mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
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:
@@ -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')
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 呼び出しはまだありません',
|
||||
|
||||
@@ -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 호출이 없습니다',
|
||||
|
||||
@@ -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 呼叫',
|
||||
|
||||
@@ -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 调用',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}$/, '')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user