Skip to content

Commit cc91b7a

Browse files
abossardCopilot
andcommitted
fix: result dialog, chart rendering, markdown fence parsing
- Run results now open in a large Dialog (900px wide, 85vh max) - Fixed parseRunOutput: strips markdown code fences from LLM output - Fixed PieChartWidget: filters non-numeric values, formats labels - Fixed BarChartWidget: accepts object {key: number} in addition to arrays - Chart containers: 300px height, 600px max-width - Tests: close dialog before cleanup (dialog blocks pointer events) - All 49 Playwright tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5509d7e commit cc91b7a

3 files changed

Lines changed: 195 additions & 174 deletions

File tree

Lines changed: 126 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
/**
2-
* RunsSidePanel — Vertical side panel showing agent run history.
2+
* RunsSidePanel — Side panel with run history list.
3+
* Clicking a run opens a full-width Dialog with the rendered result.
34
*
4-
* Displays a scrollable list of runs (newest first) with status badges,
5-
* and a detail view for the selected run rendered via SchemaRenderer.
5+
* Calculations (pure): buildAgentMap, sortRunsNewestFirst, resolveOutputSchema,
6+
* resolveAgentName, parseRunOutput, formatRelativeTime
7+
* Actions (side effects): rendering, event callbacks
68
*/
79

810
import {
911
Button,
10-
Card,
11-
Divider,
12+
Dialog,
13+
DialogActions,
14+
DialogBody,
15+
DialogContent,
16+
DialogSurface,
17+
DialogTitle,
1218
MessageBar,
1319
MessageBarBody,
1420
Spinner,
1521
Text,
1622
makeStyles,
1723
tokens,
1824
} from '@fluentui/react-components'
19-
import { ArrowClockwise24Regular } from '@fluentui/react-icons'
25+
import { ArrowClockwise24Regular, Dismiss24Regular } from '@fluentui/react-icons'
2026
import SchemaRenderer from './SchemaRenderer'
2127

