feat(ui): compact trajectory role labels

This commit is contained in:
_Kerman
2026-07-30 17:11:07 +08:00
parent 997932ffd6
commit 411bbdf2aa
3 changed files with 216 additions and 20 deletions
@@ -16,6 +16,7 @@
flex: 1;
min-width: 0;
overflow: auto;
container: trajectory-table / inline-size;
}
.table {
@@ -279,6 +280,10 @@
white-space: nowrap;
}
.turnLabelCompact {
display: none;
}
.turnLabelActive {
color: color-mix(
in srgb,
@@ -325,11 +330,66 @@
user-select: none;
}
.kindTagIcon {
display: none;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
}
.kindTagLabel {
display: inline;
}
.table .kindSlot .message {
justify-content: center;
width: 100%;
}
@container trajectory-table (max-width: 620px) {
.eventColumn {
width: 50px;
}
.event {
padding-right: 3px !important;
padding-left: 28px !important;
}
.requestBoundaryControl {
left: 6px;
}
.kindSlot {
width: 19px;
}
.kindTag,
.table .kindSlot .message {
justify-content: center;
width: 19px;
padding-right: 0;
padding-left: 0;
}
.kindTagIcon {
display: inline-flex;
}
.kindTagLabel {
display: none;
}
.turnLabelFull {
display: none;
}
.turnLabelCompact {
display: inline;
}
}
.user {
color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-state-business-tertiary);
@@ -3,7 +3,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import {
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
extractMarkdownPlainText,
IconChevronRightOutline14,
IconSettingsOutline16,
IconSparkle16,
IconUserOutline16,
JsonTree,
MarkdownText,
Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { structuredPatch } from 'diff'
import type {
@@ -26,6 +33,77 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
subtool: 'SUBTOOL',
}
function ToolWrenchIcon(): ReactNode {
return (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
data-role-icon="wrench"
aria-hidden="true"
>
<path d="M14 3.3a3.8 3.8 0 0 1-4.8 4.8l-5.1 5.1a1.6 1.6 0 1 1-2.3-2.3l5.1-5.1A3.8 3.8 0 0 1 11.7 1l-2.3 2.3 2.3 2.3L14 3.3Z" />
</svg>
)
}
function InformationIcon(): ReactNode {
return (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
data-role-icon="information"
aria-hidden="true"
>
<circle cx="8" cy="8" r="5.6" />
<circle cx="8" cy="5.5" r=".85" fill="currentColor" stroke="none" />
<path d="M8 7.75v3.4" strokeWidth="1.8" />
</svg>
)
}
function CompactedIcon(): ReactNode {
return (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
data-role-icon="compacted"
aria-hidden="true"
>
<path d="m2.5 2.5 3.75 3.75M3 6.25h3.25V3" />
<path d="m13.5 2.5-3.75 3.75M13 6.25H9.75V3" />
<path d="m2.5 13.5 3.75-3.75M3 9.75h3.25V13" />
<path d="m13.5 13.5-3.75-3.75M13 9.75H9.75V13" />
</svg>
)
}
const KIND_ICON: Record<TrajectoryCellKind, ReactNode> = {
system: <IconSettingsOutline16 size={13} />,
user: <IconUserOutline16 size={13} />,
context: <InformationIcon />,
compacted: <CompactedIcon />,
message: <IconSparkle16 size={13} />,
tool: <ToolWrenchIcon />,
subtool: <ToolWrenchIcon />,
}
interface TableRecord {
turn: number
group: string
@@ -1726,8 +1804,14 @@ export function TrajectoryTable({
className={activeTurn === record.turn
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
aria-label={`Turn ${record.turn}`}
>
Turn {record.turn}
<span className={css.turnLabelFull} aria-hidden="true">
Turn {record.turn}
</span>
<span className={css.turnLabelCompact} aria-hidden="true">
#{record.turn}
</span>
</span>
)}
<div className={css.eventInner}>
@@ -1735,24 +1819,33 @@ export function TrajectoryTable({
<span
className={css.kindSlot}
>
<span className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
>
{KIND_LABEL[record.cell.kind]}
</span>
<Tooltip label={KIND_LABEL[record.cell.kind]} side="bottom">
<span
className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
data-role-kind={record.cell.kind}
>
<span className={css.kindTagIcon} aria-hidden="true">
{KIND_ICON[record.cell.kind]}
</span>
<span className={css.kindTagLabel}>
{KIND_LABEL[record.cell.kind]}
</span>
</span>
</Tooltip>
</span>
)}
</div>
@@ -148,6 +148,49 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy()
})
it('renders responsive role icons with a custom tooltip', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
expect(toolTag).not.toBeNull()
expect(toolTag?.getAttribute('title')).toBeNull()
expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
fireEvent.mouseEnter(toolTag as HTMLElement)
expect(screen.getByRole('tooltip').textContent).toBe('TOOL')
fireEvent.mouseLeave(toolTag as HTMLElement)
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('uses information and compression glyphs for injected and compacted context', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Context',
cells: [
{ index: 1, kind: 'context', text: 'Workspace context', timeSeconds: 0 },
{ index: 2, kind: 'compacted', text: 'Compacted history', timeSeconds: 0 },
],
}],
}]
const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
expect(view.container.querySelector(
'[data-role-kind="context"] [data-role-icon="information"]',
)).toBeTruthy()
expect(view.container.querySelector(
'[data-role-kind="compacted"] [data-role-icon="compacted"]',
)).toBeTruthy()
})
it('keeps a compact turn label available for narrow layouts', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const turnLabel = screen.getByLabelText('Turn 1')
expect(turnLabel.textContent).toContain('Turn 1')
expect(turnLabel.textContent).toContain('#1')
})
it('renders a single-text JSON tool result as a JSON tree', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,