Why: Removing the artifacts builtin shifts the shortcut ranking, so the workspaces tests now expect agents at cmd-1; Workbench no longer selects previewOpen in its outer scope (FilesTab owns that read). Evidence: npx vitest run 85/85 passed; npm run build (tsc -b && vite build) passed in the task worktree (SELF). Impact: test/workspaces.test.ts expectations; Workbench.tsx selector cleanup. No behavior change beyond the committed layout rework.
175 lines
8.6 KiB
TypeScript
175 lines
8.6 KiB
TypeScript
// 右栏(第三栏):「工作 / 文件」双入口。
|
|
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
|
|
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
|
|
import { store, useStore } from '../store'
|
|
import type { Run } from '../contracts'
|
|
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
|
|
import { Icon } from '@ui'
|
|
import { previewTabsKey } from '../previewtabs'
|
|
import { ArtifactsPanel } from './ArtifactsPanel'
|
|
import { PreviewPane } from './PreviewPane'
|
|
|
|
// 工件解析/打开注入:渲染器纯展示,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, filter }: { cards: ActivityCard[]; filter: InboxFilter }) {
|
|
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} filter={filter} />
|
|
: <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} filter={filter} />
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function Workbench() {
|
|
const rightTab = useStore(s => s.ui.rightTab)
|
|
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 filter = useStore(s => s.ui.inboxFilter)
|
|
const visible = inboxScope === 'project' ? projectCards : cards
|
|
const pending = pendingCount(visible)
|
|
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
|
|
const switchTab = (tab: 'work' | 'files') =>
|
|
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
|
|
return (
|
|
<>
|
|
<div className="tabs">
|
|
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
|
|
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}</button>
|
|
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
|
|
title="文件搜索、预览、编辑与下载">文件</button>
|
|
<span className="spacer" style={{ flex: 1 }} />
|
|
{rightTab === 'work' && (
|
|
<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>
|
|
{rightTab === 'files'
|
|
? <FilesTab />
|
|
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} />}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/** 文件入口:有预览标签且处于预览子视图时展示预览;否则展示文件列表(搜索/上传)。 */
|
|
function FilesTab() {
|
|
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
|
|
const filesView = useStore(s => s.ui.filesView)
|
|
if (previewOpen && filesView === 'preview') return <PreviewPane />
|
|
return <div className="tab-body"><ArtifactsPanel /></div>
|
|
}
|
|
|
|
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
|
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
|
|
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
|
|
filter: InboxFilter
|
|
inboxScope: 'conv' | 'project'
|
|
projectCards: ActivityCard[]
|
|
cards: ActivityCard[]
|
|
}) {
|
|
return (
|
|
<>
|
|
<div className="tab-body" data-scroll-sync="inbox">
|
|
<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>
|
|
<span style={{ flex: 1 }} />
|
|
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
|
|
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
|
|
</div>
|
|
{inboxScope === 'project'
|
|
? <ProjectInbox cards={projectCards} filter={filter} />
|
|
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
|
|
</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>
|
|
)
|
|
}
|