Просмотр исходного кода

Merge pull request #937 from deepseek-harness/fix/input-ui

Web composer stats detail row and input-zone polish
imccyu 1 месяц назад
Родитель
Сommit
c66cf04ebf
23 измененных файлов с 326 добавлено и 63 удалено
  1. 6 0
      .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml
  2. 33 0
      .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md
  3. 33 0
      .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md
  4. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  5. 1 1
      packages/client/ui-conversation/README.md
  6. 1 1
      packages/client/ui-conversation/README.zh.md
  7. 5 2
      packages/client/ui-conversation/src/client/chat/ChatView.module.css
  8. 11 1
      packages/client/ui-conversation/src/client/chat/StatsLine.module.css
  9. 67 12
      packages/client/ui-conversation/src/client/chat/StatsLine.tsx
  10. 3 1
      packages/client/ui-conversation/src/client/contract/slots.ts
  11. 16 2
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
  12. 22 5
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
  13. 10 6
      packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
  14. 2 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  15. 9 6
      packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css
  16. 12 1
      packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx
  17. 1 1
      packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
  18. 12 1
      packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
  19. 41 6
      packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
  20. 12 1
      packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
  21. 1 1
      packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
  22. 15 2
      packages/client/ui-conversation/tests/skeleton.spec.tsx
  23. 11 10
      packages/client/ui-goal/src/client/GoalBar.module.css

+ 6 - 0
.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.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/feature/2026-07-30-web-composer-stats-and-input-polish.md
+2026-07-30-web-composer-stats-and-input-polish.md: 0d90b8c1d2e283f2bcca7d9e82ac461d9fa4eb7e
+2026-07-30-web-composer-stats-and-input-polish.zh.md: db47250852724e62337948aa516effb42f19066c

+ 33 - 0
.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md

@@ -0,0 +1,33 @@
+# Agent Note: Web composer stats detail and input-zone polish
+
+Status: implemented
+
+English | [中文](2026-07-30-web-composer-stats-and-input-polish.zh.md)
+
+## Problem
+
+The web composer footer showed a single joined stats string (cache/tokens/turns/steps) in its own stack row, visually detached from the input card and missing the design's duration and token-split details. The input zone itself had accumulated per-entry spacing hacks: dock strips carried their own margins, the sticky seat sat on a solid fill that clipped the transcript hard, the back-to-bottom control cleared the composer by a hardcoded offset that broke as the draft grew, and the goal and todo strips disagreed on surface color and column width.
+
+## Decision
+
+**The stats line renders inside the InputBar's width column through a new `footer` owner prop and expands to the design's grouped detail row; the composer stack owns one 8px rhythm; the seat fades the transcript through a fixed 36px token-bound gradient; the back-to-bottom control follows a live `--dsh-composer-height`; goal and todo share one 752px tip-fill column.**
+
+- `'conversation.composer.dock'` entries reach the page as the `ComposerBarOwnerProps.footer` slot, rendered under the card inside the bar's `.root`, so the stats line and the card share one width constraint. `StatsLine` derives everything client-side from the snapshot: turns/steps, LLM wall time from assistant `timing` (`completedTime - stepStartTime`), tool wall time from tool-result `time - callTime` pairs, prompt/output token split with cache-read folded into input, and cache-hit percentage. Groups render pipe-separated and drop out whole when empty; `formatTokens` (517 / 12.2K / 1.2M) and `formatDuration` (45.2s / 2m42s) are exported for tests. Durations cover only in-window nodes — the README owns that limitation.
+- `.composerStack` carries `gap: 8px` and entries carry no outer margins (QueueDock's margin removed), so a dock entry that renders null costs nothing. GoalBar is the one deliberate exception: `margin: 0 auto -10px` cancels the gap and tucks its square bottom edge 2px under the card.
+- The sticky seat's background is a `linear-gradient` from `color-mix(bg-base 0%, transparent)` at 0px to solid `bg-base` at 36px — pixel stops, not the figma export's percentage, so a growing draft widens only the solid region; `color-mix` keeps both themes fading from their own base.
+- A `useCallback` ref on the seat attaches a ResizeObserver that publishes `--dsh-composer-height` on the scroll body; ChatView's back-to-bottom slot computes `bottom` from it (152px first-paint fallback) instead of the prior hardcoded 168px.
+- The textarea's 52px two-line floor applies to the hero variant only; the docked composer collapses to content height. Goal and todo strips both use the 44px-gutter / 752px-cap column with the todo `tip` fill and l1 border; the todo header is compacted (13/20 type, 8+8 padding) so its collapsed height equals the goal strip's 38px.
+
+## Alternatives considered
+
+**Percentage gradient stops (the figma export's 24%).** Rejected: the stop scales with seat height, so a tall draft stretches the fade band over most of the transcript; the fixed 36px band equals the design's 24% at the resting ~150px composer and stays constant as the composer grows.
+
+**A skeleton-owned dock column with a generic "bottommost entry tucks" contract.** Built and backed out in review: a `.inputDock` wrapper owning width/rhythm plus `--dsh-dock-tuck-*` vars on `:last-child` would retarget the tuck automatically on reorder, but it rewrote every entry and the GoalBar DOM ahead of a pending merge. Per-entry CSS with GoalBar owning its own tuck was chosen; the generic column remains available if dock entries multiply.
+
+**Backend-supplied duration fields for the stats line.** Unnecessary: assistant `timing` and tool call/result pairs already reach the snapshot, so wall times fold client-side with no new session event or host projection.
+
+**Keeping the stats line as a composer-stack sibling.** Rejected: as a stack row it carried its own width constraint that drifted from the card's; as the bar's `footer` both share one column and the stats participate in the seat's sticky/gradient region by construction.
+
+## Consequences
+
+The stats row now reads turns/steps, LLM and tool durations, cache hit, and input/output tokens at a glance, at the cost that durations cover only the loaded event window (README Known Limitation). The one-gap stack rhythm makes dock spacing composition-independent, but GoalBar's tuck is positional: it must stay the bottommost dock entry (`order: 1`) or its negative margin tucks it under the wrong neighbor. The fade band is a constant 36px, so any future design retune is one stop value. `chat-stats-bash-sample.spec.tsx` pins the derivation (timing/tool folds, token split), both formatters, the grouped render, and the zero-renders-during-streaming acceptance.

+ 33 - 0
.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: Web composer stats detail and input-zone polish
+
+Status: implemented
+
+[English](2026-07-30-web-composer-stats-and-input-polish.md) | 中文
+
+## Problem
+
+Web 编辑器页脚原本以独立 stack 行显示一条拼接的统计字符串(cache/tokens/turns/steps),视觉上与输入卡脱节,且缺少设计稿中的耗时与 token 拆分细节。输入区自身也积累了逐条目的间距补丁:dock 条各带自己的 margin,sticky 座位下是硬切消息流的纯色填充,「回到底部」控件用硬编码偏移躲避编辑器、草稿一长高就失效,goal 与 todo 条的底色和列宽也互不一致。
+
+## Decision
+
+**统计行经由新的 `footer` owner prop 渲染进 InputBar 的宽度列内,并扩展为设计稿的分组细节行;composer stack 拥有唯一的 8px 节奏;座位以固定 36px 的 token 绑定渐变淡出消息流;「回到底部」控件跟随实时的 `--dsh-composer-height`;goal 与 todo 共用一条 752px 的 tip 填充列。**
+
+- `'conversation.composer.dock'` 条目以 `ComposerBarOwnerProps.footer` 席位到达页面,渲染在卡片下方、bar 的 `.root` 之内,统计行与卡片因此共享同一宽度约束。`StatsLine` 全部在客户端从快照推导:turns/steps、由 assistant `timing`(`completedTime - stepStartTime`)折算的 LLM 墙钟时间、由 tool-result 的 `time - callTime` 配对折算的工具墙钟时间、把 cache-read 并入输入侧的提示/输出 token 拆分,以及缓存命中率。各组以竖线分隔、无数据时整组消失;`formatTokens`(517 / 12.2K / 1.2M)与 `formatDuration`(45.2s / 2m42s)导出供测试。耗时只覆盖窗口内节点——该限制由 README 记录。
+- `.composerStack` 携带 `gap: 8px`,条目不带外边距(QueueDock 的 margin 已删除),渲染为 null 的 dock 条目零成本。GoalBar 是唯一的刻意例外:`margin: 0 auto -10px` 抵消 gap,把方形下缘塞进卡片下方 2px。
+- sticky 座位的背景是从 0px 处的 `color-mix(bg-base 0%, transparent)` 到 36px 处纯色 `bg-base` 的 `linear-gradient`——像素节点而非 figma 导出的百分比,草稿长高只扩大纯色区域;`color-mix` 让两个主题都从各自的底色淡出。
+- 座位上的 `useCallback` ref 挂 ResizeObserver,把 `--dsh-composer-height` 发布到滚动体上;ChatView 的回到底部席位据此计算 `bottom`(首帧回退 152px),替换先前硬编码的 168px。
+- textarea 的 52px 两行下限只保留在 hero 变体;停靠态编辑器折叠到内容高度。goal 与 todo 条统一使用 44px 边距/752px 上限的列、todo 的 `tip` 填充与 l1 边框;todo 表头紧凑化(13/20 字号、8+8 内边距),折叠高度与 goal 条的 38px 对齐。
+
+## Alternatives considered
+
+**百分比渐变节点(figma 导出的 24%)。** 否决:节点随座位高度缩放,长草稿会把过渡带拉伸到消息流的大半;固定 36px 过渡带等于设计稿在静息 ~150px 编辑器下的 24%,且随编辑器长高保持恒定。
+
+**骨架拥有的 dock 列加通用「最底条目贴卡」契约。** 实现后在评审中撤回:由 `.inputDock` 包装层拥有宽度/节奏、在 `:last-child` 上发布 `--dsh-dock-tuck-*` 变量,重排时贴卡会自动换人,但它在一次待合并前重写了每个条目和 GoalBar 的 DOM。最终选择逐条目 CSS、GoalBar 自持贴卡;dock 条目增多时通用列方案仍然可用。
+
+**由后端为统计行提供耗时字段。** 不必要:assistant `timing` 与工具 call/result 配对已经到达快照,墙钟时间可在客户端折算,无需新的会话事件或 host 投影。
+
+**统计行保持为 composer stack 的兄弟节点。** 否决:作为 stack 行它携带独立的宽度约束、与卡片漂移;作为 bar 的 `footer`,两者共享一列,统计行也天然落在座位的 sticky/渐变区域内。
+
+## Consequences
+
+统计行现在一眼可读 turns/steps、LLM 与工具耗时、缓存命中和输入/输出 token,代价是耗时只覆盖已加载事件窗口(README 已知限制)。单 gap 的 stack 节奏使 dock 间距与组合无关,但 GoalBar 的贴卡是位置性的:它必须保持为最底的 dock 条目(`order: 1`),否则其负边距会塞到错误的邻居下面。过渡带恒为 36px,未来设计调整只改一个节点值。`chat-stats-bash-sample.spec.tsx` 钉住推导(timing/工具折算、token 拆分)、两个格式化器、分组渲染,以及流式期间零重渲染的验收。

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 packages/client/ui-conversation/README.md
-README.md: 75c181f2e1240f753d1f8c30152d2978151b059f
-README.zh.md: 6dc167c63af27dfbc37c51b9a55087cf2c50dc7f
+README.md: 3973c14f2b8fe746549bb74af85a7a60a7d66aea
+README.zh.md: a6bb15c4cdd53d05bf28147b97d9d64d1c59da2b

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -34,7 +34,7 @@ None; this package neither assembles nor sends a provider request.
 
 ## Known Limitations and Deferred Work
 
-- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
+- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
 - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
 - **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch remains a chrome stub.
 - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -34,7 +34,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
 
 ## 已知限制与暂缓事项
 
-- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源
+- **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入
 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
 - **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub。
 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。

+ 5 - 2
packages/client/ui-conversation/src/client/chat/ChatView.module.css

@@ -144,8 +144,11 @@
 }
 
 :global([data-conversation-scroll]) .toBottomSlot {
-  /* Clears the sticky composer stack (stats + docks + input card). */
-  bottom: 168px;
+  /* Clears the sticky composer stack (docks + input card + stats): the live
+     height rides --dsh-composer-height (ConversationRoot's seat observer) so
+     the control follows a growing textarea; the fallback covers the first
+     paint before the observer fires. */
+  bottom: calc(var(--dsh-composer-height, 152px) + 16px);
 }
 
 .toBottom {

+ 11 - 1
packages/client/ui-conversation/src/client/chat/StatsLine.module.css

@@ -2,12 +2,22 @@
    736px message column axis. */
 
 .root {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
   max-width: 736px;
   width: 100%;
   margin: 0 auto;
   box-sizing: border-box;
-  padding: 4px 24px 8px;
+  padding: 4px 24px 0px;
   font-size: 12px;
   line-height: 20px;
   color: var(--dsw-alias-label-tertiary);
+  white-space: nowrap;
+  overflow: hidden;
+}
+
+.sep {
+  color: var(--dsw-alias-separator-primary);
 }

+ 67 - 12
packages/client/ui-conversation/src/client/chat/StatsLine.tsx

@@ -2,7 +2,7 @@
 // Mounted on 'conversation.composer.dock' so it sticks with the composer in the
 // active conversation scrollport (see ConversationRoot data-conversation-scroll).
 
-import { memo, useMemo } from 'react'
+import { Fragment, memo, useMemo } from 'react'
 import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import css from './StatsLine.module.css'
@@ -10,7 +10,13 @@ import css from './StatsLine.module.css'
 interface UsageTotals {
   turns: number
   steps: number
-  tokens: number
+  /** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
+  llmMs: number
+  /** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
+  toolMs: number
+  /** Prompt-side tokens: inputTokens + cacheReadTokens. */
+  inputTokens: number
+  outputTokens: number
   cacheHitPct: number | null
 }
 
@@ -22,35 +28,72 @@ interface UsageLike {
 }
 
 /**
- * Fold assistant nodes into display totals.
+ * Fold assistant and tool-result nodes into display totals.
  * @param nodes - snapshot nodes.
  * @returns totals; cacheHitPct null until any cache accounting arrives.
  */
 export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
   const turns = new Set<number>()
   let steps = 0
-  let tokens = 0
+  let llmMs = 0
+  let toolMs = 0
   let input = 0
+  let output = 0
   let cacheRead = 0
   for (const node of nodes) {
+    if (node.kind === 'tool-result') {
+      if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
+      continue
+    }
     if (node.kind !== 'assistant') continue
     turns.add(node.turn)
     steps += 1
+    if (node.timing !== undefined && node.timing.stepStartTime !== null) {
+      llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
+    }
     const usage = node.usage as UsageLike | undefined
     if (usage === undefined) continue
     input += usage.inputTokens ?? 0
+    output += usage.outputTokens ?? 0
     cacheRead += usage.cacheReadTokens ?? 0
-    tokens += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0)
   }
   const denom = input + cacheRead
   return {
     turns: turns.size,
     steps,
-    tokens,
+    llmMs,
+    toolMs,
+    inputTokens: input + cacheRead,
+    outputTokens: output,
     cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100),
   }
 }
 
