Merge branch 'master' into codex/details-default-closed

This commit is contained in:
imccyu
2026-07-30 14:50:50 +08:00
committed by GitHub
106 changed files with 859 additions and 399 deletions

View File

@@ -76,8 +76,8 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy
Run the narrowest rung that covers what you touched; escalate only when the change surface demands it.
1. **Every GUI code change**`pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck.
2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=refresh` rewrites their aria goldens after an intentional conversation-UI change; `DSH_SNAPSHOT=record` re-records fixtures with a key).
3. **Before a PR**`pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit.
2. **Any change that can alter the assembled browser or visible conversation/UI output** (client components or copy, `apps/web`, Vite, `dsh-host-webserver`, connection/handler/SSE) — additionally `DSH_SNAPSHOT=replay pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios. Linux PR CI uses the same read-only replay mode. Use `DSH_SNAPSHOT=refresh` only after confirming an intentional output change, or `DSH_SNAPSHOT=record` with a key to re-record fixtures.
3. **Before a PR**use [dsh-pre-push-checks](../../.agents/skills/dsh-pre-push-checks/SKILL.md) to select the narrow checks for the outgoing diff; there is no repo-wide pre-push aggregate.
If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.
@@ -97,5 +97,5 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the l
2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery.
4. Tokens only in CSS; Chinese product copy; English comments.
5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface).
5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`.
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.

View File

@@ -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

View File

@@ -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.

View File

@@ -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。

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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>
)
})

View File

@@ -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
}

View File

@@ -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

View File

@@ -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>
)

View File

@@ -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 {

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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[] = [

View File

@@ -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')
})
})

View File

@@ -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'

View File

@@ -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('')

View File

@@ -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 => ({

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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 {

View File

@@ -11,7 +11,7 @@ export const zh = {
'menu.aria': '模型与推理等级',
'menu.model': '模型',
'menu.effort': '推理等级',
'effort.providerDefault': '服务商默认',
'effort.providerDefault': 'Default',
'status.loading': '正在刷新模型列表…',
'error.action': '模型操作失败:{message}',
'action.reload': '重新加载',
@@ -35,7 +35,7 @@ export const en = {
'menu.aria': 'Model and reasoning effort',
'menu.model': 'Model',
'menu.effort': 'Effort',
'effort.providerDefault': 'Provider default',
'effort.providerDefault': 'Default',
'status.loading': 'Refreshing model list…',
'error.action': 'Model operation failed: {message}',
'action.reload': 'Reload',

View File

@@ -48,9 +48,9 @@ afterEach(cleanup)
describe('ModelSelect reasoning effort', () => {
it('renders adapter metadata and submits the effort as part of the session target', async () => {
const directory = createSnapshotStore(state())
const directory = createSnapshotStore<ModelDirectoryState>(state())
const select = vi.fn(async (target: ModelTarget) => {
directory.update((snapshot) => { snapshot.current = target })
directory.set(state({ current: target }))
return true
})
render(<ModelSelect
@@ -102,10 +102,10 @@ describe('ModelSelect reasoning effort', () => {
/>)
fireEvent.click(screen.getByRole('button', {
name: '选择模型,当前 Model推理等级 服务商默认',
name: '选择模型,当前 Model推理等级 Default',
}))
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
.toEqual(['服务商默认', 'Standard'])
.toEqual(['Default', 'Standard'])
})
})