Compare commits

..
Author SHA1 Message Date
weichen 4abf13d33d Mirror the prompt module contract and add the library API
Why: the backend prompt library ships new contract shapes and an
endpoint; the frontend mirror and api binding keep the TS side in sync
for upcoming management UI.

Evidence: tsc + vite build green as part of composite make test.

Impact: contracts.ts gains PromptModule/Trigger and
AgentProfile.promptModules; api gains listPromptModules. No UI change
yet.

Refs: feature/prompt-library task card.
2026-09-30 10:35:13 +00:00
weichen 0eb9066510 Merge pull request 'feature/chat-live-quality: live streaming bubble, markdown spacing, document-task quality' (#9) from feature/chat-live-quality into main 2026-09-30 09:59:24 +00:00
weichen 616795be41 Show the live streaming bubble and fix markdown spacing
Why: during a run nothing was rendered (the assistant message only lands
at completion, so the LiveState projection never hit the screen), and
white-space:pre-wrap on .part-text turned marked's inter-block newlines
into blank lines, giving ordered lists a huge per-item gap. A latent
race also wiped thinking/tool/phase state that arrived before the send
POST resolved, and thinking text ran inline with the answer.

Evidence: headless playwright against the mock backend shows the live
bar, an expanded thinking panel, tool chips and incremental text with a
blinking cursor mid-run; list item gaps measure 2.1px with
white-space:normal; make test (frontend 9 tests) green.

Impact: users watch thinking, tool calls and the answer stream in real
time; markdown typography (lists, tables, quotes, headings) is compact;
terminal runs fold the process trail behind a summary.

Refs: feature/chat-live-quality task card.
2026-09-30 09:57:35 +00:00
weichen a54022c438 Constrain the chat column height so the message stream scrolls
Why: .chat is a column-flex item of .main without min-height:0, so its
content-based automatic minimum height grew past the viewport; .chat-body
never overflowed (never scrolled) and the input bar was pushed off-screen.
Evidence: headless Chromium on mock serve, 1024x620: before .chat=2130px
and input-bar bottom=717 (>viewport), chat-body scrollable=false; after
.chat=620, chat-body clientH 501 / scrollH 2011 with wheel scroll moving
600px up and back to bottom, input-bar bottom=620. Variants (empty
conversation, single short message, 1024x400, 720x600) keep the input
visible. Full combo make test green.
Impact: chat page was unusable beyond one screen of content; single CSS
rule chain, no behavior change elsewhere.
Refs: Agent-System-Dev task card feature/chat-scroll-fix.
2026-09-30 09:15:45 +00:00
weichen a617504268 Merge pull request 'Add the tidy action and collapsed-rail pending badge (T5)' (#8) from feature/polish-r1 into main 2026-09-30 08:18:01 +00:00
weichen 4f4711a22a Add the tidy action and collapsed-rail pending badge (T5)
Why: T5 notification strategy - the collapsed right rail must still surface the pending count (badge), and the inbox needs a one-key tidy (done cards dismissed; resolved verdicts keep their stamp).
Evidence: tsc clean; vitest 9/9; vite build ok; delegation-only logic.
Impact: App collapsed rail badge, store tidyCards (done->dismiss via existing cardDismiss), Workbench tidy button, styles.
Refs: docs/proposals/2026-09-30-ui-inbox-command-workspace.md T5; task card feature/polish-r1
2026-09-30 08:13:13 +00:00
weichen e5b65ca79a Merge pull request 'Make the right rail pure inbox and move deep views into overlay workspaces (T4)' (#7) from feature/workspaces-r1 into main 2026-09-30 08:08:25 +00:00
weichen 58f3599660 Sync main before merge (stale-candidate guard) 2026-09-30 08:08:01 +00:00
weichen 298c41ff67 Make the right rail pure inbox and move deep views into overlay workspaces (T4)
Why: D25 final state - the right rail answers only "what needs me now" (inbox + a pinned current-task card); artifacts/agents/plugin panels become overlay workspaces with Cmd shortcuts and a left-rail quick section; DocViewer/AgentTracker migrate onto the shared OverlayHost.
Evidence: tsc clean; vitest 9/9; vite build ok; delegation-only logic (overlay semantics covered by ui OverlayHost suite).
Impact: Workbench drops classic tabs and gains CurrentTaskCard (plan+todos merged); new Workspaces.tsx (workspaceEntries, Cmd1-9 handler, builtin/plugin/server workspace rendering); store gains activeWorkspace/serverWorkspaces + open/close; ProjectNav quick section; Conversation project badge (D26); DocViewer/AgentTracker render through OverlayHost.
Refs: docs/proposals/2026-09-30-ui-inbox-command-workspace.md T4; task card feature/workspaces-r1
2026-09-30 08:04:21 +00:00
weichen 6a2b21a28d Merge pull request 'Wire inline confirm, cardlink locate, drop zone, and symmetric command menu (T3)' (#6) from feature/command-bus-r1 into main 2026-09-30 07:55:00 +00:00
12 changed files with 297 additions and 116 deletions
+12 -1
View File
@@ -4,6 +4,7 @@ import { store, useStore } from './store'
import { ProjectNav } from './components/ProjectNav'
import { Conversation } from './components/Conversation'
import { Workbench } from './components/Workbench'
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
import { AgentTracker } from './components/AgentTracker'
import { SettingsModal } from './components/SettingsModal'
import { DocViewer } from './components/DocViewer'
@@ -40,10 +41,17 @@ function LoginGate({ onSuccess }: { onSuccess: () => void }) {
}
export function App() {
useEffect(() => {
const h = (e: KeyboardEvent) => workspaceShortcuts(e)
window.addEventListener('keydown', h, true)
return () => window.removeEventListener('keydown', h, true)
}, [])
const ui = useStore(s => s.ui)
const toast = useStore(s => s.toast)
const trackerRunId = useStore(s => s.ui.trackerRunId)
const viewingArtifact = useStore(s => s.ui.viewingArtifact)
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
// authState:checking → ok(放行)| required(登录门)。
const [authState, setAuthState] = useState<'checking' | 'required' | 'ok'>('checking')
@@ -133,11 +141,14 @@ export function App() {
) : (
<div className="right-panel collapsed" style={{ width: 44 }}>
<div className="icon-only">
<button title="展开工作台" onClick={store.toggleRight}>◧</button>
<button title="展开收件箱" onClick={store.toggleRight}>◧
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
</div>
</div>
)}
<Workspaces />
{trackerRunId && <AgentTracker />}
{ui.settingsOpen && <SettingsModal />}
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
+4 -2
View File
@@ -3,7 +3,7 @@
import { auth } from './auth'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, PlanSnapshot, TodoList, UploadInfo,
AgentProfile, SettingDesc, StreamEvent, PlanSnapshot, TodoList, UploadInfo, PromptModule,
ActivityCard,
} from './contracts'
@@ -67,6 +67,7 @@ export const api = {
getTodos: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/todos`).then(r => r.ok ? r.json() : null) as Promise<TodoList | null>,
listProfiles: () => auth.fetch(`${BASE}/api/profiles`).then(r => json<AgentProfile[]>(r)),
listPromptModules: () => auth.fetch(`${BASE}/api/prompts`).then(r => json<PromptModule[]>(r)),
saveProfile: (p: AgentProfile) => auth.fetch(`${BASE}/api/profiles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }),
deleteProfile: (id: string) => auth.fetch(`${BASE}/api/profiles/${id}`, { method: 'DELETE' }),
@@ -78,7 +79,8 @@ export const api = {
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name: string; description: string }[]>,
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
}
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
+3 -2
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { OverlayHost } from '@ui'
import type { Message, Run, Usage } from '../contracts'
export function AgentTracker() {
@@ -24,7 +25,7 @@ export function AgentTracker() {
const focusMsgs = focus === runId ? messagesAll.filter(m => m.runId === runId) : subMessages[focus] ?? []
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.setUI({ trackerRunId: null }) }}>
<OverlayHost title="代理追踪" icon="🤖" onClose={() => store.setUI({ trackerRunId: null })}>
<div className="modal">
<div className="modal-header">
🤖 Sub-agent 追踪
@@ -75,7 +76,7 @@ export function AgentTracker() {
)}
</div>
</div>
</div>
</OverlayHost>
)
}
+31 -4
View File
@@ -7,7 +7,8 @@ import type { Message, Part, HITLCard } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
import { docBridge } from './Workbench'
marked.setOptions({ breaks: true })
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
@@ -16,7 +17,13 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
return <div className={live ? 'part-text cursor-blink' : 'part-text'}
dangerouslySetInnerHTML={{ __html: marked.parse(part.text ?? '') }} />
case 'thinking':
return <div className="part-thinking">{part.text}</div>
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
return (
<details className="part-thinking" open={live ? true : undefined}>
<summary>思考过程</summary>
<div className="part-thinking-body">{part.text}</div>
</details>
)
case 'tool_call':
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
case 'tool_result': {
@@ -93,7 +100,7 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
</div>
{split.process.length > 0 && (showProcessInline
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!live} /></div>)
: <ProcessCollapse parts={split.process} />)}
{split.content.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!(live && i === split.content.length - 1 && p.type === 'text')} /></div>)}
{live && <LiveAgentNotes live={live} />}
@@ -102,6 +109,22 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
)
}
// live 投影:run 进行中没有已落库的助手消息,把 LiveState 合成为同构的临时消息
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);run 终态对账后由真消息替代。
function liveToMessage(live: LiveState): Message {
const parts: Part[] = []
if (live.thinking) parts.push({ type: 'thinking', text: live.thinking, process: true })
for (const tc of live.toolCalls) {
parts.push({ type: 'tool_call', process: true, toolCall: { id: '', name: tc.name } })
if (tc.status !== undefined) {
parts.push({ type: 'tool_result', process: true, toolCall: { id: '', name: tc.name },
toolResult: { callId: '', status: tc.status === 'error' ? 'error' : 'ok', summary: tc.summary } })
}
}
if (live.text) parts.push({ type: 'text', text: live.text })
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
}
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
function ContextObsView({ obs }: { obs: ContextObs }) {
return (
@@ -199,6 +222,7 @@ export function Conversation() {
const artifacts = useStore(s => s.artifacts)
const profiles = useStore(s => s.profiles)
const cards = useStore(s => s.cards)
const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目')
const [text, setText] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
@@ -211,7 +235,7 @@ export function Conversation() {
useEffect(() => {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
}, [messages.length, live?.text, live?.thinking, activeHitl.length])
}, [messages.length, live?.text, live?.thinking, live?.toolCalls.length, live?.phase, activeHitl.length])
const send = async () => {
if (!text.trim() && attachmentIds.length === 0) return
@@ -235,6 +259,7 @@ export function Conversation() {
return (
<div className="chat">
<div className="chat-header">
<span className="project-badge" title="当前项目">{projectName}</span>
<div className="chat-title">{conversation?.title ?? '会话'}</div>
<select className="effect-badge" value={conversation?.profileId || 'default'} title="切换会话画像"
onChange={async e => {
@@ -277,6 +302,8 @@ export function Conversation() {
}
return <AssistantMessage key={m.id} msg={m} live={live?.runId === m.runId ? live : null} />
})}
{live && !messages.some(m => m.runId === live.runId) &&
<AssistantMessage key={`live-${live.runId}`} msg={liveToMessage(live)} live={live} />}
{activeHitl.map(c => <HITLCardView key={c.id} card={c} />)}
{messages.length === 0 && <div className="empty-state">
<div>发送消息开始对话</div>
+3 -2
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
import { store } from '../store'
import { OverlayHost } from '@ui'
import type { Artifact } from '../contracts'
export function DocViewer({ artifact }: { artifact: Artifact }) {
@@ -16,7 +17,7 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
}, [artifact.id])
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.viewArtifact(null) }}>
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
<div className="modal">
<div className="modal-header">
📄 {artifact.name}
@@ -49,7 +50,7 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
)}
</div>
</div>
</div>
</OverlayHost>
)
}
+12
View File
@@ -1,6 +1,7 @@
// 左栏:项目折叠组 → 会话列表;底部账户/设置入口(4.4)。
import { useEffect, useState } from 'react'
import { store, useStore } from '../store'
import { workspaceEntries } from './Workspaces'
export function ProjectNav() {
const projects = useStore(s => s.projects)
@@ -19,6 +20,17 @@ export function ProjectNav() {
<span className="nav-label">Agent-System</span>
<button title="新建项目" onClick={() => setCreating(v => !v)}>✚</button>
</div>
<div className="ws-quick">
<div className="nav-label">工作区</div>
<div className="ws-quick-list">
{workspaceEntries().map(e => (
<button key={e.key} className="ws-quick-item" title={`${e.title}(${e.shortcut})`}
onClick={() => store.openWorkspace(e.key)}>
<span>{e.icon}</span><span className="name">{e.title}</span><span className="sc">{e.shortcut}</span>
</button>
))}
</div>
</div>
{creating && (
<div style={{ padding: '0 10px 8px', display: 'flex', gap: 6 }}>
<input className="search-input" value={newName} onChange={e => setNewName(e.target.value)}
+48 -94
View File
@@ -1,7 +1,8 @@
// 右栏工作台:收件箱(D25 置首)+ Plan / Todos / Artifacts / Agents + 插件面板;tab 显隐排序见设置。
import { store, useStore, isTerminal } from '../store'
// 右栏终态(提案 T4/D25):纯收件箱 + 「当前任务」常驻卡(计划/待办并入;工件/代理/
// 插件面板迁为工作区弹层,见 Workspaces.tsx)。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { UIRenderer, InboxList, pendingCount, type DocBridge, type ActivityCard } from '@ui'
import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
export const docBridge: DocBridge = {
@@ -13,127 +14,80 @@ export const docBridge: DocBridge = {
}
export function Workbench() {
const ui = useStore(s => s.ui)
const cards = useStore(s => s.cards)
const plan = useStore(s => s.plan)
const todos = useStore(s => s.todos)
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}` : ''}` },
{ id: 'agents', label: '代理' },
...pluginPanels.map(p => ({ id: `plugin:${p.id}`, label: p.title })),
]
return (
<>
<div className="tabs">
{tabs.map(t => (
<button key={t.id} className={`tab ${ui.rightTab === t.id ? 'active' : ''}`}
onClick={() => store.setUI({ rightTab: t.id })}>{t.label}</button>
))}
<button className="tab" title="收起工作台" onClick={store.toggleRight}>»</button>
<span className="tab active">收件箱{pending > 0 ? ` ${pending}` : ''}</span>
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}>🧹 收纳</button>
<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 />}
{ui.rightTab === 'agents' && <AgentsTab runs={runs} />}
{ui.rightTab.startsWith('plugin:') && (
<UIRenderer nodes={pluginPanels.find(p => `plugin:${p.id}` === ui.rightTab)?.body ?? []} docBridge={docBridge} />
)}
<CurrentTaskCard plan={plan} todos={todos} />
<InboxList cards={cards} callbacks={{
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
onLocate: (c: ActivityCard) => store.openInbox(c.id),
}} />
</div>
</>
)
}
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>
if (status === 'skipped') return <span>◌</span>
return <span style={{ color: 'var(--text-faint)' }}>○</span>
}
function PlanTab({ plan }: { plan: { title: string; steps: { id: string; text: string; status: string; agentRun?: string }[] } | null }) {
if (!plan?.steps?.length) return <Empty text="暂无计划——模型可通过 update_plan 工具创建(mock:#agents)" />
/** 当前任务常驻卡(D25 R4):计划+待办合成一张卡,钉在收件箱顶部——右栏只回答"现在要我做什么"。 */
export function CurrentTaskCard({ plan, todos }: {
plan: { title: string; steps: { id: string; text: string; status: string; agentRun?: string }[] } | null
todos: { items: { id: string; text: string; status: string }[] } | null
}) {
const steps = plan?.steps ?? []
const items = todos?.items ?? []
if (steps.length === 0 && items.length === 0) return null
const done = [...steps, ...items].filter(x => x.status === 'done' || x.status === 'completed').length
const total = steps.length + items.length
return (
<div>
<h5 style={{ margin: '4px 0 10px' }}>{plan.title}</h5>
{plan.steps.map(s => (
<div className="current-task" data-testid="current-task">
<div className="ct-head">
<span className="lamp lamp-ok">●</span>
<span className="ct-title">{plan?.title ?? '当前任务'}</span>
<span className="ct-progress">{done}/{total}</span>
</div>
{steps.map(s => (
<div key={s.id} className={`step ${s.status}`}>
<span className="step-icon"><StatusIcon status={s.status} /></span>
<StatusIcon status={s.status} />
<span style={{ flex: 1 }}>{s.text}</span>
{s.agentRun && <span className="status-dot status-completed" title="子代理执行" />}
</div>
))}
{items.map(t => (
<div key={t.id} className={`step ${t.status}`}>
<StatusIcon status={t.status} />
<span>{t.text}</span>
</div>
))}
</div>
)
}
function TodosTab({ todos }: { todos: { items: { id: string; text: string; status: string }[] } | null }) {
if (!todos?.items?.length) return <Empty text="暂无待办(mock:#agents)" />
return <>{todos.items.map(t => (
<div key={t.id} className={`step ${t.status}`}>
<span className="step-icon"><StatusIcon status={t.status} /></span>
<span>{t.text}</span>
</div>
))}</>
export 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>
if (status === 'skipped') return <span>◌</span>
return <span style={{ color: 'var(--text-faint)' }}>○</span>
}
function ArtifactsTab() {
const artifacts = useStore(s => s.artifacts)
if (artifacts.length === 0) return <Empty text="暂无工件——上传附件或让模型产出文档(mock:#doc)" />
return <>{artifacts.map(a => (
<div key={a.id} className="artifact-row" onClick={() => store.viewArtifact(a)}>
<span>{a.kind === 'image' ? '🖼' : a.kind === 'document' ? '📄' : '🗂'}</span>
<span className="name" title={a.name}>{a.name}</span>
<span className="kind">{a.kind}</span>
</div>
))}</>
}
function AgentsTab({ runs }: { runs: Run[] }) {
const convRuns = runs.filter(r => r.kind !== 'tool')
if (convRuns.length === 0) return <Empty text="暂无运行——多代理任务会在此形成任务树(mock:#agents)" />
// 任务树:顶层 run + 其子代理。
const roots = convRuns.filter(r => !r.parentRunId)
return <div className="subagent-tree">
{roots.map(r => (
<div key={r.id}>
<AgentRow run={r} depth={0} />
{convRuns.filter(c => c.parentRunId === r.id).map(c => <AgentRow key={c.id} run={c} depth={1} />)}
</div>
))}
</div>
}
function AgentRow({ run, depth }: { run: Run; depth: number }) {
/** 代理行(工作区弹层复用;点击进 AgentTracker)。 */
export function AgentRow({ run, depth }: { run: Run; depth: number }) {
const label = run.kind === 'subagent' ? `🤖 ${run.agentName || '子代理'}` : '💬 对话任务'
const terminal = isTerminal(run.status)
return (
<div className="agent-row" style={{ paddingLeft: 8 + depth * 18 }} onClick={() => store.openTracker(run.id)}>
<span className={`status-dot status-${run.status}`} />
<span style={{ flex: 1 }}>{label}</span>
<span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{terminal ? run.status : (run.phase || run.status)}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{run.status}</span>
</div>
)
}
function Empty({ text }: { text: string }) {
return <div style={{ color: 'var(--text-faint)', fontSize: 12.5, padding: '10px 4px', lineHeight: 1.8 }}>{text}</div>
}
+92
View File
@@ -0,0 +1,92 @@
// 工作区弹层(提案 T4/D25):工件/代理/插件面板/插件声明工作区统一经 OverlayHost 呈现;
// ⌘1–9 快捷键与左栏快捷区共享同一清单(workspaceEntries)。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { OverlayHost, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区随后。 */
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
const s = store.getState()
const out = [
{ key: 'artifacts', title: '工件', icon: '📦', shortcut: '⌘1' },
{ key: 'agents', title: '代理', icon: '🤖', shortcut: '⌘2' },
...s.pluginPanels.map((p, i) => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', shortcut: `⌘${i + 3}` })),
...s.serverWorkspaces.map((w, i) => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', shortcut: w.shortcut ? `⌘${w.shortcut}` : `⌘${i + 3 + s.pluginPanels.length}` })),
]
return out.slice(0, 9)
}
export function Workspaces() {
const active = useStore(s => s.ui.activeWorkspace)
const artifacts = useStore(s => s.artifacts)
const runs = useStore(s => s.runs)
const pluginPanels = useStore(s => s.pluginPanels)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
if (!active) return null
const entry = workspaceEntries().find(e => e.key === active)
const close = () => store.closeWorkspace()
if (active === 'artifacts') {
return <OverlayHost title="工件" icon="📦" shortcut="⌘1" badge={artifacts.length || undefined} onClose={close}>
<ArtifactsPanel />
</OverlayHost>
}
if (active === 'agents') {
const convRuns = runs.filter(r => r.kind !== 'tool')
return <OverlayHost title="代理" icon="🤖" shortcut="⌘2" onClose={close}>
<AgentsPanel runs={convRuns} />
</OverlayHost>
}
if (active.startsWith('plugin:')) {
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
nodes={panel?.body ?? []} docBridge={docBridge as DocBridge} onRefresh={() => store.refreshPlugins()} />
}
if (active.startsWith('ws:')) {
const ws = serverWorkspaces.find(w => `ws:${w.id}` === active)
return <OverlayHost title={ws?.title ?? '工作区'} icon={ws?.icon || '🗂'} shortcut={entry?.shortcut} onClose={close}
nodes={ws?.body ?? []} docBridge={docBridge as DocBridge} onRefresh={() => store.refreshPlugins()} />
}
return null
}
/** 工件面板(自 Workbench tabs 迁入;点击进 DocViewer 深读)。 */
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
if (artifacts.length === 0) return <div className="empty-note">暂无工件——上传附件或让模型产出文档(mock:#doc)</div>
return <>{artifacts.map(a => (
<div key={a.id} className="artifact-row" onClick={() => store.viewArtifact(a)}>
<span>{a.kind === 'image' ? '🖼' : a.kind === 'document' ? '📄' : '🗂'}</span>
<span className="name" title={a.name}>{a.name}</span>
<span className="kind">{a.kind}</span>
</div>
))}</>
}
/** 代理面板(任务树;点击进 AgentTracker 深读)。 */
export function AgentsPanel({ runs }: { runs: Run[] }) {
if (runs.length === 0) return <div className="empty-note">暂无运行——多代理任务会在此形成任务树(mock:#agents)</div>
const roots = runs.filter(r => !r.parentRunId)
return <div className="subagent-tree">
{roots.map(r => (
<div key={r.id}>
<AgentRow run={r} depth={0} />
{runs.filter(c => c.parentRunId === r.id).map(c => <AgentRow key={c.id} run={c} depth={1} />)}
</div>
))}
</div>
}
/** ⌘1–9 / Ctrl+1–9 全局快捷键(挂载于 App)。 */
export function workspaceShortcuts(e: KeyboardEvent) {
if (!(e.metaKey || e.ctrlKey)) return
const n = parseInt(e.key, 10)
if (!Number.isInteger(n) || n < 1 || n > 9) return
const entries = workspaceEntries()
const hit = entries[n - 1]
if (!hit) return
e.preventDefault()
store.openWorkspace(hit.key)
}
+21
View File
@@ -192,11 +192,32 @@ export interface UIField {
options?: string[]; required?: boolean; placeholder?: string; help?: string; default?: string;
}
// ---- 提示词库(prompt-library)----
export interface PromptTrigger {
kind: 'intent' | 'command' | 'attachment' | 'manual';
keywords?: string[]; command?: string;
}
export interface PromptModule {
id: string; layer: 'base' | 'env' | 'task' | 'agent' | 'tool' | 'aux' | 'style';
version: number; title: string; description?: string; priority?: number;
triggers?: PromptTrigger[]; requiresAttachments?: boolean;
body: string; outputContract?: string;
source?: 'builtin' | 'plugin' | 'user'; owner?: string;
basedOnVersion?: number; drifted?: boolean; disabled?: boolean;
}
export interface PromptBlock { moduleId: string; version: number; layer: string; body: string; }
export interface PromptHitRecord { moduleId: string; layer: string; reason: string; injected: boolean; }
// ---- 画像 / 设置 ----
export interface AgentProfile {
id: string; owner?: string; name: string; description?: string; builtin?: boolean; source?: string;
systemPrompt: string;
/** 提示词库模块引用(有序;空 = 默认 base+env 全集)。 */
promptModules?: string[];
model: { chat?: string; lightweight?: string; embedding?: string; rerank?: string };
tools: { allow: string[]; deny?: string[]; confirm?: string[] };
context: {
+24 -4
View File
@@ -34,6 +34,7 @@ export interface UIState {
settingsOpen: boolean
trackerRunId: string | null
viewingArtifact: Artifact | null
activeWorkspace: string | null
}
interface AppState {
@@ -53,6 +54,7 @@ interface AppState {
cards: ActivityCard[]
profiles: AgentProfile[]
pluginPanels: PluginPanel[]
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
ui: UIState
connected: boolean
error: string | null
@@ -65,6 +67,7 @@ const DEFAULT_UI: UIState = {
rightWidth: 340,
rightCollapsed: false,
rightTab: 'inbox',
activeWorkspace: null,
theme: 'dark',
autoCollapseProcess: true,
settingsOpen: false,
@@ -85,6 +88,7 @@ let state: AppState = {
projects: [], currentProjectId: null, conversations: [], currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], cards: [], profiles: [], pluginPanels: [],
serverWorkspaces: [],
ui: loadUI(), connected: false, error: null, toast: null,
}
@@ -304,10 +308,12 @@ export const store = {
return
}
if (out.run) {
set({
live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() },
messages: [...state.messages, out.message],
})
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
if (!state.live || state.live.runId !== out.run.id) {
set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() } })
}
set({ messages: [...state.messages, out.message] })
store.connectIfNeeded()
}
} catch (e) {
@@ -391,6 +397,10 @@ export const store = {
const panels = list.flatMap(p => p.panels ?? [])
set({ pluginPanels: panels })
} catch { /* ignore */ }
try {
const ws = await api.listWorkspaces()
set({ serverWorkspaces: ws ?? [] })
} catch { /* ignore */ }
},
async uploadAttachment(file: File): Promise<string | null> {
@@ -409,6 +419,16 @@ export const store = {
setUI,
toggleLeft() { setUI({ leftCollapsed: !state.ui.leftCollapsed }) },
toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) },
openWorkspace(id: string) { setUI({ activeWorkspace: id }) },
/** 收纳策略(T5):一键收纳已完成卡(done→dismissed;resolved 盖章留痕不动)。 */
async tidyCards() {
const done = state.cards.filter(c => c.status === 'done')
for (const c of done) {
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
}
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
},
closeWorkspace() { setUI({ activeWorkspace: null }) },
openInbox(cardId?: string) {
setUI({ rightCollapsed: false, rightTab: 'inbox' })
if (cardId) setTimeout(() => {
+46 -6
View File
@@ -39,25 +39,46 @@ button { font-family: inherit; }
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
/* ---- 中栏对话 ---- */
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; }
/* min-height:0 须随列向 flex 链下传(.main→.chat→.chat-body):flex 项的自动最小
高度是内容高度,缺失时消息会撑破 .chat——chat-body 永不滚动、输入栏被顶出视口。 */
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.chat-header { padding: 10px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; background: var(--bg-raised); }
.chat-title { font-weight: 600; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-body { flex: 1; overflow-y: auto; padding: 18px max(18px, 12%); }
.chat-body { flex: 1; overflow-y: auto; min-height: 0; padding: 18px max(18px, 12%); }
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 8px; }
.msg { margin-bottom: 18px; }
.msg-role { font-size: 12px; color: var(--text-faint); margin-bottom: 4px; display: flex; gap: 8px; align-items: center; }
.msg.user .bubble { background: var(--accent-soft); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; white-space: pre-wrap; }
.part { margin: 4px 0; }
.part-thinking { color: var(--text-faint); font-size: 13px; border-left: 2px solid var(--border); padding-left: 10px; white-space: pre-wrap; }
.part-thinking { color: var(--text-dim); font-size: 12.5px; border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; }
.part-thinking summary { cursor: pointer; color: var(--text-faint); user-select: none; }
.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; }
.part-tool { font-size: 12.5px; }
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 8px; margin: 2px 4px 2px 0; font-family: var(--mono); }
.tool-chip .status-ok { color: var(--ok); }
.tool-chip .status-error { color: var(--err); }
.part-text { white-space: pre-wrap; line-height: 1.7; }
.part-text :is(h1,h2,h3) { margin: 0.6em 0 0.3em; }
.part-text { line-height: 1.65; overflow-wrap: break-word; }
/* 块级间距只由标签自身控制:禁用 pre-wrap(marked 输出的块间换行会被渲染成空行,
叠加出"列表逐项空一行"的松散排版);行内换行语义交给 Markdown 段落。 */
.part-text > *:first-child { margin-top: 0; }
.part-text > *:last-child { margin-bottom: 0; }
.part-text p { margin: 0.5em 0; }
.part-text :is(h1,h2,h3,h4) { margin: 0.8em 0 0.35em; line-height: 1.35; font-weight: 600; }
.part-text h1 { font-size: 1.3em; }
.part-text h2 { font-size: 1.18em; }
.part-text h3 { font-size: 1.06em; }
.part-text :is(ul,ol) { margin: 0.4em 0; padding-left: 1.6em; }
.part-text li { margin: 0.15em 0; }
.part-text li > p { margin: 0.2em 0; }
.part-text pre { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; }
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
.part-text :is(p,ul,ol) { margin: 0.4em 0; }
.part-text pre code { background: none; padding: 0; }
.part-text blockquote { margin: 0.5em 0; padding: 0.1em 0 0.1em 10px; border-left: 3px solid var(--border); color: var(--text-dim); }
.part-text table { border-collapse: collapse; margin: 0.6em 0; font-size: 0.95em; display: block; max-width: 100%; overflow-x: auto; }
.part-text th, .part-text td { border: 1px solid var(--border); padding: 4px 10px; text-align: left; }
.part-text th { background: var(--bg-raised); font-weight: 600; }
.part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
.part-text a { color: var(--accent); }
.part-error { color: var(--err); background: color-mix(in srgb, var(--err) 8%, transparent); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
.artifact-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); background: var(--accent-soft); border-radius: var(--radius-sm); padding: 4px 10px; cursor: pointer; font-size: 13px; }
.artifact-chip:hover { filter: brightness(1.15); }
@@ -182,3 +203,22 @@ button { font-family: inherit; }
background: #3b82f6; color: #fff; font-size: 14px; cursor: pointer;
}
.login-err { margin-top: 10px; font-size: 12px; color: #f87171; }
/* 工作区快捷区(T4/D26 左栏)与当前任务常驻卡(D25 R4) */
.ws-quick{padding:6px 8px 8px;border-bottom:1px solid var(--border)}
.ws-quick-list{display:flex;flex-direction:column;gap:2px;margin-top:4px}
.ws-quick-item{display:flex;align-items:center;gap:8px;padding:4px 8px;border:none;background:transparent;color:inherit;cursor:pointer;border-radius:6px;text-align:left;font-size:12px}
.ws-quick-item:hover{background:rgba(120,160,255,.12)}
.ws-quick-item .sc{margin-left:auto;font-size:10px;color:var(--text-faint);border:1px solid var(--border);border-radius:3px;padding:0 4px}
.current-task{border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:8px;background:rgba(120,160,255,.05)}
.ct-head{display:flex;align-items:center;gap:8px;font-weight:600;margin-bottom:6px}
.ct-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ct-progress{font-size:11px;color:var(--text-faint)}
.project-badge{font-size:11px;padding:1px 8px;border-radius:8px;background:rgba(120,160,255,.15);color:var(--accent);white-space:nowrap}
.tab-hint{font-size:10px;color:var(--text-faint);align-self:center}
.empty-note{color:var(--text-faint);font-size:12px;padding:12px 4px}
/* 收纳策略(T5):收起态角标 + 收纳按钮 */
.rail-badge{position:absolute;top:-4px;right:-6px;font-size:10px;background:var(--warn,#d29922);color:#fff;border-radius:8px;padding:0 5px;min-width:14px}
.right-panel.collapsed .icon-only button{position:relative}
.tab-action{font-size:11px;color:var(--text-faint)}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/ProjectNav.tsx","./src/components/SettingsModal.tsx","./src/components/Workbench.tsx","./test/auth.test.ts","./test/contracts.test.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/ProjectNav.tsx","./src/components/SettingsModal.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts"],"version":"5.9.3"}