Connect session side panel to durable event bus

This commit is contained in:
Douglas 2026-08-13 11:25:53 +01:00
parent 7ef924475d
commit b34eb14e59
18 changed files with 1226 additions and 978 deletions

View file

@ -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<ChatTimelineScrollAnimation | null>(null);
const contentRef = useRef<HTMLDivElement>(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<HTMLElement>('[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 (
<div
className="relative z-10 w-full"

View file

@ -23,7 +23,7 @@ import { isWeb } from '@/client/platform';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useInterruptedRunStatus } from '@/hooks/useInterruptedRunStatus';
import { useModelConfigCheck } from '@/hooks/useModelConfigCheck';
import { useProjectRunEventStreams } from '@/hooks/useProjectRunEventStreams';
import { useProjectEventRuntime } from '@/hooks/useProjectEventRuntime';
import { useHost } from '@/host';
import { generateUniqueId, SITE_URL } from '@/lib';
import {
@ -44,10 +44,7 @@ import { useAuthStore } from '@/store/authStore';
import { isChatEventTimelineEnabled } from '@/store/chatEventProjectionBridge';
import { buildProjectContinuationContext } from '@/store/chatStore';
import { usePageTabStore } from '@/store/pageTabStore';
import {
getProjectEventStore,
type ProjectEventStoreSnapshot,
} from '@/store/projectEventStore';
import type { ProjectEventStoreSnapshot } from '@/store/projectEventStore';
import { useSpaceStore } from '@/store/spaceStore';
import { ExecutionStatus } from '@/types';
import { AgentStep, ChatTaskStatus, SessionMode } from '@/types/constants';
@ -58,7 +55,6 @@ import {
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
@ -97,8 +93,6 @@ const READ_ONLY_EVENT_NATIVE_RUN_STATUSES = new Set([
'interrupted',
]);
const subscribeToNothing = () => () => 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;

View file

@ -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';

View file

@ -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<ConnectorProvider[]>([]);
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<typeof overview.currentRun>,
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()}
>

View file

@ -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<string, unknown> | 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 (
<div className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<HeaderBox empty />
<ProjectEventRuntimeProvider projectId={activeProjectId}>
<div className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<Workspace
variant="new-project"
embedded
sessionMode={displaySessionMode ?? SessionMode.SINGLE_AGENT}
onSessionModeChange={handleNewProjectSessionModeChange}
/>
<HeaderBox empty />
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<Workspace
variant="new-project"
embedded
sessionMode={displaySessionMode ?? SessionMode.SINGLE_AGENT}
onSessionModeChange={handleNewProjectSessionModeChange}
/>
</div>
</div>
<div
id="session-side-panel"
className={cn(
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-out',
isSidePanelVisible
? SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS
: cn(SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS, 'rounded-l-xl')
)}
>
{sessionSidePanel}
</div>
</div>
</ProjectEventRuntimeProvider>
);
}
return (
<ProjectEventRuntimeProvider projectId={activeProjectId}>
<div
ref={chatRowRef}
className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden"
>
{/* Chat content: owns the project header and folds when display opens. */}
<div
style={previewOpen ? { width: chatWidth } : undefined}
className={cn(
'flex min-h-0 min-w-0 flex-col overflow-hidden',
previewOpen ? 'shrink-0' : 'flex-1',
!isResizingPreview && 'transition-[width] duration-200 ease-out'
)}
>
<HeaderBox
projectName={activeProjectMeta?.name}
totalTokens={
chatStore.activeTaskId
? chatStore.tasks[chatStore.activeTaskId]?.tokens || 0
: 0
}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatBox />
</div>
</div>
<AnimatePresence initial={false}>
{previewOpen && (
<motion.div
key="session-display-content"
initial={{
clipPath: 'inset(0 0 0 100%)',
opacity: 0,
}}
animate={{
clipPath: 'inset(0 0 0 0%)',
opacity: 1,
flexGrow: 1,
}}
exit={{
clipPath: 'inset(0 0 0 100%)',
opacity: 0,
flexGrow: 0,
}}
transition={{ duration: 0.3, ease: DISPLAY_PANEL_EASE }}
style={{ transformOrigin: 'right center' }}
className="flex min-h-0 min-w-0 flex-1 overflow-hidden"
>
<div
onPointerDown={handlePreviewResizeStart}
role="separator"
aria-orientation="vertical"
data-resize-handle-state={
isResizingPreview ? 'drag' : 'inactive'
}
className={cn(
// Transparent 2px rail with a centered line and wider hit area.
'relative z-10 flex w-[2px] shrink-0 cursor-col-resize items-center justify-center bg-transparent transition-colors hover:bg-ds-bg-brand-subtle-default',
"before:absolute before:inset-y-0 before:-left-1 before:-right-1 before:content-['']",
'after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 after:bg-ds-bg-neutral-default-default after:transition-colors',
isResizingPreview &&
'bg-ds-bg-brand-subtle-default after:bg-ds-bg-brand-default-focus'
)}
/>
{/* Display content: middle column between chat and session. */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{activeProjectId ? (
<PreviewPanel
displaySettled={displaySettled}
onJumpToContext={handleJumpToContext}
/>
) : null}
</div>
</motion.div>
)}
</AnimatePresence>
<div
id="session-side-panel"
@ -391,97 +474,6 @@ export default function Session({ isNewProject = false }: SessionProps) {
{sessionSidePanel}
</div>
</div>
);
}
return (
<div
ref={chatRowRef}
className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden"
>
{/* Chat content: owns the project header and folds when display opens. */}
<div
style={previewOpen ? { width: chatWidth } : undefined}
className={cn(
'flex min-h-0 min-w-0 flex-col overflow-hidden',
previewOpen ? 'shrink-0' : 'flex-1',
!isResizingPreview && 'transition-[width] duration-200 ease-out'
)}
>
<HeaderBox
projectName={activeProjectMeta?.name}
totalTokens={
chatStore.activeTaskId
? chatStore.tasks[chatStore.activeTaskId]?.tokens || 0
: 0
}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatBox />
</div>
</div>
<AnimatePresence initial={false}>
{previewOpen && (
<motion.div
key="session-display-content"
initial={{
clipPath: 'inset(0 0 0 100%)',
opacity: 0,
}}
animate={{
clipPath: 'inset(0 0 0 0%)',
opacity: 1,
flexGrow: 1,
}}
exit={{
clipPath: 'inset(0 0 0 100%)',
opacity: 0,
flexGrow: 0,
}}
transition={{ duration: 0.3, ease: DISPLAY_PANEL_EASE }}
style={{ transformOrigin: 'right center' }}
className="flex min-h-0 min-w-0 flex-1 overflow-hidden"
>
<div
onPointerDown={handlePreviewResizeStart}
role="separator"
aria-orientation="vertical"
data-resize-handle-state={isResizingPreview ? 'drag' : 'inactive'}
className={cn(
// Transparent 2px rail with a centered line and wider hit area.
'relative z-10 flex w-[2px] shrink-0 cursor-col-resize items-center justify-center bg-transparent transition-colors hover:bg-ds-bg-brand-subtle-default',
"before:absolute before:inset-y-0 before:-left-1 before:-right-1 before:content-['']",
'after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 after:bg-ds-bg-neutral-default-default after:transition-colors',
isResizingPreview &&
'bg-ds-bg-brand-subtle-default after:bg-ds-bg-brand-default-focus'
)}
/>
{/* Display content: middle column between chat and session. */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{activeProjectId ? (
<PreviewPanel
displaySettled={displaySettled}
onJumpToContext={handleJumpToContext}
/>
) : null}
</div>
</motion.div>
)}
</AnimatePresence>
<div
id="session-side-panel"
className={cn(
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-out',
isSidePanelVisible
? SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS
: cn(SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS, 'rounded-l-xl')
)}
>
{sessionSidePanel}
</div>
</div>
</ProjectEventRuntimeProvider>
);
}

View file

@ -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<ProjectEventRuntimeValue>({
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<ProjectEventRuntimeValue>(
() => ({ hydration, projectId: normalizedProjectId, snapshot }),
[hydration, normalizedProjectId, snapshot]
);
return (
<ProjectEventRuntimeContext.Provider value={value}>
{children}
</ProjectEventRuntimeContext.Provider>
);
}
export function useProjectEventRuntime(): ProjectEventRuntimeValue {
return useContext(ProjectEventRuntimeContext);
}

View file

@ -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<ProjectedRun['status']>([
'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<string>();
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<string, ChatProjectionNode[]>();
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<ProjectSessionRun>((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]);
}

View file

@ -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

View file

@ -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,

View file

@ -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<typeof import('framer-motion')>();

View file

@ -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 => ({

View file

@ -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';

View file

@ -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<ChatProjectionNode, 'kind'> {
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<ChatProjectionNode, 'kind'>;
}
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<string, unknown>
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']);
});
});

View file

@ -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 (
<div>
{runtime.projectId}:{runtime.snapshot?.revision ?? 'none'}:
{runtime.hydration.status}
</div>
);
}
describe('ProjectEventRuntimeProvider', () => {
afterEach(() => {
resetProjectEventStoresForTests();
vi.clearAllMocks();
});
it('owns one hydration and live-stream subscription for its Session', () => {
render(
<ProjectEventRuntimeProvider projectId="project-1">
<Consumer />
</ProjectEventRuntimeProvider>
);
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 }),
})
);
});
});

View file

@ -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: [],
});
});
});

View file

@ -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();
});

View file

@ -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);