From 7a7c426f867963c98d427f2e3edfe410b93a20b3 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 04:10:47 +0300 Subject: [PATCH 1/2] fix(studio): modals swallow keydown, observe screens recover from daemon-down, queue tree names stop wrapping Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + apps/agent-forge/e2e/revamp.spec.ts | 13 +++ .../screens/__tests__/daemonRecovery.test.tsx | 79 +++++++++++++++++++ .../agent-forge/src/screens/hub/HubScreen.tsx | 7 +- .../src/screens/prompts/PromptDetail.tsx | 6 +- .../src/screens/prompts/PromptsScreen.tsx | 5 +- .../src/screens/threads/ThreadScreen.tsx | 7 +- .../src/screens/threads/ThreadsScreen.tsx | 6 +- .../src/screens/traces/TracesScreen.tsx | 7 +- apps/agent-forge/src/ui/CommandPalette.tsx | 11 ++- .../src/ui/__tests__/CommandPalette.test.tsx | 34 ++++++++ apps/agent-forge/src/ui/run/run.css | 8 +- 12 files changed, 161 insertions(+), 23 deletions(-) create mode 100644 apps/agent-forge/src/screens/__tests__/daemonRecovery.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 687be28b5..96f83199c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ opt-out. - Studio threads: each turn now shows its full input and output (chat messages render as bubbles) instead of the 200-char previews; a turn fetches its trace as it scrolls near the viewport, shows the preview labelled "Preview (truncated)" while loading or if the trace can't be read, and says so when the recorder truncated the turn's I/O. - An open run's root start is kept when the reporter's buffer trims or a crash flush cuts to its newest 16 MB, so a run whose child spans overflow the buffer is still listed. - Studio Scores and `GET /api/v1/scores?aggregate=eval`: `human` and `online` scores now pass only on a value of exactly 1, so a thumbs-down or an online rule returning 0 (or 0.9) without a comment counts as a failure instead of a pass; `eval` and `judge` scores keep their rule (no failure comment, or a perfect 1), and the table and the eval detail agree. +- Studio: the command palette and `?` shortcuts sheet now keep every keydown to themselves, so `S` no longer skips the annotation-queue item behind them; Traces, Threads, a Thread, Prompts and the Context Hub recover in place after the daemon restarts instead of staying on "daemon down"; and the queue review's trace tree no longer wraps a row name one letter per line at 1280px. - The run inspector shows `error`, not `running`, for a failed step that has no end time. - Studio trace viewer: an observation the crash sweep marked `interrupted` (or an open `error` one) now shows as terminal instead of "running" forever; its duration and the waterfall stop at the latest known end and the header clock stops ticking. - The observe daemon refuses cross-site browser writes and DNS rebinding on `/api/*`: a loopback-bound daemon answers only loopback `Host` names (403), writes carrying a non-loopback `Origin` (including `null`) or `Sec-Fetch-Site: cross-site` get 403, and a write with a body must be `Content-Type: application/json` (415) — `curl -d` needs `-H "Content-Type: application/json"`. The SDK reporter, CLI and Studio already comply. See [Observability](docs/observability.md). diff --git a/apps/agent-forge/e2e/revamp.spec.ts b/apps/agent-forge/e2e/revamp.spec.ts index 19a0cbe34..c9441ddff 100644 --- a/apps/agent-forge/e2e/revamp.spec.ts +++ b/apps/agent-forge/e2e/revamp.spec.ts @@ -316,3 +316,16 @@ for (const width of [860, 375]) { }); } } + +test('queue review at 1280px: a trace tree row name never wraps one letter per line', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto(`${APP}#/evals/queues/e2e-queue`); + await expect(page.locator('[data-review-trace="e2e-trace"]')).toBeVisible(); + const names = page.locator('[data-review-trace="e2e-trace"] .run-name'); + await expect(names.first()).toBeVisible(); + for (const name of await names.all()) { + const box = (await name.boundingBox())!; + const line = await name.evaluate((el) => parseFloat(getComputedStyle(el).lineHeight) || el.getBoundingClientRect().height); + expect(box.height, `${await name.textContent()} wraps`).toBeLessThanOrEqual(line * 2 + 1); + } +}); diff --git a/apps/agent-forge/src/screens/__tests__/daemonRecovery.test.tsx b/apps/agent-forge/src/screens/__tests__/daemonRecovery.test.tsx new file mode 100644 index 000000000..4bd5a033b --- /dev/null +++ b/apps/agent-forge/src/screens/__tests__/daemonRecovery.test.tsx @@ -0,0 +1,79 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HubScreen } from '../hub/HubScreen'; +import { PromptsScreen } from '../prompts/PromptsScreen'; +import { ThreadsScreen } from '../threads/ThreadsScreen'; +import { TracesScreen } from '../traces/TracesScreen'; +import { render } from '../../ui/__tests__/render'; +import { ToastProvider } from '../../ui/Toast'; + +/** The health probe always answers; every other request fails like a refused connection while `outage` holds. */ +let outage: boolean; + +function stubDaemon() { + outage = true; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://x'); + const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + if (url.pathname.endsWith('/health')) return json({ service: 'lousho-observe', ok: true }); + if (outage) throw new TypeError('fetch failed'); + if (url.pathname.endsWith('/api/v1/agents')) return json({ agents: [{ agent_id: 'a1', name: 'alpha', project: null }] }); + if (url.pathname.endsWith('/api/v1/traces')) return json({ traces: [], total: 0, limit: 50, offset: 0 }); + if (url.pathname.endsWith('/api/v1/sessions')) return json({ sessions: [], total: 0, limit: 50, offset: 0 }); + if (url.pathname.includes('/api/v1/sessions/')) return json({ error: 'No session', code: 'LOUSHO_REQUEST_INVALID' }, 404); + if (url.pathname.endsWith('/api/v1/prompts')) return json({ prompts: [] }); + if (url.pathname.includes('/api/v1/prompts/')) return json({ error: 'No prompt', code: 'LOUSHO_REQUEST_INVALID' }, 404); + if (url.pathname.endsWith('/api/v1/hub')) return json({ entries: [] }); + return json({ error: 'unexpected' }, 500); + }) + ); +} + +async function flush() { + for (let i = 0; i < 4; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + stubDaemon(); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + window.history.replaceState(null, '', '/'); +}); + +const CASES: [string, string, () => JSX.Element, string][] = [ + ['Traces', '#/traces', () => , 'No traces match these filters'], + ['Threads', '#/threads', () => , 'No threads match these filters'], + ['Thread', '#/threads/s1', () => , 'Thread not found'], + ['Prompts', '#/prompts', () => , 'No prompts yet'], + ['Prompt', '#/prompts/mira', () => , 'Prompt not found'], + ['Hub', '#/hub', () => , 'Context Hub'], +]; + +describe('observe screens recover in place after a daemon restart', () => { + it.each(CASES)('%s: a failed query shows daemon-down, then the screen returns without a reload', async (_name, hash, screen, back) => { + window.history.replaceState(null, '', `/${hash}`); + const container = render({screen()}); + await flush(); + expect(container.textContent).toContain('lousho observe start'); + expect(window.location.hash).toBe(hash); + + outage = false; + await act(async () => { + vi.advanceTimersByTime(5100); + }); + await flush(); + expect(container.textContent).not.toContain('lousho observe start'); + expect(container.textContent).toContain(back); + expect(window.location.hash).toBe(hash); + }); +}); diff --git a/apps/agent-forge/src/screens/hub/HubScreen.tsx b/apps/agent-forge/src/screens/hub/HubScreen.tsx index a0739e5e8..15ea9f8b3 100644 --- a/apps/agent-forge/src/screens/hub/HubScreen.tsx +++ b/apps/agent-forge/src/screens/hub/HubScreen.tsx @@ -13,9 +13,9 @@ import './hub.css'; import { useMemo, useState } from 'react'; import { matchRoute, navigate } from '../../router/router'; import { useRoute } from '../../router/useRoute'; -import { useAgents, useDaemonHealth, useHubEntries } from '../../observe/useObserve'; +import { useAgents, useDaemonDown, useDaemonHealth, useHubEntries } from '../../observe/useObserve'; import { ObserveDaemonDown, ObserveError } from '../../observe/chrome'; -import { ObserveDaemonDownError, type HubEntryFilter } from '../../observe/observeClient'; +import type { HubEntryFilter } from '../../observe/observeClient'; import type { ObserveAgentRow, ObserveHubEntry } from '../../observe/types'; import { fmtDateTime, fmtInt, fmtStarted } from '../../observe/format'; import { DataTable, type Column } from '../../ui/DataTable'; @@ -180,8 +180,9 @@ function HubContent({ namespace }: { namespace: string | undefined }) { const filter: HubEntryFilter | undefined = viewer === undefined ? undefined : { agent_id: viewer, namespace, scope: state.scope, limit: HUB_LIMIT }; const entries = useHubEntries(filter, refreshKey); + const down = useDaemonDown(false, [agents, entries]); - if (agents.error instanceof ObserveDaemonDownError || entries.error instanceof ObserveDaemonDownError) return ; + if (down) return ; if (viewer === undefined) { if (agents.loading || agents.error) return ; return ( diff --git a/apps/agent-forge/src/screens/prompts/PromptDetail.tsx b/apps/agent-forge/src/screens/prompts/PromptDetail.tsx index cdb6db390..4c312a1c9 100644 --- a/apps/agent-forge/src/screens/prompts/PromptDetail.tsx +++ b/apps/agent-forge/src/screens/prompts/PromptDetail.tsx @@ -11,8 +11,8 @@ import { useState } from 'react'; import { navigate } from '../../router/router'; import { useRoute } from '../../router/useRoute'; -import { usePromptVersions } from '../../observe/useObserve'; -import { ObserveError } from '../../observe/chrome'; +import { useDaemonDown, usePromptVersions } from '../../observe/useObserve'; +import { ObserveDaemonDown, ObserveError } from '../../observe/chrome'; import { observeClient } from '../../observe/observeClient'; import type { ObservePromptVersion } from '../../observe/types'; import { fmtDateTime, fmtInt } from '../../observe/format'; @@ -171,9 +171,11 @@ export function PromptDetail({ param }: { param: string }) { const route = useRoute(); const [refreshKey, setRefreshKey] = useState(0); const history = usePromptVersions(name, refreshKey); + const down = useDaemonDown(false, [history]); const versions = history.data?.versions ?? []; const selected = resolveRef(versions, ref); const diff = parseDiff(route.query); + if (down) return ; let body; if (history.loading && !history.data) body =

Loading prompt...

; diff --git a/apps/agent-forge/src/screens/prompts/PromptsScreen.tsx b/apps/agent-forge/src/screens/prompts/PromptsScreen.tsx index 36e95defa..59556dd9d 100644 --- a/apps/agent-forge/src/screens/prompts/PromptsScreen.tsx +++ b/apps/agent-forge/src/screens/prompts/PromptsScreen.tsx @@ -8,9 +8,8 @@ import './prompts.css'; import { matchRoute, navigate } from '../../router/router'; import { useRoute } from '../../router/useRoute'; -import { useDaemonHealth, usePrompts } from '../../observe/useObserve'; +import { useDaemonDown, useDaemonHealth, usePrompts } from '../../observe/useObserve'; import { ObserveDaemonDown, ObserveError } from '../../observe/chrome'; -import { ObserveDaemonDownError } from '../../observe/observeClient'; import type { ObservePromptRow } from '../../observe/types'; import { fmtDateTime, fmtInt } from '../../observe/format'; import { DataTable, type Column } from '../../ui/DataTable'; @@ -31,7 +30,7 @@ const COLUMNS: Column[] = [ function PromptList() { const prompts = usePrompts(); - if (prompts.error instanceof ObserveDaemonDownError) return ; + if (useDaemonDown(false, [prompts])) return ; return (

Prompts

diff --git a/apps/agent-forge/src/screens/threads/ThreadScreen.tsx b/apps/agent-forge/src/screens/threads/ThreadScreen.tsx index 2498eb921..f080ab9d5 100644 --- a/apps/agent-forge/src/screens/threads/ThreadScreen.tsx +++ b/apps/agent-forge/src/screens/threads/ThreadScreen.tsx @@ -6,8 +6,8 @@ * full root input/output (the session rows only carry 200-char previews). */ import { useRef, useState } from 'react'; -import { useSession } from '../../observe/useObserve'; -import { ObserveError } from '../../observe/chrome'; +import { useDaemonDown, useSession } from '../../observe/useObserve'; +import { ObserveDaemonDown, ObserveError } from '../../observe/chrome'; import type { ObserveTraceRow } from '../../observe/types'; import { fmtCost, fmtDuration, fmtInt, fmtStarted } from '../../observe/format'; import { EmptyState } from '../../ui/EmptyState'; @@ -67,6 +67,9 @@ function Turn({ trace, index, now }: { trace: ObserveTraceRow; index: number; no export function ThreadScreen({ sessionId }: { sessionId: string }) { const thread = useSession(sessionId); const [now] = useState(() => Date.now()); + // Only the session query counts: one turn's detail failing is that turn's own problem, not the daemon's. + const down = useDaemonDown(false, [thread]); + if (down) return ; const session = thread.data?.session; const traces = thread.data?.traces ?? []; diff --git a/apps/agent-forge/src/screens/threads/ThreadsScreen.tsx b/apps/agent-forge/src/screens/threads/ThreadsScreen.tsx index 27e229945..28419eb81 100644 --- a/apps/agent-forge/src/screens/threads/ThreadsScreen.tsx +++ b/apps/agent-forge/src/screens/threads/ThreadsScreen.tsx @@ -13,9 +13,8 @@ import './threads.css'; import { useEffect, useMemo } from 'react'; import { matchRoute, navigate } from '../../router/router'; import { useRoute } from '../../router/useRoute'; -import { useAgents, useDaemonHealth, useSessions } from '../../observe/useObserve'; +import { useAgents, useDaemonDown, useDaemonHealth, useSessions } from '../../observe/useObserve'; import { ObserveDaemonDown, ObserveError } from '../../observe/chrome'; -import { ObserveDaemonDownError } from '../../observe/observeClient'; import type { ObserveSessionRow } from '../../observe/types'; import { fmtCost, fmtDuration, fmtInt, fmtStarted } from '../../observe/format'; import { DataTable, type Column } from '../../ui/DataTable'; @@ -89,6 +88,7 @@ function ThreadsList() { const filter = useMemo(() => toSessionFilter(state, now), [state, now]); const sessions = useSessions(filter); const agents = useAgents(); + const down = useDaemonDown(false, [sessions, agents]); useEffect(() => rememberThreadsQuery(formatThreadsQuery(state)), [state]); @@ -100,7 +100,7 @@ function ThreadsList() { agentFacet(agents.data ?? [], state.agent, (agent) => update({ agent })), facet('status', 'Status', STATUSES, state.status, (status) => update({ status })), ]; - if (sessions.error instanceof ObserveDaemonDownError) return ; + if (down) return ; return (
diff --git a/apps/agent-forge/src/screens/traces/TracesScreen.tsx b/apps/agent-forge/src/screens/traces/TracesScreen.tsx index a7a20e6e2..533bb7f75 100644 --- a/apps/agent-forge/src/screens/traces/TracesScreen.tsx +++ b/apps/agent-forge/src/screens/traces/TracesScreen.tsx @@ -13,9 +13,9 @@ import './traces.css'; import { useEffect, useMemo } from 'react'; import { navigate } from '../../router/router'; import { useRoute } from '../../router/useRoute'; -import { useAgents, useDaemonHealth, useTraces } from '../../observe/useObserve'; +import { useAgents, useDaemonDown, useDaemonHealth, useTraces } from '../../observe/useObserve'; import { ObserveDaemonDown, ObserveError } from '../../observe/chrome'; -import { ObserveDaemonDownError, type TraceSort } from '../../observe/observeClient'; +import type { TraceSort } from '../../observe/observeClient'; import type { ObserveTraceRow } from '../../observe/types'; import { fmtCost, fmtDuration, fmtInt, fmtStarted } from '../../observe/format'; import { DataTable, type Column, type TableSort } from '../../ui/DataTable'; @@ -97,6 +97,7 @@ function TracesList() { const filter = useMemo(() => toTraceFilter(state, now), [state, now]); const traces = useTraces(filter); const agents = useAgents(); + const down = useDaemonDown(false, [traces, agents]); useEffect(() => rememberListQuery(formatTracesQuery(state)), [state]); @@ -117,7 +118,7 @@ function TracesList() { facet('model', 'Model', uniqueSorted(rows.map((t) => t.model)), state.model, setText('model')), facet('project', 'Project', uniqueSorted(agentRows.map((a) => a.project)), state.project, setText('project')), ]; - if (traces.error instanceof ObserveDaemonDownError) return ; + if (down) return ; return (
diff --git a/apps/agent-forge/src/ui/CommandPalette.tsx b/apps/agent-forge/src/ui/CommandPalette.tsx index 920bf86f2..9f1f0a4e6 100644 --- a/apps/agent-forge/src/ui/CommandPalette.tsx +++ b/apps/agent-forge/src/ui/CommandPalette.tsx @@ -63,9 +63,10 @@ function Dialog({ }; }, []); const onKeyDown = (e: ReactKeyboardEvent) => { + // Modal: no keydown leaks to window-level handlers behind it (a queue's `S` skip, table row keys). + e.stopPropagation(); if (e.key === 'Escape') { e.preventDefault(); - e.stopPropagation(); onClose(); } else if (e.key === 'Tab') { e.preventDefault(); // one focusable control per dialog: keep focus inside @@ -188,7 +189,8 @@ function ShortcutSheet({ onClose }: { onClose: () => void }) { /** * The global command palette (Ctrl/Cmd+K) and `?` shortcut sheet. Mount once - * in the app shell; it owns the window keydown listener for both. + * in the app shell; it owns the window keydown listener for both. While either + * is open it stops every keydown inside it from reaching window-level handlers. */ export function CommandPalette() { const { agents } = useAppState(); @@ -204,8 +206,9 @@ export function CommandPalette() { e.preventDefault(); setOpen((o) => (id === 'palette' && o === 'palette' ? null : id)); }; - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); + // Capture phase: the dialog stops every keydown it gets from bubbling to window, so the toggle must not wait for it. + window.addEventListener('keydown', onKeyDown, true); + return () => window.removeEventListener('keydown', onKeyDown, true); }, []); const onClose = () => setOpen(null); diff --git a/apps/agent-forge/src/ui/__tests__/CommandPalette.test.tsx b/apps/agent-forge/src/ui/__tests__/CommandPalette.test.tsx index 5e15a07c2..1c9bf56be 100644 --- a/apps/agent-forge/src/ui/__tests__/CommandPalette.test.tsx +++ b/apps/agent-forge/src/ui/__tests__/CommandPalette.test.tsx @@ -214,6 +214,40 @@ describe('CommandPalette edge cases', () => { }); }); +describe('CommandPalette modal keys', () => { + it.each([ + ['palette', () => key('k', { ctrlKey: true }), () => input()], + ['? sheet', () => key('?'), () => document.querySelector('button.btn') as HTMLElement], + ])('the %s keeps keydown away from window-level handlers behind it', (_name, open, focused) => { + const behind = vi.fn(); + mount(); + open(); + window.addEventListener('keydown', behind); + try { + const target = focused(); + for (const k of ['s', 'j', 'ArrowDown', 'Enter']) key(k, {}, target); + expect(behind).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('keydown', behind); + } + }); + + it('still toggles with Ctrl+K from inside the palette and lets keys through once closed', () => { + const behind = vi.fn(); + mount(); + key('k', { ctrlKey: true }); + key('k', { ctrlKey: true }, input()); + expect(dialog('Command palette')).toBeNull(); + window.addEventListener('keydown', behind); + try { + key('s', {}, document.body); + expect(behind).toHaveBeenCalledTimes(1); + } finally { + window.removeEventListener('keydown', behind); + } + }); +}); + describe('matchShortcut', () => { const ev = (k: string, init: KeyboardEventInit = {}) => new KeyboardEvent('keydown', { key: k, ...init }); it('maps Ctrl/Cmd+K and ? and nothing else', () => { diff --git a/apps/agent-forge/src/ui/run/run.css b/apps/agent-forge/src/ui/run/run.css index d2945cf22..7e80ba37a 100644 --- a/apps/agent-forge/src/ui/run/run.css +++ b/apps/agent-forge/src/ui/run/run.css @@ -88,8 +88,8 @@ overflow: hidden; } .run-name { - flex: 1 1 auto; - min-width: 0; + flex: 1 1 8ch; + min-width: 8ch; /* never a letter per line in a narrow pane: the chips below wrap instead */ white-space: normal; overflow-wrap: anywhere; } @@ -118,8 +118,9 @@ } .run-row-main { display: flex; + flex-wrap: wrap; align-items: flex-start; - gap: 8px; + gap: 2px 8px; flex: 1 1 auto; min-width: 0; padding: 6px 8px; @@ -131,6 +132,7 @@ cursor: pointer; } .run-row-virtual .run-row-main { + flex-wrap: nowrap; align-items: center; padding-top: 0; padding-bottom: 0; From db528ef442738e0d2eccfb9373d275ccfb14df06 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 04:45:21 +0300 Subject: [PATCH 2/2] fix(studio): split TracesList helpers to satisfy fallow complexity limits Co-Authored-By: Claude Sonnet 5.5 --- .../src/screens/traces/TracesScreen.tsx | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/agent-forge/src/screens/traces/TracesScreen.tsx b/apps/agent-forge/src/screens/traces/TracesScreen.tsx index 533bb7f75..49fa26acf 100644 --- a/apps/agent-forge/src/screens/traces/TracesScreen.tsx +++ b/apps/agent-forge/src/screens/traces/TracesScreen.tsx @@ -16,7 +16,7 @@ import { useRoute } from '../../router/useRoute'; import { useAgents, useDaemonDown, useDaemonHealth, useTraces } from '../../observe/useObserve'; import { ObserveDaemonDown, ObserveError } from '../../observe/chrome'; import type { TraceSort } from '../../observe/observeClient'; -import type { ObserveTraceRow } from '../../observe/types'; +import type { ObserveAgentRow, ObserveTraceRow } from '../../observe/types'; import { fmtCost, fmtDuration, fmtInt, fmtStarted } from '../../observe/format'; import { DataTable, type Column, type TableSort } from '../../ui/DataTable'; import { FilterBar } from '../../ui/FilterBar'; @@ -88,6 +88,24 @@ function PromptChip({ prompt, onRemove }: { prompt: string | undefined; onRemove ); } +const toSort = (next: TableSort | undefined): TracesUrlState['sort'] => + next && SORTABLE.includes(next.key) ? { key: next.key as TraceSort, order: next.order } : undefined; + +/** The agent / status / model / project facets; model options come from the rows on screen. */ +function traceFacets( + state: TracesUrlState, + rows: ObserveTraceRow[], + agentRows: ObserveAgentRow[], + setText: (key: 'agent' | 'status' | 'model' | 'project') => (v: string | undefined) => void +) { + return [ + agentFacet(agentRows, state.agent, setText('agent')), + facet('status', 'Status', STATUSES, state.status, setText('status')), + facet('model', 'Model', uniqueSorted(rows.map((t) => t.model)), state.model, setText('model')), + facet('project', 'Project', uniqueSorted(agentRows.map((a) => a.project)), state.project, setText('project')), + ]; +} + function TracesList() { const route = useRoute(); const queryString = route.query.toString(); @@ -107,17 +125,10 @@ function TracesList() { const setText = (key: 'agent' | 'status' | 'model' | 'project' | 'q' | 'prompt') => (v: string | undefined) => update({ [key]: v || undefined }); const range = useRangeFilter(state.window, '24h', (window) => update({ window })); - const onSort = (next: TableSort | undefined) => - update({ sort: next && SORTABLE.includes(next.key) ? { key: next.key as TraceSort, order: next.order } : undefined }); + const onSort = (next: TableSort | undefined) => update({ sort: toSort(next) }); const rows = traces.data?.traces ?? []; - const agentRows = agents.data ?? []; - const facets = [ - agentFacet(agentRows, state.agent, setText('agent')), - facet('status', 'Status', STATUSES, state.status, setText('status')), - facet('model', 'Model', uniqueSorted(rows.map((t) => t.model)), state.model, setText('model')), - facet('project', 'Project', uniqueSorted(agentRows.map((a) => a.project)), state.project, setText('project')), - ]; + const facets = traceFacets(state, rows, agents.data ?? [], setText); if (down) return ; return (