Compare commits

..
Author SHA1 Message Date
weichen ee85556584 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
2026-09-30 07:54:41 +00:00
weichen 801a684c26 Merge pull request 'Wire inbox components (frontend)' (#4) from feature/inbox-r2 into main 2026-09-30 06:58:17 +00:00
weichen 9c44d6b497 Wire inbox tab as the right-rail head with live card state
Why: T2 frontend of the inbox (D25): the inbox is the first tab with a live
  pending count; card events (created/updated/status/resolved) upsert the
  projection in real time; confirm-projection cards route clicks back to HITL
  resolution (run resumes, server syncs both sides); other actions call the
  cards API and append the server-persisted receipt into the timeline.

Evidence: tsc + build green; vitest 9/9; card state resets/refreshes on
  conversation open and reconcile.

Impact: default right tab becomes 收件箱 (localStorage-persisted preference
  still honored for existing users).

Refs: modules/documents docs/proposals/2026-09-30-ui-inbox-command-workspace.md
2026-09-30 06:58:16 +00:00
weichen b020ab5dab Merge pull request 'Regenerate contracts from Core (activity card, card events, UI vocabularies)' (#3) from feature/core-cards-commands-r1 into main 2026-09-30 06:25:52 +00:00
weichen 53d1f04607 Regenerate contracts from Core (activity card, card events, UI vocabularies)
Why: Core T1 added ActivityCard, card.* events, HITL anchoring fields and seven
  UI vocabularies; this repo's contracts.ts is the generated mirror.

Evidence: make contracts from Core main; frontend vitest 9/9 still green
  (additive types, shape tests unaffected).

Impact: Types available for T2 inbox implementation; no behavior change.

Refs: Agent-System-Documents proposal 2026-09-30-ui-inbox-command-workspace
2026-09-30 06:25:51 +00:00
weichen ab38738747 Merge pull request 'Consume Agent-System-UI via source-composition alias' (#2) from feature/ui-foundation-r0 into main 2026-09-30 05:52:22 +00:00
5 changed files with 178 additions and 17 deletions
+11
View File
@@ -4,6 +4,7 @@ import { auth } from './auth'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, PlanSnapshot, TodoList, UploadInfo,
ActivityCard,
} from './contracts'
const BASE = ''
@@ -43,6 +44,16 @@ export const api = {
auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) })
.then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)),
listCards: (conversationId: string) =>
auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
cardAction: (cardId: string, actionId: string, freeText?: string) =>
auth.fetch(`${BASE}/api/cards/${cardId}/actions/${encodeURIComponent(actionId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ freeText }) })
.then(r => json<{ card: ActivityCard; receipt?: Message }>(r)),
cardPin: (cardId: string, pinned: boolean) =>
auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)),
cardDismiss: (cardId: string) =>
auth.fetch(`${BASE}/api/cards/${cardId}/dismiss`, { method: 'POST' }).then(r => json<{ card: ActivityCard }>(r)),
upload: (projectId: string, file: File) => {
const fd = new FormData()
fd.append('file', file)
+36 -9
View File
@@ -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 ? (
+14 -2
View File
@@ -1,7 +1,7 @@
// 右栏工作台:Plan / Todos / Artifacts / Agents + 插件面板(4.4);tab 显隐排序见设置。
// 右栏工作台:收件箱(D25 置首)+ Plan / Todos / Artifacts / Agents + 插件面板;tab 显隐排序见设置。
import { store, useStore, isTerminal } from '../store'
import type { Run } from '../contracts'
import { UIRenderer, type DocBridge } from '@ui'
import { UIRenderer, InboxList, pendingCount, type DocBridge, type ActivityCard } from '@ui'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
export const docBridge: DocBridge = {
@@ -19,8 +19,11 @@ export function Workbench() {
const artifacts = useStore(s => s.artifacts)
const runs = useStore(s => s.runs)
const pluginPanels = useStore(s => s.pluginPanels)
const cards = useStore(s => s.cards)
const pending = pendingCount(cards)
const tabs: { id: string; label: string }[] = [
{ id: 'inbox', label: pending > 0 ? `收件箱 ${pending}` : '收件箱' },
{ id: 'plan', label: '计划' },
{ id: 'todos', label: '待办' },
{ id: 'artifacts', label: `工件${artifacts.length ? ` ${artifacts.length}` : ''}` },
@@ -38,6 +41,7 @@ export function Workbench() {
<button className="tab" title="收起工作台" onClick={store.toggleRight}>»</button>
</div>
<div className="tab-body">
{ui.rightTab === 'inbox' && <InboxTab cards={cards} />}
{ui.rightTab === 'plan' && <PlanTab plan={plan} />}
{ui.rightTab === 'todos' && <TodosTab todos={todos} />}
{ui.rightTab === 'artifacts' && <ArtifactsTab />}
@@ -50,6 +54,14 @@ export function Workbench() {
)
}
function InboxTab({ cards }: { cards: ActivityCard[] }) {
return <InboxList cards={cards} callbacks={{
onAction: (c, id) => store.cardAction(c, id),
onPin: (c, p) => store.cardPin(c, p),
onDismiss: c => store.cardDismiss(c),
}} />
}
function StatusIcon({ status }: { status?: string }) {
if (status === 'done' || status === 'completed') return <span style={{ color: 'var(--ok)' }}>●</span>
if (status === 'in_progress' || status === 'running') return <span style={{ color: 'var(--accent)' }}>◐</span>
+52 -2
View File
@@ -81,7 +81,8 @@ export type StreamEventType =
| 'run.phase' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update'
| 'agent.spawn' | 'agent.progress' | 'agent.result'
| 'artifact.created' | 'artifact.updated'
| 'run.completed' | 'run.failed' | 'error';
| 'run.completed' | 'run.failed' | 'error'
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved';
export interface StreamEvent<P = unknown> {
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
@@ -106,6 +107,28 @@ export interface ContextObservation {
}
export interface RunFailedPayload { code: string; message: string }
// ---- 收件箱卡片 ----
export type CardKind =
| 'progress' | 'confirm' | 'compare' | 'review-list'
| 'result' | 'error' | 'status';
export type CardStatus =
| 'active' | 'action_required' | 'done'
| 'resolved' | 'failed' | 'dismissed';
export interface ActivityCard {
id: ID; projectId: ID; conversationId?: ID;
sourceRunId?: ID; anchorMessageId?: ID;
kind: CardKind; title: string; status: CardStatus; tone?: string;
body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[];
pinned?: boolean; createdAt: string; updatedAt: string;
}
export interface CardPayload { card: ActivityCard }
export interface CardStatusPayload { cardId: ID; status: CardStatus; tone?: string; note?: string }
export interface CardResolvedPayload { cardId: ID; actionId?: string; answer?: string }
// ---- HITL ----
export type HITLKind = 'choice' | 'input' | 'confirm' | 'form';
@@ -118,6 +141,7 @@ export interface HITLCard {
id: ID; runId: ID; conversationId: ID; question: string; kind: HITLKind;
choices?: HITLChoice[]; allowFreeInput: boolean; freeInputHint?: string;
timeoutSec?: number | null; form?: UINode[]; createdAt: string; resolved: boolean;
anchorMessageId?: ID; blocked?: boolean; blockReason?: string;
}
export interface HITLResponse {
@@ -127,7 +151,8 @@ export interface HITLResponse {
// ---- UI 词汇 ----
export type UINodeType =
| 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite';
| 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite'
| 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
export interface UINode {
type: UINodeType;
@@ -140,6 +165,25 @@ export interface UINode {
actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger' }[];
chart?: { kind: 'bar' | 'line'; labels: string[]; series: number[] };
children?: UINode[];
progress?: { label?: string; value: number; tone?: string };
compare?: {
left: { title?: string; tone?: string; nodes?: UINode[] };
right: { title?: string; tone?: string; nodes?: UINode[] };
};
tree?: {
nodes: {
id: string; label: string; tone?: string; badge?: string; count?: number;
selected?: boolean; children?: unknown[];
}[];
};
table?: {
columns: { key: string; label: string }[];
rows: { id?: string; cells: [string, string][]; actions?: UINode['actions'] }[];
};
steps?: { current: number; steps: { label: string; status?: 'pending' | 'active' | 'done' | 'failed'; tone?: string }[] };
tabs?: { active?: string; tabs: { id: string; label: string; nodes?: UINode[] }[] };
timeline?: { items: { time?: string; title: string; tone?: string; markdown?: string; ref?: string }[] };
}
export interface UIField {
@@ -179,6 +223,7 @@ export interface PluginManifest {
provides: {
tools?: string[]; workflows?: string[]; intents?: string[];
prompts?: string[]; panels?: string[]; cards?: string[]; settings?: string[];
commands?: string[]; workspaces?: string[];
};
}
@@ -186,6 +231,11 @@ export interface PluginPanel {
id: string; title: string; icon?: string; body: UINode[]; refreshTool?: string;
}
export interface PluginWorkspace {
id: string; title: string; icon?: string; shortcut?: string;
badgeTool?: string; body: UINode[]; refreshTool?: string;
}
// ---- 摄取 / 检索 ----
export interface ParsedDocument {
+65 -4
View File
@@ -4,7 +4,7 @@ import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
AgentProfile, StreamEvent, PluginPanel,
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
} from './contracts'
export interface ContextObs {
@@ -50,6 +50,7 @@ interface AppState {
plan: PlanSnapshot | null
todos: TodoList | null
artifacts: Artifact[]
cards: ActivityCard[]
profiles: AgentProfile[]
pluginPanels: PluginPanel[]
ui: UIState
@@ -63,7 +64,7 @@ const DEFAULT_UI: UIState = {
leftCollapsed: false,
rightWidth: 340,
rightCollapsed: false,
rightTab: 'plan',
rightTab: 'inbox',
theme: 'dark',
autoCollapseProcess: true,
settingsOpen: false,
@@ -83,7 +84,7 @@ let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, conversations: [], currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], cards: [], profiles: [], pluginPanels: [],
ui: loadUI(), connected: false, error: null, toast: null,
}
@@ -151,7 +152,7 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null })
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [] })
await store.reconcile()
await store.connectLive()
},
@@ -172,6 +173,7 @@ export const store = {
})
const [plan, todos] = await Promise.all([api.getPlan(d.conversation.id), api.getTodos(d.conversation.id)])
set({ plan, todos })
store.refreshCards(d.conversation.id)
} catch (e) {
set({ error: String(e) })
}
@@ -252,6 +254,24 @@ export const store = {
set({ todos: p.list })
break
}
case 'card.created':
case 'card.updated': {
const p = e.payload as { card: ActivityCard }
if (p?.card) store.upsertCard(p.card)
break
}
case 'card.status': {
const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string }
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)
set({ cards })
break
}
case 'card.resolved': {
const p = e.payload as { cardId: string }
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)
set({ cards })
break
}
case 'artifact.created': {
if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId)
break
@@ -319,6 +339,41 @@ export const store = {
}
},
upsertCard(card: ActivityCard) {
const i = state.cards.findIndex(c => c.id === card.id)
const cards = i >= 0 ? state.cards.map(c => c.id === card.id ? card : c) : [card, ...state.cards]
set({ cards })
},
async refreshCards(convId: string) {
try { set({ cards: await api.listCards(convId) }) } catch { /* 会话竞态,静默 */ }
},
// 卡片动作:确认投影卡(来源 hitl:)路由回 HITL 解析(恢复挂起 run,服务端同步销项);
// 其余卡走卡片动作 API,并把服务端持久化的回执消息追加进本地时间线(id 一致,对账不重复)。
async cardAction(card: ActivityCard, actionId: string, freeText?: string) {
const hitlRef = card.body?.find(n => n.type === 'kv')?.kv?.find(([k]) => k === '来源')?.[1]
if (hitlRef?.startsWith('hitl:')) {
await store.resolveHITL(hitlRef.slice(5), actionId ? { choiceId: actionId } : { freeText })
return
}
try {
const out = await api.cardAction(card.id, actionId, freeText)
store.upsertCard(out.card)
if (out.receipt) set({ messages: [...state.messages, out.receipt as Message] })
} catch (e) {
store.toast(`操作失败:${e}`)
}
},
async cardPin(card: ActivityCard, pinned: boolean) {
try { store.upsertCard((await api.cardPin(card.id, pinned)).card) } catch (e) { store.toast(`置顶失败:${e}`) }
},
async cardDismiss(card: ActivityCard) {
try { store.upsertCard((await api.cardDismiss(card.id)).card) } catch (e) { store.toast(`关闭失败:${e}`) }
},
async refreshArtifacts(projectId: string) {
try {
const artifacts = await api.listArtifacts(projectId)
@@ -354,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 }) },