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
51 lines
2.9 KiB
TypeScript
51 lines
2.9 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { SafeMarkdown } from '@ui'
|
|
import type { StreamEvent } from '../contracts'
|
|
import { loadRunEvents } from '../runEvents'
|
|
import { store, useStore } from '../store'
|
|
import { runEventsToTimeline } from '../live'
|
|
import { mergeProcessTimeline } from '../messageparts'
|
|
import { ProcessTimeline } from './ProcessTimeline'
|
|
import { MessageTimestamp } from './MessageTimestamp'
|
|
|
|
/** The work pane and inline fallback share one selection. Only this detail host
|
|
* reads that run's authorized durable events; opening a record never adds chat. */
|
|
export function WorkExecutionRecord() {
|
|
const selected = useStore(s => s.workRecord)
|
|
const conversationId = useStore(s => s.currentConvId)
|
|
const messages = useStore(s => s.messages)
|
|
const run = useStore(s => s.runs.find(r => r.id === selected?.runId))
|
|
const [result, setResult] = useState<{ runId: string; events: StreamEvent[] } | null>(null)
|
|
const [failed, setFailed] = useState(false)
|
|
useEffect(() => {
|
|
if (!selected || selected.conversationId !== conversationId) return
|
|
const controller = new AbortController()
|
|
let active = true
|
|
setResult(null); setFailed(false)
|
|
void (async () => {
|
|
try {
|
|
const events = await loadRunEvents(selected.conversationId, selected.runId, controller.signal)
|
|
if (active) setResult({ runId: selected.runId, events })
|
|
} catch { if (active) setFailed(true) }
|
|
})()
|
|
return () => { active = false; controller.abort() }
|
|
}, [selected?.runId, selected?.conversationId, conversationId, run?.status, run?.endedAt])
|
|
const parts = useMemo(() => {
|
|
if (!selected || result?.runId !== selected.runId) return []
|
|
const saved = messages.filter(message => message.runId === selected.runId && message.role === 'assistant')
|
|
.flatMap(message => message.parts).map(part => ({ ...part, process: true }))
|
|
return mergeProcessTimeline(saved, runEventsToTimeline(result.events, selected.runId, true), [])
|
|
}, [messages, result, selected?.runId])
|
|
if (!selected || selected.conversationId !== conversationId) return null
|
|
return <section className="work-execution-record" aria-label={`${selected.title}的执行记录`}>
|
|
<div className="work-record-heading"><strong>{selected.title} · 执行记录</strong>
|
|
<button type="button" className="btn ghost" onClick={() => store.closeWorkRecord()}>返回工作</button></div>
|
|
<MessageTimestamp value={run?.createdAt} />
|
|
{failed ? <p role="status">完整记录暂不可用,请稍后重新打开</p>
|
|
: !result ? <p role="status">正在读取已保存的记录…</p>
|
|
: parts.length === 0 ? <p>这项工作没有已保存的过程输出</p>
|
|
: <ProcessTimeline parts={parts} live={false} idKey={`work-record:${selected.runId}`}
|
|
renderPart={part => <SafeMarkdown className="part-text" markdown={part.text ?? ''} breaks />} />}
|
|
</section>
|
|
}
|