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 的全量测试套件通过。
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/packages/client/locale/src/client/LanguageRow.module.css b/packages/client/locale/src/client/LanguageRow.module.css
index f17a67d279..57d975d9b7 100644
--- a/packages/client/locale/src/client/LanguageRow.module.css
+++ b/packages/client/locale/src/client/LanguageRow.module.css
@@ -42,6 +42,10 @@
cursor: pointer;
}
+.selector:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
.chevron {
flex: none;
}
diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css
index b4570af921..9177d721c3 100644
--- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css
+++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css
@@ -9,18 +9,6 @@
color: var(--dsw-alias-label-primary);
}
-.pulse {
- display: inline-block;
- width: 8px;
- height: 14px;
- background: var(--dsw-alias-state-business-primary);
- animation: pulse 1s infinite ease-in-out;
-}
-
-@keyframes pulse {
- 50% { opacity: 0.2; }
-}
-
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
.stopped {
align-self: flex-start;
diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
index 6daf78719e..e3573b14f5 100644
--- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
+++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
@@ -2,8 +2,8 @@
// reasoning as the figma Think summary row (expand = indented gray text),
// other-block JSON fallback. Tool-call heads are NOT rendered here: the chat
// view groups them into tool rows through its keyed toolview slot (figma
-// step-summary flow). Shared by finalized nodes and the streaming partial
-// (pulse marker).
+// step-summary flow). Shared by finalized nodes and the streaming partial;
+// the turn-level loading dots live in the chat view's tail, not here.
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -14,7 +14,7 @@ import css from './AssistantMarkdown.module.css'
export interface AssistantMarkdownProps {
blocks: readonly AssistantBlock[]
streaming: boolean
- /** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */
+ /** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
interrupted?: boolean | undefined
}
@@ -28,7 +28,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
return (
}
+ icon={}
title="Think"
summary={firstLine(text)}
body={text}
@@ -58,7 +58,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
default: return
}
})}
- {streaming && }
{interrupted && 已停止}
)
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 6d75f9a519..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 {
@@ -51,6 +52,35 @@
border-left: 1px solid var(--dsw-alias-border-l2);
}
+/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to
+ right with a stepped trail — flat keyframe holds, no tweening. Phase
+ offsets come from per-rect animation-delay (index * -250ms) set inline
+ by the component. */
+.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);
+}
+
+.turnDotCell {
+ fill: currentColor;
+ opacity: 0.15;
+ animation: dsh-turn-dots-chase 1s infinite;
+}
+
+@keyframes dsh-turn-dots-chase {
+ 0%, 24.9% { opacity: 1; }
+ 25%, 49.9% { opacity: 0.6; }
+ 50%, 74.9% { opacity: 0.35; }
+ 75%, 100% { opacity: 0.15; }
+}
+
.hint {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
index f61c6da6ef..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 (
)
})
+/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
+ * 2px cell, same blue) chasing left to right with a stepped trail — flat
+ * keyframe holds, no tweening, no rotation. Phase offsets come from
+ * per-rect animation-delay. */
+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. */
+
+
+
+ )
+}
+
/** The streaming partial, isolated so chunk batches re-render only this tail.
* onGrow lets the scroll owner follow content the parent never re-renders for. */
function StreamingTail({ useSession, onGrow }: {
@@ -167,8 +210,11 @@ 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)
const pending = useSession(s => s.pending)
@@ -268,6 +314,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
onOpenDetails={openDetails}
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
+ cwd={cwd}
/>
)
}
@@ -309,11 +356,15 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
selected={call.callId === selectedCallId}
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
+ cwd={cwd}
/>
))}
)}
{pending.map(item => )}
+ {/* Turn-level loading signal: rides the whole running turn (first-token
+ wait, tool execution, streaming) so it never flickers per step. */}
+ {running && }
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
index 5cb2126f34..e5b5fb541b 100644
--- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
@@ -13,20 +13,20 @@ import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.t
import { ToolRow } from './ToolRow.tsx'
import { IconSparkle16 } from './IconSparkle16.tsx'
-/** Variant leading icons (figma table). */
+/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
const VARIANT_ICONS: Record = {
- think: ,
- search: ,
- read: ,
- bash: ,
- write: ,
- edit: ,
- code: ,
- others: ,
+ think: ,
+ search: ,
+ read: ,
+ bash: ,
+ write: ,
+ edit: ,
+ code: ,
+ 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 (
void) | undefined
}
-/** Leading-slot state substitution: the tool icon yields to the state semantic
- * (running = blue ring, error = red, interrupted = amber halo; ok = icon). */
+/** Leading-slot state substitution: the tool icon yields to the terminal state
+ * semantic (error = red, interrupted = amber halo). Running keeps the icon —
+ * the row sweep (CSS on data-state) carries the in-flight signal. */
function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
switch (state) {
- case 'running': return
case 'error': return
case 'stopped': return
default: return icon
@@ -66,6 +68,19 @@ export function ToolRow({
event.preventDefault()
toggleExpand()
}
+ // Expandable rows preview the toggle on hover: the tool icon yields to a
+ // down chevron (CSS swap on .row:hover); state dots still take precedence.
+ const collapsedIcon = expandable
+ ? (
+ <>
+ {icon}
+
+ >
+ )
+ : icon
+ const leading = open
+ ?
+ : leadingFor(state, collapsedIcon)
return (
- {open ? : leadingFor(state, icon)}
+ {leading}
) : (
- {open ? : leadingFor(state, icon)}
+ {leading}
)}
{title}
diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts
index 8f47e334c5..f1ce061af8 100644
--- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts
+++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts
@@ -12,6 +12,16 @@ export type ChatFlowItem =
| { kind: 'node'; key: string; node: ConversationNode }
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
+/** An assistant node that renders nothing: only tool-call heads (rows render
+ * via the grouping pass) and blank text/reasoning. Skipped by the flow so it
+ * neither costs column gaps nor splits a tool-row run. Interrupted nodes
+ * always render (the 已停止 marker). */
+function rendersNothing(node: ConversationNode): boolean {
+ return node.kind === 'assistant' && node.interrupted !== true
+ && node.blocks.every(b => 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 7a99323826..8fc5b8b200 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
}
@@ -307,6 +309,8 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore &
export interface EmptyWorkspaceOwnerProps {
open: boolean
anchorRef?: RefObject
+ /** Currently active workspace (renders a trailing check in the picker list). */
+ selectedId?: WorkspaceId | undefined
onPick: (workspaceId: WorkspaceId) => void
onClose: () => 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/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
index be68ea1394..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;
@@ -87,7 +80,7 @@
padding-left: 8px;
}
-/* figma .Tab 34:11442: 13/16 wt510 text, gap 8 to the 3px bar (no bottom rounding). */
+/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */
.tab {
position: relative;
padding: 0 0 11px;
@@ -95,7 +88,7 @@
background: transparent;
font-size: 13px;
line-height: 16px;
- font-weight: 510;
+ font-weight: 500;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
@@ -139,11 +132,45 @@
NOT absolute+transform: a transform would make this box the containing
block for position:fixed descendants (pickers/modals), shrinking them. */
.composerHero {
+ position: relative; /* .heroGlow positioning context */
align-self: center;
+ /* figma 75:8208: 12 between hero chrome / workspace row / card. */
+ gap: 12px;
+ /* Foot inside the centered box floats the stack a bit above true center. */
+ padding-bottom: 32px;
width: min(776px, calc(100% - 48px));
z-index: 1;
}
+/* Blue backdrop ellipse (figma 313:14109), centered on the input card: the
+ card's resting center sits ~92px above the stack bottom (32 foot pad +
+ half of the ~120px two-row card); width tracks the card (glow asset 1051
+ vs design card 776) so blur scales in userSpace with it. z-index -1 keeps
+ it behind the in-flow hero content inside this stacking context. */
+.heroGlow {
+ position: absolute;
+ left: 50%;
+ bottom: 92px;
+ z-index: -1;
+ width: calc(100% * 1051 / 776);
+ aspect-ratio: 1051 / 468;
+ transform: translate(-50%, 50%);
+ pointer-events: none;
+}
+
+.heroWorkspaceRow {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ padding-left: 8px;
+}
+
.root[data-phase='hero'] {
justify-content: center;
}
+
+/* Settling (session replaying, hero/docked unknown): keep the composer
+ mounted but invisible so no wrong layout flashes before the phase lands. */
+.root[data-phase='settling'] .composerStack {
+ visibility: hidden;
+}
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
index d6e7492836..b110860685 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
@@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
-import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
+import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import { DisabledInputBar } from './DisabledInputBar.tsx'
import css from './ConversationRoot.module.css'
@@ -36,33 +36,53 @@ export function ConversationRoot({
workspace => workspace.workspaceId === pendingWorkspaceId,
)
+ // Clear the pending pick once the session lands in it, or when the picked
+ // workspace disappears from a ready list (deleted from the sidebar).
useEffect(() => {
- if (pendingWorkspaceId !== undefined
- && sessionWorkspace?.workspaceId === pendingWorkspaceId) {
+ if (pendingWorkspaceId === undefined) return
+ if (sessionWorkspace?.workspaceId === pendingWorkspaceId
+ || (workspaces.phase === 'ready' && pendingWorkspace === undefined)) {
setPendingWorkspaceId(undefined)
}
- }, [pendingWorkspaceId, sessionWorkspace?.workspaceId])
+ }, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace])
- const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))
+ // While a session is still replaying (loading + blank) the hero/docked
+ // choice is unknowable — render the composer hidden instead of flashing
+ // the centered hero and snapping to the docked bar (or vice versa).
+ const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading'
+ const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open')
const zone: InputZone | undefined =
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
+ // Flow optimization — worth a close PR review for code/boundary issues.
+ // The chip is a selector; label resolution walks the flow top-down:
+ // 1. a just-picked workspace (pending) → its title;
+ // 2. cold start, no session yet → placeholder ("Choose workspace");
+ // 3. the blank session's workspace is in the list → its title;
+ // 4. list still loading → cwd folder name bridges so the title does not
+ // flash on refresh (empty cwd → placeholder);
+ // 5. list ready but no owning workspace (deleted from the sidebar) →
+ // placeholder, never the deleted folder's name via cwd.
+ const chipTitle = pendingWorkspace?.title
+ ?? (sessionId === undefined
+ ? undefined
+ : sessionWorkspace?.title
+ ?? (workspaces.phase === 'ready' || cwd === undefined || cwd === ''
+ ? undefined
+ : workspaceLabel(cwd)))
+
const heroWorkspaceRow = (
- <>
+
+ )
+ }
+
+ // 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