Merge remote-tracking branch 'origin/master' into worktree/web-model-request-retry

# Conflicts:
#	apps/cli/README.i18n.yaml
#	apps/cli/README.md
#	apps/cli/README.zh.md
#	apps/web/tests/snapshots/live-interactions/cancel.expected.md
#	apps/web/tests/snapshots/live-interactions/error-auth.expected.md
#	apps/web/tests/snapshots/live-interactions/retry.expected.md
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.zh.md
This commit is contained in:
Yichen Jiang
2026-07-30 20:03:39 +08:00
684 changed files with 14535 additions and 5470 deletions

View File

@@ -10,6 +10,7 @@ import type {
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
import type { InputNotice } from './input/contract.ts'
import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
@@ -21,6 +22,7 @@ import { StatsLine } from './chat/StatsLine.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
@@ -30,6 +32,19 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
// Static no-session sources for the composer-bar hooks compartment: module
// constants so the render side's per-source hook cache (observableHook) keeps
// one identity across every no-session render.
const ABSENT_NOTICES = {
getSnapshot: (): InputNotice | null => null,
subscribe: () => () => {},
}
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
const ABSENT_LEXICON = {
getSnapshot: () => EMPTY_LEXICON,
subscribe: () => () => {},
}
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
const scoped = sessions.scope(id)
@@ -120,7 +135,7 @@ export function apply(ctx: Context): void {
children: {
'conversation.session': { kind: 'single', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.bar': { kind: 'single', scope: 'session' },
'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' },
'conversation.input.overlay': { kind: 'list', scope: 'session' },
'conversation.input.dock': { kind: 'list', scope: 'session' },
'conversation.composer.dock': { kind: 'list', scope: 'session' },
@@ -165,6 +180,9 @@ export function apply(ctx: Context): void {
// chain's fallback (decision 20). Public machine surface arrives via the
// provide channel above; the keyboard command face and the stop/retry
// verbs ride this inject (package-internal — hub and bar are one plugin).
// Session-maybe: with no current session the machine faces are absent and
// the hooks compartment binds static empty sources (module constants, so
// observableHook caching and hook order stay stable across transitions).
slots.register({
name: 'conversation.composer.bar',
// The two named control seats in the bar's tool row (plan beside the
@@ -174,7 +192,16 @@ export function apply(ctx: Context): void {
'conversation.input.plan': { kind: 'single', scope: 'session' },
'conversation.input.model': { kind: 'single', scope: 'session' },
},
inject: (sessionId: SessionId): ComposerBarInjected => {
inject: (sessionId: SessionId | undefined): ComposerBarInjected => {
if (sessionId === undefined) {
return {
keyboard: undefined,
stop: undefined,
command: undefined,
translateHint,
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
}
}
const shell = inputHub.shell(sessionId)
return {
keyboard: shell,
@@ -257,6 +284,9 @@ export function apply(ctx: Context): void {
// The todo_write row rides the same seam (a product registration, not a sample).
ctx.plugin(todoToolview)
// The ask_user_question row: waiting/answered/cancelled interaction outcome.
ctx.plugin(askQuestionToolview)
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(todoDockEntry)

View File

@@ -34,11 +34,3 @@
/* Optical align with 28px icon hit targets that pad 6px past the glyph. */
margin-left: -6px;
}
/* Hover-capable pointers: reveal shared actions on root hover/focus. */
@media (hover: hover) {
.root:hover .actions,
.root:focus-within .actions {
opacity: 1;
}
}

View File

@@ -4,7 +4,8 @@
// 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 nodes append IconActions (copy / branch / clock) once streaming ends.
// Finalized content (text) nodes append IconActions once streaming ends;
// Think / tool-head-only nodes stay chrome-free.
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -38,6 +39,11 @@ function copyText(blocks: readonly AssistantBlock[]): string {
return parts.join('')
}
/** True when the node has model-visible text content worth chrome under. */
function hasContentText(blocks: readonly AssistantBlock[]): boolean {
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
}
/** Reasoning block as the Think variant summary row (figma 39:28304). */
function ThinkRow({ text, running }: { text: string; running: boolean }) {
return (
@@ -64,8 +70,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|| interrupted === true
|| blocks.some(block => block.kind !== 'tool-call')
if (!hasVisible) return null
// Footer only after the turn settles with a known event time; streaming omits it.
const showActions = !streaming && time !== undefined
// Footer only under settled content text; Think-only / streaming omit it.
const showActions = !streaming && time !== undefined && hasContentText(blocks)
return (
<div className={css.root} data-streaming={streaming || undefined}>
<div className={css.body}>

View File

@@ -144,8 +144,11 @@
}
:global([data-conversation-scroll]) .toBottomSlot {
/* Clears the sticky composer stack (stats + docks + input card). */
bottom: 168px;
/* Clears the sticky composer stack (docks + input card + stats): the live
height rides --dsh-composer-height (ConversationRoot's seat observer) so
the control follows a growing textarea; the fallback covers the first
paint before the observer fires. */
bottom: calc(var(--dsh-composer-height, 152px) + 16px);
}
.toBottom {

View File

@@ -1,5 +1,5 @@
/* Shared message IconActions row (user + assistant). Parent modules own
hover-reveal selectors and layout offsets via the composed className. */
layout offsets via the composed className. Always visible when mounted. */
.actions {
display: flex;
@@ -25,14 +25,6 @@
white-space: nowrap;
}
/* Hover-capable pointers: hide until a parent hover/focus rule reveals. */
@media (hover: hover) {
.actions {
opacity: 0;
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
}
}
.action {
display: inline-flex;
align-items: center;

View File

@@ -18,7 +18,7 @@ export interface MessageIconActionsProps {
clock: 'start' | 'end'
/** When true, append the stub edit control (user bubble). */
edit?: boolean | undefined
/** Parent layout / hover-reveal class composed onto the actions row. */
/** Parent layout class composed onto the actions row. */
className?: string | undefined
}

View File

@@ -20,14 +20,6 @@
color: var(--dsw-alias-label-primary);
}
/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */
@media (hover: hover) {
.userRow:hover .actions,
.userRow:focus-within .actions {
opacity: 1;
}
}
.badge {
display: inline-block;
margin-bottom: 4px;

View File

@@ -2,12 +2,22 @@
736px message column axis. */
.root {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
max-width: 736px;
width: 100%;
margin: 0 auto;
box-sizing: border-box;
padding: 4px 24px 8px;
padding: 4px 24px 0px;
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
overflow: hidden;
}
.sep {
color: var(--dsw-alias-separator-primary);
}

View File

@@ -2,7 +2,7 @@
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { memo, useMemo } from 'react'
import { Fragment, memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import css from './StatsLine.module.css'
@@ -10,7 +10,13 @@ import css from './StatsLine.module.css'
interface UsageTotals {
turns: number
steps: number
tokens: number
/** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
llmMs: number
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
toolMs: number
/** Prompt-side tokens: inputTokens + cacheReadTokens. */
inputTokens: number
outputTokens: number
cacheHitPct: number | null
}
@@ -22,35 +28,72 @@ interface UsageLike {
}
/**
* Fold assistant nodes into display totals.
* Fold assistant and tool-result nodes into display totals.
* @param nodes - snapshot nodes.
* @returns totals; cacheHitPct null until any cache accounting arrives.
*/
export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
const turns = new Set<number>()
let steps = 0
let tokens = 0
let llmMs = 0
let toolMs = 0
let input = 0
let output = 0
let cacheRead = 0
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
continue
}
if (node.kind !== 'assistant') continue
turns.add(node.turn)
steps += 1
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
}
const usage = node.usage as UsageLike | undefined
if (usage === undefined) continue
input += usage.inputTokens ?? 0
output += usage.outputTokens ?? 0
cacheRead += usage.cacheReadTokens ?? 0
tokens += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0)
}
const denom = input + cacheRead
return {
turns: turns.size,
steps,
tokens,
llmMs,
toolMs,
inputTokens: input + cacheRead,
outputTokens: output,
cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100),
}
}
/**
* Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits).
* @param n - token count.
* @returns display string.
*/
export function formatTokens(n: number): string {
const scaled = (v: number): string =>
v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
if (n < 1_000) return String(n)
if (n < 1_000_000) return `${scaled(n / 1_000)}K`
return `${scaled(n / 1_000_000)}M`
}
/**
* Compact duration: 45.2s under a minute, 2m42s from there on.
* @param ms - duration in milliseconds.
* @returns display string.
*/
export function formatDuration(ms: number): string {
const s = ms / 1_000
if (s < 60) return `${Math.round(s * 10) / 10}s`
const whole = Math.round(s)
return `${Math.floor(whole / 60)}m${whole % 60}s`
}
/** Props: the conversation-snapshot selector (dock registration or unit mount). */
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
@@ -58,10 +101,22 @@ export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps)
const nodes = useSession(s => s.nodes)
const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null
const parts: string[] = []
if (stats.cacheHitPct !== null) parts.push(`cache hit ${stats.cacheHitPct}%`)
parts.push(`${stats.tokens.toLocaleString('en-US')} tokens`)
parts.push(`${stats.turns} turns`)
parts.push(`${stats.steps} steps`)
return <div className={css.root}>{parts.join(' · ')}</div>
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
const groups: string[] = [`${stats.turns} turns · ${stats.steps} steps`]
const durations: string[] = []
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
if (durations.length > 0) groups.push(durations.join(' · '))
if (stats.cacheHitPct !== null) groups.push(`Cache hit ${stats.cacheHitPct}%`)
groups.push(`Input ${formatTokens(stats.inputTokens)} tok · Output ${formatTokens(stats.outputTokens)} tok`)
return (
<div className={css.root}>
{groups.map((group, i) => (
<Fragment key={group}>
{i > 0 && <span className={css.sep} aria-hidden>|</span>}
<span>{group}</span>
</Fragment>
))}
</div>
)
})

View File

@@ -61,12 +61,6 @@
color: var(--dsw-alias-label-tertiary);
}
/* The others-variant sparkle glyph is one gray step darker than the icon
family in the source design. */
.root[data-variant='others'] .leading {
color: var(--dsw-alias-label-secondary);
}
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
shared product accent and tool-owned action title. */
.root[data-tool^='cordis_'] .leading,
@@ -86,10 +80,6 @@ button.leading {
cursor: pointer;
}
.chevron {
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. */

View File

@@ -9,7 +9,6 @@
// through the host; the row itself is not a details-panel control.
import { useState, type KeyboardEvent, 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 { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
@@ -106,12 +105,12 @@ export function ToolRow({
? (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
<IconChevronDownOutline14 className={css.chevronHover} />
</>
)
: icon
const leading = open
? <IconChevronDownOutline14 className={css.chevron} />
? <IconChevronDownOutline14 />
: leadingFor(state, collapsedIcon)
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>

View File

@@ -0,0 +1,13 @@
/** Queue contracts derived from the runtime session face and snapshot. */
import type {
ConversationSnapshot, SessionFace,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One address accepted by the runtime session's queue mutation verb. */
export type QueueItemId = Parameters<SessionFace['updateQueue']>[0]
/** One mutation accepted by the runtime session's queue mutation verb. */
export type QueueAction = Parameters<SessionFace['updateQueue']>[1]
/** One row projected by the runtime session's authoritative queue snapshot. */
export type QueueRow = ConversationSnapshot['queue'][number]

View File

@@ -67,7 +67,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* design §6 MIX evidence: entries coexist in fixed order).
*/
'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
/** The composer top-edge band (stats line family). */
/** The band under the composer card (stats line family), rendered inside the bar's width column via the `footer` owner prop. */
'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
/** Tool-row left region inside the input card (existing chrome stays in place beside entries). */
'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone }
@@ -77,11 +77,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* The default composer body: a single slot rendered as the composer
* chain's fallback (decision 20 — a real entry, not a chain rider, so a
* takeover election hides rather than unmounts it and the textarea DOM
* survives). InputBar registers here from this package's apply; its
* machine state arrives through the standard provide channel (useInput +
* inputActions), the keyboard command face through its own inject.
* survives). Session-maybe: the bar stays mounted across the
* no-session/session transition — the no-workspace hero renders the SAME
* textarea DOM disabled instead of a parallel inert tree — with the
* machine hooks absent until a session is current. InputBar registers
* here from this package's apply; its machine state arrives through the
* standard provide channel (useInput + inputActions), the keyboard
* command face through its own inject.
*/
'conversation.composer.bar': { kind: 'single'; scope: 'session'; owner: ComposerBarOwnerProps }
'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
/**
* The Plan-mode status seat in the composer tool row (left group,
* right of the access-mode control). Declared by the composer-bar
@@ -244,6 +248,12 @@ export interface ConversationSessionInjected {
export interface ComposerBarOwnerProps {
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
/**
* Inert no-workspace state: the bar renders its normal DOM fully disabled
* (textarea, add, send) so the workspace pick transitions in place instead
* of swapping component trees.
*/
disabled?: boolean
placeholder?: string
/** Optional content rendered above the textarea. */
accessory?: ReactNode
@@ -253,25 +263,32 @@ export interface ComposerBarOwnerProps {
leftItems?: ReactNode
/** input.right slot entries (tool row, before the primary button). */
rightItems?: ReactNode
/** composer.dock entries (stats line), rendered under the card inside the bar's width column. */
footer?: ReactNode
onAdd?: () => void
addLabel?: string
}
/** Injected share of the composer-bar entry (package-internal faces). */
export interface ComposerBarInjected {
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
keyboard: ComposerKeyboard
/** Cancel the in-flight turn. */
stop: () => void
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
keyboard: ComposerKeyboard | undefined
/** Cancel the in-flight turn; absent with the session. */
stop: (() => void) | undefined
/**
* Submit one slash-command line against this session's agent (the chrome
* controls' write path — the permission chip submits `/permission <preset>`).
* controls' write path — the permission chip submits `/permission <preset>`);
* absent with the session.
* Resolves admission: false = rejected/unmatched/transport failure.
*/
command: (line: string) => Promise<boolean>
/** Locale-aware hint translator for claimed command placeholders. */
command: ((line: string) => Promise<boolean>) | undefined
/** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */
translateHint: (key: string) => string
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
/**
* Registrant hooks compartment: the renderer binds these to
* useNotices/useLexicon (static absent sources without a session — hook
* order stays constant).
*/
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
notices: ObservableSnapshot<InputNotice | null>

View File

@@ -10,6 +10,7 @@ import type {
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
ReferenceInsert, SubmitOutcome, TokenSpan,
} from '@deepseek-ai/dsh-client-ui-slash/client'
import type { QueueRow } from '../contract/queue.ts'
/**
* The scoped-event application verbs: the hub's bail listeners call these,
@@ -99,12 +100,8 @@ export interface ComposerKeyboard {
dismissPopup(): void
}
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */
export interface QueuedMessage {
/** Stable row key: the enqueueing prompt's rpcId. */
readonly key: string
readonly preview: string
}
/** One independently addressable row projected from the transient queue snapshot. */
export type QueuedMessage = QueueRow
/** Guard union of the scoped consume-token event, checked by the machine. */
export type ConsumeTokenGuard = ConsumeTokenRequest['guard']

View File

@@ -1,30 +1,116 @@
/* Neutral stacked strip above the input (queue rows are informational, not a warn state). */
/* Figma .FileContainerText 1:791: 776px wrapper around the inset 752px panel. */
.dock {
margin: 6px 0;
padding: 8px 12px;
border: 1px solid var(--dsw-alias-separator-primary);
border-radius: 10px;
background: var(--dsw-alias-bg-base);
box-sizing: border-box;
flex: none;
width: 100%;
max-width: 776px;
/* 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;
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
.panel {
position: relative;
overflow: hidden;
width: 100%;
padding-top: 2px;
border-radius: 14px 14px 0 0;
background: var(--dsw-specific-tip);
}
.panel::after {
position: absolute;
inset: 0;
border: 1px solid var(--dsw-alias-border-l1);
border-bottom: none;
border-radius: inherit;
content: '';
pointer-events: none;
}
.list {
margin: 4px 0 0;
margin: 0;
padding: 0;
list-style: none;
}
.row {
overflow: hidden;
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
white-space: nowrap;
text-overflow: ellipsis;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 36px;
padding: 4px 5px 4px 12px;
border-radius: 8px;
}
.preview,
.editor {
flex: 1 1 auto;
min-width: 0;
font: var(--dsw-font-xs-13);
font-family: Inter, var(--dsw-font-family);
}
.preview {
overflow: hidden;
color: var(--dsw-alias-label-primary-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-word;
}
.editor {
box-sizing: border-box;
height: 28px;
padding: 0 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
outline: none;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
}
.editor:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.actions {
display: flex;
flex: none;
align-items: center;
gap: 10px;
}
.action {
display: grid;
flex: none;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.action:focus-visible {
outline: 2px solid var(--dsw-alias-label-tertiary);
outline-offset: -2px;
}
.action:disabled {
cursor: default;
opacity: 0.45;
}

View File

@@ -1,48 +1,185 @@
// Read-only queue dock entry (design v4 queue cut 1): renders the session's
// inbox mirror (session/queued frames + connect baseline) as one stacked
// strip above the input. No per-row actions — the host inbox has no
// addressable entries yet (queue cut 2 ledger).
// Queue dock entry: renders the authoritative transient inbox snapshot and
// addresses per-row mutations through the session-scoped conversation face.
//
// 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 type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
import css from './QueueDock.module.css'
/** Queue operations injected by the session-scoped registration. */
export interface QueueDockInjected {
updateQueue: (itemId: QueueItemId, action: QueueAction) => Promise<void>
notify: (level: 'info' | 'error', text: string) => void
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'>
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected
/** Queue strip: one preview line per queued message; renders null when the queue is empty. */
export function QueueDock({ useSession }: QueueDockProps) {
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)
useEffect(() => {
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
}, [editing, queue])
if (queue.length === 0) return null
const applyAction = async (
itemId: QueueItemId,
action: QueueAction,
failure: string,
): Promise<boolean> => {
setBusy(itemId)
try {
await updateQueue(itemId, action)
return true
} catch {
notify('error', failure)
return false
} finally {
setBusy(current => current === itemId ? null : current)
}
}
const saveEdit = async (): Promise<void> => {
if (editing === null || editing.text.trim() === '') return
if (await applyAction(
editing.id,
{ kind: 'edit', content: [{ type: 'text', text: editing.text }] },
'编辑失败:这条消息可能已经开始发送。',
)) setEditing(null)
}
return (
<div className={css.dock}>
<div className={css.title}>已排队 {queue.length} 条</div>
<ul className={css.list}>
{queue.map(row => (
<li key={row.key} className={css.row}>{row.preview}</li>
))}
</ul>
<div className={css.panel}>
<ul className={css.list}>
{queue.map(row => (
<li key={row.id} className={css.row}>
{editing?.id === row.id
? (
<input
autoFocus
className={css.editor}
aria-label="编辑排队消息"
value={editing.text}
onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setEditing(null)
return
}
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
void saveEdit()
}
}}
/>
)
: <span className={css.preview}>{row.preview}</span>}
<div className={css.actions}>
{editing?.id === row.id
? (
<>
<button
type="button"
className={css.action}
aria-label="保存排队消息"
title="保存排队消息"
disabled={busy !== null || editing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
<IconCheckOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label="取消编辑"
title="取消编辑"
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
<IconCloseOutline16 size={14} />
</button>
</>
)
: (
<>
<button
type="button"
className={css.action}
aria-label="编辑排队消息"
title={row.text === null ? '包含非文本内容,暂不支持编辑' : '编辑排队消息'}
disabled={busy !== null || row.text === null}
onClick={() => {
if (row.text !== null) setEditing({ id: row.id, text: row.text })
}}
>
<IconEditOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label="删除排队消息"
title="删除排队消息"
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
'删除失败:这条消息可能已经开始发送。',
)
}}
>
<IconTrashOutline16 size={14} />
</button>
</>
)}
</div>
</li>
))}
</ul>
</div>
</div>
)
}
/**
* The dock entry as a plain registrant plugin (bash posture).
* `inject: ['conversation']` is the ordering seam: the conversation service
* mounts after ui-conversation's slot registrations, so the
* 'conversation.input.dock' declaration is on the ledger by then.
* The dock entry as a plain registrant plugin. The conversation service is the
* ordering and action seam; session scopes provide the exact queue owner.
*/
export const queueDockEntry = {
name: 'conversation-queue-dock',
inject: ['slots', 'conversation'],
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 }, QueueDock)
ctx.slots.register({
name: 'conversation.input.dock',
id: 'queue',
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`)
const conversation = actx.get('conversation')
if (conversation === undefined) throw new Error('queue dock: conversation service unavailable')
return {
updateQueue: (itemId, action) => conversation.updateQueue(itemId, action),
notify: (level, text) => { conversation.input.for(actx).notify(level, text) },
}
},
}, QueueDock)
},
}

View File

@@ -11,8 +11,8 @@ import type { QueuedMessage } from '../input/contract.ts'
/**
* Project a session's queue rows as a bare observable (subscribe/getSnapshot).
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
* QueuedMessage and the input-contract QueuedMessage are structurally the
* same frozen shape ({key, preview}).
* QueuedMessage and the input-contract QueuedMessage are structurally
* identical.
* @param session - the resident session face.
* @returns the queue read face (snapshot reference stable while the queue is unchanged).
*/

View File

@@ -13,6 +13,7 @@ import type { Context } from 'cordis'
// error, so scope resolution goes through the sessions service (scopeOf
// method) instead of the standalone helper.
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { QueueAction, QueueItemId } from './contract/queue.ts'
import type { InputService } from './input/contract.ts'
/**
@@ -30,6 +31,13 @@ export interface IConversation {
* @returns completion; business failures reject (and land in promptError).
*/
send(text: string, mode: 'queue' | 'steer'): Promise<void>
/**
* Apply one operation to a pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - edit or remove operation.
* @returns completion; business failures reject.
*/
updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
/**
* Cancel the scoped session's in-flight turn.
* @returns completion; failures reject as in send.
@@ -71,6 +79,15 @@ export class ConversationService extends Service implements IConversation {
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
}
/** Apply one operation to a pending queue occurrence. */
async updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void> {
const session = this.scopedSession('updateQueue')
const result = await session.updateQueue(itemId, action)
if (!result.ok) {
throw new Error(`conversation.updateQueue failed: ${result.error.code}: ${result.error.message}`)
}
}
/** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */
async cancel(): Promise<void> {
const session = this.scopedSession('cancel')

View File

@@ -127,10 +127,15 @@
min-height: 0;
}
/* Composer stack: dock strips above the input card (design §6 MIX order). */
/* 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: var(--dsh-composer-stack-gap);
}
/* Common seat for the composer chain (fallback + elected overlay siblings). */
@@ -170,7 +175,17 @@
/* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
paints under a sticking code header while scrolling. */
z-index: 7;
background: var(--dsw-alias-bg-base);
/* Input mask (figma 1205:27463): transcript fades out under a FIXED 36px
band at the seat's top (the figma 24% of the resting ~150px composer),
solid below — px stops, not %, so a growing draft only widens the solid
region and the fade band never stretches. The 0px stop is bg-base at
zero alpha (not white, which the figma export hardcodes) so both themes
fade from their own base. */
background: linear-gradient(
180deg,
color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent) 0px,
var(--dsw-alias-bg-base) 36px
);
}
/* Hero phase: the composer stack (hero chrome + workspace row + card) is

View File

@@ -1,13 +1,12 @@
// Resident conversation skeleton. Hero chrome, composer positioning, and the
// chain stay mounted across no-session/session transitions. Only the inert
// input body swaps for the strict session InputBar.
// Resident conversation skeleton. Hero chrome, composer positioning, the
// chain, AND the composer bar (session-maybe slot) stay mounted across
// no-session/session transitions — the bar renders inert via owner props.
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import { DisabledInputBar } from './DisabledInputBar.tsx'
import css from './ConversationRoot.module.css'
/** Full props composed from the slot contract. */
@@ -29,6 +28,23 @@ export function ConversationRoot({
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
const pickerAnchor = useRef<HTMLButtonElement>(null)
// Publishes the seat's live height as --dsh-composer-height on the scroll
// body so floating controls (ChatView back-to-bottom) clear the composer as
// it grows. Callback ref, not an effect: the seat remounts when the tree
// moves between the no-session and session paths. Stable identity so React
// reattaches only on those remounts, not on every render.
const seatObserver = useRef<ResizeObserver | null>(null)
const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => {
seatObserver.current?.disconnect()
seatObserver.current = null
const scroller = seat?.parentElement ?? null
if (seat === null || scroller === null) return
seatObserver.current = new ResizeObserver(() => {
scroller.style.setProperty('--dsh-composer-height', `${seat.offsetHeight}px`)
})
seatObserver.current.observe(seat)
}, [])
const sessionWorkspace = sessionId === undefined
? undefined
: workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId))
@@ -96,26 +112,29 @@ export function ConversationRoot({
)
// The placeholder chip ("Choose workspace") and the inert input travel
// together: a blank session whose workspace vanished (deleted from the
// sidebar) reverts to the same disabled bar as the initial no-session state.
const inputBar = sessionId === undefined || (hero && chipTitle === undefined)
? <DisabledInputBar />
: renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
})
// together: no workspace picked yet (cold start, no session at all), or a
// blank session whose workspace vanished (deleted from the sidebar). The
// bar is ONE session-maybe slot rendered unconditionally — inert is a prop,
// not a different tree, so the textarea DOM survives the transition.
const inert = sessionId === undefined || (hero && chipTitle === undefined)
const inputBar = renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
...(inert
? { disabled: true, placeholder: 'Choose a workspace to start' }
: hero ? { placeholder: 'Describe what you want to build' } : {}),
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
// Stats band under the card, inside the bar's width column so both
// share one constraint (composer.dock = stats-line family).
footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
})
const composerBar = (
<div className={clsx(css.composerStack, hero && css.composerHero)}>
{hero && <HeroGlow className={css.heroGlow} />}
{hero && <HeroShell />}
{hero && heroWorkspaceRow}
{/* Stats band above the input-dock strips so the prior ChatView footer
order (stats → todo/queue → card) is preserved under the sticky stack. */}
{!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)}
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
{inputBar}
</div>
@@ -133,7 +152,7 @@ export function ConversationRoot({
// on the fallback alone would leave Question/Approval panels at the content
// end off-screen when the user is not pinned to the floor.
const composerSeat = (
<div className={css.composerSeat} data-composer-seat="">
<div ref={seatResizeRef} className={css.composerSeat} data-composer-seat="">
{composer}
</div>
)
@@ -159,7 +178,7 @@ export function ConversationRoot({
'conversation.session',
{ wrapActiveBody },
)}
{sessionId === undefined ? composerSeat : null}
{sessionId === undefined ? wrapActiveBody(null) : null}
</div>
)
}

View File

@@ -1,40 +0,0 @@
/** Inert no-session input body; the resident Hero shell renders around it. */
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './InputBar.module.css'
/** Disabled visual twin of the session-bound InputBar. */
export function DisabledInputBar() {
return (
<div className={clsx(css.root, css.hero)}>
<div className={css.card}>
<div className={css.grow}>
<textarea
className={css.input}
value=""
disabled
placeholder="Choose a workspace to start"
rows={2}
readOnly
/>
<div aria-hidden className={css.mirror}>{'\n'}</div>
</div>
<div className={css.row}>
<div className={css.tools}>
<button type="button" className={css.add} aria-label="Add attachment" disabled>
<IconPlusOutline16 size={14} />
</button>
</div>
<div className={css.trailing}>
<button type="button" className={css.primary} aria-label="Send message" disabled>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
</svg>
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -20,10 +20,10 @@
display: flex;
flex-direction: column;
align-items: center;
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
the chat scroller. Top 6 is the gap under the dock todo strip (12px todo
margin + 6px here); error/status strips still carry their own margin. */
padding: 6px 32px 12px;
/* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by
the chat scroller. No top pad: the composer stack's gap owns the space
above; error/status strips still carry their own margin. */
padding: 0 32px 8px;
}
.hero {
@@ -209,12 +209,16 @@
.mirror {
visibility: hidden;
pointer-events: none;
/* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */
min-height: 52px;
max-height: 336px;
overflow: hidden;
}
/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
line + 4pt); the docked composer collapses to the content height. */
.hero .mirror {
min-height: 52px;
}
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
(figma Input_Bottom chrome). */
.row {

View File

@@ -17,9 +17,13 @@ import type {} from '@deepseek-ai/dsh-plan-mode/client'
import type {} from '@deepseek-ai/dsh-goal/client'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
/** Decoration product of the no-session state (no machine, empty draft). */
const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
/** Prompt failure surface (derived from promptError). */
export interface InputBarError {
op: 'send' | 'stop'
@@ -29,15 +33,16 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon,
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
const lexicon = useLexicon(s => s)
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)
const promptError = useSession(s => s.promptError) ?? null
const running = useSession(s => s.running) ?? false
const removed = useSession(s => s.removed) ?? false
// Plan mode swaps the textarea placeholder (the projection is the folded
// host value; owner-prop placeholders — hero, session-unavailable — win).
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
@@ -49,7 +54,10 @@ export function InputBar({
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
const draft = input.draft
// Session-maybe: the machine faces are absent together while no session is
// current; the bar renders the same DOM inert instead of a parallel tree.
const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
const draft = input?.draft ?? ''
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
@@ -68,16 +76,18 @@ export function InputBar({
// (undefined = capability absent → the chip renders nothing).
const permissions = useProjection('permissions')
// Queue cut 1: running input stays free; locked = session disabled only.
// The transient machine locks (adjudicating pending / submitting) render
// Queue cut 1: running input stays free; locked = session removed, the
// inert no-workspace state, or the machine faces absent (no session). The
// transient machine locks (adjudicating pending / submitting) render
// read-only — the draft stays visible and focused, keystrokes drop.
const disabled = removed || inert || !live
const locked = disabled
const machineBusy = input.phase === 'adjudicating' || input.phase === 'submitting'
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
// Unlock (mount / session switch) returns focus to the box.
useEffect(() => {
if (!locked) inputRef.current?.focus()
}, [locked])
}, [locked, sessionId])
// Active conversation scrollport: chain the wheel. While the textarea (capped
// at 14 lines with overflow-y:auto) can still move in this direction, keep
@@ -101,6 +111,9 @@ export function InputBar({
}, [])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
// Absent machine (no session): the textarea is disabled so events cannot
// fire; the guard narrows the faces for the paths below.
if (keyboard === undefined || inputActions === undefined) return
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
// IME guard so a composition-closing Shift+Enter still breaks the line.
if (e.key === 'Enter' && e.shiftKey) return
@@ -162,6 +175,7 @@ export function InputBar({
}
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
if (keyboard === undefined) return // absent machine: disabled textarea, no events
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
const next = e.target.value
keyboard.setDraft(next)
@@ -187,6 +201,7 @@ export function InputBar({
/* oxlint-enable typescript/no-unnecessary-condition */
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
if (input === undefined || keyboard === undefined) return // absent machine: disabled textarea, no events
const el = e.currentTarget
const { start, end } = selectionOf(el)
if (start === end) return
@@ -211,6 +226,7 @@ export function InputBar({
}
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
if (keyboard === undefined) return // absent machine: disabled textarea, no events
if (machineBusy || locked) return
const text = e.clipboardData.getData('text/plain')
if (text === '') return
@@ -230,7 +246,7 @@ export function InputBar({
const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
// Any caret/selection gesture ends a live paste attempt (the machine
// cannot observe DOM selection). Cheap no-op when none is live.
if (keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
if (keyboard !== undefined && keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
void e
}
@@ -242,6 +258,7 @@ export function InputBar({
const primaryLabel = running ? 'Stop generating' : 'Send message'
const onPrimary = (): void => {
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
if (running) {
stop()
return
@@ -251,16 +268,17 @@ export function InputBar({
}
// The Access seat: the projection-fed permission chip (renders nothing
// while the permissions key is absent — permission-less host or Draft).
const accessSelect: ReactNode = (
<PermissionSelect value={permissions} locked={locked} command={command} />
)
// while the permissions key is absent — permission-less host or Draft —
// or while the command face is absent with the session).
const accessSelect: ReactNode = command === undefined
? null
: <PermissionSelect value={permissions} locked={locked} command={command} />
// Mirror-layer decorations: a visible backdrop with transparent text. The
// claim token highlights through behind the textarea glyphs; each U+FFFC
// placeholder renders as a chip (the textarea's own glyph is invisible, the
// backdrop chip supplies the visual); the claim hint is ghost text.
const deco = deriveDecorations(input, lexicon)
const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon)
const backdrop: ReactNode[] = []
{
// Segment boundaries: the token range end, every chip offset, and every
@@ -322,7 +340,7 @@ export function InputBar({
pushPlain(draft.length)
if (deco.hint !== null) {
// Claim tokens are shaped `/name ` (trailing space); trim to the bare name.
const commandName = input.claim?.token.slice(1).trim() ?? ''
const commandName = input?.claim?.token.slice(1).trim() ?? ''
const hintKey = commandName === 'goal' && hasGoal ? 'goal.active' : commandName
const translated = translateHint(hintKey)
const displayHint = translated !== hintKey ? translated : deco.hint
@@ -356,7 +374,7 @@ export function InputBar({
value={draft}
disabled={locked}
readOnly={machineBusy}
data-phase={input.phase}
data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? 'Session unavailable'
: planActive ? translateHint('placeholder.plan') : translateHint('placeholder.default'))}
@@ -417,6 +435,7 @@ export function InputBar({
</div>
</div>
</div>
{footer}
</div>
)
}

View File

@@ -1,13 +1,13 @@
/* 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 776, centered; InputBar top pad supplies 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;
width: calc(100% - 88px);
max-width: 776px;
max-width: 752px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 14px;
background: var(--dsw-specific-tip);
@@ -23,8 +23,8 @@
.body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 16px;
gap: 8px;
padding: 9px 15px;
}
.header {

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)
},
}

View File

@@ -0,0 +1,92 @@
// ask_user_question toolview: question-flavored summary row replacing the
// generic "Tool call" card, registered into the keyed
// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow
// (chrome, running sweep, leading expansion) and swaps in the interaction
// outcome — `waiting` while pending, answered-count once settled, `cancelled`
// when the user dismissed the whole set — because the questions themselves
// render in the composer takeover.
import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
/** One parsed answer entry, shape-checked (result JSON crosses the wire). */
interface AnswerEntry { selected?: unknown; custom?: unknown }
function isAnswer(value: unknown): value is AnswerEntry {
return typeof value === 'object' && value !== null
}
/** `${answered}/${total} answered` off the result JSON (a skipped question has
* empty `selected` and no `custom`); null on unexpected shape (generic fallback). */
function answeredSummary(text: string): string | null {
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
return null
}
if (typeof parsed !== 'object' || parsed === null) return null
const answers = (parsed as { answers?: unknown }).answers
if (!Array.isArray(answers) || !answers.every(isAnswer)) return null
const answered = answers.filter(a =>
(Array.isArray(a.selected) && a.selected.length > 0)
|| (typeof a.custom === 'string' && a.custom !== '')).length
return `${answered}/${answers.length} answered`
}
/** One-line question-interaction row (leading toggle expands the raw args). */
export function AskQuestionRow({ toolName, block }: ToolRowProps) {
const model = toolRowModel(toolName, block)
// Composer verdicts settle the call as specific UserInteractionErrors
// (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
// dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the
// question was pending. Both name their verdict instead of the generic
// failed shape, and the abort keeps the shared stopped (amber) semantics of
// any other interrupted tool call.
const code = 'kind' in block ? block.error?.code : undefined
let summary = model.summary
let state = model.state
if (code === 'ASK_CANCELLED') {
summary = 'cancelled'
} else if (code === 'ASK_ABORTED') {
summary = 'interrupted'
state = 'stopped'
} else if (model.state === 'running') {
summary = 'waiting'
} else if ('kind' in block && model.state === 'ok') {
const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('')
summary = answeredSummary(text) ?? model.summary
}
return (
<ToolRow
variant={model.variant}
toolName={toolName}
icon={<IconQuestionOutline14 />}
title="Ask question"
summary={summary}
body={model.body}
state={state}
/>
)
}
/**
* The ask-question row as a plain registrant plugin, riding the same
* load-order seam as todo-toolview: `inject: ['conversation']` guarantees the
* chat entry (and with it the 'conversation.chat.toolview' declaration) is on
* the ledger.
*/
export const askQuestionToolview = {
name: 'ask-question-toolview',
inject: ['slots', 'conversation'],
/**
* Register the ask-question row into the chat view's keyed toolview hole.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
},
}

View File

@@ -1,56 +0,0 @@
/* todo_write plan-update row: ToolRow chrome (figma 780:53675) —
[16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */
.row {
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
font-weight: 500; /* figma wt510, rendered 500 */
color: var(--dsw-alias-label-primary-dimmed);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.err {
flex: none;
margin-left: 8px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
}

View File

@@ -1,15 +1,16 @@
// todo_write toolview: plan-flavored summary row replacing the generic
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
// hole like the bash sample (a product registration, not a sample). The row
// summarizes the written list (counts + active item) from the call args; the
// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a
// summary of the written list (counts + active item) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line. Chrome matches ToolRow (figma 780:53675).
// row stays one line.
import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './todo-row.module.css'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
interface TodoWriteItem { content?: unknown; status?: unknown }
@@ -39,37 +40,23 @@ function summarize(argsRaw: string): string | null {
: head
}
/** Leading-slot state substitution matches ToolRow / bash: icon yields to the
* state semantic while running or failed; ok keeps the checklist glyph. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconChecklistOutline16 />
}
}
/** One-line plan update row. Non-ok execution states keep the generic row's
* dot semantics — a cancelled call wrote no todo/write, so it must not read
* as a completed update. */
/** One-line plan update row (leading toggle expands the raw args). Non-ok
* execution states keep the shared row's dot semantics — a cancelled call
* wrote no todo/write, so it must not read as a completed update. */
export function TodoRow({ toolName, block }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
return (
<div
className={css.row}
data-sample="todo-row"
data-state={model.state}
>
<span className={css.leading} aria-hidden>{leadingFor(model.state)}</span>
<span className={css.title}>更新任务清单</span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
{model.state === 'stopped' && <span className={css.err}>已中断</span>}
</div>
<ToolRow
variant={model.variant}
toolName={toolName}
icon={<IconChecklistOutline14 />}
title="更新任务清单"
summary={summary}
body={model.body}
state={model.state}
/>
)
}