Queue follow-up messages and pin files in the right rail #28

Merged
weichen merged 2 commits from feature/input-queue into main 2026-10-01 05:26:28 +00:00
8 changed files with 259 additions and 81 deletions
+67
View File
@@ -0,0 +1,67 @@
// 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内深预览/右栏预览/下载。
// ⌘1 弹层与右栏「文件」页签共用这一份。删除仍是保留能力,系统默认不提供入口。
import { useState } from 'react'
import { store, useStore } from '../store'
import { api } from '../api'
import { Icon } from '@ui'
import { artifactIcon, humanSize } from '../messageparts'
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 (
<div className="artifacts-panel">
<div className="ap-toolbar">
<input className="search-input" placeholder="搜索文件名…" value={q} onChange={e => setQ(e.target.value)} />
{kinds.map(k => (
<button key={k} className={`kind-pill ${kind === k ? 'on' : ''}`} onClick={() => setKind(k)}>
{k === 'all' ? '全部' : k === 'document' ? '文档' : k === 'data' ? '数据' : '图片'}
</button>
))}
<label className="btn ghost ap-upload" title="上传文件到当前项目">
<Icon name="paperclip" size={14} /> 上传
<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
for (const f of Array.from(e.target.files ?? [])) await store.uploadAttachment(f)
e.target.value = ''
}} />
</label>
</div>
{artifacts.length === 0
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
// 预览落位在右栏——弹层必须先收起,否则预览换到被盖住的右栏。
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => { store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}(右栏,边聊边看)`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
<span className="ar-acts">
<button className="icon-btn" title="全屏预览" onClick={e => { e.stopPropagation(); store.closeWorkspace(); store.requestPreview(a, 'full') }}><Icon name="eye" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
</div>
))}
</div>
)
}
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 '' }
}
+45 -5
View File
@@ -318,6 +318,40 @@ function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) =>
)
}
function OutboxPanel() {
const outbox = useStore(s => s.outbox)
const [editing, setEditing] = useState<string | null>(null)
const [draft, setDraft] = useState('')
if (outbox.length === 0) return null
return (
<Collapsible id="composer-outbox" defaultOpen
trigger={<span>排队 {outbox.length}</span>}
triggerClassName="outbox-trigger">
<ul className="outbox-list">
{outbox.map(item => (
<li key={item.id} className="outbox-item">
{editing === item.id ? (
<input className="search-input outbox-edit" value={draft} onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) }
}} />
) : (
<span className="outbox-text" title={item.text}>{item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''}</span>
)}
{editing === item.id ? (
<button className="btn ghost" onClick={() => { store.updateOutbox(item.id, draft); setEditing(null) }}>保存</button>
) : (
<button className="btn ghost" onClick={() => { setEditing(item.id); setDraft(item.text) }}>修改</button>
)}
<button className="btn ghost" onClick={() => store.sendOutboxNow(item.id)}>发送</button>
<button className="icon-btn" title="移出队列" onClick={() => store.removeOutbox(item.id)}><Icon name="x" size={12} /></button>
</li>
))}
</ul>
</Collapsible>
)
}
export function Conversation() {
const currentConvId = useStore(s => s.currentConvId)
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
@@ -349,8 +383,14 @@ export function Conversation() {
const send = async () => {
if (!text.trim() && attachmentIds.length === 0) return
const t = text
const ids = attachmentIds
const names = attachmentNames
setText(''); setAttachmentIds([]); setAttachmentNames([])
await store.sendMessage(t, attachmentIds)
if (active) {
store.enqueueOutbox(t, ids, names)
return
}
await store.sendMessage(t, ids)
}
if (!currentConvId) {
@@ -431,6 +471,7 @@ export function Conversation() {
</div>
<div className="input-bar">
<OutboxPanel />
{attachmentNames.length > 0 && (
<div className="attachments">
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
@@ -450,15 +491,14 @@ export function Conversation() {
}} />
</label>
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡'
: active ? '回合进行中——等当前回合结束(或点「停止」)后再发送'
: active ? '继续输入以排队'
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
{active ? (
{active && (
<button className="btn danger" onClick={() => store.cancelActive()}>停止</button>
) : (
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>发送</button>
)}
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>{active ? '排队' : '发送'}</button>
</div>
</div>
</div>
+9 -1
View File
@@ -5,6 +5,7 @@ import type { Run } from '../contracts'
import { InboxList, pendingCount, FilePreview, type ActivityCard, type DocBridge } from '@ui'
import { Icon } from '@ui'
import { api } from '../api'
import { ArtifactsPanel } from './ArtifactsPanel'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(阶段③ 收敛:默认右栏轻预览)。
@@ -59,11 +60,14 @@ export function Workbench() {
const railArtifact = useStore(s => (railId ? s.artifacts.find(a => a.id === railId) : undefined))
const showPreview = rightTab === 'preview'
const showFiles = rightTab === 'files'
return (
<>
<div className="tabs">
<button className={`tab ${!showPreview ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'inbox' })}
<button className={`tab ${!showPreview && !showFiles ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'inbox' })}
title="收件箱">收件箱{pending > 0 ? ` ${pending}` : ''}</button>
<button className={`tab ${showFiles ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'files', rightCollapsed: false })}
title="文件(与 ⌘1 工件面板同一份列表)">文件</button>
<button className={`tab ${showPreview ? 'active' : ''}`} onClick={() => store.railPreview(railArtifact ?? null)}
title="预览(右栏轻预览)"><Icon name="eye" size={13} /> 预览</button>
<span className="spacer" style={{ flex: 1 }} />
@@ -92,6 +96,10 @@ export function Workbench() {
</>
) : <div className="empty-note" style={{ padding: 14 }}>点消息里的工件卡片或右侧工件面板——文件会在这里边聊边看(md/图片/pdf/Office/代码均可)</div>}
</div>
) : showFiles ? (
<div className="tab-body">
<ArtifactsPanel />
</div>
) : (
<div className="tab-body">
<CurrentTaskCard plan={plan} todos={todos} />
+2 -68
View File
@@ -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 (
<div className="artifacts-panel">
<div className="ap-toolbar">
<input className="search-input" placeholder="搜索文件名…" value={q} onChange={e => setQ(e.target.value)} />
{kinds.map(k => (
<button key={k} className={`kind-pill ${kind === k ? 'on' : ''}`} onClick={() => setKind(k)}>
{k === 'all' ? '全部' : k === 'document' ? '文档' : k === 'data' ? '数据' : '图片'}
</button>
))}
<label className="btn ghost ap-upload" title="上传文件到当前项目">
<Icon name="paperclip" size={14} /> 上传
<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
for (const f of Array.from(e.target.files ?? [])) await store.uploadAttachment(f)
e.target.value = ''
}} />
</label>
</div>
{artifacts.length === 0
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
// 预览落位在右栏——弹层必须先收起,否则预览换到被盖住的右栏,
// 用户看到的是「点了没反应」(工件面板是全屏 OverlayHost)。
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => { store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}(右栏,边聊边看)`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
<span className="ar-acts">
<button className="icon-btn" title="全屏预览" onClick={e => { e.stopPropagation(); store.closeWorkspace(); store.requestPreview(a, 'full') }}><Icon name="eye" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
</div>
))}
</div>
)
}
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 <div className="empty-note">暂无运行——多代理任务会在此形成任务树(mock:#agents)</div>
+38
View File
@@ -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)
}
+65 -7
View File
@@ -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))
}
+5
View File
@@ -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; }
+28
View File
@@ -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])
})
})