Why: a busy conversation rejected further input, and the file list lived only behind the workspace overlay. Evidence: npx tsc --noEmit and vitest 42 tests, including outbox append/edit/promote. The queue uses Collapsible and design tokens. The files tab reuses ArtifactsPanel. Impact: while a run is active, Enter queues the draft (edit, delete, or send-now). Finished and failed runs drain the queue. HTTP 409 also enqueues instead of only toasting. Backend serialization is unchanged. Refs: task feature/input-queue Co-authored-by: Cursor <cursoragent@cursor.com>
173 lines
8.7 KiB
TypeScript
173 lines
8.7 KiB
TypeScript
// 右栏终态(提案 T4/D25 → 预览双落位):收件箱/预览两页签 + 「当前任务」常驻卡。
|
|
// 预览页签(裁定 D4)承载右栏轻预览:FilePreview compact 模式,边聊边看。
|
|
import { store, useStore } from '../store'
|
|
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 唯一入口(阶段③ 收敛:默认右栏轻预览)。
|
|
export const docBridge: DocBridge = {
|
|
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
|
|
onOpenDoc: id => {
|
|
const art = store.getState().artifacts.find(a => a.id === id)
|
|
if (art) store.requestPreview(art)
|
|
},
|
|
}
|
|
|
|
const inboxCallbacks = {
|
|
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.locateMessage(c.anchorMessageId),
|
|
onArchive: (c: ActivityCard) => store.cardToProject(c),
|
|
}
|
|
|
|
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
|
|
function ProjectInbox({ cards }: { cards: ActivityCard[] }) {
|
|
const own = cards.filter(c => !c.conversationId)
|
|
const cross = cards.filter(c => !!c.conversationId)
|
|
if (cards.length === 0) {
|
|
return <div className="inbox-empty">项目收件箱为空——「留档到项目」的产物卡与其他会话的待办会聚合在这里。</div>
|
|
}
|
|
return (
|
|
<>
|
|
<div className="inbox-zone-label">📁 项目级卡片(跨会话可见)</div>
|
|
{own.length > 0
|
|
? <InboxList cards={own} callbacks={inboxCallbacks} docBridge={docBridge} />
|
|
: <div className="inbox-empty" style={{ padding: '4px 14px' }}>暂无——会话卡点 🗂 即留档至此</div>}
|
|
{cross.length > 0 && (
|
|
<>
|
|
<div className="inbox-zone-label">🕓 其他会话待办</div>
|
|
<InboxList cards={cross} callbacks={inboxCallbacks} docBridge={docBridge} showPinnedZone={false} />
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function Workbench() {
|
|
const cards = useStore(s => s.cards)
|
|
const plan = useStore(s => s.plan)
|
|
const todos = useStore(s => s.todos)
|
|
const inboxScope = useStore(s => s.inboxScope)
|
|
const projectCards = useStore(s => s.projectCards)
|
|
const pending = pendingCount(cards) + pendingCount(projectCards)
|
|
const rightTab = useStore(s => s.ui.rightTab)
|
|
const railId = useStore(s => s.ui.railPreviewId)
|
|
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 && !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 }} />
|
|
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
|
|
<button className="tab" title="收起收件箱" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
|
|
</div>
|
|
{showPreview ? (
|
|
<div className="tab-body" data-rail-preview="">
|
|
{railArtifact ? (
|
|
<>
|
|
<div className="rail-preview-head">
|
|
<span className="rp-name" title={railArtifact.name}>{railArtifact.name}</span>
|
|
<span className="spacer" style={{ flex: 1 }} />
|
|
<button className="icon-btn" title="全屏预览(唯一大窗落位)" onClick={() => store.requestPreview(railArtifact, 'full')}><Icon name="eye" size={14} /></button>
|
|
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(railArtifact.id)} download={railArtifact.name}><Icon name="download" size={14} /></a>
|
|
<button className="icon-btn" title="关闭预览" onClick={() => store.railPreview(null)}><Icon name="x" size={14} /></button>
|
|
</div>
|
|
<FilePreview compact source={{
|
|
kind: 'artifact',
|
|
id: railArtifact.id,
|
|
name: railArtifact.name,
|
|
mime: railArtifact.mime,
|
|
contentUrl: api.artifactContentUrl(railArtifact.id),
|
|
downloadUrl: api.artifactDownloadUrl(railArtifact.id),
|
|
}} />
|
|
</>
|
|
) : <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} />
|
|
<div className="inbox-scope" role="tablist" aria-label="收件箱范围">
|
|
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
|
|
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
|
|
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
|
|
</button>
|
|
</div>
|
|
{inboxScope === 'project'
|
|
? <ProjectInbox cards={projectCards} />
|
|
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} />}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/** 当前任务常驻卡(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 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}`}>
|
|
<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>
|
|
)
|
|
}
|
|
|
|
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>
|
|
}
|
|
|
|
/** 代理行(工作区弹层复用;点击进 AgentTracker)。 */
|
|
export function AgentRow({ run, depth }: { run: Run; depth: number }) {
|
|
const label = run.kind === 'subagent' ? `🤖 ${run.agentName || '子代理'}` : '💬 对话任务'
|
|
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, overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
|
<span style={{ fontSize: 'var(--fs-ui-xs)', color: 'var(--text-faint)' }}>{run.status}</span>
|
|
</div>
|
|
)
|
|
}
|