diff --git a/src/components/Workspaces.tsx b/src/components/Workspaces.tsx
index 6798c25..2d85ee7 100644
--- a/src/components/Workspaces.tsx
+++ b/src/components/Workspaces.tsx
@@ -1,14 +1,12 @@
// 工作区弹层(提案 T4/D25):工件/代理 经 OverlayHost 呈现。
// 重设计方案 阶段②(IA 对话优先):插件面板/服务端声明工作区属系统能力,
// 只进「系统管理」分区(AdminZone),不占普通用户的 ⌘1–9;清单按角色收窄。
-import { useState } from 'react'
import { store, useStore } from '../store'
import type { Run } from '../contracts'
-import { api } from '../api'
-import { OverlayHost, Icon, type DocBridge } from '@ui'
+import { OverlayHost, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
-import { artifactIcon, humanSize } from '../messageparts'
+import { ArtifactsPanel } from './ArtifactsPanel'
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区仅管理员可见。 */
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
@@ -59,70 +57,6 @@ export function Workspaces() {
return null
}
-/** 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内 深预览/右栏预览/下载。
- * 删除是保留能力:DELETE /api/artifacts/{id} 与 api.deleteArtifact 均在(插件可用),
- * 系统默认不提供删除入口(提案 2026-09-30-file-preview-and-reserved-capabilities §2.2)。 */
-export function ArtifactsPanel() {
- const artifacts = useStore(s => s.artifacts)
- const [q, setQ] = useState('')
- const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
-
- const kinds = ['all', 'document', 'data', 'image'] as const
- const visible = artifacts.filter(a =>
- (kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
-
- return (
-
-
- setQ(e.target.value)} />
- {kinds.map(k => (
-
- ))}
-
-
- {artifacts.length === 0
- ?
暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载
- : visible.length === 0
- ?
没有匹配「{q}」的文件
- : visible.map(a => (
- // 预览落位在右栏——弹层必须先收起,否则预览换到被盖住的右栏,
- // 用户看到的是「点了没反应」(工件面板是全屏 OverlayHost)。
-
{ store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}(右栏,边聊边看)`}>
-
-
- {a.name}
- {a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}
-
-
-
- e.stopPropagation()}>
-
-
- ))}
-
- )
-}
-
-function shortTime(iso: string): string {
- try {
- const d = new Date(iso)
- const now = new Date()
- return d.toDateString() === now.toDateString()
- ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
- : d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
- } catch { return '' }
-}
-
/** 代理面板(任务树;点击进 AgentTracker 深读)。 */
export function AgentsPanel({ runs }: { runs: Run[] }) {
if (runs.length === 0) return
暂无运行——多代理任务会在此形成任务树(mock:#agents)
diff --git a/src/outbox.ts b/src/outbox.ts
new file mode 100644
index 0000000..f51a42a
--- /dev/null
+++ b/src/outbox.ts
@@ -0,0 +1,38 @@
+// 输入排队(纯数据):回合进行中先入本地队列,终态再逐条发送。
+export interface QueuedMessage {
+ id: string
+ text: string
+ attachmentIds: string[]
+ attachmentNames: string[]
+}
+
+export function newQueuedMessage(text: string, attachmentIds: string[], attachmentNames: string[]): QueuedMessage {
+ return {
+ id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
+ text,
+ attachmentIds,
+ attachmentNames,
+ }
+}
+
+export function enqueueMessage(queue: QueuedMessage[], item: QueuedMessage): QueuedMessage[] {
+ return [...queue, item]
+}
+
+export function enqueueMessageFront(queue: QueuedMessage[], item: QueuedMessage): QueuedMessage[] {
+ return [item, ...queue.filter(q => q.id !== item.id)]
+}
+
+export function removeQueuedMessage(queue: QueuedMessage[], id: string): QueuedMessage[] {
+ return queue.filter(q => q.id !== id)
+}
+
+export function replaceQueuedText(queue: QueuedMessage[], id: string, text: string): QueuedMessage[] {
+ return queue.map(q => q.id === id ? { ...q, text } : q)
+}
+
+export function promoteQueuedMessage(queue: QueuedMessage[], id: string): QueuedMessage[] {
+ const item = queue.find(q => q.id === id)
+ if (!item) return queue
+ return enqueueMessageFront(queue, item)
+}
diff --git a/src/store.ts b/src/store.ts
index ecb04a5..fd1b45d 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -3,6 +3,7 @@
import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated, type LiveItem } from './live'
+import { enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
@@ -74,6 +75,8 @@ interface AppState {
connected: boolean
error: string | null
toast: string | null
+ outbox: QueuedMessage[]
+ outboxDraining: boolean
}
const DEFAULT_UI: UIState = {
@@ -112,6 +115,7 @@ let state: AppState = {
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', profiles: [], pluginPanels: [],
serverWorkspaces: [],
ui: loadUI(), connected: false, error: null, toast: null,
+ outbox: [], outboxDraining: false,
}
const listeners = new Set<() => void>()
@@ -233,7 +237,7 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
- set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv' })
+ set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv', outbox: [], outboxDraining: false })
await store.reconcile()
await store.connectLive()
},
@@ -365,27 +369,28 @@ export const store = {
if (p?.context && e.runId) {
set({ contextObs: { ...state.contextObs, [e.runId]: p.context } })
}
- setTimeout(() => store.reconcile(), 250)
+ setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
break
}
case 'run.failed':
case 'error': {
- // 终态:短暂延迟后对账(等待消息树落库),折叠过程输出。
- setTimeout(() => store.reconcile(), 250)
+ // 终态:短暂延迟后对账(等待消息树落库),折叠过程输出,再发送排队消息。
+ setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
break
}
}
},
- async sendMessage(text: string, attachmentIds: string[]) {
- if (!state.currentConvId) return
+ async sendMessage(text: string, attachmentIds: string[]): Promise<'ok' | 'busy'> {
+ if (!state.currentConvId) return 'ok'
try {
const out = await api.sendMessage(state.currentConvId, text, attachmentIds)
if (out.command) {
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
if (out.conversation) await store.openConversation(out.conversation.id)
else await store.reconcile()
- return
+ await store.flushOutbox()
+ return 'ok'
}
if (out.run) {
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
@@ -396,9 +401,58 @@ export const store = {
set({ messages: [...state.messages, out.message] })
store.connectIfNeeded()
}
+ return 'ok'
} catch (e) {
+ if (isConversationBusy(e)) {
+ set({ outbox: enqueueMessageFront(state.outbox, newQueuedMessage(text, attachmentIds, [])) })
+ return 'busy'
+ }
store.toast(`发送失败:${e}`)
await store.reconcile()
+ return 'ok'
+ }
+ },
+
+ enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[]) {
+ set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames)) })
+ },
+
+ updateOutbox(id: string, text: string) {
+ set({ outbox: replaceQueuedText(state.outbox, id, text) })
+ },
+
+ removeOutbox(id: string) {
+ set({ outbox: removeQueuedMessage(state.outbox, id) })
+ },
+
+ async sendOutboxNow(id: string) {
+ const item = state.outbox.find(q => q.id === id)
+ if (!item) return
+ const rest = removeQueuedMessage(state.outbox, id)
+ if (activeRun(state.runs) || state.live) {
+ set({ outbox: promoteQueuedMessage(state.outbox, id) })
+ store.toast('回合进行中,已放到队首,结束后自动发送')
+ return
+ }
+ set({ outbox: rest })
+ await store.sendMessage(item.text, item.attachmentIds)
+ },
+
+ async flushOutbox() {
+ if (state.outboxDraining) return
+ if (activeRun(state.runs) || state.live) return
+ const next = state.outbox[0]
+ if (!next) return
+ set({ outbox: state.outbox.slice(1), outboxDraining: true })
+ let result: 'ok' | 'busy' = 'ok'
+ try {
+ result = await store.sendMessage(next.text, next.attachmentIds)
+ } finally {
+ set({ outboxDraining: false })
+ }
+ if (result === 'busy') return
+ if (!activeRun(state.runs) && !state.live && state.outbox.length > 0) {
+ await store.flushOutbox()
}
},
@@ -623,6 +677,10 @@ export function isTerminal(status?: string): boolean {
return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'interrupted'
}
+function isConversationBusy(err: unknown): boolean {
+ return String(err).includes('409:')
+}
+
export function activeRun(runs: Run[]): Run | undefined {
return runs.find(r => !isTerminal(r.status))
}
diff --git a/src/styles.css b/src/styles.css
index 3582e20..bd5adba 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -162,6 +162,11 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* 输入区:悬浮卡片式输入壳(16px 大圆角 = DESIGN.md 特批例外) */
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
+.outbox-trigger { width: 100%; text-align: left; background: var(--bg-inset); color: var(--text-dim); border: 1px solid var(--border-soft); border-radius: var(--radius-xs); padding: 6px 10px; font-size: var(--fs-ui-sm); cursor: pointer; }
+.outbox-list { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
+.outbox-item { display: flex; gap: 6px; align-items: center; background: var(--bg-inset); border-radius: var(--radius-xs); padding: 6px 8px; }
+.outbox-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: var(--fs-ui-sm); }
+.outbox-edit { flex: 1; min-width: 0; }
.attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px 8px; font-size: var(--fs-ui-sm); }
.input-row { display: flex; gap: 8px; align-items: flex-end; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 8px 8px 8px 14px; box-shadow: var(--shadow-md); transition: border-color .12s, box-shadow .12s; }
diff --git a/test/outbox.test.ts b/test/outbox.test.ts
new file mode 100644
index 0000000..f61fcd1
--- /dev/null
+++ b/test/outbox.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import {
+ enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage,
+ removeQueuedMessage, replaceQueuedText,
+} from '../src/outbox'
+
+describe('outbox', () => {
+ it('appends, edits, promotes, and removes', () => {
+ const a = newQueuedMessage('a', [], [])
+ const b = newQueuedMessage('b', ['f1'], ['note.md'])
+ let q = enqueueMessage(enqueueMessage([], a), b)
+ expect(q.map(x => x.text)).toEqual(['a', 'b'])
+ q = replaceQueuedText(q, a.id, 'a2')
+ expect(q[0].text).toBe('a2')
+ q = promoteQueuedMessage(q, b.id)
+ expect(q.map(x => x.text)).toEqual(['b', 'a2'])
+ q = removeQueuedMessage(q, b.id)
+ expect(q.map(x => x.text)).toEqual(['a2'])
+ })
+
+ it('puts a busy conflict back at the front without duplicating', () => {
+ const a = newQueuedMessage('a', [], [])
+ const b = newQueuedMessage('b', [], [])
+ const q = enqueueMessageFront(enqueueMessage([], a), b)
+ const again = enqueueMessageFront(q, b)
+ expect(again.map(x => x.id)).toEqual([b.id, a.id])
+ })
+})