600 lines
23 KiB
TypeScript
600 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { useLocation, useNavigate } from "react-router-dom";
|
||
import type { Message } from "@langchain/langgraph-sdk";
|
||
import { toast } from "sonner";
|
||
import { FileText, Sparkles } from "lucide-react";
|
||
|
||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||
import { SubtasksProvider } from "@/core/tasks/context";
|
||
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
|
||
import { AgentStageSelector } from "@/report-collaboration/components/AgentStageSelector";
|
||
import { ConnectionStatusBar } from "@/report-collaboration/components/ConnectionStatusBar";
|
||
import { InterventionCard } from "@/report-collaboration/components/InterventionCard";
|
||
import { NodeInspectorDrawer } from "@/report-collaboration/components/NodeInspectorDrawer";
|
||
import { ReportCollaborationHeader } from "@/report-collaboration/components/ReportCollaborationHeader";
|
||
import { ReportCollaborationLayout, NarrowSwitch } from "@/report-collaboration/components/ReportCollaborationLayout";
|
||
import { ReportComposer, type ComposerTarget } from "@/report-collaboration/components/ReportComposer";
|
||
import { ReportConversationPanel } from "@/report-collaboration/components/ReportConversationPanel";
|
||
import { ReportFlowCanvas } from "@/report-collaboration/components/ReportFlowCanvas";
|
||
import { ReportPlanCards } from "@/report-collaboration/components/ReportPlanCards";
|
||
import {
|
||
ReportAvailableCard,
|
||
ReportPreviewDialog,
|
||
} from "@/report-collaboration/components/ReportPreviewDialog";
|
||
import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay";
|
||
import { ReportSpeakerShell } from "@/report-collaboration/components/ReportSpeakerShell";
|
||
import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel";
|
||
import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout";
|
||
import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages";
|
||
import { useReportCollaborationSession, isRunActive } from "@/report-collaboration/hooks/useReportCollaborationSession";
|
||
import { useReportPlanSelection } from "@/report-collaboration/hooks/useReportPlanSelection";
|
||
import {
|
||
buildCommandConfirmCard,
|
||
buildPlanGroupCard,
|
||
buildReportAvailableCard,
|
||
readReportCollaborationCard,
|
||
} from "@/report-collaboration/state/message-adapter";
|
||
import { resolveReportSpeaker } from "@/report-collaboration/state/speaker";
|
||
import {
|
||
derivePagePhase,
|
||
selectAgentRunForNode,
|
||
selectAgentTabs,
|
||
selectAwaitingInputNodes,
|
||
selectHeadReportVersion,
|
||
selectNodeStatusMap,
|
||
selectPendingConfirmationCommands,
|
||
selectPlanForCanvas,
|
||
selectPreferredParallelFocus,
|
||
selectStreamingAgentRunIds,
|
||
} from "@/report-collaboration/state/selectors";
|
||
import { buildBaseStreamFacade } from "@/report-collaboration/state/message-adapter";
|
||
|
||
const NEW_SESSION_THREAD = buildBaseStreamFacade([], false);
|
||
|
||
/** 左侧 Tab:对话 / 历史。 */
|
||
function ConversationSidebarTabs({
|
||
conversation,
|
||
history,
|
||
}: {
|
||
conversation: React.ReactNode;
|
||
history: React.ReactNode;
|
||
}) {
|
||
const [tab, setTab] = useState<"conversation" | "history">("conversation");
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<div className="rc-tabs" role="tablist" aria-label="左侧面板">
|
||
<button type="button" role="tab" className="rc-tab" data-active={tab === "conversation"} onClick={() => setTab("conversation")}>
|
||
对话
|
||
</button>
|
||
<button type="button" role="tab" className="rc-tab" data-active={tab === "history"} onClick={() => setTab("history")}>
|
||
历史
|
||
</button>
|
||
</div>
|
||
{tab === "conversation" ? conversation : history}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 新会话入口页(无 sessionId):空态 + 输入需求后创建会话并跳转,
|
||
* 首条消息通过 location.state 传递,由会话页发送(避免竞态)。
|
||
*/
|
||
export default function ReportCollaborationPage() {
|
||
const navigate = useNavigate();
|
||
const layout = useReportPanelLayout();
|
||
const [starting, setStarting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const start = useCallback(
|
||
async (text: string) => {
|
||
setStarting(true);
|
||
setError(null);
|
||
try {
|
||
const created = await getReportCollaborationDriver().createSession();
|
||
navigate(`/page/workspace/report-collaboration/${created.id}`, {
|
||
state: { rcInitialMessage: text },
|
||
});
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setStarting(false);
|
||
}
|
||
},
|
||
[navigate],
|
||
);
|
||
|
||
return (
|
||
<ArtifactsProvider>
|
||
<ReportCollaborationLayout
|
||
layout={layout}
|
||
header={
|
||
<ReportCollaborationHeader
|
||
title="新会话"
|
||
phase="empty"
|
||
onBack={() => navigate("/page/workspace/chats/new")}
|
||
onNewSession={() => navigate("/page/workspace/report-collaboration")}
|
||
onOpenReport={() => undefined}
|
||
hasReport={false}
|
||
running={false}
|
||
onCancelRun={() => undefined}
|
||
/>
|
||
}
|
||
conversation={
|
||
<ReportConversationPanel
|
||
sessionId="rc-new"
|
||
thread={NEW_SESSION_THREAD}
|
||
businessCardSlot={() => null}
|
||
loading={false}
|
||
emptyHint={
|
||
<div className="flex flex-col items-center gap-3">
|
||
<Sparkles className="size-10 opacity-40" style={{ color: "var(--rc-accent)" }} aria-hidden />
|
||
<p className="text-[14px] font-semibold">描述你的报告需求</p>
|
||
<p className="max-w-72 text-[12px] leading-5">
|
||
系统会先澄清关键信息,然后给出 2~3 个候选协作方案;
|
||
确认方案后多智能体团队开始检索、核验、分析与写作,全过程可在对话中干预。
|
||
</p>
|
||
{error ? (
|
||
<p className="text-[12px]" style={{ color: "var(--rc-danger)" }}>
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
}
|
||
composer={
|
||
<ReportComposer
|
||
phase="empty"
|
||
busy={starting}
|
||
target={null}
|
||
onClearTarget={() => undefined}
|
||
onSend={(text) => void start(text)}
|
||
focusSignal={0}
|
||
/>
|
||
}
|
||
/>
|
||
}
|
||
canvas={<ReportFlowCanvas plan={null} nodeStates={{}} selectedNodeId={null} onSelectNode={() => undefined} />}
|
||
statusBar={<ConnectionStatusBar status="idle" runId={null} lastSeq={0} />}
|
||
/>
|
||
</ArtifactsProvider>
|
||
);
|
||
}
|
||
|
||
/** 会话页:左对话(复用 MessageList)/ 右画布 / 节点抽屉 / 报告预览。 */
|
||
export function ReportCollaborationSessionPage({ sessionId }: { sessionId: string }) {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const session = useReportCollaborationSession(sessionId);
|
||
const { state } = session;
|
||
const layout = useReportPanelLayout();
|
||
|
||
const phase = useMemo(() => derivePagePhase(state), [state]);
|
||
const agentTabs = useMemo(() => selectAgentTabs(state), [state]);
|
||
const [focusedAgentRunId, setFocusedAgentRunId] = useState<string | null>(null);
|
||
const [focusPinned, setFocusPinned] = useState(false);
|
||
const tabIds = useMemo(() => new Set(agentTabs.map((tab) => tab.agent_run_id)), [agentTabs]);
|
||
const pinnedAgent =
|
||
focusPinned && focusedAgentRunId && !tabIds.has(focusedAgentRunId)
|
||
? (state.agentRuns[focusedAgentRunId] ?? null)
|
||
: null;
|
||
const effectiveFocus =
|
||
focusPinned && focusedAgentRunId && state.agentRuns[focusedAgentRunId]
|
||
? focusedAgentRunId
|
||
: agentTabs.length === 0
|
||
? null
|
||
: focusedAgentRunId && tabIds.has(focusedAgentRunId)
|
||
? focusedAgentRunId
|
||
: agentTabs[0].agent_run_id;
|
||
const focusedTab =
|
||
agentTabs.find((tab) => tab.agent_run_id === effectiveFocus) ??
|
||
(effectiveFocus ? state.agentRuns[effectiveFocus] ?? null : null);
|
||
|
||
const { previewPlanId, previewPlan, effectivePlanId } = useReportPlanSelection(
|
||
state.plans,
|
||
state.selectedPlanId,
|
||
);
|
||
const canvasPlan = useMemo(
|
||
() => selectPlanForCanvas(state, previewPlanId),
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[state.plans, state.planMap, state.selectedPlanId, previewPlanId],
|
||
);
|
||
void effectivePlanId;
|
||
|
||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [composerTarget, setComposerTarget] = useState<ComposerTarget | null>(null);
|
||
const [focusSignal, setFocusSignal] = useState(0);
|
||
|
||
const [previewOpen, setPreviewOpen] = useState(false);
|
||
const [previewVersionId, setPreviewVersionId] = useState<string | null>(null);
|
||
const [restoreBusy, setRestoreBusy] = useState(false);
|
||
|
||
// 新会话首条消息(location.state.rcInitialMessage),快照就绪且时间线为空时发送一次。
|
||
const initialSentRef = useRef(false);
|
||
useEffect(() => {
|
||
if (initialSentRef.current || !state.snapshotLoaded) return;
|
||
initialSentRef.current = true;
|
||
const initial = (location.state as { rcInitialMessage?: unknown } | null)?.rcInitialMessage;
|
||
if (typeof initial === "string" && initial.trim() && state.messageOrder.length === 0) {
|
||
void session.send(initial);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [state.snapshotLoaded]);
|
||
|
||
// 动作错误 → toast。
|
||
useEffect(() => {
|
||
if (session.actionError) {
|
||
toast.error(session.actionError, { description: "操作未生效,可重试或刷新快照" });
|
||
session.clearActionError();
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [session.actionError]);
|
||
|
||
// 执行波次:默认落到正在输出的成员;点头像/画布后保持该成员直到该波次结束。
|
||
// 全部结束后若用户钉住某个节点,保留回看,不强制清回全量时间线。
|
||
useEffect(() => {
|
||
if (agentTabs.length === 0) {
|
||
if (!focusPinned && focusedAgentRunId) setFocusedAgentRunId(null);
|
||
return;
|
||
}
|
||
if (focusPinned && focusedAgentRunId && tabIds.has(focusedAgentRunId)) return;
|
||
if (focusPinned && focusedAgentRunId && !tabIds.has(focusedAgentRunId)) {
|
||
setFocusPinned(false);
|
||
}
|
||
const next = selectPreferredParallelFocus(agentTabs);
|
||
if (next) setFocusedAgentRunId(next);
|
||
}, [agentTabs, focusPinned, focusedAgentRunId, tabIds]);
|
||
|
||
// 虚拟业务卡:方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
|
||
// 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。
|
||
const runStarted = Boolean(state.run && state.run.plan_id);
|
||
const extraCards = useMemo<Message[]>(() => {
|
||
const cards: Message[] = [];
|
||
if (
|
||
state.plans.length > 0 &&
|
||
!runStarted &&
|
||
phase === "proposal_ready"
|
||
) {
|
||
cards.push(
|
||
buildPlanGroupCard(state.plans, state.selectedPlanId, state.session?.requirement_revision ?? 1),
|
||
);
|
||
}
|
||
for (const command of selectPendingConfirmationCommands(state)) {
|
||
cards.push(buildCommandConfirmCard(command));
|
||
}
|
||
const head = selectHeadReportVersion(state);
|
||
if (head && (phase === "completed" || phase === "reviewing" || phase === "cancelled")) {
|
||
cards.push(buildReportAvailableCard(head, state.reportVersions.length));
|
||
}
|
||
return cards;
|
||
}, [phase, runStarted, state]);
|
||
|
||
const { displayThread } = useReportCollaborationMessages(
|
||
state,
|
||
effectiveFocus,
|
||
extraCards,
|
||
session.sendBusy,
|
||
);
|
||
|
||
const streamingIds = useMemo(() => selectStreamingAgentRunIds(state), [state]);
|
||
const awaitingIds = useMemo(() => {
|
||
const ids = new Set<string>();
|
||
for (const node of selectAwaitingInputNodes(state)) {
|
||
for (const agentRun of Object.values(state.agentRuns)) {
|
||
if (agentRun.node_run_id?.startsWith(`${node.node_id}-`)) {
|
||
ids.add(agentRun.agent_run_id);
|
||
}
|
||
}
|
||
}
|
||
return ids;
|
||
}, [state]);
|
||
|
||
const findMemberOfNode = useCallback(
|
||
(nodeId: string) => selectAgentRunForNode(state, nodeId),
|
||
[state],
|
||
);
|
||
|
||
const handleSelectNode = useCallback(
|
||
(nodeId: string | null) => {
|
||
setSelectedNodeId(nodeId);
|
||
setDrawerOpen(Boolean(nodeId));
|
||
if (nodeId) {
|
||
const member = findMemberOfNode(nodeId);
|
||
if (member) {
|
||
setFocusedAgentRunId(member.agent_run_id);
|
||
setFocusPinned(true);
|
||
}
|
||
}
|
||
},
|
||
[findMemberOfNode],
|
||
);
|
||
|
||
const handleNodeComment = useCallback(
|
||
(nodeId: string, nodeName: string) => {
|
||
const member = findMemberOfNode(nodeId);
|
||
setComposerTarget({ nodeId, nodeName, agentRunId: member?.agent_run_id ?? null });
|
||
if (member) {
|
||
setFocusedAgentRunId(member.agent_run_id);
|
||
setFocusPinned(true);
|
||
}
|
||
setDrawerOpen(false);
|
||
setFocusSignal((signal) => signal + 1);
|
||
},
|
||
[findMemberOfNode],
|
||
);
|
||
|
||
const handleRestore = useCallback(
|
||
async (versionId: string) => {
|
||
setRestoreBusy(true);
|
||
try {
|
||
await getReportCollaborationDriver().restoreReportVersion(sessionId, versionId);
|
||
session.refreshSnapshot();
|
||
toast.success("已恢复为新的头版本");
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setRestoreBusy(false);
|
||
}
|
||
},
|
||
[sessionId, session],
|
||
);
|
||
|
||
const businessCardSlot = useCallback(
|
||
(message: Message) => {
|
||
const card = readReportCollaborationCard(message);
|
||
if (!card) return null;
|
||
if (card.kind === "plan_group") {
|
||
return (
|
||
<ReportPlanCards
|
||
plans={card.plans}
|
||
selectedPlanId={card.selectedPlanId}
|
||
previewPlanId={previewPlanId}
|
||
onPreview={previewPlan}
|
||
onSelect={(planId) => void session.selectPlan(planId)}
|
||
onStart={(planId) => void session.startRun(planId)}
|
||
onRegenerate={() => void session.requestPlans()}
|
||
selectBusy={session.sendBusy}
|
||
startBusy={session.sendBusy}
|
||
regenerateBusy={false}
|
||
/>
|
||
);
|
||
}
|
||
if (card.kind === "command_confirm") {
|
||
return (
|
||
<InterventionCard
|
||
command={card.command}
|
||
busy={session.sendBusy}
|
||
onConfirm={(commandId) => void session.confirmCommand(commandId)}
|
||
onCancel={(commandId) => void session.cancelCommand(commandId)}
|
||
/>
|
||
);
|
||
}
|
||
if (card.kind === "report_available") {
|
||
return (
|
||
<ReportAvailableCard
|
||
version={card.version}
|
||
versionCount={card.versionCount}
|
||
onOpen={(versionId) => {
|
||
setPreviewVersionId(versionId);
|
||
setPreviewOpen(true);
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
return null;
|
||
},
|
||
[previewPlan, previewPlanId, session],
|
||
);
|
||
|
||
const speakerShell = useCallback(
|
||
(messages: Message[], children: React.ReactNode) => {
|
||
const speaker = resolveReportSpeaker(messages, state.agentRuns);
|
||
if (!speaker) return children;
|
||
return (
|
||
<ReportSpeakerShell
|
||
speaker={speaker}
|
||
avatarSelected={Boolean(speaker.agentRunId) && effectiveFocus === speaker.agentRunId}
|
||
onAvatarClick={(agentRunId) => {
|
||
if (focusPinned && effectiveFocus === agentRunId) {
|
||
setFocusedAgentRunId(null);
|
||
setFocusPinned(false);
|
||
return;
|
||
}
|
||
setFocusedAgentRunId(agentRunId);
|
||
setFocusPinned(true);
|
||
}}
|
||
>
|
||
{children}
|
||
</ReportSpeakerShell>
|
||
);
|
||
},
|
||
[effectiveFocus, focusPinned, state.agentRuns],
|
||
);
|
||
|
||
const runActive = isRunActive(state.run?.status);
|
||
const headVersion = selectHeadReportVersion(state);
|
||
const nodeStates = selectNodeStatusMap(state);
|
||
const selectedPlanNode = canvasPlan?.nodes.find((node) => node.id === selectedNodeId) ?? null;
|
||
const outgoingIds = new Set(canvasPlan?.edges.filter((edge) => edge.source === selectedNodeId).map((edge) => edge.target) ?? []);
|
||
const drawerFallback = selectedNodeId
|
||
? {
|
||
label: state.nodes[selectedNodeId]?.label ?? selectedPlanNode?.label ?? selectedNodeId,
|
||
status: state.nodes[selectedNodeId]?.status ?? "planned",
|
||
role: state.nodes[selectedNodeId]?.role_display_name ?? selectedPlanNode?.role_display_name ?? null,
|
||
angle: state.nodes[selectedNodeId]?.angle ?? selectedPlanNode?.angle ?? null,
|
||
kind: (selectedPlanNode && selectedPlanNode.depends_on.length === 0
|
||
? "start"
|
||
: outgoingIds.size > 0
|
||
? "agent"
|
||
: "end") as "start" | "agent" | "end",
|
||
mission: selectedPlanNode?.mission ?? null,
|
||
input_artifact_types: selectedPlanNode?.input_artifact_types ?? [],
|
||
output_artifact_type: selectedPlanNode?.output_artifact_type ?? null,
|
||
allowed_tools: selectedPlanNode?.allowed_tools ?? [],
|
||
acceptance_criteria: selectedPlanNode?.acceptance_criteria ?? [],
|
||
upstream: selectedPlanNode?.depends_on ?? [],
|
||
downstream: canvasPlan?.edges.filter((edge) => edge.source === selectedNodeId).map((edge) => edge.target) ?? [],
|
||
}
|
||
: null;
|
||
|
||
if (session.snapshotError && !state.snapshotLoaded) {
|
||
return (
|
||
<ReportCollaborationLayout
|
||
layout={layout}
|
||
header={
|
||
<ReportCollaborationHeader
|
||
title="会话不可用"
|
||
phase="empty"
|
||
onBack={() => navigate("/page/workspace/report-collaboration")}
|
||
onNewSession={() => navigate("/page/workspace/report-collaboration")}
|
||
onOpenReport={() => undefined}
|
||
hasReport={false}
|
||
running={false}
|
||
onCancelRun={() => undefined}
|
||
/>
|
||
}
|
||
conversation={
|
||
<div className="rc-empty">
|
||
<FileText className="size-8 opacity-30" aria-hidden />
|
||
<p>{session.snapshotError}</p>
|
||
<button type="button" className="rc-btn" onClick={() => navigate("/page/workspace/report-collaboration")}>
|
||
返回新建会话
|
||
</button>
|
||
</div>
|
||
}
|
||
canvas={<ReportFlowCanvas plan={null} nodeStates={{}} selectedNodeId={null} onSelectNode={() => undefined} />}
|
||
statusBar={<ConnectionStatusBar status="idle" runId={null} lastSeq={0} />}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<SubtasksProvider>
|
||
<ArtifactsProvider>
|
||
<ThreadContext.Provider value={{ thread: displayThread, isMock: true }}>
|
||
<ReportCollaborationLayout
|
||
layout={layout}
|
||
header={
|
||
<ReportCollaborationHeader
|
||
title={state.session?.title ?? "报告协作"}
|
||
phase={phase}
|
||
onBack={() => navigate("/page/workspace/chats/new")}
|
||
onNewSession={() => navigate("/page/workspace/report-collaboration")}
|
||
onOpenReport={() => {
|
||
if (headVersion) {
|
||
setPreviewVersionId(headVersion.id);
|
||
setPreviewOpen(true);
|
||
}
|
||
}}
|
||
hasReport={Boolean(headVersion)}
|
||
running={runActive}
|
||
onCancelRun={() => void session.cancelRun()}
|
||
extra={
|
||
layout.isNarrow ? (
|
||
<NarrowSwitch narrowView={layout.narrowView} setNarrowView={layout.setNarrowView} />
|
||
) : null
|
||
}
|
||
/>
|
||
}
|
||
conversation={
|
||
<ConversationSidebarTabs
|
||
conversation={
|
||
<ReportConversationPanel
|
||
sessionId={sessionId}
|
||
thread={displayThread}
|
||
businessCardSlot={businessCardSlot}
|
||
speakerShell={speakerShell}
|
||
listKey={effectiveFocus ?? "timeline"}
|
||
loading={session.snapshotLoading}
|
||
stageSelector={
|
||
<AgentStageSelector
|
||
tabs={agentTabs}
|
||
pinnedAgent={pinnedAgent}
|
||
phaseLabel={
|
||
agentTabs.length > 1
|
||
? `并行 ${agentTabs.length} 席`
|
||
: focusedTab?.display_name ?? (agentTabs[0]?.display_name ?? null)
|
||
}
|
||
focusedAgentRunId={effectiveFocus}
|
||
onFocus={(agentRunId) => {
|
||
setFocusedAgentRunId(agentRunId);
|
||
setFocusPinned(true);
|
||
}}
|
||
onShowAll={() => {
|
||
setFocusedAgentRunId(null);
|
||
setFocusPinned(false);
|
||
}}
|
||
streamingAgentRunIds={streamingIds}
|
||
awaitingAgentRunIds={awaitingIds}
|
||
/>
|
||
}
|
||
emptyHint={
|
||
effectiveFocus
|
||
? `${focusedTab?.display_name ?? "智能体"}正在执行,输出会出现在这里`
|
||
: undefined
|
||
}
|
||
composer={
|
||
<ReportComposer
|
||
phase={phase}
|
||
busy={session.sendBusy}
|
||
target={composerTarget}
|
||
onClearTarget={() => setComposerTarget(null)}
|
||
onSend={(text) =>
|
||
void session.send(text, {
|
||
nodeId: composerTarget?.nodeId ?? null,
|
||
agentRunId: composerTarget?.agentRunId ?? null,
|
||
})
|
||
}
|
||
focusSignal={focusSignal}
|
||
/>
|
||
}
|
||
/>
|
||
}
|
||
history={<SessionHistoryPanel activeSessionId={sessionId} />}
|
||
/>
|
||
}
|
||
canvas={
|
||
<>
|
||
<ReportFlowCanvas
|
||
plan={canvasPlan}
|
||
nodeStates={nodeStates}
|
||
selectedNodeId={selectedNodeId}
|
||
onSelectNode={handleSelectNode}
|
||
/>
|
||
<NodeInspectorDrawer
|
||
open={drawerOpen}
|
||
nodeId={selectedNodeId}
|
||
runId={state.run?.id ?? state.session?.active_run_id ?? null}
|
||
fallback={drawerFallback}
|
||
onClose={() => setDrawerOpen(false)}
|
||
onComment={handleNodeComment}
|
||
/>
|
||
<ReportSandboxOverlay threadId={sessionId} />
|
||
</>
|
||
}
|
||
statusBar={
|
||
<ConnectionStatusBar
|
||
status={session.streamStatus.status}
|
||
runId={state.run?.id ?? null}
|
||
lastSeq={state.lastSeq}
|
||
message={session.streamStatus.message}
|
||
/>
|
||
}
|
||
/>
|
||
<ReportPreviewDialog
|
||
open={previewOpen}
|
||
onClose={() => setPreviewOpen(false)}
|
||
versions={state.reportVersions}
|
||
activeVersionId={previewVersionId}
|
||
onSelectVersion={setPreviewVersionId}
|
||
onRestore={(versionId) => void handleRestore(versionId)}
|
||
restoreBusy={restoreBusy}
|
||
/>
|
||
</ThreadContext.Provider>
|
||
</ArtifactsProvider>
|
||
</SubtasksProvider>
|
||
);
|
||
}
|