Merge remote-tracking branch 'origin/master' into mergebot/pr1008

# Conflicts:
#	examples/web-cordis/cordis.yml
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/chat/ToolRow.module.css
#	packages/client/ui-conversation/src/client/chat/ToolRow.tsx
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
#	packages/client/ui-conversation/tests/chat-view.spec.tsx
This commit is contained in:
imccyu
2026-07-31 01:16:34 +08:00
513 changed files with 14220 additions and 4037 deletions

View File

@@ -280,6 +280,13 @@ export function apply(ctx: Context): void {
},
read: () => chatScrollTops.get(sessionId) ?? null,
},
forkAt: (seq) => {
sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
.then((childId) => { sessions.open(childId) })
.catch(() => {
// Fork or child-rename failure keeps the source view untouched.
})
},
}
},
}, ChatView)

View File

@@ -23,6 +23,10 @@ export interface AssistantMarkdownProps {
interrupted?: boolean | undefined
/** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
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. */
onFork?: ((seq: number) => void) | undefined
}
function firstLine(text: string): string {
@@ -59,7 +63,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time,
blocks, streaming, interrupted, time, seq, onFork,
}: AssistantMarkdownProps) {
const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so
@@ -90,6 +94,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
text={copyText(blocks)}
time={time}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
className={css.actions}
/>
)}

View File

