diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 0b1cbf8030..69d9d620e3 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -100,17 +100,12 @@ } .table tbody tr[data-request-only='true'] td { - height: 1px; + height: 0; padding-top: 0; padding-bottom: 0; border-bottom: 0; } -.table tbody tr[data-request-only='true']:has(+ tr[data-request-only='true']) td { - /* Keep consecutive boundary markers from painting their halos over one another. */ - height: 9px; -} - .table tbody tr[data-request-only='true']:last-child td { /* Retain the lower half of the 16px boundary marker at the table's end. */ height: 9px; @@ -130,10 +125,12 @@ } .requestBoundaryControl { + --request-boundary-base-left: 12px; + position: absolute; z-index: 6; top: -8px; - left: 12px; + left: calc(var(--request-boundary-base-left) + var(--request-boundary-offset, 0px)); width: 16px; height: 16px; padding: 0; @@ -198,6 +195,12 @@ box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color); } +.requestBoundaryControl[data-request-status='error']::before, +.requestBoundaryControl[data-request-status='error']:hover::before, +.requestBoundaryControl[data-request-status='error']:focus-visible::before { + background: var(--dsw-alias-state-error-primary); +} + .requestBoundaryControl:hover::after, .requestBoundaryControl:focus-visible::after { opacity: 1; @@ -402,7 +405,7 @@ } .requestBoundaryControl { - left: 6px; + --request-boundary-base-left: 6px; } .kindSlot { @@ -1245,9 +1248,19 @@ background: transparent; cursor: pointer; font: 600 12px/18px var(--dsw-font-family); + gap: 2px; user-select: none; } +.thinkingChevron { + flex: none; + transition: transform 120ms var(--ds-ease-in-out); +} + +.thinkingToggle[aria-expanded='true'] .thinkingChevron { + transform: rotate(90deg); +} + .thinkingToggle:hover { color: var(--dsw-alias-label-secondary); } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 7973649cf5..6f37c852c5 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -191,6 +191,10 @@ type TrajectorySplitStyle = CSSProperties & { '--trajectory-tool-request-width': string } +type RequestBoundaryStyle = CSSProperties & { + '--request-boundary-offset': string +} + function clampDetailsWidth(width: number, splitWidth: number): number { const maxWidth = Math.max( DETAILS_MIN_WIDTH, @@ -453,6 +457,22 @@ function indexRequestNumbers( return numbers } +function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap { + const indexes = new Map() + let runLength = 0 + for (const record of records) { + if (record.cell.requestOnly === true) { + indexes.set(record.cell.index, runLength++) + continue + } + if (runLength > 0 && record.groupStart && requestStep(record.group) !== undefined) { + indexes.set(record.cell.index, runLength) + } + runLength = 0 + } + return indexes +} + function summarizeTurn(records: readonly TableRecord[]): string { const steps = new Set( records @@ -1212,7 +1232,8 @@ function MarkdownRecordContent({ aria-expanded={thinkingExpanded} onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }} > - {thinkingExpanded ? 'Thinking' : 'Thinking ...'} + Thinking + {thinkingExpanded && ( record.cell.index === selectedIndex) const selectedPrompt = selected?.cell.kind === 'system' ? selected.cell.promptDetail @@ -1791,6 +1813,12 @@ export function TrajectoryTable({ const requestInfo = request === undefined ? undefined : sessionRequestNumbers?.find(candidate => candidate.number === request) + const requestStatus = requestInfo?.status + ?? (record.cell.isError === true ? 'error' : undefined) + const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0 + const requestBoundaryStyle: RequestBoundaryStyle = { + '--request-boundary-offset': `${requestRunIndex * 8}px`, + } const requestLabel = request === undefined ? undefined : `Request #${request}${requestInfo?.purpose === 'compaction' ? ' ยท Compaction' : ''}` @@ -1882,6 +1910,9 @@ export function TrajectoryTable({ aria-label={requestLabel} aria-pressed={requestSelected} data-label={requestLabel} + data-request-run-index={requestRunIndex} + data-request-status={requestStatus} + style={requestBoundaryStyle} onClick={(event) => { event.stopPropagation() selectRequest({ @@ -1930,36 +1961,36 @@ export function TrajectoryTable({ - - - - {KIND_LABEL[record.cell.kind]} - + + + {KIND_LABEL[record.cell.kind]} - + )} @@ -2497,7 +2528,7 @@ export function TrajectoryTable({ )} {selectedAssistantRequestTarget !== undefined && ( { selectRequest(selectedAssistantRequestTarget, 'timing') }} diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index 65c3da3255..b9f5e5d7b7 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -64,7 +64,7 @@ describe('TrajectoryTable', () => { it('shows assistant timing facts after keyboard selection', () => { render() fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' }) - fireEvent.click(screen.getByRole('button', { name: 'Timing' })) + fireEvent.click(screen.getByRole('button', { name: 'Request Timing' })) expect(screen.getByText('500 ms')).toBeTruthy() expect(screen.getByText('1.00 s')).toBeTruthy() @@ -101,10 +101,13 @@ describe('TrajectoryTable', () => { render() fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) - const toggle = screen.getByRole('button', { name: 'Thinking ...' }) + const toggle = screen.getByRole('button', { name: 'Thinking' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText(thinking)).toBeNull() fireEvent.click(toggle) + expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length) }) @@ -222,19 +225,79 @@ describe('TrajectoryTable', () => { expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy() }) - it('renders responsive role icons with a custom tooltip', () => { + it('marks failed requests and lays coincident request markers left to right', () => { + const turns: readonly TrajectoryTurnModel[] = [ + { + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'message', + text: '', + requestOnly: true, + isError: true, + timeSeconds: 0.1, + }], + }], + }, + { + turn: 2, + groups: [{ + title: 'Step 1', + cells: [{ + index: 2, + kind: 'message', + text: '', + requestOnly: true, + isError: true, + timeSeconds: 0.1, + }], + }], + }, + { + turn: 3, + groups: [{ + title: 'Step 1', + cells: [{ + index: 3, + kind: 'message', + text: 'Recovered response', + timeSeconds: 0.1, + }], + }], + }, + ] + render() + + const failed = screen.getByRole('button', { name: 'Request #1' }) + const retry = screen.getByRole('button', { name: 'Request #2' }) + const recovered = screen.getByRole('button', { name: 'Request #3' }) + expect(failed.getAttribute('data-request-status')).toBe('error') + expect(failed.getAttribute('data-request-run-index')).toBe('0') + expect(failed.style.getPropertyValue('--request-boundary-offset')).toBe('0px') + expect(retry.getAttribute('data-request-run-index')).toBe('1') + expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px') + expect(recovered.getAttribute('data-request-run-index')).toBe('2') + expect(recovered.style.getPropertyValue('--request-boundary-offset')).toBe('16px') + }) + + it('shows the custom role tooltip only from the responsive icon', () => { const view = render() const toolTag = view.container.querySelector('[data-role-kind="tool"]') + const toolIcon = toolTag?.querySelector('[data-role-icon="wrench"]') expect(toolTag).not.toBeNull() expect(toolTag?.getAttribute('title')).toBeNull() - expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy() + expect(toolIcon).toBeTruthy() fireEvent.mouseEnter(toolTag as HTMLElement) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseEnter(toolIcon as HTMLElement) const tooltip = screen.getByRole('tooltip') expect(tooltip.textContent).toBe('TOOL') expect(tooltip.getAttribute('data-side')).toBe('right') - fireEvent.mouseLeave(toolTag as HTMLElement) + fireEvent.mouseLeave(toolIcon as HTMLElement) expect(screen.queryByRole('tooltip')).toBeNull() })