fix(desktop): give trace detail tabs a way back to the list

The trace list lives inside Settings, but opening a row jumps to a
sibling top-level tab with no return path: the detail header only
offered copy/refresh/open-window, so getting back to the list meant
finding a tab labelled "Settings" — which does not match the mental
model of someone reading a trace.

Add a "back to list" control to the detail header that returns to the
Settings trace section and closes the tab it came from, mirroring
returnFromWorkbench. It stays available in the loading and error states,
where being stranded hurts most.

Along the same path:
- Tag trace tabs with the account_tree glyph, so the title can carry the
  session name instead of a truncated "Model trace: " prefix.
- Scroll the selected Settings rail entry into view. Settings remounts on
  re-entry with the rail scrolled to the top, which left the selected
  section highlighted off-screen after returning.
- Drop the row action that duplicated the row click.
- Fall back to a browser tab for "open in separate window" outside the
  desktop shell, where it was a dead button.

Navigation is consolidated in lib/traceNavigation.ts so the list, deep
links, and the return path share one definition.
This commit is contained in:
程序员阿江(Relakkes)
2026-07-28 00:29:46 +08:00
parent ede359995f
commit cfed362e89
19 changed files with 428 additions and 41 deletions
@@ -347,14 +347,35 @@ describe('AppShell boot flow', () => {
await screen.findByText('sidebar loaded')
await waitFor(() => {
expect(mocks.openTraceTab).toHaveBeenCalledWith(
'session-deep-link',
'Trace: session-',
)
// 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(<AppShell />)
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 () => {
window.history.pushState({}, '', '/?traceWindow=1&traceSessionId=session-window')
+7 -2
View File
@@ -29,6 +29,7 @@ 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'
@@ -130,9 +131,13 @@ export function AppShell() {
await useTabStore.getState().restoreTabs()
if (cancelled) return
if (traceLaunch.sessionId) {
useTabStore.getState().openTraceTab(
// 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,
`Trace: ${traceLaunch.sessionId.slice(0, 8)}`,
launchedSession?.title || traceLaunch.sessionId.slice(0, 8),
)
return
}
@@ -39,7 +39,12 @@ vi.mock('../../pages/TerminalSettings', () => ({
}))
vi.mock('../../pages/TraceSession', () => ({
TraceSession: ({ sessionId }: { sessionId: string }) => <div data-testid="trace-session">trace:{sessionId}</div>,
TraceSession: ({ sessionId, onBack }: { sessionId: string; onBack?: () => void }) => (
<div data-testid="trace-session">
trace:{sessionId}
{onBack ? <button type="button" onClick={onBack}>back</button> : null}
</div>
),
}))
vi.mock('../../pages/TraceList', () => ({
@@ -59,13 +64,15 @@ vi.mock('../workbench/WorkbenchTab', () => ({
}))
import { ContentRouter } from './ContentRouter'
import { MARKET_TAB_ID, useTabStore } from '../../stores/tabStore'
import { MARKET_TAB_ID, SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
describe('ContentRouter tab surfaces', () => {
afterEach(() => {
cleanup()
previewBridgeMock.close.mockClear()
useTabStore.setState({ tabs: [], activeTabId: null })
useUIStore.setState({ pendingSettingsTab: null })
})
it('renders the active terminal tab as main content', () => {
@@ -147,6 +154,30 @@ describe('ContentRouter tab surfaces', () => {
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(<ContentRouter />)
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: [{
@@ -11,6 +11,7 @@ import { TraceSession } from '../../pages/TraceSession'
import { SubagentRunPage } from '../../pages/SubagentRunPage'
import { WorkbenchTab } from '../workbench/WorkbenchTab'
import { previewBridge } from '../../lib/previewBridge'
import { returnToTraceList } from '../../lib/traceNavigation'
export function ContentRouter() {
const activeTabId = useTabStore((s) => s.activeTabId)
@@ -33,8 +34,11 @@ export function ContentRouter() {
} else if (activeTabType === 'market') {
page = <Market />
} else if (activeTabType === 'trace') {
const traceSessionId = tabs.find((t) => t.sessionId === activeTabId)?.traceSessionId
page = traceSessionId ? <TraceSession sessionId={traceSessionId} /> : <EmptySession />
const traceTabId = activeTabId
const traceSessionId = tabs.find((t) => t.sessionId === traceTabId)?.traceSessionId
page = traceSessionId
? <TraceSession sessionId={traceSessionId} onBack={() => returnToTraceList(traceTabId)} />
: <EmptySession />
} else if (activeTabType === 'traces') {
page = <TraceList />
} else if (activeTabType === 'subagent') {
+5
View File
@@ -647,6 +647,11 @@ const TabItem = forwardRef<HTMLDivElement, {
{tab.type === 'workbench' && (
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">view_sidebar</span>
)}
{/* Same glyph as the Settings rail entry the trace list sits behind, so a
trace tab reads as that section rather than as another chat. */}
{tab.type === 'trace' && (
<span className="material-symbols-outlined text-[14px] flex-shrink-0 text-[var(--color-text-tertiary)]">account_tree</span>
)}
<span className={`flex-1 truncate text-xs ${isActive ? 'text-[var(--color-text-primary)] font-medium' : 'text-[var(--color-text-secondary)]'}`}>
{displayTitle}
+1
View File
@@ -2253,6 +2253,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'trace.copySessionId': 'Copy session ID',
'trace.refresh': 'Refresh trace',
'trace.openWindow': 'Open in separate window',
'trace.backToList': 'Back to list',
'trace.delete': 'Delete trace',
'trace.noResponse': 'No response body captured',
'trace.emptyTitle': 'No trace calls yet',
+1
View File
@@ -2255,6 +2255,7 @@ export const jp: Record<TranslationKey, string> = {
'trace.copySessionId': 'Session ID をコピー',
'trace.refresh': 'Trace を更新',
'trace.openWindow': '別ウィンドウで開く',
'trace.backToList': '一覧に戻る',
'trace.delete': 'Trace を削除',
'trace.noResponse': 'レスポンス本文はキャプチャされていません',
'trace.emptyTitle': 'Trace 呼び出しはまだありません',
+1
View File
@@ -2255,6 +2255,7 @@ export const kr: Record<TranslationKey, string> = {
'trace.copySessionId': 'Session ID 복사',
'trace.refresh': 'Trace 새로고침',
'trace.openWindow': '별도 창에서 열기',
'trace.backToList': '목록으로',
'trace.delete': 'Trace 삭제',
'trace.noResponse': '응답 본문이 캡처되지 않았습니다',
'trace.emptyTitle': '아직 Trace 호출이 없습니다',
+1
View File
@@ -2254,6 +2254,7 @@ export const zh: Record<TranslationKey, string> = {
'trace.copySessionId': '複製 Session ID',
'trace.refresh': '重新整理 Trace',
'trace.openWindow': '在獨立視窗開啟',
'trace.backToList': '返回列表',
'trace.delete': '刪除 Trace',
'trace.noResponse': '未捕獲回應正文',
'trace.emptyTitle': '暫無 Trace 呼叫',
+1
View File
@@ -2254,6 +2254,7 @@ export const zh: Record<TranslationKey, string> = {
'trace.copySessionId': '复制 Session ID',
'trace.refresh': '刷新 Trace',
'trace.openWindow': '在独立窗口打开',
'trace.backToList': '返回列表',
'trace.delete': '删除 Trace',
'trace.noResponse': '未捕获响应正文',
'trace.emptyTitle': '暂无 Trace 调用',
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
openTraceCaptureSettings,
openTraceDetail,
openTraceList,
returnToTraceList,
} from './traceNavigation'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
import { useSettingsStore } from '../stores/settingsStore'
vi.mock('../api/sessions', () => ({
sessionsApi: { list: vi.fn() },
}))
describe('traceNavigation', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState({ tabs: [], activeTabId: null })
useUIStore.setState({ pendingSettingsTab: null })
})
afterEach(() => {
useTabStore.setState({ tabs: [], activeTabId: null })
useUIStore.setState({ pendingSettingsTab: null })
})
it('opens the trace list by focusing the Settings tab on its Trace section', () => {
openTraceList()
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === SETTINGS_TAB_ID)?.type).toBe('settings')
expect(useUIStore.getState().pendingSettingsTab).toBe('trace')
})
it('sends the capture switches to General, not to the list', () => {
openTraceCaptureSettings()
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
expect(useUIStore.getState().pendingSettingsTab).toBe('general')
})
it('titles a detail tab with the session alone, no trace prefix', () => {
const tabId = openTraceDetail('session-9', 'Debug stuck agent')
expect(tabId).toBe('__trace__session-9')
const tab = useTabStore.getState().tabs.find((entry) => entry.sessionId === tabId)
expect(tab?.title).toBe('Debug stuck agent')
expect(tab?.traceSessionId).toBe('session-9')
})
it('lands on the list and closes the detail tab it came from', () => {
const tabId = openTraceDetail('session-9', 'Debug stuck agent')
returnToTraceList(tabId)
expect(useTabStore.getState().tabs.some((tab) => tab.sessionId === tabId)).toBe(false)
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
expect(useUIStore.getState().pendingSettingsTab).toBe('trace')
})
it('does not strand focus on a neighbouring tab when closing the detail tab', () => {
// Closing the active tab hands focus to whichever tab takes its index, so
// the return has to switch away first. With a chat tab sitting where the
// trace tab was, a close-then-switch order would land on the chat instead.
useTabStore.setState({
tabs: [{ sessionId: 'chat-1', title: 'Chat', type: 'session', status: 'idle' }],
activeTabId: 'chat-1',
})
const tabId = openTraceDetail('session-9', 'Debug stuck agent')
returnToTraceList(tabId)
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['chat-1', SETTINGS_TAB_ID])
})
it('reuses the existing detail tab when the same trace is reopened', () => {
const first = openTraceDetail('session-9', 'Debug stuck agent')
useTabStore.getState().setActiveTab(SETTINGS_TAB_ID)
const second = openTraceDetail('session-9', 'Debug stuck agent renamed')
expect(second).toBe(first)
expect(useTabStore.getState().tabs.filter((tab) => tab.type === 'trace')).toHaveLength(1)
expect(useTabStore.getState().activeTabId).toBe(first)
})
})
+37
View File
@@ -0,0 +1,37 @@
import { t } from '../i18n'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
/**
* The trace list lives inside Settings, so "show the list" and "go back to the
* list" are the same action: focus the Settings tab and select its Trace
* section. Routing every entry point through here keeps the return path one hop
* regardless of where the detail tab was opened from — the list, a deep link,
* or a restored tab.
*/
export function openTraceList(): void {
useUIStore.getState().setPendingSettingsTab('trace')
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
}
/** The capture switches themselves live one section over, under General. */
export function openTraceCaptureSettings(): void {
useUIStore.getState().setPendingSettingsTab('general')
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
}
/** Drill into one session's trace. The tab is titled with the session it traces. */
export function openTraceDetail(sessionId: string, title: string): string {
return useTabStore.getState().openTraceTab(sessionId, title)
}
/**
* Leave a detail tab the way `returnFromWorkbench` leaves a workbench tab: land
* on the list first, then drop the tab we came from, so browsing several traces
* in a row does not leave a trail of dead tabs behind. Ordering matters — the
* close runs after the switch so the tab bar never flashes an interim tab.
*/
export function returnToTraceList(tabId: string): void {
openTraceList()
useTabStore.getState().closeTab(tabId)
}
+12
View File
@@ -277,8 +277,20 @@ export function Settings() {
}
function TabButton({ icon, label, active, onClick }: { icon: string; label: string; active: boolean; onClick: () => void }) {
const ref = useRef<HTMLButtonElement>(null)
// The rail is taller than its viewport, and Settings remounts whenever the
// tab is re-entered — from a trace tab's "back to list", say — with the
// scroll position reset to the top. Without this the selected section can be
// highlighted somewhere off-screen. `nearest` is a no-op when already visible.
useEffect(() => {
if (!active) return
ref.current?.scrollIntoView?.({ block: 'nearest' })
}, [active])
return (
<button
ref={ref}
onClick={onClick}
aria-current={active ? 'page' : undefined}
className={`w-full flex items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-[13.5px] text-left transition-[background-color,color] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-container-low)] ${
@@ -0,0 +1,77 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../api/traces', () => ({
tracesApi: {
list: vi.fn().mockResolvedValue({
total: 0,
storageDir: '/tmp/cc-haha/traces',
settings: { enabled: true, storageDir: '/tmp/cc-haha/traces' },
traces: [],
}),
deleteSession: vi.fn(),
},
}))
import { Settings } from './Settings'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
/**
* The rail is a scroll container taller than its viewport, and Settings
* remounts every time the tab is re-entered — notably when a trace tab's "back
* to list" walks the user here. These cover the two halves of landing
* correctly: the right section is shown, and its rail entry is in view.
*/
describe('Settings section navigation', () => {
const scrollIntoView = vi.fn()
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
writable: true,
value: scrollIntoView,
})
useSettingsStore.setState({ locale: 'en' })
useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null })
})
afterEach(() => {
cleanup()
scrollIntoView.mockClear()
useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null })
})
it('opens the section a pending request asked for and clears the request', async () => {
useUIStore.setState({ pendingSettingsTab: 'trace' })
render(<Settings />)
expect(await screen.findByRole('heading', { level: 1, name: 'Trace list' })).toBeInTheDocument()
expect(useUIStore.getState().activeSettingsTab).toBe('trace')
expect(useUIStore.getState().pendingSettingsTab).toBeNull()
})
it('brings the selected rail entry into view on mount', async () => {
useUIStore.setState({ activeSettingsTab: 'trace' })
render(<Settings />)
await waitFor(() => {
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
})
const railEntry = screen.getByRole('button', { name: 'Trace', current: 'page' })
expect(railEntry).toBeInTheDocument()
})
it('follows the selection when another section is picked', () => {
render(<Settings />)
scrollIntoView.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Diagnostics' }))
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
expect(useUIStore.getState().activeSettingsTab).toBe('diagnostics')
})
})
+31 -5
View File
@@ -14,8 +14,10 @@ vi.mock('../api/traces', () => ({
},
}))
const { openTraceWindowMock } = vi.hoisted(() => ({
const { openTraceWindowMock, hostState } = vi.hoisted(() => ({
openTraceWindowMock: vi.fn(async () => {}),
/** Flipped per-test to model H5/browser, where there is no native window API. */
hostState: { hasTraceHost: true },
}))
vi.mock('../lib/desktopHost', async (importOriginal) => {
@@ -24,7 +26,7 @@ vi.mock('../lib/desktopHost', async (importOriginal) => {
...actual,
getDesktopHost: () => ({
...actual.getDesktopHost(),
trace: { openWindow: openTraceWindowMock },
trace: hostState.hasTraceHost ? { openWindow: openTraceWindowMock } : undefined,
}),
}
})
@@ -90,6 +92,7 @@ describe('TraceList', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState({ tabs: [], activeTabId: null })
hostState.hasTraceHost = true
vi.mocked(tracesApi.list).mockResolvedValue(traceList)
vi.mocked(tracesApi.deleteSession).mockResolvedValue({ sessionId: 'session-trace-list', deleted: true })
})
@@ -138,7 +141,11 @@ describe('TraceList', () => {
fireEvent.click(await screen.findByText('Debug stuck agent'))
expect(useTabStore.getState().activeTabId).toBe('__trace__session-trace-list')
expect(useTabStore.getState().tabs.find((tab) => tab.type === 'trace')?.traceSessionId).toBe('session-trace-list')
const traceTab = useTabStore.getState().tabs.find((tab) => tab.type === 'trace')
expect(traceTab?.traceSessionId).toBe('session-trace-list')
// Titled with the session itself — the tab bar's trace glyph carries the
// type, so a "Model trace: " prefix would only eat the visible width.
expect(traceTab?.title).toBe('Debug stuck agent')
useTabStore.setState({ tabs: [], activeTabId: null })
fireEvent.keyDown(within(await findTraceRow(/Debug stuck agent/)).getByRole('button', { name: /Debug stuck agent/ }), { key: 'Enter' })
@@ -156,9 +163,28 @@ describe('TraceList', () => {
expect(openTraceWindowMock).toHaveBeenCalledWith('session-trace-list')
expect(useTabStore.getState().activeTabId).toBeNull()
fireEvent.click(within(row).getByRole('button', { name: 'Trace' }))
// The row actions cover only what a row click cannot express. A separate
// "open" button would just duplicate the click and crowd the hover strip.
expect(within(row).getAllByRole('button').map((button) => button.getAttribute('aria-label')))
.toEqual([null, 'Open in separate window', 'Delete trace'])
})
expect(useTabStore.getState().activeTabId).toBe('__trace__session-trace-list')
it('opens a browser tab instead of a dead button when there is no desktop shell', async () => {
hostState.hasTraceHost = false
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
render(<TraceList />)
const row = await findTraceRow(/Debug stuck agent/)
fireEvent.click(within(row).getByRole('button', { name: 'Open in separate window' }))
expect(openTraceWindowMock).not.toHaveBeenCalled()
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('traceWindow=1'),
'_blank',
'noopener,noreferrer',
)
expect(openSpy.mock.calls[0]?.[0]).toContain('traceSessionId=session-trace-list')
openSpy.mockRestore()
})
it('requires confirmation before deleting a trace session', async () => {
+17 -25
View File
@@ -2,8 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import type { KeyboardEvent } from 'react'
import { ExternalLink, RefreshCw, Trash2, Workflow } from 'lucide-react'
import { tracesApi } from '../api/traces'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
import { useTranslation } from '../i18n'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
@@ -13,6 +11,8 @@ import { EmptyState } from '@/components/ui/EmptyState'
import { ErrorState } from '@/components/ui/ErrorState'
import { SearchField } from '@/components/ui/SearchField'
import { getDesktopHost } from '../lib/desktopHost'
import { buildTraceWindowUrl } from '../lib/traceLaunch'
import { openTraceCaptureSettings, openTraceDetail } from '../lib/traceNavigation'
import type { TraceSessionList, TraceSessionListItem } from '../types/trace'
type TraceListState =
@@ -159,7 +159,7 @@ export function TraceList() {
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button size="base" variant="secondary" onClick={() => openTraceSettings(t)}>
<Button size="base" variant="secondary" onClick={openTraceCaptureSettings}>
{t('trace.list.settings')}
</Button>
<Button size="base" variant="secondary" onClick={() => void load()}>
@@ -212,7 +212,14 @@ export function TraceList() {
silent: true,
})}
onOpenWindow={(sessionId) => {
if (host.trace) void host.trace.openWindow(sessionId)
// Matches TraceSession's own header button: outside the desktop
// shell there is no native window to ask for, so fall back to a
// browser tab rather than leaving the control dead.
if (host.trace) {
void host.trace.openWindow(sessionId)
return
}
window.open(buildTraceWindowUrl(sessionId), '_blank', 'noopener,noreferrer')
}}
onDelete={setDeleteTarget}
/>
@@ -309,12 +316,14 @@ function TraceRow({
const t = useTranslation()
const title = getTraceTitle(trace, t)
const updatedAt = trace.summary.updatedAt ?? trace.fileUpdatedAt
// The tab is titled with the session alone; the tab bar's trace glyph says
// what kind of tab it is, so a prefix here would only eat the visible width.
const open = () => openTraceDetail(trace.sessionId, title)
const failedCalls = trace.summary.failedCalls
const visibleModels = trace.summary.models.slice(0, MAX_MODEL_CHIPS)
const hiddenModels = trace.summary.models.length - visibleModels.length
const totalTokens = trace.summary.totalInputTokens + trace.summary.totalOutputTokens
const open = () => openTrace(trace.sessionId, title, t)
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
@@ -375,17 +384,9 @@ function TraceRow({
<MetricCell label={t('trace.tokens')} value={formatCompact(totalTokens)} />
</div>
</button>
<div className="flex w-[92px] shrink-0 items-center justify-end gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<IconButton
size="sm"
tone="secondary"
label={t('trace.open')}
icon={<Workflow className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />}
onClick={(event) => {
event.stopPropagation()
open()
}}
/>
{/* Opening the trace is what the row itself does — the row actions are
only for the two things a click cannot express. */}
<div className="flex w-[62px] shrink-0 items-center justify-end gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<IconButton
size="sm"
tone="secondary"
@@ -453,19 +454,10 @@ function TraceListSkeleton({ label }: { label: string }) {
)
}
function openTrace(sessionId: string, title: string, t: ReturnType<typeof useTranslation>) {
useTabStore.getState().openTraceTab(sessionId, `${t('trace.title')}: ${title}`)
}
function getTraceTitle(trace: TraceSessionListItem, t: ReturnType<typeof useTranslation>): string {
return trace.session?.title || t('session.untitled')
}
function openTraceSettings(t: ReturnType<typeof useTranslation>) {
useUIStore.getState().setPendingSettingsTab('general')
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
}
/** `claude-sonnet-4-5-20250929` -> `sonnet-4-5`; non-Claude ids pass through. */
function shortModelName(model: string): string {
const short = model.replace(/^claude-/i, '').replace(/-\d{8}$/, '')
+51
View File
@@ -684,4 +684,55 @@ describe('TraceSession', () => {
expect(await screen.findByRole('heading', { level: 1, name: 'Trace API title' })).toBeInTheDocument()
})
it('offers a way back to the list when opened as a tab', async () => {
const onBack = vi.fn()
render(<TraceSession sessionId={SESSION_ID} onBack={onBack} pollIntervalMs={60_000} />)
await screen.findByTestId('trace-split-layout')
const back = within(screen.getByTestId('trace-header')).getByRole('button', { name: 'Back to list' })
fireEvent.click(back)
expect(onBack).toHaveBeenCalledTimes(1)
})
it('keeps the way back available while the trace is still loading', async () => {
let releaseTrace: (value: TraceSessionData) => void = () => {}
vi.mocked(sessionsApi.getTrace).mockReturnValue(
new Promise<TraceSessionData>((resolve) => { releaseTrace = resolve }),
)
const onBack = vi.fn()
render(<TraceSession sessionId={SESSION_ID} onBack={onBack} pollIntervalMs={60_000} />)
expect(await screen.findByTestId('trace-skeleton')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Back to list' }))
expect(onBack).toHaveBeenCalledTimes(1)
releaseTrace(baseTrace)
await screen.findByTestId('trace-split-layout')
})
it('keeps the way back available when the trace fails to load', async () => {
// The failure state is exactly where being stranded hurts most: there is no
// content to act on, so the exit has to survive the error path.
vi.mocked(sessionsApi.getTrace).mockRejectedValue(new Error('boom'))
const onBack = vi.fn()
render(<TraceSession sessionId={SESSION_ID} onBack={onBack} pollIntervalMs={60_000} />)
expect(await screen.findByText('Failed to load trace')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Back to list' }))
expect(onBack).toHaveBeenCalledTimes(1)
})
it('hides the way back in the standalone window, which has no list behind it', async () => {
render(<TraceSession sessionId={SESSION_ID} standalone pollIntervalMs={60_000} />)
await screen.findByTestId('trace-split-layout')
expect(screen.queryByRole('button', { name: 'Back to list' })).not.toBeInTheDocument()
// The standalone window also drops "open in window" — it is already one.
expect(screen.queryByRole('button', { name: 'Open in separate window' })).not.toBeInTheDocument()
})
})
+32
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Copy,
ExternalLink,
@@ -44,10 +45,16 @@ const TRACE_POLL_INTERVAL_MS = 1500
export function TraceSession({
sessionId,
standalone = false,
onBack,
pollIntervalMs = TRACE_POLL_INTERVAL_MS,
}: {
sessionId: string
standalone?: boolean
/**
* Return to the trace list. Omitted in the standalone window, which has no
* list to go back to.
*/
onBack?: () => void
pollIntervalMs?: number
}) {
const t = useTranslation()
@@ -204,6 +211,7 @@ export function TraceSession({
sessionId={sessionId}
title={sessionTitle ?? t('session.untitled')}
standalone={standalone}
onBack={onBack}
onOpenWindow={openWindow}
onRefresh={refresh}
refreshing={refreshing}
@@ -221,6 +229,7 @@ export function TraceSession({
sessionId={sessionId}
title={sessionTitle ?? t('session.untitled')}
standalone={standalone}
onBack={onBack}
onOpenWindow={openWindow}
onRefresh={refresh}
refreshing={refreshing}
@@ -266,6 +275,7 @@ export function TraceSession({
trace={trace}
viewModel={viewModel}
standalone={standalone}
onBack={onBack}
onOpenWindow={openWindow}
onRefresh={refresh}
refreshing={refreshing}
@@ -317,6 +327,7 @@ function TraceHeader({
trace,
viewModel,
standalone,
onBack,
onOpenWindow,
onRefresh,
refreshing = false,
@@ -327,6 +338,7 @@ function TraceHeader({
trace?: TraceSessionData
viewModel?: TraceViewModel | null
standalone?: boolean
onBack?: () => void
onOpenWindow?: () => void
onRefresh?: () => void
refreshing?: boolean
@@ -342,6 +354,25 @@ function TraceHeader({
data-testid="trace-header"
>
<div className="flex min-w-0 items-center gap-3.5">
{/*
A trace tab is a drill-down, so it owes the reader a way back up. The
hairline separates this navigation affordance from the identity block
beside it, the way a breadcrumb separates from a page title.
*/}
{onBack ? (
<div className="flex shrink-0 items-center gap-3.5">
<Button
variant="ghost"
size="base"
onClick={onBack}
icon={<ArrowLeft size={15} strokeWidth={2} aria-hidden="true" />}
data-testid="trace-back"
>
{t('trace.backToList')}
</Button>
<span className="h-6 w-px bg-[var(--color-border)]" aria-hidden="true" />
</div>
) : null}
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)]">
<RadioTower size={17} strokeWidth={1.8} aria-hidden="true" />
</span>
@@ -350,6 +381,7 @@ function TraceHeader({
<h1
className="min-w-0 truncate text-[17px] font-bold tracking-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
title={title}
>
{title}
</h1>
+3 -1
View File
@@ -372,10 +372,12 @@ export const useTabStore = create<TabStore>((set, get) => ({
return { sessionId: PERSISTENT_SPECIAL_TAB_IDS[specialType], title: t.title, type: specialType, status: 'idle' as const }
}
if (t.type === 'trace' && t.traceSessionId) {
// Titled with the traced session, same as a freshly opened trace
// tab — the tab bar's glyph is what marks it as a trace.
const sourceTitle = sessions.find((s) => s.id === t.traceSessionId)?.title || t.title
return {
sessionId: `${TRACE_TAB_PREFIX}${t.traceSessionId}`,
title: sourceTitle === t.title ? t.title : `Trace: ${sourceTitle}`,
title: sourceTitle,
type: 'trace' as const,
status: 'idle' as const,
traceSessionId: t.traceSessionId,