Merge origin/master into worktree/composer-caret-binding
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/** Registers the conversation components, shared store, and service callbacks. */
|
||||
import type { Context } from 'cordis'
|
||||
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { deferRegistration, 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).
|
||||
@@ -16,11 +16,16 @@ import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import type { IConversation } from './service.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import { ComposerSubmissionPolicy } from './input/submission-policy.ts'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx'
|
||||
import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { StatsLine } from './chat/StatsLine.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { readToolview } from './toolviews/read-row.tsx'
|
||||
import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
|
||||
import { searchToolview } from './toolviews/search-row.tsx'
|
||||
import { webToolview } from './toolviews/web-row.tsx'
|
||||
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
@@ -91,6 +96,22 @@ export function apply(ctx: Context): void {
|
||||
|
||||
// Apply-time construction keeps store identity bound to this fiber.
|
||||
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')
|
||||
|
||||
// Chat scroll offsets by session, surviving view switches (the chat view
|
||||
// unmounts under the tab ring). Deliberately not persisted: a fresh page
|
||||
@@ -164,7 +185,10 @@ export function apply(ctx: Context): void {
|
||||
slots.register({
|
||||
name: 'conversation.session',
|
||||
locale: NS,
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
children: {
|
||||
'conversation.view': { kind: 'list', scope: 'session' },
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
|
||||
views: {
|
||||
@@ -198,6 +222,8 @@ export function apply(ctx: Context): void {
|
||||
if (sessionId === undefined) {
|
||||
return {
|
||||
keyboard: undefined,
|
||||
resolveSubmitMode: (running, gesture, steeringAvailable) =>
|
||||
submissionPolicy.resolve(running, gesture, steeringAvailable),
|
||||
toggleCommandMenu: undefined,
|
||||
stop: undefined,
|
||||
command: undefined,
|
||||
@@ -208,6 +234,8 @@ export function apply(ctx: Context): void {
|
||||
const slash = inputHub.slash(sessionId)
|
||||
return {
|
||||
keyboard: shell,
|
||||
resolveSubmitMode: (running, gesture, steeringAvailable) =>
|
||||
submissionPolicy.resolve(running, gesture, steeringAvailable),
|
||||
toggleCommandMenu: slash === undefined
|
||||
? undefined
|
||||
: (selection) => {
|
||||
@@ -317,13 +345,22 @@ export function apply(ctx: Context): void {
|
||||
ctx.plugin(ConversationService, { input: inputHub })
|
||||
|
||||
// The bash sample rides that exact seam, in third-party posture
|
||||
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
|
||||
// (ToolRow-matching Bash · {description} chrome).
|
||||
ctx.plugin(bashToolviewSample)
|
||||
|
||||
// The read row rides the same seam (a product registration, not a sample):
|
||||
// Read · {path} chrome with the file's read card resident below it.
|
||||
ctx.plugin(readToolview)
|
||||
|
||||
// The write/edit rows ride the same seam: a file-mutation call declares the
|
||||
// diff render intent, so these rows stack the applied diff card under their
|
||||
// path-link summary (the terminal card's posture, applied to diffs).
|
||||
ctx.plugin(fileMutationToolview)
|
||||
|
||||
// The grep/glob search row rides the same seam: one component registered
|
||||
// under both tool names, since both declare the same search render intent.
|
||||
ctx.plugin(searchToolview)
|
||||
|
||||
// The web rows ride the same seam: one WebRow registered under both
|
||||
// web_search and web_fetch, rendering the completed retrieval's web card
|
||||
// resident under the summary (a product registration, not a sample).
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
// view groups them into tool rows through its keyed toolview slot (figma
|
||||
// step-summary flow). Shared by finalized nodes and the streaming partial;
|
||||
// the turn-level loading dots live in the chat view's tail, not here.
|
||||
// Finalized turn-tail content (text) nodes append IconActions once streaming
|
||||
// ends (`time` is omitted for mid-turn narration); Think / tool-head-only
|
||||
// nodes stay chrome-free.
|
||||
// Finalized content (text) nodes append IconActions once streaming ends
|
||||
// (`time` is omitted for mid-turn narration); their branch action is enabled
|
||||
// only when the node is also the completed turn's transcript tail. Think /
|
||||
// tool-head-only nodes stay chrome-free.
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -28,8 +29,10 @@ export interface AssistantMarkdownProps {
|
||||
time?: number | undefined
|
||||
/** Event sequence used as the fork boundary; omitted while streaming. */
|
||||
seq?: number | undefined
|
||||
/** Fork the session through the turn containing this finalized message. */
|
||||
/** Fork the session through this finalized message's completed turn when eligible. */
|
||||
onFork?: ((seq: number) => void) | undefined
|
||||
/** The message is not the transcript tail of a completed turn. */
|
||||
forkUnavailable?: boolean | undefined
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
@@ -39,6 +42,13 @@ function firstLine(text: string): string {
|
||||
return nl === -1 ? text : text.slice(0, nl)
|
||||
}
|
||||
|
||||
/** Latest non-blank reasoning line while the block is still streaming. */
|
||||
function latestLine(text: string): string {
|
||||
const visible = text.trimEnd()
|
||||
const nl = visible.lastIndexOf('\n')
|
||||
return nl === -1 ? visible : visible.slice(nl + 1)
|
||||
}
|
||||
|
||||
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
|
||||
function copyText(blocks: readonly AssistantBlock[]): string {
|
||||
const parts: string[] = []
|
||||
@@ -61,7 +71,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
|
||||
variant="think"
|
||||
icon={<IconThinkOutline14 size={14} />}
|
||||
title="Think"
|
||||
summary={firstLine(text)}
|
||||
summary={running ? latestLine(text) : firstLine(text)}
|
||||
body={text}
|
||||
state={running ? 'running' : 'ok'}
|
||||
/>
|
||||
@@ -69,7 +79,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
|
||||
}
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time, seq, onFork, t,
|
||||
blocks, streaming, interrupted, time, seq, onFork, forkUnavailable, t,
|
||||
}: AssistantMarkdownProps) {
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
// per render would rebuild MarkdownText's component table every chunk.
|
||||
@@ -113,6 +123,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
time={time}
|
||||
clock="end"
|
||||
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
|
||||
branchUnavailable={forkUnavailable}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -30,11 +30,11 @@ import type {
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem } from './MessageItem.tsx'
|
||||
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
@@ -236,6 +236,8 @@ export function ChatView({
|
||||
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const turnEnds = useSession(s => s.turnEnds)
|
||||
const inbox = useSession(s => s.queue)
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
const running = useSession(s => s.running)
|
||||
@@ -248,10 +250,15 @@ export function ChatView({
|
||||
const selectedCallId = useStore(s => s.selection?.callId)
|
||||
|
||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||||
const pendingSteering = useMemo(
|
||||
() => inbox.filter(item => item.placement === 'steering'),
|
||||
[inbox],
|
||||
)
|
||||
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
|
||||
// Only the last content assistant of each turn owns IconActions; mid-turn
|
||||
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
|
||||
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
|
||||
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
|
||||
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const atBottomRef = useRef(true)
|
||||
@@ -261,6 +268,7 @@ export function ChatView({
|
||||
const firstSeqRef = useRef<number | null>(null)
|
||||
const openedRef = useRef(false)
|
||||
const lastKeyRef = useRef<string | null>(null)
|
||||
const lastSteeringIdRef = useRef<string | null>(null)
|
||||
/** Flow tip signature — follow-scroll only when this moves, never on a
|
||||
* scroll-driven at-bottom chrome re-render (that was snapping inertial
|
||||
* scrolls the rest of the way to the floor). */
|
||||
@@ -269,7 +277,8 @@ export function ChatView({
|
||||
const firstSeq = nodes[0]?.seq ?? null
|
||||
const lastItem = items[items.length - 1]
|
||||
const lastKey = lastItem?.key ?? null
|
||||
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}`
|
||||
const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null
|
||||
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}`
|
||||
|
||||
const toBottom = (el: HTMLElement): void => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
@@ -298,6 +307,7 @@ export function ChatView({
|
||||
}
|
||||
firstSeqRef.current = firstSeq
|
||||
lastKeyRef.current = lastKey
|
||||
lastSteeringIdRef.current = lastSteeringId
|
||||
followSigRef.current = followSig
|
||||
return
|
||||
}
|
||||
@@ -308,6 +318,7 @@ export function ChatView({
|
||||
firstSeqRef.current = firstSeq
|
||||
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
|
||||
lastKeyRef.current = lastKey
|
||||
lastSteeringIdRef.current = lastSteeringId
|
||||
followSigRef.current = followSig
|
||||
return
|
||||
}
|
||||
@@ -316,12 +327,14 @@ export function ChatView({
|
||||
// (send lives in the composer, so arrival is detected here, not armed there).
|
||||
const appendedUser = lastKey !== lastKeyRef.current
|
||||
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
|
||||
const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current
|
||||
const tipMoved = followSigRef.current !== followSig
|
||||
lastKeyRef.current = lastKey
|
||||
lastSteeringIdRef.current = lastSteeringId
|
||||
followSigRef.current = followSig
|
||||
// Follow new flow content while pinned; do NOT re-pin on every render
|
||||
// merely because atBottomRef is true (scroll threshold → setState → snap).
|
||||
if (appendedUser || (tipMoved && atBottomRef.current)) toBottom(el)
|
||||
if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el)
|
||||
})
|
||||
|
||||
const onScrollRef = useRef(() => {})
|
||||
@@ -402,6 +415,7 @@ export function ChatView({
|
||||
time={actionSeqs.has(node.seq) ? node.time : undefined}
|
||||
seq={node.seq}
|
||||
onFork={forkAt}
|
||||
forkUnavailable={!branchSeqs.has(node.seq)}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
@@ -417,6 +431,7 @@ export function ChatView({
|
||||
node={node}
|
||||
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
|
||||
onFork={forkAt}
|
||||
forkUnavailable={!branchSeqs.has(node.seq)}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
@@ -467,6 +482,9 @@ export function ChatView({
|
||||
{/* Turn-level loading signal: rides the whole running turn (first-token
|
||||
wait, tool execution, streaming) so it never flickers per step. */}
|
||||
{running && <TurnStatus />}
|
||||
{pendingSteering.map(item => (
|
||||
<PendingSteeringBubble key={item.id} content={item.content} t={t} />
|
||||
))}
|
||||
</div>
|
||||
{!atBottom && (
|
||||
<div className={css.toBottomSlot}>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// CompactionItem: the one row a landed compaction contributes to the flow.
|
||||
// The conversation it shadowed on the model surface stays above it, so this
|
||||
// marker reports where the model stopped seeing that history — it never
|
||||
// replaces it. The framed checkpoint payload is written for the model and is
|
||||
// not rendered; the disclosure shows the summary from the checkpoint's own
|
||||
// provenance, and a window cut that left that provenance outside makes the row
|
||||
// non-expandable rather than empty.
|
||||
|
||||
import { memo, useState } from 'react'
|
||||
import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconChevronDownOutline14,
|
||||
IconChevronRightOutline14,
|
||||
MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
interface CompactionItemProps {
|
||||
node: CompactionSummaryNode
|
||||
/** The owning view's locale seat. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
/**
|
||||
* The collapsed-by-default compaction marker.
|
||||
* @param props - the marker node off the snapshot cache.
|
||||
* @returns the marker row, with the summary disclosure when one is available.
|
||||
*/
|
||||
export const CompactionItem = memo(function CompactionItem({ node, t }: CompactionItemProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandable = node.summary !== null
|
||||
const open = expandable && expanded
|
||||
return (
|
||||
<div className={css.compactionRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.compactionButton}
|
||||
disabled={!expandable}
|
||||
aria-expanded={expandable ? open : undefined}
|
||||
onClick={() => { setExpanded(value => !value) }}
|
||||
>
|
||||
<span className={css.compactionLeading}>
|
||||
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
</span>
|
||||
<span className={css.compactionTitle}>{t('message.compaction')}</span>
|
||||
<span className={css.compactionSep} aria-hidden />
|
||||
<span className={css.compactionSummary}>
|
||||
{expandable ? t('message.compaction.expand') : t('message.compaction.unavailable')}
|
||||
</span>
|
||||
</button>
|
||||
{open && node.summary !== null
|
||||
&& <div className={css.compactionBody}><MarkdownText text={node.summary} /></div>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px clipped code block. */
|
||||
/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px code block cap. */
|
||||
|
||||
.root {
|
||||
min-width: 0;
|
||||
@@ -15,7 +15,7 @@
|
||||
.body {
|
||||
box-sizing: border-box;
|
||||
width: calc(100% - 22px);
|
||||
height: 141px;
|
||||
max-height: 141px;
|
||||
margin: 4px 0 0 22px;
|
||||
overflow: auto;
|
||||
padding: 10px 16px 12px 12px;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/* The generic card grows a resident web card under its summary row when the
|
||||
tool declares the `web` render intent but has no keyed row of its own (the
|
||||
web_search/web_fetch rows register their own WebRow). A column around the
|
||||
ToolRow keeps the row's own 24px height. */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.web {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
@@ -7,15 +7,16 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
|
||||
IconThinkOutline14, WebBlock,
|
||||
IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { readCardModel } from '../contract/read-card-model.ts'
|
||||
import { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { searchCardModel } from '../contract/search-card-model.ts'
|
||||
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
|
||||
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
|
||||
import { webCardModel } from '../contract/web-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import css from './GenericToolCard.module.css'
|
||||
|
||||
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
|
||||
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
@@ -37,7 +38,9 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const read = readCardModel(block, cwd)
|
||||
const diff = diffCardModel(block)
|
||||
const search = searchCardModel(block)
|
||||
const web = webCardModel(block)
|
||||
// A failing exit status is the terminal card's own error signal (the call
|
||||
// itself settles isError:false), surfaced as the row's red state dot.
|
||||
@@ -45,7 +48,7 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
|
||||
? 'error'
|
||||
: model.state
|
||||
const singleFile = model.filePath !== undefined
|
||||
const row = (
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
@@ -53,29 +56,24 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
title={model.title}
|
||||
// A terminal presenter's description is the contract's above-card text, so
|
||||
// it outranks the args-derived summary here exactly as it does in BashRow.
|
||||
summary={terminal?.description ?? model.summary}
|
||||
// it outranks the args-derived summary here exactly as it does in BashRow;
|
||||
// a search result view's replacement title outranks it the same way.
|
||||
summary={terminal?.description ?? search?.title ?? model.summary}
|
||||
// Single-file tools never expose an args body — the path link is the only
|
||||
// args interaction. A diff card is not an args body: a write/edit row is
|
||||
// single-file AND carries a diff, so the card expands under the path link.
|
||||
// args interaction. A card is not an args body: a read/write/edit row is
|
||||
// single-file AND carries a card, so the card expands under the path link.
|
||||
body={singleFile ? null : model.body}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
terminal={terminal}
|
||||
diff={diff}
|
||||
read={read}
|
||||
search={search}
|
||||
web={web}
|
||||
state={state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
// A web-declaring tool without its own keyed row lands here; its card is
|
||||
// resident under the summary, mirroring WebRow (and BashRow's terminal card).
|
||||
if (web === null) return row
|
||||
return (
|
||||
<div className={css.card}>
|
||||
{row}
|
||||
<WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,3 +43,23 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Unavailable stays focusable and hoverable so Tooltip can explain why. */
|
||||
.action[data-unavailable] {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.action[data-unavailable]:hover {
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// Shared IconActions chrome for user and assistant messages: copy live,
|
||||
// branch wired through onBranch, date-aware clock,
|
||||
// optional edit stub.
|
||||
// Shared IconActions chrome for user, steering, and assistant messages: copy
|
||||
// live, optional branch wiring, and an optional date-aware clock.
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useId } from 'react'
|
||||
import {
|
||||
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
|
||||
IconBranchOutline16, IconCopyOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
|
||||
@@ -14,14 +13,16 @@ import css from './MessageIconActions.module.css'
|
||||
export interface MessageIconActionsProps {
|
||||
/** Plain text the copy action writes. */
|
||||
text: string
|
||||
/** Unix epoch ms for the clock label. */
|
||||
time: number
|
||||
/** Unix epoch ms for the clock label; omitted for transient messages. */
|
||||
time?: number | undefined
|
||||
/** Clock before icons (user) or after (assistant). */
|
||||
clock: 'start' | 'end'
|
||||
/** When true, append the stub edit control (user bubble). */
|
||||
edit?: boolean | undefined
|
||||
/** Fork the session at this message. */
|
||||
/** Fork the session at this message; omission hides the branch action. */
|
||||
onBranch?: (() => void) | undefined
|
||||
/** The message is not a completed transcript tail, so branch stays visible but unavailable. */
|
||||
branchUnavailable?: boolean | undefined
|
||||
/** Additional branch visibility gate for transient message chrome; defaults to true. */
|
||||
showBranch?: boolean | undefined
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
@@ -30,17 +31,18 @@ export interface MessageIconActionsProps {
|
||||
|
||||
/**
|
||||
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
|
||||
* @param props - Copy text, event time, clock side, optional edit, branch callback, className.
|
||||
* @param props - Copy text, event time, clock side, branch callback, className.
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, clock, edit, onBranch, className, t,
|
||||
text, time, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const reasonId = useId()
|
||||
const onCopy = useCallback(() => {
|
||||
void writeClipboard(text)
|
||||
}, [text])
|
||||
const clockEl = (
|
||||
const clockEl = time === undefined ? null : (
|
||||
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
|
||||
{formatMessageClock(time, t, day)}
|
||||
</span>
|
||||
@@ -53,18 +55,25 @@ export function MessageIconActions({
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('message.branch')} side="bottom">
|
||||
<button type="button" className={css.action} aria-label={t('message.branch')} onClick={onBranch}>
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{edit === true && (
|
||||
<Tooltip label={t('edit')} side="bottom">
|
||||
<button type="button" className={css.action} aria-label={t('edit')}>
|
||||
<IconEditOutline16 />
|
||||
{showBranch && onBranch !== undefined && (
|
||||
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
|
||||
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={t('message.branch')}
|
||||
aria-disabled={branchUnavailable || undefined}
|
||||
aria-describedby={branchUnavailable ? reasonId : undefined}
|
||||
data-unavailable={branchUnavailable || undefined}
|
||||
onClick={branchUnavailable ? undefined : onBranch}
|
||||
>
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showBranch && onBranch !== undefined && branchUnavailable && (
|
||||
<span id={reasonId} className={css.visuallyHidden}>{t('message.branchUnavailable')}</span>
|
||||
)}
|
||||
{clock === 'end' ? clockEl : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -20,20 +20,85 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-state-warn-primary);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.contextRow {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* Compaction marker: one dim 24px row with a chevron disclosure for the
|
||||
summary body. Dimmed title (not label-primary) — the row is a boundary
|
||||
notice, not conversation content. */
|
||||
.compactionRow {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.compactionButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.compactionLeading {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.compactionTitle {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
}
|
||||
|
||||
.compactionSep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
margin: 0 8px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.compactionSummary {
|
||||
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;
|
||||
}
|
||||
|
||||
.compactionBody {
|
||||
padding: 4px 0 4px 22px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.retryRow {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
@@ -116,6 +181,40 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.turnErrorRow {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 2px 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.turnErrorDot {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.turnErrorCopy {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.turnErrorTitle {
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.turnErrorMessage {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.turnErrorCode {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
@keyframes retry-shimmer {
|
||||
from {
|
||||
background-position: 100% 50%;
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
// MessageItem: simple chat nodes — user bubble (right-aligned, with
|
||||
// clock + copy / branch / edit IconActions), steering (badged bubble), context
|
||||
// injection, retry disclosure, and unknown-surface JSON rows.
|
||||
// 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.
|
||||
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
||||
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
|
||||
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { CompactionItem } from './CompactionItem.tsx'
|
||||
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
export interface MessageItemProps {
|
||||
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | ModelRetryNode | UnknownSurfaceNode
|
||||
node:
|
||||
| UserMessageNode
|
||||
| SteeringMessageNode
|
||||
| ContextMessageNode
|
||||
| CompactionSummaryNode
|
||||
| ModelRetryNode
|
||||
| TurnErrorNode
|
||||
| UnknownSurfaceNode
|
||||
retryActive?: boolean
|
||||
/** Fork the session through the turn containing this message (user-bubble branch action). */
|
||||
/** Fork through this message's completed turn when eligible. */
|
||||
onFork?: (seq: number) => void
|
||||
/** The message is not the transcript tail of a completed turn. */
|
||||
forkUnavailable?: boolean
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
@@ -107,6 +119,24 @@ function ModelRetryItem({ node, active, t }: {
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
/** Persistent, turn-positioned feedback for a terminal failure. */
|
||||
function TurnErrorItem({ node, t }: {
|
||||
node: TurnErrorNode
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
return (
|
||||
<div className={css.turnErrorRow} role="status">
|
||||
<StateDot state="error" className={css.turnErrorDot} />
|
||||
<div className={css.turnErrorCopy}>
|
||||
<span className={css.turnErrorTitle}>{t('message.turnError')}</span>
|
||||
<span className={css.turnErrorMessage}>{node.message}</span>
|
||||
</div>
|
||||
{node.code !== undefined && <code className={css.turnErrorCode}>{node.code}</code>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display projection of reference forms in a user bubble (free geometry — no
|
||||
* textarea alignment constraint here); everything else stays plain text. The
|
||||
@@ -139,49 +169,92 @@ function projectUserText(text: string): ReactNode {
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
/** Right-aligned bubble shared by user and steering rows. */
|
||||
function UserStyleBubble({
|
||||
content, actions, pending = 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
|
||||
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}>
|
||||
<div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
</div>
|
||||
{actions?.(text)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one Host-authoritative pending steering item with the same visual
|
||||
* language as its eventual durable transcript node.
|
||||
* @param props - Pending message content and conversation translator.
|
||||
* @returns the pending steering bubble.
|
||||
*/
|
||||
export function PendingSteeringBubble({ content, t }: {
|
||||
content: readonly unknown[]
|
||||
t: ChatViewSlotProps['t']
|
||||
}): ReactNode {
|
||||
return (
|
||||
<UserStyleBubble
|
||||
content={content}
|
||||
pending
|
||||
t={t}
|
||||
actions={text => (
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
clock="start"
|
||||
showBranch={false}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({
|
||||
node, retryActive = false, onFork, t,
|
||||
node, retryActive = false, onFork, forkUnavailable = false, t,
|
||||
}: MessageItemProps) {
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
switch (node.kind) {
|
||||
case 'user': {
|
||||
const { text, rest } = contentText(node.content)
|
||||
case 'user':
|
||||
case 'steering':
|
||||
return (
|
||||
<div className={css.userRow}>
|
||||
<div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
</div>
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
time={node.time}
|
||||
clock="start"
|
||||
edit
|
||||
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
<UserStyleBubble
|
||||
content={node.content}
|
||||
t={t}
|
||||
actions={text => (
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
time={node.time}
|
||||
clock="start"
|
||||
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
|
||||
branchUnavailable={forkUnavailable}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'steering': {
|
||||
const { text, rest } = contentText(node.content)
|
||||
return (
|
||||
<div className={css.userRow}>
|
||||
<div className={css.bubble}>
|
||||
<span className={css.badge}>{t('message.steering')}</span>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'context':
|
||||
return (
|
||||
<ContextInjectionRow content={node.content} source={node.source} t={t} />
|
||||
)
|
||||
case 'compaction':
|
||||
return <CompactionItem node={node} t={t} />
|
||||
case 'model-retry':
|
||||
return <ModelRetryItem node={node} active={retryActive} t={t} />
|
||||
case 'turn-error':
|
||||
return <TurnErrorItem node={node} t={t} />
|
||||
default:
|
||||
return (
|
||||
<div className={css.contextRow}>
|
||||
|
||||
@@ -3,43 +3,35 @@
|
||||
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
|
||||
|
||||
import { Fragment, memo, useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
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 css from './StatsLine.module.css'
|
||||
|
||||
interface UsageTotals {
|
||||
interface WindowStats {
|
||||
turns: number
|
||||
steps: number
|
||||
/** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
|
||||
llmMs: number
|
||||
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
|
||||
toolMs: number
|
||||
/** Prompt-side tokens: inputTokens + cacheReadTokens. */
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheHitPct: number | null
|
||||
}
|
||||
|
||||
/** Token accounting slice of assistant `usage` (typed upstream as unknown). */
|
||||
interface UsageLike {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold assistant and tool-result nodes into display totals.
|
||||
* Fold assistant and tool-result nodes into the window-scoped display totals.
|
||||
*
|
||||
* Counts and wall times describe the loaded window on purpose — they answer
|
||||
* "what is on screen". Token accounting deliberately does NOT come from here:
|
||||
* the window is paged and compaction rewrites it, so billing rides the durable
|
||||
* `tokenUsage` projection instead.
|
||||
* @param nodes - snapshot nodes.
|
||||
* @returns totals; cacheHitPct null until any cache accounting arrives.
|
||||
* @returns visible counts and summed wall times.
|
||||
*/
|
||||
export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
|
||||
export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
|
||||
const turns = new Set<number>()
|
||||
let steps = 0
|
||||
let llmMs = 0
|
||||
let toolMs = 0
|
||||
let input = 0
|
||||
let output = 0
|
||||
let cacheRead = 0
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'tool-result') {
|
||||
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
|
||||
@@ -51,22 +43,8 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
|
||||
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
|
||||
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
|
||||
}
|
||||
const usage = node.usage as UsageLike | undefined
|
||||
if (usage === undefined) continue
|
||||
input += usage.inputTokens ?? 0
|
||||
output += usage.outputTokens ?? 0
|
||||
cacheRead += usage.cacheReadTokens ?? 0
|
||||
}
|
||||
const denom = input + cacheRead
|
||||
return {
|
||||
turns: turns.size,
|
||||
steps,
|
||||
llmMs,
|
||||
toolMs,
|
||||
inputTokens: input + cacheRead,
|
||||
outputTokens: output,
|
||||
cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100),
|
||||
}
|
||||
return { turns: turns.size, steps, llmMs, toolMs }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,21 +72,82 @@ export function formatDuration(ms: number): string {
|
||||
return `${Math.floor(whole / 60)}m${whole % 60}s`
|
||||
}
|
||||
|
||||
/** Props: the conversation-snapshot selector (dock registration or unit mount). */
|
||||
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
/**
|
||||
* Cache-hit share of prompt-side input over the whole durable log.
|
||||
* @param usage - the session's token-usage projection value.
|
||||
* @returns rounded integer percent, or null when no input was billed.
|
||||
*/
|
||||
export function cacheHitPercent(usage: TokenUsageProjection): number | null {
|
||||
const denominator = billedInputTokens(usage)
|
||||
return denominator === 0
|
||||
? null
|
||||
: Math.round(usage.cacheReadTokens / denominator * 100)
|
||||
}
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
|
||||
/** Sum the three disjoint prompt-side billing buckets. */
|
||||
function billedInputTokens(usage: TokenUsageProjection): number {
|
||||
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
|
||||
}
|
||||
|
||||
interface ContextOccupancy {
|
||||
percent: 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).
|
||||
* @param pressure - the session's context-pressure projection value.
|
||||
* @returns occupancy and its 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
|
||||
return {
|
||||
percent: Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)),
|
||||
contextWindow: pressure.contextWindow,
|
||||
}
|
||||
}
|
||||
|
||||
/** Props: the conversation-snapshot selector plus the projection read seat. */
|
||||
export interface StatsLineProps {
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
useProjection: UseProjection
|
||||
}
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const usage = useProjection('tokenUsage')
|
||||
const pressure = useProjection('contextPressure')
|
||||
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
||||
if (stats.steps === 0) return null
|
||||
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
|
||||
const groups: string[] = [`${stats.turns} turns · ${stats.steps} steps`]
|
||||
const durations: string[] = []
|
||||
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
|
||||
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
|
||||
if (durations.length > 0) groups.push(durations.join(' · '))
|
||||
if (stats.cacheHitPct !== null) groups.push(`Cache hit ${stats.cacheHitPct}%`)
|
||||
groups.push(`Input ${formatTokens(stats.inputTokens)} tok · Output ${formatTokens(stats.outputTokens)} tok`)
|
||||
const groups: string[] = []
|
||||
if (stats.steps > 0) {
|
||||
groups.push(`${stats.turns} turns · ${stats.steps} steps`)
|
||||
const durations: string[] = []
|
||||
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
|
||||
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
|
||||
if (durations.length > 0) groups.push(durations.join(' · '))
|
||||
}
|
||||
const context = contextOccupancy(pressure)
|
||||
if (context !== null) {
|
||||
groups.push(`Context ${context.percent}% of ${formatTokens(context.contextWindow)}`)
|
||||
}
|
||||
// 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 (groups.length === 0) return null
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{groups.map((group, i) => (
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Live reasoning follows its one-line summary to the inline end. */
|
||||
.summary[data-follow-end] {
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
/* File-tool path: same geometry as .summary; hover underline + pointer. */
|
||||
.fileLink {
|
||||
flex: 1 1 auto;
|
||||
@@ -246,21 +251,32 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The two block-shaped expanded bodies: the code variant's run_code program
|
||||
through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
|
||||
command output through TerminalBlock. Both are drawn by the shared
|
||||
primitive, so only the row's indentation is this file's concern — the margin
|
||||
also replaces each primitive's own standalone vertical spacing with the
|
||||
flow's row rhythm. */
|
||||
/* The block-shaped expanded bodies: the code variant's run_code program
|
||||
through CodeBlock (shiki-highlighted TypeScript), a terminal card's command
|
||||
output through TerminalBlock, a diff card through DiffBlock, a read card's
|
||||
line-numbered window through ReadBlock, a search card's grouped matches or
|
||||
path list through SearchBlock, and a web card's citation/source list through
|
||||
WebBlock. All are drawn by the shared primitive, so only the row's
|
||||
indentation is this file's concern — the margin also replaces each
|
||||
primitive's own standalone vertical spacing with the flow's row rhythm. */
|
||||
.codeBody,
|
||||
.terminalBody {
|
||||
.terminalBody,
|
||||
.diffBody,
|
||||
.readBody,
|
||||
.searchBody,
|
||||
.webBody {
|
||||
margin: 4px 0 4px 4px;
|
||||
}
|
||||
|
||||
/* A write/edit diff renders through DiffBlock; like the terminal card it draws
|
||||
its own surface, so only the row indentation is this file's concern. */
|
||||
.diffBody {
|
||||
/* The recovery footer for a capped search: the result text (its `Full … stored
|
||||
at …` locator) below the card in the muted tone, since the card holds only the
|
||||
retained rows. Same column indent as the card body. */
|
||||
.searchRecovery {
|
||||
margin: 4px 0 4px 4px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* In-row code renders at the smaller code size (12/18) via each primitive's
|
||||
@@ -278,3 +294,15 @@
|
||||
--dsl-terminal-output-max-height: 224px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
/* Visually hidden run-state label for assistive technology: the StateDot and
|
||||
the running sweep are aria-hidden / colour-only, so the text carries the
|
||||
running/failed/interrupted state to a screen reader. */
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -3,24 +3,33 @@
|
||||
// separator dot + FILL-truncated summary, drawn through the shared
|
||||
// DisclosureRow chrome with the whole row as the expand toggle (click /
|
||||
// Enter / Space, icon→chevron hover preview). The collapsed row is always
|
||||
// one line; every row with body, output, or terminal material is expandable;
|
||||
// the summary stays inline while open, except Think, whose body opens with
|
||||
// the same first line and would repeat it.
|
||||
// one line; every row with body, output, or a card material (terminal, diff,
|
||||
// read, search, web) is expandable; the summary stays inline while open,
|
||||
// except Think, where the running collapsed row follows the latest line at its
|
||||
// scroll end and the summary yields while open to avoid repeating the body.
|
||||
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
|
||||
// text input/output, the run_code program through CodeBlock, or a terminal
|
||||
// card's command output through TerminalBlock — lives in a max-height scroll
|
||||
// text input/output, the run_code program through CodeBlock, or a card
|
||||
// primitive (TerminalBlock, DiffBlock, ReadBlock, SearchBlock, WebBlock) for a
|
||||
// call that declared that render intent — lives in a max-height scroll
|
||||
// container so a long payload scrolls internally instead of taking over the
|
||||
// message flow; Think's prose is the exception and flows uncapped like
|
||||
// message text. Expand state is component-local view state. File-tool
|
||||
// summaries are path links that open through the host (stopPropagation keeps
|
||||
// the two gestures independent); an error row's collapsed summary is the
|
||||
// failure's first line in the error color.
|
||||
// message flow; Think's prose is the exception and flows uncapped like message
|
||||
// text. Every card kind starts collapsed, so a run of tool calls stays
|
||||
// scannable; the details panel is the single-call full-height reading surface.
|
||||
// Expand state is component-local view state. File-tool summaries are path
|
||||
// links that open through the host (stopPropagation keeps the two gestures
|
||||
// independent); an error row's collapsed summary is the failure's first line in
|
||||
// the error color.
|
||||
|
||||
import { useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { CodeBlock, DiffBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../contract/read-card-model.ts'
|
||||
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
|
||||
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
@@ -45,17 +54,34 @@ export interface ToolRowProps {
|
||||
/**
|
||||
* Terminal-card material for a call whose render intent is a terminal card
|
||||
* (derived by `terminalCardModel`); it replaces the text sections when
|
||||
* present. A row with no body, no output, and no terminal material is not
|
||||
* expandable.
|
||||
* present. A call carries at most one card kind, so the card props below are
|
||||
* mutually exclusive.
|
||||
*/
|
||||
terminal?: TerminalCardModel | null | undefined
|
||||
/**
|
||||
* Diff-card material for a call whose render intent is a diff card (derived by
|
||||
* `diffCardModel`); it replaces the text body when present, the same way
|
||||
* `terminal` does. A call carries at most one card intent, so the two are
|
||||
* never both set.
|
||||
* `terminal` does.
|
||||
*/
|
||||
diff?: DiffCardModel | null | undefined
|
||||
/**
|
||||
* Read-card material for a call whose render intent is a read card (derived by
|
||||
* `readCardModel`); it replaces the text body with the file's line-numbered,
|
||||
* syntax-highlighted window when present.
|
||||
*/
|
||||
read?: ReadCardModel | null | undefined
|
||||
/**
|
||||
* Search-card material for a call whose render intent is a search card
|
||||
* (derived by `searchCardModel`); it replaces the text body with grouped
|
||||
* matches or a path list when present.
|
||||
*/
|
||||
search?: SearchCardModel | null | undefined
|
||||
/**
|
||||
* Web-card material for a call whose render intent is a web card (derived by
|
||||
* `webCardModel`); it replaces the text body with the retrieval's citation
|
||||
* list or fetched-source card when present.
|
||||
*/
|
||||
web?: WebBlockProps | null | undefined
|
||||
state: ToolRowState
|
||||
/**
|
||||
* Filesystem path from tool args; when set with onOpenFile, the summary
|
||||
@@ -92,6 +118,19 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
/** Visually hidden run-state label: the StateDot and the CSS sweep are both
|
||||
* aria-hidden / colour-only, so assistive technology needs this text to know a
|
||||
* row is running, failed, or interrupted. null in the ok state (the icon and
|
||||
* summary already describe a settled row). */
|
||||
function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): string | null {
|
||||
switch (state) {
|
||||
case 'running': return t('row.running')
|
||||
case 'error': return t('row.failed')
|
||||
case 'stopped': return t('row.stopped')
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolRow({
|
||||
t,
|
||||
variant,
|
||||
@@ -104,23 +143,46 @@ export function ToolRow({
|
||||
errorSummary,
|
||||
terminal,
|
||||
diff,
|
||||
read,
|
||||
search,
|
||||
web,
|
||||
state,
|
||||
filePath,
|
||||
onOpenFile,
|
||||
inspect,
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const summaryRef = useRef<HTMLSpanElement>(null)
|
||||
const terminalBody = terminal ?? null
|
||||
const diffBody = diff ?? null
|
||||
const readBody = read ?? null
|
||||
const searchBody = search ?? null
|
||||
const webBody = web ?? null
|
||||
const outputText = output ?? null
|
||||
const expandable = body !== null || outputText !== null || terminalBody !== null || diffBody !== null
|
||||
// A card replaces the text body; a call carries at most one card kind, so the
|
||||
// card props are mutually exclusive. Any of them, or a text body/output,
|
||||
// makes the row expandable.
|
||||
const card = terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody
|
||||
const expandable = body !== null || outputText !== null || card !== null
|
||||
const open = expanded && expandable
|
||||
// The run-state label AT needs: the StateDot and the running sweep are both
|
||||
// aria-hidden / colour-only, so a stopped or running row is otherwise silent.
|
||||
const status = stateStatus(state, t)
|
||||
// An error row's collapsed summary IS the failure: the first error line in
|
||||
// the error color outranks both the args summary and a terminal description.
|
||||
const failureLine = state === 'error' ? errorSummary ?? null : null
|
||||
const summaryText = failureLine ?? summary
|
||||
// The failure line is error prose, not the path: no open-file affordance.
|
||||
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
|
||||
const isThink = variant === 'think'
|
||||
const followSummaryEnd = isThink && state === 'running' && !open
|
||||
useLayoutEffect(() => {
|
||||
const summaryElement = summaryRef.current
|
||||
if (summaryElement === null) return
|
||||
summaryElement.scrollLeft = followSummaryEnd
|
||||
? summaryElement.scrollWidth - summaryElement.clientWidth
|
||||
: 0
|
||||
}, [followSummaryEnd, summaryText])
|
||||
const toggleExpand = () => {
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
@@ -128,10 +190,16 @@ export function ToolRow({
|
||||
event.stopPropagation()
|
||||
if (filePath !== undefined) onOpenFile?.(filePath)
|
||||
}
|
||||
// Keep Enter/Space on the focused path link from bubbling to the row's
|
||||
// keydown handler, which would preventDefault() the key and toggle expand
|
||||
// instead of activating the link — the keyboard analogue of openFile's
|
||||
// stopPropagation. The native button still fires its own onClick from the key.
|
||||
const fileLinkKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
|
||||
}
|
||||
// Think reasoning is prose, not an input payload: expanded, it renders as
|
||||
// plain indented text (no IN/OUT card) and the inline summary — the body's
|
||||
// own first line — yields to avoid repeating itself.
|
||||
const isThink = variant === 'think'
|
||||
// plain indented text (no IN/OUT card) and the inline summary yields to avoid
|
||||
// repeating the body.
|
||||
// The code variant's program renders through CodeBlock (shiki), so only its
|
||||
// output joins the IN/OUT card; every other variant's input does too.
|
||||
const cardBody = variant === 'code' ? null : body
|
||||
@@ -140,6 +208,7 @@ export function ToolRow({
|
||||
// of losing it with the icon.
|
||||
return (
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
<DisclosureRow
|
||||
rowClassName={css.row}
|
||||
leadingClassName={css.leading}
|
||||
@@ -162,11 +231,16 @@ export function ToolRow({
|
||||
type="button"
|
||||
className={css.fileLink}
|
||||
onClick={openFile}
|
||||
onKeyDown={fileLinkKeyDown}
|
||||
>
|
||||
{summaryText}
|
||||
</button>
|
||||
) : (
|
||||
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
|
||||
<span
|
||||
ref={isThink ? summaryRef : undefined}
|
||||
className={clsx(css.summary, failureLine !== null && css.errorSummary)}
|
||||
data-follow-end={followSummaryEnd || undefined}
|
||||
>
|
||||
{summaryText}
|
||||
</span>
|
||||
)}
|
||||
@@ -187,38 +261,53 @@ export function ToolRow({
|
||||
)
|
||||
: diffBody !== null
|
||||
? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
|
||||
: isThink
|
||||
? <div className={css.thinkBody}>{body}</div>
|
||||
: (
|
||||
<>
|
||||
{variant === 'code' && body !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{(cardBody !== null || outputText !== null) && (
|
||||
<div className={css.ioCard}>
|
||||
{cardBody !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>IN</span>
|
||||
<span className={css.ioText}>{cardBody}</span>
|
||||
</div>
|
||||
)}
|
||||
{cardBody !== null && outputText !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{outputText !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>OUT</span>
|
||||
<span className={css.ioText} data-error={state === 'error' || undefined}>
|
||||
{outputText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
: readBody !== null
|
||||
? <ReadBlock {...readBody} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
|
||||
: searchBody !== null
|
||||
? (
|
||||
<>
|
||||
<SearchBlock {...searchBody.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.searchBody} />
|
||||
{/* A capped search's recovery locator lives only in the result
|
||||
text; show it below the card so the dropped rows survive. */}
|
||||
{searchBody.recovery !== undefined && (
|
||||
<div className={css.searchRecovery}>{searchBody.recovery}</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: webBody !== null
|
||||
? <WebBlock {...webBody} className={css.webBody} />
|
||||
: isThink
|
||||
? <div className={css.thinkBody}>{body}</div>
|
||||
: (
|
||||
<>
|
||||
{variant === 'code' && body !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{(cardBody !== null || outputText !== null) && (
|
||||
<div className={css.ioCard}>
|
||||
{cardBody !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>IN</span>
|
||||
<span className={css.ioText}>{cardBody}</span>
|
||||
</div>
|
||||
)}
|
||||
{cardBody !== null && outputText !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{outputText !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>OUT</span>
|
||||
<span className={css.ioText} data-error={state === 'error' || undefined}>
|
||||
{outputText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{inspect !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* reuse the first notice's row while projecting the latest retry turn.
|
||||
* Item identity keys are stable across snapshots so the list parent can
|
||||
* subscribe to keys only while rows subscribe to content. IconActions ownership
|
||||
* (last content assistant per turn) is derived here too so ChatView and the
|
||||
* flow share one gate.
|
||||
* and completed-turn branch points are derived here too so ChatView and the
|
||||
* flow share their gates.
|
||||
*/
|
||||
import type {
|
||||
AssistantBlock, ConversationNode, ToolResultNode,
|
||||
@@ -47,10 +47,43 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
|
||||
return new Set(lastByTurn.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Seq set of message rows that may fork: the last transcript node of a
|
||||
* completed turn, when that node owns message chrome. A later tool, reasoning,
|
||||
* error, or other transcript node leaves the earlier message's branch action
|
||||
* unavailable because the Host would include the whole turn.
|
||||
* @param nodes - snapshot nodes in event order.
|
||||
* @param turnEnds - completed turn boundaries retained from the event window.
|
||||
* @returns Message seq values whose visible position matches the fork boundary.
|
||||
*/
|
||||
export function messageBranchSeqs(
|
||||
nodes: readonly ConversationNode[],
|
||||
turnEnds: ReadonlyMap<number, number>,
|
||||
): ReadonlySet<number> {
|
||||
const result = new Set<number>()
|
||||
const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1])
|
||||
let nodeIndex = 0
|
||||
for (const [turn, endSeq] of boundaries) {
|
||||
let tail: ConversationNode | undefined
|
||||
while (nodeIndex < nodes.length) {
|
||||
const candidate = nodes[nodeIndex]
|
||||
if (candidate === undefined || candidate.seq > endSeq) break
|
||||
tail = candidate
|
||||
nodeIndex++
|
||||
}
|
||||
if (tail?.kind === 'user'
|
||||
|| (tail?.kind === 'steering' && tail.turn === turn)
|
||||
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
|
||||
result.add(tail.seq)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Group finalized nodes into the step-summary flow.
|
||||
* @param nodes - snapshot nodes (surface order).
|
||||
* @returns flow items; consecutive tool results and retry notices reuse their first key.
|
||||
* @param nodes - snapshot nodes in human-transcript and durable-notice order.
|
||||
* @returns flow items; consecutive tool results group and retry notices reuse their first key.
|
||||
*/
|
||||
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
|
||||
const items: ChatFlowItem[] = []
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Composer submission vocabulary shared by the input and settings domains. */
|
||||
|
||||
/** Delivery mode requested for one ordinary composer message. */
|
||||
export type InputSubmitMode = 'queue' | 'steer'
|
||||
|
||||
/** Configurable meaning of plain Enter while the addressed agent is busy. */
|
||||
export type BusyEnterBehavior = InputSubmitMode
|
||||
|
||||
/** Keyboard gesture whose delivery mode the submission policy resolves. */
|
||||
export type ComposerSubmitGesture = 'enter' | 'accelerated'
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Pure derivation of the read-card props from a frozen call slice: the
|
||||
* `card:'read'` render intent the read tool declares arrives on the snapshot as
|
||||
* the settled result node's `resultView`, and this is the one place that turns
|
||||
* it into what {@link ReadBlock} draws. Both conversation render sites (the chat
|
||||
* tool row's resident body and the details panel's Output section) call this, so
|
||||
* the path, lines, total, and language they show are derived once.
|
||||
*
|
||||
* The read card is result-side only ([read card note](../../../../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)):
|
||||
* a call carries no file content until `execute` returns, so the pending call
|
||||
* stays a generic card (`kind: 'read'`). A running read therefore has no read
|
||||
* card, and this returns null for it — the row keeps its args-derived summary
|
||||
* until the result arrives.
|
||||
* @module
|
||||
*/
|
||||
import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Content lines the chat row's resident read body shows before collapsing the
|
||||
* middle — half the primitive's own default, which the details panel keeps. A
|
||||
* chat row is a summary surface inside the message flow: the flow must stay
|
||||
* scannable across many calls, while the details panel is the single-call
|
||||
* reading surface. A design constant of this UI's row geometry, not a
|
||||
* deployment choice, so it is fixed here rather than a plugin Config field. The
|
||||
* same split [`CHAT_TERMINAL_MAX_LINES`](./terminal-card-model.ts) draws for
|
||||
* terminal output.
|
||||
*/
|
||||
export const CHAT_READ_MAX_LINES = 8
|
||||
|
||||
/**
|
||||
* The {@link ReadBlock} props this derivation owns. Picked off the primitive's
|
||||
* props so the two stay in step; `maxLines`/`className` belong to each render
|
||||
* site.
|
||||
*/
|
||||
export type ReadCardModel = Pick<ReadBlockProps, 'label' | 'lines' | 'totalLines' | 'lang'>
|
||||
|
||||
/**
|
||||
* Derive the read-card props for a tool call, or null when this call is not a
|
||||
* read card and belongs on the generic path.
|
||||
*
|
||||
* The read card is result-side only, so only a settled call whose result view
|
||||
* declares `card:'read'` produces one. Every other case is null — the
|
||||
* documented generic-card default:
|
||||
*
|
||||
* - A running call: it has no result view yet, and a read carries no content at
|
||||
* call time.
|
||||
* - A settled call whose result view is not a read card — including a `card`
|
||||
* value this UI version does not know, which arrives over the wire and cannot
|
||||
* be trusted to be one of the compiled variants, and the read tool's own
|
||||
* generic fallback for an error result or a non-envelope body.
|
||||
*
|
||||
* The label is the read view's `title` when the tool supplied one (the
|
||||
* presentation contract's replacement-title rule), otherwise the file path
|
||||
* relativized to the session workspace so a workspace-rooted absolute path
|
||||
* displays the same short form the row summary shows.
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @param sessionCwd - the session workspace root; a workspace-rooted absolute
|
||||
* path label displays relative to it. Absent leaves the path as authored.
|
||||
* @returns the read-card props, or null for the generic path.
|
||||
*/
|
||||
export function readCardModel(block: ToolCallBlock, sessionCwd?: string): ReadCardModel | null {
|
||||
// Running has no result view; a read carries no content until execute returns.
|
||||
if (!('kind' in block)) return null
|
||||
const result = block.resultView?.card === 'read' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
// Lines arrive frozen off the snapshot; copy into the primitive's own line
|
||||
// shape so the card never holds a reference into the runtime's cache.
|
||||
const lines: ReadBlockLine[] = result.lines.map(line => ({ number: line.number, text: line.text }))
|
||||
return {
|
||||
label: result.title ?? relativizeToCwd(result.path, sessionCwd),
|
||||
lines,
|
||||
totalLines: result.totalLines,
|
||||
lang: result.lang,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Pure derivation of the search-card props from a frozen call slice: the
|
||||
* `card:'search'` render intent the `grep` and `glob` tools declare arrives on
|
||||
* the snapshot as `resultView`, and this is the one place that turns it into
|
||||
* what {@link SearchBlock} draws. Both conversation render sites (the chat tool
|
||||
* row's resident body and the details panel's Output section) call this, so the
|
||||
* grouped matches or the path list they show are derived once.
|
||||
*
|
||||
* The search card is result-time only: a search call has no matches or paths
|
||||
* before `execute`, so its pending state stays a `GenericCallView`
|
||||
* ({@link module:@deepseek-ai/dsh-tools/src/presentation}). This derivation
|
||||
* therefore reads only `resultView` and returns null for a still-running call,
|
||||
* unlike the terminal card whose call view carries the command before
|
||||
* execution.
|
||||
*
|
||||
* A capped result also carries a recovery locator (grep/glob's `Full … stored
|
||||
* at …` footer) in the raw `tool/result` content, not in the structured
|
||||
* matches/paths the view carries. Since both render sites replace that raw
|
||||
* result with the card, this derivation surfaces the block's own result text as
|
||||
* {@link SearchCardModel.recovery} so the one path to the dropped rows is not
|
||||
* lost.
|
||||
* @module
|
||||
*/
|
||||
import type { SearchBlockProps, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Distributive `Omit`: a plain `Omit<A | B, K>` keeps only the keys common to
|
||||
* both members, which would drop the `files`/`paths` discriminated fields.
|
||||
* Distributing over the naked type parameter `T` preserves each shape.
|
||||
*/
|
||||
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never
|
||||
|
||||
/** The {@link SearchBlockProps} union minus each render site's own fields. */
|
||||
type SearchBlockModelProps = DistributiveOmit<SearchBlockProps, 'maxLines' | 'className'>
|
||||
|
||||
/**
|
||||
* Result rows the chat row's resident search body shows before collapsing the
|
||||
* middle — half the primitive's own default, which the details panel keeps. A
|
||||
* chat row is a summary surface inside the message flow: the flow must stay
|
||||
* scannable across many calls, while the details panel is the single-call
|
||||
* reading surface. A design constant of this UI's row geometry, not a
|
||||
* deployment choice, so it is fixed here rather than a plugin Config field.
|
||||
*/
|
||||
export const CHAT_SEARCH_MAX_LINES = 8
|
||||
|
||||
/**
|
||||
* The {@link SearchBlock} props this derivation owns. Held as a nested object
|
||||
* (`card`) so a render site spreads exactly the primitive's own surface and can
|
||||
* never leak a neighbouring field into it. `maxLines`/`className` belong to each
|
||||
* render site.
|
||||
*/
|
||||
export interface SearchCardModel {
|
||||
/**
|
||||
* The props {@link SearchBlock} draws, minus each render site's own
|
||||
* `maxLines`/`className`.
|
||||
*/
|
||||
card: SearchBlockModelProps
|
||||
/**
|
||||
* The result view's replacement title, which the presentation contract lets a
|
||||
* search tool set at settle time. Absent when the presenter supplied none; a
|
||||
* row then keeps its args-derived summary.
|
||||
*/
|
||||
title: string | undefined
|
||||
/**
|
||||
* The raw `tool/result` text, flattened, surfaced only when the search was
|
||||
* capped. The card renders the retained matches or paths, but the recovery
|
||||
* locator a capped result carries — grep/glob's `Full … stored at: <locator>`
|
||||
* footer, the one way to reach the rows the cap dropped — lives only in the raw
|
||||
* result text, which the card replaces. A UI that shows the card would
|
||||
* otherwise lose it. Absent when the result was not capped (the card holds
|
||||
* every result) or the block carries no text.
|
||||
*/
|
||||
recovery: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every file group in a matches view is structurally valid: the wire
|
||||
* frame carries `shape` and `card` as strings the host schema checks, but not the
|
||||
* grouped shape, so a version mismatch or loose producer could deliver
|
||||
* `shape: 'matches'` with a missing or malformed `files`. Rendering that would
|
||||
* crash {@link SearchBlock} at `.reduce`/`.map`; an invalid shape falls to the
|
||||
* generic path instead.
|
||||
* @param files - the candidate `files` field off the untrusted result view.
|
||||
* @returns whether `files` is a valid {@link SearchFileGroup} array.
|
||||
*/
|
||||
function isValidFiles(files: unknown): files is SearchFileGroup[] {
|
||||
return Array.isArray(files) && files.every(file =>
|
||||
typeof file === 'object' && file !== null
|
||||
&& typeof (file as { path?: unknown }).path === 'string'
|
||||
&& Array.isArray((file as { matches?: unknown }).matches)
|
||||
&& (file as { matches: unknown[] }).matches.every(match =>
|
||||
typeof match === 'object' && match !== null
|
||||
&& typeof (match as { lineNumber?: unknown }).lineNumber === 'number'
|
||||
&& typeof (match as { line?: unknown }).line === 'string'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a settled tool result's content blocks to their text, joined by
|
||||
* newlines. The search view carries no result text — a UI without a card falls
|
||||
* back to the raw `tool/result` content — so the truncation recovery footer is
|
||||
* read from the block's own content here. Non-text blocks (a search result
|
||||
* carries none) are skipped.
|
||||
* @param content - the result node's content blocks.
|
||||
* @returns the joined text, or undefined when empty.
|
||||
*/
|
||||
function flattenContent(content: readonly { type: string; text?: string }[]): string | undefined {
|
||||
const text = content
|
||||
.filter((block): block is { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string')
|
||||
.map(block => block.text)
|
||||
.join('\n')
|
||||
return text === '' ? undefined : text
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the search-card props for a tool call, or null when this call is not a
|
||||
* search card and belongs on the generic path.
|
||||
*
|
||||
* Only the result side matters: the search card carries no call-time state, so
|
||||
* a still-running call (no result view) is null, as is a settled call whose
|
||||
* result view is not a search card — including a `card` value this UI version
|
||||
* does not know, which arrives over the wire and cannot be trusted to be one of
|
||||
* the compiled variants, a `card: 'search'` view whose `shape` is neither
|
||||
* `matches` nor `paths` (equally untrusted wire data), and a generic result a
|
||||
* `grep`/`glob` failure or nested `run_code` dispatch produces (its text keeps
|
||||
* the generic path).
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @returns the search-card props, or null for the generic path.
|
||||
*/
|
||||
export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
|
||||
// Running: no result view exists yet, and a search card is result-only.
|
||||
if (!('kind' in block)) return null
|
||||
const result = block.resultView?.card === 'search' ? block.resultView : null
|
||||
if (result === null) return null
|
||||
const common = { truncated: result.truncated, total: result.total }
|
||||
// The recovery footer only matters when the tool capped the result: an
|
||||
// uncapped card holds every match/path, so the raw text adds nothing the card
|
||||
// does not already show. When capped, the raw result's `Full … stored at …`
|
||||
// locator is the only path to the dropped rows, so surface it.
|
||||
const recovery = result.truncated ? flattenContent(block.content) : undefined
|
||||
if (result.shape === 'matches') {
|
||||
// `files` rides the untrusted wire frame: the host schema checks `card`/`shape`
|
||||
// strings but not the grouped shape, so validate it before SearchBlock, which
|
||||
// would crash on a missing/malformed `files`. An invalid shape falls to generic.
|
||||
if (!isValidFiles(result.files)) return null
|
||||
return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } }
|
||||
}
|
||||
// `shape` rides the same untrusted wire frame as `card`, so a version mismatch
|
||||
// or a loose protocol producer could deliver a `card: 'search'` subtype this
|
||||
// client does not compile. Guard the paths shape explicitly: an unknown shape
|
||||
// falls to the generic path rather than being rendered as a paths card, which
|
||||
// would leave SearchBlock calling `.length`/`.map` on an absent `paths`.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- shape is wire data; the compiled union cannot prove this exhaustive.
|
||||
if (result.shape !== 'paths') return null
|
||||
// `paths` is likewise unchecked by the wire schema; a known shape with a
|
||||
// missing/malformed array would crash the paths card at `.map`.
|
||||
if (!Array.isArray(result.paths) || !result.paths.every((path): path is string => typeof path === 'string')) return null
|
||||
return { title: result.title, recovery, card: { kind: 'paths', paths: result.paths, ...common } }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInte
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -17,6 +18,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* remounted when the current session id changes.
|
||||
*/
|
||||
'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps }
|
||||
/** Session-header actions contributed by feature plugins. */
|
||||
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
|
||||
/**
|
||||
* The conversation view ring: one list entry per view tab (chat here;
|
||||
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
|
||||
@@ -135,6 +138,9 @@ export interface ConversationSessionOwnerProps {
|
||||
wrapActiveBody?: (view: ReactNode) => ReactNode
|
||||
}
|
||||
|
||||
/** Header actions derive their state from the standard session/global kit. */
|
||||
export interface ConversationHeaderActionOwnerProps {}
|
||||
|
||||
/**
|
||||
* The input-region slot currency (plan §1.4): dock/left/right entries read
|
||||
* the conversation snapshot and the live input state as owner props (both
|
||||
@@ -280,6 +286,12 @@ export interface ComposerBarOwnerProps {
|
||||
export interface ComposerBarInjected {
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
|
||||
keyboard: ComposerKeyboard | undefined
|
||||
/** Resolve one keyboard submission gesture against the current running state and persisted preference. */
|
||||
resolveSubmitMode: (
|
||||
running: boolean,
|
||||
gesture: ComposerSubmitGesture,
|
||||
steeringAvailable: boolean,
|
||||
) => InputSubmitMode
|
||||
/** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */
|
||||
toggleCommandMenu: ((selection: EditSelection) => void) | undefined
|
||||
/** Cancel the in-flight turn; absent with the session. */
|
||||
@@ -331,6 +343,8 @@ export type ComposerBarProps =
|
||||
*/
|
||||
export interface ComposerChainProps {
|
||||
interactions: readonly PendingInteraction[]
|
||||
/** Current conversation facts for feature-owned takeover selectors. */
|
||||
session: ConversationSnapshot | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,7 +366,7 @@ export type ConversationSlotProps =
|
||||
/** Full strict-session content props: per-session store, view ring, callbacks, and the locale seat. */
|
||||
export type ConversationSessionSlotProps =
|
||||
PropsRuntime<'conversation.session'>
|
||||
& PropsRenderSlots<'conversation.view'>
|
||||
& PropsRenderSlots<'conversation.view' | 'conversation.session.header.actions'>
|
||||
& PropsStore<ChatStore>
|
||||
& ConversationSessionInjected
|
||||
& PropsLocale<'conversation'>
|
||||
@@ -447,7 +461,7 @@ export interface ChatViewInjected {
|
||||
/** Last recorded offset, or null when pinned or never recorded. */
|
||||
read: () => number | null
|
||||
}
|
||||
/** Fork the session through the turn containing the message at `seq`, then open the child. */
|
||||
/** Fork through the completed turn ending at the eligible message `seq`, then open the child. */
|
||||
forkAt: (seq: number) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -133,8 +133,13 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
|
||||
others: [],
|
||||
}
|
||||
|
||||
/** Strip the workspace root from workspace-rooted absolute paths (display only). */
|
||||
function relativizeToCwd(text: string, cwd: string | undefined): string {
|
||||
/**
|
||||
* Strip the workspace root from a workspace-rooted absolute path (display only).
|
||||
* @param text - the path to shorten.
|
||||
* @param cwd - session workspace root; absent or empty leaves the path unchanged.
|
||||
* @returns the path relative to the workspace root, or unchanged when it is not rooted there.
|
||||
*/
|
||||
export function relativizeToCwd(text: string, cwd: string | undefined): string {
|
||||
if (cwd === undefined || cwd === '') return text
|
||||
const root = cwd.replace(/[/\\]+$/, '')
|
||||
if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)
|
||||
|
||||
@@ -15,16 +15,6 @@
|
||||
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolCallBlock } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Sources the chat row's web body shows before collapsing the middle — half
|
||||
* the primitive's own default, which the details panel keeps. A chat row is a
|
||||
* summary surface inside the message flow: the flow must stay scannable across
|
||||
* many calls, while the details panel is the single-call reading surface. A
|
||||
* design constant of this UI's row geometry, not a deployment choice, so it is
|
||||
* fixed here rather than a plugin Config field.
|
||||
*/
|
||||
export const CHAT_WEB_MAX_SOURCES = 8
|
||||
|
||||
/**
|
||||
* Derive the web-card props for a tool call, or null when this call is not a
|
||||
* web card and belongs on the generic path.
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ReferenceInsert, SubmitOutcome, TokenSpan,
|
||||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { QueueRow } from '../contract/queue.ts'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
|
||||
/**
|
||||
* The scoped-event application verbs: the hub's bail listeners call these,
|
||||
@@ -28,8 +29,11 @@ export interface InputTarget {
|
||||
export interface SessionInput extends InputTarget {
|
||||
/** Single write path for draft text (all mutation rides machine events). */
|
||||
setDraft(text: string): void
|
||||
/** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */
|
||||
submit(mode?: 'queue' | 'steer'): void
|
||||
/**
|
||||
* THE complexity sink: enter adjudication, submit transaction, and the default sink live inside.
|
||||
* @param mode - delivery intent retained through asynchronous adjudication and serialization.
|
||||
*/
|
||||
submit(mode?: InputSubmitMode): void
|
||||
/**
|
||||
* Surface a notice outside the machine's own effect stream: detached
|
||||
* command results and business notifications render through here.
|
||||
@@ -60,7 +64,7 @@ export interface InputActions {
|
||||
/** Single public draft write path (full next draft; occurrence math via diff scan). */
|
||||
setDraft(text: string): void
|
||||
/** Enter submission (adjudication / claim transaction / default sink inside). */
|
||||
submit(mode?: 'queue' | 'steer'): void
|
||||
submit(): void
|
||||
}
|
||||
|
||||
/** One surfaced notice (command results, adjudication failures). seq keys re-render of repeats. */
|
||||
@@ -82,8 +86,8 @@ export interface ComposerKeyboard {
|
||||
readonly snapshot: InputState
|
||||
/** Draft write with the DOM-observed edit shape (narrows occurrence math). */
|
||||
setDraft(text: string, editRange?: EditRange): void
|
||||
/** Newline at the selection as a machine transaction (Ctrl+Enter path). */
|
||||
newline(selection: EditSelection): void
|
||||
/** Submit with an explicit delivery mode resolved by the keyboard policy. */
|
||||
submit(mode: InputSubmitMode): void
|
||||
undo(): void
|
||||
redo(): void
|
||||
/** Paste over the selection (sync components ride the same transaction). */
|
||||
@@ -191,7 +195,7 @@ export interface InputState {
|
||||
readonly occurrences: readonly Occurrence[]
|
||||
/** Live paste-match attempt (absent when no paste is matchable). */
|
||||
readonly paste?: PasteAttemptState
|
||||
/** Read-only queue projection (session/queued frames + connect snapshot). */
|
||||
/** Read-only transient inbox projection (`session/queue`, including pending steering). */
|
||||
readonly queue: readonly QueuedMessage[]
|
||||
}
|
||||
|
||||
@@ -206,6 +210,8 @@ export interface SubmitAttempt {
|
||||
readonly signal: AbortSignal
|
||||
/** Draft at enter time; rollback restores it only while the live draft still equals it. */
|
||||
readonly draftSnapshot: string
|
||||
/** Default-message delivery intent retained while slash adjudication is pending. */
|
||||
readonly mode: InputSubmitMode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,8 +223,6 @@ export interface SubmitAttempt {
|
||||
export type InputEvent =
|
||||
/** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */
|
||||
| { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange }
|
||||
/** Insert '\n' replacing the selection (F1: the execCommand newline path moved into the machine). */
|
||||
| { readonly type: 'newline'; readonly selection: EditSelection }
|
||||
| { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan }
|
||||
/** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */
|
||||
| { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan }
|
||||
@@ -239,7 +243,7 @@ export type InputEvent =
|
||||
| { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert }
|
||||
/** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */
|
||||
| { readonly type: 'invalidate-paste' }
|
||||
| { readonly type: 'enter'; readonly mode: 'queue' | 'steer' }
|
||||
| { readonly type: 'enter'; readonly mode: InputSubmitMode }
|
||||
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
|
||||
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
|
||||
| { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
|
||||
@@ -258,5 +262,5 @@ export type InputEvent =
|
||||
export type InputEffect =
|
||||
| { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
|
||||
| { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
|
||||
| { readonly type: 'default-sink'; readonly draft: string; readonly mode: 'queue' | 'steer' }
|
||||
| { readonly type: 'default-sink'; readonly draft: string; readonly mode: InputSubmitMode }
|
||||
| { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
|
||||
PasteComponent, QueuedMessage, SessionInput, SubmitAttempt,
|
||||
} from './contract.ts'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
import { InputMachine } from './machine.ts'
|
||||
|
||||
/** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */
|
||||
@@ -39,7 +40,7 @@ export interface SessionInputDeps {
|
||||
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
|
||||
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
|
||||
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
|
||||
defaultSink(text: string, mode: 'queue' | 'steer'): void
|
||||
defaultSink(text: string, mode: InputSubmitMode): void
|
||||
}
|
||||
|
||||
/** Guard tier from the machine phase. */
|
||||
@@ -68,7 +69,7 @@ export class SessionInputShell implements SessionInput {
|
||||
/** The public provide-channel action face (one stable identity per session — decision 20). */
|
||||
readonly actions: InputActions = {
|
||||
setDraft: (text) => { this.setDraft(text) },
|
||||
submit: (mode) => { this.submit(mode) },
|
||||
submit: () => { this.submit('queue') },
|
||||
}
|
||||
|
||||
// Real wall clock: the typing-run merge window must actually expire in
|
||||
@@ -106,15 +107,6 @@ export class SessionInputShell implements SessionInput {
|
||||
this.run(this.core.dispatch({ type: 'send-committed' }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a newline at the selection as one machine transaction (the
|
||||
* execCommand path is gone — a second undo history would fork).
|
||||
* @param selection - current DOM selection in draft coordinates.
|
||||
*/
|
||||
newline(selection: EditSelection): void {
|
||||
this.run(this.core.dispatch({ type: 'newline', selection }))
|
||||
}
|
||||
|
||||
/** Undo the latest transaction (InputBar intercepts the platform chord). */
|
||||
undo(): void {
|
||||
this.run(this.core.dispatch({ type: 'undo' }))
|
||||
@@ -151,9 +143,8 @@ export class SessionInputShell implements SessionInput {
|
||||
* from the machine; this method only feeds the event. Lock entry
|
||||
* (adjudicating/submitting) force-closes the transient layers: the popup
|
||||
* dismisses and the menu tracks frozen.
|
||||
* @param mode - default-sink mode (queue appends; steer interrupts).
|
||||
*/
|
||||
submit(mode: 'queue' | 'steer' = 'queue'): void {
|
||||
submit(mode: InputSubmitMode = 'queue'): void {
|
||||
this.run(this.core.dispatch({ type: 'enter', mode }))
|
||||
const phase = this.snapshot.phase
|
||||
if (phase === 'adjudicating' || phase === 'submitting') {
|
||||
@@ -354,7 +345,7 @@ export class SessionInputShell implements SessionInput {
|
||||
* send — notice + draft and chips retained, never a silent downgrade to
|
||||
* the clipboard text. Chip-free drafts skip the async detour.
|
||||
*/
|
||||
private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void {
|
||||
private sinkSerialized(draft: string, mode: InputSubmitMode): void {
|
||||
const occurrences = this.core.state.occurrences
|
||||
if (occurrences.length === 0) {
|
||||
this.deps.defaultSink(draft.trim(), mode)
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId }
|
||||
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { queueReadFaceOf } from '../queue/store.ts'
|
||||
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
import type { PopupDismissFace } from './facade.ts'
|
||||
import { SessionInputShell } from './facade.ts'
|
||||
|
||||
@@ -123,7 +124,7 @@ export class InputHub implements InputService {
|
||||
* exactly one path; a failed first prompt is an ordinary prompt failure
|
||||
* (error strip via promptError, draft restored only while untouched).
|
||||
*/
|
||||
private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void {
|
||||
private sink(session: SessionFace, text: string, mode: InputSubmitMode): void {
|
||||
if (text === '') return
|
||||
const shell = this.shells.get(session.sessionId)
|
||||
// Commit, not an editable clear: undo must not resurrect sent content.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* paste-upgrade all answer their bail events this way).
|
||||
*/
|
||||
import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { InputSubmitMode } from '../contract/composer-submission.ts'
|
||||
import type {
|
||||
ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, InputMachineOptions,
|
||||
InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt,
|
||||
@@ -112,7 +113,6 @@ export class InputMachine {
|
||||
private inflight: {
|
||||
readonly attempt: SubmitAttempt
|
||||
readonly controller: AbortController
|
||||
readonly mode: 'queue' | 'steer'
|
||||
} | undefined
|
||||
private log: Transaction[] = []
|
||||
private redoStack: Transaction[] = []
|
||||
@@ -150,7 +150,6 @@ export class InputMachine {
|
||||
dispatch(ev: InputEvent): readonly InputEffect[] {
|
||||
switch (ev.type) {
|
||||
case 'draft-changed': return this.onDraftChanged(ev.draft, ev.editRange)
|
||||
case 'newline': return this.onNewline(ev.selection)
|
||||
case 'begin-command': return this.onBeginCommand(ev.claim, ev.span)
|
||||
case 'insert-ref': return this.onInsertRef(ev.reference, ev.span)
|
||||
case 'consume-token': return this.onConsumeToken(ev.guard)
|
||||
@@ -255,19 +254,6 @@ export class InputMachine {
|
||||
return []
|
||||
}
|
||||
|
||||
/** F1: caret newline as an ordinary machine transaction (execCommand path removed). */
|
||||
private onNewline(selection: EditSelection): InputEffect[] {
|
||||
const { start, end } = selection
|
||||
if (start < 0 || start > end || end > this.draft.length) return []
|
||||
this.pushTxn(selection)
|
||||
this.typingRun = undefined
|
||||
this.reconcile({ start, end, insertedLength: 1 })
|
||||
this.adopt(this.draft.slice(0, start) + '\n' + this.draft.slice(end))
|
||||
this.watchClaim()
|
||||
this.paste = undefined
|
||||
return []
|
||||
}
|
||||
|
||||
/** Span CAS: revision equality (content identity follows) plus bounds sanity. */
|
||||
private casOk(span: TokenSpan): boolean {
|
||||
return span.draftRev === this.draftRev
|
||||
@@ -462,15 +448,15 @@ export class InputMachine {
|
||||
// ---- submit plane ----
|
||||
|
||||
/** Mint the next SubmitAttempt and take the in-flight slot. */
|
||||
private beginAttempt(mode: 'queue' | 'steer'): SubmitAttempt {
|
||||
private beginAttempt(mode: InputSubmitMode): SubmitAttempt {
|
||||
const controller = new AbortController()
|
||||
this.seq += 1
|
||||
const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft }
|
||||
this.inflight = { attempt, controller, mode }
|
||||
const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft, mode }
|
||||
this.inflight = { attempt, controller }
|
||||
return attempt
|
||||
}
|
||||
|
||||
private onEnter(mode: 'queue' | 'steer'): InputEffect[] {
|
||||
private onEnter(mode: InputSubmitMode): InputEffect[] {
|
||||
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
|
||||
if (this.phase === 'claimed' && this.claim !== undefined) {
|
||||
const attempt = this.beginAttempt(mode)
|
||||
@@ -507,7 +493,7 @@ export class InputMachine {
|
||||
this.inflight = undefined
|
||||
this.phase = 'plain'
|
||||
return outcome === undefined
|
||||
? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }]
|
||||
? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: attempt.mode }]
|
||||
: []
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Browser-local Composer submission policy. It owns the persisted busy-Enter
|
||||
* preference and resolves keyboard gestures into queue/steer delivery modes;
|
||||
* Host and Agent keep the actual delivery-window authority.
|
||||
*/
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode,
|
||||
} from '../contract/composer-submission.ts'
|
||||
|
||||
/** localStorage key holding the busy-Enter preference. */
|
||||
export const BUSY_ENTER_STORAGE_KEY = 'dsh.conversation.busyEnter'
|
||||
|
||||
/** Default preserves Enter-as-Queue for running conversations. */
|
||||
export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue'
|
||||
|
||||
/**
|
||||
* Persisted policy used by both the composer inject face and its Settings row.
|
||||
* Direct `steer` is intentionally best-effort: AgentLoop turns a closed-window
|
||||
* submission into the next waking Queue item.
|
||||
*/
|
||||
export class ComposerSubmissionPolicy {
|
||||
/** Reactive preference source for the Settings row. */
|
||||
readonly busyEnter: SnapshotStore<BusyEnterBehavior> = createSnapshotStore(restoreBusyEnter())
|
||||
|
||||
/**
|
||||
* Resolve one keyboard gesture without changing state.
|
||||
* @param running - whether the addressed agent currently reports busy.
|
||||
* @param gesture - plain Enter or the Cmd/Ctrl-accelerated chord.
|
||||
* @param steeringAvailable - whether this session transport supports steering.
|
||||
* @returns Queue outside steer-capable busy state; otherwise the preferred mode or its opposite.
|
||||
*/
|
||||
resolve(
|
||||
running: boolean,
|
||||
gesture: ComposerSubmitGesture,
|
||||
steeringAvailable: boolean,
|
||||
): InputSubmitMode {
|
||||
if (!running || !steeringAvailable) return 'queue'
|
||||
const preferred = this.busyEnter.getSnapshot()
|
||||
if (gesture === 'enter') return preferred
|
||||
return preferred === 'queue' ? 'steer' : 'queue'
|
||||
}
|
||||
|
||||
/**
|
||||
* Change and persist the plain-Enter behavior used during busy state.
|
||||
* @param behavior - Queue or Steer.
|
||||
*/
|
||||
setBusyEnter(behavior: BusyEnterBehavior): void {
|
||||
if (this.busyEnter.getSnapshot() === behavior) return
|
||||
this.busyEnter.set(behavior)
|
||||
persistBusyEnter(behavior)
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a valid preference; unavailable or corrupt storage uses Queue. */
|
||||
function restoreBusyEnter(): BusyEnterBehavior {
|
||||
if (typeof localStorage === 'undefined') return DEFAULT_BUSY_ENTER_BEHAVIOR
|
||||
let stored: string | null
|
||||
try {
|
||||
stored = localStorage.getItem(BUSY_ENTER_STORAGE_KEY)
|
||||
} catch {
|
||||
// Storage access can fail in privacy modes; the default remains usable.
|
||||
return DEFAULT_BUSY_ENTER_BEHAVIOR
|
||||
}
|
||||
if (stored === 'queue' || stored === 'steer') return stored
|
||||
return DEFAULT_BUSY_ENTER_BEHAVIOR
|
||||
}
|
||||
|
||||
/** Persist a preference when browser storage is available. */
|
||||
function persistBusyEnter(behavior: BusyEnterBehavior): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(BUSY_ENTER_STORAGE_KEY, behavior)
|
||||
} catch {
|
||||
// A storage failure makes the preference session-only; input stays usable.
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ export const zh = {
|
||||
'input.stop': '停止生成',
|
||||
'input.send': '发送消息',
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'settings.enter.title': '繁忙时 Enter 键行为',
|
||||
'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
|
||||
'settings.enter.queue': '排队发送',
|
||||
'settings.enter.steer': '插话发送',
|
||||
'access.confirm.title': '确认启用 Full access?',
|
||||
'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
|
||||
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
@@ -47,12 +51,15 @@ export const zh = {
|
||||
'chat.loadOlder': '加载更早',
|
||||
'chat.toBottom': '回到底部',
|
||||
'message.extraBlock': '附加内容块',
|
||||
'message.steering': '插话',
|
||||
'message.contextInjection': '上下文注入',
|
||||
'message.compaction': '上下文已压缩',
|
||||
'message.compaction.expand': '点击查看压缩摘要',
|
||||
'message.compaction.unavailable': '压缩摘要不可用',
|
||||
'message.unknownSurface': '未知 surface 事件:{type}',
|
||||
'message.unknownBlock': '未知内容块',
|
||||
'message.stopped': '已停止',
|
||||
'message.branch': '在新对话中分支',
|
||||
'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支',
|
||||
'message.retry.active': '正在重试模型请求',
|
||||
'message.retry.cancelled': '模型请求重试已取消',
|
||||
'message.retry.started': '已重试模型请求',
|
||||
@@ -60,6 +67,7 @@ export const zh = {
|
||||
'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s',
|
||||
'message.retry.delay': '重试延迟:',
|
||||
'message.retry.failure': '失败原因:',
|
||||
'message.turnError': '本轮运行失败',
|
||||
'command.running': '执行中…',
|
||||
'command.failed': '命令失败',
|
||||
'command.done': '已完成',
|
||||
@@ -77,14 +85,20 @@ export const zh = {
|
||||
'bash.running': '运行中',
|
||||
'bash.failed': '失败',
|
||||
'bash.stopped': '已停止',
|
||||
'row.running': '运行中',
|
||||
'row.failed': '失败',
|
||||
'row.stopped': '已停止',
|
||||
'queue.count': '{n} 条排队消息',
|
||||
'queue.edit': '编辑排队消息',
|
||||
'queue.edit.unsupported': '包含非文本内容,暂不支持编辑',
|
||||
'queue.save': '保存排队消息',
|
||||
'queue.cancelEdit': '取消编辑',
|
||||
'queue.remove': '删除排队消息',
|
||||
'queue.steer': '插话发送',
|
||||
'queue.steer.unavailable': '仅运行中可插话发送',
|
||||
'queue.editFailed': '编辑失败:这条消息可能已经开始发送。',
|
||||
'queue.removeFailed': '删除失败:这条消息可能已经开始发送。',
|
||||
'queue.steerFailed': '插话发送失败,请重试。',
|
||||
'terminal.signal': '信号 {signal}',
|
||||
'terminal.exitCode': '退出码 {code}',
|
||||
'terminal.running': '运行中',
|
||||
@@ -117,6 +131,10 @@ export const en = {
|
||||
'input.stop': 'Stop generating',
|
||||
'input.send': 'Send message',
|
||||
'input.accessMode': 'Access mode, current: {name}',
|
||||
'settings.enter.title': 'Enter behavior while busy',
|
||||
'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
|
||||
'settings.enter.queue': 'Queue',
|
||||
'settings.enter.steer': 'Steer',
|
||||
'access.confirm.title': 'Enable Full access?',
|
||||
'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
|
||||
'access.confirm.acknowledge': 'I understand the risks and want to continue',
|
||||
@@ -141,12 +159,15 @@ export const en = {
|
||||
'chat.loadOlder': 'Load earlier',
|
||||
'chat.toBottom': 'Back to bottom',
|
||||
'message.extraBlock': 'Extra content block',
|
||||
'message.steering': 'Interjection',
|
||||
'message.contextInjection': 'Context injection',
|
||||
'message.compaction': 'Context compacted',
|
||||
'message.compaction.expand': 'View compaction summary',
|
||||
'message.compaction.unavailable': 'Compaction summary unavailable',
|
||||
'message.unknownSurface': 'Unknown surface event: {type}',
|
||||
'message.unknownBlock': 'Unknown content block',
|
||||
'message.stopped': 'Stopped',
|
||||
'message.branch': 'Branch into a new conversation',
|
||||
'message.branchUnavailable': 'Available only on the last message of a completed turn',
|
||||
'message.retry.active': 'Retrying model request',
|
||||
'message.retry.cancelled': 'Model request retry cancelled',
|
||||
'message.retry.started': 'Retried model request',
|
||||
@@ -154,6 +175,7 @@ export const en = {
|
||||
'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s',
|
||||
'message.retry.delay': 'Retry delay: ',
|
||||
'message.retry.failure': 'Failure reason: ',
|
||||
'message.turnError': 'This turn failed',
|
||||
'command.running': 'Running…',
|
||||
'command.failed': 'Command failed',
|
||||
'command.done': 'Completed',
|
||||
@@ -171,14 +193,20 @@ export const en = {
|
||||
'bash.running': 'Running',
|
||||
'bash.failed': 'Failed',
|
||||
'bash.stopped': 'Stopped',
|
||||
'row.running': 'Running',
|
||||
'row.failed': 'Failed',
|
||||
'row.stopped': 'Stopped',
|
||||
'queue.count': '{n} queued messages',
|
||||
'queue.edit': 'Edit queued message',
|
||||
'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet',
|
||||
'queue.save': 'Save queued message',
|
||||
'queue.cancelEdit': 'Cancel editing',
|
||||
'queue.remove': 'Remove queued message',
|
||||
'queue.steer': 'Steer queued message',
|
||||
'queue.steer.unavailable': 'Steering is available only while the agent is running',
|
||||
'queue.editFailed': 'Edit failed: this message may have already started sending.',
|
||||
'queue.removeFailed': 'Removal failed: this message may have already started sending.',
|
||||
'queue.steerFailed': 'Steering failed. Try again.',
|
||||
'terminal.signal': 'signal {signal}',
|
||||
'terminal.exitCode': 'exit code {code}',
|
||||
'terminal.running': 'Running',
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
// The 'conversation.input.dock' SlotMap declaration lives in
|
||||
// ../contract/slots.ts beside the other input-region slots.
|
||||
import type { Context } from 'cordis'
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
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, IconTrashOutline16,
|
||||
IconCloseOutline16, IconEditOutline16, IconSendOutline16, IconTrashOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
|
||||
import { NS } from '../locales.ts'
|
||||
@@ -29,7 +29,10 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock
|
||||
* collapsible count header; an empty queue renders nothing.
|
||||
*/
|
||||
export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) {
|
||||
const queue = useSession(s => s.queue)
|
||||
const inbox = useSession(s => s.queue)
|
||||
const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox])
|
||||
const running = useSession(s => s.running)
|
||||
const queueMutable = useSession(s => s.subagent === null)
|
||||
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
|
||||
const [busy, setBusy] = useState<QueueItemId | null>(null)
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
@@ -37,12 +40,12 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
||||
|
||||
useEffect(() => {
|
||||
if (queue.length === 0 && !collapsed) setCollapsed(true)
|
||||
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
|
||||
}, [collapsed, editing, queue])
|
||||
if (editing !== null && (!queueMutable || !queue.some(row => row.id === editing.id))) setEditing(null)
|
||||
}, [collapsed, editing, queue, queueMutable])
|
||||
|
||||
if (queue.length === 0) return null
|
||||
|
||||
const interactionActive = editing !== null || busy !== null
|
||||
const interactionActive = queueMutable && (editing !== null || busy !== null)
|
||||
const expanded = !collapsed || interactionActive
|
||||
const listVisible = queue.length === 1 || expanded
|
||||
|
||||
@@ -114,7 +117,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
||||
/>
|
||||
)
|
||||
: <span className={css.preview}>{row.preview}</span>}
|
||||
<div className={css.actions}>
|
||||
{queueMutable && <div className={css.actions}>
|
||||
{editing?.id === row.id
|
||||
? (
|
||||
<>
|
||||
@@ -170,9 +173,25 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Queue read face for the InputState.queue projection (frozen contract in
|
||||
* ../input/contract.ts): a uSES-compatible observable over one session's
|
||||
* queue rows. The Session snapshot already keeps the queue array
|
||||
* transient inbox rows. The Session snapshot already keeps the queue array
|
||||
* reference-stable across unrelated snapshot swaps, so this is a pure
|
||||
* projection — no second store, no copy.
|
||||
*/
|
||||
@@ -9,7 +9,7 @@ import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-ru
|
||||
import type { QueuedMessage } from '../input/contract.ts'
|
||||
|
||||
/**
|
||||
* Project a session's queue rows as a bare observable (subscribe/getSnapshot).
|
||||
* Project a session's transient inbox rows as a bare observable (subscribe/getSnapshot).
|
||||
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
|
||||
* QueuedMessage and the input-contract QueuedMessage are structurally
|
||||
* identical.
|
||||
|
||||
@@ -25,21 +25,20 @@ export interface IConversation {
|
||||
/** The per-session input machine registry (InputService face). */
|
||||
readonly input: InputService
|
||||
/**
|
||||
* Send a prompt into the caller scope's session.
|
||||
* Send a prompt into the caller scope's session (queued turn).
|
||||
* @param text - prompt text, sent verbatim as one text block.
|
||||
* @param mode - queue after the current turn, or steer into it.
|
||||
* @returns completion; business failures reject (and land in promptError).
|
||||
*/
|
||||
send(text: string, mode: 'queue' | 'steer'): Promise<void>
|
||||
send(text: string): Promise<void>
|
||||
/**
|
||||
* Apply one operation to a pending queue occurrence.
|
||||
* Apply one edit, remove, or strict steer operation to a pending queue occurrence.
|
||||
* @param itemId - agent-owned inbox occurrence identity.
|
||||
* @param action - edit or remove operation.
|
||||
* @returns completion; business failures reject.
|
||||
* @param action - requested queue operation.
|
||||
* @returns completion; converged strict-steer races resolve, while other failures reject.
|
||||
*/
|
||||
updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
|
||||
/**
|
||||
* Cancel the scoped session's in-flight turn.
|
||||
* Cancel the scoped session's in-flight turn while preserving its pending Queue.
|
||||
* @returns completion; failures reject as in send.
|
||||
*/
|
||||
cancel(): Promise<void>
|
||||
@@ -71,11 +70,10 @@ export class ConversationService extends Service implements IConversation {
|
||||
* session snapshot's promptError (object-layer surface); the rejection here
|
||||
* exists for caller choreography (the composer restores the draft on it).
|
||||
* @param text - prompt text, sent verbatim as one text block.
|
||||
* @param mode - queue after the current turn, or steer into it.
|
||||
*/
|
||||
async send(text: string, mode: 'queue' | 'steer'): Promise<void> {
|
||||
async send(text: string): Promise<void> {
|
||||
const session = this.scopedSession('send')
|
||||
const result = await session.prompt([{ type: 'text', text }], mode)
|
||||
const result = await session.prompt([{ type: 'text', text }], 'queue')
|
||||
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
@@ -84,11 +82,15 @@ export class ConversationService extends Service implements IConversation {
|
||||
const session = this.scopedSession('updateQueue')
|
||||
const result = await session.updateQueue(itemId, action)
|
||||
if (!result.ok) {
|
||||
if (
|
||||
action.kind === 'steer'
|
||||
&& (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found')
|
||||
) return
|
||||
throw new Error(`conversation.updateQueue failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */
|
||||
/** Cancel the scoped session's in-flight turn while preserving Queue (failures land in promptError and reject, as in send). */
|
||||
async cancel(): Promise<void> {
|
||||
const session = this.scopedSession('cancel')
|
||||
const result = await session.cancel()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* Composer Enter preference row: title/description plus selector pill. */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.rowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/** General Settings row for the Composer's busy-state Enter preference. */
|
||||
import { useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { BusyEnterBehavior } from '../contract/composer-submission.ts'
|
||||
import type { ConversationKey } from '../locales.ts'
|
||||
import css from './EnterBehaviorRow.module.css'
|
||||
|
||||
/** Registration-side preference face. */
|
||||
export interface EnterBehaviorRowInjected {
|
||||
hooks: {
|
||||
/** Persisted busy-state preference bound as useBusyEnter. */
|
||||
busyEnter: SnapshotStore<BusyEnterBehavior>
|
||||
}
|
||||
/** Change the busy-state plain-Enter behavior. */
|
||||
setBusyEnter: (behavior: BusyEnterBehavior) => void
|
||||
}
|
||||
|
||||
/** Full Settings-row props. */
|
||||
export type EnterBehaviorRowProps =
|
||||
PropsRuntime<'settings.general.item'>
|
||||
& PropsLocale<'conversation'>
|
||||
& InjectFace<EnterBehaviorRowInjected>
|
||||
|
||||
const OPTIONS: readonly {
|
||||
id: BusyEnterBehavior
|
||||
label: ConversationKey
|
||||
}[] = [
|
||||
{ id: 'queue', label: 'settings.enter.queue' },
|
||||
{ id: 'steer', label: 'settings.enter.steer' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Render the busy-state Enter behavior selector.
|
||||
* @param props - composed Settings slot props.
|
||||
* @returns the preference row.
|
||||
*/
|
||||
export function EnterBehaviorRow({ useBusyEnter, setBusyEnter, t }: EnterBehaviorRowProps) {
|
||||
const behavior = useBusyEnter(value => value)
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedLabel = behavior === 'queue' ? 'settings.enter.queue' : 'settings.enter.steer'
|
||||
|
||||
return (
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('settings.enter.title')}</div>
|
||||
<div className={css.desc}>{t('settings.enter.description')}</div>
|
||||
</div>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={OPTIONS.map(option => ({ id: option.id, label: t(option.label) }))}
|
||||
selectedId={behavior}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
setBusyEnter(id as BusyEnterBehavior)
|
||||
}}
|
||||
align="end"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selector}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
{t(selectedLabel)}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Conversation column skeleton: header (breadcrumb row + tabs) over the view
|
||||
/* Conversation column skeleton: header (breadcrumb row only for subagents not fork + tabs) over the view
|
||||
area, composer InputBar at the bottom. Column width/squeeze is layout's;
|
||||
this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with
|
||||
a 3px active bar. */
|
||||
@@ -23,13 +23,12 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.crumbRow {
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -78,6 +77,13 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
|
||||
.tabs {
|
||||
display: flex;
|
||||
@@ -202,6 +208,26 @@
|
||||
);
|
||||
}
|
||||
|
||||
/* Views may opt into a composer overlay while ConversationRoot retains
|
||||
ownership of the seat geometry and its active-phase precedence. */
|
||||
.scrollBody:has([data-conversation-composer-overlay]) {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollBody:has([data-conversation-composer-overlay]) > .viewArea {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollBody:has([data-conversation-composer-overlay]) > .composerSeat {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Hero phase: the composer stack (hero chrome + workspace row + card) is
|
||||
flex-centered in the column; composer phase docks it at the bottom. Flex,
|
||||
NOT absolute+transform: a transform would make this box the containing
|
||||
|
||||
@@ -22,6 +22,7 @@ export function ConversationRoot({
|
||||
const session = useSession(s => s)
|
||||
const inputState = useInput(s => s)
|
||||
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
|
||||
const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank)
|
||||
const workspaces = useWorkspaces(s => s)
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
@@ -65,8 +66,16 @@ export function ConversationRoot({
|
||||
// While a session is still replaying (loading + blank) the hero/docked
|
||||
// choice is unknowable — render the composer hidden instead of flashing
|
||||
// the centered hero and snapping to the docked bar (or vice versa).
|
||||
// Exemption: a session the list summary already proves blank can only
|
||||
// land on the hero, so hiding would blank the column for the whole
|
||||
// history round-trip (the startup auto-selection flash) for nothing.
|
||||
// The exemption is deliberately open-state-wide, not loading-only: a
|
||||
// summary-blank session is the hero before its open starts (`cold`) and
|
||||
// after one fails (`error`) for the same reason — there is no history.
|
||||
const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading'
|
||||
const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open')
|
||||
&& summaryBlank !== true
|
||||
const hero = sessionId === undefined
|
||||
|| (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))
|
||||
const zone: InputZone | undefined =
|
||||
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
|
||||
|
||||
@@ -144,7 +153,7 @@ export function ConversationRoot({
|
||||
const phase = settling ? 'settling' : hero ? 'hero' : 'active'
|
||||
const composer = renderSlotChain(
|
||||
'conversation.composer',
|
||||
{ interactions: pending },
|
||||
{ interactions: pending, session },
|
||||
{ fallback: composerBar, overlay: true },
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSessionSlotProps } from '../contract/slots.ts'
|
||||
import css from './ConversationRoot.module.css'
|
||||
@@ -10,18 +9,35 @@ import css from './ConversationRoot.module.css'
|
||||
/** Full props composed from the strict session slot contract. */
|
||||
export type ConversationSessionProps = ConversationSessionSlotProps
|
||||
|
||||
function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] {
|
||||
const chain: SessionSummary[] = []
|
||||
interface Breadcrumb {
|
||||
readonly id: SessionId
|
||||
readonly displayTitle: string
|
||||
}
|
||||
|
||||
function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] {
|
||||
const chain: Breadcrumb[] = []
|
||||
const seen = new Set<SessionId>()
|
||||
let cursor: SessionId | undefined = id
|
||||
while (cursor !== undefined) {
|
||||
if (seen.has(cursor)) break
|
||||
seen.add(cursor)
|
||||
const summary: SessionSummary | undefined = list.byId[cursor]
|
||||
if (summary === undefined || chain.includes(summary)) break
|
||||
chain.unshift(summary)
|
||||
if (summary === undefined) break
|
||||
chain.unshift({ id: summary.id, displayTitle: summary.displayTitle })
|
||||
if (summary.origin !== 'subagent') break
|
||||
cursor = summary.parentId
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrumb[]): boolean {
|
||||
return left.length === right.length
|
||||
&& left.every((item, index) => {
|
||||
const other = right.at(index)
|
||||
return other !== undefined && item.id === other.id && item.displayTitle === other.displayTitle
|
||||
})
|
||||
}
|
||||
|
||||
export function ConversationSession({
|
||||
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
|
||||
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
|
||||
@@ -30,7 +46,7 @@ export function ConversationSession({
|
||||
const tabs = views.list()
|
||||
const activeId = useStore(s => s.view) ?? 'chat'
|
||||
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
|
||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs)
|
||||
const composerPhase = useSession(s => s.composerPhase)
|
||||
const blank = useSession(s => s.blank)
|
||||
const inputState = useInput(s => s)
|
||||
@@ -69,7 +85,7 @@ export function ConversationSession({
|
||||
>
|
||||
{!hideChrome && (
|
||||
<>
|
||||
<div className={css.crumbRow}>
|
||||
<div className={css.titleRow}>
|
||||
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
|
||||
{ancestry.map((summary, index) => {
|
||||
const last = index === ancestry.length - 1
|
||||
@@ -89,6 +105,9 @@ export function ConversationSession({
|
||||
})}
|
||||
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
||||
</nav>
|
||||
<div className={css.headerActions}>
|
||||
{renderSlot('conversation.session.header.actions', {})}
|
||||
</div>
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className={css.tabs} role="tablist">
|
||||
|
||||
@@ -101,15 +101,27 @@
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* A card body (terminal or diff) sits directly under its section label, so it
|
||||
drops the primitive's standalone vertical margin; the section owns the
|
||||
spacing. Card-neutral: no terminal- or diff-specific value. */
|
||||
/* A card body (terminal, diff, or search) sits directly under its section
|
||||
label, so it drops the primitive's standalone vertical margin; the section
|
||||
owns the spacing. Card-neutral: no card-kind-specific value. */
|
||||
.cardBody {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Same rule for the web card: it sits under the section label, so the section
|
||||
owns the spacing rather than the primitive's own vertical margin. */
|
||||
/* The recovery footer for a capped search: the result text (its `Full … stored
|
||||
at …` locator) below the card in the muted tone, since the card holds only the
|
||||
retained rows. */
|
||||
.searchRecovery {
|
||||
margin: 6px 0 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The read and web cards sit directly under their section label, same as the
|
||||
terminal card: drop the primitive's standalone vertical margin. */
|
||||
.read,
|
||||
.web {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
// session snapshot — no data of its own.
|
||||
|
||||
import { CodeBlock, DiffBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, DiffBlock, ReadBlock, SearchBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { readCardModel } from '../contract/read-card-model.ts'
|
||||
import { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { searchCardModel } from '../contract/search-card-model.ts'
|
||||
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { webCardModel } from '../contract/web-card-model.ts'
|
||||
import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
@@ -129,11 +131,16 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
|
||||
* The Output section's body for the selected call. A terminal-card call — a
|
||||
* shell command's call/result views — renders through the shared TerminalBlock
|
||||
* at the primitive's own full height allowance, so column-aligned output keeps
|
||||
* its alignment and scrolls sideways instead of folding. A diff-card call — a
|
||||
* its alignment and scrolls sideways instead of folding. A read-card call
|
||||
* renders through the shared ReadBlock at that same full height, so the whole
|
||||
* returned window is line-numbered and highlighted. A diff-card call — a
|
||||
* write/edit's applied change — renders through the shared DiffBlock at the same
|
||||
* full height. A web-card call — a `web_search`/`web_fetch` result — renders
|
||||
* through WebBlock at its own full source-list allowance. Every other call, and
|
||||
* a running call with no card yet, keeps the flattened text form.
|
||||
* full height. A search-card call — a `grep`/`glob` result view — renders
|
||||
* through the shared SearchBlock at the same full height allowance, with a
|
||||
* capped search's recovery footer below it. A web-card call — a
|
||||
* `web_search`/`web_fetch` result — renders through WebBlock at its own full
|
||||
* source-list allowance. Every other call, and a running call with no card yet,
|
||||
* keeps the flattened text form.
|
||||
* @param props.material - the selected call's material from {@link materialFor}.
|
||||
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
|
||||
* @param props.t - the panel's locale seat, passed down as a plain prop.
|
||||
@@ -153,16 +160,32 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
|
||||
</>
|
||||
)
|
||||
}
|
||||
const read = readCardModel(material.block, cwd)
|
||||
// The panel takes the primitive's own default cap, not the row's tighter one:
|
||||
// it is the single-call reading surface, so the whole window is available.
|
||||
if (read !== null) return <ReadBlock {...read} className={css.read} />
|
||||
const diff = diffCardModel(material.block)
|
||||
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
|
||||
const search = searchCardModel(material.block)
|
||||
if (search !== null) {
|
||||
return (
|
||||
<>
|
||||
<SearchBlock {...search.card} className={css.cardBody} />
|
||||
{/* A capped search's recovery locator lives only in the result text;
|
||||
show it below the card so the dropped rows stay reachable. */}
|
||||
{search.recovery !== undefined && (
|
||||
<div className={css.searchRecovery}>{search.recovery}</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
const web = webCardModel(material.block)
|
||||
// Full source-list allowance here (the panel is the single-call reading
|
||||
// surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the
|
||||
// panel also renders the flattened result content — the model-visible text
|
||||
// the card does not carry verbatim (a web_fetch card shows only the URL and
|
||||
// status, so its fetched body lives only here; a search card's answer and
|
||||
// sources are structured, so the flattened form repeats them as the raw text
|
||||
// the model saw).
|
||||
// The card shows every source the tool returned (the same list the model saw),
|
||||
// scrolling within its own capped height. Below the card the panel also renders
|
||||
// the flattened result content — the model-visible text the card does not carry
|
||||
// verbatim (a web_fetch card shows only the URL and status, so its fetched body
|
||||
// lives only here; a search card's answer and sources are structured, so the
|
||||
// flattened form repeats them as the raw text the model saw).
|
||||
if (web !== null) {
|
||||
const settled = 'kind' in material.block ? material.block : null
|
||||
const body = settled === null ? '' : resultText(settled)
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface InputBarError {
|
||||
export type InputBarProps = ComposerBarProps
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, toggleCommandMenu, stop, command, t,
|
||||
useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t,
|
||||
renderSlot, useNotices, useLexicon, useMenuLauncher,
|
||||
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
|
||||
}: InputBarProps) {
|
||||
@@ -44,6 +44,7 @@ export function InputBar({
|
||||
const commandMenuOpen = useMenuLauncher(source => source === 'command')
|
||||
const promptError = useSession(s => s.promptError) ?? null
|
||||
const running = useSession(s => s.running) ?? false
|
||||
const subagent = useSession(s => s.subagent) ?? null
|
||||
const removed = useSession(s => s.removed) ?? false
|
||||
// Plan mode swaps the textarea placeholder (the projection is the folded
|
||||
// host value; owner-prop placeholders — hero, session-unavailable — win).
|
||||
@@ -150,12 +151,12 @@ export function InputBar({
|
||||
}, [locked, sessionId, draft !== ''])
|
||||
|
||||
// Caret restore after an edit the composer performs itself. The machine owns
|
||||
// the draft and the undo log, so paste, ctrl/meta-Enter newline and cut all
|
||||
// suppress the native edit and write the value through the machine — and a
|
||||
// the draft and the undo log, so paste and cut suppress the native edit and
|
||||
// write the value through the machine — and a
|
||||
// programmatic selection change reveals nothing: measured in chromium and
|
||||
// WebKit, pasting a long block leaves the view where it was while the caret
|
||||
// sits at the end of the draft. Native typing gets its reveal from the
|
||||
// browser; these three have to ask for it, so they share one restore.
|
||||
// browser; these two have to ask for it, so they share one restore.
|
||||
const restoreCaret = (el: HTMLTextAreaElement, caret: number): void => {
|
||||
requestAnimationFrame(() => {
|
||||
el.setSelectionRange(caret, caret)
|
||||
@@ -230,22 +231,14 @@ export function InputBar({
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Newline as a machine transaction (the machine owns undo history; an
|
||||
// execCommand write would fork a second, browser-owned history).
|
||||
e.preventDefault()
|
||||
if (!machineBusy && !locked) {
|
||||
const el = e.currentTarget
|
||||
const sel = selectionOf(el)
|
||||
keyboard.newline(sel)
|
||||
restoreCaret(el, sel.start + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
if (e.repeat) return // held-down Enter must not machine-gun sends
|
||||
if (locked || machineBusy) return
|
||||
inputActions.submit('queue')
|
||||
keyboard.submit(resolveSubmitMode(
|
||||
running,
|
||||
e.ctrlKey || e.metaKey ? 'accelerated' : 'enter',
|
||||
subagent === null,
|
||||
))
|
||||
}
|
||||
|
||||
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
|
||||
@@ -338,15 +331,17 @@ export function InputBar({
|
||||
if (el !== null) toggleCommandMenu?.(selectionOf(el))
|
||||
}
|
||||
|
||||
const primaryLabel = running ? t('input.stop') : t('input.send')
|
||||
const ordinary = subagent === null
|
||||
const stopping = running && ordinary
|
||||
const primaryLabel = stopping ? t('input.stop') : t('input.send')
|
||||
const onPrimary = (): void => {
|
||||
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
|
||||
if (running) {
|
||||
stop()
|
||||
if (stopping) {
|
||||
stop?.()
|
||||
return
|
||||
}
|
||||
if (inputActions === undefined) return // absent machine: the button is disabled
|
||||
/* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
|
||||
if (!empty && !disabled && !machineBusy) inputActions.submit('queue')
|
||||
if (!empty && !disabled && !machineBusy) inputActions.submit()
|
||||
}
|
||||
|
||||
// The Access seat: the projection-fed permission chip (renders nothing
|
||||
@@ -510,11 +505,11 @@ export function InputBar({
|
||||
className={css.primary}
|
||||
aria-label={primaryLabel}
|
||||
title={primaryLabel}
|
||||
disabled={!running && (empty || disabled || machineBusy)}
|
||||
disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onPrimary}
|
||||
>
|
||||
{running ? (
|
||||
{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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Todo strip in the composer context stack (Figma 9:959): tip surface,
|
||||
14px radius, status icons + secondary item labels. It shares the composer
|
||||
card geometry and adds the dock inset on both sides. */
|
||||
/* 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. */
|
||||
|
||||
.root {
|
||||
box-sizing: border-box;
|
||||
@@ -12,11 +12,15 @@
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
max-width: calc(
|
||||
var(--dsh-composer-card-max-width) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
|
||||
@@ -138,10 +138,10 @@ export const todoDockEntry = {
|
||||
name: 'conversation-todo-dock',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* Register the plan strip between the goal and queue entries (order 10).
|
||||
* 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: 10, locale: NS }, TodoDock)
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,6 +20,63 @@
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
/* A bash execution error can settle without terminal-card material (for
|
||||
example, command cancellation). Preserve ToolRow's bounded IN/OUT fallback
|
||||
so the original command and full error remain available from this keyed row. */
|
||||
.ioCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 4px 0 4px 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
.ioSection {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 14px;
|
||||
align-items: baseline;
|
||||
padding: 12px 16px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ioSection::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ioSection::-webkit-scrollbar-track {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.ioLabel {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.ioDivider {
|
||||
flex: none;
|
||||
height: 1px;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.ioText {
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.ioText[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* ToolRow's unified expand interaction, replicated per the registrant
|
||||
posture: pointer on the expandable row (the icon→chevron hover preview is
|
||||
the affordance, no row fill). */
|
||||
@@ -99,17 +156,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.scopeBadge {
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
padding: 0 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Bash toolview registrant: third-party posture over the keyed toolview hole
|
||||
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
|
||||
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
|
||||
// Child sessions keep a scoped badge so session-dimension differentiation stays
|
||||
// observable inside the component (no parallel registry).
|
||||
//
|
||||
// A bash call declares the terminal render intent, so this row renders the
|
||||
// command's own output through TerminalBlock — expand-gated exactly like
|
||||
// A bash call normally declares the terminal render intent, so this row renders
|
||||
// the command's own output through TerminalBlock. Execution failures that
|
||||
// settle without terminal material use the bounded generic IN/OUT fallback —
|
||||
// both are expand-gated exactly like
|
||||
// ToolRow's unified interaction: collapsed by default, the whole summary row
|
||||
// is the toggle (click / Enter / Space, icon→chevron hover preview; the
|
||||
// summary stays inline while open),
|
||||
@@ -50,7 +50,7 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
|
||||
|
||||
/**
|
||||
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, the
|
||||
* whole row toggling the command's terminal card (ToolRow's unified
|
||||
* whole row toggling the command's terminal or generic error card (ToolRow's unified
|
||||
* expand interaction, replicated locally per the registrant posture).
|
||||
*/
|
||||
export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) {
|
||||
@@ -64,10 +64,15 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
||||
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
|
||||
? 'error'
|
||||
: model.state
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
const status = stateStatus(state, t)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandable = terminal !== null
|
||||
// Execution failures (for example cancellation before the process reports a
|
||||
// terminal result) use the generic presenter. Keep their recorded args and
|
||||
// full error reachable instead of collapsing the row to the first line.
|
||||
const genericError = terminal === null
|
||||
&& model.state === 'error'
|
||||
&& (model.body !== null || model.output !== null)
|
||||
const expandable = terminal !== null || genericError
|
||||
const open = expanded && expandable
|
||||
const failureLine = model.state === 'error' ? model.errorSummary : null
|
||||
const toggleExpand = () => {
|
||||
@@ -92,7 +97,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
||||
<div className={css.card}>
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-sample="bash"
|
||||
data-variant="bash"
|
||||
data-state={state}
|
||||
data-expandable={expandable || undefined}
|
||||
@@ -104,7 +109,6 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
||||
>
|
||||
<span className={css.leading}>{leading}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{/* The terminal presenter's description is the contractual
|
||||
@@ -113,16 +117,40 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
||||
{failureLine ?? terminal?.description ?? model.summary}
|
||||
</span>
|
||||
</div>
|
||||
{terminal !== null && open && (
|
||||
{open && (
|
||||
/* Same hover-Inspect posture as ToolRow's expanded body, replicated
|
||||
locally per the registrant posture. */
|
||||
<div className={css.bodyWrap}>
|
||||
<TerminalBlock
|
||||
{...terminal.card}
|
||||
maxLines={Infinity}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminal}
|
||||
/>
|
||||
{terminal !== null
|
||||
? (
|
||||
<TerminalBlock
|
||||
{...terminal.card}
|
||||
maxLines={Infinity}
|
||||
labels={terminalBlockLabels(t)}
|
||||
className={css.terminal}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={css.ioCard}>
|
||||
{model.body !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>IN</span>
|
||||
<span className={css.ioText}>{model.body}</span>
|
||||
</div>
|
||||
)}
|
||||
{model.body !== null && model.output !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{model.output !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>OUT</span>
|
||||
<span className={css.ioText} data-error>
|
||||
{model.output}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{inspect !== undefined && (
|
||||
<button type="button" className={css.inspectButton} onClick={inspect}>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
/* File-mutation toolview: same geometry/tokens as ToolRow (figma
|
||||
{Edit,Write} · path), plus the diff card the row stacks under its summary
|
||||
line. Mirrors bash-sample.module.css, whose terminal card this replaces with
|
||||
a diff card. */
|
||||
|
||||
/* Summary line over the diff card; the summary row keeps its own 24px height,
|
||||
so the card is a column around it rather than a change to it. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.diff {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
|
||||
.root[data-state='running']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-file-mutation-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-file-mutation-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* File-tool path: same geometry as .summary; hover underline + pointer. */
|
||||
.fileLink {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The result text for an errored mutation, indented to the card's own column
|
||||
(the diff card's inset) and in the error tone, since it stands in for the diff
|
||||
card the failure path does not produce. */
|
||||
.failure {
|
||||
margin: 4px 0 4px 22px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
@@ -1,104 +1,54 @@
|
||||
// File-mutation toolview registrant: third-party posture over the keyed
|
||||
// toolview hole (ctx.slots.register + ToolRowProps only — never imports the
|
||||
// chat domain), registered under both `edit` and `write`. Product chrome
|
||||
// matches ToolRow (figma: {Edit,Write} · {path}).
|
||||
//
|
||||
// A write/edit call declares the diff render intent, so this row renders the
|
||||
// applied change through DiffBlock resident below its summary line — the same
|
||||
// posture BashRow gives a terminal card. The row has no expand control and is
|
||||
// not a details-panel target (tool rows stopped being one), so the diff body
|
||||
// is resident rather than expand-gated, and the card's own copy and expand
|
||||
// controls are the row's only interactions. CHAT_DIFF_MAX_LINES caps the body
|
||||
// against the message flow; the details panel keeps the block's full default.
|
||||
// The summary stays a path link (the file-tool interaction) that opens through
|
||||
// the host.
|
||||
// File-mutation toolview registrant: the keyed toolview hole for the `edit`
|
||||
// and `write` tools. The row composes the shared ToolRow (chrome, running
|
||||
// sweep, whole-row expand) and feeds it the applied diff as ToolRow's `diff`
|
||||
// card material, so the change renders through DiffBlock in the collapsed-by-
|
||||
// default expanded body — the same unified interaction every other card row
|
||||
// has. The summary stays a path link (the file-tool interaction) that opens
|
||||
// through the host; an errored mutation (write/edit return no diff on
|
||||
// `result.isError`) keeps the model-facing error text on ToolRow's Output
|
||||
// section, its first line in the collapsed summary.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { DiffBlock, IconEditOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconEditOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './file-mutation-row.module.css'
|
||||
import { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
function leadingFor(state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
// Running keeps the icon — the row sweep carries the in-flight signal.
|
||||
default: return <IconEditOutline16 size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
|
||||
function stateStatus(state: ToolRowState): string | null {
|
||||
switch (state) {
|
||||
case 'running': return '运行中'
|
||||
case 'error': return '失败'
|
||||
case 'stopped': return '已停止'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A settled result's text, flattened from its content blocks, for the arm that
|
||||
* shows a failure the diff card cannot: write/edit return `undefined` from
|
||||
* `presentResult` on `result.isError`, so an errored mutation has no diff card,
|
||||
* and the keyed row is not a details-panel target. Without this the failure —
|
||||
* an `old_string` that did not match, a permission denial — would read as a bare
|
||||
* red dot with the model-facing error text nowhere on screen.
|
||||
* @param block - the frozen call slice.
|
||||
* @returns the result text, or null for a running call or an empty result.
|
||||
*/
|
||||
function errorText(block: ToolRowProps['block']): string | null {
|
||||
if (!('kind' in block)) return null
|
||||
const parts: string[] = []
|
||||
for (const item of block.content) {
|
||||
if (item.type === 'text') parts.push(item.text)
|
||||
}
|
||||
if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`)
|
||||
const text = parts.join('\n')
|
||||
return text === '' ? null : text
|
||||
}
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type FileMutationRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
* File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome,
|
||||
* with the applied diff resident below it. The summary is a path link (a file
|
||||
* tool's interaction); the host's `openFile` resolves it against the session
|
||||
* cwd, so this passes the tool's own path verbatim. The card's copy and expand
|
||||
* controls are the row's only other actions.
|
||||
* with the applied diff as the row's collapsed-by-default card body. The
|
||||
* summary is a path link (a file tool's interaction); the host's `openFile`
|
||||
* resolves it against the session cwd, so this passes the tool's own path
|
||||
* verbatim. An errored mutation has no diff card, so ToolRow surfaces the
|
||||
* model-facing error text through its Output section and its first line in the
|
||||
* collapsed summary instead.
|
||||
*/
|
||||
export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) {
|
||||
export function FileMutationRow({ toolName, block, cwd, openFile, inspect, t }: FileMutationRowProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const diff = diffCardModel(block)
|
||||
const status = stateStatus(model.state)
|
||||
const filePath = model.filePath
|
||||
// An errored mutation has no diff card (presentResult returns undefined on
|
||||
// isError); surface its result text so the failure is more than a red dot.
|
||||
const failure = diff === null && model.state === 'error' ? errorText(block) : null
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div className={css.root} data-variant={model.variant} data-state={model.state}>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{filePath !== undefined ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileLink}
|
||||
onClick={() => { openFile(filePath) }}
|
||||
>
|
||||
{model.summary}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
)}
|
||||
</div>
|
||||
{diff !== null && (
|
||||
<DiffBlock {...diff.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diff} />
|
||||
)}
|
||||
{failure !== null && <div className={css.failure}>{failure}</div>}
|
||||
</div>
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconEditOutline16 size={14} />}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
diff={diff}
|
||||
state={model.state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={openFile}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,7 +67,7 @@ export const fileMutationToolview = {
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit' }, FileMutationRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write' }, FileMutationRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Read toolview registrant: the keyed toolview hole for the read tool. The row
|
||||
// composes the shared ToolRow (chrome, running sweep, whole-row expand) and
|
||||
// feeds it the file's line-numbered, syntax-highlighted content as ToolRow's
|
||||
// `read` card material, so it renders through ReadBlock in the collapsed-by-
|
||||
// default expanded body — the same unified interaction every other card row
|
||||
// has. The summary path is an openable host link. A running read (no result
|
||||
// yet) and a non-read result render the summary row alone: the read intent is
|
||||
// result-side only, so there is no running-state read card to draw.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { readCardModel } from '../contract/read-card-model.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type ReadRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
* Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's
|
||||
* read card as the row's collapsed-by-default card body. The summary path is an
|
||||
* openable host link when the row names a single file.
|
||||
*/
|
||||
export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const read = readCardModel(block, cwd)
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconBrowseOutline16 size={14} />}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
read={read}
|
||||
state={model.state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={openFile}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const readToolview = {
|
||||
name: 'read-toolview',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* 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)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Search toolview registrant: the keyed toolview hole for the `grep` and `glob`
|
||||
// tools. One SearchRow component registered under both, since both declare the
|
||||
// same `card: 'search'` render intent and render as one visual object; the
|
||||
// derived model's `kind` decides the card shape (grouped matches or a path
|
||||
// list). The row composes the shared ToolRow (chrome, running sweep, whole-row
|
||||
// expand) and feeds it the completed search as ToolRow's `search` card
|
||||
// material, so it renders through SearchBlock in the collapsed-by-default
|
||||
// expanded body — with a capped search's recovery footer below the card. A
|
||||
// search declares its render intent result-time only, so a running row is the
|
||||
// summary line alone; a settled call with no search card (an errored search, a
|
||||
// nested run_code sub-dispatch, a legacy generic result) surfaces its
|
||||
// model-facing text through ToolRow's Output section instead.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { searchCardModel } from '../contract/search-card-model.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type SearchRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
* Search row: icon + Search · {summary} in the shared ToolRow chrome, with the
|
||||
* completed search's card as the row's collapsed-by-default card body (a capped
|
||||
* search's recovery footer rides below it, inside ToolRow). Registered under
|
||||
* both `grep` and `glob`; the derived model's `kind` decides the card shape. A
|
||||
* settled call with no search card surfaces its model-facing text through
|
||||
* ToolRow's Output section, since the keyed SearchRow owns this render slot.
|
||||
*/
|
||||
export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const search = searchCardModel(block)
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconSearchOutline16 size={14} />}
|
||||
title={model.title}
|
||||
// The result view's replacement title outranks the args-derived summary,
|
||||
// matching the terminal card's description precedence.
|
||||
summary={search?.title ?? model.summary}
|
||||
body={null}
|
||||
// A settled call with no search card (errored search, nested run_code
|
||||
// sub-dispatch, legacy generic result) has its text nowhere else to go;
|
||||
// ToolRow's Output section carries it, and errorSummary its first line.
|
||||
// When a card is present ToolRow renders it instead of the output, so
|
||||
// passing model.output unconditionally is safe and keeps the four card
|
||||
// rows symmetric.
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
search={search}
|
||||
state={model.state}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
export const searchToolview = {
|
||||
name: 'search-toolview',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* 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)
|
||||
},
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/* Web toolview: same geometry/tokens as ToolRow (figma icon · summary), plus
|
||||
the web card the row stacks under its summary line, mirroring the bash row's
|
||||
resident terminal card. */
|
||||
|
||||
/* Summary line over the web card; the summary row keeps its own 24px height,
|
||||
so the card is a column around it rather than a change to it. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.web {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
|
||||
.root[data-state='running']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-web-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-web-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,24 +1,25 @@
|
||||
// Web toolview registrant: third-party posture over the keyed toolview hole
|
||||
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
|
||||
// Registered under BOTH web_search and web_fetch, since both declare the one
|
||||
// `web` render intent and render through the one WebBlock family; the row
|
||||
// discriminates on the toolName only to pick its icon and title.
|
||||
//
|
||||
// A web tool declares the `web` render intent at result time, so this row
|
||||
// renders the completed retrieval through WebBlock resident below its summary,
|
||||
// the same posture BashRow uses for the terminal card: no expand control on the
|
||||
// row itself, not a details-panel target, and the block's own expander keeps a
|
||||
// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is
|
||||
// passed as maxSources — the chat flow's tighter cap over the block's default
|
||||
// of 16). Until the call settles there is no web card (the tools keep a generic
|
||||
// pending view), so a running row is the summary line alone.
|
||||
// Web toolview registrant: the keyed toolview hole for the `web_search` and
|
||||
// `web_fetch` tools. Registered under BOTH, since both declare the one `web`
|
||||
// render intent and render through the one WebBlock family; the row
|
||||
// discriminates on the toolName only to pick its icon and title. The row
|
||||
// composes the shared ToolRow (chrome, running sweep, whole-row expand) and
|
||||
// feeds it the completed retrieval as ToolRow's `web` card material, so it
|
||||
// renders through WebBlock in the collapsed-by-default expanded body — the same
|
||||
// unified interaction every other card row has. Until the call settles there is
|
||||
// no web card (the tools keep a generic pending view), so a running row is the
|
||||
// summary line alone.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './web-row.module.css'
|
||||
import { webCardModel } from '../contract/web-card-model.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type WebRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/** web_fetch reads one URL; web_search queries. Titles are figma literals. */
|
||||
const WEB_TITLES: Record<string, string> = {
|
||||
@@ -26,49 +27,30 @@ const WEB_TITLES: Record<string, string> = {
|
||||
web_fetch: 'Fetch',
|
||||
}
|
||||
|
||||
/** Leading icon per tool, yielding to the state semantic while failed/stopped. */
|
||||
function leadingFor(toolName: string, state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
// Running keeps the icon — the row sweep carries the in-flight signal.
|
||||
default: return toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
|
||||
function stateStatus(state: ToolRowState): string | null {
|
||||
switch (state) {
|
||||
case 'running': return '运行中'
|
||||
case 'error': return '失败'
|
||||
case 'stopped': return '已停止'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with
|
||||
* the completed retrieval's web card resident below it. The summary row is not
|
||||
* a details-panel control (tool rows stopped being one), so the card's own
|
||||
* links and expander are the row's only interactions.
|
||||
* the completed retrieval's web card as the row's collapsed-by-default card
|
||||
* body. The row discriminates on `toolName` only to pick its icon and title.
|
||||
*/
|
||||
export function WebRow({ toolName, block }: ToolRowProps) {
|
||||
export function WebRow({ toolName, block, inspect, t }: WebRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const web = webCardModel(block)
|
||||
const status = stateStatus(model.state)
|
||||
const icon = toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div className={css.root} data-variant="web" data-tool={toolName} data-state={model.state}>
|
||||
<span className={css.leading}>{leadingFor(toolName, model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
<span className={css.title}>{WEB_TITLES[toolName] ?? model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
</div>
|
||||
{web !== null && (
|
||||
<WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
|
||||
)}
|
||||
</div>
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={icon}
|
||||
title={WEB_TITLES[toolName] ?? model.title}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
web={web}
|
||||
state={model.state}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,7 +68,7 @@ export const webToolview = {
|
||||
* @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' }, WebRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch' }, WebRow)
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user