From 3754d1f2a52cfd437bac0f31a3ad569e73187f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F-Relakkes?= Date: Sun, 4 Oct 2026 17:14:07 +0800 Subject: [PATCH] feat(desktop): replace the settings trace list with an in-session trajectory view (#1443) Sessions get a Chat / Trajectory switch in the header. The trajectory is a dense one-line-per-event ledger (system prompt, user, injected context such as skills and system reminders, assistant responses, tool calls) with a three-lane minimap, turn folding, search, and a detail panel per row. - CLI: record a deduplicated prompt snapshot sidecar for desktop sessions (system prompt, tool catalog, user context) without touching the transcript. - Server: project transcript records into trajectory rows with bounded, cursor-paged reads, live appends, a turn index, row detail by byte range, snapshot blobs, and a time-window lookup into the raw trace capture. - Desktop: windowed ledger, minimap, detail panel with a raw request summary, chat <-> trajectory navigation, subagent drill-in. - Remove the Settings trace list, trace tabs and the standalone trace window; migrate persisted trace tabs to session tabs. --- desktop/electron/ipc/capabilities.test.ts | 4 +- desktop/electron/ipc/capabilities.ts | 1 - desktop/electron/ipc/channels.ts | 1 - desktop/electron/main.ts | 44 +- .../services/microphonePermissions.test.ts | 38 +- .../services/microphonePermissions.ts | 8 +- .../src/__tests__/generalSettings.test.tsx | 29 +- desktop/src/api/sessions.ts | 6 +- desktop/src/api/traces.ts | 36 +- desktop/src/api/trajectory.ts | 51 + desktop/src/components/chat/ActivityGroup.tsx | 25 +- .../src/components/chat/CodeViewer.test.tsx | 37 +- desktop/src/components/chat/CodeViewer.tsx | 20 +- .../src/components/chat/MermaidRenderer.tsx | 4 +- .../src/components/chat/MessageList.test.tsx | 98 ++ desktop/src/components/chat/MessageList.tsx | 75 +- desktop/src/components/chat/ToolCallBlock.tsx | 229 ++-- desktop/src/components/chat/ToolCallGroup.tsx | 9 + .../src/components/chat/chatBlocks.test.tsx | 23 + .../src/components/layout/AppShell.test.tsx | 58 +- desktop/src/components/layout/AppShell.tsx | 67 +- .../components/layout/ContentRouter.test.tsx | 72 -- .../src/components/layout/ContentRouter.tsx | 11 - desktop/src/components/layout/TabBar.tsx | 9 +- desktop/src/components/trace/TraceBadges.tsx | 182 --- desktop/src/components/trace/TraceDetail.tsx | 208 ---- .../src/components/trace/TraceSplitLayout.tsx | 95 -- desktop/src/components/trace/TraceTree.tsx | 406 ------- .../components/trace/detail/MessageDetail.tsx | 59 - .../trace/detail/SessionOverview.tsx | 260 ---- .../components/trace/detail/ToolDetail.tsx | 105 -- .../trajectory/TrajectoryDetailPanel.test.tsx | 92 ++ .../trajectory/TrajectoryDetailPanel.tsx | 439 +++++++ .../trajectory/TrajectoryLinkContext.test.tsx | 81 ++ .../trajectory/TrajectoryLinkContext.tsx | 34 + .../trajectory/TrajectoryMinimap.tsx | 132 ++ .../components/trajectory/TrajectoryTable.tsx | 378 ++++++ .../trajectory/TrajectoryToolbar.tsx | 128 ++ .../trajectory/TrajectoryView.test.tsx | 286 +++++ .../components/trajectory/TrajectoryView.tsx | 415 +++++++ .../trajectory/TrajectoryViewSwitch.tsx | 25 + .../trajectory/detail/DetailBlocks.tsx | 64 + .../detail/MessageBlocks.tsx | 10 +- .../detail/RawRequestDetail.tsx} | 40 +- .../trajectory/detail/RawRequestTab.test.tsx | 139 +++ .../trajectory/detail/RawRequestTab.tsx | 77 ++ .../trajectory/detail/RequestSummary.tsx | 89 ++ .../{trace => trajectory}/detail/Section.tsx | 0 .../trajectory/detail/SnapshotDetail.tsx | 214 ++++ .../components/trajectory/trajectoryLabels.ts | 159 +++ .../trajectory/useTrajectoryData.ts | 202 ++++ .../trajectory/useTrajectoryDetail.ts | 81 ++ desktop/src/i18n/locales/en.ts | 276 +++-- desktop/src/i18n/locales/jp.ts | 276 +++-- desktop/src/i18n/locales/kr.ts | 276 +++-- desktop/src/i18n/locales/zh-TW.ts | 276 +++-- desktop/src/i18n/locales/zh.ts | 276 +++-- desktop/src/lib/desktopHost/browserHost.ts | 10 - .../src/lib/desktopHost/electronHost.test.ts | 12 - desktop/src/lib/desktopHost/electronHost.ts | 3 - desktop/src/lib/desktopHost/types.ts | 3 - desktop/src/lib/formatTokenCount.test.ts | 7 +- desktop/src/lib/formatTokenCount.ts | 7 +- desktop/src/lib/persistenceMigrations.test.ts | 73 +- desktop/src/lib/persistenceMigrations.ts | 52 +- desktop/src/lib/trace/callCache.ts | 4 +- desktop/src/lib/trace/formatters.ts | 24 + desktop/src/lib/trace/requestSummary.test.ts | 80 ++ desktop/src/lib/trace/requestSummary.ts | 80 ++ desktop/src/lib/traceLaunch.test.ts | 27 - desktop/src/lib/traceLaunch.ts | 30 - desktop/src/lib/traceNavigation.test.ts | 87 -- desktop/src/lib/traceNavigation.ts | 37 - desktop/src/lib/traceViewModel.test.ts | 333 ----- desktop/src/lib/traceViewModel.ts | 793 ------------ desktop/src/lib/trajectory/chatTarget.ts | 58 + desktop/src/lib/trajectory/detailContent.ts | 126 ++ .../src/lib/trajectory/findTraceCall.test.ts | 67 ++ desktop/src/lib/trajectory/findTraceCall.ts | 71 ++ desktop/src/lib/trajectory/helpers.test.ts | 114 ++ desktop/src/lib/trajectory/lineDiff.ts | 85 ++ desktop/src/lib/trajectory/mergeRows.ts | 100 ++ desktop/src/lib/trajectory/minimap.test.ts | 78 ++ desktop/src/lib/trajectory/minimap.ts | 167 +++ .../src/lib/trajectory/trajectoryFixtures.ts | 19 + .../src/lib/trajectory/useFixedRowWindow.ts | 91 ++ desktop/src/lib/trajectory/viewModel.test.ts | 131 ++ desktop/src/lib/trajectory/viewModel.ts | 323 +++++ desktop/src/lib/workspace/persistence.test.ts | 3 +- desktop/src/main.test.tsx | 1 - desktop/src/main.tsx | 10 +- desktop/src/pages/ActiveSession.test.tsx | 63 +- desktop/src/pages/ActiveSession.tsx | 62 +- desktop/src/pages/Settings.tsx | 7 +- desktop/src/pages/SettingsNavigation.test.tsx | 34 +- desktop/src/pages/TraceList.test.tsx | 339 ------ desktop/src/pages/TraceList.tsx | 486 -------- desktop/src/pages/TraceSession.test.tsx | 1069 ----------------- desktop/src/pages/TraceSession.tsx | 666 ---------- desktop/src/stores/sessionStore.ts | 4 +- desktop/src/stores/tabStore.test.ts | 51 +- desktop/src/stores/tabStore.ts | 127 +- .../src/stores/trajectoryViewStore.test.ts | 51 + desktop/src/stores/trajectoryViewStore.ts | 106 ++ desktop/src/stores/uiStore.test.ts | 8 + desktop/src/stores/uiStore.ts | 11 +- desktop/src/theme/globals.css | 73 +- desktop/src/theme/globals.test.ts | 18 + desktop/src/types/trajectory.ts | 108 ++ docs/desktop/sessions.md | 16 + docs/desktop/settings.md | 8 +- docs/en/desktop/sessions.md | 16 + docs/en/desktop/settings.md | 8 +- src/query.ts | 5 + .../__tests__/session-trajectory-http.test.ts | 427 +++++++ src/server/__tests__/trace-near.test.ts | 171 +++ src/server/api/sessions.ts | 88 +- src/server/services/boundedSessionHistory.ts | 80 +- .../services/localIndex/traceIndex.test.ts | 30 + src/server/services/localIndex/traceIndex.ts | 21 + .../services/trajectoryProjection.test.ts | 426 +++++++ src/server/services/trajectoryProjection.ts | 766 ++++++++++++ src/server/services/trajectoryService.ts | 860 +++++++++++++ src/server/services/trajectoryTypes.ts | 176 +++ src/services/api/claude.ts | 11 + src/services/api/promptSnapshot.test.ts | 477 ++++++++ src/services/api/promptSnapshot.ts | 410 +++++++ src/services/api/traceCapture.ts | 316 ++++- src/utils/sessionStorage.ts | 11 + 129 files changed, 11099 insertions(+), 6348 deletions(-) create mode 100644 desktop/src/api/trajectory.ts delete mode 100644 desktop/src/components/trace/TraceBadges.tsx delete mode 100644 desktop/src/components/trace/TraceDetail.tsx delete mode 100644 desktop/src/components/trace/TraceSplitLayout.tsx delete mode 100644 desktop/src/components/trace/TraceTree.tsx delete mode 100644 desktop/src/components/trace/detail/MessageDetail.tsx delete mode 100644 desktop/src/components/trace/detail/SessionOverview.tsx delete mode 100644 desktop/src/components/trace/detail/ToolDetail.tsx create mode 100644 desktop/src/components/trajectory/TrajectoryDetailPanel.test.tsx create mode 100644 desktop/src/components/trajectory/TrajectoryDetailPanel.tsx create mode 100644 desktop/src/components/trajectory/TrajectoryLinkContext.test.tsx create mode 100644 desktop/src/components/trajectory/TrajectoryLinkContext.tsx create mode 100644 desktop/src/components/trajectory/TrajectoryMinimap.tsx create mode 100644 desktop/src/components/trajectory/TrajectoryTable.tsx create mode 100644 desktop/src/components/trajectory/TrajectoryToolbar.tsx create mode 100644 desktop/src/components/trajectory/TrajectoryView.test.tsx create mode 100644 desktop/src/components/trajectory/TrajectoryView.tsx create mode 100644 desktop/src/components/trajectory/TrajectoryViewSwitch.tsx create mode 100644 desktop/src/components/trajectory/detail/DetailBlocks.tsx rename desktop/src/components/{trace => trajectory}/detail/MessageBlocks.tsx (91%) rename desktop/src/components/{trace/detail/LlmCallDetail.tsx => trajectory/detail/RawRequestDetail.tsx} (91%) create mode 100644 desktop/src/components/trajectory/detail/RawRequestTab.test.tsx create mode 100644 desktop/src/components/trajectory/detail/RawRequestTab.tsx create mode 100644 desktop/src/components/trajectory/detail/RequestSummary.tsx rename desktop/src/components/{trace => trajectory}/detail/Section.tsx (100%) create mode 100644 desktop/src/components/trajectory/detail/SnapshotDetail.tsx create mode 100644 desktop/src/components/trajectory/trajectoryLabels.ts create mode 100644 desktop/src/components/trajectory/useTrajectoryData.ts create mode 100644 desktop/src/components/trajectory/useTrajectoryDetail.ts create mode 100644 desktop/src/lib/trace/requestSummary.test.ts create mode 100644 desktop/src/lib/trace/requestSummary.ts delete mode 100644 desktop/src/lib/traceLaunch.test.ts delete mode 100644 desktop/src/lib/traceLaunch.ts delete mode 100644 desktop/src/lib/traceNavigation.test.ts delete mode 100644 desktop/src/lib/traceNavigation.ts delete mode 100644 desktop/src/lib/traceViewModel.test.ts delete mode 100644 desktop/src/lib/traceViewModel.ts create mode 100644 desktop/src/lib/trajectory/chatTarget.ts create mode 100644 desktop/src/lib/trajectory/detailContent.ts create mode 100644 desktop/src/lib/trajectory/findTraceCall.test.ts create mode 100644 desktop/src/lib/trajectory/findTraceCall.ts create mode 100644 desktop/src/lib/trajectory/helpers.test.ts create mode 100644 desktop/src/lib/trajectory/lineDiff.ts create mode 100644 desktop/src/lib/trajectory/mergeRows.ts create mode 100644 desktop/src/lib/trajectory/minimap.test.ts create mode 100644 desktop/src/lib/trajectory/minimap.ts create mode 100644 desktop/src/lib/trajectory/trajectoryFixtures.ts create mode 100644 desktop/src/lib/trajectory/useFixedRowWindow.ts create mode 100644 desktop/src/lib/trajectory/viewModel.test.ts create mode 100644 desktop/src/lib/trajectory/viewModel.ts delete mode 100644 desktop/src/pages/TraceList.test.tsx delete mode 100644 desktop/src/pages/TraceList.tsx delete mode 100644 desktop/src/pages/TraceSession.test.tsx delete mode 100644 desktop/src/pages/TraceSession.tsx create mode 100644 desktop/src/stores/trajectoryViewStore.test.ts create mode 100644 desktop/src/stores/trajectoryViewStore.ts create mode 100644 desktop/src/types/trajectory.ts create mode 100644 src/server/__tests__/session-trajectory-http.test.ts create mode 100644 src/server/__tests__/trace-near.test.ts create mode 100644 src/server/services/trajectoryProjection.test.ts create mode 100644 src/server/services/trajectoryProjection.ts create mode 100644 src/server/services/trajectoryService.ts create mode 100644 src/server/services/trajectoryTypes.ts create mode 100644 src/services/api/promptSnapshot.test.ts create mode 100644 src/services/api/promptSnapshot.ts diff --git a/desktop/electron/ipc/capabilities.test.ts b/desktop/electron/ipc/capabilities.test.ts index 8be829eb..bc4af8c9 100644 --- a/desktop/electron/ipc/capabilities.test.ts +++ b/desktop/electron/ipc/capabilities.test.ts @@ -89,8 +89,8 @@ describe('Electron IPC capabilities', () => { expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardReadText, undefined)).toBe(true) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardWriteText, 'paste me')).toBe(true) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardWriteText, { text: 'paste me' })).toBe(false) - expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '4673a448-9e2c-475e-898d-9aa0ee2d1ab7')).toBe(true) - expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '../escape')).toBe(false) + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsFocusSession, '4673a448-9e2c-475e-898d-9aa0ee2d1ab7')).toBe(true) + expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsFocusSession, '../escape')).toBe(false) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, undefined)).toBe(true) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, {})).toBe(false) expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowStartDragging, undefined)).toBe(true) diff --git a/desktop/electron/ipc/capabilities.ts b/desktop/electron/ipc/capabilities.ts index f8755eae..6dae35d1 100644 --- a/desktop/electron/ipc/capabilities.ts +++ b/desktop/electron/ipc/capabilities.ts @@ -345,7 +345,6 @@ export const ELECTRON_IPC_VALIDATORS = { [ELECTRON_IPC_CHANNELS.clipboardWriteText]: stringPayload, [ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload, [ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload, - [ELECTRON_IPC_CHANNELS.traceOpenWindow]: sessionIdPayload, [ELECTRON_IPC_CHANNELS.petsList]: noPayload, [ELECTRON_IPC_CHANNELS.petsCreateFromImage]: petCreateFromAtlas, [ELECTRON_IPC_CHANNELS.petsCreateFromAtlas]: petCreateFromAtlas, diff --git a/desktop/electron/ipc/channels.ts b/desktop/electron/ipc/channels.ts index 2be2b5b4..e9e27c6f 100644 --- a/desktop/electron/ipc/channels.ts +++ b/desktop/electron/ipc/channels.ts @@ -17,7 +17,6 @@ export const ELECTRON_IPC_CHANNELS = { clipboardWriteText: 'desktop:clipboard:write-text', shellOpen: 'desktop:shell:open', shellOpenPath: 'desktop:shell:open-path', - traceOpenWindow: 'desktop:trace:open-window', petsList: 'desktop:pets:list', petsCreateFromImage: 'desktop:pets:create-from-image', petsCreateFromAtlas: 'desktop:pets:create-from-atlas', diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index e1711326..56ed986c 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -119,7 +119,6 @@ let previewService: ElectronPreviewService | null = null let workspaceBrowserService: ElectronWorkspaceBrowserService | null = null let workspaceBrowserSessionConfigured = false let petWindowController: PetWindowController | null = null -const traceWindows = new Map() let isQuitting = false let quitCleanupStarted = false let quitCleanupFinished = false @@ -197,10 +196,7 @@ function installSystemAppearanceWatch() { const current = currentAppearance() if (current && !current.followSystem) return const background = startupWindowBackground(current, nativeTheme.shouldUseDarkColors) - for (const window of [mainWindow, ...traceWindows.values()]) { - if (!window || window.isDestroyed()) continue - window.setBackgroundColor(background) - } + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.setBackgroundColor(background) }) } @@ -220,41 +216,6 @@ async function loadRendererEntry( } } -async function openTraceWindow(sessionId: string) { - const existing = traceWindows.get(sessionId) - if (existing && !existing.isDestroyed()) { - showMainWindow(existing, app) - return - } - - const traceWindow = new BrowserWindow({ - width: 1180, - height: 780, - minWidth: 860, - minHeight: 560, - title: 'Trace', - autoHideMenuBar: true, - show: false, - backgroundColor: resolveStartupWindowBackground(), - webPreferences: { - preload: preloadPath(), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }) - traceWindows.set(sessionId, traceWindow) - traceWindow.on('closed', () => { - traceWindows.delete(sessionId) - }) - installMainWindowNavigationGuards(traceWindow.webContents, { openExternal: openExternalUrl }) - await loadRendererEntry(traceWindow, { - traceWindow: '1', - traceSessionId: sessionId, - }) - showMainWindow(traceWindow, app) -} - function getServerRuntime() { serverRuntime ??= new ElectronServerRuntime({ onServerUnavailable: () => { void publicAccessManager?.serverUnavailable() }, @@ -625,7 +586,6 @@ function registerIpcHandlers() { registerHandler(ELECTRON_IPC_CHANNELS.clipboardWriteText, (_event, payload) => clipboard.writeText(String(payload))) registerHandler(ELECTRON_IPC_CHANNELS.shellOpen, (_event, payload) => openExternalUrl(String(payload))) registerHandler(ELECTRON_IPC_CHANNELS.shellOpenPath, (_event, payload) => openSystemPath(resolveRelocatedAttachmentPath(String(payload)))) - registerHandler(ELECTRON_IPC_CHANNELS.traceOpenWindow, (_event, payload) => openTraceWindow(String(payload))) registerHandler(ELECTRON_IPC_CHANNELS.petsList, () => listCustomPets()) registerHandler(ELECTRON_IPC_CHANNELS.petsCreateFromImage, async (event, payload) => { const input = payload as { @@ -926,7 +886,7 @@ function registerIpcHandlers() { applyAppliedAppearance(payload, { app, // The pet window is deliberately transparent, so it stays out of this. - windows: () => [mainWindow, ...traceWindows.values()].filter((window): window is BrowserWindow => !!window), + windows: () => mainWindow ? [mainWindow] : [], }) }) } diff --git a/desktop/electron/services/microphonePermissions.test.ts b/desktop/electron/services/microphonePermissions.test.ts index d899fa3d..c0aec340 100644 --- a/desktop/electron/services/microphonePermissions.test.ts +++ b/desktop/electron/services/microphonePermissions.test.ts @@ -32,9 +32,9 @@ function setup(options: { entry?: string } = {}) { const primary = { id: 1 } as unknown as WebContents - const trace = { id: 3 } as unknown as WebContents + const secondary = { id: 3 } as unknown as WebContents const other = { id: 2 } as unknown as WebContents - const appContents = new Set([primary, trace]) + const appContents = new Set([primary, secondary]) let check!: CheckHandler let request!: RequestHandler const session = { @@ -75,7 +75,7 @@ function setup(options: { }) return { primary, - trace, + secondary, other, systemPreferences, ask, @@ -90,9 +90,9 @@ describe('installMicrophonePermissions request handler', () => { await expect(ask(primary, {})).resolves.toBe(true) }) - it('grants audio-only capture to the detached trace window, whatever its query string', async () => { - const { trace, ask } = setup({ platform: 'linux' }) - await expect(ask(trace, { requestingUrl: `${APP_URL}?traceWindow=1&traceSessionId=abc` })).resolves.toBe(true) + it('grants audio-only capture to a secondary application window, whatever its query string', async () => { + const { secondary, ask } = setup({ platform: 'linux' }) + await expect(ask(secondary, { requestingUrl: `${APP_URL}?petWindow=1&sessionId=abc` })).resolves.toBe(true) }) it('denies audio requests from contents that are not an application window', async () => { @@ -102,18 +102,18 @@ describe('installMicrophonePermissions request handler', () => { }) it('denies once the application window is gone', async () => { - const { primary, trace, ask, closeWindow } = setup({ platform: 'win32' }) + const { primary, secondary, ask, closeWindow } = setup({ platform: 'win32' }) closeWindow(primary) - closeWindow(trace) + closeWindow(secondary) await expect(ask(primary, {})).resolves.toBe(false) - await expect(ask(trace, {})).resolves.toBe(false) + await expect(ask(secondary, {})).resolves.toBe(false) }) - it('still denies subframes and video from the trace window', async () => { - const { trace, ask } = setup({ platform: 'win32' }) - await expect(ask(trace, { isMainFrame: false })).resolves.toBe(false) - await expect(ask(trace, { mediaTypes: ['audio', 'video'] })).resolves.toBe(false) - await expect(ask(trace, { requestingUrl: 'https://example.com/' })).resolves.toBe(false) + it('still denies subframes and video from a secondary application window', async () => { + const { secondary, ask } = setup({ platform: 'win32' }) + await expect(ask(secondary, { isMainFrame: false })).resolves.toBe(false) + await expect(ask(secondary, { mediaTypes: ['audio', 'video'] })).resolves.toBe(false) + await expect(ask(secondary, { requestingUrl: 'https://example.com/' })).resolves.toBe(false) }) it('denies subframes and foreign or missing frame URLs', async () => { @@ -192,9 +192,9 @@ describe('installMicrophonePermissions check handler', () => { } }) - it('reports audio as permitted for the trace window but not for non-application contents', () => { - const { trace, other, probe } = setup({ platform: 'win32' }) - expect(probe(trace, { requestingUrl: `${APP_URL}?traceWindow=1` })).toBe(true) + it('reports audio as permitted for a secondary application window but not for non-application contents', () => { + const { secondary, other, probe } = setup({ platform: 'win32' }) + expect(probe(secondary, { requestingUrl: `${APP_URL}?petWindow=1` })).toBe(true) expect(probe(other)).toBe(false) }) @@ -222,7 +222,7 @@ describe('createRendererUrlMatcher', () => { it('matches the packaged index.html regardless of query and hash', () => { const matches = createRendererUrlMatcher(APP_ENTRY) expect(matches(APP_URL)).toBe(true) - expect(matches(`${APP_URL}?traceWindow=1#/chat`)).toBe(true) + expect(matches(`${APP_URL}?petWindow=1#/chat`)).toBe(true) expect(matches(pathToFileURL('/Applications/Other.app/dist/index.html').toString())).toBe(false) expect(matches('https://example.com/dist/index.html')).toBe(false) expect(matches('not a url')).toBe(false) @@ -259,7 +259,7 @@ describe('main window wiring', () => { it('installs the microphone policy once, on the shared app session, for any application window', () => { expect(mainWindowSource).toContain('installMicrophonePermissions(mainWindow.webContents.session') expect(mainSource.match(/installMicrophonePermissions\(/g)).toHaveLength(1) - // A detached trace window renders the same ChatInput, so the gate cannot be "is mainWindow". + // The policy covers every application window, so the gate cannot be "is mainWindow". expect(mainWindowSource).toContain('BrowserWindow.fromWebContents(contents)') expect(mainWindowSource).not.toMatch(/primary:/) }) diff --git a/desktop/electron/services/microphonePermissions.ts b/desktop/electron/services/microphonePermissions.ts index 07f21300..462e3b25 100644 --- a/desktop/electron/services/microphonePermissions.ts +++ b/desktop/electron/services/microphonePermissions.ts @@ -13,8 +13,8 @@ export type MicrophoneSystemPreferences = { export type MicrophonePermissionOptions = { /** * True for contents that live in a window this application created and that - * is still open (main window and the detached trace window, both of which - * render the chat input). + * is still open. Deliberately not "is the main window": the policy belongs + * to every application window, not to one specific window. */ isAppContents: (contents: WebContents) => boolean /** True only for a frame URL that is the application's own renderer entry. */ @@ -25,8 +25,8 @@ export type MicrophonePermissionOptions = { /** * Match the application's own renderer document: the dev server origin, or the - * packaged `dist/index.html`. Query and hash are ignored because the main and - * trace windows load the same entry with different query strings. + * packaged `dist/index.html`. Query and hash are ignored because every + * application window loads the same entry with its own query string. */ export function createRendererUrlMatcher( entry: string, diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index f21cb17c..db7c49ab 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -127,9 +127,6 @@ vi.mock('../pages/ActivitySettings', () => ({ ActivitySettings: () =>
Activity Settings Mock
, })) -vi.mock('../pages/TraceList', () => ({ - TraceList: () =>
Trace List Mock
, -})) vi.mock('../stores/agentStore', () => ({ useAgentStore: () => ({ @@ -1203,18 +1200,10 @@ describe('Settings > General tab', () => { expect(screen.getByText('Activity Settings Mock')).toBeInTheDocument() }) - it('opens the Trace tab from Settings navigation between Token usage and Diagnostics', () => { + it('does not list a Trace section in Settings navigation', () => { render() - const usageTab = screen.getByText('Token usage') - const traceTab = screen.getByText('Trace') - const diagnosticsTab = screen.getByText('Diagnostics') - expect((usageTab.compareDocumentPosition(traceTab) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) - expect((traceTab.compareDocumentPosition(diagnosticsTab) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) - - fireEvent.click(traceTab) - - expect(screen.getByText('Trace List Mock')).toBeInTheDocument() + expect(screen.queryByText('Trace')).not.toBeInTheDocument() }) it('lets the user disable WebFetch preflight skipping', () => { @@ -1462,6 +1451,20 @@ describe('Settings > General tab', () => { expect(screen.getByText('Message Sending')).toBeInTheDocument() }) + it('points the capture hint at the session Trajectory view instead of the removed trace list', async () => { + render() + + fireEvent.click(screen.getByText('General')) + expect(screen.getByText(/switch to Raw request/)).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByLabelText('Collect agent traces')) + }) + + expect(screen.getByText(/Raw request in each session's Trajectory view/)).toBeInTheDocument() + expect(screen.queryByText(/trace list/i)).not.toBeInTheDocument() + }) + it('uses the shared dropdown for response language', () => { render() diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 60018070..dff1e68a 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -530,8 +530,10 @@ export const sessionsApi = { return api.get(`/api/sessions/${sessionId}/trace${suffix}`, options) }, - getTraceCall(sessionId: string, callId: string) { - return api.get<{ call: TraceCallRecord }>(`/api/sessions/${sessionId}/trace/calls/${callId}`) + /** `at` is the record locator from `/trace/near`, needed for calls outside the indexed window. */ + getTraceCall(sessionId: string, callId: string, at?: string) { + const suffix = at ? `?${new URLSearchParams({ at })}` : '' + return api.get<{ call: TraceCallRecord }>(`/api/sessions/${sessionId}/trace/calls/${callId}${suffix}`) }, create(input?: string | CreateSessionRequest) { diff --git a/desktop/src/api/traces.ts b/desktop/src/api/traces.ts index a69730ed..8a457f72 100644 --- a/desktop/src/api/traces.ts +++ b/desktop/src/api/traces.ts @@ -1,35 +1,7 @@ -import { api, type ApiRequestOptions } from './client' -import type { TraceCaptureSettings, TraceSessionDeleteResult, TraceSessionList } from '../types/trace' - -export type TraceSessionRevision = { - sessionId: string - revision: number - revisionToken?: string - changed: boolean - reset: boolean -} +import { api } from './client' +import type { TraceCaptureSettings } from '../types/trace' export const tracesApi = { - list(options?: { limit?: number; offset?: number; query?: string }, requestOptions?: ApiRequestOptions) { - const params = new URLSearchParams() - if (options?.limit !== undefined) params.set('limit', String(options.limit)) - if (options?.offset !== undefined) params.set('offset', String(options.offset)) - if (options?.query) params.set('q', options.query) - const suffix = params.toString() ? `?${params}` : '' - return api.get(`/api/traces${suffix}`, requestOptions) - }, - - getRevision(sessionId: string, sinceRevision?: number, sinceRevisionToken?: string, options?: ApiRequestOptions) { - const params = new URLSearchParams() - if (sinceRevision !== undefined) params.set('sinceRevision', String(sinceRevision)) - if (sinceRevisionToken !== undefined) params.set('sinceRevisionToken', sinceRevisionToken) - const suffix = params.toString() ? `?${params}` : '' - return api.get( - `/api/traces/${encodeURIComponent(sessionId)}/revision${suffix}`, - options, - ) - }, - getSettings() { return api.get('/api/traces/settings') }, @@ -37,8 +9,4 @@ export const tracesApi = { updateSettings(settings: Partial>) { return api.put('/api/traces/settings', settings) }, - - deleteSession(sessionId: string) { - return api.delete(`/api/traces/${encodeURIComponent(sessionId)}`) - }, } diff --git a/desktop/src/api/trajectory.ts b/desktop/src/api/trajectory.ts new file mode 100644 index 00000000..2f9d7468 --- /dev/null +++ b/desktop/src/api/trajectory.ts @@ -0,0 +1,51 @@ +import { api, type ApiRequestOptions } from './client' +import type { TraceCallsNear, TrajectoryLoc, TrajectoryPage, TrajectoryRowDetail, TrajectorySnapshotBlob } from '../types/trajectory' + +function sessionPath(sessionId: string): string { + return `/api/sessions/${encodeURIComponent(sessionId)}/trajectory` +} + +export const trajectoryApi = { + /** Tail page when neither `cursor` nor `after` is given. */ + getPage( + sessionId: string, + page: { cursor?: string; after?: string; agentId?: string } = {}, + options?: ApiRequestOptions, + ) { + const query = new URLSearchParams() + if (page.cursor) query.set('cursor', page.cursor) + if (page.after) query.set('after', page.after) + if (page.agentId) query.set('agentId', page.agentId) + const suffix = query.size ? `?${query}` : '' + return api.get(`${sessionPath(sessionId)}${suffix}`, options) + }, + + getRow( + sessionId: string, + rowId: string, + loc: TrajectoryLoc[], + agentId?: string, + options?: ApiRequestOptions, + ) { + const query = new URLSearchParams() + query.set('loc', loc.map(([start, end]) => `${start}-${end}`).join(',')) + if (agentId) query.set('agentId', agentId) + return api.get( + `${sessionPath(sessionId)}/rows/${encodeURIComponent(rowId)}?${query}`, + options, + ) + }, + + /** Captured model calls that started inside `[from, to]` (located by time through the capture index). */ + getTraceCallsNear(sessionId: string, from: string, to: string, options?: ApiRequestOptions) { + const query = new URLSearchParams({ from, to }) + return api.get(`/api/sessions/${encodeURIComponent(sessionId)}/trace/near?${query}`, options) + }, + + getSnapshot(sessionId: string, hash: string, options?: ApiRequestOptions) { + return api.get( + `${sessionPath(sessionId)}/snapshots/${encodeURIComponent(hash)}`, + options, + ) + }, +} diff --git a/desktop/src/components/chat/ActivityGroup.tsx b/desktop/src/components/chat/ActivityGroup.tsx index 8e1c530e..bcb2bf5a 100644 --- a/desktop/src/components/chat/ActivityGroup.tsx +++ b/desktop/src/components/chat/ActivityGroup.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, useState } from 'react' +import { memo, useEffect, useMemo, useState } from 'react' import { getDisclosure, setDisclosure } from '../../lib/disclosureMemory' import { toolResultImagesFor, type ToolResultImageExtraction } from '@/lib/toolResultContent' import { CircleX } from 'lucide-react' @@ -35,6 +35,12 @@ type Props = { isLive?: boolean /** Stable key that survives virtualized row unmount/remount. */ disclosureKey?: string + /** + * A "locate in chat" jump landed on this call. A folded run would hide it, + * so the run opens (and stays open, as if the reader had clicked) and the + * row is marked while the jump's highlight lasts. + */ + revealToolUseId?: string } /** @@ -62,6 +68,7 @@ export const ActivityGroup = memo(function ActivityGroup({ isStreaming, isLive = false, disclosureKey, + revealToolUseId, }: Props) { const t = useTranslation() /** null = follow the run's own state; set = the reader decided. */ @@ -75,6 +82,12 @@ export const ActivityGroup = memo(function ActivityGroup({ } const toolCalls = useMemo(() => activityStepToolCalls(steps), [steps]) + const revealsHere = revealToolUseId !== undefined && toolCalls.some((toolCall) => toolCall.toolUseId === revealToolUseId) + useEffect(() => { + if (revealsHere) setPinnedCollapsed(false) + // Pin once per request; `setPinnedCollapsed` is recreated every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [revealsHere, revealToolUseId]) const failedCount = countFailedToolCalls(toolCalls, resultMap, childToolCallsByParent) const hasActiveThinking = Boolean(activeThinkingId) && steps.some( (step) => step.kind === 'thinking' && step.message.id === activeThinkingId, @@ -87,7 +100,9 @@ export const ActivityGroup = memo(function ActivityGroup({ // now". The latter flickers: a run of six tools resolves and restarts six // times, and folding on each gap made the whole block open and shut under the // reader while they were trying to watch it. - const collapsed = pinnedCollapsed ?? !isLive + // Open in the same render the request arrives in, so the row is in the DOM + // for the scroll that follows; the effect above makes it stick. + const collapsed = revealsHere ? false : (pinnedCollapsed ?? !isLive) const soleToolCall = steps.length === 1 && steps[0]?.kind === 'tool' ? steps[0].toolCall : null if (soleToolCall) { @@ -101,6 +116,7 @@ export const ActivityGroup = memo(function ActivityGroup({ toolCall={soleToolCall} resultMap={resultMap} childToolCallsByParent={childToolCallsByParent} + revealToolUseId={revealToolUseId} /> @@ -170,6 +186,7 @@ export const ActivityGroup = memo(function ActivityGroup({ toolCall={step.toolCall} resultMap={resultMap} childToolCallsByParent={childToolCallsByParent} + revealToolUseId={revealToolUseId} /> ))} @@ -243,10 +260,12 @@ function ActivityToolRow({ toolCall, resultMap, childToolCallsByParent, + revealToolUseId, }: { toolCall: ToolCall resultMap: Map childToolCallsByParent: Map + revealToolUseId?: string }) { const result = resultMap.get(toolCall.toolUseId) const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? [] @@ -262,6 +281,8 @@ function ActivityToolRow({ status={toolCall.status} partialInput={toolCall.partialInput} durationMs={toolCallDurationMs(toolCall, result)} + toolUseId={toolCall.parentToolUseId ? undefined : toolCall.toolUseId} + navigationHighlighted={revealToolUseId !== undefined && toolCall.toolUseId === revealToolUseId} /> {childToolCalls.length > 0 && (
diff --git a/desktop/src/components/chat/CodeViewer.test.tsx b/desktop/src/components/chat/CodeViewer.test.tsx index f1331bb4..a85666e8 100644 --- a/desktop/src/components/chat/CodeViewer.test.tsx +++ b/desktop/src/components/chat/CodeViewer.test.tsx @@ -1,8 +1,30 @@ import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { CodeViewer } from './CodeViewer' +import { useSettingsStore } from '../../stores/settingsStore' describe('CodeViewer', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + }) + + it('translates its line count and copy action', () => { + // Regression: the header spelled "3 lines" and the copy button "Copy" in + // English whatever the interface language was. + useSettingsStore.setState({ locale: 'zh' }) + render() + + expect(screen.getByText('3 行')).toBeTruthy() + expect(screen.queryByText(/lines?$/)).toBeNull() + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('counts a single line in the singular', () => { + render() + expect(screen.getByText('1 line')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Copy' })).toBeTruthy() + }) + it('keeps the same inner padding for highlighted code content', () => { const { container } = render( , @@ -37,4 +59,17 @@ describe('CodeViewer', () => { expect(contentWrapper?.style.whiteSpace).toBe('pre-wrap') expect(contentWrapper?.style.wordBreak).toBe('break-word') }) + it("caps the code area by default, so a tool card cannot take over the chat", () => { + const { container } = render() + expect(container.querySelector(".code-viewer-area")?.className).toContain("max-h-[420px]") + }) + + it("lets a host that already scrolls grow the code area with its content", () => { + // Regression: inside the trajectory detail panel the 420px box scrolled on + // its own and left the rest of the panel empty below it. + const { container } = render() + const area = container.querySelector(".code-viewer-area") + expect(area?.className).not.toContain("max-h-") + expect(area?.getAttribute("data-unbounded-height")).toBe("true") + }) }) diff --git a/desktop/src/components/chat/CodeViewer.tsx b/desktop/src/components/chat/CodeViewer.tsx index 4976d84b..4539a8c2 100644 --- a/desktop/src/components/chat/CodeViewer.tsx +++ b/desktop/src/components/chat/CodeViewer.tsx @@ -13,6 +13,12 @@ type Props = { wrapLongLines?: boolean chrome?: CodeViewerChrome label?: string + /** + * Drop the 420px cap on the code area and let it grow with its content. + * For hosts that already scroll (a side panel): a capped box inside a + * scrolling panel leaves the panel's own space empty below it. + */ + unboundedHeight?: boolean } const warmPrismTheme: PrismTheme = { @@ -189,11 +195,13 @@ function CodeArea({ language, showLineNumbers, wrapLongLines, + unboundedHeight, }: { code: string language?: string showLineNumbers: boolean wrapLongLines: boolean + unboundedHeight: boolean }) { const containerRef = useRef(null) const [runtime, setRuntime] = useState(null) @@ -236,7 +244,8 @@ function CodeArea({
{(!ShikiHighlighter || !loaded) && ( maxLines return ( @@ -334,6 +347,8 @@ export function CodeViewer({
{/* Expand/collapse toggle */} diff --git a/desktop/src/components/chat/MermaidRenderer.tsx b/desktop/src/components/chat/MermaidRenderer.tsx index 80c1ff39..580629dc 100644 --- a/desktop/src/components/chat/MermaidRenderer.tsx +++ b/desktop/src/components/chat/MermaidRenderer.tsx @@ -701,7 +701,7 @@ export function MermaidRenderer({ code }: Props) { > {t('mermaid.preview')} - +
@@ -767,7 +767,7 @@ export function MermaidRenderer({ code }: Props) { onClick={zoomIn} /> - +
{ expect(container.querySelector('[data-chat-render-item-key="user-0"]')?.className).toContain('chat-render-item--navigation-target') }) + it('scrolls to and highlights the message a trajectory row asks to locate', async () => { + const messages: UIMessage[] = Array.from({ length: 220 }, (_, index) => ({ + id: `${index % 2 === 0 ? 'user' : 'assistant'}-${index}`, + type: index % 2 === 0 ? 'user_text' : 'assistant_text', + content: `${index % 2 === 0 ? 'Prompt' : 'Answer'} ${index}`, + timestamp: index, + })) as UIMessage[] + useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ messages }) } }) + useTrajectoryViewStore.setState({ nav: null, modes: {}, opened: {} }) + + const { container } = render() + const scroller = container.querySelector('.chat-scroll-area') as HTMLElement + let scrollTop = 24_000 + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 500 }) + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 25_000 }) + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { scrollTop = value }, + }) + + // An unknown uuid first, then the real one: any listed uuid identifies the item. + act(() => useTrajectoryViewStore.getState().revealInChat(ACTIVE_TAB, { uuids: ['not-loaded', 'assistant-1'] })) + + await waitFor(() => expect(container.querySelector('[data-chat-render-item-key="assistant-1"]')?.className).toContain('chat-render-item--navigation-target')) + expect(scrollTop).toBeLessThan(1000) + expect(container.querySelector('[data-chat-render-item-key="user-0"]')?.className ?? '').not.toContain('chat-render-item--navigation-target') + expect(useTrajectoryViewStore.getState().nav).toBeNull() + }) + + it('opens a folded run onto the tool call a trajectory row locates, and marks that row', async () => { + // Regression: the jump only scrolled to the run and tinted it, but a settled + // run is folded to its summary line, so the call it was about stayed hidden. + const messages: UIMessage[] = [ + { id: 'locate-user', type: 'user_text', content: 'Inspect the repo', timestamp: 1 }, + { id: 'locate-read', type: 'tool_use', toolName: 'Read', toolUseId: 'toolu-locate-read', input: { file_path: '/repo/a.ts' }, timestamp: 2 }, + { id: 'locate-read-result', type: 'tool_result', toolUseId: 'toolu-locate-read', content: 'a', isError: false, timestamp: 3 }, + { id: 'locate-bash', type: 'tool_use', toolName: 'Bash', toolUseId: 'toolu-locate-bash', input: { command: 'ls' }, timestamp: 4 }, + { id: 'locate-bash-result', type: 'tool_result', toolUseId: 'toolu-locate-bash', content: 'a.ts', isError: false, timestamp: 5 }, + { id: 'locate-grep', type: 'tool_use', toolName: 'Grep', toolUseId: 'toolu-locate-grep', input: { pattern: 'x' }, timestamp: 6 }, + { id: 'locate-grep-result', type: 'tool_result', toolUseId: 'toolu-locate-grep', content: '', isError: false, timestamp: 7 }, + { id: 'locate-answer', type: 'assistant_text', content: 'Done.', timestamp: 8 }, + ] as UIMessage[] + useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ messages }) } }) + useTrajectoryViewStore.setState({ nav: null, modes: {}, opened: {} }) + + const { container } = render() + const scroller = container.querySelector('.chat-scroll-area') as HTMLElement + const scrollWrites: number[] = [] + let scrollTop = 0 + Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 500 }) + Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 4_000 }) + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { scrollTop = value; scrollWrites.push(value) }, + }) + // Only the located row has a box; the run around it measures as empty, so + // any correction can only have come from aligning the row itself. + const realRect = HTMLElement.prototype.getBoundingClientRect + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.dataset.toolUseId === 'toolu-locate-bash') return { top: 900, bottom: 920, height: 20, left: 0, right: 100, width: 100, x: 0, y: 900, toJSON: () => ({}) } as DOMRect + return realRect.call(this) + }) + + const group = screen.getByTestId('activity-group') + expect(group.getAttribute('data-expanded')).toBe('false') + expect(container.querySelector('[data-tool-use-id="toolu-locate-bash"]')).toBeNull() + + act(() => useTrajectoryViewStore.getState().revealInChat(ACTIVE_TAB, { toolUseId: 'toolu-locate-bash' })) + + await waitFor(() => expect(container.querySelector('[data-tool-use-id="toolu-locate-bash"]')?.className).toContain('chat-tool-navigation-target')) + expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('true') + // Exactly the located call is marked, not its neighbours. + expect(container.querySelectorAll('.chat-tool-navigation-target')).toHaveLength(1) + expect(container.querySelector('[data-tool-use-id="toolu-locate-read"]')?.className).not.toContain('chat-tool-navigation-target') + // Aligned to the row: 900px down, brought to the 25% reading line of a 500px viewport. + await waitFor(() => expect(scrollWrites.length).toBeGreaterThanOrEqual(2)) + expect(scrollWrites.at(-1)! - scrollWrites.at(-2)!).toBe(900 - 500 * 0.25) + + // The mark is brief; the run stays open as if the reader had opened it. + await waitFor( + () => expect(container.querySelector('.chat-tool-navigation-target')).toBeNull(), + { timeout: 3_000 }, + ) + expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('true') + }) + + it('ignores a locate request addressed to another session', async () => { + useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ messages: makeConversationNavigationMessages() }) } }) + useTrajectoryViewStore.setState({ nav: null, modes: {}, opened: {} }) + render() + act(() => useTrajectoryViewStore.getState().revealInChat('other-session', { uuids: ['user-0'] })) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(useTrajectoryViewStore.getState().nav).toMatchObject({ sessionId: 'other-session' }) + }) + it('keeps streaming output out of the user-turn navigator after a prompt jump', async () => { const messages = makeConversationNavigationMessages() useChatStore.setState({ diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index d68b7732..66c0e435 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -55,6 +55,8 @@ import { formatTokenCount } from '../../lib/formatTokenCount' import { formatDurationMs, hasRunningBackgroundTasks as hasAnyRunningBackgroundTasks } from '../../lib/backgroundTasks' import { buildTurnCompletionByMessageId, type TurnCompletion } from '../../lib/turnCompletion' import { isTouchH5Document } from '../../lib/touchH5' +import { findChatRenderTarget } from '../../lib/trajectory/chatTarget' +import { useTrajectoryViewStore } from '../../stores/trajectoryViewStore' import { EMPTY_TEAM_LIFECYCLE_CURSOR, isTeamLifecycleScopedAt, @@ -2408,6 +2410,8 @@ export function MessageList({ }) const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0) const [highlightedNavigationItemKey, setHighlightedNavigationItemKey] = useState(null) + /** The tool call inside the highlighted item a jump is about, if any. */ + const [highlightedNavigationToolUseId, setHighlightedNavigationToolUseId] = useState(null) const [programmaticNavigationItemId, setProgrammaticNavigationItemId] = useState(null) const [activeConversationFindMatch, setActiveConversationFindMatch] = useState(null) const conversationFindMatchesRef = useRef([]) @@ -3314,7 +3318,10 @@ export function MessageList({ return result }, [toolResultMap]) - const handleNavigateToConversationItem = useCallback((item: ConversationNavigationItem) => { + const handleNavigateToConversationItem = useCallback(( + item: ConversationNavigationItem, + options: { toolUseId?: string } = {}, + ) => { const container = scrollContainerRef.current if (!container) return @@ -3322,6 +3329,9 @@ export function MessageList({ userScrollIntentUntilRef.current = 0 setProgrammaticNavigationItemId(item.id) setHighlightedNavigationItemKey(item.renderItemKey) + // A tool call inside the item: its group opens onto it and the call itself + // takes the highlight, since the item is mostly the rows around it. + setHighlightedNavigationToolUseId(options.toolUseId ?? null) const scheduleHighlightClear = () => { if (navigationHighlightTimerRef.current !== null) { @@ -3354,10 +3364,22 @@ export function MessageList({ } setVirtualViewport({ scrollTop: targetScrollTop, viewportHeight }) - requestAnimationFrame(() => { - const targetNode = Array.from( + const alignTarget = (retriesLeft: number) => { + const itemNode = Array.from( scrollContentRef.current?.querySelectorAll('[data-chat-render-item-key]') ?? [], ).find((node) => node.dataset.chatRenderItemKey === item.renderItemKey) + // Align the call itself, not the top of a group that may be thousands of + // rows tall. The group opens in the render this navigation triggered, so + // give it one more frame if the row is not in the DOM yet. + const toolNode = options.toolUseId && itemNode + ? Array.from(itemNode.querySelectorAll('[data-tool-use-id]')) + .find((node) => node.dataset.toolUseId === options.toolUseId) + : undefined + if (options.toolUseId && itemNode && !toolNode && retriesLeft > 0) { + requestAnimationFrame(() => alignTarget(retriesLeft - 1)) + return + } + const targetNode = toolNode ?? itemNode if (targetNode) { const targetRect = targetNode.getBoundingClientRect() @@ -3372,7 +3394,8 @@ export function MessageList({ } scheduleHighlightClear() - }) + } + requestAnimationFrame(() => alignTarget(1)) }, [ syncVirtualViewportFromContainer, virtualTranscriptWindow.offsets, @@ -3408,6 +3431,33 @@ export function MessageList({ conversationFindStreamingTextRef.current = streamingText const conversationFindControllerRef = useRef(null) + // "Locate in chat" from the trajectory view. The request arrives together + // with the switch back to chat, so wait two frames for the list to have real + // dimensions again before measuring offsets. + const trajectoryNav = useTrajectoryViewStore((state) => ( + state.nav?.to === 'chat' && state.nav.sessionId === resolvedSessionId ? state.nav : null + )) + const consumeTrajectoryNav = useTrajectoryViewStore((state) => state.consumeNav) + const handleNavigateToConversationItemRef = useRef(handleNavigateToConversationItem) + handleNavigateToConversationItemRef.current = handleNavigateToConversationItem + useEffect(() => { + if (trajectoryNav?.to !== 'chat') return + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + consumeTrajectoryNav(trajectoryNav.nonce) + const items = conversationFindRenderItemsRef.current + const { index, toolUseId } = findChatRenderTarget(items, trajectoryNav.target) + if (index < 0) return + const key = getRenderItemKey(items[index]!) + handleNavigateToConversationItemRef.current( + { id: key, renderItemKey: key, renderIndex: index, turnNumber: 0, preview: '', attachmentCount: 0 }, + { toolUseId }, + ) + }) + }) + return () => cancelAnimationFrame(frame) + }, [consumeTrajectoryNav, trajectoryNav]) + useEffect(() => { if (!resolvedSessionId || resolvedSessionId !== activeTabId) return @@ -3556,6 +3606,13 @@ export function MessageList({ }, [isWorkspacePanelOpen, resolvedSessionId, restoreWorkspaceOrigin, workspacePanelOrigin]) + /** Only the item a jump landed on hears about it, so other memoized items keep their props. */ + const navigationToolUseIdFor = (item: RenderItem): string | undefined => ( + highlightedNavigationToolUseId !== null && highlightedNavigationItemKey === getRenderItemKey(item) + ? highlightedNavigationToolUseId + : undefined + ) + const renderTranscriptItem = (item: RenderItem, index: number) => { const cardsForItem = turnCardsByRenderIndex.get(index) ?? [] @@ -3584,6 +3641,7 @@ export function MessageList({ // whether a run stands open. isLive={chatState !== 'idle' && index === renderItems.length - 1 && !hasTrailingStreamingItem} disclosureKey={getRenderItemKey(item)} + revealToolUseId={navigationToolUseIdFor(item)} /> ) : item.kind === 'team_card' ? ( resolvedSessionId ? (() => { @@ -3625,6 +3683,10 @@ export function MessageList({ isTurnOutputOwner={turnOutputOwnerIndexes.has(index)} turnCompletion={turnCompletionByMessageId.get(item.message.id)} supersededAskUserQuestionIds={supersededAskUserQuestionIds} + navigationHighlighted={ + item.message.type === 'tool_use' && + navigationToolUseIdFor(item) === item.message.toolUseId + } /> )} @@ -3871,6 +3933,7 @@ export const MessageBlock = memo(function MessageBlock({ isTurnOutputOwner, turnCompletion, supersededAskUserQuestionIds, + navigationHighlighted = false, }: { sessionId?: string | null message: UIMessage @@ -3888,6 +3951,8 @@ export const MessageBlock = memo(function MessageBlock({ isTurnOutputOwner?: boolean turnCompletion?: TurnCompletion supersededAskUserQuestionIds?: ReadonlySet + /** A "locate in chat" jump landed on this tool call. */ + navigationHighlighted?: boolean }) { const t = useTranslation() const teammateVisual = message.type === 'user_text' && message.teammateFrom && team @@ -3976,6 +4041,8 @@ export const MessageBlock = memo(function MessageBlock({ status={message.status} partialInput={message.partialInput} disclosureKey={message.toolUseId} + toolUseId={message.parentToolUseId ? undefined : message.toolUseId} + navigationHighlighted={navigationHighlighted} agentTaskNotification={ message.toolName === 'Agent' ? agentTaskNotifications[message.toolUseId] diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx index cb475289..5e34db1b 100644 --- a/desktop/src/components/chat/ToolCallBlock.tsx +++ b/desktop/src/components/chat/ToolCallBlock.tsx @@ -14,6 +14,7 @@ import { InlineImageGallery } from './InlineImageGallery' import { ToolResultImages } from './ToolResultImages' import { ImageGenerationBlock } from './ImageGenerationBlock' import { isImageGenerationToolName } from './imageGenerationTools' +import { ViewInTrajectoryButton } from '../trajectory/TrajectoryLinkContext' import type { AgentTaskNotification } from '../../types/chat' import { PlanPreviewCard, @@ -44,6 +45,10 @@ type Props = { durationMs?: number /** Stable key that survives virtualized row unmount/remount. */ disclosureKey?: string + /** Enables "view in trajectory" when rendered inside the main session's message list. */ + toolUseId?: string + /** Briefly marks this call as the one a "locate in chat" jump landed on. */ + navigationHighlighted?: boolean } const TOOL_ICONS: Record = { @@ -136,7 +141,7 @@ type ContentStats = { windowed?: boolean } -export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, chrome = 'card', isPending = false, status, partialInput, defaultExpanded = false, durationMs, disclosureKey }: Props) { +export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, chrome = 'card', isPending = false, status, partialInput, defaultExpanded = false, durationMs, disclosureKey, toolUseId, navigationHighlighted = false }: Props) { const isRow = chrome === 'row' const isExitPlanTool = isExitPlanModeTool(toolName) const isEnterPlanTool = isEnterPlanModeTool(toolName) @@ -172,6 +177,9 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu [isPending, obj, partialInput, toolName], ) const liveStatsSummary = liveStats ? formatContentStats(liveStats, t) : '' + const pendingTitle = pendingSummary + ? (liveStatsSummary ? `${pendingSummary} · ${liveStatsSummary}` : pendingSummary) + : undefined // The text extractors below skip image blocks; this is what gives them a thumbnail. const toolImages = useMemo( () => toolResultImagesFor({ toolName, input, content: result?.content }), @@ -241,24 +249,25 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu return (
- + {toolUseId ? ( + // Its own slot left of the duration: the space is always held (no + // jump on hover) and nothing is drawn over the duration or chevron. + // `relative` lifts it above the disclosure's stretched overlay; the + // negative margin keeps a 24px target from growing a 20px row. + ) : null} {durationSummary && ( )} - +
{SESSION_TOOL_NAMES.has(toolName) ? : null} @@ -653,6 +690,8 @@ function ShellOutput({ content, isError, toolName }: { content: unknown; isError
@@ -824,6 +863,8 @@ function renderResultOutput( {label} @@ -853,6 +894,8 @@ function renderResultOutput( {label} @@ -911,6 +954,8 @@ function renderDetails( {label} diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx index 7b5db470..92742262 100644 --- a/desktop/src/components/chat/ToolCallGroup.tsx +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -108,6 +108,8 @@ type Props = { isLive?: boolean /** Stable key that survives virtualized row unmount/remount. */ disclosureKey?: string + /** A "locate in chat" jump targets this call: open its run and mark its row. */ + revealToolUseId?: string } export type OpenAgentRunPayload = { @@ -131,6 +133,7 @@ export const ToolCallGroup = memo(function ToolCallGroup({ isStreaming, isLive = false, disclosureKey, + revealToolUseId, }: Props) { const resolvedSteps = useMemo(() => steps ?? toActivitySteps(toolCalls), [steps, toolCalls]) const memoryActivity = getMemoryToolActivity(toolCalls, resultMap) @@ -164,6 +167,7 @@ export const ToolCallGroup = memo(function ToolCallGroup({ showOpenRun={showOpenRun} isStreaming={isStreaming} disclosureKey={disclosureKey} + revealToolUseId={revealToolUseId} /> ) : null} @@ -185,6 +189,7 @@ export const ToolCallGroup = memo(function ToolCallGroup({ isStreaming={isStreaming} isLive={isLive} disclosureKey={disclosureKey} + revealToolUseId={revealToolUseId} /> ) }) @@ -205,6 +210,7 @@ function ToolCallGroupContent({ isStreaming, isLive = false, disclosureKey, + revealToolUseId, }: ContentProps) { const toolCalls = activityStepToolCalls(steps) const hasImageGeneration = toolCalls.some((toolCall) => isImageGenerationToolName(toolCall.toolName)) @@ -262,6 +268,7 @@ function ToolCallGroupContent({ activeThinkingId={activeThinkingId} showOpenRun={showOpenRun} isStreaming={isStreaming} + revealToolUseId={revealToolUseId} /> ))} @@ -316,6 +323,7 @@ function ToolCallGroupContent({ isStreaming={isStreaming} isLive={isLive} disclosureKey={disclosureKey} + revealToolUseId={revealToolUseId} /> ) } @@ -804,6 +812,7 @@ function ToolCallTree({ status={toolCall.status} partialInput={toolCall.partialInput} durationMs={toolCallDurationMs(toolCall, result)} + toolUseId={toolCall.parentToolUseId ? undefined : toolCall.toolUseId} /> {childToolCalls.length > 0 && (
{ useChatStore.setState({ sessions: {} }) }) + it.each(['card', 'row'] as const)('marks a located %s by its tool id, on the chrome the highlight style keys off', (chrome) => { + const { container, rerender } = render( + , + ) + const root = container.querySelector('[data-tool-use-id="toolu_mark"]')! + expect(root.getAttribute('data-tool-call-chrome')).toBe(chrome) + expect(root.className).toContain('chat-tool-navigation-target') + + rerender() + expect(container.querySelector('[data-tool-use-id="toolu_mark"]')!.className).not.toContain('chat-tool-navigation-target') + }) + + it.each(['card', 'row'] as const)('names the %s output copy action in the interface language', (chrome) => { + // Regression: tool output copy buttons fell back to CopyButton's English "Copy". + useSettingsStore.setState({ locale: 'zh' }) + const { container } = render( + , + ) + fireEvent.click(container.querySelector('[data-chat-disclosure="true"]')!) + expect(screen.getAllByRole('button', { name: '复制' }).length).toBeGreaterThan(0) + expect(screen.queryByRole('button', { name: 'Copy' })).toBeNull() + }) + it('keeps thinking collapsed by default', () => { const { container } = render() diff --git a/desktop/src/components/layout/AppShell.test.tsx b/desktop/src/components/layout/AppShell.test.tsx index 612436d3..2e3ed1fe 100644 --- a/desktop/src/components/layout/AppShell.test.tsx +++ b/desktop/src/components/layout/AppShell.test.tsx @@ -18,7 +18,6 @@ const mocks = vi.hoisted(() => ({ connectToSession: vi.fn(), setActiveTab: vi.fn(), openTab: vi.fn(), - openTraceTab: vi.fn(), getDesktopUiPreferences: vi.fn(), updatePetPreferences: vi.fn(), tabState: { @@ -66,7 +65,6 @@ vi.mock('../../stores/tabStore', () => { activeTabId: mocks.tabState.activeTabId, tabs: mocks.tabState.tabs, openTab: mocks.openTab, - openTraceTab: mocks.openTraceTab, setActiveTab: mocks.setActiveTab, }) useTabStore.setState = (next: { activeTabId?: string | null }) => { @@ -145,14 +143,6 @@ vi.mock('./H5ConnectionView', () => ({ ), })) -vi.mock('../../pages/TraceSession', () => ({ - TraceSession: ({ sessionId, standalone }: { sessionId: string; standalone?: boolean }) => ( -
- trace:{sessionId} -
- ), -})) - vi.mock('@/components/layout/Toast', () => ({ ToastContainer: () => null, })) @@ -209,7 +199,6 @@ describe('AppShell boot flow', () => { }, }) mocks.openTab.mockReset() - mocks.openTraceTab.mockReset() mocks.setActiveTab.mockImplementation((sessionId: string) => { mocks.tabState.activeTabId = sessionId }) @@ -389,51 +378,16 @@ describe('AppShell boot flow', () => { }) }) - it('opens a trace tab from a session-scoped trace deep link', async () => { - window.history.pushState({}, '', '/?traceSessionId=session-deep-link') - - render() - - await screen.findByText('sidebar loaded') - await waitFor(() => { - // No session in the store yet, so the id prefix is all the title we have. - expect(mocks.openTraceTab).toHaveBeenCalledWith('session-deep-link', 'session-') - }) - expect(mocks.connectToSession).not.toHaveBeenCalled() - }) - - it('titles a deep-linked trace tab with the session once the store knows it', async () => { - useSessionStore.setState({ - sessions: [{ - id: 'session-deep-link', - title: 'Debug stuck agent', - createdAt: '2026-06-09T10:00:00.000Z', - modifiedAt: '2026-06-09T10:10:00.000Z', - messageCount: 2, - projectPath: '/tmp', - workDir: '/tmp', - workDirExists: true, - }], - }) - window.history.pushState({}, '', '/?traceSessionId=session-deep-link') - - render() - - await screen.findByText('sidebar loaded') - await waitFor(() => { - expect(mocks.openTraceTab).toHaveBeenCalledWith('session-deep-link', 'Debug stuck agent') - }) - }) - - it('renders a dedicated trace window shell from traceWindow deep links', async () => { + it('boots the normal shell for a retired trace-window deep link', async () => { + // Trace windows and `?traceSessionId=` deep links were removed with the + // standalone Trace page; a stale URL must not hide the workspace shell. window.history.pushState({}, '', '/?traceWindow=1&traceSessionId=session-window') render() - expect(await screen.findByTestId('trace-session')).toHaveTextContent('trace:session-window') - expect(screen.getByTestId('trace-session')).toHaveAttribute('data-standalone', 'true') - expect(screen.queryByText('sidebar loaded')).not.toBeInTheDocument() - expect(mocks.restoreTabs).not.toHaveBeenCalled() + await screen.findByText('sidebar loaded') + await waitFor(() => expect(mocks.restoreTabs).toHaveBeenCalledTimes(1)) + expect(mocks.openTab).not.toHaveBeenCalled() }) it('routes native menu navigation through the desktop host', async () => { diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 47cc94c9..f37bd506 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react' +import { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react' import { Sidebar } from './Sidebar' import { ContentRouter } from './ContentRouter' import { ToastContainer } from '@/components/layout/Toast' @@ -37,10 +37,6 @@ import { useTranslation } from '../../i18n' import { H5ConnectionView } from './H5ConnectionView' import { useMobileViewport } from '../../hooks/useMobileViewport' import type { Tab } from '../../stores/tabStore' -import { getTraceLaunchRequest } from '../../lib/traceLaunch' -import { openTraceDetail } from '../../lib/traceNavigation' -import { TraceList } from '../../pages/TraceList' -import { TraceSession } from '../../pages/TraceSession' function isChatTab(tab: Tab | undefined) { return tab?.type === 'session' @@ -63,7 +59,6 @@ export function AppShell() { setDesktopUiPreferencesRequest((current) => current === request ? null : current) }, []) const t = useTranslation() - const traceLaunch = useMemo(() => getTraceLaunchRequest(), []) const desktopRuntime = isDesktopRuntime() const isMobileShell = useMobileViewport() && !desktopRuntime const tabs = useTabStore((s) => s.tabs) @@ -130,42 +125,27 @@ export function AppShell() { await fetchSettings() if (cancelled) return - if (!traceLaunch.windowMode) { - const displayNameHydrationRevision = captureProjectDisplayNameHydrationRevision() - const preferencesRequest = desktopUiPreferencesApi.getPreferences() - setDesktopUiPreferencesRequest(preferencesRequest) - void preferencesRequest - .then(({ preferences }) => { - if (cancelled) return - hydrateProjectDisplayNames( - preferences.projectDisplayNames ?? {}, - displayNameHydrationRevision, - ) - if (desktopRuntime && preferences.pet.enabled) { - return getDesktopHost().pets.show() - } - }) - .catch(() => undefined) - } + const displayNameHydrationRevision = captureProjectDisplayNameHydrationRevision() + const preferencesRequest = desktopUiPreferencesApi.getPreferences() + setDesktopUiPreferencesRequest(preferencesRequest) + void preferencesRequest + .then(({ preferences }) => { + if (cancelled) return + hydrateProjectDisplayNames( + preferences.projectDisplayNames ?? {}, + displayNameHydrationRevision, + ) + if (desktopRuntime && preferences.pet.enabled) { + return getDesktopHost().pets.show() + } + }) + .catch(() => undefined) setReady(true) void (async () => { - if (traceLaunch.windowMode) return - await useTabStore.getState().restoreTabs() if (cancelled) return - if (traceLaunch.sessionId) { - // A deep link arrives before the session list is in the store often - // enough that the id prefix is the only title we can guarantee. - const launchedSession = useSessionStore.getState().sessions - .find((session) => session.id === traceLaunch.sessionId) - openTraceDetail( - traceLaunch.sessionId, - launchedSession?.title || traceLaunch.sessionId.slice(0, 8), - ) - return - } const { activeTabId: activeId, tabs } = useTabStore.getState() const activeTab = tabs.find((tab) => tab.sessionId === activeId) if (activeId && activeTab?.type === 'session') { @@ -191,7 +171,7 @@ export function AppShell() { return () => { cancelled = true } - }, [bootstrapNonce, fetchSettings, desktopRuntime, traceLaunch]) + }, [bootstrapNonce, fetchSettings, desktopRuntime]) // Listen for macOS native menu navigation events (About / Settings) useEffect(() => { @@ -299,19 +279,6 @@ export function AppShell() { ) } - if (traceLaunch.windowMode) { - return ( -
- {traceLaunch.sessionId ? ( - - ) : ( - - )} - -
- ) - } - return (
{isMobileShell && effectiveSidebarOpen ? ( diff --git a/desktop/src/components/layout/ContentRouter.test.tsx b/desktop/src/components/layout/ContentRouter.test.tsx index 04aa8558..fda28974 100644 --- a/desktop/src/components/layout/ContentRouter.test.tsx +++ b/desktop/src/components/layout/ContentRouter.test.tsx @@ -32,19 +32,6 @@ vi.mock('../../pages/TerminalSettings', () => ({ ), })) -vi.mock('../../pages/TraceSession', () => ({ - TraceSession: ({ sessionId, onBack }: { sessionId: string; onBack?: () => void }) => ( -
- trace:{sessionId} - {onBack ? : null} -
- ), -})) - -vi.mock('../../pages/TraceList', () => ({ - TraceList: () =>
, -})) - vi.mock('../../pages/SubagentRunPage', () => ({ SubagentRunPage: ({ sourceSessionId, taskId, toolUseId, title }: { sourceSessionId: string; taskId?: string; toolUseId: string; title: string }) => (
{sourceSessionId}:{toolUseId}:{taskId}:{title}
@@ -159,65 +146,6 @@ describe('ContentRouter tab surfaces', () => { expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === useTabStore.getState().activeTabId)?.terminalCwd).toBe('/tmp/project') }) - it('renders trace tabs without mounting the chat session surface', () => { - useTabStore.setState({ - tabs: [{ - sessionId: '__trace__session-1', - title: 'Trace', - type: 'trace', - status: 'idle', - traceSessionId: 'session-1', - }], - activeTabId: '__trace__session-1', - }) - - render() - - expect(screen.getByTestId('trace-session')).toHaveTextContent('trace:session-1') - expect(screen.queryByTestId('active-session')).not.toBeInTheDocument() - }) - - it('walks a trace tab back to the list and closes the tab behind it', () => { - useTabStore.setState({ - tabs: [ - { sessionId: 'session-1', title: 'Chat', type: 'session', status: 'idle' }, - { - sessionId: '__trace__session-1', - title: 'Chat', - type: 'trace', - status: 'idle', - traceSessionId: 'session-1', - }, - ], - activeTabId: '__trace__session-1', - }) - - render() - fireEvent.click(screen.getByRole('button', { name: 'back' })) - - const { tabs, activeTabId } = useTabStore.getState() - expect(activeTabId).toBe(SETTINGS_TAB_ID) - expect(tabs.some((tab) => tab.sessionId === '__trace__session-1')).toBe(false) - expect(useUIStore.getState().pendingSettingsTab).toBe('trace') - }) - - it('renders the trace list tab without mounting the chat session surface', () => { - useTabStore.setState({ - tabs: [{ - sessionId: '__traces__', - title: 'Trace', - type: 'traces', - status: 'idle', - }], - activeTabId: '__traces__', - }) - - render() - - expect(screen.getByTestId('trace-list')).toBeInTheDocument() - expect(screen.queryByTestId('active-session')).not.toBeInTheDocument() - }) - it('renders SubAgent run tabs', () => { useTabStore.setState({ tabs: [{ diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index 41c50fe3..10419ff5 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -6,12 +6,9 @@ import { ScheduledTasks } from '../../pages/ScheduledTasks' import { ExtensionMarket } from '@/pages/ExtensionMarket' import { Settings } from '../../pages/Settings' import { TerminalSettings } from '../../pages/TerminalSettings' -import { TraceList } from '../../pages/TraceList' -import { TraceSession } from '../../pages/TraceSession' import { SubagentRunPage, TeamMemberRunPage } from '../../pages/SubagentRunPage' import { AgentTeamsWorkbenchTab } from '../agentTeams/AgentTeamsWorkbenchTab' import { WorkspaceBrowserGuestLayer } from '../workbench/WorkspaceBrowserGuestLayer' -import { returnToTraceList } from '../../lib/traceNavigation' export function ContentRouter() { const activeTabId = useTabStore((s) => s.activeTabId) @@ -51,14 +48,6 @@ export function ContentRouter() { page = } else if (activeTabType === 'connectors' || activeTabType === 'market') { page = - } else if (activeTabType === 'trace') { - const traceTabId = activeTabId - const traceSessionId = tabs.find((t) => t.sessionId === traceTabId)?.traceSessionId - page = traceSessionId - ? returnToTraceList(traceTabId)} /> - : - } else if (activeTabType === 'traces') { - page = } else if (activeTabType === 'subagent') { const subagentTab = tabs.find((t) => t.sessionId === activeTabId) page = subagentTab?.sourceSessionId && subagentTab.subagentToolUseId diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 50fc65c9..468a5086 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -9,8 +9,6 @@ import { TEAM_MEMBER_TAB_PREFIX, TEAM_TAB_PREFIX, TERMINAL_TAB_PREFIX, - TRACE_LIST_TAB_ID, - TRACE_TAB_PREFIX, WORKBENCH_TAB_PREFIX, useTabStore, type Tab, @@ -84,16 +82,13 @@ function clippedSide( // One glyph per *non-chat* tab kind: the glyph says "this tab is not a // conversation". Chat tabs deliberately have none — a bubble on every tab in a // strip that is mostly chats is pure noise, and the slot it occupied is worth -// more as title. `trace` and `traces` share the Settings rail's glyph on -// purpose — a trace tab should read as that section, not as another chat. +// more as title. const TAB_TYPE_ICON: Partial> = { settings: 'settings', scheduled: 'schedule', market: 'storefront', connectors: 'link', terminal: 'terminal', - trace: 'account_tree', - traces: 'account_tree', workbench: 'view_sidebar', subagent: 'smart_toy', team: 'account_tree', @@ -123,9 +118,7 @@ function isSessionTabId(tabId: string | null) { tabId !== SCHEDULED_TAB_ID && tabId !== MARKET_TAB_ID && tabId !== CONNECTORS_TAB_ID && - tabId !== TRACE_LIST_TAB_ID && !tabId.startsWith(TERMINAL_TAB_PREFIX) && - !tabId.startsWith(TRACE_TAB_PREFIX) && !tabId.startsWith(WORKBENCH_TAB_PREFIX) && !tabId.startsWith(SUBAGENT_TAB_PREFIX) && !tabId.startsWith(TEAM_TAB_PREFIX) && diff --git a/desktop/src/components/trace/TraceBadges.tsx b/desktop/src/components/trace/TraceBadges.tsx deleted file mode 100644 index 7c3e13ac..00000000 --- a/desktop/src/components/trace/TraceBadges.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import type { ReactNode } from 'react' -import { - AlertTriangle, - Bot, - CircleDot, - Clock3, - FileJson2, - GitBranch, - MessageSquareText, - RadioTower, - Sparkles, - Wrench, -} from 'lucide-react' -import { Badge } from '@/components/ui/Badge' -import { useTranslation } from '../../i18n' -import type { TraceSpan, TraceSpanStatus } from '../../lib/traceViewModel' - -type TraceTranslator = ReturnType - -export function TypeIcon({ span, size = 14 }: { span: TraceSpan; size?: number }) { - const { icon, className } = iconForSpan(span, size) - return ( - - ) -} - -/** - * Three tiers of ink, so a glance at the timeline separates the model from - * everything else: clay for LLM calls, secondary ink for content (messages, - * tool invocations), tertiary for structure and lifecycle. - * - * The spec calls the message icons "blue-grey", but `--color-info` resolves to - * the same clay as `--color-brand` in five of the six themes, which would erase - * the LLM signal. Secondary ink is the closest thing that holds in all six. - */ -function iconForSpan(span: TraceSpan, size: number): { icon: ReactNode; className: string } { - const tertiary = 'text-[var(--color-text-tertiary)]' - const secondary = 'text-[var(--color-text-secondary)]' - switch (span.kind) { - case 'llm': - return { icon: , className: 'text-[var(--color-brand)]' } - case 'tool': - return { icon: , className: secondary } - case 'tool_result': - return { icon: , className: tertiary } - case 'turn': - return { icon: , className: tertiary } - case 'session': - return { icon: , className: tertiary } - case 'event': - return span.status === 'error' - ? { icon: , className: 'text-[var(--color-error)]' } - : { icon: , className: tertiary } - case 'message': - if (span.message?.type === 'assistant') { - return { icon: , className: secondary } - } - if (span.message?.type === 'system') { - return { icon: , className: tertiary } - } - return { icon: , className: secondary } - default: - return { icon: , className: tertiary } - } -} - -export function StatusGlyph({ status }: { status: TraceSpanStatus }) { - if (status === 'error') { - return