feat(web): merge compact status and summary cards
This commit is contained in:
@@ -32,6 +32,7 @@ import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
|
||||
@@ -267,17 +268,21 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
|
||||
/** One command lifecycle row: keyed dispatch on the command name with the
|
||||
* generic card as the render-site fallback (zero registration required). A
|
||||
* run-less cross-window node has no name and always lands on the fallback. */
|
||||
const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
|
||||
const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CommandNode
|
||||
compaction?: Extract<ConversationNode, { kind: 'compaction' }>
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const owner = useMemo(() => ({ node }), [node])
|
||||
const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node])
|
||||
const fallback = node.name === 'compact' || compaction !== undefined
|
||||
? <CompactionCommandCard {...owner} t={t} />
|
||||
: <GenericCommandCard {...owner} t={t} />
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
{renderSlot('conversation.chat.commandview', owner, {
|
||||
entryKey: node.name ?? '',
|
||||
fallback: <GenericCommandCard {...owner} t={t} />,
|
||||
fallback,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
@@ -580,6 +585,16 @@ export function ChatView({
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.kind === 'command-compaction') {
|
||||
return (
|
||||
<CommandRow
|
||||
renderSlot={renderSlot}
|
||||
node={item.command}
|
||||
compaction={item.compaction}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const node: ConversationNode = item.node
|
||||
if (node.kind === 'assistant') {
|
||||
const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined
|
||||
@@ -642,9 +657,17 @@ export function ChatView({
|
||||
<div
|
||||
key={item.key}
|
||||
className={css.flowItem}
|
||||
data-chat-anchor-key={item.kind === 'node' ? `node:${String(item.node.seq)}` : undefined}
|
||||
data-chat-anchor-key={item.kind === 'node'
|
||||
? `node:${String(item.node.seq)}`
|
||||
: item.kind === 'command-compaction'
|
||||
? `node:${String(item.compaction.seq)}`
|
||||
: undefined}
|
||||
data-chat-flow-key={item.key}
|
||||
data-chat-flow-kind={item.kind === 'node' ? item.node.kind : 'tool-group'}
|
||||
data-chat-flow-kind={item.kind === 'node'
|
||||
? item.node.kind
|
||||
: item.kind === 'command-compaction'
|
||||
? item.kind
|
||||
: 'tool-group'}
|
||||
>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// CompactionCommandCard: the `/compact` command's running row and its
|
||||
// successful checkpoint disclosure. Outcomes without a checkpoint keep the
|
||||
// generic command card so no-history, cancellation, and failures retain their
|
||||
// complete handler-authored text.
|
||||
|
||||
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
|
||||
import { CompactionItem } from './CompactionItem.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
|
||||
interface CompactionCommandCardProps extends CommandRowOwnerProps {
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
/** Render one manual compaction lifecycle without duplicating its checkpoint marker. */
|
||||
export function CompactionCommandCard({ node, compaction, t }: CompactionCommandCardProps) {
|
||||
if (compaction !== undefined) {
|
||||
return (
|
||||
<CompactionItem
|
||||
node={compaction}
|
||||
title={node.name ?? 'compact'}
|
||||
fallbackSummary={node.outcome?.text ?? null}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.outcome !== null) return <GenericCommandCard node={node} t={t} />
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant="others"
|
||||
icon={<IconApiOutline14 size={14} />}
|
||||
title={node.name ?? 'compact'}
|
||||
summary={t('message.compaction.running')}
|
||||
body={null}
|
||||
state="running"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,10 @@ import css from './MessageItem.module.css'
|
||||
|
||||
interface CompactionItemProps {
|
||||
node: CompactionSummaryNode
|
||||
/** Optional command title for a manual compaction folded into this marker. */
|
||||
title?: string
|
||||
/** Command settlement text used only when the summary provenance page is absent. */
|
||||
fallbackSummary?: string | null
|
||||
/** The owning view's locale seat. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
@@ -27,10 +31,22 @@ interface CompactionItemProps {
|
||||
* @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) {
|
||||
export const CompactionItem = memo(function CompactionItem({
|
||||
node,
|
||||
title,
|
||||
fallbackSummary,
|
||||
t,
|
||||
}: CompactionItemProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandable = node.summary !== null
|
||||
const open = expandable && expanded
|
||||
const summary = node.shadowedItemCount !== null && node.shadowedTokenCount !== null
|
||||
? t('message.compaction.completed', {
|
||||
items: node.shadowedItemCount,
|
||||
tokens: node.shadowedTokenCount,
|
||||
})
|
||||
: fallbackSummary
|
||||
?? (expandable ? t('message.compaction.expand') : t('message.compaction.unavailable'))
|
||||
return (
|
||||
<div className={css.compactionRow}>
|
||||
<button
|
||||
@@ -43,11 +59,9 @@ export const CompactionItem = memo(function CompactionItem({ node, t }: Compacti
|
||||
<span className={css.compactionLeading}>
|
||||
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
</span>
|
||||
<span className={css.compactionTitle}>{t('message.compaction')}</span>
|
||||
<span className={css.compactionTitle}>{title ?? t('message.compaction')}</span>
|
||||
<span className={css.compactionSep} aria-hidden />
|
||||
<span className={css.compactionSummary}>
|
||||
{expandable ? t('message.compaction.expand') : t('message.compaction.unavailable')}
|
||||
</span>
|
||||
<span className={css.compactionSummary}>{summary}</span>
|
||||
</button>
|
||||
{open && node.summary !== null
|
||||
&& <div className={css.compactionBody}><MarkdownText text={node.summary} /></div>}
|
||||
|
||||
@@ -9,13 +9,48 @@
|
||||
* flow share their gates.
|
||||
*/
|
||||
import type {
|
||||
AssistantBlock, ConversationNode, ConversationSnapshot, ToolResultNode,
|
||||
AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One renderable flow item; key is the React key and the parent's identity unit. */
|
||||
export type ChatFlowItem =
|
||||
| { kind: 'node'; key: string; node: ConversationNode }
|
||||
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
|
||||
| {
|
||||
kind: 'command-compaction'
|
||||
key: string
|
||||
command: CommandNode
|
||||
compaction: CompactionSummaryNode
|
||||
}
|
||||
|
||||
/** Match explicit command outcome references to exactly one compaction checkpoint. */
|
||||
function commandCompactionPairs(nodes: readonly ConversationNode[]): {
|
||||
readonly byCommandId: ReadonlyMap<string, CompactionSummaryNode>
|
||||
readonly byCompactionSeq: ReadonlyMap<number, CommandNode>
|
||||
} {
|
||||
const commandsBySource = new Map<number, CommandNode | null>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue
|
||||
const source = node.outcome.sourceEventSeq
|
||||
if (source === undefined) continue
|
||||
commandsBySource.set(source, commandsBySource.has(source) ? null : node)
|
||||
}
|
||||
const compactionsBySummary = new Map<number, CompactionSummaryNode | null>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue
|
||||
const summary = node.summaryEventSeq
|
||||
compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node)
|
||||
}
|
||||
const byCommandId = new Map<string, CompactionSummaryNode>()
|
||||
const byCompactionSeq = new Map<number, CommandNode>()
|
||||
for (const [source, command] of commandsBySource) {
|
||||
const compaction = compactionsBySummary.get(source)
|
||||
if (command === null || compaction === undefined || compaction === null) continue
|
||||
byCommandId.set(command.commandId, compaction)
|
||||
byCompactionSeq.set(compaction.seq, command)
|
||||
}
|
||||
return { byCommandId, byCompactionSeq }
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the node has model-visible text content worth IconActions chrome.
|
||||
@@ -115,9 +150,29 @@ export function assistantBranchSeqs(
|
||||
*/
|
||||
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
|
||||
const items: ChatFlowItem[] = []
|
||||
const pairs = commandCompactionPairs(nodes)
|
||||
let group: ToolResultNode[] | null = null
|
||||
for (const node of nodes) {
|
||||
if (rendersNothing(node)) continue
|
||||
if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) {
|
||||
group = null
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'compaction') {
|
||||
group = null
|
||||
const command = pairs.byCompactionSeq.get(node.seq)
|
||||
if (command !== undefined) {
|
||||
items.push({
|
||||
kind: 'command-compaction',
|
||||
key: `c${command.commandId}`,
|
||||
command,
|
||||
compaction: node,
|
||||
})
|
||||
} else {
|
||||
items.push({ kind: 'node', key: `n${node.seq}`, node })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'tool-result') {
|
||||
if (group === null) {
|
||||
group = [node]
|
||||
@@ -138,7 +193,13 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
|
||||
}
|
||||
} else {
|
||||
group = null
|
||||
items.push({ kind: 'node', key: `n${node.seq}`, node })
|
||||
items.push({
|
||||
kind: 'node',
|
||||
key: node.kind === 'command' && node.name === 'compact'
|
||||
? `c${node.commandId}`
|
||||
: `n${node.seq}`,
|
||||
node,
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
|
||||
import type {
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerBlock } from '../input/blocks.ts'
|
||||
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
@@ -217,14 +217,16 @@ export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
|
||||
/**
|
||||
* Owner share of the per-command row slot: the frozen {@link CommandNode}
|
||||
* slice off the snapshot (cache-stable reference — memo premise). The node
|
||||
* carries the whole lifecycle (structured name/args, pairing id,
|
||||
* outcome-or-executing), so a
|
||||
* registrant needs no second data channel; domain state arrives through its
|
||||
* own projection cell.
|
||||
* carries the whole lifecycle (structured name/args, pairing id, and
|
||||
* outcome-or-executing). A successful domain command may also carry the
|
||||
* explicitly linked projection node needed to fold two log records into one
|
||||
* presentation row.
|
||||
*/
|
||||
export interface CommandRowOwnerProps {
|
||||
/** Folded command lifecycle node (run + optional done). */
|
||||
node: CommandNode
|
||||
/** Explicitly linked compaction checkpoint for the settled `/compact` presentation. */
|
||||
compaction?: CompactionSummaryNode
|
||||
}
|
||||
|
||||
/** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */
|
||||
|
||||
@@ -80,6 +80,8 @@ export const zh = {
|
||||
'message.context.recall.truncated': '已截断',
|
||||
'message.steering': '插话',
|
||||
'message.compaction': '上下文已压缩',
|
||||
'message.compaction.running': '正在压缩…',
|
||||
'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)',
|
||||
'message.compaction.expand': '点击查看压缩摘要',
|
||||
'message.compaction.unavailable': '压缩摘要不可用',
|
||||
'message.unknownSurface': '未知 surface 事件:{type}',
|
||||
@@ -220,6 +222,8 @@ export const en = {
|
||||
'message.context.recall.truncated': 'truncated',
|
||||
'message.steering': 'Interjection',
|
||||
'message.compaction': 'Context compacted',
|
||||
'message.compaction.running': 'Compacting context…',
|
||||
'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)',
|
||||
'message.compaction.expand': 'View compaction summary',
|
||||
'message.compaction.unavailable': 'Compaction summary unavailable',
|
||||
'message.unknownSurface': 'Unknown surface event: {type}',
|
||||
|
||||
Reference in New Issue
Block a user