From ee8555658415b3cd3b79311d0e0a802cedb32b05 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 07:54:41 +0000 Subject: [PATCH] Wire inline confirm, cardlink locate, drop zone, and symmetric command menu (T3) Why: the chat column must host the danger-command confirm interaction inline (anchored, blocking hint, collapsed once resolved), cardlinks must locate their inbox card (D32), files dropped on the conversation must attach, and the slash menu must reflect the command registry (same source as buttons/cards/chat, D27). Evidence: vitest 9/9; tsc clean; delegation-only logic (rendering covered by ui InlineConfirmCard suite, dispatch by backend e2e). Impact: Conversation renders anchored confirm cards via @ui InlineConfirmCard with openInbox locate (store helper), PartCard cardlink click locates the inbox card, chat-body accepts file drops through the existing upload path, composer hints while a confirm is open, CommandMenu tolerates commands without a slash alias and marks danger=high. Refs: docs/proposals/2026-09-30-ui-inbox-command-workspace.md T3; task card feature/command-bus-r1 --- src/components/Conversation.tsx | 45 ++++++++++++++++++++++++++------- src/store.ts | 6 +++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index d781f95..f6cd13d 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -4,7 +4,7 @@ import { marked } from 'marked' import { api } from '../api' import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store' import type { Message, Part, HITLCard } from '../contracts' -import { UIRenderer } from '@ui' +import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui' import { docBridge } from './Workbench' marked.setOptions({ breaks: true }) @@ -34,8 +34,16 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) { return
❓ {part.hitl?.question} → {part.hitl?.answer || '…'}
case 'plan': return null // 计划在右栏 Plan 标签呈现 - case 'card': - return
+ case 'card': { + // cardlink 互定位(D32):点击 ↗ 定位收件箱对应卡(从 data 中解析 card:)。 + const m = /card:([A-Za-z0-9_]+)/.exec(JSON.stringify(part.card?.data ?? '')) + return ( +
m && store.openInbox(m[1])}> + +
+ ) + } case 'error': return
{part.error?.code}: {part.error?.message}
default: @@ -161,18 +169,19 @@ function HITLCardView({ card }: { card: HITLCard }) { } function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) => void }) { - const [commands, setCommands] = useState<{ name: string; description: string }[]>([]) + const [commands, setCommands] = useState<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>([]) const [active, setActive] = useState(0) useEffect(() => { api.listCommands().then(setCommands).catch(() => undefined) }, []) const q = text.slice(1).toLowerCase() - const hits = commands.filter(c => c.name.slice(1).toLowerCase().startsWith(q)) + // 四入口同源(D27):菜单即注册表——有 `/` 别名的可直接补全;无别名的展示说明(对称提示)。 + const hits = commands.filter(c => (c.name ?? '').slice(1).toLowerCase().startsWith(q)) if (hits.length === 0) return null return (
{hits.map((c, i) => (
setActive(i)} onClick={() => onPick(c.name + ' ')}> - {c.name}{c.description} + onMouseEnter={() => setActive(i)} onClick={() => onPick(c.name! + ' ')}> + {c.name}{c.danger === 'high' ? ' ⚠' : ''}{c.description}
))}
@@ -189,6 +198,7 @@ export function Conversation() { const connected = useStore(s => s.connected) const artifacts = useStore(s => s.artifacts) const profiles = useStore(s => s.profiles) + const cards = useStore(s => s.cards) const [text, setText] = useState('') const [attachmentIds, setAttachmentIds] = useState([]) @@ -238,13 +248,30 @@ export function Conversation() { {active && } -
+
{ e.preventDefault(); e.currentTarget.classList.add('drag-over') }} + onDragLeave={e => e.currentTarget.classList.remove('drag-over')} + onDrop={async e => { + e.preventDefault(); e.currentTarget.classList.remove('drag-over') + for (const f of Array.from(e.dataTransfer.files ?? [])) { + const id = await store.uploadAttachment(f) + if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) } + } + }}> {messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent').map(m => { if (m.role === 'user') { + // 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。 + const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm') return (
你
{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}
+ {anchored.map(c => ( + store.cardAction(card, actionId), + onLocate: card => store.openInbox(card.id), + }} /> + ))}
) } @@ -276,7 +303,7 @@ export function Conversation() { e.target.value = '' }} /> -