+/**
+ * Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits).
+ * @param n - token count.
+ * @returns display string.
+ */
+export function formatTokens(n: number): string {
+  const scaled = (v: number): string =>
+    v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
+  if (n < 1_000) return String(n)
+  if (n < 1_000_000) return `${scaled(n / 1_000)}K`
+  return `${scaled(n / 1_000_000)}M`
+}
+
+/**
+ * Compact duration: 45.2s under a minute, 2m42s from there on.
+ * @param ms - duration in milliseconds.
+ * @returns display string.
+ */
+export function formatDuration(ms: number): string {
+  const s = ms / 1_000
+  if (s < 60) return `${Math.round(s * 10) / 10}s`
+  const whole = Math.round(s)
+  return `${Math.floor(whole / 60)}m${whole % 60}s`
+}
+
 /** Props: the conversation-snapshot selector (dock registration or unit mount). */
 export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
 
@@ -58,10 +101,22 @@ export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps)
   const nodes = useSession(s => s.nodes)
   const stats = useMemo(() => deriveStats(nodes), [nodes])
   if (stats.steps === 0) return null
-  const parts: string[] = []
-  if (stats.cacheHitPct !== null) parts.push(`cache hit ${stats.cacheHitPct}%`)
-  parts.push(`${stats.tokens.toLocaleString('en-US')} tokens`)
-  parts.push(`${stats.turns} turns`)
-  parts.push(`${stats.steps} steps`)
-  return <div className={css.root}>{parts.join(' · ')}</div>
+  // Pipe-separated groups (figma stats strip); a group with no data drops out whole.
+  const groups: string[] = [`${stats.turns} turns · ${stats.steps} steps`]
+  const durations: string[] = []
+  if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
+  if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
+  if (durations.length > 0) groups.push(durations.join(' · '))
+  if (stats.cacheHitPct !== null) groups.push(`Cache hit ${stats.cacheHitPct}%`)
+  groups.push(`Input ${formatTokens(stats.inputTokens)} tok · Output ${formatTokens(stats.outputTokens)} tok`)
+  return (
+    <div className={css.root}>
+      {groups.map((group, i) => (
+        <Fragment key={group}>
+          {i > 0 && <span className={css.sep} aria-hidden>|</span>}
+          <span>{group}</span>
+        </Fragment>
+      ))}
+    </div>
+  )
 })

