|
1 | 1 | /** |
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. |
3 | 4 | * |
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 |
6 | 8 | */ |
7 | 9 |
|
8 | 10 | import { |
9 | 11 | Button, |
10 | | - Card, |
11 | | - Divider, |
| 12 | + Dialog, |
| 13 | + DialogActions, |
| 14 | + DialogBody, |
| 15 | + DialogContent, |
| 16 | + DialogSurface, |
| 17 | + DialogTitle, |
12 | 18 | MessageBar, |
13 | 19 | MessageBarBody, |
14 | 20 | Spinner, |
15 | 21 | Text, |
16 | 22 | makeStyles, |
17 | 23 | tokens, |
18 | 24 | } from '@fluentui/react-components' |
19 | | -import { ArrowClockwise24Regular } from '@fluentui/react-icons' |
| 25 | +import { ArrowClockwise24Regular, Dismiss24Regular } from '@fluentui/react-icons' |
20 | 26 | import SchemaRenderer from './SchemaRenderer' |
21 | 27 |
|
22 | 28 | const useStyles = makeStyles({ |
@@ -53,10 +59,8 @@ const useStyles = makeStyles({ |
53 | 59 | display: 'flex', |
54 | 60 | flexDirection: 'column', |
55 | 61 | gap: '2px', |
56 | | - border: `1px solid transparent`, |
57 | | - '&:hover': { |
58 | | - backgroundColor: tokens.colorNeutralBackground1Hover, |
59 | | - }, |
| 62 | + border: '1px solid transparent', |
| 63 | + '&:hover': { backgroundColor: tokens.colorNeutralBackground1Hover }, |
60 | 64 | }, |
61 | 65 | runEntrySelected: { |
62 | 66 | backgroundColor: tokens.colorNeutralBackground1Selected, |
@@ -101,199 +105,190 @@ const useStyles = makeStyles({ |
101 | 105 | backgroundColor: tokens.colorPaletteBlueBorderActive, |
102 | 106 | color: tokens.colorNeutralForegroundOnBrand, |
103 | 107 | }, |
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 | | - }, |
114 | 108 | empty: { |
115 | 109 | padding: tokens.spacingVerticalL, |
116 | 110 | textAlign: 'center', |
117 | 111 | color: tokens.colorNeutralForeground3, |
118 | 112 | }, |
| 113 | + dialogSurface: { |
| 114 | + maxWidth: '900px', |
| 115 | + width: '90vw', |
| 116 | + maxHeight: '85vh', |
| 117 | + }, |
| 118 | + dialogContent: { |
| 119 | + overflowY: 'auto', |
| 120 | + }, |
119 | 121 | }) |
120 | 122 |
|
121 | 123 | // ============================================================================ |
122 | | -// CALCULATIONS (pure — no side effects, no I/O) |
| 124 | +// CALCULATIONS (pure — no side effects) |
123 | 125 | // ============================================================================ |
124 | 126 |
|
125 | | -/** Build a lookup map from agent id → agent object. */ |
126 | 127 | function buildAgentMap(agents) { |
127 | 128 | const map = {} |
128 | | - for (const a of agents) { |
129 | | - map[a.id] = a |
130 | | - } |
| 129 | + for (const a of agents) map[a.id] = a |
131 | 130 | return map |
132 | 131 | } |
133 | 132 |
|
134 | | -/** Sort runs by created_at descending (newest first). */ |
135 | 133 | function sortRunsNewestFirst(runs) { |
136 | 134 | return [...runs].sort( |
137 | 135 | (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), |
138 | 136 | ) |
139 | 137 | } |
140 | 138 |
|
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 | | - */ |
146 | 139 | function resolveOutputSchema(agent, run) { |
147 | 140 | const agentSchema = agent?.output_schema |
148 | 141 | if (agentSchema && agentSchema.properties) return agentSchema |
149 | | - |
150 | 142 | const snapshotSchema = run?.agent_snapshot?.output_schema |
151 | 143 | if (snapshotSchema && snapshotSchema.properties) return snapshotSchema |
152 | | - |
153 | 144 | return null |
154 | 145 | } |
155 | 146 |
|
156 | | -/** Look up the display name for a run's agent. */ |
157 | 147 | function resolveAgentName(agentMap, run) { |
158 | 148 | return agentMap[run.agent_id]?.name ?? run.agent_snapshot?.name ?? 'Unknown Agent' |
159 | 149 | } |
160 | 150 |
|
161 | | -/** Parse run output into an object suitable for SchemaRenderer. */ |
162 | 151 | function parseRunOutput(output) { |
163 | 152 | if (!output) return null |
164 | 153 | 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 | + |
165 | 160 | try { |
166 | | - const parsed = JSON.parse(output) |
| 161 | + const parsed = JSON.parse(cleaned) |
167 | 162 | if (typeof parsed === 'object' && parsed !== null) return parsed |
168 | 163 | } catch { /* not JSON */ } |
169 | 164 | return { message: output } |
170 | 165 | } |
171 | 166 |
|
172 | | -/** Format a date string as a relative time label (e.g. "2m ago"). Pure if given a fixed `now`. */ |
173 | 167 | function formatRelativeTime(dateStr, now = Date.now()) { |
174 | 168 | 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)) |
178 | 170 | 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` |
185 | 176 | } |
186 | 177 |
|
187 | 178 | // ============================================================================ |
188 | | -// COMPONENTS (actions — render UI, handle events) |
| 179 | +// COMPONENTS (actions — render UI) |
189 | 180 | // ============================================================================ |
190 | 181 |
|
191 | 182 | function StatusBadge({ status }) { |
192 | 183 | const styles = useStyles() |
193 | | - if (status === 'completed') { |
| 184 | + if (status === 'completed') |
194 | 185 | return <span className={`${styles.badge} ${styles.badgeCompleted}`}>completed</span> |
195 | | - } |
196 | | - if (status === 'failed') { |
| 186 | + if (status === 'failed') |
197 | 187 | return <span className={`${styles.badge} ${styles.badgeFailed}`}>failed</span> |
198 | | - } |
199 | 188 | return ( |
200 | 189 | <span className={`${styles.badge} ${styles.badgeRunning}`}> |
201 | | - <Spinner size="extra-tiny" /> |
202 | | - running |
| 190 | + <Spinner size="extra-tiny" /> running |
203 | 191 | </span> |
204 | 192 | ) |
205 | 193 | } |
206 | 194 |
|
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 | | - */ |
216 | 195 | export default function RunsSidePanel({ runs = [], agents = [], selectedRunId, onSelectRun, onRefresh }) { |
217 | 196 | const styles = useStyles() |
218 | 197 |
|
219 | | - // Pure calculations — derived from props, no side effects |
220 | 198 | const agentMap = buildAgentMap(agents) |
221 | 199 | const sortedRuns = sortRunsNewestFirst(runs) |
222 | 200 | 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 | + ) |
225 | 205 |
|
226 | 206 | 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> |
240 | 220 |
|
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> |
263 | 241 | </div> |
264 | | - </div> |
265 | | - ) |
266 | | - })} |
| 242 | + ) |
| 243 | + })} |
| 244 | + </div> |
267 | 245 | </div> |
268 | 246 |
|
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 | + </> |
290 | 289 | )} |
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 | + </> |
298 | 293 | ) |
299 | 294 | } |
0 commit comments