Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt
#	apps/web/tests/snapshots/queue-actions/preserved.expected.md
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt
This commit is contained in:
_Kerman
2026-08-03 17:15:34 +08:00
187 changed files with 4546 additions and 590 deletions

View File

@@ -4,9 +4,10 @@
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial;
// the turn-level loading dots live in the chat view's tail, not here.
// Finalized turn-tail content (text) nodes append IconActions once streaming
// ends (`time` is omitted for mid-turn narration); Think / tool-head-only
// nodes stay chrome-free.
// Finalized content (text) nodes append IconActions once streaming ends
// (`time` is omitted for mid-turn narration); their branch action is enabled
// only when the node is also the completed turn's transcript tail. Think /
// tool-head-only nodes stay chrome-free.
import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -28,8 +29,10 @@ export interface AssistantMarkdownProps {
time?: number | undefined
/** Event sequence used as the fork boundary; omitted while streaming. */
seq?: number | undefined
/** Fork the session through the turn containing this finalized message. */
/** Fork the session through this finalized message's completed turn when eligible. */
onFork?: ((seq: number) => void) | undefined
/** The message is not the transcript tail of a completed turn. */
forkUnavailable?: boolean | undefined
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -76,7 +79,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, seq, onFork, t,
blocks, streaming, interrupted, time, seq, onFork, forkUnavailable, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
@@ -120,6 +123,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
time={time}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
branchUnavailable={forkUnavailable}
className={css.actions}
t={t}
/>

View File

@@ -30,7 +30,7 @@ import type {
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -236,6 +236,7 @@ export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
const turnEnds = useSession(s => s.turnEnds)
const inbox = useSession(s => s.queue)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -257,6 +258,7 @@ export function ChatView({
// Only the last content assistant of each turn owns IconActions; mid-turn
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
@@ -413,6 +415,7 @@ export function ChatView({
time={actionSeqs.has(node.seq) ? node.time : undefined}
seq={node.seq}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
t={t}
/>
)
@@ -428,6 +431,7 @@ export function ChatView({
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
t={t}
/>
)

View File

@@ -43,3 +43,23 @@
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
/* Unavailable stays focusable and hoverable so Tooltip can explain why. */
.action[data-unavailable] {
cursor: default;
opacity: 0.4;
}
.action[data-unavailable]:hover {
background: transparent;
color: var(--dsw-alias-label-tertiary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,7 +1,7 @@
// Shared IconActions chrome for user and assistant messages: copy
// live, optional branch wiring, and an optional date-aware clock.
import { useCallback } from 'react'
import { useCallback, useId } from 'react'
import {
IconBranchOutline16, IconCopyOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -17,9 +17,11 @@ export interface MessageIconActionsProps {
time?: number | undefined
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** Fork the session at this message. */
/** Fork the session at this message; omission hides the branch action. */
onBranch?: (() => void) | undefined
/** Whether to render the branch action; defaults to true. */
/** The message is not a completed transcript tail, so branch stays visible but unavailable. */
branchUnavailable?: boolean | undefined
/** Additional branch visibility gate for transient message chrome; defaults to true. */
showBranch?: boolean | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
@@ -33,9 +35,10 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, onBranch, showBranch = true, className, t,
text, time, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
@@ -52,13 +55,25 @@ export function MessageIconActions({
<IconCopyOutline16 />
</button>
</Tooltip>
{showBranch && (
<Tooltip label={t('message.branch')} side="bottom">
<button type="button" className={css.action} aria-label={t('message.branch')} onClick={onBranch}>
{showBranch && onBranch !== undefined && (
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
<button
type="button"
className={css.action}
aria-label={t('message.branch')}
aria-disabled={branchUnavailable || undefined}
aria-describedby={branchUnavailable ? reasonId : undefined}
data-unavailable={branchUnavailable || undefined}
onClick={branchUnavailable ? undefined : onBranch}
>
<IconBranchOutline16 />
</button>
</Tooltip>
)}
{showBranch && onBranch !== undefined && branchUnavailable && (
<span id={reasonId} className={css.visuallyHidden}>{t('message.branchUnavailable')}</span>
)}
{clock === 'end' ? clockEl : null}
</div>
)

View File

@@ -25,8 +25,10 @@ export interface MessageItemProps {
| TurnErrorNode
| UnknownSurfaceNode
retryActive?: boolean
/** Fork the session through the turn containing this message (user-bubble branch action). */
/** Fork through this message's completed turn when eligible. */
onFork?: (seq: number) => void
/** The message is not the transcript tail of a completed turn. */
forkUnavailable?: boolean
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -219,7 +221,7 @@ export function PendingSteeringBubble({ content, t }: {
}
export const MessageItem = memo(function MessageItem({
node, retryActive = false, onFork, t,
node, retryActive = false, onFork, forkUnavailable = false, t,
}: MessageItemProps) {
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
@@ -234,6 +236,7 @@ export const MessageItem = memo(function MessageItem({
time={node.time}
clock="start"
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
branchUnavailable={forkUnavailable}
className={css.actions}
t={t}
/>

View File

@@ -5,8 +5,8 @@
* reuse the first notice's row while projecting the latest retry turn.
* Item identity keys are stable across snapshots so the list parent can
* subscribe to keys only while rows subscribe to content. IconActions ownership
* (last content assistant per turn) is derived here too so ChatView and the
* flow share one gate.
* and completed-turn branch points are derived here too so ChatView and the
* flow share their gates.
*/
import type {
AssistantBlock, ConversationNode, ToolResultNode,
@@ -47,6 +47,38 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
return new Set(lastByTurn.values())
}
/**
* Seq set of message rows that may fork: the last transcript node of a
* completed turn, when that node owns message chrome. A later tool, reasoning,
* error, or other transcript node leaves the earlier message's branch action
* unavailable because the Host would include the whole turn.
* @param nodes - snapshot nodes in event order.
* @param turnEnds - completed turn boundaries retained from the event window.
* @returns Message seq values whose visible position matches the fork boundary.
*/
export function messageBranchSeqs(
nodes: readonly ConversationNode[],
turnEnds: ReadonlyMap<number, number>,
): ReadonlySet<number> {
const result = new Set<number>()
const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1])
let nodeIndex = 0
for (const [turn, endSeq] of boundaries) {
let tail: ConversationNode | undefined
while (nodeIndex < nodes.length) {
const candidate = nodes[nodeIndex]
if (candidate === undefined || candidate.seq > endSeq) break
tail = candidate
nodeIndex++
}
if (tail?.kind === 'user'
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
result.add(tail.seq)
}
}
return result
}
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes in human-transcript and durable-notice order.

View File

@@ -461,7 +461,7 @@ export interface ChatViewInjected {
/** Last recorded offset, or null when pinned or never recorded. */
read: () => number | null
}
/** Fork the session through the turn containing the message at `seq`, then open the child. */
/** Fork through the completed turn ending at the eligible message `seq`, then open the child. */
forkAt: (seq: number) => void
}

View File

@@ -59,6 +59,7 @@ export const zh = {
'message.unknownBlock': '未知内容块',
'message.stopped': '已停止',
'message.branch': '在新对话中分支',
'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支',
'message.retry.active': '正在重试模型请求',
'message.retry.cancelled': '模型请求重试已取消',
'message.retry.started': '已重试模型请求',
@@ -166,6 +167,7 @@ export const en = {
'message.unknownBlock': 'Unknown content block',
'message.stopped': 'Stopped',
'message.branch': 'Branch into a new conversation',
'message.branchUnavailable': 'Available only on the last message of a completed turn',
'message.retry.active': 'Retrying model request',
'message.retry.cancelled': 'Model request retry cancelled',
'message.retry.started': 'Retried model request',

View File

@@ -208,6 +208,26 @@
);
}
/* Views may opt into a composer overlay while ConversationRoot retains
ownership of the seat geometry and its active-phase precedence. */
.scrollBody:has([data-conversation-composer-overlay]) {
position: relative;
overflow: hidden;
}
.scrollBody:has([data-conversation-composer-overlay]) > .viewArea {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.scrollBody:has([data-conversation-composer-overlay]) > .composerSeat {
position: absolute;
right: 0;
bottom: 0;
left: 0;
}
/* Hero phase: the composer stack (hero chrome + workspace row + card) is
flex-centered in the column; composer phase docks it at the bottom. Flex,
NOT absolute+transform: a transform would make this box the containing

View File

@@ -20,6 +20,63 @@
border: 1px solid var(--dsw-alias-border-l1);
}
/* A bash execution error can settle without terminal-card material (for
example, command cancellation). Preserve ToolRow's bounded IN/OUT fallback
so the original command and full error remain available from this keyed row. */
.ioCard {
display: flex;
flex-direction: column;
margin: 4px 0 4px 4px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 12px;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block-small);
}
.ioSection {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 14px;
align-items: baseline;
padding: 12px 16px;
max-height: 150px;
overflow-y: auto;
}
.ioSection::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 6px;
}
.ioSection::-webkit-scrollbar-track {
margin: 6px 0;
}
.ioLabel {
position: sticky;
top: 0;
align-self: start;
color: var(--dsw-alias-label-caption);
}
.ioDivider {
flex: none;
height: 1px;
background: var(--dsw-alias-border-l2);
}
.ioText {
min-width: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--dsw-alias-label-secondary);
}
.ioText[data-error] {
color: var(--dsw-alias-state-error-primary);
}
/* ToolRow's unified expand interaction, replicated per the registrant
posture: pointer on the expandable row (the icon→chevron hover preview is
the affordance, no row fill). */

View File

@@ -2,8 +2,10 @@
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
//
// A bash call declares the terminal render intent, so this row renders the
// command's own output through TerminalBlock — expand-gated exactly like
// A bash call normally declares the terminal render intent, so this row renders
// the command's own output through TerminalBlock. Execution failures that
// settle without terminal material use the bounded generic IN/OUT fallback —
// both are expand-gated exactly like
// ToolRow's unified interaction: collapsed by default, the whole summary row
// is the toggle (click / Enter / Space, icon→chevron hover preview; the
// summary stays inline while open),
@@ -48,7 +50,7 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
/**
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, the
* whole row toggling the command's terminal card (ToolRow's unified
* whole row toggling the command's terminal or generic error card (ToolRow's unified
* expand interaction, replicated locally per the registrant posture).
*/
export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) {
@@ -64,7 +66,13 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
: model.state
const status = stateStatus(state, t)
const [expanded, setExpanded] = useState(false)
const expandable = terminal !== null
// Execution failures (for example cancellation before the process reports a
// terminal result) use the generic presenter. Keep their recorded args and
// full error reachable instead of collapsing the row to the first line.
const genericError = terminal === null
&& model.state === 'error'
&& (model.body !== null || model.output !== null)
const expandable = terminal !== null || genericError
const open = expanded && expandable
const failureLine = model.state === 'error' ? model.errorSummary : null
const toggleExpand = () => {
@@ -109,16 +117,40 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
{failureLine ?? terminal?.description ?? model.summary}
</span>
</div>
{terminal !== null && open && (
{open && (
/* Same hover-Inspect posture as ToolRow's expanded body, replicated
locally per the registrant posture. */
<div className={css.bodyWrap}>
<TerminalBlock
{...terminal.card}
maxLines={Infinity}
labels={terminalBlockLabels(t)}
className={css.terminal}
/>
{terminal !== null
? (
<TerminalBlock
{...terminal.card}
maxLines={Infinity}
labels={terminalBlockLabels(t)}
className={css.terminal}
/>
)
: (
<div className={css.ioCard}>
{model.body !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>IN</span>
<span className={css.ioText}>{model.body}</span>
</div>
)}
{model.body !== null && model.output !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{model.output !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>OUT</span>
<span className={css.ioText} data-error>
{model.output}
</span>
</div>
)}
</div>
)}
{inspect !== undefined && (
<button type="button" className={css.inspectButton} onClick={inspect}>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>