Merge branch 'master' into feat/web-preview-badge

This commit is contained in:
Ziya
2026-08-05 23:29:04 -07:00
committed by GitHub
2511 changed files with 48883 additions and 24908 deletions

View File

@@ -1,6 +1,6 @@
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import { deferRegistration, resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -98,20 +98,16 @@ export function apply(ctx: Context): void {
const chatStore = createChatStore()
const submissionPolicy = new ComposerSubmissionPolicy()
ctx.effect(() => {
const row = deferRegistration(ctx.slots, 'settings.general.item', EnterBehaviorRow, () =>
ctx.slots.register({
name: 'settings.general.item',
id: 'composer-enter',
order: 20,
locale: NS,
inject: (): EnterBehaviorRowInjected => ({
hooks: { busyEnter: submissionPolicy.busyEnter },
setBusyEnter: (behavior) => { submissionPolicy.setBusyEnter(behavior) },
}),
}, EnterBehaviorRow))
return () => { row.dispose() }
}, 'ui-conversation: Enter behavior settings row')
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: 'composer-enter',
order: 20,
locale: NS,
inject: (): EnterBehaviorRowInjected => ({
hooks: { busyEnter: submissionPolicy.busyEnter },
setBusyEnter: (behavior) => { submissionPolicy.setBusyEnter(behavior) },
}),
}, EnterBehaviorRow))
// Chat semantic reader positions by session, surviving view switches and
// width reflow when the tab ring remounts the view. Deliberately not
@@ -334,17 +330,15 @@ export function apply(ctx: Context): void {
}, ChatView)
// Session stats stick with the composer (composer.dock = stats-line family).
slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0 }, StatsLine)
slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS }, StatsLine)
// Class-plugin mount (packages/AGENTS.md service form): the service
// registers itself as `conversation` and lives on its own child fiber.
// Mounted AFTER the chat entry register above — construction guarantee for
// toolview registrants using `inject: ['conversation']` as their load-order
// seam: the service being present implies the chat entry (and with it the
// 'conversation.chat.toolview' declaration) is on the ledger.
// Presentation registrants depend directly on their slot declarations;
// this service remains only where conversation actions are required.
ctx.plugin(ConversationService, { input: inputHub })
// The bash sample rides that exact seam, in third-party posture
// The bash sample rides the same declaration seam, in third-party posture
// (ToolRow-matching Bash · {description} chrome).
ctx.plugin(bashToolviewSample)

View File

@@ -30,6 +30,10 @@ export interface AssistantMarkdownProps {
/** Turn wall time in ms for the IconActions run-time label; omitted when the
* turn's triggering input is outside the loaded window. */
runMs?: number | undefined
/** Turn first-step TTFT in ms for the IconActions label; omitted when unrecorded. */
ttftMs?: number | undefined
/** Turn decode throughput for the IconActions label; omitted when unrecorded. */
tokensPerSecond?: number | undefined
/** Event sequence used as the fork boundary; omitted while streaming. */
seq?: number | undefined
/** Fork the session through this finalized message's completed turn when eligible. */
@@ -82,7 +86,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, runMs, seq, onFork, forkUnavailable, t,
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
@@ -125,6 +129,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
text={copyText(blocks)}
time={time}
runMs={runMs}
ttftMs={ttftMs}
tokensPerSecond={tokensPerSecond}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
branchUnavailable={forkUnavailable}

View File

@@ -16,7 +16,9 @@
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 16px 24px;
/* Sides = composer clearance + 16px: on narrow viewports the transcript
stays exactly 32px narrower than the input card (the shared width rule). */
padding: 16px calc(var(--dsh-composer-side-clearance) + 16px);
}
:global([data-conversation-scroll]) .root {
@@ -31,10 +33,11 @@
min-height: auto;
}
/* Message column: 736px fixed width, centered on the same axis as the
input box; the scroller itself stays full-bleed. */
/* Message column: shared chat width (ConversationRoot --dsh-chat-content-width),
centered on the same axis as the input box (which caps at chat + 16px); the
scroller itself stays full-bleed. */
.column {
max-width: 736px;
max-width: var(--dsh-chat-content-width);
width: 100%;
margin: 0 auto;
display: flex;
@@ -166,7 +169,7 @@
height: 0;
display: flex;
justify-content: flex-end;
padding-right: max(0px, calc((100% - 736px) / 2));
padding-right: max(0px, calc((100% - var(--dsh-chat-content-width)) / 2));
pointer-events: none;
}

View File

