Why: Repeated progress and coupled scrolling obscured chat, and task choices lacked a reliable independent interaction path. Evidence: 285 frontend unit tests and 23 browser scenarios pass locally before the shared event-loader extraction; final combination checks follow in Dev. Impact: Coalesce cumulative progress, retain execution records, show timestamps and quiet notifications, support versioned work actions and explicit result references without automatic chat backfill. Refs: feature/workbench-flow-completion
79 lines
4.9 KiB
TypeScript
79 lines
4.9 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { ActivityCardView, Collapsible, activitySummary, groupActivityCards, pendingCount, type ActivityCard } from '@ui'
|
|
import { store, useStore } from '../store'
|
|
import { docBridge } from './Workbench'
|
|
import type { WorkCardAction } from '../contracts'
|
|
import { WorkExecutionRecord } from './WorkExecutionRecord'
|
|
|
|
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
|
|
export function ConversationWork() {
|
|
const cards = useStore(s => s.cards)
|
|
const conversationId = useStore(s => s.currentConvId)
|
|
const projectId = useStore(s => s.currentProjectId)
|
|
const messages = useStore(s => s.messages)
|
|
const actionStates = useStore(s => s.cardActionStates)
|
|
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
|
|
const rightTab = useStore(s => s.ui.rightTab)
|
|
const record = useStore(s => s.workRecord)
|
|
const detailHere = rightCollapsed || rightTab !== 'work'
|
|
const [summaryOpen, setSummaryOpen] = useState(detailHere)
|
|
useEffect(() => setSummaryOpen(detailHere), [detailHere])
|
|
const { current, required, focused, history } = useMemo(() => {
|
|
const current = cards.filter(card => card.status !== 'dismissed'
|
|
&& card.conversationId === conversationId && card.projectId === projectId)
|
|
const required: ActivityCard[] = []
|
|
const remaining: ActivityCard[] = []
|
|
for (const card of current) {
|
|
// A confirmation already visible at its user message has one main action
|
|
// path in the conversation; assistant-anchored cards still need this path.
|
|
if (card.kind === 'confirm' && card.anchorMessageId
|
|
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
|
|
const summary = activitySummary(card)
|
|
if (card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
|
|
else remaining.push(card)
|
|
}
|
|
// Preserve the host's newest-first order for completed results and history.
|
|
const results = groupActivityCards(remaining).find(group => group.key === 'results')?.cards ?? []
|
|
const focused = remaining.find(card => card.pinned && card.status === 'active')
|
|
?? remaining.find(card => card.status === 'active')
|
|
?? results[0]
|
|
return { current, required, focused, history: remaining.filter(card => card !== focused).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) }
|
|
}, [cards, messages, conversationId, projectId])
|
|
if (!detailHere) return null
|
|
if (record) return <div className="conversation-work" data-conversation-work><WorkExecutionRecord /></div>
|
|
if (current.length === 0) return null
|
|
const pending = pendingCount(current)
|
|
const active = current.filter(card => card.status === 'active').length
|
|
const results = current.filter(card => card.status === 'done' || card.status === 'failed').length
|
|
const summary = [pending ? `${pending} 项待处理` : '', active ? `${active} 项工作` : '', results ? `${results} 项结果` : ''].filter(Boolean).join(' · ')
|
|
const callbacks = {
|
|
onAction: (card: ActivityCard, actionId: string, payload?: WorkCardAction) => { void store.cardAction(card, actionId, payload) },
|
|
onOpenRecord: (card: ActivityCard) => store.openWorkRecord(card),
|
|
onReference: (card: ActivityCard) => store.referenceWork(card),
|
|
actionState: (card: ActivityCard) => actionStates?.[card.id],
|
|
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
|
|
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
|
|
onLocate: (card: ActivityCard) => { store.locateMessage(card.anchorMessageId) },
|
|
onArchive: (card: ActivityCard) => { void store.cardToProject(card) },
|
|
}
|
|
const view = (card: ActivityCard) => <ActivityCardView key={card.id} card={card} callbacks={callbacks} docBridge={docBridge} />
|
|
|
|
return (
|
|
<section className="conversation-work" data-conversation-work aria-label="工作进展">
|
|
<div className="conversation-work-heading"><span>工作进展</span><span>{summary || '已处理的工作'}</span></div>
|
|
<div className="conversation-work-current">
|
|
{required.map(view)}
|
|
{focused && <Collapsible id={`conversation-current:${conversationId}:${focused.id}`} open={summaryOpen} onToggle={setSummaryOpen}
|
|
triggerClassName="conversation-work-more" trigger={<><span className="process-collapse-chev">▸</span>{focused.title} · 查看摘要</>}>
|
|
{view(focused)}
|
|
</Collapsible>}
|
|
</div>
|
|
{history.length > 0 && <Collapsible id={`conversation-work:${projectId}:${conversationId}`}
|
|
triggerClassName="conversation-work-more"
|
|
trigger={<><span className="process-collapse-chev">▸</span>更多工作与成果 · {history.length} 项</>}>
|
|
<div className="conversation-work-history">{history.map(view)}</div>
|
|
</Collapsible>}
|
|
</section>
|
|
)
|
|
}
|