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
This commit is contained in:
@@ -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 <div className="tool-chip">❓ {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
|
||||
case 'plan':
|
||||
return null // 计划在右栏 Plan 标签呈现
|
||||
case 'card':
|
||||
return <div className="ui-section"><UIRenderer nodes={part.card?.data ?? []} docBridge={docBridge} /></div>
|
||||
case 'card': {
|
||||
// cardlink 互定位(D32):点击 ↗ 定位收件箱对应卡(从 data 中解析 card:<id>)。
|
||||
const m = /card:([A-Za-z0-9_]+)/.exec(JSON.stringify(part.card?.data ?? ''))
|
||||
return (
|
||||
<div className="ui-section card-link" title={m ? '点击在收件箱中定位' : undefined}
|
||||
onClick={() => m && store.openInbox(m[1])}>
|
||||
<UIRenderer nodes={part.card?.data ?? []} docBridge={docBridge} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'error':
|
||||
return <div className="part-error">{part.error?.code}: {part.error?.message}</div>
|
||||
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 (
|
||||
<div className="cmd-menu">
|
||||
{hits.map((c, i) => (
|
||||
<div key={c.name} className={`cmd-item ${i === active ? 'active' : ''}`}
|
||||
onMouseEnter={() => setActive(i)} onClick={() => onPick(c.name + ' ')}>
|
||||
<span className="name">{c.name}</span><span>{c.description}</span>
|
||||
onMouseEnter={() => setActive(i)} onClick={() => onPick(c.name! + ' ')}>
|
||||
<span className="name">{c.name}{c.danger === 'high' ? ' ⚠' : ''}</span><span>{c.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -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<string[]>([])
|
||||
@@ -238,13 +248,30 @@ export function Conversation() {
|
||||
{active && <button className="btn ghost" onClick={() => store.cancelActive()}>■ 停止</button>}
|
||||
</div>
|
||||
|
||||
<div className="chat-body" ref={bodyRef}>
|
||||
<div className="chat-body" ref={bodyRef}
|
||||
onDragOver={e => { 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 (
|
||||
<div className="msg user" key={m.id}>
|
||||
<div className="msg-role">你</div>
|
||||
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
|
||||
{anchored.map(c => (
|
||||
<InlineConfirmCard key={c.id} card={c} callbacks={{
|
||||
onAction: (card, actionId) => store.cardAction(card, actionId),
|
||||
onLocate: card => store.openInbox(card.id),
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -276,7 +303,7 @@ export function Conversation() {
|
||||
e.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
<textarea rows={1} value={text} placeholder={artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
||||
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡' : artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
|
||||
{active ? (
|
||||
|
||||
@@ -409,6 +409,12 @@ export const store = {
|
||||
setUI,
|
||||
toggleLeft() { setUI({ leftCollapsed: !state.ui.leftCollapsed }) },
|
||||
toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) },
|
||||
openInbox(cardId?: string) {
|
||||
setUI({ rightCollapsed: false, rightTab: 'inbox' })
|
||||
if (cardId) setTimeout(() => {
|
||||
document.querySelector(`[data-card-id="${cardId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 60)
|
||||
},
|
||||
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
||||
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
|
||||
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
|
||||
|
||||
Reference in New Issue
Block a user