+ 3 - 1
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -67,7 +67,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      * design §6 MIX evidence: entries coexist in fixed order).
      */
     'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
-    /** The composer top-edge band (stats line family). */
+    /** The band under the composer card (stats line family), rendered inside the bar's width column via the `footer` owner prop. */
     'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
     /** Tool-row left region inside the input card (existing chrome stays in place beside entries). */
     'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone }
@@ -253,6 +253,8 @@ export interface ComposerBarOwnerProps {
   leftItems?: ReactNode
   /** input.right slot entries (tool row, before the primary button). */
   rightItems?: ReactNode
+  /** composer.dock entries (stats line), rendered under the card inside the bar's width column. */
+  footer?: ReactNode
   onAdd?: () => void
   addLabel?: string
 }

+ 16 - 2
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css

@@ -127,10 +127,14 @@
   min-height: 0;
 }
 
-/* Composer stack: dock strips above the input card (design §6 MIX order). */
+/* Composer stack: dock strips above the input card (design §6 MIX order).
+   The stack owns the vertical rhythm: one gap here, entries carry no outer
+   margins — an entry that renders null costs nothing, so spacing stays
+   correct for any dock combination. */
 .composerStack {
   display: flex;
   flex-direction: column;
+  gap: 8px;
 }
 
 /* Common seat for the composer chain (fallback + elected overlay siblings). */