2228
const useStyles = makeStyles({
@@ -53,10 +59,8 @@ const useStyles = makeStyles({
5359
display: 'flex',
5460
flexDirection: 'column',
5561
gap: '2px',
56-
border: `1px solid transparent`,
57-
'&:hover': {
58-
backgroundColor: tokens.colorNeutralBackground1Hover,
59-
},
62+
border: '1px solid transparent',
63+
'&:hover': { backgroundColor: tokens.colorNeutralBackground1Hover },
6064
},
6165
runEntrySelected: {
6266
backgroundColor: tokens.colorNeutralBackground1Selected,
@@ -101,199 +105,190 @@ const useStyles = makeStyles({
101105
backgroundColor: tokens.colorPaletteBlueBorderActive,
102106
color: tokens.colorNeutralForegroundOnBrand,
103107
},
104-
detailSection: {
105-
flexShrink: 0,
106-
maxHeight: '45%',
107-
overflowY: 'auto',
108-
borderTop: `1px solid ${tokens.colorNeutralStroke1}`,
109-
padding: tokens.spacingVerticalM,
110-
},
111-
detailCard: {
112-
padding: tokens.spacingVerticalS,
113-
},
114108
empty: {
115109
padding: tokens.spacingVerticalL,
116110
textAlign: 'center',
117111
color: tokens.colorNeutralForeground3,
118112
},
113+
dialogSurface: {
114+
maxWidth: '900px',
115+
width: '90vw',
116+
maxHeight: '85vh',
117+
},
118+
dialogContent: {
119+
overflowY: 'auto',
120+
},
119121
})
120122

121123
// ============================================================================
122-
// CALCULATIONS (pure — no side effects, no I/O)
124+
// CALCULATIONS (pure — no side effects)
123125
// ============================================================================
124126

125-
/** Build a lookup map from agent id → agent object. */
126127
function buildAgentMap(agents) {
127128
const map = {}
128-
for (const a of agents) {
129-
map[a.id] = a
130-
}
129+
for (const a of agents) map[a.id] = a
131130
return map
132131
}
133132

134-
/** Sort runs by created_at descending (newest first). */
135133
function sortRunsNewestFirst(runs) {
136134
return [...runs].sort(
137135
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
138136
)
139137
}
140138

141-
/**
142-
* Resolve the output schema for a run.
143-
* Prefers the agent definition's schema; falls back to the run snapshot's schema.
144-
* Returns null if neither has properties.
145-
*/
146139
function resolveOutputSchema(agent, run) {
147140
const agentSchema = agent?.output_schema
148141
if (agentSchema && agentSchema.properties) return agentSchema
149-
150142
const snapshotSchema = run?.agent_snapshot?.output_schema
151143
if (snapshotSchema && snapshotSchema.properties) return snapshotSchema
152-
153144
return null
154145
}
155146

156-
/** Look up the display name for a run's agent. */
157147
function resolveAgentName(agentMap, run) {
158148
return agentMap[run.agent_id]?.name ?? run.agent_snapshot?.name ?? 'Unknown Agent'
159149
}
160150

161-
/** Parse run output into an object suitable for SchemaRenderer. */
162151
function parseRunOutput(output) {
163152
if (!output) return null
164153
if (typeof output !== 'string') return output
154+
155+
// Strip markdown code fences (```json ... ```) that LLMs often wrap output in
156+
let cleaned = output.trim()
157+
const fenceMatch = cleaned.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/)
158+
if (fenceMatch) cleaned = fenceMatch[1].trim()
159+
165160
try {
166-
const parsed = JSON.parse(output)
161+
const parsed = JSON.parse(cleaned)
167162
if (typeof parsed === 'object' && parsed !== null) return parsed
168163
} catch { /* not JSON */ }
169164
return { message: output }
170165
}
171166

172-
/** Format a date string as a relative time label (e.g. "2m ago"). Pure if given a fixed `now`. */
173167
function formatRelativeTime(dateStr, now = Date.now()) {
174168
if (!dateStr) return ''
175-
const then = new Date(dateStr).getTime()
176-
const diffSec = Math.max(0, Math.floor((now - then) / 1000))
177-
169+
const diffSec = Math.max(0, Math.floor((now - new Date(dateStr).getTime()) / 1000))
178170
if (diffSec < 60) return `${diffSec}s ago`
179-
const diffMin = Math.floor(diffSec / 60)
180-
if (diffMin < 60) return `${diffMin}m ago`
181-
const diffHr = Math.floor(diffMin / 60)
182-
if (diffHr < 24) return `${diffHr}h ago`
183-
const diffDay = Math.floor(diffHr / 24)
184-
return `${diffDay}d ago`
171+
const m = Math.floor(diffSec / 60)
172+
if (m < 60) return `${m}m ago`
173+
const h = Math.floor(m / 60)
174+
if (h < 24) return `${h}h ago`
175+
return `${Math.floor(h / 24)}d ago`
185176
}
186177

187178
// ============================================================================
188-
// COMPONENTS (actions — render UI, handle events)
179+
// COMPONENTS (actions — render UI)
189180
// ============================================================================
190181

191182
function StatusBadge({ status }) {
192183
const styles = useStyles()
193-
if (status === 'completed') {
184+
if (status === 'completed')
194185
return <span className={`${styles.badge} ${styles.badgeCompleted}`}>completed</span>
195-
}
196-
if (status === 'failed') {
186+
if (status === 'failed')
197187
return <span className={`${styles.badge} ${styles.badgeFailed}`}>failed</span>
198-
}
199188
return (
200189
<span className={`${styles.badge} ${styles.badgeRunning}`}>
201-
<Spinner size="extra-tiny" />
202-
running
190+
<Spinner size="extra-tiny" /> running
203191
</span>
204192
)
205193
}
206194

207-
/**
208-
* RunsSidePanel
209-
*
210-
* @param {object[]} props.runs - Run objects (id, agent_id, status, output, error, created_at, completed_at, agent_snapshot)
211-
* @param {object[]} props.agents - Agent objects (to look up names by id)
212-
* @param {string|null} props.selectedRunId - Currently selected run ID
213-
* @param {function} props.onSelectRun - Callback when a run is clicked
214-
* @param {function} props.onRefresh - Callback to reload runs
215-
*/
216195
export default function RunsSidePanel({ runs = [], agents = [], selectedRunId, onSelectRun, onRefresh }) {
217196
const styles = useStyles()
218197

219-
// Pure calculations — derived from props, no side effects
220198
const agentMap = buildAgentMap(agents)
221199
const sortedRuns = sortRunsNewestFirst(runs)
222200
const selectedRun = selectedRunId ? runs.find((r) => r.id === selectedRunId) : null
223-
const selectedAgent = selectedRun ? agentMap[selectedRun.agent_id] : null
224-
const outputSchema = resolveOutputSchema(selectedAgent, selectedRun)
201+
const outputSchema = resolveOutputSchema(
202+
selectedRun ? agentMap[selectedRun.agent_id] : null,
203+
selectedRun,
204+
)
225205

226206
return (
227-
<div className={styles.panel} data-testid="runs-side-panel">
228-
{/* Header */}
229-
<div className={styles.header}>
230-
<Text weight="semibold" size={400}>Runs</Text>
231-
<Button
232-
appearance="subtle"
233-
icon={<ArrowClockwise24Regular />}
234-
size="small"
235-
onClick={onRefresh}
236-
data-testid="runs-panel-refresh"
237-
title="Refresh runs"
238-
/>
239-
</div>
207+
<>
208+
<div className={styles.panel} data-testid="runs-side-panel">
209+
<div className={styles.header}>
210+
<Text weight="semibold" size={400}>Runs</Text>
211+
<Button
212+
appearance="subtle"
213+
icon={<ArrowClockwise24Regular />}
214+
size="small"
215+
onClick={onRefresh}
216+
data-testid="runs-panel-refresh"
217+
title="Refresh runs"
218+
/>
219+
</div>
240220

241-
{/* Run list */}
242-
<div className={styles.runList}>
243-
{sortedRuns.length === 0 && (
244-
<Text className={styles.empty} italic>No runs yet</Text>
245-
)}
246-
{sortedRuns.map((run) => {
247-
const isSelected = run.id === selectedRunId
248-
return (
249-
<div
250-
key={run.id}
251-
className={`${styles.runEntry} ${isSelected ? styles.runEntrySelected : ''}`}
252-
onClick={() => onSelectRun(run.id)}
253-
data-testid={`run-entry-${run.id}`}
254-
>
255-
<div className={styles.runRow}>
256-
<span className={styles.agentName}>
257-
{resolveAgentName(agentMap, run)}
258-
</span>
259-
<span className={styles.timestamp}>{formatRelativeTime(run.created_at)}</span>
260-
</div>
261-
<div className={styles.runRow}>
262-
<StatusBadge status={run.status} />
221+
<div className={styles.runList}>
222+
{sortedRuns.length === 0 && (
223+
<Text className={styles.empty} italic>No runs yet</Text>
224+
)}
225+
{sortedRuns.map((run) => {
226+
const isSelected = run.id === selectedRunId
227+
return (
228+
<div
229+
key={run.id}
230+
className={`${styles.runEntry} ${isSelected ? styles.runEntrySelected : ''}`}
231+
onClick={() => onSelectRun(run.id)}
232+
data-testid={`run-entry-${run.id}`}
233+
>
234+
<div className={styles.runRow}>
235+
<span className={styles.agentName}>{resolveAgentName(agentMap, run)}</span>
236+
<span className={styles.timestamp}>{formatRelativeTime(run.created_at)}</span>
237+
</div>
238+
<div className={styles.runRow}>
239+
<StatusBadge status={run.status} />
240+
</div>
263241
</div>
264-
</div>
265-
)
266-
})}
242+
)
243+
})}
244+
</div>
267245
</div>
268246

269-
{/* Detail view */}
270-
{selectedRun && (
271-
<div className={styles.detailSection} data-testid={`run-detail-${selectedRun.id}`}>
272-
<Divider />
273-
<Text weight="semibold" size={200} block style={{ marginBottom: tokens.spacingVerticalS, marginTop: tokens.spacingVerticalS }}>
274-
{resolveAgentName(agentMap, selectedRun)} — Result
275-
</Text>
276-
277-
{selectedRun.error && (
278-
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
279-
<MessageBarBody>{selectedRun.error}</MessageBarBody>
280-
</MessageBar>
281-
)}
282-
283-
{selectedRun.output && (
284-
<Card className={styles.detailCard}>
285-
<SchemaRenderer
286-
data={parseRunOutput(selectedRun.output)}
287-
schema={outputSchema || undefined}
288-
/>
289-
</Card>
247+
{/* Result Dialog — full width modal when a run is clicked */}
248+
<Dialog
249+
open={Boolean(selectedRun)}
250+
onOpenChange={(_, data) => { if (!data.open) onSelectRun(null) }}
251+
>
252+
<DialogSurface className={styles.dialogSurface}>
253+
{selectedRun && (
254+
<>
255+
<DialogTitle
256+
action={
257+
<Button appearance="subtle" icon={<Dismiss24Regular />} onClick={() => onSelectRun(null)} />
258+
}
259+
>
260+
{resolveAgentName(agentMap, selectedRun)} — Result
261+
</DialogTitle>
262+
<DialogBody>
263+
<DialogContent className={styles.dialogContent}>
264+
<div data-testid={`run-detail-${selectedRun.id}`}>
265+
{selectedRun.error && (
266+
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalM }}>
267+
<MessageBarBody>{selectedRun.error}</MessageBarBody>
268+
</MessageBar>
269+
)}
270+
{selectedRun.output && (
271+
<SchemaRenderer
272+
data={parseRunOutput(selectedRun.output)}
273+
schema={outputSchema || undefined}
274+
/>
275+
)}
276+
{!selectedRun.output && !selectedRun.error && selectedRun.status === 'running' && (
277+
<Spinner size="medium" label="Running…" />
278+
)}
279+
{!selectedRun.output && !selectedRun.error && selectedRun.status !== 'running' && (
280+
<Text italic>No output</Text>
281+
)}
282+
</div>
283+
</DialogContent>
284+
</DialogBody>
285+
<DialogActions>
286+
<Button appearance="secondary" onClick={() => onSelectRun(null)}>Close</Button>
287+
</DialogActions>
288+
</>
290289
)}
291-
292-
{!selectedRun.output && !selectedRun.error && selectedRun.status === 'running' && (
293-
<Spinner size="small" label="Running…" />
294-
)}
295-
</div>
296-
)}
297-
</div>
290+
</DialogSurface>
291+
</Dialog>
292+
</>
298293
)
299294
}

0 commit comments

Comments
 (0)