+ )
+ }
+
+ // Portal lists render hidden until placed: the placement effect measures
+ // this pre-render in the same commit, so the first painted frame is
+ // already at the final position (with getAnchorRect returning null the
+ // list simply stays hidden).
+ const list = open && (
{ onClose() } : undefined}
// React portals bubble synthetic events through the REACT tree: without
@@ -158,63 +255,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
// (open/toggle) after onSelect.
onClick={(e) => { e.stopPropagation() }}
>
- {items.map((entry) => {
- if (isSeparator(entry)) {
- return
- }
- if (isLabel(entry)) {
- return
{/* Mounted for every real session, hero included: ConversationSession
renders no chrome while blank but owns the draft-persistence mirror
bind — unmounting it in the hero would lose pre-first-send text on
diff --git a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx
index 118baf5ac9..bfaa0b8d02 100644
--- a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx
@@ -29,7 +29,7 @@ export function DisabledInputBar() {
diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
index ca64fd7f44..fe3ee28d32 100644
--- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
@@ -59,6 +59,40 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
)
}
+/**
+ * The soft blue backdrop ellipse (figma 313:14109). Rendered by the hero
+ * owner (ConversationRoot), not HeroShell, so it can center on the input
+ * card; the owner's className supplies all positioning.
+ * @param props.className - positioning class from the owner.
+ * @returns the blurred-ellipse svg element.
+ */
+export function HeroGlow({ className }: { className?: string }) {
+ // Stable filter id so multiple hero mounts do not collide in the DOM.
+ const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
+ return (
+
+ )
+}
+
/** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */
export interface HeroShellProps {
/** Overlay content after the stack (modals). */
@@ -66,13 +100,12 @@ export interface HeroShellProps {
}
/**
- * Render the hero chrome (headline + glow; no composer, no workspace row).
+ * Render the hero chrome (headline only; no glow, no composer, no workspace
+ * row — the glow is the owner's {@link HeroGlow}).
* @param props - see {@link HeroShellProps}.
* @returns the centered hero element tree.
*/
export function HeroShell({ children }: HeroShellProps) {
- // Stable filter id so multiple hero mounts do not collide in the DOM.
- const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
return (
@@ -82,29 +115,6 @@ export function HeroShell({ children }: HeroShellProps) {
Let's start building
- {/* figma 313:14109: soft ellipse behind workspace + composer; width
- tracks the card (glow asset 1051 vs design card 776) so blur
- scales in userSpace with it. */}
-
{/* The resident composer (rendered by ConversationRoot at its stable
tree position; the workspace row rides its accessory hole) is
CSS-positioned into this gap during the hero phase — see
diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css
index 6bc6af5fea..71b90bfdfa 100644
--- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css
+++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css
@@ -41,8 +41,9 @@
color: var(--dsw-alias-state-business-primary);
}
-/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is
- centered on this block so it stays under the picker + InputBar together. */
+/* Workspace row sits 12px above the input card (figma y80 → y112). The blue
+ glow lives with the owner (ConversationRoot .heroGlow) so it can center on
+ the input card. */
.body {
position: relative;
display: flex;
@@ -52,19 +53,7 @@
overflow: visible;
}
-/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */
-.glow {
- position: absolute;
- left: 50%;
- top: 50%;
- z-index: 0;
- width: calc(100% * 1051 / 776);
- aspect-ratio: 1051 / 468;
- transform: translate(-50%, -50%);
- pointer-events: none;
-}
-
-.body > :not(.glow) {
+.body > * {
position: relative;
z-index: 1;
}
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
index 7b2711da39..0cb3920f6e 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
@@ -171,6 +171,10 @@
.input,
.mirror,
.backdrop {
+ /* Textareas default to content-box (unlike buttons/inputs): without this the
+ width:100% textarea gains its padding OUTSIDE the card and text runs past
+ the right padding — and wraps 28px later than the mirror/backdrop layers. */
+ box-sizing: border-box;
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these
metrics or the highlight ranges drift off the glyphs. */
padding: 4px 12px 0 16px;
@@ -306,11 +310,14 @@
border: none;
border-radius: 999px;
background: var(--dsw-alias-button-info-fill);
- color: var(--dsw-alias-label-primary-foreground);
+ /* Static white, not the foreground token: the arrow stays white on the blue
+ fill in both themes (design 34:10465). */
+ color: #fff;
cursor: pointer;
+ transition: background-color 100ms ease;
}
-.primary:hover {
+.primary:hover:not(:disabled) {
background: var(--dsw-alias-button-info-hover);
}
@@ -319,14 +326,6 @@
cursor: default;
}
-/* Stop state: same slot, dimmed brand fill — the running-state send-key
- replacement is a design gap filled by us (figma gives no stop form). */
-.stopping,
-.stopping:hover {
- background: var(--dsw-alias-button-primary-dimmed);
- color: var(--dsw-alias-label-primary);
-}
-
.retry {
margin-left: 8px;
padding: 1px 8px;
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
index f2313d798b..f72103ffc0 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
@@ -374,7 +374,7 @@ export function InputBar({
{machineBusy && }
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 84224c9979..81123532d5 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,5 +1,8 @@
/* 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 {
display: flex;
align-items: center;
@@ -7,6 +10,19 @@
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;
+}
+
+@keyframes dsh-bash-row-sweep {
+ from { mask-position: 150% 0; }
+ to { mask-position: -50% 0; }
}
.leading {
diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx
index dc4dd6b367..fbb1104eca 100644
--- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx
+++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx
@@ -12,9 +12,9 @@ import css from './bash-sample.module.css'
function leadingFor(state: ToolRowState) {
switch (state) {
- case 'running': return
case 'error': return
case 'stopped': return
+ // Running keeps the icon — the row sweep carries the in-flight signal.
default: return
}
}
diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
index aa9451b413..798f5c52bc 100644
--- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
@@ -251,7 +251,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const view = mountApp(b.slots)
// The nested row derives 'running' from the RunningToolCall shape — the
- // same StateDot ring a native in-flight row wears.
+ // same data-state chrome (row sweep) a native in-flight row wears.
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]')
expect(nested).not.toBeNull()
})
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 13a74548b4..4425e98cb3 100644
--- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
@@ -125,12 +125,12 @@ describe('ToolRow', () => {
expect(view.getByText('List files')).toBeTruthy()
})
- it('running and error states replace the icon with a StateDot', () => {
+ it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => {
const runningView = render()
- expect(runningView.queryByTestId('tool-icon')).toBeNull()
+ expect(runningView.queryByTestId('tool-icon')).not.toBeNull()
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
const errorView = render()
- expect(errorView.queryByTestId('tool-icon')).toBeNull()
+ expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
})
it('non-expandable rows render a passive leading slot', () => {
diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx
index 136901bd6d..86bb99246d 100644
--- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx
+++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx
@@ -76,7 +76,7 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
- it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => {
+ it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped (root session arm)', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore({
ids: [sid],
diff --git a/packages/client/ui-primitives/src/StateDot.module.css b/packages/client/ui-primitives/src/StateDot.module.css
index e5a8ebdb50..e9442f46bb 100644
--- a/packages/client/ui-primitives/src/StateDot.module.css
+++ b/packages/client/ui-primitives/src/StateDot.module.css
@@ -1,7 +1,7 @@
/* Ongoing blue has no alias token (state-business-primary is the 500 step,
* not this 450) — component-level var pinned to the static scale instead. */
.dot,
-.ring {
+.matrix {
--dsh-state-ongoing: var(--dsw-static-deepseek-450);
}
@@ -42,24 +42,24 @@
color: var(--dsw-alias-state-error-primary);
}
-.ring {
+/* Pixel chase: each outer cell holds a discrete brightness step (flat keyframe
+ * holds, no tweening — the retro feel), peaking when the chase hits it and
+ * decaying over the next three cells. Phase offsets come from per-rect
+ * animation-delay (index * -125ms) set inline by the component. */
+.matrix {
flex: none;
color: var(--dsh-state-ongoing);
- animation: dsh-state-dot-spin 1s linear infinite;
}
-.stopFrom {
- stop-color: currentColor;
- stop-opacity: 1;
+.cell {
+ fill: currentColor;
+ opacity: 0.15;
+ animation: dsh-state-dot-chase 1s infinite;
}
-.stopTo {
- stop-color: currentColor;
- stop-opacity: 0;
-}
-
-@keyframes dsh-state-dot-spin {
- to {
- transform: rotate(360deg);
- }
+@keyframes dsh-state-dot-chase {
+ 0%, 12.4% { opacity: 1; }
+ 12.5%, 24.9% { opacity: 0.6; }
+ 25%, 37.4% { opacity: 0.35; }
+ 37.5%, 100% { opacity: 0.15; }
}
diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx
index c4117673ba..643f9283f4 100644
--- a/packages/client/ui-primitives/src/StateDot.tsx
+++ b/packages/client/ui-primitives/src/StateDot.tsx
@@ -1,15 +1,19 @@
// StateDot: session state indicator (figma nodes 14:3303/3305/3312, 122:9182).
// done/warning/error: 10x10 halo (same color, 10% opacity) around a 6x6 solid
-// core. ongoing: 10x10 ring, 1px inside stroke, color fading out along a
-// linear gradient, spinning. Colors resolve through --dsw-* tokens only.
+// core. ongoing: a pixel-art chase — the 8 outer cells of a 3x3 matrix light
+// up clockwise with a stepped trail. Colors resolve through --dsw-* tokens only.
-import { useId } from 'react'
import clsx from 'clsx'
import css from './StateDot.module.css'
/** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */
export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error'
+/** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */
+const MATRIX_CELLS: readonly (readonly [number, number])[] = [
+ [0, 0], [4, 0], [8, 0], [8, 4], [8, 8], [4, 8], [0, 8], [0, 4],
+]
+
/**
* Render a state dot.
* @param props.state - which of the four states to show.
@@ -22,25 +26,29 @@ export function StateDot({ state, size = 10, className }: {
size?: number
className?: string
}) {
- const gradientId = useId()
if (state === 'ongoing') {
return (
)
}
diff --git a/packages/client/ui-primitives/tests/state-dot.spec.tsx b/packages/client/ui-primitives/tests/state-dot.spec.tsx
index a3759174ff..86e41c7787 100644
--- a/packages/client/ui-primitives/tests/state-dot.spec.tsx
+++ b/packages/client/ui-primitives/tests/state-dot.spec.tsx
@@ -14,16 +14,17 @@ describe('StateDot', () => {
expect(dot.getAttribute('aria-hidden')).toBe('true')
})
- it('solid states are spans; ongoing is an svg gradient ring', () => {
+ it('solid states are spans; ongoing is an svg pixel matrix', () => {
const { container, rerender } = render()
expect(container.firstElementChild?.tagName).toBe('SPAN')
rerender()
- const ring = container.firstElementChild as SVGSVGElement
- expect(ring.tagName).toBe('svg')
- const circle = ring.querySelector('circle')
- expect(circle?.getAttribute('stroke-width')).toBe('1')
- expect(circle?.getAttribute('stroke')).toMatch(/^url\(#/)
- expect(ring.querySelector('linearGradient')).not.toBeNull()
+ const matrix = container.firstElementChild as SVGSVGElement
+ expect(matrix.tagName).toBe('svg')
+ const cells = matrix.querySelectorAll('rect')
+ expect(cells).toHaveLength(8)
+ // Chase phase: every cell carries its own negative animation delay.
+ const delays = [...cells].map(cell => (cell).style.animationDelay)
+ expect(new Set(delays).size).toBe(8)
})
it('sizes via the size prop in both shapes', () => {
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 4/8] 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 (
@@ -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', () => {
From 0a625b1144bd5affebda2dcd486d5b73f1fca4cf Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Tue, 28 Jul 2026 12:18:18 +0800
Subject: [PATCH 5/8] fix(web-ui): header title 14/20, drop turns counter,
global grayscale antialiasing
---
.../src/client/skeleton/ConversationRoot.module.css | 11 ++---------
.../src/client/skeleton/ConversationSession.tsx | 8 --------
packages/client/web/src/base.css | 4 ++++
3 files changed, 6 insertions(+), 17 deletions(-)
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
index 6c18b8c1a5..de974da86c 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
@@ -54,8 +54,8 @@
border: none;
border-radius: 12px;
background: transparent;
- font-size: 13px;
- line-height: 16px;
+ font-size: 14px;
+ line-height: 20px;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
@@ -72,13 +72,6 @@
cursor: default;
}
-.meta {
- margin-left: 4px;
- font-size: 12px;
- line-height: 18px;
- color: var(--dsw-alias-label-tertiary);
-}
-
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
.tabs {
display: flex;
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
index 45d98c1693..0c1addcba7 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
@@ -31,7 +31,6 @@ export function ConversationSession({
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
- const turns = useSession(s => countTurns(s))
const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)
@@ -69,7 +68,6 @@ export function ConversationSession({
)
})}
{ancestry.length === 0 && {sessionId}}
- · {turns} turns
{tabs.length > 1 && (
@@ -95,9 +93,3 @@ export function ConversationSession({
>
)
}
-
-function countTurns(snapshot: { nodes: readonly { kind: string }[] }): number {
- let count = 0
- for (const node of snapshot.nodes) if (node.kind === 'user') count += 1
- return count
-}
diff --git a/packages/client/web/src/base.css b/packages/client/web/src/base.css
index b8449634eb..03593fa829 100644
--- a/packages/client/web/src/base.css
+++ b/packages/client/web/src/base.css
@@ -15,6 +15,10 @@ body,
body {
font-family: var(--dsw-font-family);
+ /* Grayscale antialiasing over subpixel rendering: WebKit/Blink and the
+ Firefox macOS equivalent; other engines ignore both lines. */
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-base);
}
From 8eb1a6f66b941f4025115a4727ce3c1875afab20 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Tue, 28 Jul 2026 12:25:32 +0800
Subject: [PATCH 6/8] fix(web-ui): narrow flow item before reading group
results in chat-view spec
---
packages/client/ui-conversation/tests/chat-view.spec.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx
index f9028bd386..1754397eba 100644
--- a/packages/client/ui-conversation/tests/chat-view.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx
@@ -141,7 +141,8 @@ describe('chat-flow derivation', () => {
}
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'])
+ const group = items[0]!
+ expect(group.kind === 'tool-group' && group.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')
From 3134f2d4301b120ad20bc7581f25a74d923f3ee6 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Tue, 28 Jul 2026 12:36:29 +0800
Subject: [PATCH 7/8] doc: agent note for the web conversation polish sweep
---
...28-web-conversation-polish-sweep.i18n.yaml | 6 +++
...026-07-28-web-conversation-polish-sweep.md | 37 +++++++++++++++++++
...-07-28-web-conversation-polish-sweep.zh.md | 37 +++++++++++++++++++
3 files changed, 80 insertions(+)
create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml
create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md
create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md
diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml
new file mode 100644
index 0000000000..3df46dedf2
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md
+2026-07-28-web-conversation-polish-sweep.md: cae52217d66017509c025a5d8d37b1e1e8173c6a
+2026-07-28-web-conversation-polish-sweep.zh.md: d19a5f75937e9ae9f553b2c941594db118fa434e
diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md
new file mode 100644
index 0000000000..cae52217d6
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md
@@ -0,0 +1,37 @@
+# Agent Note: Web conversation UI polish sweep
+
+Status: implemented
+
+English | [中文](2026-07-28-web-conversation-polish-sweep.zh.md)
+
+## Problem
+
+A design review of the web GUI's conversation surfaces found a batch of presentation defects: portal menus painted one frame at the wrong position before repositioning (visible open jump), the chat column split one tool run into several groups whenever a step message carried only tool-call heads, tool row summaries printed workspace-absolute paths that consumed most of the row, the running-row sweep was implemented as an alpha mask that dimmed the whole row, the hero workspace chip resurrected a deleted workspace's folder name from the session cwd, and the header showed a turns counter nobody asked for next to a 13px title.
+
+## Decision
+
+The sweep lands as presentation-layer changes only; nothing enters the session log.
+
+- **Portal menus pre-render hidden and measure before paint.** The menu list mounts with `visibility: hidden` at (0,0), measures in `useLayoutEffect`, and becomes visible already at its final position. Menus keep 12px viewport clearance with internal scroll; workspace create actions pin in a non-scrolling footer.
+- **The chat flow skips assistant nodes that render nothing.** A finalized assistant node whose blocks are only tool-call heads and blank text/reasoning is dropped from the flow derivation, so consecutive tool results merge into one group. Interrupted nodes always render (they carry the 已停止 marker).
+- **Tool row summaries relativize workspace-rooted paths.** The session cwd threads through the toolview slot contract (`ToolRowOwnerProps.cwd`) and `toolRowModel` strips it from summaries that start with it; paths outside the workspace stay verbatim. Display-only — args and the log are untouched.
+- **The running sweep is a glare-band overlay.** A fixed-width `::after` gradient band animates across the row (the deepsuite ShimmerText pattern), replacing the previous `mask-image` approach, in both ToolRow and the Bash toolview.
+- **The hero workspace chip is a selector, not an echo.** With no live selection (cold start, or the workspace was deleted after the list settled) it shows a "Choose workspace" placeholder; the cwd-derived name only bridges the initial list load, and stale pending picks clear when their workspace leaves a ready list.
+- **One 16px vertical rhythm.** The chat column gap and in-group tool-row gap are both 16px, replacing the 10px in-group gap plus a negative cross-group margin.
+- **Header title reads 14/20 with no turns counter**; StateDot ongoing and the turn tail use a stepped pixel-chase loading language; `body` gets grayscale antialiasing (`-webkit-font-smoothing` and the Firefox macOS equivalent).
+
+## Alternatives considered
+
+- **Position menus synchronously from anchor rects before mount.** Rejected: the list's own size is unknown until it lays out, so clamping to the viewport still needs a post-layout measure; measuring a hidden mounted node is the pattern React and Floating UI document.
+- **Filter empty assistant messages host-side.** Rejected: the node is real model output that Trajectory and replay must keep; only the chat presentation should skip it, and the web layer is pure presentation by contract.
+- **Relativize paths in each tool's presenter.** Rejected: the redundancy is shared by every path-summarizing tool; one display-only pass in `toolRowModel` covers them all and non-chat consumers keep absolute paths.
+- **Keep the mask-based sweep.** Rejected: the mask dims the entire row content including state dots, and its exit transition fought the hover icon crossfade; an overlay band composites above the content without touching its alpha.
+- **Keep showing the deleted workspace's name in the chip.** Rejected: the chip is the selector for the *next* session; echoing a cwd whose workspace the user just deleted misrepresents the current pick.
+
+## Consequences
+
+Chat renders fewer flow items than the snapshot has nodes: anyone counting rendered blocks against nodes must account for skipped render-nothing assistants (the chat-view spec pins this). The path relativization is a prefix check against the session cwd, so a workspace rename mid-session shows absolute paths until the summary re-derives — accepted as display-only staleness. The uniform 16px rhythm retires the tighter 10px tool-run look; a future denser layout would reintroduce a second constant deliberately. The menu pre-render adds one hidden layout pass per open, negligible at menu sizes.
+
+## Testing
+
+`chat-view.spec.tsx` pins the render-nothing grouping (including the interrupted exception); `chat-tool-row.spec.tsx` pins cwd relativization inside/outside the workspace and with an empty cwd; `atoms.spec.tsx` and `workspace-picker.spec.tsx` cover the menu and chip states; the full ui-conversation, ui-primitives, and ui-workspace suites pass.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md
new file mode 100644
index 0000000000..d19a5f7593
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md
@@ -0,0 +1,37 @@
+# Agent Note: Web conversation UI polish sweep
+
+Status: implemented
+
+[English](2026-07-28-web-conversation-polish-sweep.md) | 中文
+
+## Problem
+
+一次针对 web GUI 对话界面的设计评审发现了一批视觉呈现缺陷:portal 菜单在重新定位前会先在错误位置绘制一帧(打开时可见跳动);只要某条步骤消息只携带工具调用头,聊天列就会把一次工具运行拆成好几组;工具行摘要打印以工作区为根的绝对路径,占掉行内大部分空间;运行中行的扫光效果用 alpha 遮罩实现,把整行都压暗;hero 区的工作区 chip 会从会话 cwd 里复现已删除工作区的文件夹名;标题栏还在 13px 的标题旁显示一个没人需要的轮次计数。
+
+## Decision
+
+本次修复全部为纯展示侧改动落地;不会有任何内容进入会话日志。
+
+- **Portal 菜单先隐藏预渲染,绘制前完成测量。**菜单列表以 `visibility: hidden` 挂载在 (0,0),在 `useLayoutEffect` 中测量,显示时已处于最终位置。菜单与视口保持 12px 间距并支持内部滚动;工作区创建操作固定在不滚动的页脚区。
+- **聊天流跳过不渲染任何内容的助手节点。**已定稿的助手节点若其块仅含工具调用头和空白的文本/推理(reasoning)内容,就会从流推导中剔除,于是连续的工具结果合并为一组。被中断的节点始终渲染(它们携带「已停止」标记)。
+- **工具行摘要把以工作区为根的路径转为相对路径。**会话 cwd 经由 toolview 插槽契约(`ToolRowOwnerProps.cwd`)逐层传递,`toolRowModel` 从以其开头的摘要中剥去该前缀;工作区之外的路径保持原样。这只影响显示:工具参数与日志均不受影响。
+- **运行中的扫光效果改为高光带叠加层。**一条固定宽度的 `::after` 渐变光带横向扫过整行(即 deepsuite 的 ShimmerText 模式),取代先前的 `mask-image` 方案,ToolRow 与 Bash toolview 两处均已替换。
+- **hero 区的工作区 chip 是选择器,而非回显。**没有有效选中项时(冷启动,或列表稳定后工作区被删除),它显示「Choose workspace」占位文案;由 cwd 推导的名称只用于衔接列表的首次加载,待定选择对应的工作区从已就绪的列表中消失时,该陈旧选择会被清除。
+- **统一为 16px 的纵向节奏。**聊天列间距与分组内工具行间距统一为 16px,取代原先「分组内 10px 间距加跨分组负外边距」的做法。
+- **标题栏标题改为 14/20,去掉轮次计数**;StateDot 的进行中状态与轮次尾部采用逐格推进的像素追逐式加载视觉语言;`body` 启用灰度抗锯齿(`-webkit-font-smoothing` 及 Firefox 在 macOS 上的等价设置)。
+
+## Alternatives considered
+
+- **挂载前根据锚点矩形同步定位菜单。**不予采纳:列表自身尺寸在布局完成前无从得知,向视口内收拢仍然需要布局后测量;对已挂载的隐藏节点做测量正是 React 与 Floating UI 文档记载的模式。
+- **在宿主侧过滤空的助手消息。**不予采纳:该节点是真实的模型输出,Trajectory 与回放都必须保留它;只有聊天展示应当跳过它,且按契约 web 层只负责呈现。
+- **在每个工具各自的展示器中做路径相对化。**不予采纳:这种冗余是所有输出路径摘要的工具共有的;在 `toolRowModel` 里做一次仅影响显示的处理即可覆盖全部工具,非聊天消费方仍拿到绝对路径。
+- **保留基于遮罩的扫光。**不予采纳:遮罩会把包括状态圆点在内的整行内容压暗,其退出过渡还与悬停图标的交叉淡入淡出相互冲突;叠加光带在内容之上合成,完全不触碰内容的 alpha。
+- **让 chip 继续显示已删除工作区的名称。**不予采纳:chip 是为*下一个*会话服务的选择器;用户刚删掉某个工作区,还回显它的 cwd,就是在错误呈现当前的选择。
+
+## Consequences
+
+聊天渲染出的流条目数少于快照中的节点数:凡是拿渲染出的块与节点数对账的人,都必须把被跳过的「不渲染任何内容」的助手节点计算在内(chat-view 规格测试固定了这一点)。路径相对化只是针对会话 cwd 的前缀检查,因此会话中途重命名工作区后,摘要在重新推导前会显示绝对路径,这被接受为仅影响显示的陈旧状态。统一的 16px 节奏淘汰了原先更紧凑的 10px 工具运行外观;将来若要更紧凑的布局,应当有意识地重新引入第二个常量。菜单预渲染让每次打开多一次隐藏布局计算,在菜单的尺寸量级下开销可忽略。
+
+## Testing
+
+`chat-view.spec.tsx` 固定了「不渲染任何内容」节点的分组行为(含被中断节点这一例外);`chat-tool-row.spec.tsx` 固定了工作区内、工作区外以及 cwd 为空时的 cwd 相对化行为;`atoms.spec.tsx` 与 `workspace-picker.spec.tsx` 覆盖菜单与 chip 的各种状态;ui-conversation、ui-primitives 与 ui-workspace 的全量测试套件通过。
From 0b1a8b3fee1ed367cf83925c7fd425e4d0a2469d Mon Sep 17 00:00:00 2001
From: imccyu
Date: Tue, 28 Jul 2026 15:25:38 +0800
Subject: [PATCH 8/8] fix: ci
---
apps/web/tests/workspace-flow.snapshot.ts | 7 +++++--
vitest.config.ts | 3 +++
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts
index e1fe183ef1..b33df1b157 100644
--- a/apps/web/tests/workspace-flow.snapshot.ts
+++ b/apps/web/tests/workspace-flow.snapshot.ts
@@ -165,7 +165,7 @@ it('locks the composer in the New Session view state until a Workspace is chosen
sidebar: visibleText(tree),
}).toMatchInlineSnapshot(`
{
- "chip": "New Workspace",
+ "chip": "Choose workspace",
"composerDisabled": true,
"headline": "Let's start building",
"sendDisabled": true,
@@ -232,7 +232,10 @@ it('New Session reuses the Workspace blank session and converts the single visib
// New Session resolves through the recent Workspace and reuses its blank
// session in place: no locked interlude, no second entity.
- fireEvent.click(screen.getByRole('button', { name: 'New session' }))
+ const newSessionButton = screen.getAllByRole('button', { name: 'New session' })
+ .find(button => visibleText(button) === 'New Session')
+ if (newSessionButton === undefined) throw new Error('New Session button missing')
+ fireEvent.click(newSessionButton)
const composer = await findHeroComposer()
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
diff --git a/vitest.config.ts b/vitest.config.ts
index d19b1aa0f8..0141b11f96 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -103,6 +103,9 @@ export default defineConfig({
// yet. TODO(gui): cover and remove as the client test lane matures.
'packages/client/ui-trajectory/src/*',
'packages/client/ui-question/src/client/QuestionComposer.tsx',
+ 'packages/client/ui-primitives/src/Menu.tsx',
+ 'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx',
+ 'packages/client/ui-workspace/src/client/WorkspacePicker.tsx',
'packages/client/web-react/src/*',
'packages/client/runtime/src/*',
'packages/client/ui-conversation/src/*',