@@ -170,7 +174,17 @@
   /* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
      paints under a sticking code header while scrolling. */
   z-index: 7;
-  background: var(--dsw-alias-bg-base);
+  /* Input mask (figma 1205:27463): transcript fades out under a FIXED 36px
+     band at the seat's top (the figma 24% of the resting ~150px composer),
+     solid below — px stops, not %, so a growing draft only widens the solid
+     region and the fade band never stretches. The 0px stop is bg-base at
+     zero alpha (not white, which the figma export hardcodes) so both themes
+     fade from their own base. */
+  background: linear-gradient(
+    180deg,
+    color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent) 0px,
+    var(--dsw-alias-bg-base) 36px
+  );
 }
 
 /* Hero phase: the composer stack (hero chrome + workspace row + card) is

+ 22 - 5
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx

@@ -2,7 +2,7 @@
 // chain stay mounted across no-session/session transitions. Only the inert
 // input body swaps for the strict session InputBar.
 
-import { useEffect, useRef, useState, type ReactNode } from 'react'
+import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
 import clsx from 'clsx'
 import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
@@ -29,6 +29,23 @@ export function ConversationRoot({
   const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
   const pickerAnchor = useRef<HTMLButtonElement>(null)
 
+  // Publishes the seat's live height as --dsh-composer-height on the scroll
+  // body so floating controls (ChatView back-to-bottom) clear the composer as
+  // it grows. Callback ref, not an effect: the seat remounts when the tree
+  // moves between the no-session and session paths. Stable identity so React
+  // reattaches only on those remounts, not on every render.
+  const seatObserver = useRef<ResizeObserver | null>(null)
+  const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => {
+    seatObserver.current?.disconnect()
+    seatObserver.current = null
+    const scroller = seat?.parentElement ?? null
+    if (seat === null || scroller === null) return
+    seatObserver.current = new ResizeObserver(() => {
+      scroller.style.setProperty('--dsh-composer-height', `${seat.offsetHeight}px`)
+    })
+    seatObserver.current.observe(seat)
+  }, [])
+
   const sessionWorkspace = sessionId === undefined
     ? undefined
     : workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId))
@@ -106,6 +123,9 @@ export function ConversationRoot({
       overlay: renderSlot('conversation.input.overlay', {}),
       leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
       rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
+      // Stats band under the card, inside the bar's width column so both
+      // share one constraint (composer.dock = stats-line family).
+      footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
     })
 
   const composerBar = (
@@ -113,9 +133,6 @@ export function ConversationRoot({
       {hero && <HeroGlow className={css.heroGlow} />}
       {hero && <HeroShell />}
       {hero && heroWorkspaceRow}
-      {/* Stats band above the input-dock strips so the prior ChatView footer
-          order (stats → todo/queue → card) is preserved under the sticky stack. */}
-      {!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)}
       {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
       {inputBar}
     </div>
@@ -133,7 +150,7 @@ export function ConversationRoot({
   // on the fallback alone would leave Question/Approval panels at the content
   // end off-screen when the user is not pinned to the floor.
   const composerSeat = (
-    <div className={css.composerSeat} data-composer-seat="">
+    <div ref={seatResizeRef} className={css.composerSeat} data-composer-seat="">
       {composer}
     </div>
   )

+ 10 - 6
packages/client/ui-conversation/src/client/skeleton/InputBar.module.css

@@ -20,10 +20,10 @@
   display: flex;
   flex-direction: column;
   align-items: center;
-  /* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
-     the chat scroller. Top 6 is the gap under the dock todo strip (12px todo
-     margin + 6px here); error/status strips still carry their own margin. */
-  padding: 6px 32px 12px;
+  /* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by
+     the chat scroller. No top pad: the composer stack's gap owns the space
+     above; error/status strips still carry their own margin. */
+  padding: 0 32px 8px;
 }
 
 .hero {
@@ -209,12 +209,16 @@
 .mirror {
   visibility: hidden;
   pointer-events: none;
-  /* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */
-  min-height: 52px;
   max-height: 336px;
   overflow: hidden;
 }
 
+/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
+   line + 4pt); the docked composer collapses to the content height. */
+.hero .mirror {
+  min-height: 52px;
+}
+
 /* Toolbar: attach + Plan + Read-only on the left; model + send on the right
    (figma Input_Bottom chrome). */
 .row {

+ 2 - 1
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -30,7 +30,7 @@ export type InputBarProps = ComposerBarProps
 
 export function InputBar({
   useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
-  variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
+  variant, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment',
 }: InputBarProps) {
   const input = useInput(s => s)
   const notice = useNotices(s => s)
@@ -417,6 +417,7 @@ export function InputBar({
           </div>
         </div>
       </div>
+      {footer}
     </div>
   )
 }

+ 9 - 6
packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css

@@ -1,13 +1,14 @@
 /* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
    tip surface, 14px radius, status icons + secondary item labels. Column is
-   calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */
+   calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer
+   stack owns the gap. */
 
 .root {
   flex: none;
   overflow: hidden;
   margin: 0 auto;
   width: calc(100% - 88px);
-  max-width: 776px;
+  max-width: 752px;
   border: 1px solid var(--dsw-alias-border-l1);
   border-radius: 14px;
   background: var(--dsw-specific-tip);
@@ -20,11 +21,13 @@
   --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
 }
 
+/* Compact scale (GoalBar reference): collapsed header totals the goal
+   strip's 38px (8+8 pad + 20 line + 2 border). */
 .body {
   display: flex;
   flex-direction: column;
-  gap: 10px;
-  padding: 10px 16px;
+  gap: 8px;
+  padding: 8px 14px;
 }
 
 .header {
@@ -41,8 +44,8 @@
 
 .title {
   flex: none;
-  font-size: 14px;
-  line-height: 24px;
+  font-size: 13px;
+  line-height: 20px;
   font-weight: 500;
   color: var(--dsw-alias-label-primary);
 }

+ 12 - 1
packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx

@@ -29,9 +29,20 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
 const SID = 's1' as SessionId
 
-afterEach(cleanup)
+/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
+class ResizeObserverStub {
+  observe(): void {}
+  unobserve(): void {}
+  disconnect(): void {}
+}
+
+afterEach(() => {
+  cleanup()
+  vi.unstubAllGlobals()
+})
 beforeEach(() => {
   localStorage.clear()
+  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
 })
 
 const TODOS: TodoItem[] = [

+ 1 - 1
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx

@@ -227,6 +227,6 @@ describe('small branch tails', () => {
     const view = render(
       <StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
     )
-    expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
+    expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok')
   })
 })

+ 12 - 1
packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx

@@ -23,9 +23,20 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
 const SID = 's1' as SessionId
 
-afterEach(cleanup)
+/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
+class ResizeObserverStub {
+  observe(): void {}
+  unobserve(): void {}
+  disconnect(): void {}
+}
+
+afterEach(() => {
+  cleanup()
+  vi.unstubAllGlobals()
+})
 beforeEach(() => {
   localStorage.clear()
+  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
 })
 
 const PROGRAM = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\nreturn listing'

+ 41 - 6
packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx

@@ -12,7 +12,7 @@ import type {
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { StatsLine, deriveStats, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
+import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
 import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
 
 afterEach(cleanup)
@@ -51,7 +51,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
 }
 
 describe('deriveStats', () => {
-  it('folds turns/steps/tokens and cache hit percentage', () => {
+  it('folds turns/steps/token split and cache hit percentage', () => {
     const stats = deriveStats([
       assistant(1, 1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 900 }),
       assistant(2, 1, { inputTokens: 100, outputTokens: 50 }),
@@ -59,19 +59,53 @@ describe('deriveStats', () => {
     ])
     expect(stats.turns).toBe(2)
     expect(stats.steps).toBe(3)
-    expect(stats.tokens).toBe(1200)
+    expect(stats.inputTokens).toBe(1100)
+    expect(stats.outputTokens).toBe(100)
     expect(stats.cacheHitPct).toBe(82)
   })
 
-  it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => {
+  it('cache hit stays null with no cache accounting; out-of-window tool results ignored', () => {
     const tool: ToolResultNode = {
       kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
       isError: false, callView: null, resultView: null,
     }
     const stats = deriveStats([tool, assistant(1, 1)])
     expect(stats.steps).toBe(1)
+    expect(stats.toolMs).toBe(0)
     expect(stats.cacheHitPct).toBeNull()
   })
+
+  it('sums LLM wall time from assistant timing and tool wall time from call/result pairs', () => {
+    const timed: AssistantMessageNode = {
+      ...assistant(1, 1),
+      timing: { stepStartTime: 1_000, firstTokenTime: 1_200, completedTime: 3_500 },
+    }
+    const untimed: AssistantMessageNode = {
+      ...assistant(2, 1),
+      timing: { stepStartTime: null, firstTokenTime: null, completedTime: 9_000 },
+    }
+    const tool: ToolResultNode = {
+      kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
+      isError: false, callView: null, resultView: null,
+    }
+    const stats = deriveStats([timed, untimed, tool])
+    expect(stats.llmMs).toBe(2_500)
+    expect(stats.toolMs).toBe(3_000)
+  })
+})
+
+describe('formatters', () => {
+  it('formats token counts compactly', () => {
+    expect(formatTokens(517)).toBe('517')
+    expect(formatTokens(12_240)).toBe('12.2K')
+    expect(formatTokens(517_000)).toBe('517K')
+    expect(formatTokens(1_230_000)).toBe('1.2M')
+  })
+
+  it('formats durations under and over a minute', () => {
+    expect(formatDuration(45_230)).toBe('45.2s')
+    expect(formatDuration(162_000)).toBe('2m42s')
+  })
 })
 
 describe('StatsLine', () => {
@@ -79,12 +113,13 @@ describe('StatsLine', () => {
     return { useSession: bindSnapshotSelector(source) }
   }
 
-  it('renders the joined stats row and hides with zero steps', () => {
+  it('renders the grouped stats row and hides with zero steps', () => {
     const { source } = makeSource({
       nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })],
     })
     const view = render(<StatsLine {...props(source)} />)
-    expect(view.getByText('cache hit 90% · 105 tokens · 1 turns · 1 steps')).toBeTruthy()
+    // No timing on the fixture: the duration group drops out whole.
+    expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok')
     const empty = makeSource()
     const emptyView = render(<StatsLine {...props(empty.source)} />)
     expect(emptyView.container.textContent).toBe('')

+ 12 - 1
packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx

@@ -21,10 +21,21 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
 
 const SID = 's1' as SessionId
 
-afterEach(cleanup)
+/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
+class ResizeObserverStub {
+  observe(): void {}
+  unobserve(): void {}
+  disconnect(): void {}
+}
+
+afterEach(() => {
+  cleanup()
+  vi.unstubAllGlobals()
+})
 // The chat store persists under its declared key; clear between cases.
 beforeEach(() => {
   localStorage.clear()
+  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
 })
 
 const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({

+ 1 - 1
packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx

@@ -49,7 +49,7 @@ describe('render branch tails', () => {
     const view = render(
       <StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
     )
-    expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
+    expect(view.container.textContent).toBe('2 turns · 3 steps|Cache hit 0%|Input 9 tok · Output 6 tok')
   })
 
   it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {

+ 15 - 2
packages/client/ui-conversation/tests/skeleton.spec.tsx

@@ -28,8 +28,21 @@ function fakeWiring() {
   return { wiring: shell, sink, shell }
 }
 
-afterEach(cleanup)
-beforeEach(() => { localStorage.clear() })
+/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
+class ResizeObserverStub {
+  observe(): void {}
+  unobserve(): void {}
+  disconnect(): void {}
+}
+
+afterEach(() => {
+  cleanup()
+  vi.unstubAllGlobals()
+})
+beforeEach(() => {
+  localStorage.clear()
+  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+})
 
 const sid = (id: string) => id as SessionId
 const wid = (id: string) => id as WorkspaceId

+ 11 - 10
packages/client/ui-goal/src/client/GoalBar.module.css

@@ -1,11 +1,12 @@
-/* GoalBar: the goal strip docked above the composer card. The dock mirrors
-   InputBar's horizontal geometry (32px side padding, 776px centered cap)
-   plus the mock's 12px inset, so the bar's edges land 12px inside the
-   composer card's edges in both the capped and the squeezed regimes. The
-   negative bottom margin eats InputBar's 8px top padding and tucks the
+/* GoalBar: the goal strip docked above the composer card. The dock's 44px
+   side padding and the bar's 752px cap match the todo strip's column
+   (TodoPanel.module.css), 24px inside the composer card's edges. The
+   negative bottom margin cancels the composer stack's 8px gap and tucks the
    bar's square bottom edge 2px under the composer card's top edge (the
-   card, later in DOM order, paints over it). All states share one fixed
-   38px height so switching between them never resizes the strip. */
+   card, later in DOM order, paints over it). Surface matches the todo
+   strip: tip fill, l1 border — no bottom edge where it disappears under the
+   card. All states share one fixed 38px height so switching between them
+   never resizes the strip. */
 
 .dock {
   padding: 0 44px;
@@ -20,10 +21,10 @@
   height: 38px;
   margin: 0 auto -10px;
   padding: 0 14px;
+  border: 1px solid var(--dsw-alias-border-l1);
+  border-bottom: none;
   border-radius: 14px 14px 0 0;
-  /* Translucent hover gray doubles as the mock's #F5F6F7 over the white
-     base and lifts the strip off the composer card in dark mode. */
-  background: var(--dsw-alias-interactive-bg-hover);
+  background: var(--dsw-specific-tip);
 }
 
 .sparkle {