From 16b54aa4c4012897144e851acf47776f5fa9d2e6 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 12:05:58 +0800 Subject: [PATCH] fix(web-ui): cwd-relative path summaries, sweep glare rework, uniform 16px chat rhythm Tool row summaries strip the session workspace root; the running sweep becomes a glare-band overlay (deepsuite ShimmerText pattern); assistant nodes that render nothing no longer split tool-row groups; block and tool-row spacing collapse to one 16px rhythm. --- .../src/client/chat/ChatView.module.css | 11 ++- .../src/client/chat/ChatView.tsx | 75 +++++++++++-------- .../src/client/chat/GenericToolCard.tsx | 4 +- .../src/client/chat/ToolRow.module.css | 41 +++++----- .../src/client/chat/chat-flow.ts | 11 +++ .../src/client/contract/slots.ts | 2 + .../src/client/contract/tool-call-model.ts | 13 +++- .../src/client/skeleton/EmptyHero.tsx | 2 +- .../src/client/skeleton/TodoPanel.module.css | 8 +- .../client/toolviews/bash-sample.module.css | 31 +++++--- .../tests/chat-tool-row.spec.tsx | 10 +++ .../ui-conversation/tests/chat-view.spec.tsx | 15 ++++ 12 files changed, 152 insertions(+), 71 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index f4ebce83db..fae96e5f6c 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -1,5 +1,6 @@ -/* Chat flow: block gap 16 between narration/bubbles/tool groups (figma); - tool rows inside a group gap 10. Input padding cap rides the skeleton. */ +/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool + runs) via the column gap and between consecutive tool rows via the group + gap. Input padding cap rides the skeleton. */ .root { position: relative; @@ -30,7 +31,7 @@ .toolGroup { display: flex; flex-direction: column; - gap: 10px; + gap: 16px; } .callRow { @@ -58,6 +59,10 @@ .turnDots { align-self: flex-start; flex: none; + display: flex; + align-items: center; + /* One message line box: the dots center inside the text line height. */ + height: 26px; /* Same pin as StateDot: ongoing blue has no alias token (business-primary is the 500 step, not this 450). */ color: var(--dsw-static-deepseek-450); diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 737f1f042f..deb7f09f6c 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -49,19 +49,20 @@ type UseConversation = SnapshotSelectorHook * top-level call (same registrations, same fallback), nested by the parent. * A started-but-unsettled sub-call arrives as the RunningToolCall shape and * renders the running state exactly as a native in-flight row. */ -const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: { +const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected, cwd }: { renderSlot: RenderToolRow node: CodeSubCall onOpenDetails: OpenDetails selected: boolean + cwd: string | undefined }) { const settled = 'kind' in node const toolName = settled ? node.call?.name ?? '' : node.name const seq = settled ? node.seq : node.time const owner = useMemo(() => ({ - callId: node.callId, toolName, block: node, + callId: node.callId, toolName, block: node, cwd, openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) }, - }), [node, toolName, seq, onOpenDetails]) + }), [node, toolName, seq, cwd, onOpenDetails]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -77,7 +78,9 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s * GenericToolCard at this render site. A `run_code` call additionally * renders its logged sub-dispatches as always-visible indented rows — * each one the same keyed-slot dispatch as a native top-level call. */ -const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: { +const CallRow = memo(function CallRow({ + renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId, cwd, +}: { renderSlot: RenderToolRow callId: string toolName: string @@ -91,11 +94,13 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq subCalls?: readonly CodeSubCall[] | undefined /** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */ selectedCallId?: string | undefined + /** Session workspace root for path-relative summaries. */ + cwd: string | undefined }) { const owner = useMemo(() => ({ - callId, toolName, block, + callId, toolName, block, cwd, openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) }, - }), [callId, toolName, block, seq, onOpenDetails]) + }), [callId, toolName, block, seq, cwd, onOpenDetails]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -111,6 +116,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq node={node} onOpenDetails={onOpenDetails} selected={node.callId === selectedCallId} + cwd={cwd} /> ))}
@@ -119,8 +125,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq ) }) -/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: { +/** Consecutive tool results as one step-run group (uniform 16px rhythm). */ +const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches, cwd }: { renderSlot: RenderToolRow results: readonly ToolResultNode[] onOpenDetails: OpenDetails @@ -128,6 +134,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId: string | undefined /** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */ codeDispatches: ReadonlyMap + /** Session workspace root for path-relative summaries. */ + cwd: string | undefined }) { return (
@@ -143,6 +151,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selected={node.callId === selectedCallId} subCalls={codeDispatches.get(node.callId)} selectedCallId={selectedCallId} + cwd={cwd} /> ))}
@@ -157,27 +166,29 @@ const LOADER_CELLS = [0, 5, 10, 15] as const function TurnDots() { return ( - + /* The wrapper is a 26px line box (message line height) so the loader + occupies one text line and centers the dots inside it. */ + ) } @@ -199,8 +210,10 @@ function StreamingTail({ useSession, onGrow }: { * The chat view slot entry: pure component over the composed props (tool rows * render through the declared keyed hole's renderSlot share). */ -export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { +export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openDetails, loadOlder }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) + // Workspace root off the session list row: path summaries display relative to it. + const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) const runningCalls = useSession(s => s.runningCalls) const codeDispatches = useSession(s => s.codeDispatches) @@ -301,6 +314,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl onOpenDetails={openDetails} selectedCallId={inGroup ? selectedCallId : undefined} codeDispatches={codeDispatches} + cwd={cwd} /> ) } @@ -342,6 +356,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl selected={call.callId === selectedCallId} subCalls={codeDispatches.get(call.callId)} selectedCallId={selectedCallId} + cwd={cwd} /> ))}
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 24b3b36a22..e5b5fb541b 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -25,8 +25,8 @@ const VARIANT_ICONS: Record = { others: , } -export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) { - const model = toolRowModel(toolName, block) +export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOwnerProps) { + const model = toolRowModel(toolName, block, cwd) return ( b.kind === 'tool-call' + || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes (surface order). @@ -21,6 +31,7 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem const items: ChatFlowItem[] = [] let group: ToolResultNode[] | null = null for (const node of nodes) { + if (rendersNothing(node)) continue if (node.kind === 'tool-result') { if (group === null) { group = [node] diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index c7c53aaafe..a40eb3cd7f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -143,6 +143,8 @@ export interface ToolRowOwnerProps { toolName: string /** Frozen call slice: the running call or the settled result node. */ block: ToolCallBlock + /** Session workspace root; path summaries display relative to it. */ + cwd?: string | undefined /** Open the details panel for this call (session-level facility, supplied by the view). */ openDetails: () => void } diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 5b725df00b..c7db85b0aa 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -101,6 +101,14 @@ const SUMMARY_KEYS: Record = { others: [], } +/** Strip the workspace root from workspace-rooted absolute paths (display only). */ +function relativizeToCwd(text: string, cwd: string | undefined): string { + if (cwd === undefined || cwd === '') return text + const root = cwd.replace(/[/\\]+$/, '') + if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1) + return text +} + function deriveSummary(variant: ToolRowVariant, argsRaw: string): string { const parsed = parseArgs(argsRaw) if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw) @@ -130,16 +138,17 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null { * Derive the full row model from a frozen call slice. * @param toolName - wire tool name (dispatch-supplied; survives windowless results). * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @param cwd - session workspace root; workspace-rooted path summaries display relative to it. * @returns the row model. */ -export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowModel { +export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel { const variant = classifyTool(toolName) const done = 'kind' in block const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? '' const state: ToolRowState = !done ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' - const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw) + const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd) const toolTitle = TOOL_TITLES[toolName] // Others keeps the static "Tool call" title (figma literal); the real tool // name rides the mutable summary slot unless the tool owns a specific title. diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index fe3ee28d32..7fbed0ee25 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -66,7 +66,7 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { * @param props.className - positioning class from the owner. * @returns the blurred-ellipse svg element. */ -export function HeroGlow({ className }: { className?: string }) { +export function HeroGlow({ className }: { className?: string | undefined }) { // Stable filter id so multiple hero mounts do not collide in the DOM. const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` return ( diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 8086cbfea7..c6b50a2c77 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -92,15 +92,15 @@ color: var(--dsw-alias-state-success-primary); } +.glyphPending { + color: var(--dsw-alias-label-caption); +} + .glyphProgress { color: var(--dsw-alias-state-business-primary); animation: todo-progress-spin 1s linear infinite; } -.glyphPending { - color: var(--dsw-alias-label-caption); -} - @keyframes todo-progress-spin { to { transform: rotate(360deg); diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 81123532d5..a79faf30f1 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,28 +1,37 @@ /* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ -/* Row sweep mask — same masked in-flight signal and exit-glide contract as - ToolRow (mask always mounted, band parked off-screen; running only adds - the animation; the transition finishes the sweep on exit). */ .root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; display: flex; align-items: center; height: 24px; min-width: 0; cursor: pointer; border-radius: 6px; - mask-image: linear-gradient(100deg, #000 30%, rgba(0, 0, 0, 0.35) 50%, #000 70%); - mask-size: 200% 100%; - mask-position: -50% 0; - transition: mask-position 1.6s ease-out; } -.root[data-state='running'] { - animation: dsh-bash-row-sweep 2.2s linear infinite; +/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-bash-row-sweep 2.6s ease-out infinite; + pointer-events: none; } @keyframes dsh-bash-row-sweep { - from { mask-position: 150% 0; } - to { mask-position: -50% 0; } + 0% { left: -300px; } + 90%, 100% { left: 100%; } } .leading { diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 4425e98cb3..63f2da6586 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -64,6 +64,16 @@ describe('tool-call-model', () => { expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1') }) + it('displays workspace-rooted paths relative to the session cwd', () => { + const cwd = '/Users/u/ws/' + expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts') + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), cwd).summary).toBe('a.md') + // Paths outside the workspace (and non-path summaries) stay verbatim. + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/etc/hosts"}' }), cwd).summary).toBe('/etc/hosts') + expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' }), cwd).summary).toBe('pwd') + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md') + }) + it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => { expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}') expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw') diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 20389c9e23..f9028bd386 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -131,6 +131,21 @@ describe('chat-flow derivation', () => { expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6') expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6') }) + + it('skips render-nothing assistant nodes so tool runs stay one group', () => { + // A tool-call-only step message (and blank text/reasoning) renders nothing: + // it must not split the run into two groups with an empty line between. + const headsOnly: AssistantMessageNode = { + kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2, + blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }], + } + const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')]) + expect(flowKeys(items)).toBe('g3') + expect(items[0]!.kind === 'tool-group' && items[0].results.map(r => r.callId)).toEqual(['a', 'b']) + // Interrupted and visible-content nodes still render (已停止 marker / prose). + expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5') + expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5') + }) }) describe('ChatView', () => {