Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a54022c438 | ||
|
|
a617504268 | ||
|
|
4f4711a22a | ||
|
|
e5b65ca79a | ||
|
|
58f3599660 | ||
|
|
298c41ff67 | ||
|
|
6a2b21a28d | ||
|
|
ee85556584 | ||
|
|
801a684c26 |
+12
-1
@@ -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} />}
|
||||
|
||||
+2
-1
@@ -78,7 +78,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 游标续传;断线自动重连 ----
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 @@ 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 projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目')
|
||||
|
||||
const [text, setText] = useState('')
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
|
||||
@@ -225,6 +236,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 => {
|
||||
@@ -238,13 +250,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 +305,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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -391,6 +395,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 +417,22 @@ 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(() => {
|
||||
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 }) },
|
||||
|
||||
+23
-2
@@ -39,10 +39,12 @@ 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; }
|
||||
@@ -182,3 +184,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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user