Files
deepseek-harness/packages/client/ui-conversation
creatixchu cb754a0319 feat(locale): derive the initial Settings language from the browser
A first visit resolved to Chinese regardless of the browser: LocaleService
read `dsh.locale` and fell straight back to `zh` when nothing was stored,
ignoring the languages the browser already states it reads.

The initial locale now resolves through three ordered sources — the persisted
preference, then `navigator` (first entry of the ordered language list whose
primary subtag names a shipped locale, so `zh-Hans-CN` -> zh and `en-GB` ->
en), then `FALLBACK_LOCALE`. An explicit choice still wins and nothing writes
the detected locale back to storage, so "has the user chosen?" stays a
question only the stored value answers.

Specs asserting the shipped Chinese copy now state the browser they assume:
the web e2e scenarios open their page with `locale: ZH_BROWSER_LOCALE`, and
package specs pin it through the new `pinBrowserLanguages` test helper.
`settings-chrome.e2e.ts` gains an English-browser scenario as the
assembled-app proof.
2026-07-31 15:26:46 +08:00
..

@deepseek-ai/dsh-client-ui-conversation

English | 中文

Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient Deep diving... turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).

The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped conversation.hero.workspace slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (data-conversation-scroll) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.

The view ring IS a slot: the conversation registration declares the 'conversation.view' list slot (session scope) in its children table, ConversationRoot renders the active entry through its renderSlot share (only: <active id>), and view tabs project from the ring ledger's registration options (id/order/label). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain ctx.slots.register — the former package-local view registry (registerView/ViewEntry/ConversationViewMap and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.

Approvals take over the composer through the chain this package declares: ApprovalPanel registers as a selector-routed 'conversation.composer' entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The PendingApproval domain face in contract/slots.ts owns the wire encoding — the ApprovalResponsePayload value with the audit correlation — over the runtime's PendingWait carrier; the broadcast approval/resolved frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked waitingApproval list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts PermissionSelect, fed by the host-computed permissions projection through the standard-kit useProjection (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit /permission <preset> immediately through the bar's injected command callback, while danger-full-access is presented as Full access and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.

Logged non-user messages render as a default-collapsed 上下文注入 disclosure. It shares the Tool calls header geometry and interaction with ToolRow through the package-internal DisclosureRow, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both content and source, and no tool state, summary, or keyed toolview dispatch is synthesized (decision).

Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (host.openPath, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored description and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting Inspect, Mount temporary Plugin, and Unmount temporary Plugin with a shared Cordis accent; mount keeps the code variant's expandable source rendering.

A tool call declaring the terminal render intent renders its command output inline, at both conversation render sites, through ui-primitives' TerminalBlock. contract/terminal-card-model.ts is the single derivation from the snapshot's callView/resultView pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same StateDot semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed BashRow carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at CHAT_TERMINAL_MAX_LINES (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only (decision).

A tool call declaring the web render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' WebBlock. contract/web-card-model.ts is the single derivation from the snapshot's resultView, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a card tag this client version does not know, or a web card whose kind this client version does not know (a newer host's value, which the wire cannot be trusted to be search or fetch). The keyed WebRow registers one component under both web_search and web_fetch, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the GenericToolCard fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at CHAT_WEB_MAX_SOURCES (8) against the panel's 16, the same summary-versus-reading split the terminal card draws (decision).

A tool call declaring the diff render intent (the write/edit tools) renders its applied change inline through ui-primitives' DiffBlock, the same four-layer shape. contract/diff-card-model.ts is the single derivation from the callView/resultView pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed FileMutationRow (registered under both write and edit) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at CHAT_DIFF_MAX_LINES (8) against the panel's 16 (decision).

The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show . Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.

Tool rows are slots too — the standalone tool ring (ToolViewRegistry/ctx.toolviews/outlet) is retired. The chat entry declares the keyed 'conversation.chat.toolview' hole (session scope; the key space is runtime-open); its render site dispatches per row via entryKey: toolName with GenericToolCard as the call-site fallback. The owner payload is the uniform ToolRowOwnerProps (callId/toolName/block/openFile) and ToolRowProps pre-composes it with the session standard kit. A registrant is a plain plugin: ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row) with inject: ['slots', 'conversation'] as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (useSessions reading parentId — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).

The todo surfaces are two registrations over that shape, both plain registrant plugins with inject: ['slots', 'conversation']. TodoRow takes the 'conversation.chat.toolview' key todo_write and summarizes what the call attempted (<done>/<total> 已完成 · <active item> parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). TodoDock takes the 'conversation.input.dock' list slot at order: -1 — above the queue rows — and is the plan strip: it reads the host-computed todos projection via useProjection (standing plan: latest todo/write with no later turn/start) and renders TodoPanel, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus "<done>/<total> tasks · <n> in progress" (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a conversation.composer takeover such as ui-question's) hides the whole dock, this strip included.

QueueDock is the terminal input-dock entry at order: 20. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed "<n> 条排队消息" header whose button expands or collapses the complete list. The header exposes aria-expanded and aria-controls; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit and delete actions.

Per-session UI state for selection and the active view lives in the declared chat store (stores.ts createChatStore); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies useSession/sessionId, global useSessions/useWorkspaces, and the input machine's useInput/inputActions; store faces and inject factories supply the remaining state and callbacks.

The composer bar declares session-scoped single seats for 'conversation.input.plan' (right of the local access-mode control) and 'conversation.input.model' (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the locked owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's SlashController to open only the / trigger's command source over the current textarea selection, while ui-slash's existing MenuView remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the plan projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the conversation locale namespace this package registers (the placeholder.plan / hint.plan keys) and shared verbatim with the claimed /plan command hint (a host-folded value read through the standard-kit useProjection; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is session-maybe: with no current session the same bar renders inert (machine faces absent, disabled owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.

src/client/ is organized for the future package split: contract/ is the sole inter-domain shared face (slots.ts slot declarations + composed slot props including the tool-row contract, views.ts shared primitives, tool-call-model.ts); the skeleton/, chat/, and toolviews/ (sample registrants) domain directories import contract files and never each other; apply.ts is the only assembly point allowed to import all three domains. The /client export surface is the contract only — apply/inject, the two service classes, and the contract/ type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the ./src/* subpath).

Model Experience

None, as the conversation UI renders session history and streams in the browser; nothing here reaches a model request.

KV Cache effect

None; this package neither assembles nor sends a provider request.

Known Limitations and Deferred Work

  • 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 the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.
  • 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.
  • The approval panel's "Always allow this type" is deferred — durable grants need a grant-storage design; only allow-once/reject answer today.
  • TodoPanel truncates long item text to one ellipsized line — the figma strip has no wrap or expand affordance; full text is not readable inline.
  • Queue edit is text-only — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control.
  • Web exposes pending Queue only — the Host omits pending steering from the Queue snapshot until steering has its own interaction. A consumed steering/message still renders in the durable transcript so external steering remains truthful on replay.