Files
deepseek-harness/packages/client/ui-conversation

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

Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's compact/summary provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders.

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 shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. 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.

The session header declares and renders the session-scoped 'conversation.session.header.actions' list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation session; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and session.cancel would bypass its ownership.

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 body follows its content height up to a 141px scrolling cap, shows inline JSON for both content and source, and synthesizes no tool state, summary, or keyed toolview dispatch (decision).

A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge (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; it composes the shared ToolRow, feeding the card as ToolRow's web body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the GenericToolCard fallback, which routes the card through ToolRow the same way, 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 read call declaring the read render intent renders the returned file window inline, at both conversation render sites, through ui-primitives' ReadBlock — the line-numbered, syntax-highlighted content the tool projects. contract/read-card-model.ts is the single derivation from the snapshot's resultView; the read card is result-side only (a call carries no file content until execute returns), so a running read shows its summary alone and it yields null — the generic path — for a non-read result view or a card tag this client version does not know. The keyed ReadRow composes the shared ToolRow, feeding the card as ToolRow's read body, so it is the row's collapsed-by-default expanded card; the summary stays a path link that opens the file through the host. The render-site fallback and the details panel are read-aware too. Rows cap at CHAT_READ_MAX_LINES (8) against the panel's 16 (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) composes the shared ToolRow, feeding the diff as ToolRow's diff body, so it is the row's collapsed-by-default expanded card; the summary path link still opens the file through the host, and an errored mutation (no diff card) surfaces its error text through ToolRow's Output section with the first line in the collapsed summary. 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. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments.

A grep/glob call declaring the search render intent renders its result inline, at the same render sites, through ui-primitives' SearchBlock — grep's matches grouped by file (each a collapsible header of lineNumber: line rows), glob's flat path list. contract/search-card-model.ts is the single derivation from the snapshot's resultView; unlike the terminal card it reads no callView, since a search has no matches or paths before execute, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a card or kind this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose files/paths is malformed. The keyed SearchRow, registered under both grep and glob since the derived kind decides the shape, composes the shared ToolRow, feeding the card as ToolRow's search body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at CHAT_SEARCH_MAX_LINES (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's Full … stored at … footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested run_code sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary (decision).

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); 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: 0 — before Goal and Queue — 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 ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.

The Host's placement-aware session/queue snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable steering/message has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same MessageId. The bubble therefore hands off without a gap or duplicate, restores Copy and Fork from the durable node, and survives reconnect from the same authority.

Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort session.prompt(mode: 'steer') contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.

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.

The chat stats line takes its token accounting from two generic token-meter projections read through the standard-kit useProjection: tokenUsage for full-log billing (billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total) and contextPressure for context occupancy. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. A deployment without token-meter drops the token groups, and occupancy stays hidden until both provider pressure and route capacity are known. Occupancy is deliberately an approximation — its numerator and capacity are independent last-wins projection fields, not one atomic request observation (rationale). The inline stats row remains the sole context UI; the model selector has no circle or accessory.

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

  • Compaction markers show no scale — the row does not yet report how many messages or which range the checkpoint replaced.
  • 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.
  • Sent user messages cannot be edited — the user bubble's IconActions row carries clock / copy / branch only, and branching from the message is the nearest gesture. The control returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it (decision).
  • 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 and strict steer with save and cancel; Enter saves and Escape cancels.
  • Queue strict steer preserves complete messages — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed steering/message folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority.