Files
Agent-System-Frontend/src/components/ConversationWork.tsx
T
weichenandCursor b02ff83dfc
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 39s
Hide project-only surfaces in scratch conversations
Why: Independent review found the cross-read toggle, the project inbox
scope and the archive-to-project action still reachable from an open
scratch (no-project) conversation.

Evidence: npx vitest run (685 tests) and npx tsc -b --noEmit pass locally
against the UI fix branch (SELF); CI on the PR head is authoritative.

Impact: Scratch conversations hide the cross-read setting, always show
the conversation inbox and omit onArchive; real projects are unchanged.

Refs: Agent-System-UI#60; agent-exec receipt u3e-run (cursor, success)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 03:48:00 +00:00

82 lines
5.2 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 scratch = useStore(s => !!s.tasks.find(t => t.id === s.currentConvId)?.scratch)
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)
const taskNotice = card.task?.limitations?.length || card.task?.phase.id === 'correction'
|| card.task?.findings?.some(f => f.status === 'active' && (f.kind === 'needs_user' || f.corrects))
if (taskNotice || 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) },
...(scratch ? {} : { 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>
)
}