Merge remote-tracking branch 'origin/master' into feat/web-terminal-card

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
Chinesezjc
2026-07-28 21:03:26 +08:00
407 changed files with 6332 additions and 3102 deletions
@@ -38,7 +38,7 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
summary={model.summary}
// Single-file tools never expose an args body — the path link is the only action.
body={singleFile ? null : model.body}
terminal={terminalCardModel(block)}
terminal={terminalCardModel(block, cwd)}
state={model.state}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}
@@ -9,7 +9,7 @@
* @module
*/
import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
/**
* Output lines the chat row's expanded terminal body shows before collapsing
@@ -33,6 +33,24 @@ export type TerminalCardModel = Pick<
'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'
>
/**
* Resolve a terminal view's working directory the way the render-intent
* contract assigns to the UI bridge: an absolute path is used as-is, a relative
* one joins under the session workspace, and an omitted one IS the session
* workspace. A pure presenter cannot see the session cwd, which is why this
* resolution belongs here rather than in the tool. Without a session cwd there
* is nothing to resolve against, so a relative path stays as authored and an
* omitted one stays absent (the prompt row then draws a bare `$`).
* @param viewCwd - the cwd the terminal call view carries, if any.
* @param sessionCwd - the session workspace root, if the caller knows it.
* @returns the working directory for the prompt label, or undefined.
*/
function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
if (viewCwd === undefined || viewCwd === '') return sessionCwd
if (sessionCwd === undefined || sessionCwd === '') return viewCwd
return resolveToolPath(sessionCwd, viewCwd)
}
/**
* Derive the terminal-card props for a tool call, or null when this call is
* not a terminal card and belongs on the generic path.
@@ -55,15 +73,17 @@ export type TerminalCardModel = Pick<
* result view's replacement title, then to an empty command (the prompt line
* draws bare), and the prompt shows no cwd.
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @param sessionCwd - the session workspace root, which resolves an omitted or
* relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved.
* @returns the terminal-card props, or null for the generic path.
*/
export function terminalCardModel(block: ToolCallBlock): TerminalCardModel | null {
export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): TerminalCardModel | null {
const call = block.callView?.card === 'terminal' ? block.callView : null
if (!('kind' in block)) {
// Running: the call view exists, the result view does not yet.
return call === null ? null : {
command: call.title,
cwd: call.cwd,
cwd: resolveTerminalCwd(call.cwd, sessionCwd),
output: undefined,
exitCode: undefined,
signal: undefined,
@@ -73,8 +93,11 @@ export function terminalCardModel(block: ToolCallBlock): TerminalCardModel | nul
const result = block.resultView?.card === 'terminal' ? block.resultView : null
if (result === null) return null
return {
command: call?.title ?? result.title ?? '',
cwd: call?.cwd,
// The result's title REPLACES the pending one when the tool supplies it
// (the presentation contract's replacement-title rule); the call title is
// what a result without one keeps.
command: result.title ?? call?.title ?? '',
cwd: resolveTerminalCwd(call?.cwd, sessionCwd),
output: result.output,
exitCode: result.exitCode,
signal: result.signal,
@@ -68,8 +68,11 @@ function pretty(raw: string): string {
}
}
export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails }: DetailsPanelProps) {
const selection = useStore(s => s.selection)
// Session workspace root: an omitted or relative terminal cwd resolves
// against it, which the pure presenter cannot see.
const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
const callId = selection?.callId
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
// stable members (result node reference rides the snapshot's structural sharing).
@@ -107,7 +110,11 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
)}
<section className={css.section}>
<div className={css.sectionLabel}>Output</div>
<OutputBody material={material} />
{/* Keyed by the selected call: the body owns per-call view
state (the terminal card's expand and copy), which React
would otherwise carry into the next selection because the
panel does not unmount between calls. */}
<OutputBody key={callId} material={material} cwd={sessionCwd} />
</section>
</>
)}
@@ -123,10 +130,11 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
* its alignment and scrolls sideways instead of folding. Every other call, and
* a running call with no terminal card yet, keeps the flattened text form.
* @param props.material - the selected call's material from {@link materialFor}.
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
* @returns the Output section's body element.
*/
function OutputBody({ material }: { material: CallMaterial }) {
const terminal = terminalCardModel(material.block)
function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
const terminal = terminalCardModel(material.block, cwd)
if (terminal !== null) return <TerminalBlock {...terminal} className={css.terminal} />
// A settled call always carries the result node the flattened form needs;
// the running shape has no result to flatten.
@@ -1,9 +1,9 @@
// TodoPanel: persistent plan strip above the composer (the web counterpart
// of the TUI plan panel). Renders the latest todo/write whole-list snapshot
// no data of its own, hidden while the list is empty. Mounted through the
// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does
// the selecting, so the panel takes the plain list and stays framework-free.
// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded).
// TodoPanel: plan strip above the composer (the web counterpart of the TUI
// plan panel). Renders the standing todo/write whole-list snapshot (cleared on
// the next turn/start) — no data of its own, hidden while the list is empty.
// Mounted through the 'conversation.input.dock' slot (QueueDock posture): the
// dock adapter does the selecting, so the panel takes the plain list and stays
// framework-free. Visual: figma 772:51905 / 772:52972 / 772:53419.
import { useId, useState } from 'react'
import type { Context } from 'cordis'
@@ -45,7 +45,10 @@ function stateStatus(state: ToolRowState): string | null {
*/
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const terminal = terminalCardModel(block)
// Session workspace root: the terminal view's cwd resolves against it (an
// omitted workdir IS the workspace), which the pure presenter cannot do.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
const terminal = terminalCardModel(block, cwd)
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
const status = stateStatus(model.state)
return (