From a533cb6ce4e4d098b1b5eeec24766c399a3ab9a6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 15:01:35 +0800 Subject: [PATCH] fix(ui-trajectory): distinguish overlapping request markers --- .../src/client/TrajectoryTable.module.css | 19 +++++---- .../src/client/TrajectoryTable.tsx | 31 ++++++++++++++ .../client/ui-trajectory/tests/table.spec.tsx | 41 +++++++++++++++++++ 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 0b1cbf8030..40f4791d45 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 { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 7973649cf5..13dfee2606 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,23 @@ function indexRequestNumbers( return numbers } +function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap { + const indexes = new Map() + let previous: TableRecord | undefined + let runIndex = 0 + for (const record of records) { + if (record.cell.requestOnly !== true) { + previous = record + runIndex = 0 + continue + } + runIndex = previous?.cell.requestOnly === true ? runIndex + 1 : 0 + indexes.set(record.cell.index, runIndex) + previous = record + } + return indexes +} + function summarizeTurn(records: readonly TableRecord[]): string { const steps = new Set( records @@ -1545,6 +1566,7 @@ export function TrajectoryTable({ collapsedAssistants, ) : filterRecords(allRecords, searchMatchIndexes) + const requestBoundaryRuns = indexRequestBoundaryRuns(records) const selected = allRecords.find(record => 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({ diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index 65c3da3255..aaea399a83 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -222,6 +222,47 @@ describe('TrajectoryTable', () => { expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy() }) + it('marks failed requests and lays coincident request markers left to right', () => { + const turns: readonly TrajectoryTurnModel[] = [ + { + turn: null, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'message', + text: '', + requestOnly: true, + isError: true, + timeSeconds: 0.1, + }], + }], + }, + { + turn: null, + groups: [{ + title: 'Step 2', + cells: [{ + index: 2, + kind: 'message', + text: '', + requestOnly: true, + timeSeconds: 0.1, + }], + }], + }, + ] + render() + + const failed = screen.getByRole('button', { name: 'Request #1' }) + const retry = screen.getByRole('button', { name: 'Request #2' }) + 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') + }) + it('renders responsive role icons with a custom tooltip', () => { const view = render() const toolTag = view.container.querySelector('[data-role-kind="tool"]')