Merge pull request 'Wire inline confirm, cardlink locate, drop zone, and symmetric command menu (T3)' (#6) from feature/command-bus-r1 into main
This commit was merged in pull request #6.
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