@@ -240,7 +240,7 @@ function StreamingTail({ useSession, onGrow }: {
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll,
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
@@ -402,6 +402,8 @@ export function ChatView({
streaming={false}
interrupted={node.interrupted}
time={node.time}
seq={node.seq}
onFork={forkAt}
/>
)
}
@@ -410,7 +412,7 @@ export function ChatView({
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} />
return <MessageItem key={item.key} node={node} onFork={forkAt} />
}
return (

View File

@@ -0,0 +1,29 @@
/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px clipped code block. */
.root {
min-width: 0;
}
.root[data-open] {
padding-bottom: 4px;
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
.body {
box-sizing: border-box;
width: calc(100% - 22px);
height: 141px;
margin: 4px 0 0 22px;
overflow: auto;
padding: 10px 16px 12px 12px;
border: none;
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
font: 400 11px/16px var(--ds-font-family-code);
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@@ -0,0 +1,81 @@
import { useMemo, useState } from 'react'
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow } from './DisclosureRow.tsx'
import css from './ContextInjectionRow.module.css'
const MAX_CHARS = 20_000
function inlineJson(payload: unknown): string {
const raw = JSON.stringify(payload)
let formatted = ''
let quoted = false
let escaped = false
for (let index = 0; index < raw.length; index++) {
const char = raw.charAt(index)
if (quoted) {
formatted += char
if (escaped) escaped = false
else if (char === '\\') escaped = true
else if (char === '"') quoted = false
continue
}
if (char === '"') {
quoted = true
formatted += char
continue
}
if (char === '{' || char === '[') {
formatted += char
const close = char === '{' ? '}' : ']'
if (raw[index + 1] !== close) formatted += ' '
continue
}
if (char === '}' || char === ']') {
const open = char === '}' ? '{' : '['
if (raw[index - 1] !== open) formatted += ' '
formatted += char
continue
}
formatted += char === ':' || char === ',' ? `${char} ` : char
}
return formatted
}
/** Props for the logged non-user message presentation. */
export interface ContextInjectionRowProps {
content: ContextMessageNode['content']
source: ContextMessageNode['source']
}
/**
* Render logged context with the Tool calls disclosure chrome from Figma.
* @param props - Durable content and source provenance.
* @returns A collapsed context row with a bounded JSON body.
*/
export function ContextInjectionRow({ content, source }: ContextInjectionRowProps) {
const [open, setOpen] = useState(false)
const body = useMemo(() => {
if (!open) return ''
const text = inlineJson({ content, source })
return text.length > MAX_CHARS
? `${text.slice(0, MAX_CHARS)}\n… 已截断,共 ${text.length} 字符`
: text
}, [content, open, source])
return (
<DisclosureRow
className={css.root}
icon={<IconBrowseOutline16 size={14} />}
chevronClassName={css.chevron}
title="上下文注入"
open={open}
expandable
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<pre className={css.body} data-context-injection-body>{body}</pre>
</DisclosureRow>
)
}

View File

@@ -0,0 +1,69 @@
/* Shared Tool calls disclosure header: [16px leading] gap 6 [title 14/24]. */
.root {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
}
.row {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
.row[data-expandable] {
cursor: pointer;
}
.leading {
position: relative;
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
padding: 0;
border: none;
background: none;
color: var(--dsw-alias-label-tertiary);
}
button.leading {
cursor: pointer;
}
.iconIdle {
display: inline-flex;
opacity: 1;
transition: opacity 100ms ease;
}
.chevronHover {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: opacity 100ms ease;
}
.row:hover .iconIdle {
opacity: 0;
}
.row:hover .chevronHover {
opacity: 1;
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}

View File

@@ -0,0 +1,104 @@
import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './DisclosureRow.module.css'
/** Shared 24px disclosure chrome for conversation flow rows. */
export interface DisclosureRowProps {
icon: ReactNode
title: string
open: boolean
expandable: boolean
onToggle: () => void
/** Makes the complete title row the disclosure target. */
expandOnRowClick?: boolean | undefined
/** Replaces the collapsed icon with a chevron while the row is hovered. */
previewChevron?: boolean | undefined
/** Keeps `collapsedContent` inline while open (ToolRow's summary stays readable next to the expanded card). */
keepContentWhenOpen?: boolean | undefined
collapsedContent?: ReactNode
children?: ReactNode
className?: string | undefined
rowClassName?: string | undefined
leadingClassName?: string | undefined
chevronClassName?: string | undefined
titleClassName?: string | undefined
}
/**
* Render one disclosure header and its controlled expanded content.
* @param props - Visual content, controlled state, and interaction policy.
* @returns The disclosure row.
*/
export function DisclosureRow({
icon,
title,
open,
expandable,
onToggle,
expandOnRowClick = false,
previewChevron = expandable,
keepContentWhenOpen = false,
collapsedContent,
children,
className,
rowClassName,
leadingClassName,
chevronClassName,
titleClassName,
}: DisclosureRowProps) {
const rowExpands = expandable && expandOnRowClick
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
onToggle()
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
onToggle()
}
const collapsedLeading = previewChevron
? (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={clsx(chevronClassName, css.chevronHover)} />
</>
)
: icon
const leading = open
? <IconChevronDownOutline14 className={chevronClassName} />
: collapsedLeading
return (
<div className={clsx(css.root, className)} data-open={open || undefined}>
<div
className={clsx(css.row, rowClassName)}
data-disclosure-row
data-expandable={rowExpands || undefined}
role={rowExpands ? 'button' : undefined}
tabIndex={rowExpands ? 0 : undefined}
aria-expanded={rowExpands ? open : undefined}
onClick={rowExpands ? onToggle : undefined}
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
>
{expandable && !rowExpands ? (
<button
type="button"
className={clsx(css.leading, leadingClassName)}
aria-expanded={open}
onClick={toggleFromLeading}
>
{leading}
</button>
) : (
<span className={clsx(css.leading, leadingClassName)}>
{leading}
</span>
)}
<span className={clsx(css.title, titleClassName)}>{title}</span>
{(keepContentWhenOpen || !open) && collapsedContent}
</div>
{open && children}
</div>
)
}

View File

@@ -1,5 +1,6 @@
// Shared IconActions chrome for user and assistant messages: copy / branch
// live (branch still a stub), date-aware clock, optional edit stub.
// Shared IconActions chrome for user and assistant messages: copy live,
// branch wired through onBranch, date-aware clock,
// optional edit stub.
import { useCallback } from 'react'
import {
@@ -18,17 +19,19 @@ export interface MessageIconActionsProps {
clock: 'start' | 'end'
/** When true, append the stub edit control (user bubble). */
edit?: boolean | undefined
/** Fork the session at this message. */
onBranch?: (() => void) | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
}
/**
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
* @param props - Copy text, event time, clock side, optional edit, className.
* @param props - Copy text, event time, clock side, optional edit, branch callback, className.
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, edit, className,
text, time, clock, edit, onBranch, className,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
@@ -48,7 +51,7 @@ export function MessageIconActions({
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<button type="button" className={css.action} aria-label="在新对话中分支" onClick={onBranch}>
<IconBranchOutline16 />
</button>
</Tooltip>

View File

@@ -1,23 +1,23 @@
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
// clock + copy / branch / edit IconActions), steering (badged bubble), context
// injection (a ToolRow-chromed collapsible row: the injection reads as "the
// harness read something into context", so it borrows the read variant's icon
// and the IN-card expanded body) and unknown-surface JSON rows. Props are
// frozen node slices off the snapshot cache; memo holds across streaming
// because unchanged nodes keep their references.
// injection and unknown-surface JSON rows. Props are frozen node slices off
// the snapshot cache; memo holds across streaming because unchanged nodes
// keep their references.
import { memo } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { IconBrowseOutline16, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
import css from './MessageItem.module.css'
export interface MessageItemProps {
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
/** Fork the session through the turn containing this message (user-bubble branch action). */
onFork?: (seq: number) => void
}
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
@@ -63,7 +63,7 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) {
switch (node.kind) {
case 'user': {
const { text, rest } = contentText(node.content)
@@ -78,6 +78,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
time={node.time}
clock="start"
edit
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
/>
</div>
@@ -95,29 +96,10 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
</div>
)
}
case 'context': {
// Pure-text injections show their text; anything with non-text blocks
// (or nothing at all) keeps the full JSON payload so no material is lost.
// Title-only collapsed row (no summary), label-less expanded card: the
// injection is ambient context, not a call's input.
const { text, rest } = contentText(node.content)
const body = rest.length === 0 && text !== ''
? text
: JSON.stringify({ content: node.content, source: node.source }, null, 2)
case 'context':
return (
<div className={css.contextRow}>
<ToolRow
variant="read"
icon={<IconBrowseOutline16 size={14} />}
title="上下文注入"
summary=""
body={body}
plainBody
state="ok"
/>
</div>
<ContextInjectionRow content={node.content} source={node.source} />
)
}
default:
return (
<div className={css.contextRow}>

View File

@@ -9,10 +9,6 @@
.row {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band —
@@ -41,22 +37,8 @@
90%, 100% { left: 100%; }
}
/* Every expandable row is the expand control: pointer only — the icon→chevron
hover preview is the affordance, no row fill. */
.row[data-expandable] {
cursor: pointer;
}
.leading {
position: relative; /* .chevronHover overlay anchor */
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
flex-shrink: 0;
}
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
@@ -78,36 +60,8 @@
color: var(--dsw-alias-label-secondary);
}
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
into a down chevron before the row is opened. The chevron overlays the
icon cell absolutely so both can stay mounted for the opacity transition. */
.iconIdle {
display: inline-flex;
opacity: 1;
transition: opacity 100ms ease;
}
.chevronHover {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: opacity 100ms ease;
}
.row:hover .iconIdle {
opacity: 0;
}
.row:hover .chevronHover {
opacity: 1;
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
font-weight: 400;
}
.sep {
@@ -249,28 +203,17 @@
/* Card-internal scrollbar: a 2px transparent border clips the thumb inward so
it floats off the rounded card edge instead of hugging it (the terminal
card's own output scroller carries the same treatment in TerminalBlock). */
.ioSection::-webkit-scrollbar-thumb,
.ioCardPlain::-webkit-scrollbar-thumb {
.ioSection::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 6px;
}
/* Track end-margins keep the thumb's travel out of the rounded corners. */
.ioSection::-webkit-scrollbar-track,
.ioCardPlain::-webkit-scrollbar-track {
.ioSection::-webkit-scrollbar-track {
margin: 6px 0;
}
/* Label-less variant of the card (plainBody): the text is not an IN/OUT pair,
so it renders as one plain padded block scrolling as a whole. */
.ioCardPlain {
display: block;
padding: 12px 16px;
max-height: 260px;
overflow-y: auto;
}
/* Caption (not tertiary): one step dimmer than the payload text so the
gutter labels read as labels, not as part of the content. Sticky against
the section's own scroll so the label stays readable while its payload

View File

@@ -1,26 +1,27 @@
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
// separator dot + FILL-truncated summary. The collapsed row is always one
// line; every row with body, output, or terminal material is a whole-row
// expand toggle (click / Enter / Space, icon→chevron hover preview); the
// summary stays inline while open, except Think, whose body opens with the
// same first line and would repeat it.
// separator dot + FILL-truncated summary, drawn through the shared
// DisclosureRow chrome with the whole row as the expand toggle (click /
// Enter / Space, icon→chevron hover preview). The collapsed row is always
// one line; every row with body, output, or terminal material is expandable;
// the summary stays inline while open, except Think, whose body opens with
// the same first line and would repeat it.
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
// text input/output, the run_code program through CodeBlock, or a terminal
// card's command output through TerminalBlock — lives in a max-height scroll
// container so a long payload scrolls internally instead of taking over the
// message flow; Think's prose is the exception and flows uncapped like
// message text. Expand state is component-local view state. File-tool summaries are path links that open
// through the host (stopPropagation keeps the two gestures independent); an
// error row's collapsed summary is the failure's first line in the error
// color.
// message text. Expand state is component-local view state. File-tool
// summaries are path links that open through the host (stopPropagation keeps
// the two gestures independent); an error row's collapsed summary is the
// failure's first line in the error color.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { useState, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
import css from './ToolRow.module.css'
export interface ToolRowProps {
@@ -44,11 +45,6 @@ export interface ToolRowProps {
* expandable.
*/
terminal?: TerminalCardModel | null | undefined
/**
* Render the expanded body in the card without the IN gutter label — for
* material that is not a call's input payload (context injection).
*/
plainBody?: boolean | undefined
state: ToolRowState
/**
* Filesystem path from tool args; when set with onOpenFile, the summary
@@ -60,7 +56,7 @@ export interface ToolRowProps {
/**
* Jump to this call in the trajectory view: a hover-revealed Inspect pill
* over the expanded body. Absent = no affordance (rows without a call
* identity, like Think and context injection).
* identity, like Think).
*/
inspect?: (() => void) | undefined
}
@@ -95,7 +91,6 @@ export function ToolRow({
output,
errorSummary,
terminal,
plainBody,
state,
filePath,
onOpenFile,
@@ -115,11 +110,6 @@ export function ToolRow({
const toggleExpand = () => {
setExpanded(v => !v)
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
const openFile = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
if (filePath !== undefined) onOpenFile?.(filePath)
@@ -131,40 +121,26 @@ export function ToolRow({
// The code variant's program renders through CodeBlock (shiki), so only its
// output joins the IN/OUT card; every other variant's input does too.
const cardBody = variant === 'code' ? null : body
// Expandable rows preview the toggle on hover: the idle leading — the tool
// icon OR the state dot — yields to a down chevron (CSS swap on .row:hover).
// The state substitution happens inside the idle slot so an error row keeps
// the hover preview instead of losing it with the icon.
const idleLeading = leadingFor(state, icon)
const collapsedIcon = expandable
? (
<>
<span className={css.iconIdle}>{idleLeading}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
</>
)
: idleLeading
const leading = open
? <IconChevronDownOutline14 className={css.chevron} />
: collapsedIcon
// The state substitution rides the idle icon slot, so an expandable error
// row keeps DisclosureRow's icon→chevron hover preview (its default) instead
// of losing it with the icon.
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
<div
className={css.row}
data-expandable={expandable || undefined}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
aria-expanded={expandable ? open : undefined}
onClick={expandable ? toggleExpand : undefined}
onKeyDown={expandable ? toggleFromKeyboard : undefined}
>
<span className={css.leading}>
{leading}
</span>
<span className={css.title}>{title}</span>
{/* An empty summary drops the separator with it (a row that is only
its title, like the context-injection row, shows no trailing dot). */}
{!(open && isThink) && summaryText !== '' && (
<DisclosureRow
rowClassName={css.row}
leadingClassName={css.leading}
titleClassName={css.title}
chevronClassName={css.chevron}
icon={leadingFor(state, icon)}
title={title}
open={open}
expandable={expandable}
expandOnRowClick
keepContentWhenOpen={!isThink}
onToggle={toggleExpand}
collapsedContent={summaryText !== '' && (
/* An empty summary drops the separator with it (a row that is only
its title shows no trailing dot). */
<>
<span className={css.sep} aria-hidden />
{fileLink ? (
@@ -182,10 +158,9 @@ export function ToolRow({
)}
</>
)}
</div>
{open && (
/* The wrapper (sibling of .row, so clicks inside never toggle the
row) carries the expanded body and the Inspect pill below it. */
>
{/* The wrapper (sibling of the header row, so clicks inside never
toggle it) carries the expanded body and the Inspect pill below. */}
<div className={css.bodyWrap}>
{terminalBody !== null
? <TerminalBlock {...terminalBody.card} maxLines={Infinity} className={css.terminalBody} />
@@ -198,12 +173,7 @@ export function ToolRow({
<CodeBlock code={body} lang="typescript" className={css.codeBody} />
</div>
)}
{plainBody === true && cardBody !== null && (
<div className={clsx(css.ioCard, css.ioCardPlain)}>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{plainBody !== true && (cardBody !== null || outputText !== null) && (
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
@@ -237,7 +207,7 @@ export function ToolRow({
</button>
)}
</div>
)}
</DisclosureRow>
</div>
)
}

View File

@@ -441,6 +441,8 @@ 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. */
forkAt: (seq: number) => void
}
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */

View File

@@ -5,9 +5,11 @@
flex: none;
width: 100%;
max-width: 776px;
/* Eat InputBar's 6px top padding and tuck the panel 2px under the card;
the later composer sibling paints its surface and shadow over this edge. */
margin: 0 auto -10px;
/* Flex gap still applies after this item; subtract it together with the
design's overlap so the later composer paints over the queue edge. */
margin: 0 auto calc(
0px - var(--dsh-composer-stack-gap) - var(--dsh-queue-composer-overlap)
);
padding: 2px 12px;
}
@@ -18,6 +20,8 @@
padding-top: 2px;
border-radius: 14px 14px 0 0;
background: var(--dsw-specific-tip);
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.panel::after {
@@ -30,7 +34,52 @@
pointer-events: none;
}
.header {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 36px;
padding: 4px 16px 4px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
}
.header:focus-visible {
outline: 2px solid var(--dsw-alias-label-tertiary);
outline-offset: -2px;
}
.header:disabled {
cursor: default;
}
.count {
flex: 1 1 auto;
min-width: 0;
font-family: Inter, var(--dsw-font-family);
font-size: 14px;
font-weight: 500;
line-height: 24px;
}
.chevron {
display: grid;
flex: none;
place-items: center;
width: 14px;
height: 14px;
color: var(--dsw-alias-label-tertiary);
}
.list {
max-height: 180px;
overflow-y: auto;
margin: 0;
padding: 0;
list-style: none;

View File

@@ -4,11 +4,12 @@
// The 'conversation.input.dock' SlotMap declaration lives in
// ../contract/slots.ts beside the other input-region slots.
import type { Context } from 'cordis'
import { useEffect, useState } from 'react'
import { useEffect, useId, useState } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
import css from './QueueDock.module.css'
@@ -22,18 +23,28 @@ export interface QueueDockInjected {
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected
/** Queue strip: one preview line per queued message; renders null when the queue is empty. */
/**
* Queue strip: one item renders directly; multiple items default to a
* collapsible count header; an empty queue renders nothing.
*/
export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
const queue = useSession(s => s.queue)
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
const [busy, setBusy] = useState<QueueItemId | null>(null)
const [collapsed, setCollapsed] = useState(true)
const listId = useId()
useEffect(() => {
if (queue.length === 0 && !collapsed) setCollapsed(true)
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
}, [editing, queue])
}, [collapsed, editing, queue])
if (queue.length === 0) return null
const interactionActive = editing !== null || busy !== null
const expanded = !collapsed || interactionActive
const listVisible = queue.length === 1 || expanded
const applyAction = async (
itemId: QueueItemId,
action: QueueAction,
@@ -63,8 +74,23 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
return (
<div className={css.dock}>
<div className={css.panel}>
<ul className={css.list}>
{queue.map(row => (
{queue.length > 1 && (
<button
type="button"
className={css.header}
aria-controls={listId}
aria-expanded={expanded}
disabled={interactionActive}
onClick={() => { setCollapsed(value => !value) }}
>
<span className={css.count}>{queue.length} 条排队消息</span>
<span className={css.chevron} aria-hidden>
{expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
</span>
</button>
)}
<ul id={listId} className={css.list} hidden={!listVisible}>
{listVisible && queue.map(row => (
<li key={row.id} className={css.row}>
{editing?.id === row.id
? (
@@ -162,14 +188,14 @@ export const queueDockEntry = {
name: 'conversation-queue-dock',
inject: ['slots', 'conversation', 'sessions'],
/**
* Register the queue strip into the input dock (list entry, order 0).
* Register the queue strip as the terminal input-dock entry (order 20).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({
name: 'conversation.input.dock',
id: 'queue',
order: 0,
order: 20,
inject: (sessionId: SessionId): QueueDockInjected => {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`)

View File

@@ -19,6 +19,13 @@
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
/* Elevated surface in dark, same as the menus: `.body` inside scrolls once
the justification or command passes the cap, so the thumb takes the l2
pair. Declared on the card because the elevation belongs to the surface,
and the custom properties inherit down to the region that actually
scrolls (see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Tinted full-width header band. */
@@ -40,11 +47,22 @@
background: var(--dsw-alias-state-warn-primary);
}
/* Scroll region: an agent's justification and its command are unbounded model
text (a one-line `cd` or a 40-line heredoc), and the seat sits in a
fixed-height column — uncapped, a long command pushed the action row past
the viewport and the approval could not be answered at all. The strip and
the action row stay outside, so the buttons are always on screen. */
.body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 16px 14px;
/* border-box so the cap is the region's OUTER height: the composer's draft
area counts its padding inside the same number, and the two seats are
only interchangeable if they occupy the same box. */
box-sizing: border-box;
max-height: var(--dsh-composer-text-max-height);
overflow-y: auto;
padding: 12px 16px 0;
}
/* The model's justification is the panel's message, not a footnote. */
@@ -63,11 +81,15 @@
word-break: break-all;
}
/* Card-level row, not body content. Its padding reproduces the metrics the row
had inside the body: 14px above (the flex gap of 6 plus the row's 8px top
margin, neither of which reaches it out here) and the body's former 14px
bottom pad below, so the resting card is unchanged. */
.actionRow {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
padding: 14px 16px 14px;
}
.allow,

View File

@@ -4,7 +4,11 @@
// pending, this panel occupies the composer slot in place of the InputBar:
// an amber "Waiting for approval" strip on the card top, the model's
// justification as the headline, the paired command in muted code text, and
// a right-aligned refuse/allow action row. One-shot: the buttons disable
// a right-aligned refuse/allow action row. Justification and command are
// unbounded model text, so they scroll inside the card at the shared composer
// cap (`data-approval-scroll`) and the action row stays outside it — the
// buttons must be reachable no matter how long the command is.
// One-shot: the buttons disable
// after a click and the panel leaves (the InputBar returns) on the broadcast
// resolved frame. The draft's "Always allow this type" is deferred with
// grant storage.
@@ -53,17 +57,20 @@ function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?
<div className={css.root} data-approval-key={pending.key}>
<div className={css.card}>
<div className={css.strip}><span className={css.dot} />等待审批</div>
<div className={css.body}>
{/* Tab stop: the region scrolls once the command passes the cap and
holds nothing focusable of its own, so without one a keyboard-only
user cannot reach the command's tail before answering. */}
<div className={css.body} data-approval-scroll="" tabIndex={0} role="group" aria-label="审批详情">
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
{command !== undefined && <div className={css.command}>{command}</div>}
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
拒绝
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
允许一次
</button>
</div>
</div>
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
拒绝
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
允许一次
</button>
</div>
</div>
</div>

View File

@@ -127,14 +127,15 @@
min-height: 0;
}
/* Composer stack: dock strips above the input card (design §6 MIX order).
The stack owns the vertical rhythm: one gap here, entries carry no outer
margins — an entry that renders null costs nothing, so spacing stays
correct for any dock combination. */
/* Composer context stack (Figma 9:937): standalone dock cards share one
rhythm; the terminal queue strip additionally tucks under the input card. */
.composerStack {
--dsh-composer-stack-gap: 6px;
--dsh-queue-composer-overlap: 5px;
display: flex;
flex-direction: column;
gap: 8px;
gap: var(--dsh-composer-stack-gap);
}
/* Common seat for the composer chain (fallback + elected overlay siblings). */
@@ -142,6 +143,14 @@
display: flex;
flex: none;
flex-direction: column;
/* One cap for every scrolling text region a composer seat can hold: the
InputBar draft (figma Input 75:8208 max 14 lines × 24px line) and the
takeover panels' bodies top out at the same height, so electing a
takeover never grows the footer past the card it replaces. Declared on
the seat because it is the chain's only shared ancestor — fallback and
elected overlay are siblings — and custom properties inherit down to
whichever entry is mounted. */
--dsh-composer-text-max-height: 336px;
}
/* Active phase: header is ordinary column chrome above the scrollport (not

View File

@@ -209,7 +209,9 @@
.mirror {
visibility: hidden;
pointer-events: none;
max-height: 336px;
/* 14-line cap, shared with the composer takeovers (declared on
ConversationRoot .composerSeat). */
max-height: var(--dsh-composer-text-max-height);
overflow: hidden;
}

View File

@@ -1,9 +1,8 @@
/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
tip surface, 14px radius, status icons + secondary item labels. Column is
calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer
stack owns the gap. */
/* Todo strip in the composer context stack (Figma 9:959): tip surface,
14px radius, status icons + secondary item labels. */
.root {
box-sizing: border-box;
flex: none;
overflow: hidden;
margin: 0 auto;
@@ -21,13 +20,11 @@
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Compact scale (GoalBar reference): collapsed header totals the goal
strip's 38px (8+8 pad + 20 line + 2 border). */
.body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 14px;
padding: 9px 15px;
}
.header {
@@ -44,8 +41,8 @@
.title {
flex: none;
font-size: 13px;
line-height: 20px;
font-size: 14px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}

View File

@@ -135,10 +135,10 @@ export const todoDockEntry = {
name: 'conversation-todo-dock',
inject: ['slots', 'conversation'],
/**
* Register the plan strip into the input dock (list entry, above the queue rows).
* Register the plan strip between the goal and queue entries (order 10).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
},
}