diff --git a/src/components/ChatBox/EventNativeProjectTimeline.tsx b/src/components/ChatBox/EventNativeProjectTimeline.tsx index 7a3dd5f9..52262743 100644 --- a/src/components/ChatBox/EventNativeProjectTimeline.tsx +++ b/src/components/ChatBox/EventNativeProjectTimeline.tsx @@ -12,12 +12,12 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import { useProjectEventStoreHydration } from '@/hooks/useProjectEventStoreHydration'; -import { useProjectChatProjection } from '@/hooks/useProjectEventView'; +import { useProjectEventRuntime } from '@/hooks/useProjectEventRuntime'; import { selectRenderableChatNodes, type ChatProjectionNode, } from '@/lib/projector/chat'; +import { usePageTabStore } from '@/store/pageTabStore'; import { useEffect, useLayoutEffect, @@ -97,13 +97,12 @@ export function EventNativeProjectTimeline({ scrollContainerRef, scrollBottomInsetPx, }: EventNativeProjectTimelineProps) { - const hydration = useProjectEventStoreHydration({ - projectId, - enabled: true, - }); - const projection = useProjectChatProjection(projectId); + const runtime = useProjectEventRuntime(); + const hydration = runtime.hydration; + const projection = + runtime.projectId === projectId ? runtime.snapshot?.chat : undefined; const allNodes = useMemo( - () => selectRenderableChatNodes(projection), + () => (projection ? selectRenderableChatNodes(projection) : []), [projection] ); const timelineWindow = useMemo( @@ -118,6 +117,12 @@ export function EventNativeProjectTimeline({ const ignoreAnchorScrollRef = useRef(false); const anchorAnimationRef = useRef(null); const contentRef = useRef(null); + const scrollToTurnRequest = usePageTabStore( + (state) => state.scrollToTurnRequest + ); + const setScrollToTurnRequest = usePageTabStore( + (state) => state.setScrollToTurnRequest + ); const latestNode = visibleNodes.at(-1); const latestEventId = latestNode?.eventId; const userMessageNodes = visibleNodes.filter( @@ -228,6 +233,38 @@ export function EventNativeProjectTimeline({ [] ); + useEffect(() => { + if ( + !scrollToTurnRequest || + scrollToTurnRequest.projectId !== projectId || + !scrollContainerRef?.current + ) { + return; + } + const container = scrollContainerRef.current; + const target = Array.from( + container.querySelectorAll('[data-run-id]') + ).find( + (element) => + element.getAttribute('data-run-id') === scrollToTurnRequest.taskId + ); + if (!target) return; + + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + container.scrollTo({ + top: container.scrollTop + targetRect.top - containerRect.top, + behavior: 'smooth', + }); + setScrollToTurnRequest(null); + }, [ + projectId, + scrollContainerRef, + scrollToTurnRequest, + setScrollToTurnRequest, + visibleNodes, + ]); + return (
() => undefined; - type EventNativeProjectedRun = ProjectEventStoreSnapshot['view']['runs'][string]; @@ -312,32 +306,12 @@ export default function ChatBox(): JSX.Element { ); const activeProjectId = projectStore.activeProjectId; const eventNativeTimelineEnabled = isChatEventTimelineEnabled(); - const eventNativeProjectStore = useMemo( - () => - eventNativeTimelineEnabled && activeProjectId - ? getProjectEventStore(activeProjectId) - : null, - [activeProjectId, eventNativeTimelineEnabled] - ); - const subscribeToEventNativeProject = useCallback( - (listener: () => void) => - eventNativeProjectStore?.subscribe(listener) ?? subscribeToNothing(), - [eventNativeProjectStore] - ); - const getEventNativeProjectSnapshot = useCallback( - () => eventNativeProjectStore?.getSnapshot() ?? null, - [eventNativeProjectStore] - ); - const eventNativeProjectSnapshot = useSyncExternalStore( - subscribeToEventNativeProject, - getEventNativeProjectSnapshot, - getEventNativeProjectSnapshot - ); - useProjectRunEventStreams({ - projectId: activeProjectId, - snapshot: eventNativeProjectSnapshot, - enabled: eventNativeTimelineEnabled, - }); + const { snapshot: sharedProjectEventSnapshot } = useProjectEventRuntime(); + const eventNativeProjectSnapshot = + eventNativeTimelineEnabled && + sharedProjectEventSnapshot?.view.projectId === activeProjectId + ? sharedProjectEventSnapshot + : null; const eventNativeReadOnlyRun = selectLatestReadOnlyEventNativeRun( eventNativeProjectSnapshot ); @@ -1751,7 +1725,7 @@ export default function ChatBox(): JSX.Element { const handleEventNativeStopRun = async (runId: string) => { const currentRunId = selectEventNativeActiveRunId( - eventNativeProjectStore?.getSnapshot() ?? null, + eventNativeProjectSnapshot, eligibleLegacyActiveRunId ); if (runId !== currentRunId) return; diff --git a/src/components/Folder/index.tsx b/src/components/Folder/index.tsx index d61a8d3c..dca04656 100644 --- a/src/components/Folder/index.tsx +++ b/src/components/Folder/index.tsx @@ -57,7 +57,7 @@ import FolderComponent from './FolderComponent'; import { fetchGet, getBaseURL } from '@/api/http'; import { MarkDown } from '@/components/ChatBox/MessageItem/MarkDown'; -import { getSidePanelOutputFilesRevision } from '@/components/Session/SidePanelSections/collectSidePanelOutputFiles'; +import { getSidePanelOutputFilesRevision } from '@/components/Session/SidePanel/sections/collectSidePanelOutputFiles'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn'; import { useHost } from '@/host'; diff --git a/src/components/Session/SidePanel/components/ActivityPanel.tsx b/src/components/Session/SidePanel/components/ActivityPanel.tsx index 162566dc..c22633a5 100644 --- a/src/components/Session/SidePanel/components/ActivityPanel.tsx +++ b/src/components/Session/SidePanel/components/ActivityPanel.tsx @@ -47,6 +47,7 @@ import { import { useProjectOutputFiles } from '@/components/Session/SidePanel/sections/useProjectOutputFiles'; import { Button } from '@/components/ui/button'; import { TooltipSimple } from '@/components/ui/tooltip'; +import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { useProjectSessionOverview } from '@/hooks/useProjectSessionOverview'; import { useHost } from '@/host'; import { usePageTabStore } from '@/store/pageTabStore'; @@ -398,9 +399,12 @@ export function SessionActivityPanel({ }) { const { t } = useTranslation(); const host = useHost(); + const { chatStore } = useChatStoreAdapter(); const projectStore = useProjectRuntimeStore(); const projectId = projectStore.activeProjectId; const overview = useProjectSessionOverview(projectId); + const activeTaskId = chatStore?.activeTaskId ?? null; + const activeTask = activeTaskId ? chatStore?.tasks[activeTaskId] : undefined; const skills = useSkillsStore((state) => state.skills); const [connectors, setConnectors] = useState([]); const requestTaskBoxFocus = usePageTabStore( @@ -444,8 +448,8 @@ export function SessionActivityPanel({ ); const projectFiles = useProjectOutputFiles( projectId, - overview.currentRun?.task, - overview.currentRun?.taskId + activeTask, + activeTaskId ); const files = useMemo( () => @@ -465,16 +469,12 @@ export function SessionActivityPanel({ ] ); - const attachToRun = ( - run: NonNullable, - selectedFiles: File[] - ) => { - if (selectedFiles.length === 0) return; + const attachToRun = (selectedFiles: File[]) => { + if (selectedFiles.length === 0 || !chatStore || !activeTaskId) return; // Read attaches at merge time so files added while the picker was open // are not clobbered. - const state = run.chatStore.getState(); - const existingFiles = state.tasks[run.taskId]?.attaches ?? []; - state.setAttaches(run.taskId, [ + const existingFiles = chatStore.tasks[activeTaskId]?.attaches ?? []; + chatStore.setAttaches(activeTaskId, [ ...existingFiles, ...selectedFiles.filter( (selected) => @@ -486,8 +486,7 @@ export function SessionActivityPanel({ }; const addFiles = async () => { - const run = overview.currentRun; - if (!run || addingFiles) return; + if (!chatStore || !activeTaskId || addingFiles) return; if (isWeb()) { // A dismissed file dialog has no dependable signal (`cancel` is not @@ -521,7 +520,7 @@ export function SessionActivityPanel({ ); } } - attachToRun(run, uploads); + attachToRun(uploads); } finally { setAddingFiles(false); } @@ -537,7 +536,7 @@ export function SessionActivityPanel({ filters: [{ name: t('chat.all-files'), extensions: ['*'] }], }); if (result?.success && Array.isArray(result.files)) { - attachToRun(run, result.files); + attachToRun(result.files); } } catch (error) { console.error('Select session files failed:', error); @@ -614,7 +613,7 @@ export function SessionActivityPanel({ size="sm" buttonContent="icon-only" buttonRadius="lg" - disabled={addingFiles || !overview.currentRun} + disabled={addingFiles || !chatStore || !activeTaskId} aria-label={addFilesLabel} onClick={() => void addFiles()} > diff --git a/src/components/Session/SidePanel/sections/buildProjectSessionPanelData.ts b/src/components/Session/SidePanel/sections/buildProjectSessionPanelData.ts index 17a1c5a7..020076b6 100644 --- a/src/components/Session/SidePanel/sections/buildProjectSessionPanelData.ts +++ b/src/components/Session/SidePanel/sections/buildProjectSessionPanelData.ts @@ -13,20 +13,21 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import type { ProjectSessionRun } from '@/hooks/useProjectSessionOverview'; +import type { + ChatActivityNode, + ChatPlanTaskStatus, + ChatProjectionNode, +} from '@/lib/projector/chat'; import { httpUrlOrNull } from '@/lib/richText'; -import { AgentStep, TaskStatus } from '@/types/constants'; +import { TaskStatus, type TaskStatusType } from '@/types/constants'; import { - buildContextItems, extractLoadedSkillNames, normalizeContextKey, resolveContextConnector, type ContextConnector, type ContextSkill, } from './buildContextItems'; -import { - collectSidePanelOutputFiles, - mergeSidePanelOutputFiles, -} from './collectSidePanelOutputFiles'; +import { mergeSidePanelOutputFiles } from './collectSidePanelOutputFiles'; import type { ContextCategory, ContextItem } from './ExecutionContextSection'; export interface SessionProgressItem { @@ -48,19 +49,21 @@ export interface SessionAgentItem { createdAt: number; updatedAt: number; subagent: boolean; - sourceAgent?: Agent; } export interface SessionToolCall { id: string; toolkitName: string; method: string; + /** Safe semantic detail only; raw durable payloads never enter this model. */ input: string; + /** Safe semantic detail only; raw durable payloads never enter this model. */ output: string; status: 'running' | 'done'; taskId: string; agentName: string; createdAt: number; + updatedAt: number; skillNames: string[]; } @@ -101,7 +104,7 @@ export interface SessionEnvironmentItem { updatedAt: number; } -/** Project-wide data consumed by the unified SidePanel. */ +/** Project-wide data consumed by the locked SidePanel UI. */ export interface ProjectSessionPanelData { agents: SessionAgentItem[]; contextItems: SessionContextItem[]; @@ -112,284 +115,295 @@ export interface ProjectSessionPanelData { toolCalls: SessionToolCall[]; } -function normalizeMessage(value: unknown): string { - if (typeof value === 'string') return value; - if (value == null) return ''; - try { - return JSON.stringify(value); - } catch { - return String(value); - } +const ACTIVE_TOOL_STATUSES = new Set(['pending', 'running']); +const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed', 'cancelled']); + +function nodeTime(node: ChatProjectionNode, fallback = 0): number { + const parsed = node.createdAt ? Date.parse(node.createdAt) : Number.NaN; + return Number.isFinite(parsed) ? parsed : fallback + node.runSequence; } -function eventTime(event: AgentMessage, fallback: number, sequence: number) { - if (typeof event.timestamp === 'number' && Number.isFinite(event.timestamp)) { - return event.timestamp < 1_000_000_000_000 - ? event.timestamp * 1000 - : event.timestamp; - } - if (event.created_at) { - const parsed = Date.parse(event.created_at); - if (Number.isFinite(parsed)) return parsed; - } - return fallback + sequence; +function normalizedToolIdentity(node: ChatActivityNode): string { + return JSON.stringify([ + node.runId, + normalizeContextKey(node.agentId || node.agentName || ''), + normalizeContextKey(node.toolkitName || node.toolName || 'tool'), + normalizeContextKey(node.methodName || node.toolName || node.title), + ]); } +function safeToolDetail(node: ChatActivityNode): string { + if (node.detail?.trim()) return node.detail.trim(); + // Legacy toolkit frames route their already-approved display text through + // the semantic title. Typed durable events never take this compatibility + // path and still require explicit display_detail/display_summary fields. + if ( + node.legacyStep === 'activate_toolkit' || + node.legacyStep === 'deactivate_toolkit' + ) { + return node.title.trim(); + } + return ''; +} + +function toolCallFromNode(node: ChatActivityNode): SessionToolCall { + const active = ACTIVE_TOOL_STATUSES.has(node.status); + const detail = safeToolDetail(node); + return { + id: node.toolCallId || `tool-call:${node.eventId}`, + toolkitName: node.toolkitName?.trim() || node.toolName?.trim() || 'Tool', + method: + node.methodName?.trim() || node.toolName?.trim() || node.title.trim(), + input: active ? detail : '', + output: active ? '' : detail, + status: active ? 'running' : 'done', + taskId: node.runId, + agentName: node.agentName?.trim() || '', + createdAt: nodeTime(node), + updatedAt: nodeTime(node), + skillNames: [], + }; +} + +function mergeToolNode(call: SessionToolCall, node: ChatActivityNode): void { + const detail = safeToolDetail(node); + const active = ACTIVE_TOOL_STATUSES.has(node.status); + if (detail) { + if (active && !call.input) call.input = detail; + if (!active) { + call.output = [call.output, detail].filter(Boolean).join('\n\n'); + } + } + call.updatedAt = Math.max(call.updatedAt, nodeTime(node)); + if (!active) call.status = 'done'; + if (!call.agentName && node.agentName) call.agentName = node.agentName; +} + +/** + * Fold immutable semantic tool lifecycle nodes into logical calls. Explicit + * backend call IDs win; older legacy frames use FIFO pairing per identity. + */ export function collectSessionToolCalls( runs: ProjectSessionRun[] ): SessionToolCall[] { const calls: SessionToolCall[] = []; - let sequence = 0; + const byCallId = new Map(); + const anonymousOpen = new Map(); for (const run of [...runs].reverse()) { - for (const agent of run.task.taskAssigning ?? []) { - const openByPair = new Map(); - for (const event of agent.log ?? []) { - if ( - event.step !== AgentStep.ACTIVATE_TOOLKIT && - event.step !== AgentStep.DEACTIVATE_TOOLKIT - ) { - continue; - } - const toolkitName = String(event.data?.toolkit_name ?? '').trim(); - if (!toolkitName || toolkitName.toLowerCase() === 'notice') continue; - const method = String(event.data?.method_name ?? '').trim(); - const message = normalizeMessage(event.data?.message).trim(); - const pair = `${normalizeContextKey(toolkitName)}:${method - .toLowerCase() - .replace(/_/g, ' ')}`; + for (const node of run.nodes) { + if (node.kind !== 'activity' || node.activityType !== 'tool') continue; + const identity = normalizedToolIdentity(node); - if (event.step === AgentStep.ACTIVATE_TOOLKIT) { - const call: SessionToolCall = { - id: `${run.taskId}:${agent.agent_id}:${sequence}`, - toolkitName, - method, - input: message, - output: '', - status: 'running', - taskId: run.taskId, - agentName: agent.name, - createdAt: eventTime(event, run.createdAt, sequence), - skillNames: - normalizeContextKey(toolkitName) === 'skill' - ? extractLoadedSkillNames(message) - : [], - }; - sequence += 1; + if (node.toolCallId) { + const callKey = `${node.runId}:${node.toolCallId}`; + const existing = byCallId.get(callKey); + if (existing) { + mergeToolNode(existing, node); + } else { + const call = toolCallFromNode(node); + byCallId.set(callKey, call); calls.push(call); - const pending = openByPair.get(pair) ?? []; - pending.push(call); - openByPair.set(pair, pending); - continue; } + continue; + } - const pending = openByPair.get(pair) ?? []; - const call = [...pending] - .reverse() - .find((item) => item.status === 'running'); - if (call) { - call.output = [call.output, message].filter(Boolean).join('\n\n'); - call.status = 'done'; - continue; - } + if (ACTIVE_TOOL_STATUSES.has(node.status)) { + const call = toolCallFromNode(node); + calls.push(call); + const pending = anonymousOpen.get(identity) ?? []; + pending.push(call); + anonymousOpen.set(identity, pending); + continue; + } - calls.push({ - id: `${run.taskId}:${agent.agent_id}:${sequence}`, - toolkitName, - method, - input: '', - output: message, - status: 'done', - taskId: run.taskId, - agentName: agent.name, - createdAt: eventTime(event, run.createdAt, sequence), - skillNames: [], - }); - sequence += 1; + const pending = anonymousOpen.get(identity) ?? []; + const call = TERMINAL_TOOL_STATUSES.has(node.status) + ? pending.shift() + : undefined; + if (call) { + mergeToolNode(call, node); + } else { + calls.push(toolCallFromNode(node)); } } } - return calls.sort((a, b) => a.createdAt - b.createdAt); -} - -function mergeSubtasks(run: ProjectSessionRun): TaskInfo[] { - const { task } = run; - const singleAgent = task.taskAssigning.find( - (agent) => agent.type === 'single_agent' - ); - const base = - singleAgent?.tasks?.length && singleAgent.tasks.length > 0 - ? singleAgent.tasks - : task.taskInfo; - const liveById = new Map( - task.taskRunning.map((item) => [item.id, item] as const) - ); - const merged = base.map((item) => { - const live = liveById.get(item.id); - return live - ? { ...item, ...live, content: item.content || live.content } - : item; - }); - for (const live of task.taskRunning) { - if (!merged.some((item) => item.id === live.id)) merged.push(live); - } - return merged.filter((item) => item.content.trim() !== ''); -} - -function selectedToolNames(agent: Agent): string[] { - const names = new Set(agent.tools ?? []); - const selected = agent.workerInfo?.selectedTools; - if (Array.isArray(selected)) { - for (const value of selected) { - if (typeof value === 'string') { - names.add(value); - } else if (value && typeof value === 'object') { - const item = value as { name?: string; key?: string; toolkit?: string }; - const name = item.name ?? item.key ?? item.toolkit; - if (name) names.add(name); - } + for (const call of calls) { + if (/skill/i.test(call.toolkitName)) { + call.skillNames = extractLoadedSkillNames( + [call.input, call.output].filter(Boolean).join('\n') + ); } } - return [...names]; -} - -function extractJsonString(input: string, key: string): string | null { - try { - const parsed = JSON.parse(input); - const value = parsed?.[key]; - return typeof value === 'string' && value.trim() ? value.trim() : null; - } catch { - const match = input.match( - new RegExp(`${key}\\s*=\\s*["']([^"']+)["']`, 'i') - ); - return match?.[1]?.trim() || null; - } + return calls.sort( + (left, right) => + left.createdAt - right.createdAt || left.id.localeCompare(right.id) + ); } function collectAgents( runs: ProjectSessionRun[], calls: SessionToolCall[] ): SessionAgentItem[] { - const items = new Map(); + const agents = new Map(); + const put = ( + run: ProjectSessionRun, + identity: string, + name: string, + description: string, + subagent: boolean + ) => { + const key = `${subagent ? 'subagent' : 'agent'}:${normalizeContextKey( + identity || name || 'agent' + )}`; + const existing = agents.get(key); + if (!existing) { + agents.set(key, { + id: key, + name, + type: subagent ? 'subagent' : 'agent', + description, + tools: [], + historical: !run.isCurrent, + createdAt: run.createdAt, + updatedAt: run.updatedAt, + subagent, + }); + return; + } + if (!existing.name && name) existing.name = name; + if (!existing.description && description) + existing.description = description; + if (run.isCurrent) existing.historical = false; + existing.createdAt = Math.max(existing.createdAt, run.createdAt); + existing.updatedAt = Math.max(existing.updatedAt, run.updatedAt); + }; for (const run of runs) { - for (const agent of run.task.taskAssigning ?? []) { - const displayName = agent.workerInfo?.name || agent.name; - const key = `${agent.type}:${displayName.trim().toLowerCase()}`; - const existing = items.get(key); - if (existing) { - existing.createdAt = Math.max(existing.createdAt, run.createdAt); - existing.updatedAt = Math.max(existing.updatedAt, run.updatedAt); - if (!run.isCurrent || !existing.historical) continue; + for (const node of run.nodes) { + if (node.kind !== 'activity') continue; + if (node.activityType === 'agent') { + const text = `${node.eventType} ${node.title}`.toLowerCase(); + const subagent = /sub.?agent|remote/.test(text); + put( + run, + node.agentId || node.agentName || node.title, + node.agentName || node.title, + node.detail || '', + subagent + ); + } else if (node.agentId || node.agentName) { + put( + run, + node.agentId || node.agentName || 'agent', + node.agentName || '', + '', + false + ); } - items.set(key, { - id: key, - name: displayName, - type: agent.type, - description: agent.workerInfo?.description || '', - tools: selectedToolNames(agent), - historical: !run.isCurrent, - createdAt: Math.max(existing?.createdAt ?? 0, run.createdAt), - updatedAt: Math.max(existing?.updatedAt ?? 0, run.updatedAt), - subagent: false, - sourceAgent: agent, - }); } } for (const call of calls) { - const normalized = `${call.toolkitName} ${call.method}` - .toLowerCase() - .replace(/[_-]/g, ' '); - if (!normalized.includes('sub agent') && !normalized.includes('subagent')) { - continue; + const run = runs.find((candidate) => candidate.runId === call.taskId); + if (!run) continue; + const callText = `${call.toolkitName} ${call.method}`.toLowerCase(); + const subagent = /sub.?agent|remote/.test(callText); + const identity = call.agentName || (subagent ? 'remote-subagent' : 'agent'); + put(run, identity, call.agentName, '', subagent); + const key = `${subagent ? 'subagent' : 'agent'}:${normalizeContextKey( + identity + )}`; + const agent = agents.get(key); + if (agent && !agent.tools.includes(call.toolkitName)) { + agent.tools.push(call.toolkitName); } - const owningRun = runs.find((run) => run.taskId === call.taskId); - const remoteName = extractJsonString(call.input, 'remote_agent_name') || ''; - const key = `subagent:${remoteName.toLowerCase() || 'remote-subagent'}`; - const existing = items.get(key); - const createdAt = Math.max( - existing?.createdAt ?? 0, - owningRun?.createdAt ?? call.createdAt - ); - const updatedAt = Math.max( - existing?.updatedAt ?? 0, - call.createdAt, - owningRun?.updatedAt ?? 0 - ); - if (existing && !existing.historical) { - existing.createdAt = createdAt; - existing.updatedAt = updatedAt; - continue; - } - items.set(key, { - id: key, - name: remoteName, - type: 'subagent', - description: extractJsonString(call.input, 'instruction') || '', - tools: [], - historical: !owningRun?.isCurrent, - createdAt, - updatedAt, - subagent: true, - }); } - return [...items.values()].sort( - (a, b) => - Number(a.historical) - Number(b.historical) || - Number(a.subagent) - Number(b.subagent) + return [...agents.values()].sort( + (left, right) => + Number(left.historical) - Number(right.historical) || + Number(left.subagent) - Number(right.subagent) || + left.name.localeCompare(right.name) ); } -function uploadedFiles(run: ProjectSessionRun): File[] { - const all = [ - ...run.task.messages - .filter((message) => message.role === 'user') - .flatMap((message) => message.attaches ?? []), - ...(run.task.attaches ?? []), - ]; - const seen = new Set(); - return all.filter((file) => { - if (!file.filePath || seen.has(file.filePath)) return false; - seen.add(file.filePath); - return true; - }); +function planStatus(status: ChatPlanTaskStatus): TaskStatusType { + switch (status) { + case 'completed': + return TaskStatus.COMPLETED; + case 'failed': + return TaskStatus.FAILED; + case 'skipped': + return TaskStatus.SKIPPED; + case 'blocked': + return TaskStatus.BLOCKED; + case 'running': + return TaskStatus.RUNNING; + default: + return TaskStatus.WAITING; + } } -function callMatchesContext( - call: SessionToolCall, - item: ContextItem, - connectors: ContextConnector[] -): boolean { - if (item.category === 'skill') { - const itemKey = normalizeContextKey(item.label); - return call.skillNames.some( - (name) => normalizeContextKey(name) === itemKey - ); - } - if (item.category === 'connector') { - const connector = resolveContextConnector( - call.toolkitName, - call.method, - call.input, - connectors - ); - if (connector) { - return ( - normalizeContextKey(connector.service) === - normalizeContextKey(item.id) || - normalizeContextKey(connector.displayName || connector.service) === - normalizeContextKey(item.label) - ); +function activityTaskStatus(node: ChatActivityNode): TaskStatusType { + return planStatus(node.status === 'cancelled' ? 'skipped' : node.status); +} + +function collectProgress(runs: ProjectSessionRun[]): SessionProgressItem[] { + const progress: SessionProgressItem[] = []; + for (const run of runs) { + const tasks = new Map(); + for (const node of run.nodes) { + const time = nodeTime(node, run.createdAt); + if (node.kind === 'plan') { + for (const task of node.tasks) { + const key = task.id || `${node.eventId}:${task.title}`; + const existing = tasks.get(key); + tasks.set(key, { + key: `${run.runId}:${key}`, + task: { + id: key, + content: task.title, + status: planStatus(task.status), + }, + taskId: run.runId, + historical: !run.isCurrent, + createdAt: existing?.createdAt ?? time, + updatedAt: time, + }); + } + } + if (node.kind !== 'activity' || node.activityType !== 'task') continue; + const key = node.taskId || node.eventId; + const existing = tasks.get(key); + tasks.set(key, { + key: `${run.runId}:${key}`, + task: { + id: key, + content: existing?.task.content || node.detail || node.title, + status: activityTaskStatus(node), + }, + taskId: run.runId, + historical: !run.isCurrent, + createdAt: existing?.createdAt ?? time, + updatedAt: time, + }); } - return ( - normalizeContextKey(call.toolkitName) === - normalizeContextKey(item.label || item.id) - ); + progress.push(...tasks.values()); } - return false; + return progress.sort( + (left, right) => + Number(left.historical) - Number(right.historical) || + left.createdAt - right.createdAt + ); +} + +function displaySkillName(name: string, skills: ContextSkill[]): string { + const match = skills.find( + (skill) => normalizeContextKey(skill.name) === normalizeContextKey(name) + ); + return match?.name || name; } function collectContext( @@ -398,51 +412,71 @@ function collectContext( skills: ContextSkill[], connectors: ContextConnector[] ): SessionContextItem[] { + const runById = new Map(runs.map((run) => [run.runId, run])); const items = new Map(); - for (const run of runs) { - const runItems = buildContextItems( - run.task.taskAssigning, - run.task.taskRunning, - uploadedFiles(run), - skills, - connectors - ).filter( - ( - item - ): item is ContextItem & { category: Exclude } => - item.category !== 'file' - ); - for (const item of runItems) { - const key = `${item.category}:${normalizeContextKey(item.label)}`; - const itemCalls = calls.filter((call) => - callMatchesContext(call, item, connectors) - ); - const existing = items.get(key); - if (existing) { - existing.calls = Array.from( - new Map( - [...existing.calls, ...itemCalls].map((call) => [call.id, call]) - ).values() - ); - if (run.isCurrent) existing.historical = false; - existing.createdAt = Math.max(existing.createdAt, run.createdAt); - existing.updatedAt = Math.max(existing.updatedAt, run.updatedAt); - continue; + + const put = ( + category: Exclude, + label: string, + iconUrl: string | undefined, + call: SessionToolCall + ) => { + const run = runById.get(call.taskId); + if (!run || !label.trim()) return; + const key = `${category}:${normalizeContextKey(label)}`; + const existing = items.get(key); + if (existing) { + if (!existing.calls.some((candidate) => candidate.id === call.id)) { + existing.calls.push(call); } - items.set(key, { - ...item, - historical: !run.isCurrent, - createdAt: run.createdAt, - updatedAt: Math.max( - run.updatedAt, - ...itemCalls.map((call) => call.createdAt) - ), - calls: itemCalls, - }); + if (run.isCurrent) existing.historical = false; + existing.createdAt = Math.max(existing.createdAt, run.createdAt); + existing.updatedAt = Math.max(existing.updatedAt, call.updatedAt); + return; + } + items.set(key, { + id: key, + label, + category, + iconUrl, + historical: !run.isCurrent, + createdAt: run.createdAt, + updatedAt: call.updatedAt, + calls: [call], + }); + }; + + for (const call of calls) { + if (/skill/i.test(call.toolkitName)) { + const names = call.skillNames.length > 0 ? call.skillNames : ['Skill']; + for (const name of names) { + put('skill', displaySkillName(name, skills), undefined, call); + } + continue; + } + + const connector = resolveContextConnector( + call.toolkitName, + call.method, + [call.input, call.output].filter(Boolean).join('\n'), + connectors + ); + if (connector) { + put( + 'connector', + connector.displayName || connector.service, + connector.iconUrl || undefined, + call + ); + } else if (/mcp|connector/i.test(call.toolkitName)) { + put('connector', call.toolkitName, undefined, call); } } + return [...items.values()].sort( - (a, b) => Number(a.historical) - Number(b.historical) + (left, right) => + Number(left.historical) - Number(right.historical) || + left.label.localeCompare(right.label) ); } @@ -469,210 +503,154 @@ function resourceLabel(url: string): string { } } -function collectResources( - runs: ProjectSessionRun[], - calls: SessionToolCall[] -): SessionResourceItem[] { +function safeNodeText(node: ChatProjectionNode): string { + switch (node.kind) { + case 'message': + return node.content; + case 'notice': + return `${node.title || ''}\n${node.content}`; + case 'interaction': + return `${node.prompt || ''}\n${node.response || ''}`; + case 'plan': + return `${node.title || ''}\n${node.summary || ''}`; + case 'activity': + return `${node.title}\n${node.detail || ''}`; + case 'artifact': + return node.path; + case 'run_status': + return node.reason || ''; + case 'unknown': + return ''; + } +} + +function collectResources(runs: ProjectSessionRun[]): SessionResourceItem[] { const resources = new Map(); - const outputPaths = new Set( - runs.flatMap((run) => - collectSidePanelOutputFiles(run.task).flatMap((file) => [ - file.path, - file.relativePath ?? '', - ]) - ) - ); - - const put = (item: SessionResourceItem) => { - const existing = resources.get(item.id); - if (existing) { - item.createdAt = Math.max(item.createdAt, existing.createdAt); - item.updatedAt = Math.max(item.updatedAt, existing.updatedAt); - } - if (!existing || (existing.historical && !item.historical)) { - resources.set(item.id, item); - } else if (existing) { - existing.updatedAt = item.updatedAt; - } - }; - for (const run of runs) { - for (const entry of run.task.webViewUrls ?? []) { - const urls = extractHttpUrls(entry.url); - for (const url of urls) { - put({ + for (const node of run.nodes) { + for (const url of extractHttpUrls(safeNodeText(node))) { + const existing = resources.get(url); + const time = nodeTime(node, run.createdAt); + const item: SessionResourceItem = { id: `url:${url}`, label: resourceLabel(url), kind: 'url', url, - taskId: run.taskId, + taskId: run.runId, historical: !run.isCurrent, - createdAt: run.createdAt, - updatedAt: run.updatedAt, - }); - } - } - - for (const file of uploadedFiles(run)) { - if (outputPaths.has(file.filePath)) continue; - const asUrl = httpUrlOrNull(file.filePath); - if (asUrl) { - put({ - id: `url:${asUrl}`, - label: file.fileName || resourceLabel(asUrl), - kind: 'url', - url: asUrl, - taskId: run.taskId, - historical: !run.isCurrent, - createdAt: run.createdAt, - updatedAt: run.updatedAt, - }); - } else { - put({ - id: `file:${file.filePath}`, - label: - file.fileName || file.filePath.split('/').pop() || file.filePath, - kind: 'file', - file: { - name: file.fileName, - path: file.filePath, - type: file.fileName.split('.').pop() || '', - }, - taskId: run.taskId, - historical: !run.isCurrent, - createdAt: run.createdAt, - updatedAt: run.updatedAt, - }); + createdAt: existing?.createdAt ?? time, + updatedAt: Math.max(existing?.updatedAt ?? 0, time), + }; + if (!existing || (existing.historical && run.isCurrent)) { + resources.set(url, item); + } else { + existing.updatedAt = item.updatedAt; + } } } } - - for (const call of calls) { - const run = runs.find((candidate) => candidate.taskId === call.taskId); - for (const url of extractHttpUrls(`${call.input}\n${call.output}`)) { - put({ - id: `url:${url}`, - label: resourceLabel(url), - kind: 'url', - url, - taskId: call.taskId, - historical: !run?.isCurrent, - createdAt: run?.createdAt ?? call.createdAt, - updatedAt: Math.max(call.createdAt, run?.updatedAt ?? 0), - }); - } - } - return [...resources.values()].sort( - (a, b) => Number(a.historical) - Number(b.historical) + (left, right) => + Number(left.historical) - Number(right.historical) || + right.updatedAt - left.updatedAt ); } +function fileInfoFromArtifact( + node: Extract +): FileInfo { + const name = + node.name || node.path.split('/').filter(Boolean).at(-1) || node.path; + const isRelative = !/^(?:[a-z]+:|\/|[a-z]:[\\/])/i.test(node.path); + return { + name, + type: name.includes('.') ? name.split('.').at(-1) || '' : '', + path: node.path, + relativePath: isRelative ? node.path : undefined, + artifactChange: + node.operation === 'created' + ? 'generated' + : node.operation === 'updated' + ? 'changed' + : undefined, + mimeType: node.mimeType, + }; +} + function collectFiles(runs: ProjectSessionRun[]): SessionFileItem[] { const files = new Map(); - for (const run of runs) { - for (const file of collectSidePanelOutputFiles(run.task)) { - const key = file.relativePath || file.path || file.name; - if (!key) continue; + for (const run of [...runs].reverse()) { + for (const node of run.nodes) { + if (node.kind !== 'artifact' || !node.path || httpUrlOrNull(node.path)) { + continue; + } + const key = node.path; + if (node.operation === 'deleted') { + files.delete(key); + continue; + } + const time = nodeTime(node, run.createdAt); const existing = files.get(key); - if (existing) { - existing.createdAt = Math.max(existing.createdAt, run.createdAt); - existing.updatedAt = Math.max(existing.updatedAt, run.updatedAt); - } - if (!existing || (existing.historical && run.isCurrent)) { - files.set(key, { - id: key, - file, - taskId: run.taskId, - historical: !run.isCurrent, - createdAt: Math.max(existing?.createdAt ?? 0, run.createdAt), - updatedAt: Math.max(existing?.updatedAt ?? 0, run.updatedAt), - }); - } + files.set(key, { + id: key, + file: fileInfoFromArtifact(node), + taskId: run.runId, + historical: existing?.historical === false ? false : !run.isCurrent, + createdAt: existing?.createdAt ?? time, + updatedAt: time, + }); } } return [...files.values()].sort( - (a, b) => Number(a.historical) - Number(b.historical) + (left, right) => + Number(left.historical) - Number(right.historical) || + right.updatedAt - left.updatedAt ); } function collectEnvironments( - runs: ProjectSessionRun[], - calls: SessionToolCall[] + runs: ProjectSessionRun[] ): SessionEnvironmentItem[] { const environments = new Map(); - const put = ( - label: string, - taskId: string, - historical: boolean, - createdAt: number, - updatedAt: number - ) => { + const put = (label: string, run: ProjectSessionRun, time: number) => { const id = label.toLowerCase(); const existing = environments.get(id); - const latestCreation = Math.max(existing?.createdAt ?? 0, createdAt); - const latestUpdate = Math.max(existing?.updatedAt ?? 0, updatedAt); - if (!existing || (existing.historical && !historical)) { + if (!existing || (existing.historical && run.isCurrent)) { environments.set(id, { id, label, - taskId, - historical, - createdAt: latestCreation, - updatedAt: latestUpdate, + taskId: run.runId, + historical: !run.isCurrent, + createdAt: existing?.createdAt ?? time, + updatedAt: Math.max(existing?.updatedAt ?? 0, time), }); } else { - existing.createdAt = latestCreation; - existing.updatedAt = latestUpdate; + existing.updatedAt = Math.max(existing.updatedAt, time); } }; for (const run of runs) { - if (run.task.webViewUrls.length > 0) { - put('Browser', run.taskId, !run.isCurrent, run.createdAt, run.updatedAt); - } - const hasTerminal = run.task.taskAssigning.some((agent) => - agent.tasks.some((task) => (task.terminal?.length ?? 0) > 0) - ); - if (hasTerminal) { - put('Terminal', run.taskId, !run.isCurrent, run.createdAt, run.updatedAt); + for (const node of run.nodes) { + if (node.kind !== 'activity') continue; + const time = nodeTime(node, run.createdAt); + const identity = `${node.activityType} ${node.title} ${ + node.toolkitName || '' + } ${node.methodName || ''}`.toLowerCase(); + if (node.activityType === 'terminal' || /terminal|shell/.test(identity)) { + put('Terminal', run, time); + } + if (/browser|search|scrape/.test(identity)) { + put('Browser', run, time); + } + if (/sub.?agent|remote/.test(identity)) { + put('Remote environment', run, time); + } } } - - for (const call of calls) { - const run = runs.find((candidate) => candidate.taskId === call.taskId); - const normalized = `${call.toolkitName} ${call.method}`.toLowerCase(); - if (/browser|search|scrape/.test(normalized)) { - put( - 'Browser', - call.taskId, - !run?.isCurrent, - run?.createdAt ?? call.createdAt, - Math.max(call.createdAt, run?.updatedAt ?? 0) - ); - } - if (/terminal|shell|code execution/.test(normalized)) { - put( - 'Terminal', - call.taskId, - !run?.isCurrent, - run?.createdAt ?? call.createdAt, - Math.max(call.createdAt, run?.updatedAt ?? 0) - ); - } - if (/sub.?agent|remote/.test(normalized)) { - put( - 'Remote environment', - call.taskId, - !run?.isCurrent, - run?.createdAt ?? call.createdAt, - Math.max(call.createdAt, run?.updatedAt ?? 0) - ); - } - } - return [...environments.values()].sort( - (a, b) => Number(a.historical) - Number(b.historical) + (left, right) => + Number(left.historical) - Number(right.historical) || + left.label.localeCompare(right.label) ); } @@ -685,19 +663,10 @@ export function buildProjectSessionPanelData( return { agents: collectAgents(runs, toolCalls), contextItems: collectContext(runs, toolCalls, skills, connectors), - environments: collectEnvironments(runs, toolCalls), + environments: collectEnvironments(runs), files: collectFiles(runs), - progress: runs.flatMap((run) => - mergeSubtasks(run).map((task) => ({ - key: `${run.taskId}:${task.id}`, - task, - taskId: run.taskId, - historical: !run.isCurrent, - createdAt: run.createdAt, - updatedAt: run.updatedAt, - })) - ), - resources: collectResources(runs, toolCalls), + progress: collectProgress(runs), + resources: collectResources(runs), toolCalls, }; } @@ -714,7 +683,7 @@ export function mergeProjectFiles( return merged.map((file) => { const id = file.relativePath || file.path || file.name; return ( - items.find((item) => item.id === id) ?? { + items.find((item) => item.id === id || item.file.path === file.path) ?? { id, file, taskId: fallbackTaskId, diff --git a/src/components/Session/index.tsx b/src/components/Session/index.tsx index 6b157c9f..1f3a4e52 100644 --- a/src/components/Session/index.tsx +++ b/src/components/Session/index.tsx @@ -17,6 +17,7 @@ import { HeaderBox } from '@/components/Session/HeaderBox'; import { PreviewPanel } from '@/components/Session/PreviewPanel'; import Workspace from '@/components/Workspace'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; +import { ProjectEventRuntimeProvider } from '@/hooks/useProjectEventRuntime'; import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn'; import { inferSessionModeFromTask } from '@/lib/sessionMode'; import { cn } from '@/lib/utils'; @@ -109,21 +110,6 @@ export default function Session({ isNewProject = false }: SessionProps) { return projectStore.getAllChatStores(projectStore.activeProjectId); }, [projectStore]); - const hasAnyMessages = useMemo(() => { - const hasMessages = (store: typeof chatStore) => - !!store && - Object.values(store.tasks).some( - (task) => (task.messages?.length || 0) > 0 || task.hasMessages - ); - if (hasMessages(chatStore)) return true; - return getAllChatStoresMemoized.some(({ chatStore: store }) => { - const state = store.getState(); - return Object.values(state.tasks).some( - (task) => (task.messages?.length || 0) > 0 || task.hasMessages - ); - }); - }, [chatStore, getAllChatStoresMemoized]); - const workforcePanelKey = chatStore?.activeTaskId ?? ''; const hasSessionStarted = useMemo(() => { @@ -137,7 +123,7 @@ export default function Session({ isNewProject = false }: SessionProps) { // assume the project never started, and bounce the user back to the // workforce shell — even though the project chatStore already has // task content. Cross-check live state via `getAllChatStores` (same - // pattern as `hasAnyMessages` above) to avoid that race. + // pattern as the project-wide store lookup above) to avoid that race. const checkTasks = (tasksRecord: Record | undefined) => { if (!tasksRecord) return false; return Object.values(tasksRecord).some((task) => { @@ -366,18 +352,115 @@ export default function Session({ isNewProject = false }: SessionProps) { if (isNewProject) { return ( -
-
- + +
- + +
+ +
+
+ +
+ {sessionSidePanel}
+
+ ); + } + + return ( + +
+ {/* Chat content: owns the project header and folds when display opens. */} +
+ +
+ +
+
+ + + {previewOpen && ( + +
+ + {/* Display content: middle column between chat and session. */} +
+ {activeProjectId ? ( + + ) : null} +
+ + )} +
- ); - } - - return ( -
- {/* Chat content: owns the project header and folds when display opens. */} -
- -
- -
-
- - - {previewOpen && ( - -
- - {/* Display content: middle column between chat and session. */} -
- {activeProjectId ? ( - - ) : null} -
- - )} - - -
- {sessionSidePanel} -
-
+ ); } diff --git a/src/hooks/useProjectEventRuntime.tsx b/src/hooks/useProjectEventRuntime.tsx new file mode 100644 index 00000000..13a583c6 --- /dev/null +++ b/src/hooks/useProjectEventRuntime.tsx @@ -0,0 +1,107 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { + useProjectEventStoreHydration, + type ProjectEventStoreHydrationState, +} from '@/hooks/useProjectEventStoreHydration'; +import { useProjectRunEventStreams } from '@/hooks/useProjectRunEventStreams'; +import { + getProjectEventStore, + type ProjectEventStoreSnapshot, +} from '@/store/projectEventStore'; +import { + createContext, + useCallback, + useContext, + useMemo, + useSyncExternalStore, + type ReactNode, +} from 'react'; + +const subscribeToNothing = () => () => undefined; +const getNullSnapshot = () => null; + +const IDLE_HYDRATION: ProjectEventStoreHydrationState = { + status: 'idle', + errorCode: null, + eventsTruncated: false, +}; + +export interface ProjectEventRuntimeValue { + hydration: ProjectEventStoreHydrationState; + projectId: string | null; + snapshot: ProjectEventStoreSnapshot | null; +} + +const ProjectEventRuntimeContext = createContext({ + hydration: IDLE_HYDRATION, + projectId: null, + snapshot: null, +}); + +/** + * Own the durable Project event runtime once for the whole Session shell. + * ChatBox and SessionSidePanel are read-only consumers of this shared owner. + */ +export function ProjectEventRuntimeProvider({ + children, + projectId, +}: { + children: ReactNode; + projectId: string | null | undefined; +}) { + const normalizedProjectId = projectId || null; + const store = useMemo( + () => + normalizedProjectId ? getProjectEventStore(normalizedProjectId) : null, + [normalizedProjectId] + ); + const subscribe = useCallback( + (listener: () => void) => + store?.subscribe(listener) ?? subscribeToNothing(), + [store] + ); + const getSnapshot = useCallback(() => store?.getSnapshot() ?? null, [store]); + const snapshot = useSyncExternalStore( + subscribe, + getSnapshot, + getNullSnapshot + ); + const hydration = useProjectEventStoreHydration({ + projectId: normalizedProjectId, + enabled: Boolean(normalizedProjectId), + }); + + useProjectRunEventStreams({ + projectId: normalizedProjectId, + snapshot, + enabled: Boolean(normalizedProjectId), + }); + + const value = useMemo( + () => ({ hydration, projectId: normalizedProjectId, snapshot }), + [hydration, normalizedProjectId, snapshot] + ); + + return ( + + {children} + + ); +} + +export function useProjectEventRuntime(): ProjectEventRuntimeValue { + return useContext(ProjectEventRuntimeContext); +} diff --git a/src/hooks/useProjectSessionOverview.ts b/src/hooks/useProjectSessionOverview.ts index 529393b8..4acf7eff 100644 --- a/src/hooks/useProjectSessionOverview.ts +++ b/src/hooks/useProjectSessionOverview.ts @@ -12,15 +12,25 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import type { ChatStore, VanillaChatStore } from '@/store/chatStore'; -import { useProjectRuntimeStore } from '@/store/projectRuntimeStore'; -import { useEffect, useMemo, useReducer } from 'react'; +import { useProjectEventRuntime } from '@/hooks/useProjectEventRuntime'; +import type { ProjectedRun } from '@/lib/projector'; +import type { ChatProjectionNode } from '@/lib/projector/chat'; +import type { ProjectEventStoreSnapshot } from '@/store/projectEventStore'; +import { useMemo } from 'react'; + +const LIVE_RUN_STATUSES = new Set([ + 'pending', + 'running', + 'waiting_for_user', + 'cancelling', +]); export interface ProjectSessionRun { - chatId: string; - chatStore: VanillaChatStore; - task: ChatStore['tasks'][string]; + /** Durable Run identity. `taskId` remains as a UI compatibility alias. */ + runId: string; taskId: string; + status: ProjectedRun['status']; + nodes: ChatProjectionNode[]; createdAt: number; updatedAt: number; isCurrent: boolean; @@ -32,102 +42,100 @@ export interface ProjectSessionOverview { runs: ProjectSessionRun[]; } -function persistedEventTime(event: AgentMessage): number | null { - if (typeof event.timestamp === 'number' && Number.isFinite(event.timestamp)) { - return event.timestamp < 1_000_000_000_000 - ? event.timestamp * 1000 - : event.timestamp; - } - if (event.created_at) { - const parsed = Date.parse(event.created_at); - if (Number.isFinite(parsed)) return parsed; - } - return null; +function timestamp(value: string | null | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; } -function taskUpdatedAt(task: ChatStore['tasks'][string]): number { - const createdAt = task.createdAt || 0; - let updatedAt = createdAt; - - if (task.taskTime > 0) updatedAt = Math.max(updatedAt, task.taskTime); - if (createdAt > 0 && task.elapsed > 0) { - updatedAt = Math.max(updatedAt, createdAt + task.elapsed); +function compareNodes(left: ChatProjectionNode, right: ChatProjectionNode) { + const byTime = timestamp(left.createdAt) - timestamp(right.createdAt); + if (byTime !== 0) return byTime; + if (left.runId === right.runId && left.runSequence !== right.runSequence) { + return left.runSequence - right.runSequence; } - for (const agent of task.taskAssigning ?? []) { - for (const event of agent.log ?? []) { - updatedAt = Math.max(updatedAt, persistedEventTime(event) ?? 0); - } - } - - return updatedAt; + return left.eventId.localeCompare(right.eventId); } -/** - * Project-level view of every run. Unlike `useSelectedProjectTurn`, this hook - * is deliberately independent from chat scroll position and TurnTabs. - */ -export function useProjectSessionOverview( - projectId: string | null | undefined -): ProjectSessionOverview { - const projectStore = useProjectRuntimeStore(); - const [, refresh] = useReducer((value: number) => value + 1, 0); - - const stores = useMemo( - () => (projectId ? projectStore.getAllChatStores(projectId) : []), - [projectId, projectStore] +function compareRuns(left: ProjectSessionRun, right: ProjectSessionRun) { + return ( + right.updatedAt - left.updatedAt || + right.createdAt - left.createdAt || + right.runId.localeCompare(left.runId) ); +} - useEffect(() => { - const unsubscribers = stores.map(({ chatStore }) => - chatStore.subscribe(refresh) - ); - return () => unsubscribers.forEach((unsubscribe) => unsubscribe()); - }, [stores]); - - const activeStore = projectId - ? projectStore.getActiveChatStore(projectId) - : null; - const activeTaskId = activeStore?.getState().activeTaskId ?? null; - const seen = new Set(); - const runs: ProjectSessionRun[] = []; - - for (const { chatId, chatStore } of stores) { - const state = chatStore.getState(); - for (const [taskId, task] of Object.entries(state.tasks)) { - if (seen.has(taskId)) continue; - const hasProjectContent = - task.messages.some((message) => message.role === 'user') || - task.taskInfo.length > 0 || - task.taskRunning.length > 0 || - task.taskAssigning.length > 0; - const isActiveTask = chatStore === activeStore && taskId === activeTaskId; - if (!hasProjectContent && !isActiveTask) continue; - seen.add(taskId); - const createdAt = task.createdAt || 0; - runs.push({ - chatId, - chatStore, - task, - taskId, - createdAt, - updatedAt: taskUpdatedAt(task), - isCurrent: isActiveTask, - }); - } +/** Build the SidePanel Run view exclusively from the bounded durable bus. */ +export function buildProjectSessionOverview( + snapshot: ProjectEventStoreSnapshot | null +): ProjectSessionOverview { + if (!snapshot) { + return { currentRun: null, historicalRuns: [], runs: [] }; } - runs.sort((a, b) => b.createdAt - a.createdAt || b.updatedAt - a.updatedAt); - const currentRun = - runs.find((run) => run.isCurrent) ?? (runs.length > 0 ? runs[0]! : null); + const nodesByRun = new Map(); + for (const node of snapshot.chat.nodes) { + const nodes = nodesByRun.get(node.runId) ?? []; + nodes.push(node); + nodesByRun.set(node.runId, nodes); + } + + const runIds = new Set([ + ...Object.keys(snapshot.view.runs), + ...nodesByRun.keys(), + ]); + const runs = [...runIds].map((runId) => { + const projectedRun = snapshot.view.runs[runId]; + const nodes = [...(nodesByRun.get(runId) ?? [])].sort(compareNodes); + const nodeTimes = nodes + .map((node) => timestamp(node.createdAt)) + .filter((value) => value > 0); + const aggregateTime = timestamp(projectedRun?.updatedAt); + const createdAt = + nodeTimes.length > 0 ? Math.min(...nodeTimes) : aggregateTime; + const updatedAt = Math.max(aggregateTime, ...nodeTimes, createdAt); + + return { + runId, + taskId: runId, + status: projectedRun?.status ?? 'unknown', + nodes, + createdAt, + updatedAt, + isCurrent: false, + }; + }); + + runs.sort(compareRuns); + const current = + runs + .filter((run) => LIVE_RUN_STATUSES.has(run.status)) + .sort(compareRuns)[0] ?? + runs[0] ?? + null; const normalizedRuns = runs.map((run) => ({ ...run, - isCurrent: run.taskId === currentRun?.taskId, + isCurrent: run.runId === current?.runId, })); + const currentRun = + normalizedRuns.find((run) => run.isCurrent) ?? current ?? null; return { - currentRun: - normalizedRuns.find((run) => run.isCurrent) ?? currentRun ?? null, + currentRun, historicalRuns: normalizedRuns.filter((run) => !run.isCurrent), runs: normalizedRuns, }; } + +/** + * Project-level SidePanel view backed by the SQLite journal projection. + * ChatStore is intentionally not consulted here. + */ +export function useProjectSessionOverview( + projectId: string | null | undefined +): ProjectSessionOverview { + const runtime = useProjectEventRuntime(); + const snapshot = + projectId && runtime.projectId === projectId ? runtime.snapshot : null; + return useMemo(() => buildProjectSessionOverview(snapshot), [snapshot]); +} diff --git a/src/store/chatEventProjectionBridge.ts b/src/store/chatEventProjectionBridge.ts index 82deaf8f..a1609fcb 100644 --- a/src/store/chatEventProjectionBridge.ts +++ b/src/store/chatEventProjectionBridge.ts @@ -28,16 +28,13 @@ export type ChatEventProjectionInput = { }; /** - * Shadowing is opt-in everywhere, including development. - * - * It was previously on for every dev build. Shadow ingestion builds a full - * parallel per-Project event store (multi-megabyte queue, legacy and chat - * budgets) that no visible UI reads while the timeline flag is off, so paying - * that cost in every dev session is not worth the parity signal. Set - * VITE_CHATBOX_EVENT_SHADOW=true to measure parity. + * The Session side panel is event-native, so the migration bridge is enabled + * by default even while the ChatBox renderer remains behind its own cutover + * flag. Set VITE_SESSION_SIDE_PANEL_EVENT_BUS=false only for emergency rollback. */ export function isChatEventProjectionEnabled(): boolean { return ( + import.meta.env.VITE_SESSION_SIDE_PANEL_EVENT_BUS !== 'false' || import.meta.env.VITE_CHATBOX_EVENT_SHADOW === 'true' || // The visible read path still needs the legacy /chat source bridge while // the canonical companion owns typed Run events. Keep the flags diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 5155907e..7ba75b6e 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -87,6 +87,13 @@ const PROJECT_CONTEXT_MAX_RUNS = 8; // end step. const MAX_CHAT_HISTORY_SUMMARY_LENGTH = 1024; +/** Compatibility lookup used by callers that only need the backend platform. */ +export function getCloudModelPlatform(modelId: string): string { + return ( + getCloudModelStore().resolveCloudModel(modelId)?.model.model_platform || '' + ); +} + export async function admitDurableRunResume( runId: string, requestId: string, diff --git a/test/unit/components/ChatBox/EventNativeProjectTimeline.test.tsx b/test/unit/components/ChatBox/EventNativeProjectTimeline.test.tsx index c023d2c0..8e542da8 100644 --- a/test/unit/components/ChatBox/EventNativeProjectTimeline.test.tsx +++ b/test/unit/components/ChatBox/EventNativeProjectTimeline.test.tsx @@ -37,11 +37,12 @@ const mocks = vi.hoisted(() => ({ } as ProjectEventStoreHydrationState, })); -vi.mock('@/hooks/useProjectEventView', () => ({ - useProjectChatProjection: () => mocks.projection, -})); -vi.mock('@/hooks/useProjectEventStoreHydration', () => ({ - useProjectEventStoreHydration: () => mocks.hydration, +vi.mock('@/hooks/useProjectEventRuntime', () => ({ + useProjectEventRuntime: () => ({ + projectId: 'project-1', + hydration: mocks.hydration, + snapshot: mocks.projection ? { chat: mocks.projection } : null, + }), })); vi.mock('framer-motion', async (importOriginal) => { const actual = await importOriginal(); diff --git a/test/unit/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts b/test/unit/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts index 16ad8b86..a6ba1427 100644 --- a/test/unit/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts +++ b/test/unit/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts @@ -15,7 +15,7 @@ import { collectSidePanelOutputFiles, getSidePanelOutputFilesRevision, -} from '@/components/Session/SidePanelSections/collectSidePanelOutputFiles'; +} from '@/components/Session/SidePanel/sections/collectSidePanelOutputFiles'; import { describe, expect, it } from 'vitest'; const reportFile = (): FileInfo => ({ diff --git a/test/unit/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx b/test/unit/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx index 5275531f..d6eff45b 100644 --- a/test/unit/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx +++ b/test/unit/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx @@ -12,7 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import { useProjectOutputFiles } from '@/components/Session/SidePanelSections/useProjectOutputFiles'; +import { useProjectOutputFiles } from '@/components/Session/SidePanel/sections/useProjectOutputFiles'; import { HostProvider } from '@/host'; import { useAuthStore } from '@/store/authStore'; import { ChatTaskStatus } from '@/types/constants'; diff --git a/test/unit/components/buildProjectSessionPanelData.test.ts b/test/unit/components/buildProjectSessionPanelData.test.ts index d096b3a0..d3c1ce58 100644 --- a/test/unit/components/buildProjectSessionPanelData.test.ts +++ b/test/unit/components/buildProjectSessionPanelData.test.ts @@ -18,177 +18,207 @@ import { extractHttpUrls, } from '@/components/Session/SidePanel/sections/buildProjectSessionPanelData'; import type { ProjectSessionRun } from '@/hooks/useProjectSessionOverview'; -import { AgentStep } from '@/types/constants'; +import type { + ChatActivityNode, + ChatArtifactNode, + ChatPlanNode, + ChatProjectionNode, +} from '@/lib/projector/chat'; import { describe, expect, it } from 'vitest'; +function baseNode( + runId: string, + eventId: string, + runSequence: number +): Omit { + return { + id: eventId, + eventId, + projectId: 'project-1', + runId, + createdAt: new Date(runSequence * 1_000).toISOString(), + runSequence, + cloudCursor: null, + eventType: 'test.event', + legacyStep: null, + } as Omit; +} + +function toolNode( + runId: string, + eventId: string, + sequence: number, + status: ChatActivityNode['status'], + detail: string, + toolCallId?: string +): ChatActivityNode { + return { + ...baseNode(runId, eventId, sequence), + kind: 'activity', + activityType: 'tool', + status, + title: 'Notion search', + detail, + agentId: 'agent-1', + agentName: 'Research Agent', + toolkitName: 'MCPToolkit', + methodName: 'notion_search', + toolCallId, + }; +} + function makeRun( - taskId: string, + runId: string, isCurrent: boolean, - task: Record + nodes: ChatProjectionNode[] ): ProjectSessionRun { return { - chatId: `chat-${taskId}`, - chatStore: {} as ProjectSessionRun['chatStore'], - taskId, - createdAt: isCurrent ? 200 : 100, - updatedAt: isCurrent ? 200 : 100, + runId, + taskId: runId, + status: isCurrent ? 'running' : 'completed', + nodes, + createdAt: isCurrent ? 2_000 : 1_000, + updatedAt: isCurrent ? 20_000 : 10_000, isCurrent, - task: { - messages: [], - taskInfo: [], - taskRunning: [], - taskAssigning: [], - fileList: [], - webViewUrls: [], - attaches: [], - ...task, - } as ProjectSessionRun['task'], }; } describe('buildProjectSessionPanelData', () => { - it('pairs toolkit activation and deactivation as request and response', () => { - const run = makeRun('run-1', true, { - taskAssigning: [ - { - agent_id: 'agent-1', - name: 'Browser Agent', - type: 'browser_agent', - tasks: [], - log: [ - { - step: AgentStep.ACTIVATE_TOOLKIT, - timestamp: 1, - data: { - toolkit_name: 'Browser Toolkit', - method_name: 'search', - message: '{"query":"Eigent"}', - }, - }, - { - step: AgentStep.DEACTIVATE_TOOLKIT, - timestamp: 2, - data: { - toolkit_name: 'Browser Toolkit', - method_name: 'search', - message: 'https://eigent.ai/docs', - }, - }, - ], - }, - ], - }); + it('pairs semantic tool lifecycle events by durable call id', () => { + const run = makeRun('run-1', true, [ + toolNode('run-1', 'tool-start', 1, 'running', 'Searching', 'call-1'), + toolNode('run-1', 'tool-end', 2, 'completed', '3 results', 'call-1'), + ]); expect(collectSessionToolCalls([run])).toMatchObject([ { - toolkitName: 'Browser Toolkit', - method: 'search', - input: '{"query":"Eigent"}', - output: 'https://eigent.ai/docs', + id: 'call-1', + toolkitName: 'MCPToolkit', + method: 'notion_search', + input: 'Searching', + output: '3 results', status: 'done', taskId: 'run-1', }, ]); }); - it('keeps run ownership internally while folding historical content', () => { - const current = makeRun('run-current', true, { - taskInfo: [ - { id: 'p-current', content: 'Current step', status: 'running' }, - ], - webViewUrls: [ - { - url: 'https://current.example.com/reference', - processTaskId: 'p-current', - }, - ], - }); - const historical = makeRun('run-old', false, { - taskInfo: [{ id: 'p-old', content: 'Older step', status: 'completed' }], - webViewUrls: [ - { - url: 'https://old.example.com/research', - processTaskId: 'p-old', - }, - ], - }); + it('scopes durable call ids to their owning Run', () => { + const current = makeRun('run-current', true, [ + toolNode( + 'run-current', + 'current-start', + 1, + 'running', + 'current input', + 'call-1' + ), + toolNode( + 'run-current', + 'current-end', + 2, + 'completed', + 'current output', + 'call-1' + ), + ]); + const historical = makeRun('run-old', false, [ + toolNode('run-old', 'old-start', 1, 'running', 'old input', 'call-1'), + toolNode('run-old', 'old-end', 2, 'completed', 'old output', 'call-1'), + ]); + + expect(collectSessionToolCalls([current, historical])).toMatchObject([ + { taskId: 'run-old', input: 'old input', output: 'old output' }, + { + taskId: 'run-current', + input: 'current input', + output: 'current output', + }, + ]); + }); + + it('uses FIFO fallback for older tool frames without correlation ids', () => { + const run = makeRun('run-1', true, [ + toolNode('run-1', 'start-1', 1, 'running', 'first'), + toolNode('run-1', 'start-2', 2, 'running', 'second'), + toolNode('run-1', 'end-1', 3, 'completed', 'first result'), + toolNode('run-1', 'end-2', 4, 'completed', 'second result'), + ]); + + expect(collectSessionToolCalls([run])).toMatchObject([ + { input: 'first', output: 'first result', status: 'done' }, + { input: 'second', output: 'second result', status: 'done' }, + ]); + }); + + it('projects plans, artifacts and safe URL resources across Runs', () => { + const plan: ChatPlanNode = { + ...baseNode('run-current', 'plan', 3), + kind: 'plan', + tasks: [{ id: 'task-1', title: 'Build report', status: 'running' }], + }; + const artifact: ChatArtifactNode = { + ...baseNode('run-current', 'artifact', 4), + kind: 'artifact', + operation: 'created', + path: 'outputs/report.md', + name: 'report.md', + }; + const current = makeRun('run-current', true, [plan, artifact]); + const historical = makeRun('run-old', false, [ + { + ...baseNode('run-old', 'message', 1), + kind: 'message', + role: 'assistant', + content: 'Read https://old.example.com/research.', + status: 'complete', + }, + ]); const data = buildProjectSessionPanelData([current, historical], []); expect(data.progress).toMatchObject([ - { taskId: 'run-current', historical: false, updatedAt: 200 }, - { taskId: 'run-old', historical: true, updatedAt: 100 }, + { + taskId: 'run-current', + historical: false, + task: { id: 'task-1', content: 'Build report', status: 'running' }, + }, + ]); + expect(data.files).toMatchObject([ + { + id: 'outputs/report.md', + taskId: 'run-current', + historical: false, + file: { name: 'report.md', artifactChange: 'generated' }, + }, ]); expect(data.resources).toMatchObject([ - { taskId: 'run-current', historical: false, updatedAt: 200 }, - { taskId: 'run-old', historical: true, updatedAt: 100 }, + { taskId: 'run-old', historical: true }, ]); }); - it('extracts unique searched URLs without trailing punctuation', () => { - expect( - extractHttpUrls( - 'Read https://example.com/a, then https://example.com/a and https://docs.example.com/page).' - ) - ).toEqual(['https://example.com/a', 'https://docs.example.com/page']); - }); - - it('uses Open Connector identity and combines its calls across runs', () => { - const connectorAgent = (timestamp: number) => ({ - agent_id: `agent-${timestamp}`, - name: 'Agent', - type: 'single_agent', - workerInfo: { - name: 'Agent', - description: '', - tools: [], - mcp_tools: { mcpServers: { connector_gateway: {} } }, - selectedTools: [], - }, - tasks: [ - { - id: `task-${timestamp}`, - content: 'Search Notion', - status: 'completed', - toolkits: [ - { - toolkitName: 'MCPToolkit', - toolkitMethods: 'notion_search', - message: '{"query":"roadmap"}', - }, - ], - }, - ], - log: [ - { - step: AgentStep.ACTIVATE_TOOLKIT, - timestamp, - data: { - toolkit_name: 'MCPToolkit', - method_name: 'notion_search', - message: '{"query":"roadmap"}', - }, - }, - { - step: AgentStep.DEACTIVATE_TOOLKIT, - timestamp: timestamp + 1, - data: { - toolkit_name: 'MCPToolkit', - method_name: 'notion_search', - message: '{"results":[]}', - }, - }, - ], - }); - const current = makeRun('run-current', true, { - taskAssigning: [connectorAgent(3)], - }); - const historical = makeRun('run-old', false, { - taskAssigning: [connectorAgent(1)], - }); + it('uses connector identity without reading a raw event payload', () => { + const current = makeRun('run-current', true, [ + toolNode( + 'run-current', + 'tool-start', + 1, + 'running', + 'roadmap', + 'notion-call' + ), + toolNode( + 'run-current', + 'tool-end', + 2, + 'completed', + 'done', + 'notion-call' + ), + ]); const data = buildProjectSessionPanelData( - [current, historical], + [current], [], [ { @@ -200,16 +230,23 @@ describe('buildProjectSessionPanelData', () => { ] ); - expect(data.contextItems).toHaveLength(1); - expect(data.contextItems[0]).toMatchObject({ - id: 'notion', - label: 'Notion', - iconUrl: 'https://cdn.example.com/notion.svg', - historical: false, - }); - expect(data.contextItems[0]?.calls.map((call) => call.taskId)).toEqual([ - 'run-old', - 'run-current', + expect(data.contextItems).toMatchObject([ + { + id: 'connector:notion', + label: 'Notion', + iconUrl: 'https://cdn.example.com/notion.svg', + historical: false, + calls: [{ id: 'notion-call' }], + }, ]); + expect(JSON.stringify(data)).not.toContain('__legacy_data'); + }); + + it('extracts unique searched URLs without trailing punctuation', () => { + expect( + extractHttpUrls( + 'Read https://example.com/a, then https://example.com/a and https://docs.example.com/page).' + ) + ).toEqual(['https://example.com/a', 'https://docs.example.com/page']); }); }); diff --git a/test/unit/hooks/useProjectEventRuntime.test.tsx b/test/unit/hooks/useProjectEventRuntime.test.tsx new file mode 100644 index 00000000..87da2009 --- /dev/null +++ b/test/unit/hooks/useProjectEventRuntime.test.tsx @@ -0,0 +1,78 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { + ProjectEventRuntimeProvider, + useProjectEventRuntime, +} from '@/hooks/useProjectEventRuntime'; +import { resetProjectEventStoresForTests } from '@/store/projectEventStore'; +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + hydration: vi.fn(() => ({ + status: 'ready' as const, + errorCode: null, + eventsTruncated: false, + })), + streams: vi.fn(), +})); + +vi.mock('@/hooks/useProjectEventStoreHydration', () => ({ + useProjectEventStoreHydration: mocks.hydration, +})); + +vi.mock('@/hooks/useProjectRunEventStreams', () => ({ + useProjectRunEventStreams: mocks.streams, +})); + +function Consumer() { + const runtime = useProjectEventRuntime(); + return ( +
+ {runtime.projectId}:{runtime.snapshot?.revision ?? 'none'}: + {runtime.hydration.status} +
+ ); +} + +describe('ProjectEventRuntimeProvider', () => { + afterEach(() => { + resetProjectEventStoresForTests(); + vi.clearAllMocks(); + }); + + it('owns one hydration and live-stream subscription for its Session', () => { + render( + + + + ); + + expect(screen.getByText('project-1:0:ready')).toBeInTheDocument(); + expect(mocks.hydration).toHaveBeenCalledTimes(1); + expect(mocks.hydration).toHaveBeenCalledWith({ + projectId: 'project-1', + enabled: true, + }); + expect(mocks.streams).toHaveBeenCalledTimes(1); + expect(mocks.streams).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'project-1', + enabled: true, + snapshot: expect.objectContaining({ revision: 0 }), + }) + ); + }); +}); diff --git a/test/unit/hooks/useProjectSessionOverview.test.tsx b/test/unit/hooks/useProjectSessionOverview.test.tsx index 6dbe814e..1b599e8a 100644 --- a/test/unit/hooks/useProjectSessionOverview.test.tsx +++ b/test/unit/hooks/useProjectSessionOverview.test.tsx @@ -12,104 +12,114 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import { useProjectSessionOverview } from '@/hooks/useProjectSessionOverview'; -import { usePageTabStore } from '@/store/pageTabStore'; -import { ProjectType, useProjectStore } from '@/store/projectStore'; -import { act, renderHook } from '@testing-library/react'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { buildProjectSessionOverview } from '@/hooks/useProjectSessionOverview'; +import { + createChatProjectionState, + type ChatMessageNode, +} from '@/lib/projector/chat'; +import { + createHumanControlProjectionState, + type HumanControlProjectionState, +} from '@/lib/projector/control'; +import type { ProjectEventStoreSnapshot } from '@/store/projectEventStore'; +import { describe, expect, it } from 'vitest'; -describe('useProjectSessionOverview', () => { - beforeEach(() => { - useProjectStore.setState({ - activeProjectId: null, - projects: {}, - navLeadByProjectId: {}, - historyLoadingProjectIds: {}, - }); - usePageTabStore.setState({ - sidePanelSelectedTurnByProject: {}, - sidePanelManualUntilByProject: {}, - sidePanelViewedTurnByProject: {}, - }); - }); +function message(runId: string, sequence: number): ChatMessageNode { + return { + id: `${runId}:${sequence}`, + eventId: `${runId}:${sequence}`, + projectId: 'project-1', + runId, + createdAt: new Date(sequence * 1_000).toISOString(), + runSequence: sequence, + cloudCursor: null, + eventType: 'message.completed', + legacyStep: null, + kind: 'message', + role: 'assistant', + content: runId, + status: 'complete', + }; +} - it('keeps the active run current while exposing every historical run', () => { - const projectStore = useProjectStore.getState(); - const projectId = projectStore.createProject( - 'History', - undefined, - 'project-history', - ProjectType.REPLAY - ); - const oldChatId = projectStore.createChatStore(projectId, 'Old'); - const latestChatId = projectStore.createChatStore(projectId, 'Latest'); - const oldStore = projectStore.getChatStore(projectId, oldChatId!); - const latestStore = projectStore.getChatStore(projectId, latestChatId!); - const oldTaskId = oldStore!.getState().create('task-old'); - const latestTaskId = latestStore!.getState().create('task-latest'); - oldStore!.getState().addMessages(oldTaskId, { - id: 'old-user', - role: 'user', - content: 'Old prompt', - }); - latestStore!.getState().addMessages(latestTaskId, { - id: 'latest-user', - role: 'user', - content: 'Latest prompt', - }); - projectStore.setActiveChatStore(projectId, latestChatId!); - - act(() => { - usePageTabStore.getState().setSidePanelSelectedTurn(projectId, oldTaskId); - }); - - const { result } = renderHook(() => useProjectSessionOverview(projectId)); - - expect(result.current.currentRun?.taskId).toBe(latestTaskId); - expect(result.current.runs.map((run) => run.taskId)).toEqual( - expect.arrayContaining([oldTaskId, latestTaskId]) - ); - expect( - result.current.historicalRuns.some((run) => run.taskId === oldTaskId) - ).toBe(true); - }); - - it('keeps a newly created empty active run current', () => { - const projectStore = useProjectStore.getState(); - const projectId = projectStore.createProject( - 'New run', - undefined, - 'project-new-run', - ProjectType.REPLAY - ); - const chatStore = projectStore.getActiveChatStore(projectId)!; - const historicalTaskId = chatStore.getState().create('task-historical'); - chatStore.getState().addMessages(historicalTaskId, { - id: 'historical-user', - role: 'user', - content: 'Previous prompt', - }); - const emptyActiveTaskId = chatStore.getState().create('task-empty-active'); - chatStore.setState((state) => ({ - tasks: { - ...state.tasks, - [historicalTaskId]: { - ...state.tasks[historicalTaskId], - createdAt: 100, +function snapshot(): ProjectEventStoreSnapshot { + const nodes = [message('run-complete', 4), message('run-live', 1)]; + return { + view: { + projectId: 'project-1', + mode: 'live', + seenEventIds: {}, + currentCursor: 0, + eventsTruncated: false, + lastSyncedAt: null, + needsResync: false, + resyncReason: null, + resyncTargetCursor: null, + runs: { + 'run-complete': { + runId: 'run-complete', + status: 'completed', + lastSequence: 4, + runVersion: 4, + updatedAt: new Date(4_000).toISOString(), + origin: 'local', + resumeBlockedReason: null, }, - [emptyActiveTaskId]: { - ...state.tasks[emptyActiveTaskId], - createdAt: 200, + 'run-live': { + runId: 'run-live', + status: 'running', + lastSequence: 1, + runVersion: 1, + updatedAt: new Date(1_000).toISOString(), + origin: 'local', + resumeBlockedReason: null, }, }, - })); + legacySteps: [], + unknownEvents: [], + }, + chat: { + ...createChatProjectionState('project-1'), + nodes, + nodeById: Object.fromEntries(nodes.map((node) => [node.id, node])), + seenEventIds: Object.fromEntries( + nodes.map((node) => [node.eventId, true as const]) + ), + }, + control: createHumanControlProjectionState( + 'project-1' + ) as HumanControlProjectionState, + revision: 1, + hasHydratedSnapshot: true, + overflowed: false, + lastEffects: [], + }; +} - const { result } = renderHook(() => useProjectSessionOverview(projectId)); +describe('buildProjectSessionOverview', () => { + it('keeps an active durable Run current even when history is newer', () => { + const overview = buildProjectSessionOverview(snapshot()); - expect(result.current.currentRun?.taskId).toBe(emptyActiveTaskId); - expect(result.current.runs[0]?.taskId).toBe(emptyActiveTaskId); - expect(result.current.historicalRuns.map((run) => run.taskId)).toContain( - historicalTaskId + expect(overview.currentRun?.runId).toBe('run-live'); + expect(overview.historicalRuns.map((run) => run.runId)).toContain( + 'run-complete' ); }); + + it('groups semantic nodes by Run without consulting ChatStore', () => { + const overview = buildProjectSessionOverview(snapshot()); + + expect(overview.runs).toHaveLength(2); + expect( + overview.runs.find((run) => run.runId === 'run-complete')?.nodes + ).toMatchObject([{ content: 'run-complete' }]); + }); + + it('returns an empty view before durable hydration has a snapshot', () => { + expect(buildProjectSessionOverview(null)).toEqual({ + currentRun: null, + historicalRuns: [], + runs: [], + }); + }); }); diff --git a/test/unit/lib/projector.test.ts b/test/unit/lib/projector.test.ts index 1acad99f..e8b06533 100644 --- a/test/unit/lib/projector.test.ts +++ b/test/unit/lib/projector.test.ts @@ -1,3 +1,17 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + import { completeProjectViewResync, createProjectViewState, @@ -620,6 +634,8 @@ describe('projector pipeline', () => { lastSequence: 16, runVersion: 0, updatedAt: '2026-08-05T09:00:00Z', + origin: null, + resumeBlockedReason: null, }); expect(snapshot.lastSyncedAt).toBeNull(); }); diff --git a/test/unit/store/chatStore.test.ts b/test/unit/store/chatStore.test.ts index 3f184241..bb24cf1e 100644 --- a/test/unit/store/chatStore.test.ts +++ b/test/unit/store/chatStore.test.ts @@ -118,6 +118,10 @@ vi.mock('../../../src/store/projectStore', () => ({ getState: vi.fn(() => ({ activeProjectId: null, getHistoryId: () => null, + getProjectById: (projectId: string) => ({ + id: projectId, + mode: 'single-agent', + }), })), }, })); @@ -1253,6 +1257,7 @@ describe('ChatStore - Core Functionality', () => { describe('SSE onerror - no retry when task already finished (issue #1212)', () => { it('should stop retry when task is already FINISHED (avoids duplicate execution)', async () => { const mockFetchEventSource = vi.mocked(fetchEventSource); + vi.mocked(proxyFetchGet).mockResolvedValue([]); mockFetchEventSource.mockImplementation((_url, opts) => { // Simulate connection error; when onerror runs, store checks task status // and throws to stop retry (issue #1212 fix) @@ -1282,7 +1287,18 @@ describe('ChatStore - Core Functionality', () => { }); await act(async () => { - await result.current.getState().startTask(taskId!); + await result.current + .getState() + .startTask( + taskId!, + 'share', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); }); expect(mockFetchEventSource).toHaveBeenCalledTimes(1);