Compare commits

...
Author SHA1 Message Date
weichen dc70399d45 Rebuild the left nav as a task-centric sidebar (option A, R1)
Why: creation entries were split (project + at sidebar top-left, new
session at bottom-left) and the project tree forced per-project
navigation; the approved option A design makes one primary New Task
entry, organization a view mode, and identity always present.

Evidence:
- New TaskSidebar: single New Task split button (current-project context,
  Cmd-N, secondary menu for new project / new task in project), project
  <-> timeline organization with persisted collapse, pinned section,
  archive view toggle, hover row actions, account footer fed by
  /api/me (embedded mode shows local tenant; logout clears the key),
  workspace icon strip retained (D25 shared list with Cmd-1..9).
- store: tasks = cross-project list via GET /api/conversations; openTask
  switches project context; pin/archive optimistic + refresh; last
  project context persisted; stale conversations field removed.
- tasklist.ts pure helpers + 6 vitest cases; npm run build passes;
  headless walk on the real binary: cross-project open, pinned-first,
  archive hide/show/restore, new-task flow, zero console errors.

Impact: ProjectNav removed; contracts.ts regenerated from core
(pinned/archived); no composer/right-pane changes (D25/D26/D31 intact).

Refs: Agent-System-Documents docs/proposals/2026-09-30-ui-redesign-and-auth.md
2026-09-30 10:41:12 +00:00
weichen 3aeafc4440 Merge pull request 'feature/prompt-library' (#10) from feature/prompt-library into main 2026-09-30 10:36:34 +00:00
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
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
16 changed files with 875 additions and 222 deletions
+19 -5
View File
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from 'react'
import { auth } from './auth'
import { store, useStore } from './store'
import { ProjectNav } from './components/ProjectNav'
import { TaskSidebar } from './components/TaskSidebar'
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')
@@ -105,15 +113,18 @@ export function App() {
{!ui.leftCollapsed ? (
<>
<div className="left-nav" style={{ width: ui.leftWidth }}>
<ProjectNav />
<TaskSidebar />
</div>
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
</>
) : (
<div className="left-nav collapsed" style={{ width: 52 }}>
<div className="icon-only">
<button title="展开侧栏" onClick={store.toggleLeft}>☰</button>
<button title="新会话" onClick={() => store.createConversation()}>✚</button>
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}>☰</button>
<button title="新建任务(⌘N)" onClick={() => store.createTask()}>✚</button>
<button title="收件箱" onClick={() => store.openInbox()}>◍
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
<button title="设置" onClick={() => store.openSettings(true)}>⚙</button>
</div>
</div>
@@ -133,11 +144,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} />}
+8 -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'
@@ -24,6 +24,10 @@ export const api = {
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)),
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean }>(r)),
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
@@ -67,6 +71,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 +83,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 游标续传;断线自动重连 ----
+5
View File
@@ -30,6 +30,11 @@ export const auth = {
headers.set('Authorization', `Bearer ${key}`)
return fetch(input, { ...init, headers })
},
// 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。
logout() {
this.clear()
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
},
// 无法带头的资源 URL(EventSource / <img> / 下载):追加 ?key=。
withKey(url: string): string {
const key = this.apiKey()
+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>
)
}
+68 -14
View File
@@ -4,10 +4,11 @@ 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 })
// 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': {
@@ -34,8 +41,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:
@@ -85,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} />}
@@ -94,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 (
@@ -161,18 +192,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>
@@ -181,7 +213,7 @@ function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) =>
export function Conversation() {
const currentConvId = useStore(s => s.currentConvId)
const conversation = useStore(s => s.conversations.find(c => c.id === s.currentConvId))
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
const messages = useStore(s => s.messages)
const activeHitl = useStore(s => s.activeHitl)
const live = useStore(s => s.live)
@@ -189,6 +221,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[]>([])
@@ -201,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
@@ -225,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 => {
@@ -238,18 +273,37 @@ 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>
)
}
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>
@@ -276,7 +330,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 -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>
)
}
-62
View File
@@ -1,62 +0,0 @@
// 左栏:项目折叠组 → 会话列表;底部账户/设置入口(4.4)。
import { useEffect, useState } from 'react'
import { store, useStore } from '../store'
export function ProjectNav() {
const projects = useStore(s => s.projects)
const currentProjectId = useStore(s => s.currentProjectId)
const conversations = useStore(s => s.conversations)
const currentConvId = useStore(s => s.currentConvId)
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({})
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
useEffect(() => { setNewName(`项目 ${projects.length + 1}`) }, [projects.length])
return (
<>
<div className="nav-header">
<span className="nav-label">Agent-System</span>
<button title="新建项目" onClick={() => setCreating(v => !v)}>✚</button>
</div>
{creating && (
<div style={{ padding: '0 10px 8px', display: 'flex', gap: 6 }}>
<input className="search-input" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && newName.trim()) { await store.createProject(newName.trim()); setCreating(false) }
}} placeholder="项目名称" autoFocus />
<button className="btn" onClick={async () => { if (newName.trim()) { await store.createProject(newName.trim()); setCreating(false) } }}>建</button>
</div>
)}
<div className="nav-scroll">
{projects.length === 0 && <div style={{ padding: 12, color: 'var(--text-faint)', fontSize: 12.5 }}>尚无项目——点击 ✚ 创建</div>}
{projects.map(p => {
const open = !collapsedProjects[p.id]
return (
<div className="nav-project" key={p.id}>
<div className={`nav-project-name ${open ? 'open' : ''} ${p.id === currentProjectId ? '' : ''}`}
style={p.id === currentProjectId ? { color: 'var(--text)' } : undefined}
onClick={async () => {
setCollapsedProjects(c => ({ ...c, [p.id]: !(c[p.id] ?? p.id === currentProjectId ? false : true) }))
await store.openProject(p.id)
}}>
<span className="chev">▶</span>
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
</div>
{open && p.id === currentProjectId && conversations.map(c => (
<div key={c.id} className={`nav-conv ${c.id === currentConvId ? 'active' : ''}`}
onClick={() => store.openConversation(c.id)} title={c.title}>
{c.title}
</div>
))}
</div>
)
})}
</div>
<div className="nav-footer">
<button className="nav-label" onClick={() => store.createConversation()}>+ 新会话</button>
<button className="nav-label" onClick={() => store.openSettings(true)}>⚙ 设置</button>
</div>
</>
)
}
+242
View File
@@ -0,0 +1,242 @@
// 左栏(方案 A「任务中心」,提案 2026-09-30 R1):单一主创建入口(P6)+
// 组织视图(P8:按项目/时间线)+ 置顶/归档 + 账户区(P7)。
// 工作区快捷图标条保留(D25:⌘ 与左栏共享同一清单)。
import { useEffect, useRef, useState } from 'react'
import { store, useStore } from '../store'
import { auth } from '../auth'
import type { Conversation } from '../contracts'
import { workspaceEntries } from './Workspaces'
import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist'
const GROUPS_KEY = 'as.taskGroups'
function loadCollapsed(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(GROUPS_KEY) ?? '{}') } catch { return {} }
}
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClose: () => void; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!anchor || !ref.current) return
const r = anchor.getBoundingClientRect()
const el = ref.current
let x = r.left, y = r.bottom + 6
if (x + el.offsetWidth > window.innerWidth - 8) x = window.innerWidth - el.offsetWidth - 8
if (y + el.offsetHeight > window.innerHeight - 8) y = r.top - el.offsetHeight - 6
el.style.left = `${Math.max(8, x)}px`
el.style.top = `${y}px`
const h = (e: MouseEvent) => { if (!el.contains(e.target as Node)) onClose() }
window.addEventListener('mousedown', h)
return () => window.removeEventListener('mousedown', h)
}, [anchor])
if (!anchor) return null
return <div ref={ref} className="tsb-menu" onClick={e => e.stopPropagation()}>{children}</div>
}
function MenuItem({ onClick, danger, children }: { onClick: () => void; danger?: boolean; children: React.ReactNode }) {
return <button className={`tsb-mi ${danger ? 'danger' : ''}`} onClick={onClick}>{children}</button>
}
export function TaskSidebar() {
const projects = useStore(s => s.projects)
const tasks = useStore(s => s.tasks)
const me = useStore(s => s.me)
const currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId)
const org = useStore(s => s.ui.taskOrg)
const showArchived = useStore(s => s.ui.showArchived)
const theme = useStore(s => s.ui.theme)
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
const [q, setQ] = useState('')
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed)
const [menu, setMenu] = useState<{ kind: 'new' | 'filter' | 'account' | `row:${string}`; anchor: HTMLElement } | null>(null)
const [creatingProject, setCreatingProject] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
// ⌘N/Ctrl+N 新建任务(P6:创建入口唯一 + 快捷键)。
useEffect(() => {
const h = (e: KeyboardEvent) => {
const k = e.key.toLowerCase()
if ((e.metaKey || e.ctrlKey) && k === 'n') { e.preventDefault(); store.createTask() }
if ((e.metaKey || e.ctrlKey) && k === 'b') { e.preventDefault(); store.toggleLeft() }
}
window.addEventListener('keydown', h, true)
return () => window.removeEventListener('keydown', h, true)
}, [])
const tglGroup = (key: string) => {
setCollapsed(c => {
const next = { ...c, [key]: !c[key] }
try { localStorage.setItem(GROUPS_KEY, JSON.stringify(next)) } catch { /* ignore */ }
return next
})
}
const setOrg = (o: TaskOrg) => store.setUI({ taskOrg: o })
const visible = filterTasks(tasks, q)
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const groups = org === 'project' ? groupByProject(rest, projects) : groupByTimeline(rest)
const currentProject = projects.find(p => p.id === currentProjectId)
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
const rowActions = (t: Conversation) => (
<span className="tsb-row-acts">
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}>📌</button>
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
{t.archived ? '↩' : '🗄'}
</button>
</span>
)
const taskRow = (t: Conversation, badge?: string) => (
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
onClick={() => store.openTask(t.id)}
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
{badge && <span className="tsb-pbadge">{badge}</span>}
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
{rowActions(t)}
</div>
)
return (
<>
<div className="tsb-brand">
<span className="tsb-logo">A</span>
<span className="tsb-name">Agent-System</span>
<span style={{ flex: 1 }} />
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}>⇤</button>
</div>
{/* 单一主创建入口(P6):新建任务 + 次级菜单(新建项目/指定项目新建) */}
<div className="tsb-actions">
<div className="tsb-newtask">
<button className="tsb-newtask-main" title="在当前项目新建任务(⌘N)" onClick={() => store.createTask()}>
+ 新建任务{currentProject ? <span className="tsb-newtask-proj">{currentProject.name}</span> : null}
</button>
<button className="tsb-newtask-caret" title="更多创建选项"
onClick={e => setMenu(m => m?.kind === 'new' ? null : { kind: 'new', anchor: e.currentTarget })}>▾</button>
</div>
<input className="tsb-search" placeholder="搜索任务…" value={q} onChange={e => setQ(e.target.value)} />
</div>
{/* 组织视图切换(P8)+ 过滤 */}
<div className="tsb-orgrow">
<div className="tsb-pills">
<button className={`tsb-pill ${org === 'project' ? 'on' : ''}`} onClick={() => setOrg('project')}>按项目</button>
<button className={`tsb-pill ${org === 'timeline' ? 'on' : ''}`} onClick={() => setOrg('timeline')}>时间线</button>
</div>
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}>🗄</button>
</div>
<div className="tsb-scroll">
{creatingProject && (
<div className="tsb-newproject">
<input className="tsb-search" autoFocus value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && newProjectName.trim()) {
await store.createProject(newProjectName.trim())
setCreatingProject(false); setNewProjectName('')
}
if (e.key === 'Escape') setCreatingProject(false)
}}
placeholder="项目名称,回车创建" />
</div>
)}
{pinned.length > 0 && (
<>
<div className="tsb-seclabel">置顶</div>
{pinned.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
</>
)}
{groups.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">尚无任务——点击「新建任务」开始<br />(项目内素材上传、检索与生成全程可在对话中完成)</div>
)}
{groups.map(g => (
<div key={g.key} className={`tsb-group ${collapsed[g.key] ? 'closed' : ''}`}>
{org === 'project' ? (
<div className="tsb-group-head" onClick={() => tglGroup(g.key)}>
<span className="tsb-chev">▶</span>
<span className="tsb-group-name" title={g.label}>{g.label}</span>
<span className="tsb-group-cnt">{g.tasks.length}</span>
<span style={{ flex: 1 }} />
<button className="tsb-group-add" title="在此项目新建任务" onClick={e => { e.stopPropagation(); store.createTask(g.projectId) }}>+</button>
</div>
) : (
<div className="tsb-seclabel">{g.label}</div>
)}
{!collapsed[g.key] && g.tasks.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
</div>
))}
</div>
{/* 工作区快捷图标条(D25:与 ⌘1–9 同一清单) */}
<div className="tsb-wsstrip">
{workspaceEntries().map(e => (
<button key={e.key} title={`${e.title}(${e.shortcut})`} onClick={() => store.openWorkspace(e.key)}>{e.icon}</button>
))}
<span style={{ flex: 1 }} />
<button title={theme === 'dark' ? '切换浅色' : '切换深色'} onClick={() => store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' })}>{theme === 'dark' ? '☀' : '☾'}</button>
</div>
{/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
<div className="tsb-footer">
<button className="tsb-user" onClick={e => setMenu(m => m?.kind === 'account' ? null : { kind: 'account', anchor: e.currentTarget })}>
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
<span className="tsb-user-info">
<span className="tsb-user-name">{me?.name || me?.owner || '未连接'}</span>
<span className="tsb-user-sub">{me ? (me.auth ? 'API 密钥 · 已认证' : '本地模式 · 默认租户') : '…'}</span>
</span>
<span className="tsb-user-chev">▿</span>
</button>
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}>⚙</button>
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.openInbox()}>◍ {pendingBadge}</button>}
</div>
{/* 弹出菜单们 */}
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
<MenuItem onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
<div className="tsb-menu-div" />
<MenuItem onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
<MenuItem key={p.id} onClick={() => { store.createTask(p.id); setMenu(null) }}>在「{p.name}」新建任务</MenuItem>
))}
</Menu>
<Menu anchor={rowMenuAnchor} onClose={() => setMenu(null)}>
{rowTask && <>
<MenuItem onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
<MenuItem onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
<div className="tsb-menu-div" />
<MenuItem onClick={() => { store.setUI({ taskOrg: 'timeline' }); setMenu(null) }}>按时间线查看</MenuItem>
</>}
</Menu>
<Menu anchor={menu?.kind === 'account' ? menu.anchor : null} onClose={() => setMenu(null)}>
<div className="tsb-acc-head">
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
<span>
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
</span>
</div>
<div className="tsb-menu-div" />
<MenuItem onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
<MenuItem onClick={() => { store.openInbox(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
{me?.auth && <>
<div className="tsb-menu-div" />
<MenuItem danger onClick={() => { auth.logout() }}>退出登录</MenuItem>
</>}
</Menu>
</>
)
}
+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)
}
+22
View File
@@ -14,6 +14,7 @@ export interface Project {
export interface Conversation {
id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID;
pinned?: boolean; archived?: boolean;
createdAt: string; updatedAt: string;
}
@@ -192,11 +193,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: {
+91 -13
View File
@@ -34,13 +34,20 @@ export interface UIState {
settingsOpen: boolean
trackerRunId: string | null
viewingArtifact: Artifact | null
activeWorkspace: string | null
taskOrg: 'project' | 'timeline'
showArchived: boolean
lastProjectId: string | null
}
export interface Me { owner: string; name: string; auth: boolean }
interface AppState {
contextObs: Record<string, ContextObs>
projects: Project[]
currentProjectId: string | null
conversations: Conversation[]
tasks: Conversation[]
me: Me | null
currentConvId: string | null
messages: Message[]
runs: Run[]
@@ -53,6 +60,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,11 +73,15 @@ const DEFAULT_UI: UIState = {
rightWidth: 340,
rightCollapsed: false,
rightTab: 'inbox',
activeWorkspace: null,
theme: 'dark',
autoCollapseProcess: true,
settingsOpen: false,
trackerRunId: null,
viewingArtifact: null,
taskOrg: 'project',
showArchived: false,
lastProjectId: null,
}
function loadUI(): UIState {
@@ -82,9 +94,10 @@ function loadUI(): UIState {
let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, conversations: [], currentConvId: null,
projects: [], currentProjectId: null, tasks: [], me: null, 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,
}
@@ -122,12 +135,23 @@ export const store = {
async refreshProjects() {
try {
const projects = await api.listProjects()
const current = projects.find(p => p.id === state.currentProjectId) ?? projects[0]
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
set({ projects, currentProjectId: current?.id ?? null, error: null })
if (current) await store.openProject(current.id)
} catch (e) {
set({ error: String(e) })
}
store.refreshTasks()
store.refreshMe()
},
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
async refreshTasks(includeArchived = state.ui.showArchived) {
try { set({ tasks: await api.listTasks(includeArchived) }) } catch { /* 列表竞态,静默 */ }
},
async refreshMe() {
try { set({ me: await api.me() }) } catch { /* 身份不可用时侧栏只显示通用入口 */ }
},
async createProject(name: string) {
@@ -138,16 +162,47 @@ export const store = {
},
async openProject(projectId: string) {
const conversations = await api.listConversations(projectId)
set({ currentProjectId: projectId, conversations })
set({ currentProjectId: projectId })
store.setUI({ lastProjectId: projectId })
await store.refreshArtifacts(projectId)
},
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。 */
async createTask(projectId?: string) {
const pid = projectId ?? state.currentProjectId
if (!pid) { store.toast('请先创建项目'); return }
const c = await api.createConversation(pid)
await store.refreshTasks()
await store.openTask(c.id)
},
/** 兼容旧入口(⌘/命令总线) */
async createConversation(title?: string) {
if (!state.currentProjectId) return
const c = await api.createConversation(state.currentProjectId, title)
await store.openProject(state.currentProjectId)
await store.openConversation(c.id)
if (title) store.toast('已在当前项目创建任务')
await store.createTask()
},
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
async openTask(convId: string) {
const t = state.tasks.find(x => x.id === convId)
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
await store.openConversation(convId)
},
async pinTask(convId: string, pinned: boolean) {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, pinned } : t)
set({ tasks })
try { await api.setConversationFlags(convId, { pinned }) } catch (e) { store.toast(`置顶失败:${e}`); store.refreshTasks() }
},
async archiveTask(convId: string, archived: boolean) {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, archived } : t)
set({ tasks })
try {
await api.setConversationFlags(convId, { archived })
if (state.currentConvId === convId && archived) store.toast('任务已归档')
} catch (e) { store.toast(`归档失败:${e}`) }
store.refreshTasks()
},
async openConversation(convId: string) {
@@ -174,6 +229,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)
store.refreshTasks()
} catch (e) {
set({ error: String(e) })
}
@@ -304,10 +360,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 +449,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 +471,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 }) },
+120 -27
View File
@@ -17,47 +17,48 @@ button { font-family: inherit; }
.right-panel { background: var(--bg-panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
.right-panel.collapsed { width: 44px !important; }
/* ---- 左栏 ---- */
.nav-header { padding: 14px 14px 8px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.nav-header button { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px; padding: 4px; border-radius: var(--radius-sm); }
.nav-header button:hover { background: var(--bg-hover); color: var(--text); }
.nav-scroll { flex: 1; overflow-y: auto; padding: 4px 8px 8px; }
.nav-project { margin-bottom: 4px; }
.nav-project-name { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 500; cursor: pointer; user-select: none; }
.nav-project-name:hover { background: var(--bg-hover); }
.nav-project-name .chev { font-size: 10px; transition: transform .12s; }
.nav-project-name.open .chev { transform: rotate(90deg); }
.nav-conv { padding: 5px 8px 5px 26px; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav-conv:hover { background: var(--bg-hover); color: var(--text); }
.nav-conv.active { background: var(--accent-soft); color: var(--accent); }
.nav-footer { border-top: 1px solid var(--border); padding: 8px; display: flex; gap: 6px; }
.nav-footer button { flex: 1; background: none; border: none; color: var(--text-dim); padding: 7px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12px; }
.nav-footer button:hover { background: var(--bg-hover); color: var(--text); }
.left-nav.collapsed .nav-label { display: none; }
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 10px; }
.icon-only button { background: none; border: none; color: var(--text-dim); font-size: 16px; cursor: pointer; padding: 8px; border-radius: var(--radius-sm); }
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
/* ---- 左栏:任务中心(方案 A)见文末 tsb-* ---- */
/* ---- 中栏对话 ---- */
.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 +183,95 @@ 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) */
.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)}
/* ---- 左栏:任务中心(方案 A,提案 2026-09-30) ---- */
.tsb-brand { display: flex; align-items: center; gap: 8px; padding: 12px 12px 6px; }
.tsb-logo { width: 22px; height: 22px; border-radius: 6px; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
.tsb-name { font-weight: 600; font-size: 13.5px; }
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; position: relative; }
.tsb-iconbtn:hover { background: var(--bg-hover); color: var(--text); }
.tsb-iconbtn.active { color: var(--accent); background: var(--accent-soft); }
.tsb-actions { padding: 4px 10px 8px; display: flex; flex-direction: column; gap: 6px; }
.tsb-newtask { display: flex; height: 34px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-soft); overflow: hidden; }
.tsb-newtask-main { flex: 1; display: flex; align-items: center; gap: 6px; padding: 0 10px; background: none; border: none; color: var(--accent); font-weight: 600; font-size: 13px; cursor: pointer; min-width: 0; }
.tsb-newtask-main:hover { filter: brightness(1.08); }
.tsb-newtask-proj { font-weight: 400; font-size: 11.5px; opacity: .8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-newtask-caret { width: 28px; border: none; border-left: 1px solid var(--accent); background: none; color: var(--accent); cursor: pointer; font-size: 10px; }
.tsb-newtask-caret:hover { background: var(--accent-soft); }
.tsb-search { height: 30px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 10px; font-size: 12.5px; width: 100%; }
.tsb-search:focus { border-color: var(--accent); outline: none; }
.tsb-search::placeholder { color: var(--text-faint); }
.tsb-orgrow { display: flex; align-items: center; gap: 6px; padding: 0 10px 8px; }
.tsb-pills { flex: 1; display: flex; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 2px; gap: 2px; }
.tsb-pill { flex: 1; height: 22px; border: none; border-radius: 4px; background: none; color: var(--text-faint); font-size: 12px; cursor: pointer; }
.tsb-pill:hover { color: var(--text-dim); }
.tsb-pill.on { background: var(--bg-active); color: var(--text); font-weight: 500; }
.tsb-scroll { flex: 1; overflow-y: auto; padding: 0 8px 8px; min-height: 0; }
.tsb-seclabel { font-size: 11px; color: var(--text-faint); padding: 10px 8px 4px; letter-spacing: .3px; }
.tsb-empty { padding: 18px 12px; color: var(--text-faint); font-size: 12.5px; line-height: 1.8; text-align: center; }
.tsb-newproject { padding: 0 2px 8px; }
.tsb-group { margin-bottom: 2px; }
.tsb-group-head { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 6px 0 8px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 500; font-size: 13px; cursor: pointer; user-select: none; }
.tsb-group-head:hover { background: var(--bg-hover); }
.tsb-group.closed .tsb-chev { transform: rotate(-90deg); }
.tsb-chev { font-size: 9px; color: var(--text-faint); transition: transform .12s; }
.tsb-group-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-group-cnt { font-size: 11px; color: var(--text-faint); font-weight: 400; }
.tsb-group-add { visibility: hidden; width: 20px; height: 20px; border: none; border-radius: 4px; background: none; color: var(--text-faint); font-size: 12px; cursor: pointer; }
.tsb-group-head:hover .tsb-group-add { visibility: visible; }
.tsb-group-add:hover { background: var(--bg-active); color: var(--text); }
.tsb-task { display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 8px; margin: 1px 0; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; }
.tsb-task:hover { background: var(--bg-hover); color: var(--text); }
.tsb-task.active { background: var(--bg-active); color: var(--text); }
.tsb-task.archived .tsb-task-t { text-decoration: line-through; opacity: .6; }
.tsb-task-t { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.tsb-task-tm { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
.tsb-pbadge { font-size: 10.5px; color: var(--text-faint); border: 1px solid var(--border); border-radius: 4px; padding: 0 5px; line-height: 1.5; white-space: nowrap; }
.tsb-row-acts { display: none; gap: 2px; }
.tsb-task:hover .tsb-row-acts { display: inline-flex; }
.tsb-task:hover .tsb-task-tm { display: none; }
.tsb-row-act { border: none; background: none; color: var(--text-faint); font-size: 11px; cursor: pointer; padding: 2px 3px; border-radius: 4px; }
.tsb-row-act:hover { background: var(--bg-active); color: var(--text); }
.tsb-wsstrip { display: flex; align-items: center; gap: 2px; padding: 6px 10px; border-top: 1px solid var(--border); }
.tsb-wsstrip button { border: none; background: none; color: var(--text-faint); font-size: 13px; cursor: pointer; padding: 4px 5px; border-radius: var(--radius-sm); }
.tsb-wsstrip button:hover { background: var(--bg-hover); color: var(--text); }
.tsb-footer { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-top: 1px solid var(--border); }
.tsb-user { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 0; height: 38px; padding: 0 7px; border: none; border-radius: var(--radius-sm); background: none; cursor: pointer; text-align: left; }
.tsb-user:hover { background: var(--bg-hover); }
.tsb-avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 12px; font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; }
.tsb-user-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.tsb-user-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-sub { font-size: 11px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-chev { color: var(--text-faint); font-size: 10px; }
.tsb-menu { position: fixed; z-index: 300; min-width: 210px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 12px 32px rgba(0,0,0,.35); padding: 5px; display: flex; flex-direction: column; }
.tsb-mi { display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 4px 9px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); font-size: 13px; cursor: pointer; text-align: left; }
.tsb-mi:hover { background: var(--bg-hover); color: var(--text); }
.tsb-mi.danger { color: var(--err); }
.tsb-mi.danger:hover { background: var(--err); color: #fff; }
.tsb-mi kbd { margin-left: auto; font-size: 10.5px; color: var(--text-faint); }
.tsb-menu-div { height: 1px; background: var(--border); margin: 5px 4px; }
.tsb-acc-head { display: flex; gap: 10px; align-items: center; padding: 8px 9px 6px; }
.tsb-acc-name { font-size: 13.5px; font-weight: 600; }
.tsb-acc-mail { font-size: 11.5px; color: var(--text-faint); }
+82
View File
@@ -0,0 +1,82 @@
// 任务列表组织(方案 A,提案 2026-09-30):纯函数,供 TaskSidebar 与测试消费。
// 组织是本端视图偏好(P8),不改数据模型——数据源恒为跨项目 tasks(updated_at 倒序)。
import type { Conversation, Project } from './contracts'
export type TaskOrg = 'project' | 'timeline'
export interface TaskGroup {
key: string
label: string
/** 项目分组时携带(组头「在此项目新建」入口与计数)。 */
projectId?: string
tasks: Conversation[]
}
/** 按项目分组:项目按服务端序(更新倒序),组内任务按列表序(置顶已在服务端排首)。 */
export function groupByProject(tasks: Conversation[], projects: Project[]): TaskGroup[] {
const byProject = new Map<string, Conversation[]>()
for (const t of tasks) {
const list = byProject.get(t.projectId) ?? []
list.push(t)
byProject.set(t.projectId, list)
}
const groups: TaskGroup[] = []
for (const p of projects) {
const list = byProject.get(p.id)
if (!list || list.length === 0) continue
groups.push({ key: `prj:${p.id}`, label: p.name, projectId: p.id, tasks: list })
byProject.delete(p.id)
}
// 项目已删但会话仍在(竞态残留):收进"未分组",不静默丢弃。
for (const [pid, list] of byProject) {
groups.push({ key: `prj:${pid}`, label: '未分组', projectId: pid, tasks: list })
}
return groups
}
/** 按时间线分组:今天 / 昨天 / 本周更早 / 更早(按 updatedAt 本地时区)。 */
export function groupByTimeline(tasks: Conversation[], now = Date.now()): TaskGroup[] {
const startOfDay = (ts: number) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime() }
const today0 = startOfDay(now)
const dayMs = 86_400_000
const buckets: TaskGroup[] = [
{ key: 'today', label: '今天', tasks: [] },
{ key: 'yesterday', label: '昨天', tasks: [] },
{ key: 'week', label: '本周更早', tasks: [] },
{ key: 'older', label: '更早', tasks: [] },
]
const weekday = (new Date(now).getDay() + 6) % 7 // 周一=0
const weekStart = today0 - weekday * dayMs
for (const t of tasks) {
const ts = Date.parse(t.updatedAt)
if (Number.isNaN(ts)) { buckets[3].tasks.push(t); continue }
if (ts >= today0) buckets[0].tasks.push(t)
else if (ts >= today0 - dayMs) buckets[1].tasks.push(t)
else if (ts >= weekStart) buckets[2].tasks.push(t)
else buckets[3].tasks.push(t)
}
return buckets.filter(g => g.tasks.length > 0)
}
/** 相对时间(侧栏行尾):刚刚 / N 分钟 / N 小时 / 昨天 / N 天 / 月-日。 */
export function relativeTime(iso: string, now = Date.now()): string {
const ts = Date.parse(iso)
if (Number.isNaN(ts)) return ''
const diff = now - ts
if (diff < 60_000) return '刚刚'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时`
if (diff < 172_800_000) return '昨天'
if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天`
const d = new Date(ts)
const nowD = new Date(now)
const md = `${d.getMonth() + 1}/${d.getDate()}`
return d.getFullYear() === nowD.getFullYear() ? md : `${d.getFullYear()}/${md}`
}
/** 标题过滤(不区分大小写;空串返回原列表)。 */
export function filterTasks(tasks: Conversation[], q: string): Conversation[] {
const needle = q.trim().toLowerCase()
if (!needle) return tasks
return tasks.filter(t => t.title.toLowerCase().includes(needle))
}
+71
View File
@@ -0,0 +1,71 @@
// 任务列表组织纯函数测试(方案 A R1)。
import { describe, it, expect } from 'vitest'
import { groupByProject, groupByTimeline, relativeTime, filterTasks } from '../src/tasklist'
import type { Conversation, Project } from '../src/contracts'
const NOW = new Date('2026-09-30T15:00:00').getTime()
const H = 3_600_000
function conv(id: string, projectId: string, updatedAt: string, extra: Partial<Conversation> = {}): Conversation {
return { id, projectId, title: id, createdAt: updatedAt, updatedAt, ...extra }
}
describe('groupByProject', () => {
it('groups tasks under projects in project order and never drops orphans', () => {
const projects: Project[] = [
{ id: 'p2', name: '乙', createdAt: '', updatedAt: '' },
{ id: 'p1', name: '甲', createdAt: '', updatedAt: '' },
]
const tasks = [conv('a', 'p1', '2026-09-30T10:00:00Z'), conv('b', 'p2', '2026-09-29T10:00:00Z'), conv('c', 'pX', '2026-09-28T10:00:00Z')]
const groups = groupByProject(tasks, projects)
expect(groups.map(g => g.label)).toEqual(['乙', '甲', '未分组'])
expect(groups[0].projectId).toBe('p2')
expect(groups[2].tasks.map(t => t.id)).toEqual(['c'])
})
it('skips projects with zero visible tasks', () => {
const projects: Project[] = [{ id: 'p1', name: '甲', createdAt: '', updatedAt: '' }]
expect(groupByProject([], projects)).toEqual([])
})
})
describe('groupByTimeline', () => {
it('buckets by local day and week boundaries', () => {
// NOW=周三 15:00(本地);构造今天/昨天/本周更早(周一)/更早 各一条。
const tasks = [
conv('today', 'p', new Date(NOW - H).toISOString()),
conv('yesterday', 'p', new Date(NOW - 26 * H).toISOString()),
conv('week', 'p', new Date(NOW - 50 * H).toISOString()),
conv('older', 'p', new Date(NOW - 10 * 24 * H).toISOString()),
]
const groups = groupByTimeline(tasks, NOW)
expect(groups.map(g => g.label)).toEqual(['今天', '昨天', '本周更早', '更早'])
expect(groups[0].tasks[0].id).toBe('today')
})
it('hides empty buckets and tolerates bad timestamps', () => {
const groups = groupByTimeline([conv('x', 'p', 'not-a-date')], NOW)
expect(groups).toHaveLength(1)
expect(groups[0].label).toBe('更早')
})
})
describe('relativeTime', () => {
it('formats gradients and year rollover', () => {
expect(relativeTime(new Date(NOW - 30_000).toISOString(), NOW)).toBe('刚刚')
expect(relativeTime(new Date(NOW - 5 * 60_000).toISOString(), NOW)).toBe('5 分钟')
expect(relativeTime(new Date(NOW - 3 * H).toISOString(), NOW)).toBe('3 小时')
expect(relativeTime(new Date(NOW - 30 * H).toISOString(), NOW)).toBe('昨天')
expect(relativeTime(new Date(NOW - 3 * 24 * H).toISOString(), NOW)).toBe('3 天')
expect(relativeTime('2026-05-01T00:00:00', NOW)).toBe('5/1')
expect(relativeTime('2025-12-31T00:00:00', NOW)).toBe('2025/12/31')
expect(relativeTime('bad')).toBe('')
})
})
describe('filterTasks', () => {
it('matches title case-insensitively and passes through on empty query', () => {
const tasks = [conv('a', 'p', '', { title: '周报 Draft' }), conv('b', 'p', '', { title: '接口评审' })]
expect(filterTasks(tasks, '周报')).toEqual([tasks[0]])
expect(filterTasks(tasks, ' ')).toEqual(tasks)
expect(filterTasks(tasks, 'DRAFT')).toEqual([tasks[0]])
})
})
+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/tasklist.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}