@@ -36,6 +36,7 @@ import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
import { formatRunDuration } from './message-chrome.ts'
import { deriveTurnMetrics } from './turn-metrics.ts'
import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
@@ -362,6 +363,7 @@ export function ChatView({
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings])
const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes])
const listRef = useRef<HTMLDivElement | null>(null)
const columnRef = useRef<HTMLDivElement | null>(null)
@@ -599,6 +601,9 @@ export function ChatView({
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined
// Metrics gate on the settled in-window timing: turn/start loaded means
// every step of the turn is loaded, so first-step TTFT is genuine.
const metrics = timing?.endTime === undefined ? undefined : turnMetrics.get(node.turn)
return (
<AssistantMarkdown
blocks={node.blocks}
@@ -608,6 +613,8 @@ export function ChatView({
runMs={timing?.endTime === undefined
? undefined
: Math.max(0, timing.endTime - timing.startTime)}
ttftMs={metrics?.ttftMs}
tokensPerSecond={metrics?.tokensPerSecond}
seq={node.seq}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}

View File

@@ -0,0 +1,161 @@
/* Expanded context bodies: one code-block surface shared by every form, so the
disclosure keeps the Figma 10:2482 geometry whichever form renders inside. */
.text {
margin: 0;
color: var(--dsw-alias-label-secondary);
font: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Provenance beneath the text: dimmer than the content it describes. */
.fields {
display: flex;
flex-direction: column;
gap: 2px;
margin: 8px 0 0;
padding-top: 8px;
border-top: 1px solid var(--dsw-alias-line-secondary);
}
.field {
display: flex;
gap: 8px;
min-width: 0;
}
.fieldKey {
flex: none;
min-width: 96px;
color: var(--dsw-alias-label-caption);
}
.fieldValue {
flex: 1 1 auto;
min-width: 0;
margin: 0;
color: var(--dsw-alias-label-tertiary);
overflow-wrap: anywhere;
}
/* instructions: the reconciled files, above their text. */
.files {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin: 0 0 8px;
padding: 0;
list-style: none;
}
.file {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.filePath {
color: var(--dsw-alias-label-secondary);
overflow-wrap: anywhere;
}
.fileAction {
color: var(--dsw-alias-label-caption);
}
/* catalog: a replacement notice above one row per published entry. */
.catalogNotice {
margin: 0 0 6px;
color: var(--dsw-alias-label-caption);
}
.entries {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.entry {
display: flex;
gap: 8px;
min-width: 0;
}
.entryName {
flex: none;
color: var(--dsw-alias-label-secondary);
}
.entryDescription {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}
/* snapshot: one titled block per contributing subsystem. */
.sections {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
}
.section {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sectionName {
color: var(--dsw-alias-label-caption);
}
.sectionText {
margin: 0;
color: var(--dsw-alias-label-secondary);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* relay: who sent this, above what they said. */
.relaySender {
margin: 0 0 6px;
color: var(--dsw-alias-label-caption);
overflow-wrap: anywhere;
}
/* recall: one row per source session, with how much of it survived. */
.recalls {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0 0 8px;
padding: 0;
list-style: none;
}
.recall {
display: flex;
gap: 8px;
min-width: 0;
}
.recallLabel {
color: var(--dsw-alias-label-secondary);
overflow-wrap: anywhere;
}
.recallCounts {
flex: none;
color: var(--dsw-alias-label-caption);
}

View File

@@ -0,0 +1,591 @@
// Expanded bodies for the context disclosure, one per durable context form.
// The producer declares the form; this module only chooses a presentation for
// it. Every form falls back to OpaqueBody, which is the documented default for
// an absent, unknown, or malformed form — a resumed or foreign log must render
// even when this UI version has never seen its producer.
import type { ReactNode } from 'react'
import type { ContextMessageNode, KnownContextForm } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import css from './ContextBody.module.css'
/** Model-facing text stays bounded at the disclosure, not at the producer. */
const MAX_CHARS = 20_000
/** Rows a list body materializes before summarizing the remainder. */
const MAX_ENTRIES = 200
type Translate = ChatViewSlotProps['t']
/** One durable source narrowed to the readable-record shape; null for anything else. */
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
/** One run of the model-facing content: adjacent text, or one unknown block. */
type ContentRun = { text: string } | { block: unknown }
/**
* The content blocks as runs, IN THE ORDER the model received them.
*
* Adjacent text blocks join with no separator, matching how provider adapters
* flatten them — inserting a line break would show the reader a line the model
* never saw. An unknown block breaks the run and keeps its own fallback rather
* than being hoisted past the text around it or vanishing; the block union is
* merge-extensible, so a foreign log may interleave shapes this build does not
* know.
*/
function contentRuns(content: ContextMessageNode['content']): ContentRun[] {
const runs: ContentRun[] = []
for (const block of content) {
if (block.type !== 'text') {
runs.push({ block })
continue
}
const last = runs[runs.length - 1]
if (last !== undefined && 'text' in last) last.text += block.text
else runs.push({ text: block.text })
}
return runs
}
/** Only the blocks this UI version does not know, for bodies that replace the text. */
function unknownBlocks(content: ContextMessageNode['content']): unknown[] {
return contentRuns(content).flatMap(run => 'block' in run ? [run.block] : [])
}
/** The model-facing text, truncated to the display bound. */
function boundedText(text: string, t: Translate): string {
return text.length > MAX_CHARS
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
: text
}
/**
* One source field rendered as a value row; nested shapes stay compact JSON.
* Bounded on its own, because provenance is as unbounded as the text: an unknown
* producer may record an arbitrarily large string or array.
*/
function fieldValue(value: unknown, t: Translate): string {
const text = typeof value === 'string'
? value
: typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value)
return boundedText(text, t)
}
/**
* Provenance fields as a key/value list. `kind` is always omitted because the
* row header already names the producer. `form` is omitted only when a
* dedicated body rendered for it — then the presentation the reader is looking
* at IS that value. On the opaque fallback the declaration is kept, because
* that is the one place a form this version cannot present would otherwise
* disappear from the UI entirely.
*/
function SourceFields({ source, formRendered, t }: {
source: unknown
formRendered: boolean
t: Translate
}): ReactNode {
const record = asRecord(source)
if (record === null) return null
const hidden = formRendered ? ['kind', 'form'] : ['kind']
const rows = Object.entries(record).filter(([key]) => !hidden.includes(key))
if (rows.length === 0) return null
return (
<dl className={css.fields} data-context-fields>
{rows.map(([key, value]) => (
<div key={key} className={css.field}>
<dt className={css.fieldKey}>{key}</dt>
<dd className={css.fieldValue}>{fieldValue(value, t)}</dd>
</div>
))}
</dl>
)
}
/**
* Content blocks this UI version does not know, kept visible rather than
* dropped: the block union is merge-extensible, so a newer or foreign log may
* carry a shape this build has no presentation for.
* @param props - The unrecognized blocks and the locale seat.
* @returns One generic JSON block per unknown entry.
*/
function UnknownBlocks({ blocks, t }: { blocks: readonly unknown[]; t: Translate }): ReactNode {
return (
<>
{blocks.map((block, index) => (
<JsonBlock
key={index}
label={t('message.unknownBlock')}
payload={block}
truncatedLabel={total => t('json.truncated', { total })}
/>
))}
</>
)
}
/**
* The model-facing content of one context, shared by every form that shows it:
* the text with its real line breaks, then any block this UI version does not
* know, which keeps its own fallback rather than vanishing.
* @param props - Durable content and the locale seat.
* @returns The content blocks as the model received them.
*/
function ModelFacingContent({ content, t }: {
content: ContextMessageNode['content']
t: Translate
}): ReactNode {
return (
<>
{contentRuns(content).map((run, index) => ('text' in run
? run.text !== '' && (
<pre key={index} className={css.text} data-context-text>{boundedText(run.text, t)}</pre>
)
: (
<JsonBlock
key={index}
label={t('message.unknownBlock')}
payload={run.block}
truncatedLabel={total => t('json.truncated', { total })}
/>
)))}
</>
)
}
/**
* Default presentation: the model-facing text as text, with its real line
* breaks, and the remaining provenance beneath it. This is what every form
* this UI version does not recognize renders as.
* @param props - Durable content, its source, and the locale seat.
* @returns The opaque context body.
*/
export function OpaqueBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
return (
<>
<ModelFacingContent content={content} t={t} />
<SourceFields source={source} formRendered={false} t={t} />
</>
)
}
/** One reconciled instruction file, as the durable source records it. */
interface InstructionChange {
action: 'set' | 'replace' | 'remove'
path: string
digest?: string
}
/**
* Instruction changes read off the source, or null when the record is not a
* usable instruction list.
*
* The read is all-or-nothing: silently dropping one unreadable entry would show
* a confident, incomplete file list for a log this version cannot fully read.
* Paths are deduplicated in first-seen order, matching how the header label is
* derived from the same array.
*/
function instructionChanges(source: unknown): InstructionChange[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['changes']
if (!Array.isArray(list)) return null
const changes: InstructionChange[] = []
const seen = new Set<string>()
for (const entry of list as readonly unknown[]) {
const change = asRecord(entry)
if (change === null) return null
const path = change['path']
if (typeof path !== 'string' || path === '') return null
const action = change['action']
// The action decides which word the row shows, so an unrecognized one is
// not a readable change — it would be presented as loaded or updated.
if (action !== 'set' && action !== 'replace' && action !== 'remove') return null
const digest = change['digest']
if (seen.has(path)) continue
seen.add(path)
changes.push({ action, path, ...typeof digest === 'string' ? { digest } : {} })
}
return changes.length === 0 ? null : changes
}
/**
* Locale key for one reconciled file. The baseline loads a file; a later delta
* distinguishes a newly reconciled path from a rewritten one, which `set` and
* `replace` already separate at the producer.
* @param action - the durable change action.
* @param baseline - whether this context is the startup/resume baseline.
* @returns the key naming what happened to that file.
*/
function instructionAction(
action: InstructionChange['action'],
baseline: boolean,
): 'message.context.instructions.removed' | 'message.context.instructions.loaded'
| 'message.context.instructions.added' | 'message.context.instructions.updated' {
if (action === 'remove') return 'message.context.instructions.removed'
if (baseline) return 'message.context.instructions.loaded'
return action === 'set' ? 'message.context.instructions.added' : 'message.context.instructions.updated'
}
/**
* `instructions` form: the files this context reconciled, then their text.
*
* The text keeps its `<system-reminder>` framing verbatim — the framing is part
* of what the model read, so hiding it would misreport the request.
* @param props - Durable content, its source, and the locale seat.
* @returns The instructions context body, or the opaque body when the change
* list is unreadable.
*/
export function InstructionsBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const changes = instructionChanges(source)
if (changes === null) return <OpaqueBody content={content} source={source} t={t} />
const baseline = asRecord(source)?.['baseline'] === true
return (
<>
<ul className={css.files} data-context-files>
{changes.map(change => (
<li key={change.path} className={css.file} title={change.digest}>
<span className={css.filePath}>{change.path}</span>
<span className={css.fileAction}>
{t(instructionAction(change.action, baseline))}
</span>
</li>
))}
</ul>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** One catalog entry, as the durable source records it. */
interface CatalogEntry {
name: string
description: string
}
/**
* Catalog entries read off the source, or null when the record is not a usable
* catalog. All-or-nothing for the same reason as the instruction list: this body
* replaces the model-facing text, so a partial list would hide the only complete
* account of what the model read.
*/
function catalogEntries(source: unknown): CatalogEntry[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['entries']
if (!Array.isArray(list)) return null
const entries: CatalogEntry[] = []
for (const item of list as readonly unknown[]) {
const entry = asRecord(item)
if (entry === null) return null
const name = entry['name']
const description = entry['description']
if (typeof name !== 'string' || name === '' || typeof description !== 'string') return null
entries.push({ name, description })
}
// An empty list is a real catalog: a replacement with no entries retires
// every earlier name. Only an unreadable shape falls back.
return entries
}
/**
* `catalog` form: the published entries as a list, read from the source rather
* than re-parsed out of the model-facing prose.
*
* A catalog whose source carries no usable entries falls through to the opaque
* body, so an older or hand-edited log still shows its text.
* @param props - Durable content, its source, and the locale seat.
* @returns The catalog context body, or the opaque body when the entry list is
* unreadable.
*/
export function CatalogBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const entries = catalogEntries(source)
if (entries === null) return <OpaqueBody content={content} source={source} t={t} />
const update = asRecord(source)?.['update'] === true
// Entry count is unbounded (a provider may publish any number of skills), and
// the scrollport bounds height, not node count — so the list bounds itself.
const shown = entries.slice(0, MAX_ENTRIES)
const rest = unknownBlocks(content)
return (
<>
{update && <p className={css.catalogNotice} data-context-catalog-update>{t('message.context.catalog.replaced')}</p>}
<ul className={css.entries} data-context-entries>
{shown.map((entry, index) => (
// Index key: a hand-edited or foreign log may repeat a name, and a
// duplicate React key would drop a row the model did see.
<li key={index} className={css.entry}>
<code className={css.entryName}>{entry.name}</code>
<span className={css.entryDescription}>{entry.description}</span>
</li>
))}
</ul>
{shown.length < entries.length && (
<p className={css.catalogNotice} data-context-entries-truncated>
{t('message.context.catalog.more', { count: entries.length - shown.length })}
</p>
)}
{/* The block union is merge-extensible: a catalog message carrying an
unknown block still shows it rather than dropping model-visible content. */}
<UnknownBlocks blocks={rest} t={t} />
</>
)
}
/** One named contribution to a runtime snapshot, as the durable source records it. */
interface SnapshotSection {
name: string
text: string
}
/** Snapshot sections read off the source, or null when the record is unusable. */
function snapshotSections(source: unknown): SnapshotSection[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['sections']
if (!Array.isArray(list)) return null
const sections: SnapshotSection[] = []
for (const item of list as readonly unknown[]) {
const section = asRecord(item)
if (section === null) return null
const name = section['name']
const text = section['text']
if (typeof name !== 'string' || name === '' || typeof text !== 'string') return null
sections.push({ name, text })
}
return sections.length === 0 ? null : sections
}
/**
* `snapshot` form: the named contributions this snapshot assembled, in order.
*
* The sections are the same bytes the model read, split at the boundaries the
* producer assembled them on, so a reader sees which subsystem contributed
* which state instead of one undifferentiated wall.
*
* One sentence of the model-facing text is NOT in any section: the producer's
* framing line declaring that this snapshot supersedes earlier ones. Unlike the
* `<system-reminder>` wrapper an instruction context carries — which wraps
* content and cannot be separated from it — that line states the form's own
* semantics, so the body states them as a caption instead of reprinting the
* joined prose beside the sections it was split from.
* @param props - Durable content, its source, and the locale seat.
* @returns The snapshot context body, or the opaque body when unreadable.
*/
export function SnapshotBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sections = snapshotSections(source)
/* v8 ignore next -- contextBody reads the sections before choosing this body. */
if (sections === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<>
<p className={css.catalogNotice} data-context-snapshot-supersedes>
{t('message.context.snapshot.supersedes')}
</p>
<dl className={css.sections} data-context-sections>
{sections.map((section, index) => (
<div key={index} className={css.section}>
<dt className={css.sectionName}>{section.name}</dt>
<dd className={css.sectionText}>{boundedText(section.text, t)}</dd>
</div>
))}
</dl>
</>
)
}
/**
* `notice` form: what just happened, with the model-facing text beneath it.
*
* The one-line account also rides the collapsed row ({@link contextBody}), so a
* notice is usually readable without expanding at all.
* @param props - Durable content, its source, and the locale seat.
* @returns The notice context body.
*/
export function NoticeBody({ content, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
return <ModelFacingContent content={content} t={t} />
}
/**
* `relay` form: which agent sent this, then what it said.
*
* The sender is an opaque session id; it is shown as provenance rather than a
* label, because this client cannot resolve it to a title.
* @param props - Durable content, its source, and the locale seat.
* @returns The relay context body.
*/
export function RelayBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sender = relaySender(source)
/* v8 ignore next -- contextBody resolves the sender before choosing this body. */
if (sender === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<>
<p className={css.relaySender} data-context-relay-sender>
{t('message.context.relay.from', { session: sender })}
</p>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** The sending agent's session id, or null when the record does not name one. */
function relaySender(source: unknown): string | null {
const sender = asRecord(source)?.['senderSessionId']
return typeof sender === 'string' && sender !== '' ? sender : null
}
/** One recalled session, as the durable source records it. */
interface RecalledSession {
label: string
retained: number
omitted: number
truncated: boolean
}
/** Recalled sessions read off the source, or null when the record is unusable. */
function recalledSessions(source: unknown): RecalledSession[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['references']
if (!Array.isArray(list)) return null
const sessions: RecalledSession[] = []
for (const item of list as readonly unknown[]) {
const reference = asRecord(item)
if (reference === null) return null
const label = reference['label']
const retained = reference['retainedMessages']
const omitted = reference['omittedMessages']
const truncated = reference['truncated']
// Completeness is the fact this card exists to report, so a reference that
// cannot state it is not a readable recall — showing the label alone would
// present a confident card over unknown loss.
if (typeof label !== 'string' || label === ''
|| typeof retained !== 'number' || typeof omitted !== 'number'
|| typeof truncated !== 'boolean') return null
sessions.push({ label, retained, omitted, truncated })
}
return sessions.length === 0 ? null : sessions
}
/**
* `recall` form: which sessions this material came from and how much of each
* survived the read, then the material itself.
*
* Completeness is the fact a reader needs first: recalled context is bounded on
* the way in, so a card that hid the omitted count would overstate what the
* model received.
* @param props - Durable content, its source, and the locale seat.
* @returns The recall context body, or the opaque body when unreadable.
*/
export function RecallBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sessions = recalledSessions(source)
if (sessions === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<>
<ul className={css.recalls} data-context-recalls>
{sessions.map((session, index) => (
<li key={index} className={css.recall}>
<span className={css.recallLabel}>{session.label}</span>
<span className={css.recallCounts}>
{t('message.context.recall.counts', {
retained: session.retained,
omitted: session.omitted,
})}
</span>
{session.truncated && (
<span className={css.recallCounts}>{t('message.context.recall.truncated')}</span>
)}
</li>
))}
</ul>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** The one-line account a `notice` puts on its collapsed row, when it records one. */
function noticeSummary(source: unknown): string | null {
const summary = asRecord(source)?.['summary']
return typeof summary === 'string' && summary !== '' ? summary : null
}
/**
* Choose the body for one context node.
*
* Returns the form the body actually rendered as, which is not always the
* declared one: a declared form whose fields are unreadable falls back to
* opaque, and the caller labels the row with what it really shows.
* `summary` is the collapsed row's one-line account, which only a `notice`
* records: its whole point is being readable without expanding.
* @param form - the producer-declared form projected onto the node.
* @param props - durable content, its source, and the locale seat.
* @returns the rendered form (null for opaque), its collapsed summary, and its body.
*/
export function contextBody(
form: ContextMessageNode['form'],
props: { content: ContextMessageNode['content']; source: unknown; t: Translate },
): { rendered: KnownContextForm | null; summary: string | null; body: ReactNode } {
const opaque = { rendered: null, summary: null, body: <OpaqueBody {...props} /> }
switch (form) {
case 'instructions':
return instructionChanges(props.source) === null
? opaque
: { rendered: 'instructions', summary: null, body: <InstructionsBody {...props} /> }
case 'catalog':
return catalogEntries(props.source) === null
? opaque
: { rendered: 'catalog', summary: null, body: <CatalogBody {...props} /> }
case 'snapshot':
return snapshotSections(props.source) === null
? opaque
: { rendered: 'snapshot', summary: null, body: <SnapshotBody {...props} /> }
case 'notice': {
const summary = noticeSummary(props.source)
return summary === null
? opaque
: { rendered: 'notice', summary, body: <NoticeBody {...props} /> }
}
case 'relay':
return relaySender(props.source) === null
? opaque
: { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
case 'recall':
return recalledSessions(props.source) === null
? opaque
: { rendered: 'recall', summary: null, body: <RecallBody {...props} /> }
case null:
return opaque
/* v8 ignore next 4 -- closed-union backstop; the compiler rejects a new
KnownContextForm here rather than letting it degrade to opaque silently. */
default: {
const unreachable: never = form
throw new Error(`unreachable context form: ${String(unreachable)}`)
}
}
}

View File

@@ -12,6 +12,40 @@
color: var(--dsw-alias-label-secondary);
}
/* Separator and producer name beside the role title: ToolRow's summary geometry,
so the two disclosure rows keep one 24px rhythm and one separator shape. */
.sep {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
.source {
flex: none;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A notice's one-line account: the reason it rarely needs expanding. */
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
box-sizing: border-box;
width: calc(100% - 22px);
@@ -23,7 +57,6 @@
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
/* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */
font: 400 11px/16px var(--ds-font-family-code);
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@@ -1,84 +1,70 @@
import { useMemo, useState } from 'react'
import { useState } from 'react'
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow } from './DisclosureRow.tsx'
import { contextBody } from './ContextBody.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']
/** Role and producer name projected from the durable source. */
provenance: ContextMessageNode['provenance']
/** Producer-declared information form; null renders the opaque body. */
form: ContextMessageNode['form']
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
/**
* 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.
*
* The header names the role the context plays and, beside it, the producer the
* durable source identifies, so a reader can tell an injected skill catalog
* from a workspace instruction file or a recalled session without expanding.
* The expanded body follows the producer-declared form; an absent or unknown
* form renders the opaque body.
* @param props - Durable content, its projected provenance and form, and the locale seat.
* @returns A collapsed context row with a bounded, form-specific body.
*/
export function ContextInjectionRow({ content, source, t }: ContextInjectionRowProps) {
export function ContextInjectionRow({ content, source, provenance, form, t }: 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${t('json.truncated', { total: text.length })}`
: text
}, [content, open, source, t])
// Resolved rather than declared: a form whose fields are unreadable renders
// the opaque body, and the marker must say what the row actually shows.
const { rendered, summary, body } = contextBody(form, { content, source, t })
return (
<DisclosureRow
className={css.root}
icon={<IconBrowseOutline16 size={14} />}
chevronClassName={css.chevron}
title={t('message.contextInjection')}
title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
collapsedContent={provenance.label === null ? undefined : (
/* ToolRow's separator shape: an aria-hidden dot, so the accessible name
stays the two readable parts and the two disclosure rows expose one
name shape. A source that names no producer drops the dot with it. */
<>
<span className={css.sep} aria-hidden />
<span className={css.source} data-context-source>{provenance.label}</span>
{summary !== null && (
<>
<span className={css.sep} aria-hidden />
<span className={css.summary} data-context-summary>{summary}</span>
</>
)}
</>
)}
keepContentWhenOpen
open={open}
expandable
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<pre className={css.body} data-context-injection-body>{body}</pre>
<div className={css.body} data-context-injection-body data-context-form={rendered ?? undefined}>
{body}
</div>
</DisclosureRow>
)
}

View File

@@ -1,12 +1,12 @@
// Shared IconActions chrome for user, steering, and assistant messages: copy
// Shared IconActions chrome for user and assistant messages: copy
// live, optional branch wiring, and an optional date-aware clock.
import { useCallback, useId } from 'react'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import {
IconBranchOutline16, IconCopyOutline16, Tooltip,
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { formatMessageClock, formatRunDuration, writeClipboard } from './message-chrome.ts'
import { formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
@@ -17,6 +17,10 @@ export interface MessageIconActionsProps {
time?: number | undefined
/** Turn wall time in ms, appended to the clock as `· Ran for 15s`; omitted when the turn's start is unknown. */
runMs?: number | undefined
/** Turn first-step TTFT in ms, appended as `· TTFT 1.2s`; omitted when unrecorded. */
ttftMs?: number | undefined
/** Turn decode throughput, appended as `· 34 tok/s`; omitted when unrecorded. */
tokensPerSecond?: number | undefined
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** Fork the session at this message; omission hides the branch action. */
@@ -37,30 +41,74 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, runMs, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
// Same success chrome as CodeBlock: a short check swap after the write,
// gated so re-clicks during the window neither re-copy nor stack timers.
const [copied, setCopied] = useState(false)
const copyPending = useRef(false)
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const copyEpoch = useRef(0)
useEffect(() => () => {
copyEpoch.current += 1
copyPending.current = false
if (copyTimer.current !== null) clearTimeout(copyTimer.current)
}, [])
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
if (copied || copyPending.current) return
const epoch = copyEpoch.current
copyPending.current = true
void writeClipboard(text).then((ok) => {
if (epoch !== copyEpoch.current) return
copyPending.current = false
if (!ok) return
setCopied(true)
copyTimer.current = window.setTimeout(() => {
copyTimer.current = null
setCopied(false)
}, 1000)
})
}, [copied, text])
// The dot is decorative and stays hidden, but its margins separate the
// readings only on screen: without the flanking spaces a reader hears one
// run-on string ("Ran for 13sTTFT 0.2s12 tok/s") instead of three facts.
const clockEl = time === undefined ? null : (
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
{formatMessageClock(time, t, day)}
{runMs !== undefined && (
<>
{' '}
<span className={css.runTimeDot} aria-hidden>·</span>
{' '}
{t('message.ranFor', { duration: formatRunDuration(runMs, t) })}
</>
)}
{ttftMs !== undefined && (
<>
{' '}
<span className={css.runTimeDot} aria-hidden>·</span>
{' '}
{t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })}
</>
)}
{tokensPerSecond !== undefined && (
<>
{' '}
<span className={css.runTimeDot} aria-hidden>·</span>
{' '}
{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}
</>
)}
</span>
)
return (
<div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
{clock === 'start' ? clockEl : null}
<Tooltip label={t('copy')} side="bottom">
<button type="button" className={css.action} aria-label={t('copy')} onClick={onCopy}>
<IconCopyOutline16 />
<Tooltip label={copied ? t('copied') : t('copy')} side="bottom">
<button type="button" className={css.action} aria-label={copied ? t('copied') : t('copy')} onClick={onCopy}>
{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
</button>
</Tooltip>
{showBranch && onBranch !== undefined && (

View File

@@ -8,6 +8,15 @@
gap: 6px;
}
/* Steering caption above the bubble: mid-turn interjections carry the same
bubble as a turn-opening prompt, so the transcript names which one this is. */
.steeringMark {
padding-right: 4px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 16px;
}
.bubble {
/* 525px cap inside the 736 column; percentage keeps narrow windows sane. */
max-width: min(525px, 82%);

View File

@@ -1,7 +1,8 @@
// MessageItem: simple chat nodes — user and consumed-steering bubbles
// (right-aligned, with clock + copy / branch IconActions), pending steering
// (copy only), context injection, compaction marker, retry disclosure, and
// unknown-surface JSON rows.
// (right-aligned, with clock + copy / branch IconActions; steering adds the
// interjection caption that names it), pending steering (caption + copy only),
// context injection, compaction marker, retry disclosure, and unknown-surface
// JSON rows.
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
@@ -171,19 +172,22 @@ function projectUserText(text: string): ReactNode {
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, actions, pending = false, t,
content, actions, pending = false, steering = false, t,
}: {
content: readonly unknown[]
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
actions?: (text: string) => ReactNode
/** Whether this is the Host-authoritative pre-admission steering projection. */
pending?: boolean
/** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */
steering?: boolean
t: ChatViewSlotProps['t']
}): ReactNode {
const { text, rest } = contentText(content)
const truncated = (total: number): string => t('json.truncated', { total })
return (
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
{steering && <span className={css.steeringMark} data-steering-mark>{t('message.steering')}</span>}
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
@@ -207,6 +211,7 @@ export function PendingSteeringBubble({ content, t }: {
<UserStyleBubble
content={content}
pending
steering
t={t}
actions={text => (
<MessageIconActions
@@ -231,6 +236,7 @@ export const MessageItem = memo(function MessageItem({
return (
<UserStyleBubble
content={node.content}
steering={node.kind === 'steering'}
t={t}
actions={text => (
<MessageIconActions
@@ -247,7 +253,13 @@ export const MessageItem = memo(function MessageItem({
)
case 'context':
return (
<ContextInjectionRow content={node.content} source={node.source} t={t} />
<ContextInjectionRow
content={node.content}
source={node.source}
provenance={node.provenance}
form={node.form}
t={t}
/>
)
case 'compaction':
return <CompactionItem node={node} t={t} />

View File

@@ -1,23 +1,25 @@
/* Session stats row: 12/20 tertiary text under the flow, aligned to the
736px message column axis. */
shared message column axis (--dsh-chat-content-width). */
.root {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
max-width: 736px;
/* Block, not flex: text-overflow only elides a block's inline content, so
an overlong line ends in … instead of a mid-glyph clip. */
display: block;
text-align: center;
max-width: var(--dsh-chat-content-width);
width: 100%;
margin: 0 auto;
box-sizing: border-box;
padding: 4px 24px 0px;
padding: 4px calc(var(--dsh-composer-side-clearance) + 16px) 0px;
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sep {
color: var(--dsw-alias-separator-primary);
margin: 0 10px; /* carries the former flex gap */
}

View File

@@ -2,10 +2,14 @@
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { Fragment, memo, useMemo } from 'react'
import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import type { ComposerBarProps } from '../contract/slots.ts'
import { formatTokensPerSecond } from './message-chrome.ts'
import { assistantStepReading } from './turn-metrics.ts'
import css from './StatsLine.module.css'
interface WindowStats {
@@ -15,6 +19,14 @@ interface WindowStats {
llmMs: number
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
toolMs: number
/** Summed first-token latency over `ttftSteps`; 0 when no step records it. */
ttftMs: number
/** Steps carrying a recorded TTFT. */
ttftSteps: number
/** Summed decode wall time over steps that also report output tokens. */
decodeMs: number
/** Summed output tokens over the same decode-timed steps. */
decodeTokens: number
}
/**
@@ -32,6 +44,10 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
let steps = 0
let llmMs = 0
let toolMs = 0
let ttftMs = 0
let ttftSteps = 0
let decodeMs = 0
let decodeTokens = 0
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
@@ -43,8 +59,17 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
}
const reading = assistantStepReading(node)
if (reading.ttftMs !== null) {
ttftMs += reading.ttftMs
ttftSteps += 1
}
if (reading.decodeMs !== null && reading.outputTokens !== null) {
decodeMs += reading.decodeMs
decodeTokens += reading.outputTokens
}
}
return { turns: turns.size, steps, llmMs, toolMs }
return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }
}
/**
@@ -84,30 +109,41 @@ export function cacheHitPercent(usage: TokenUsageProjection): number | null {
: Math.round(usage.cacheReadTokens / denominator * 100)
}
/** Sum the three disjoint prompt-side billing buckets. */
function billedInputTokens(usage: TokenUsageProjection): number {
/**
* Sum the three disjoint prompt-side billing buckets.
* @param usage - the session's token-usage projection value.
* @returns billed input tokens.
*/
export function billedInputTokens(usage: TokenUsageProjection): number {
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
}
interface ContextOccupancy {
percent: number
usedTokens: number
contextWindow: number
}
/**
* Approximate context occupancy, using the TUI's integer rounding and upper
* clamp. The numerator and capacity are independent last-wins projection
* fields, so this is a reference figure rather than an exact measurement of one
* request (see the token-meter README).
* clamp. The numerator is `projectedTokens` — the provider sample carried
* forward over the surface's movement since — so compaction shows immediately
* instead of waiting for the next request to report usage; it falls back to the
* bare sample only for a log whose projection predates that field. Numerator
* and capacity remain independent last-wins projection fields, so this is a
* reference figure rather than an exact measurement of one request (see the
* token-meter README).
* @param pressure - the session's context-pressure projection value.
* @returns occupancy and its denominator, or null until both values are known.
* @returns occupancy with its numerator and denominator, or null until both values are known.
*/
export function contextOccupancy(
pressure: ContextPressureProjection | undefined,
): ContextOccupancy | null {
if (pressure?.pressureTokens === undefined || pressure.contextWindow === undefined) return null
const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens
if (usedTokens === undefined || pressure?.contextWindow === undefined) return null
return {
percent: Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)),
percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
usedTokens,
contextWindow: pressure.contextWindow,
}
}
@@ -116,46 +152,73 @@ export function contextOccupancy(
export interface StatsLineProps {
useSession: SnapshotSelectorHook<ConversationSnapshot>
useProjection: UseProjection
/** The owning dock's locale seat. */
t: ComposerBarProps['t']
}
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) {
const nodes = useSession(s => s.nodes)
const usage = useProjection('tokenUsage')
const pressure = useProjection('contextPressure')
const stats = useMemo(() => deriveStats(nodes), [nodes])
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
const groups: string[] = []
if (stats.steps > 0) {
groups.push(`${stats.turns} turns · ${stats.steps} steps`)
groups.push(t('stats.counts', { turns: stats.turns, steps: stats.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 (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs) }))
if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs) }))
if (durations.length > 0) groups.push(durations.join(' · '))
// Window-scoped like the wall times above: averages describe loaded steps.
const speeds: string[] = []
if (stats.ttftSteps > 0) {
speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) }))
}
if (stats.decodeMs > 0) {
speeds.push(t('stats.tokensPerSecond', {
throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
}))
}
if (speeds.length > 0) groups.push(speeds.join(' · '))
}
const context = contextOccupancy(pressure)
if (context !== null) {
groups.push(`Context ${context.percent}% of ${formatTokens(context.contextWindow)}`)
}
// Context occupancy deliberately lives on the composer's ContextMeter ring,
// not here — one home per fact.
// Billing rides the durable projection, so these survive paging and
// compaction. Suppress the empty projection on a brand-new session.
if (usage !== undefined
&& (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
const cacheHit = cacheHitPercent(usage)
if (cacheHit !== null) groups.push(`Cache hit ${cacheHit}%`)
groups.push(
`Input ${formatTokens(billedInputTokens(usage))} tok`
+ ` · Output ${formatTokens(usage.outputTokens)} tok`,
)
if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit }))
groups.push(t('stats.tokens', {
input: formatTokens(billedInputTokens(usage)),
output: formatTokens(usage.outputTokens),
}))
}
const line = groups.join(' | ')
// The row elides with ellipsis when overlong; a delayed hover tooltip carries
// the full line, enabled only while content is actually clipped.
const rootRef = useRef<HTMLDivElement | null>(null)
const [truncated, setTruncated] = useState(false)
useLayoutEffect(() => {
const el = rootRef.current
if (el === null) return
const measure = () => { setTruncated(el.scrollWidth > el.clientWidth) }
measure()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => { observer.disconnect() }
}, [line])
if (groups.length === 0) return null
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>
<Tooltip label={line} side="top" delayMs={500} disabled={!truncated}>
<div ref={rootRef} className={css.root}>
{groups.map((group, i) => (
<Fragment key={group}>
{i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
<span>{group}</span>
</Fragment>
))}
</div>
</Tooltip>
)
})

View File

@@ -86,8 +86,7 @@ export function messageBranchSeqs(
tail = candidate
nodeIndex++
}
if (tail?.kind === 'user'
|| (tail?.kind === 'steering' && tail.turn === turn)
if (tail?.kind === 'user' || tail?.kind === 'steering'
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
result.add(tail.seq)
}

View File

@@ -1,5 +1,4 @@
// Shared chrome helpers for user/assistant IconActions rows: clipboard write
// and the compact date+clock label from a session-event epoch.
// Shared time-label helpers for user/assistant IconActions rows.
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
@@ -8,46 +7,6 @@ export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
/** The elapsed-duration share of the conversation dictionary. */
export type RunDurationTranslate = Translate<'duration.seconds' | 'duration.minutes'>
/**
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
* @param text - Plain text to place on the clipboard.
*/
export async function writeClipboard(text: string): Promise<void> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* oxlint-disable-next-line typescript/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
} catch {
// Denied permissions / iframe policy.
}
return
}
// execCommand('copy') is the only clipboard fallback where the async API
// is missing (insecure contexts); deprecated but deliberately retained.
/* oxlint-disable typescript/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
exec('copy')
} catch {
// Clipboard unavailable; the button stays idle.
}
/* oxlint-enable typescript/no-deprecated */
el.remove()
}
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
@@ -89,6 +48,27 @@ export function formatRunDuration(ms: number, t: RunDurationTranslate): string {
: t('duration.seconds', { seconds })
}
/**
* Sub-turn latency figure: one decimal under ten seconds, whole seconds
* beyond. Unit-less so the locale template owns the second suffix.
* @param ms - Latency in milliseconds (negatives clamp to zero).
* @returns Display number in seconds without unit.
*/
export function formatLatencySeconds(ms: number): string {
const s = Math.max(0, ms) / 1000
return s < 10 ? String(Math.round(s * 10) / 10) : String(Math.round(s))
}
/**
* Decode-throughput figure: whole tokens from ten up, one decimal below.
* @param tps - Tokens per second.
* @returns Display number without unit.
*/
export function formatTokensPerSecond(tps: number): string {
const clamped = Math.max(0, tps)
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10)
}
/**
* Compact local timestamp for message IconActions. Same calendar day →
* `HH:mm`; earlier this year → the `clock.md` date template + clock; other

View File

@@ -0,0 +1,97 @@
// Latency/throughput folds shared by the settled turn footer and StatsLine.
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
/** Latency and decode-throughput readings for one turn's footer. */
export interface TurnMetrics {
/** First-step TTFT in ms; absent when that step carries no recorded timing. */
ttftMs?: number
/** Decode throughput over steps carrying both timing and provider usage. */
tokensPerSecond?: number
}
/** One assistant step's derivable latency facts; null marks an unrecorded part. */
export interface StepReading {
/** step/start → first token delta, in ms. */
ttftMs: number | null
/** First token delta → final message, in ms. */
decodeMs: number | null
/** Provider-reported completion tokens. */
outputTokens: number | null
}
interface UsageLike {
outputTokens?: number
}
type AssistantNode = Extract<ConversationSnapshot['nodes'][number], { kind: 'assistant' }>
function usageOutputTokens(usage: unknown): number | null {
if (typeof usage !== 'object' || usage === null) return null
const value = (usage as UsageLike).outputTokens
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
}
/**
* Read one assistant node's TTFT, decode wall time, and output tokens.
* @param node - A settled assistant node.
* @returns Per-part readings with `null` for unrecorded values.
*/
export function assistantStepReading(node: AssistantNode): StepReading {
const timing = node.timing
const ttftMs = timing !== undefined && timing.stepStartTime !== null && timing.firstTokenTime !== null
? Math.max(0, timing.firstTokenTime - timing.stepStartTime)
: null
const decodeMs = timing !== undefined && timing.firstTokenTime !== null
? Math.max(0, timing.completedTime - timing.firstTokenTime)
: null
return { ttftMs, decodeMs, outputTokens: usageOutputTokens(node.usage) }
}
interface TurnFold {
firstStep: number
firstStepTtftMs: number | null
decodeMs: number
outputTokens: number
sampled: boolean
}
/**
* Fold assistant nodes into per-turn footer metrics.
*
* TTFT is the turn's lowest-step request-dispatch-to-first-token reading, so
* it is only meaningful when the turn's start is inside
* the loaded window (the caller gates on `turnTimings`, which shares that
* window). Throughput divides summed output tokens by summed decode wall time,
* counting only steps that carry both.
* @param nodes - Snapshot nodes of the loaded window.
* @returns Turn number → available metrics; turns with none are absent.
*/
export function deriveTurnMetrics(nodes: ConversationSnapshot['nodes']): Map<number, TurnMetrics> {
const folds = new Map<number, TurnFold>()
for (const node of nodes) {
if (node.kind !== 'assistant') continue
const reading = assistantStepReading(node)
let fold = folds.get(node.turn)
if (fold === undefined) {
fold = { firstStep: node.step, firstStepTtftMs: reading.ttftMs, decodeMs: 0, outputTokens: 0, sampled: false }
folds.set(node.turn, fold)
} else if (node.step < fold.firstStep) {
fold.firstStep = node.step
fold.firstStepTtftMs = reading.ttftMs
}
if (reading.decodeMs !== null && reading.outputTokens !== null) {
fold.decodeMs += reading.decodeMs
fold.outputTokens += reading.outputTokens
fold.sampled = true
}
}
const metrics = new Map<number, TurnMetrics>()
for (const [turn, fold] of folds) {
const entry: TurnMetrics = {}
if (fold.firstStepTtftMs !== null) entry.ttftMs = fold.firstStepTtftMs
if (fold.sampled && fold.decodeMs > 0) entry.tokensPerSecond = fold.outputTokens / (fold.decodeMs / 1000)
if (entry.ttftMs !== undefined || entry.tokensPerSecond !== undefined) metrics.set(turn, entry)
}
return metrics
}

View File

@@ -76,11 +76,8 @@ function narrowDiffs(diffs: unknown): DiffHunk[] | null {
*
* This derivation consumes only `diffs`; the render intent's `title` field is
* deliberately dropped. The row supplies its own title (`Edit`/`Write · path`
* from the args) and that outranks the view's `title`, matching the TUI diff
* branch, which likewise draws no view title. A tool that names its own diff
* header therefore does not surface that text on the Web row — an accepted
* product choice, recorded here as the one asymmetry with the terminal card,
* whose derivation does consume the view's title.
* from the args), which outranks the view's `title`. A tool that names its own
* diff header therefore does not surface that text on the Web row.
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @returns the diff-card props, or null for the generic path.
*/

View File

@@ -1,6 +1,6 @@
/**
* Pure derivation of the terminal-card props from a frozen call slice: the
* `card:'terminal'` render intent the bash tool declares arrives on the
* `card:'terminal'` render intent the shell tools declare arrives on the
* snapshot as `callView`/`resultView`, and this is the one place that turns
* that pair into what {@link TerminalBlock} draws. Both conversation render
* sites (the chat tool row's expanded body and the details panel's Output

View File

@@ -31,6 +31,9 @@ export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
/** Known tool name -> variant. */
const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
bash: 'bash',
// The PowerShell twin is a shell tool: the bash row family (icon, colors)
// with its own title from TOOL_TITLES, not the generic `others` row.
pwsh: 'bash',
read: 'read',
web_fetch: 'read',
web_search: 'search',
@@ -49,6 +52,7 @@ const TOOL_TITLES: Record<string, string> = {
cordis_inspect: 'Inspect',
cordis_mount: 'Mount temporary Plugin',
cordis_unmount: 'Unmount temporary Plugin',
pwsh: 'Pwsh',
}
/**

View File

@@ -23,6 +23,18 @@ export const zh = {
'input.stop': '停止生成',
'input.send': '发送消息',
'input.accessMode': '访问模式,当前:{name}',
'context.aria': '上下文已用 {percent}',
'context.used': '上下文已用',
'context.system': '系统提示词',
'context.tools': '工具',
'context.messages': '对话消息',
'stats.counts': '{turns} 轮 · {steps} 步',
'stats.llm': 'LLM {duration}',
'stats.toolCall': '工具调用 {duration}',
'stats.ttftAverage': '首 token 平均 {duration}',
'stats.tokensPerSecond': '{throughput} tok/s',
'stats.cacheHit': '缓存命中 {percent}%',
'stats.tokens': '输入 {input} tok · 输出 {output} tok',
'settings.enter.title': '繁忙时 Enter 键行为',
'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
'settings.enter.queue': '排队发送',
@@ -43,8 +55,10 @@ export const zh = {
'details.input': '输入',
'details.output': '输出',
'details.running': '运行中…',
'todo.title': '任务清单',
'todo.progress': '{done}/{total} 项任务 · {active} 项进行中',
'todo.title': '任务',
'todo.progress.done': '{done} 已完成',
'todo.progress.active': '{active} 进行中',
'todo.progress.pending': '{pending} 待处理',
'todo.rowTitle': '更新任务清单',
'todo.completed': '{done}/{total} 已完成',
'chat.loadingHistory': '载入历史…',
@@ -53,6 +67,18 @@ export const zh = {
'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块',
'message.contextInjection': '上下文注入',
'message.contextRecall': '跨会话召回',
'message.context.instructions.loaded': '已载入',
'message.context.instructions.added': '已新增',
'message.context.instructions.updated': '已更新',
'message.context.instructions.removed': '已移除',
'message.context.catalog.replaced': '替换目录',
'message.context.catalog.more': '…还有 {count} 条',
'message.context.snapshot.supersedes': '取代先前的快照',
'message.context.relay.from': '来自会话 {session}',
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
'message.context.recall.truncated': '已截断',
'message.steering': '插话',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
'message.compaction.unavailable': '压缩摘要不可用',
@@ -70,6 +96,8 @@ export const zh = {
'message.retry.failure': '失败原因:',
'message.turnError': '本轮运行失败',
'message.ranFor': '用时 {duration}',
'message.ttft': '首 token {seconds}秒',
'message.tokensPerSecond': '{tps} tok/s',
'duration.seconds': '{seconds}秒',
'duration.minutes': '{minutes}分{seconds}秒',
'command.running': '执行中…',
@@ -135,6 +163,18 @@ export const en = {
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.accessMode': 'Access mode, current: {name}',
'context.aria': '{percent} of context used',
'context.used': 'of context used',
'context.system': 'System prompt',
'context.tools': 'Tools',
'context.messages': 'Messages',
'stats.counts': '{turns} turns · {steps} steps',
'stats.llm': 'LLM {duration}',
'stats.toolCall': 'Tool call {duration}',
'stats.ttftAverage': 'TTFT avg {duration}',
'stats.tokensPerSecond': '{throughput} tok/s',
'stats.cacheHit': 'Cache hit {percent}%',
'stats.tokens': 'Input {input} tok · Output {output} tok',
'settings.enter.title': 'Enter behavior while busy',
'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
'settings.enter.queue': 'Queue',
@@ -156,7 +196,9 @@ export const en = {
'details.output': 'Output',
'details.running': 'Running…',
'todo.title': 'To-dos',
'todo.progress': '{done}/{total} tasks · {active} in progress',
'todo.progress.done': '{done} completed',
'todo.progress.active': '{active} in progress',
'todo.progress.pending': '{pending} pending',
'todo.rowTitle': 'Update to-do list',
'todo.completed': '{done}/{total} completed',
'chat.loadingHistory': 'Loading history…',
@@ -165,6 +207,18 @@ export const en = {
'chat.toBottom': 'Back to bottom',
'message.extraBlock': 'Extra content block',
'message.contextInjection': 'Context injection',
'message.contextRecall': 'Session recall',
'message.context.instructions.loaded': 'loaded',
'message.context.instructions.added': 'added',
'message.context.instructions.updated': 'updated',
'message.context.instructions.removed': 'removed',
'message.context.catalog.replaced': 'Replacement catalog',
'message.context.catalog.more': '… {count} more',
'message.context.snapshot.supersedes': 'Supersedes earlier snapshots',
'message.context.relay.from': 'From session {session}',
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
'message.context.recall.truncated': 'truncated',
'message.steering': 'Interjection',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',
'message.compaction.unavailable': 'Compaction summary unavailable',
@@ -182,6 +236,8 @@ export const en = {
'message.retry.failure': 'Failure reason: ',
'message.turnError': 'This turn failed',
'message.ranFor': 'Ran for {duration}',
'message.ttft': 'TTFT {seconds}s',
'message.tokensPerSecond': '{tps} tok/s',
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes}m {seconds}s',
'command.running': 'Running…',

View File

@@ -16,20 +16,21 @@
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
/* 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;
/* Cancel the stack gap after this item and tuck 3px under the input card
(square bottom), reading as one attached surface. */
margin: 0 auto calc(0px - var(--dsh-composer-stack-gap) - 3px);
/* Horizontal padding completes the shared dock inset (this wrapper only
subtracts two insets from its width); no vertical padding, so the visual
gap above the panel stays the uniform stack gap. */
padding: 0 var(--dsh-composer-dock-inset);
}
.panel {
position: relative;
overflow: hidden;
width: 100%;
padding-top: 2px;
border-radius: 14px 14px 0 0;
padding: 2px 0;
border-radius: 12px 12px 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);
@@ -39,6 +40,7 @@
position: absolute;
inset: 0;
border: 1px solid var(--dsw-alias-border-l1);
/* The input card's own top border closes the shape below. */
border-bottom: none;
border-radius: inherit;
content: '';
@@ -52,7 +54,9 @@
gap: 10px;
width: 100%;
height: 36px;
padding: 4px 16px 4px 12px;
/* Right inset 12px puts the chevron on the same vertical line as the Todo
header's chevron (12px body padding there). */
padding: 4px 12px;
border: none;
border-radius: 8px;
background: transparent;
@@ -70,11 +74,18 @@
cursor: default;
}
.lead {
display: grid;
flex: none;
place-items: center;
color: var(--dsw-alias-label-tertiary);
}
.count {
flex: 1 1 auto;
min-width: 0;
font-family: Inter, var(--dsw-font-family);
font-size: 14px;
font-size: 13px;
font-weight: 500;
line-height: 24px;
}

View File

@@ -8,8 +8,8 @@ import { useEffect, useId, useMemo, useState } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
IconCloseOutline16, IconEditOutline16, IconSendOutline16, IconTrashOutline16,
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16,
IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
import { NS } from '../locales.ts'
@@ -87,6 +87,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
disabled={interactionActive}
onClick={() => { setCollapsed(value => !value) }}
>
<span className={css.lead} aria-hidden><IconQueueOutline14 /></span>
<span className={css.count}>{t('queue.count', { n: queue.length })}</span>
<span className={css.chevron} aria-hidden>
{expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
@@ -96,6 +97,8 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
<ul id={listId} className={css.list} hidden={!listVisible}>
{listVisible && queue.map(row => (
<li key={row.id} className={css.row}>
{/* Single-item strip has no count header, so the row itself carries the queue glyph. */}
{queue.length === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
{editing?.id === row.id
? (
<input
@@ -121,74 +124,83 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
{editing?.id === row.id
? (
<>
<button
type="button"
className={css.action}
aria-label={t('queue.save')}
title={t('queue.save')}
disabled={busy !== null || editing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
<IconCheckOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.cancelEdit')}
title={t('queue.cancelEdit')}
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
<IconCloseOutline16 size={14} />
</button>
<Tooltip label={t('queue.save')} side="bottom" delayMs={500}>
<button
type="button"
className={css.action}
aria-label={t('queue.save')}
disabled={busy !== null || editing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
<IconCheckOutline16 size={14} />
</button>
</Tooltip>
<Tooltip label={t('queue.cancelEdit')} side="bottom" delayMs={500}>
<button
type="button"
className={css.action}
aria-label={t('queue.cancelEdit')}
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
<IconCloseOutline16 size={14} />
</button>
</Tooltip>
</>
)
: (
<>
<button
type="button"
className={css.action}
aria-label={t('queue.edit')}
title={row.text === null ? t('queue.edit.unsupported') : t('queue.edit')}
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={t('queue.remove')}
title={t('queue.remove')}
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
t('queue.removeFailed'),
)
}}
>
<IconTrashOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.steer')}
title={running ? t('queue.steer') : t('queue.steer.unavailable')}
disabled={busy !== null || !running}
onClick={() => {
void applyAction(
row.id,
{ kind: 'steer' },
t('queue.steerFailed'),
)
}}
>
<IconSendOutline16 size={14} />
</button>
<Tooltip label={t('queue.edit')} side="bottom" delayMs={500} disabled={row.text === null}>
<button
type="button"
className={css.action}
aria-label={t('queue.edit')}
// Disabled buttons fire no hover events, so the
// unsupported hint stays a native title.
title={row.text === null ? t('queue.edit.unsupported') : undefined}
disabled={busy !== null || row.text === null}
onClick={() => {
if (row.text !== null) setEditing({ id: row.id, text: row.text })
}}
>
<IconEditOutline16 size={14} />
</button>
</Tooltip>
<Tooltip label={t('queue.remove')} side="bottom" delayMs={500}>
<button
type="button"
className={css.action}
aria-label={t('queue.remove')}
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
t('queue.removeFailed'),
)
}}
>
<IconTrashOutline16 size={14} />
</button>
</Tooltip>
<Tooltip label={t('queue.steer')} side="bottom" delayMs={500} disabled={!running}>
<button
type="button"
className={css.action}
aria-label={t('queue.steer')}
title={running ? undefined : t('queue.steer.unavailable')}
disabled={busy !== null || !running}
onClick={() => {
void applyAction(
row.id,
{ kind: 'steer' },
t('queue.steerFailed'),
)
}}
>
<IconSendOutline14 />
</button>
</Tooltip>
</>
)}
</div>}
@@ -201,8 +213,8 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
}
/**
* The dock entry as a plain registrant plugin. The conversation service is the
* ordering and action seam; session scopes provide the exact queue owner.
* The dock entry as a plain registrant plugin. The conversation service is
* the action seam; the slot declaration is its independent lifecycle seam.
*/
export const queueDockEntry = {
name: 'conversation-queue-dock',
@@ -212,7 +224,7 @@ export const queueDockEntry = {
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
name: 'conversation.input.dock',
id: 'queue',
order: 20,
@@ -227,6 +239,6 @@ export const queueDockEntry = {
notify: (level, text) => { conversation.input.for(actx).notify(level, text) },
}
},
}, QueueDock)
}, QueueDock))
},
}

View File

@@ -8,13 +8,15 @@
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 32px 12px;
/* Sides = clearance + 16px so the card lands on the shared content width
(input card - 32) at every viewport. */
padding: 8px calc(var(--dsh-composer-side-clearance) + 16px) 12px;
}
.card {
overflow: hidden;
width: 100%;
max-width: 776px;
max-width: var(--dsh-chat-content-width);
border: 1px solid var(--dsw-alias-state-warn-secondary);
border-radius: 20px;
background: var(--dsw-specific-input-major);
@@ -84,7 +86,9 @@
/* 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. */
bottom pad below, so the resting card is unchanged. Buttons are the shared
outline/primary capsules (Button atom, matching QuestionComposer's footer);
only the reject's danger hover is local. */
.actionRow {
display: flex;
justify-content: flex-end;
@@ -92,36 +96,6 @@
padding: 14px 16px 14px;
}
.allow,
.reject {
padding: 6px 16px;
border-radius: 10px;
font-size: 13px;
line-height: 18px;
cursor: pointer;
}
.allow:disabled,
.reject:disabled {
opacity: 0.5;
cursor: default;
}
/* Primary action: filled ink (draft's rightmost emphasis, minus the dropped
always-allow button). */
.allow {
border: none;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-label-primary-foreground);
}
/* Secondary: quiet outline. */
.reject {
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
background: transparent;
color: var(--dsw-alias-label-secondary);
}
.reject:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);

View File

@@ -14,6 +14,7 @@
// grant storage.
import { useMemo, useState } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
import css from './ApprovalPanel.module.css'
@@ -69,12 +70,12 @@ function ApprovalFlow({ pending, command, t }: {
{command !== undefined && <div className={css.command}>{command}</div>}
</div>
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
<Button variant="outline" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
{t('approval.reject')}
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
</Button>
<Button variant="primary" disabled={answered} onClick={() => { answer('allowed-once') }}>
{t('approval.allowOnce')}
</button>
</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,147 @@
/* Context-occupancy ring beside the send button plus its click-open breakdown
panel (menu surface: r12, inverted hairline, shadow-lv3). */
.root {
position: relative;
display: inline-flex;
}
/* Same 28px circular hit target family as the composer's attach button. */
.trigger {
display: grid;
place-items: center;
flex: none;
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.trigger:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.track {
fill: none;
stroke: var(--dsw-alias-border-l3);
stroke-width: 2;
}
.fill {
fill: none;
stroke: var(--dsw-alias-label-tertiary);
stroke-width: 2;
stroke-linecap: round;
}
.panel {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
z-index: 100;
box-sizing: border-box;
width: 264px;
padding: 12px;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
cursor: default;
}
.header {
display: flex;
align-items: center;
gap: 6px;
}
.figures {
margin-left: auto;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-primary);
}
.percent {
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.headline {
color: var(--dsw-alias-label-tertiary);
}
/* The headline brackets the reading, so the side a locale leaves empty must
drop out of the flex row rather than spend a gap. */
.headline:empty {
display: none;
}
.bar {
display: flex;
gap: 1px;
margin: 10px 0 12px;
height: 4px;
border-radius: 999px;
background: var(--dsw-alias-interactive-bg-hover);
overflow: hidden;
}
.segment {
flex: none;
min-width: 2px;
height: 100%;
border-radius: 1px;
background: var(--meter-tint, var(--dsw-alias-label-tertiary));
}
.swatch {
display: inline-block;
margin-right: 6px;
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--meter-tint);
vertical-align: baseline;
}
.colorSystem {
--meter-tint: var(--dsw-static-neutral-bluish-400);
}
.colorTools {
/* The design platform ships no purple static token; violet-400 literal. */
--meter-tint: rgb(167, 139, 250);
}
.colorMessages {
--meter-tint: var(--dsw-static-blue-450);
}
.rows {
margin: 6px 0 0;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 0;
}
.row dt {
color: var(--dsw-alias-label-secondary);
}
.row dd {
margin: 0;
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-primary);
}

View File

@@ -0,0 +1,153 @@
/** Composer context-occupancy meter: a ring beside the send button fed by the
* `contextPressure` projection, with a click-open panel of the heuristic
* `contextBreakdown` composition (system prompt, tools, conversation).
* Renders nothing until a provider reports both pressure and a route capacity
* (same gate as the stats row used). */
import { useEffect, useRef, useState } from 'react'
import type { UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the `contextPressure` / `contextBreakdown` projection key merges.
import type {} from '@deepseek-ai/dsh-token-meter/client'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import { contextOccupancy, formatTokens } from '../chat/StatsLine.tsx'
import css from './ContextMeter.module.css'
/** Ring geometry: 14px viewBox, 2px stroke. */
const RADIUS = 5.5
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
/**
* Marker the localized occupancy sentence is split on, so the panel headline
* keeps the reading in its own tone while each locale still owns the word
* order (`45% of context used` / `上下文已用 45%`).
*/
const READING_SLOT = '\u0000'
/** Panel legend rows, in bar-segment order; each color class carries the shared swatch/segment tint. */
const ROWS = [
{ key: 'systemTokens', label: 'context.system', color: css.colorSystem },
{ key: 'toolsTokens', label: 'context.tools', color: css.colorTools },
{ key: 'messageTokens', label: 'context.messages', color: css.colorMessages },
] as const
export interface ContextMeterProps {
useProjection: UseProjection
/** The owning bar's locale seat, passed down as a plain prop. */
t: ComposerBarProps['t']
}
export function ContextMeter({ useProjection, t }: ContextMeterProps) {
const pressure = useProjection('contextPressure')
const breakdown = useProjection('contextBreakdown')
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLSpanElement | null>(null)
const context = contextOccupancy(pressure)
const available = context !== null
// A model switch can temporarily remove capacity while this component stays
// mounted. Close the now-unavailable panel instead of preserving stale UI.
useEffect(() => {
if (!available && open) setOpen(false)
}, [available, open])
// Outside click / Escape close, one document listener while open (Menu's pattern).
useEffect(() => {
if (!open || !available) return
const onPointerDown = (e: PointerEvent): void => {
if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return
setOpen(false)
}
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('pointerdown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [available, open])
if (context === null) return null
const percent = context.percent
const reading = `${percent}%`
const [headBefore = '', headAfter = ''] = t('context.aria', { percent: READING_SLOT })
.split(READING_SLOT)
.map(part => part.trim())
// The bar's overall length stays the provider-exact percent; the heuristic
// breakdown only proportions its colored parts. A zero-width part is dropped
// instead of rendered: `.segment`'s min-width keeps a hairline part visible,
// which at 0% occupancy would draw a filled bar over an empty context.
const breakdownTotal = breakdown === undefined
? 0
: breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
const parts = breakdown === undefined || breakdownTotal === 0
? [{ key: 'total', color: undefined, width: percent }]
: ROWS.map(row => ({ key: row.key, color: row.color, width: percent * breakdown[row.key] / breakdownTotal }))
const segments = parts.filter(part => part.width > 0)
return (
<span ref={rootRef} className={css.root}>
<Tooltip label={t('context.aria', { percent: reading })} side="top" delayMs={200} disabled={open}>
<button
type="button"
className={css.trigger}
aria-label={t('context.aria', { percent: reading })}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => { setOpen(!open) }}
>
<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden>
<circle className={css.track} cx="7" cy="7" r={RADIUS} />
<circle
className={css.fill}
cx="7"
cy="7"
r={RADIUS}
strokeDasharray={`${CIRCUMFERENCE * percent / 100} ${CIRCUMFERENCE}`}
transform="rotate(-90 7 7)"
/>
</svg>
</button>
</Tooltip>
{open && (
<div className={css.panel} role="dialog" aria-label={t('context.used')}>
<div className={css.header}>
{/* Empty sides collapse through `.headline:empty` so the locale that
needs no leading (or trailing) text spends no header gap. */}
<span className={css.headline}>{headBefore}</span>
<span className={css.percent}>{reading}</span>
<span className={css.headline}>{headAfter}</span>
<span className={css.figures}>
{`~${formatTokens(context.usedTokens)} / ${formatTokens(context.contextWindow)}`}
</span>
</div>
<div className={css.bar}>
{segments.map(segment => (
<div
key={segment.key}
className={segment.color === undefined ? css.segment : `${css.segment} ${segment.color}`}
style={{ width: `${segment.width}%` }}
/>
))}
</div>
{breakdown !== undefined && (
<dl className={css.rows}>
{ROWS.map(row => (
<div key={row.key} className={css.row}>
<dt>
<span className={`${css.swatch} ${row.color}`} aria-hidden />
{t(row.label)}
</dt>
<dd>{`~${formatTokens(breakdown[row.key])}`}</dd>
</div>
))}
</dl>
)}
</div>
)}
</span>
)
}

View File

@@ -9,6 +9,20 @@
height: 100%;
min-width: 0;
background: var(--dsw-alias-bg-base);
/* Shared width axis for the whole column: one content width W
(--dsh-chat-content-width) for the transcript, the dock cards
(todo/goal/queue: card minus four insets, 4 x 8 = 32), and the takeover
cards (question/approval/plan review); the input card alone is W + 32px.
The relation also holds when a narrow viewport shrinks everything: the
chat scroller and the takeover frames pad clearance + 16px per side while
the input card clears the bare clearance, so the input card stays exactly
content + 32px at every width. Declared on the root because the
transcript and the composer seat are sibling subtrees. */
--dsh-chat-content-width: 748px;
--dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px);
--dsh-composer-side-clearance: 16px;
--dsh-composer-dock-inset: 8px;
}
.header {
@@ -134,16 +148,11 @@
}
/* Composer context stack (Figma 9:937): standalone dock cards share one
rhythm; the terminal queue strip additionally tucks under the input card. */
rhythm above the input card. */
.composerStack {
/* Horizontal geometry (card width, clearance, dock inset) rides the shared
.root variables above so takeover siblings match the stack. */
--dsh-composer-stack-gap: 6px;
--dsh-queue-composer-overlap: 5px;
/* InputBar and dock registrants derive their horizontal geometry from the
same card width, outer clearance, and dock inset. */
--dsh-composer-card-max-width: 800px;
--dsh-composer-side-clearance: 32px;
--dsh-composer-dock-inset: 12px;
display: flex;
flex-direction: column;
@@ -182,6 +191,11 @@
flex-direction: column;
min-height: 0;
overflow-y: auto;
/* Reserved unconditionally: the composer seat rides this box's content box in
Chat and its padding box under a view's composer overlay, so an `auto`
gutter moves the input card sideways by the bar's width whenever the two
differ ([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
scrollbar-gutter: stable;
}
.root[data-phase='active'] .viewArea {
@@ -212,7 +226,13 @@
ownership of the seat geometry and its active-phase precedence. */
.scrollBody:has([data-conversation-composer-overlay]) {
position: relative;
overflow: hidden;
/* A clipping box nothing scrolls out of, stated as a scroll container on both
axes rather than `overflow: hidden`: WebKit honours the reservation above
only in the `overflow-y: auto` form, and a single-axis scroller computes
the other axis to `auto`
([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
overflow-x: hidden;
overflow-y: auto;
}
.scrollBody:has([data-conversation-composer-overlay]) > .viewArea {
@@ -239,7 +259,9 @@
gap: 12px;
/* Foot inside the centered box floats the stack a bit above true center. */
padding-bottom: 32px;
width: min(776px, calc(100% - 48px));
/* Card cap + both clearances: the hero input card lands at exactly the same
width as the docked composer at every viewport. */
width: min(calc(var(--dsh-composer-card-max-width) + 2 * var(--dsh-composer-side-clearance)), 100%);
z-index: 1;
}
@@ -263,7 +285,9 @@
display: flex;
align-items: center;
min-width: 0;
padding-left: 8px;
/* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to
the card's inner controls below. */
padding-left: 20px;
}
/* Hero: the composer sits inside the session scroll body; center there so

View File

@@ -145,7 +145,7 @@ export function ConversationRoot({
{hero && <HeroGlow className={css.heroGlow} />}
{hero && <HeroShell t={t} />}
{hero && heroWorkspaceRow}
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
{zone !== undefined && renderSlot('conversation.input.dock', zone)}
{inputBar}
</div>
)

View File

@@ -11,23 +11,28 @@
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
viewport bottom inside the centered message column; textarea on top, action
row below, one primary circle button bottom-right. Input width rides the
column (800 is a cap, not a fixed size — layout rule: the box shrinks with
the center column keeping its padding). Hero variant = the same card
centered in the empty state; the transition between the two is a position
move of one component. */
column (--dsh-composer-card-max-width = chat content + 32px, 16px per side,
is a cap, not a fixed size — layout rule: the box shrinks with the center
column keeping its clearance). Hero variant = the same card centered in the
empty state; the transition between the two is a position move of one
component. */
.root {
display: flex;
flex-direction: column;
align-items: center;
/* 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. */
/* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8;
the sides narrow with the shared width axis); 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 var(--dsh-composer-side-clearance) 8px;
}
.hero {
padding: 0;
/* No bottom pad in the centered hero, but the side clearance must survive:
the hero wrapper is full-width on narrow viewports, so this padding is
the only thing keeping the card off the edges there. */
padding: 0 var(--dsh-composer-side-clearance);
}
.error,
@@ -83,7 +88,7 @@
the input border is one notch weaker than buttons) — exactly the
l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 20px;
border-radius: 22px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
font-size: 16px;
@@ -256,8 +261,16 @@
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 10px 10px 10px;
/* 2px moved from the bottom pad to the top: the whole control row sits 2px
lower in the card (it read too high against the textarea) while the card
height and the controls' own centering stay untouched. */
padding: 2px 8px 6px;
min-width: 0;
/* Size container so the chips inside can collapse to icon-only when the
card runs out of row width (PermissionSelect @container rule). Anonymous
on purpose: CSS modules hash container-name per module, so a name declared
here can never match a query in another module's sheet. */
container-type: inline-size;
}
.tools,
@@ -268,13 +281,15 @@
min-width: 0;
}
/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */
/* figma 75:8208 drew 16 between + and the mode chips and 4 between Plan /
Read-only; the chip gap widened to 12 so the pill chips read as separate
controls. */
.tools {
gap: 16px;
}
.modes {
gap: 4px;
gap: 12px;
}
.trailing {
@@ -354,6 +369,10 @@
color: #fff;
cursor: pointer;
transition: background-color 100ms ease;
/* Opts out of the row's 2px downward shift (.row top pad): the send circle
keeps its original seat while the smaller chips sit lower. Transform, not
margin, so flex centering math is untouched. */
transform: translateY(-2px);
}
.primary:hover:not(:disabled) {

View File

@@ -9,7 +9,7 @@
import { useEffect, useRef } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: the `plan` projection key merge (the TodoDock posture — the
// composer reads a host-computed value; the domain owns the key).
import type {} from '@deepseek-ai/dsh-plan-mode/client'
@@ -19,6 +19,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
import { ContextMeter } from './ContextMeter.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
@@ -489,19 +490,20 @@ export function InputBar({
</div>
<div className={css.row}>
<div className={css.tools}>
<button
type="button"
className={css.add}
aria-label={t('input.commands')}
title={t('input.commands')}
aria-haspopup="listbox"
aria-expanded={commandMenuOpen}
disabled={locked || toggleCommandMenu === undefined}
onMouseDown={keepFocus}
onClick={onToggleCommandMenu}
>
<IconPlusOutline16 size={14} />
</button>
<Tooltip label={t('input.commands')} side="top" delayMs={500}>
<button
type="button"
className={css.add}
aria-label={t('input.commands')}
aria-haspopup="listbox"
aria-expanded={commandMenuOpen}
disabled={locked || toggleCommandMenu === undefined}
onMouseDown={keepFocus}
onClick={onToggleCommandMenu}
>
<IconPlusOutline16 size={14} />
</button>
</Tooltip>
<div className={css.modes}>
{accessSelect}
{renderSlot('conversation.input.plan', { locked })}
@@ -511,26 +513,28 @@ export function InputBar({
<div className={css.trailing}>
{rightItems}
{renderSlot('conversation.input.model', { locked })}
<ContextMeter useProjection={useProjection} t={t} />
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
<button
type="button"
className={css.primary}
aria-label={primaryLabel}
title={primaryLabel}
disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{stopping ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>
) : (
<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>
<Tooltip label={primaryLabel} side="top" delayMs={500}>
<button
type="button"
className={css.primary}
aria-label={primaryLabel}
disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{stopping ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>
) : (
<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>
</Tooltip>
</div>
</div>
</div>

View File

@@ -7,7 +7,8 @@
height: 28px;
padding: 0 4px 0 8px;
border: none;
border-radius: 8px;
/* Rounded chip chrome, matching the sibling model trigger. */
border-radius: 24px;
outline: none;
background: transparent;
color: var(--dsw-alias-label-secondary);
@@ -30,6 +31,18 @@
cursor: default;
}
.triggerIcon {
display: inline-flex;
flex: 0 0 auto;
}
/* The shared 16px glyphs render one step smaller on the exposed trigger;
the dropdown rows keep the full 16px. */
.triggerIcon svg {
width: 14px;
height: 14px;
}
.triggerLabel {
min-width: 0;
overflow: hidden;
@@ -40,4 +53,21 @@
.chevron {
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
transition: transform 120ms ease;
}
/* Narrow composer: the trigger collapses to icon + chevron so the row keeps
fitting. Only triggers that actually carry a mode glyph drop their label —
a host-configured mode without one keeps its text as the sole identifier.
The 460px cut is the point where the row (attach + modes + model + send)
starts squeezing labels; the container is the composer row (InputBar .row —
anonymous query because CSS modules hash container-names per module). */
@container (max-width: 460px) {
.trigger:has(.triggerIcon) .triggerLabel {
display: none;
}
}
.chevronOpen {
transform: rotate(180deg);
}

View File

@@ -1,12 +1,50 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14, Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import css from './PermissionSelect.module.css'
const FULL_ACCESS = 'danger-full-access'
/* Shield glyphs (design set 1556): check = read-only, pencil = workspace
write, exclamation = full access. currentColor so the trigger and menu
rows tint them with their own text color. */
const shieldOutline = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z'
const permissionGlyphs = {
'read-only': (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
<path d="M12.1654 5.7552L8.9447 9.41475C8.73044 9.65816 8.53628 9.8804 8.35774 10.0423C8.1713 10.2114 7.94235 10.3717 7.64016 10.4254C7.48207 10.4535 7.32 10.4552 7.16151 10.4294C6.85843 10.3801 6.62728 10.2223 6.43836 10.0559C6.25752 9.89653 6.06037 9.67732 5.84264 9.43705L4.72925 8.20897L5.63557 7.38707L6.74897 8.61594C6.98603 8.87755 7.12974 9.03533 7.24673 9.13839C7.31033 9.19443 7.34485 9.21476 7.35823 9.22122C7.38068 9.22484 7.40352 9.22515 7.42593 9.22122C7.40522 9.22502 7.42893 9.23294 7.53583 9.136C7.65132 9.03126 7.79316 8.87139 8.02643 8.60638L11.2479 4.94763L12.1654 5.7552Z" fill="currentColor" />
</svg>
),
'workspace-write': (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M8.08887 0.251709C8.20479 0.23085 8.32486 0.241168 8.43652 0.282959L15.0215 2.75171C15.2787 2.84819 15.4492 3.09414 15.4492 3.3689V7.0105C15.4492 7.10986 15.4441 7.2081 15.4414 7.30542C15.0285 7.07175 14.5905 6.87695 14.1309 6.73022V3.82495L8.20508 1.60327L2.2793 3.82495V7.0105C2.27936 9.7171 3.4745 11.5379 5.02734 12.7947C5.01025 12.9942 5 13.1962 5 13.4001C5.00001 13.7617 5.02722 14.1169 5.08008 14.4636C2.91555 13.0393 0.961014 10.752 0.960938 7.0105V3.3689C0.960938 3.09417 1.13146 2.84821 1.38867 2.75171L7.97461 0.282959L8.08887 0.251709Z" fill="currentColor" />
<path d="M11.3525 5.64688V6.85688H5V5.64688H11.3525Z" fill="currentColor" />
<path d="M9.5824 8.29376V9.50376H5V8.29376H9.5824Z" fill="currentColor" />
<path d="M14.6647 15.6852H10.0338C10.3878 15.3751 10.7567 15.0517 11.0772 14.7706C11.2531 14.6164 11.4144 14.4746 11.5511 14.3547H14.6647V15.6852Z" fill="currentColor" />
<path d="M8.14852 14.1308L7.33925 15.4976C7.22458 15.6912 7.42245 15.9194 7.63037 15.8333L9.09785 15.2254L15.0399 10.0719L14.0905 8.97733L8.14852 14.1308Z" fill="currentColor" />
</svg>
),
[FULL_ACCESS]: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
<path d="M9.10094 4.5V8.75939H7.59888V4.5H9.10094Z" fill="currentColor" />
<path d="M9.10094 9.8114V11.5H7.59888V9.8114H9.10094Z" fill="currentColor" />
</svg>
),
} as Record<string, ReactNode>
/** Glyph for a permission option value; host-configured names outside the design set get none. */
function permissionGlyph(value: string): ReactNode | undefined {
return permissionGlyphs[value]
}
/**
* Display transform: kebab-case machine names render as title-case labels
* (`workspace-write` → `Workspace Write`); non-kebab host-configured names
@@ -52,7 +90,10 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
const items: MenuEntry[] = value.options
.filter(o => o.value !== 'custom')
.map(option => ({ id: option.value, label: optionLabel(option) }))
.map((option) => {
const icon = permissionGlyph(option.value)
return { id: option.value, label: optionLabel(option), ...icon === undefined ? {} : { icon } }
})
const submit = (id: string): void => {
setPick(id)
@@ -102,10 +143,14 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
disabled={locked || busy}
onClick={() => { setOpen(!open) }}
>
{permissionGlyph(currentValue) !== undefined && (
<span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span>
)}
<span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
{/* Same glyph + open rotation as the sibling ModelSelect trigger. */}
<span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden>
<IconChevronDownOutline14 />
</span>
</button>
}
/>

View File

@@ -1,6 +1,7 @@
/* Todo strip in the composer context stack (Figma 1236:32276): tip surface,
14px radius, status icons + secondary item labels. Its visible card aligns
with the GoalBar and the Queue panel inside their shared dock column. */
status icons + secondary item labels. Its visible card aligns with the
GoalBar and the Queue panel inside their shared dock column: 12px radius,
36px collapsed row, 12px side padding, 14px tertiary leading glyph. */
.root {
box-sizing: border-box;
@@ -24,7 +25,7 @@
var(--dsh-composer-dock-inset)
);
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 14px;
border-radius: 12px;
background: var(--dsw-specific-tip);
/* Elevated surface: `--dsw-specific-tip` is the same dark rung as the menu
surface, and `.list` scrolls inside this card, so the thumb takes the l2
@@ -39,7 +40,7 @@
display: flex;
flex-direction: column;
gap: 8px;
padding: 9px 15px;
padding: 6px 12px;
}
.header {
@@ -54,9 +55,16 @@
cursor: pointer;
}
.lead {
display: grid;
flex: none;
place-items: center;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
font-size: 13px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);

View File

@@ -13,7 +13,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots
// declare) and the payload type. Type-only by construction — the outlet is
// free of host value imports, so no host Context merge enters this program.
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChecklistOutline14, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { NS } from '../locales.ts'
import css from './TodoPanel.module.css'
@@ -78,11 +78,18 @@ function StatusGlyph({ status }: { status: TodoItem['status'] }) {
}
}
/** Header summary: "<done>/<total> tasks · <n> in progress". */
/** Header summary: "·"-joined per-status counts; zero-count segments are omitted as noise (a non-empty list keeps at least one). */
function progressLabel(todos: readonly TodoItem[], t: TodoPanelProps['t']): string {
const done = todos.filter(item => item.status === 'completed').length
const active = todos.filter(item => item.status === 'in_progress').length
return t('todo.progress', { done, total: todos.length, active })
const pending = todos.length - done - active
// En spaces (U+2002): HTML collapses runs of ASCII spaces, so widening the
// separator breathing room needs a literal wide space.
return [
...done > 0 ? [t('todo.progress.done', { done })] : [],
...active > 0 ? [t('todo.progress.active', { active })] : [],
...pending > 0 ? [t('todo.progress.pending', { pending })] : [],
].join('\u2002·\u2002')
}
export function TodoPanel({ todos, t }: TodoPanelProps) {
@@ -98,6 +105,7 @@ export function TodoPanel({ todos, t }: TodoPanelProps) {
aria-expanded={!collapsed}
onClick={() => { setCollapsed(v => !v) }}
>
<span className={css.lead} aria-hidden><IconChecklistOutline14 /></span>
<span className={css.title}>{t('todo.title')}</span>
<span className={css.progress}>{progressLabel(todos, t)}</span>
<span className={css.chevron} aria-hidden>
@@ -129,19 +137,18 @@ export function TodoDock({ useProjection, t }: TodoDockProps) {
}
/**
* The plan strip as a plain registrant plugin (QueueDock 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 plan strip as a plain registrant plugin (QueueDock posture), following
* the input-dock declaration across independent activation and reload.
*/
export const todoDockEntry = {
name: 'conversation-todo-dock',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the plan strip before the goal and queue entries (order 0).
* @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: 0, locale: NS }, TodoDock)
ctx.slots.inject('conversation.input.dock', () =>
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock))
},
}

View File

@@ -83,19 +83,19 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr
}
/**
* 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.
* The ask-question row as a plain registrant plugin following the chat
* toolview declaration across independent activation and reload lifetimes.
*/
export const askQuestionToolview = {
name: 'ask-question-toolview',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* 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', locale: NS }, AskQuestionRow)
ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({
name: 'conversation.chat.toolview', key: 'ask_user_question', locale: NS,
}, AskQuestionRow))
},
}

View File

@@ -166,19 +166,18 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
}
/**
* The sample as a plain registrant plugin. `inject` carries the load-order
* seam: requiring the conversation service guarantees the chat entry (and
* with it the 'conversation.chat.toolview' declaration) is registered —
* ui-conversation's apply mounts the service after the chat entry.
* The sample as a plain registrant plugin. Slot injection follows the chat
* toolview declaration across independent activation and reload lifetimes.
*/
export const bashToolviewSample = {
name: 'bash-toolview-sample',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the bash 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: 'bash', locale: NS }, BashRow)
ctx.slots.inject('conversation.chat.toolview', () =>
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash', locale: NS }, BashRow))
},
}

View File

@@ -53,21 +53,21 @@ export function FileMutationRow({ toolName, block, cwd, openFile, inspect, t }:
}
/**
* The file-mutation rows as a plain registrant plugin. `inject` carries the
* load-order seam: requiring the conversation service guarantees the chat entry
* (and with it the 'conversation.chat.toolview' declaration) is registered —
* ui-conversation's apply mounts the service after the chat entry.
* The file-mutation rows as a plain registrant plugin following the chat
* toolview declaration across independent activation and reload lifetimes.
*/
export const fileMutationToolview = {
name: 'file-mutation-toolview',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the file-mutation row into the chat view's keyed toolview hole
* under both mutation tool names.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow)
ctx.slots.inject('conversation.chat.toolview', function* () {
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow)
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow)
})
},
}

View File

@@ -48,19 +48,18 @@ export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowP
}
/**
* The read row as a plain registrant plugin. `inject` carries the load-order
* seam: requiring the conversation service guarantees the chat entry (and with
* it the 'conversation.chat.toolview' declaration) is registered —
* ui-conversation's apply mounts the service after the chat entry.
* The read row as a plain registrant plugin following the chat toolview
* declaration across independent activation and reload lifetimes.
*/
export const readToolview = {
name: 'read-toolview',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the read 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: 'read', locale: NS }, ReadRow)
ctx.slots.inject('conversation.chat.toolview', () =>
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read', locale: NS }, ReadRow))
},
}

View File

@@ -61,22 +61,22 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
}
/**
* The search toolview as a plain registrant plugin. `inject` carries the
* load-order seam: requiring the conversation service guarantees the chat entry
* (and with it the 'conversation.chat.toolview' declaration) is registered.
* The one component registers under both keys, since `grep` and `glob` are the
* same visual object discriminated only by the result view's `kind`.
* The search toolview follows the chat toolview declaration across activation
* and reload. One component registers under both keys because `grep` and
* `glob` are the same visual object discriminated by the result view's `kind`.
*/
export const searchToolview = {
name: 'search-toolview',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the search row into the chat view's keyed toolview hole under both
* the `grep` and `glob` tool names.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow)
ctx.slots.inject('conversation.chat.toolview', function* () {
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow)
})
},
}

View File

@@ -71,18 +71,18 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
}
/**
* The todo row as a plain registrant plugin, riding the same load-order seam
* as the bash sample: `inject: ['conversation']` guarantees the chat entry
* (and with it the 'conversation.chat.toolview' declaration) is on the ledger.
* The todo row as a plain registrant plugin following the chat toolview
* declaration across independent activation and reload lifetimes.
*/
export const todoToolview = {
name: 'todo-toolview',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the todo 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: 'todo_write', locale: NS }, TodoRow)
ctx.slots.inject('conversation.chat.toolview', () =>
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow))
},
}

View File

@@ -55,20 +55,20 @@ export function WebRow({ toolName, block, inspect, t }: WebRowProps) {
}
/**
* The web rows as a plain registrant plugin, riding the same load-order seam as
* the bash sample: `inject: ['conversation']` guarantees the chat entry (and
* with it the 'conversation.chat.toolview' declaration) is on the ledger. One
* WebRow component registers under both web tool names.
* The web rows follow the chat toolview declaration across activation and
* reload. One WebRow component registers under both web tool names.
*/
export const webToolview = {
name: 'web-toolview',
inject: ['slots', 'conversation'],
inject: ['slots'],
/**
* Register the web row under both web tool names' keyed toolview holes.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow)
ctx.slots.inject('conversation.chat.toolview', function* () {
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow)
yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow)
})
},
}