Files
deepseek-harness/packages/client/ui-conversation
Yichen Jiang 0d53752c49 refactor(host)!: retire the skill.invoke RPC for the gesture boundary
Invocation is an ordinary session.prompt again: the pre-step gesture
boundary makes it deterministic host-side for every front end, so the
dedicated RPC (handler, wire schema, error codes, client face, fixtures)
and ui-skill's claim machinery are net deletions. The menu keeps decision
21 exactly — a pick lands literal /name text — plus the user-only marker
from skill.list's modelInvocable flag.
2026-08-08 13:15:52 +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).

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. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. 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 the scrollport (data-conversation-scroll) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown (decision). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.

Another plugin can make one session's composer inert through ctx.conversation.blocks: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite.

The view ring is a slot: the strict session-body registration declares the session-scoped 'conversation.view' list in its children table, that body renders the active entry through its renderSlot share (only: <active id>), and view tabs project from registration options (id/order/label). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through ctx.slots.register, and each view owns its chrome.

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 runtime manager projects every approval or question wait through SessionSummary.pendingInteraction, including sessions never instantiated; ui-workspace owns its sidebar presentation. 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 whose header names the role the runtime projected for the message — 上下文注入 for an injection, 跨会话召回 for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls 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 and synthesizes no tool state, summary, or keyed toolview dispatch (historical disclosure decision, provenance decision). That body follows the form the producer declared on its durable source: instructions names the reconciled files above their text, catalog lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an 插话 / Interjection caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.

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 an underlined link — it reads as one at rest, not only on hover, because a path styled like the surrounding prose is an affordance nobody finds — and it opens the file through the Host (host.openPath, relative paths resolve against the session cwd). A document a browser renders prefers the default browser where the Host platform can name one; Windows and WSL use the Windows registered association. The Host opens it on the Host's own machine: a client reached over a network sees nothing, which is the deliberate scope of this surface. 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 below its summary row; tool rows are summary surfaces, so 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 keeps the summary 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 Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-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 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. Both render sites show the same complete source list — the one the tool returned and the model saw — bounded only by the card's own scroll container height, with no row-versus-panel cap (decision, source scroll).

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 use the keyed, session-scoped 'conversation.chat.toolview' slot; its render site dispatches via entryKey: toolName with GenericToolCard as the call-site fallback. The owner payload is the uniform ToolRowOwnerProps (callId/toolName/block/openFile), and ToolRowProps composes it with the session standard kit. A registrant is a plain plugin with only the slot service edge: ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)). The declaration is the activation and reload dependency; ConversationService is required only by registrations that call its actions. Trajectory and waterfall toolview slots share this shape and use their own render sites; RendersCheck rejects a declaration nobody renders.

The todo surfaces are two registrations over that shape, both using slot declaration injection without a ConversationService edge. TodoRow takes the 'conversation.chat.toolview' key todo_write and summarizes what the call attempted (<done>/<total> completed · <active item> plus a +<n> count of the other active ones, parsed from its args through toolviews/plan-summary.ts planSummary, 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). When the deployment permits parallel work, several items may be in_progress at once, so planSummary names the first and counts the rest, and deliberately returns the two unjoined: the row ellipsizes its summary text, so a count concatenated onto the end of the task name would be the first thing a narrow row clips. The row hands the count to ToolRow's summarySuffix, the shared row's non-shrinking slot beside that ellipsized text (an error row drops it, since its collapsed summary is the failure line). 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 its own ·-joined per-status counts (localized, 1 completed · 2 in progress · 1 pending, zero-count segments omitted; status glyphs are the figma check / progress / dashed-pending set), so it reports the parallel count without needing a name to truncate. 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; non-user next-step items (injected context) carry the context placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable user/message carrying the steering 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, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action (decision) — 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 the generic token-meter tokenUsage projection read through the standard-kit useProjection: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. 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. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the conversation locale namespace (TTFT avg … · … tok/s in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed TTFT {s}s · {tps} tok/s labels to its assistant footer after the Ran for duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by contextPressure and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the percent used header and ~used / capacity figures with a color-segmented bar and ~-prefixed heuristic composition rows (system prompt, tools, messages) from the contextBreakdown projection. The ring and header read projectedTokens — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header (rationale). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.

src/client/ is organized by domain. contract/ is the sole inter-domain shared face (slots.ts slot declarations and composed props, views.ts shared primitives, tool-call-model.ts); the skeleton/, chat/, and toolviews/ 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 and the store factory stay internal and reach the page through apply's slot registrations.

A finished turn ends with a turn-tail hole: the chat view renders the conversation.chat.turnTail list slot between the closing assistant's body and its IconActions, once per turn at the seq assistantActionsSeqs elects, dispatching TurnTailOwnerProps (the snapshot nodes, the closing seq, and the tool rows' openFile). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' locations, the chip cap, the copy — lives in @deepseek-ai/dsh-client-ui-deliverables, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost.

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 and speeds cover the in-window flow only — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant timing and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
  • The details panel has no entry pointChatViewInjected.openDetails is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link.
  • Assistant per-message paging is a reserved slot — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected (decision).
  • Sent user messages cannot be edited — user bubbles retain clock and copy; branch lives only under assistant answers (decision). Editing 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 has no durable grant control — it supports allow-once and reject only.
  • 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 user/message folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority.