mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-16 12:13:31 +00:00
Connect session side panel to durable event bus
This commit is contained in:
parent
7ef924475d
commit
b34eb14e59
18 changed files with 1226 additions and 978 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
107
src/hooks/useProjectEventRuntime.tsx
Normal file
107
src/hooks/useProjectEventRuntime.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')>();
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
78
test/unit/hooks/useProjectEventRuntime.test.tsx
Normal file
78
test/unit/hooks/useProjectEventRuntime.test.tsx
Normal 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 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue