diff --git a/src/components/ChatBox/BottomBox/BoxHeader.tsx b/src/components/ChatBox/BottomBox/BoxHeader.tsx index 0a0cbda5..f610fea9 100644 --- a/src/components/ChatBox/BottomBox/BoxHeader.tsx +++ b/src/components/ChatBox/BottomBox/BoxHeader.tsx @@ -42,7 +42,8 @@ export function BoxHeaderDisplay({ contextItems = [], details = [], onRemoveContextItem, -}: BottomBoxHeaderContent) { + className, +}: BottomBoxHeaderContent & { className?: string }) { const hasCopy = Boolean(eyebrow || title || description); if (!hasCopy && contextItems.length === 0 && details.length === 0) return null; @@ -51,7 +52,7 @@ export function BoxHeaderDisplay({
{hasCopy && (
diff --git a/src/components/ChatBox/BottomBox/ControlInput.tsx b/src/components/ChatBox/BottomBox/ControlInput.tsx index 9c586748..4f105882 100644 --- a/src/components/ChatBox/BottomBox/ControlInput.tsx +++ b/src/components/ChatBox/BottomBox/ControlInput.tsx @@ -436,7 +436,7 @@ export function ControlInputRouter({ content = ( void; /** Array of file attachments */ files?: FileAttachment[]; - /** Render attachment chips inside the input surface. BottomBox moves them to BoxHeader. */ + /** Render attachment chips inside the input surface (layer 2). */ showFileAttachments?: boolean; + /** Input-required question, details, and non-file context (layer 1). */ + header?: BottomBoxHeaderContent; /** Callback when files are modified */ onFilesChange?: (files: FileAttachment[]) => void; /** Callback when add file button is clicked */ @@ -96,14 +100,11 @@ export interface InputboxProps { /** * Inputbox Component * - * A multi-state input component with two visual states: - * - **Default**: Empty state with placeholder text and disabled send button - * - **Focus/Input**: Active state with content, file attachments, and active send button - * - * Features: - * - Auto-expanding rich text input (links + #skills, up to 200px height) - * - File attachment display (shows up to 5 files + count indicator) - * - Action buttons (add file on left, send on right) + * A multi-state input component with four stacked layers: + * - **Layer 1**: Input-required question / details (when provided) + * - **Layer 2**: File attachment chips (original design, up to 5 + overflow) + * - **Layer 3**: Auto-expanding rich text input + * - **Layer 4**: Action buttons (attach, connectors, skills, send) * - Send button changes color based on content (gray when empty, green when has content) * - Arrow icon rotates when there's content * - Supports Enter to send, Shift+Enter for new line @@ -138,6 +139,7 @@ export const Inputbox = ({ onSend, files = [], showFileAttachments = true, + header, onFilesChange, onAddFile, placeholder, @@ -345,6 +347,8 @@ export const Inputbox = ({
)} + {/* Layer 1: Input-required question / details */} + {header && } {/* Layer 2: File attachments (only show if has files) */} {showFileAttachments && files.length > 0 && (
diff --git a/src/components/ChatBox/BottomBox/PickerPanel.tsx b/src/components/ChatBox/BottomBox/PickerPanel.tsx index 4ce5cc3d..67da4829 100644 --- a/src/components/ChatBox/BottomBox/PickerPanel.tsx +++ b/src/components/ChatBox/BottomBox/PickerPanel.tsx @@ -350,7 +350,7 @@ export function ConnectorPickerPanel({ renderTag={(item) => ( @@ -418,7 +418,7 @@ export function SkillPickerPanel({ return ( diff --git a/src/components/ChatBox/BottomBox/index.tsx b/src/components/ChatBox/BottomBox/index.tsx index e34cb77e..d8f2a2ed 100644 --- a/src/components/ChatBox/BottomBox/index.tsx +++ b/src/components/ChatBox/BottomBox/index.tsx @@ -23,11 +23,7 @@ import { ControlInputRouter } from './ControlInput'; import type { FileAttachment, InputboxProps } from './InputBox'; import { ConnectorPickerPanel, SkillPickerPanel } from './PickerPanel'; import { QueuedBox, type QueuedMessage } from './QueuedBox'; -import type { - BottomBoxContextItem, - BottomBoxVariant, - LegacyBottomBoxVariant, -} from './types'; +import type { BottomBoxVariant, LegacyBottomBoxVariant } from './types'; import { UsageLimitBanner, type UsageLimitBannerProps, @@ -191,41 +187,10 @@ export default function BottomBox({ const hasOverlay = !!usageLimitBanner || !!activePickerPanel; const variantHeader = normalizedVariant.header; - const attachedFiles = - normalizedVariant.kind === 'input' ? (inputProps.files ?? []) : []; - const attachmentContext: BottomBoxContextItem[] = attachedFiles.map( - (file) => ({ - id: `attachment:${file.filePath}`, - label: file.fileName, - description: file.filePath, - kind: 'file', - removable: typeof inputProps.onFilesChange === 'function', - }) - ); - const headerContextItems = [ - ...(variantHeader?.contextItems ?? []), - ...attachmentContext, - ]; - const resolvedHeader = - variantHeader || headerContextItems.length > 0 - ? { - ...variantHeader, - contextItems: headerContextItems, - onRemoveContextItem: - inputProps.onFilesChange || variantHeader?.onRemoveContextItem - ? (id: string) => { - if (id.startsWith('attachment:')) { - const filePath = id.slice('attachment:'.length); - inputProps.onFilesChange?.( - attachedFiles.filter((file) => file.filePath !== filePath) - ); - return; - } - variantHeader?.onRemoveContextItem?.(id); - } - : undefined, - } - : undefined; + // Composer question/details live inside InputBox. Other variants keep the + // display header above their control surface. + const externalHeader = + normalizedVariant.kind === 'input' ? undefined : variantHeader; const variantDisabled = normalizedVariant.kind === 'input' @@ -296,7 +261,7 @@ export default function BottomBox({ loading={loading} /> )} - {resolvedHeader && } + {externalHeader && } {/* InputBox — controlled router selected by event-derived variant. */} (undefined); + const previousProjectIdRef = useRef(projectId); + const pinToBottomRef = useRef(true); + const ignoreAnchorScrollRef = useRef(false); + const anchorAnimationRef = useRef(null); + const contentRef = useRef(null); const latestNode = visibleNodes.at(-1); + const latestEventId = latestNode?.eventId; + const userMessageNodes = visibleNodes.filter( + (node) => node.kind === 'message' && node.role === 'user' + ); + const latestUserEventId = userMessageNodes.at(-1)?.eventId; useLayoutEffect(() => { const container = scrollContainerRef?.current; if (!container) return; + const content = contentRef.current; + if (previousProjectIdRef.current !== projectId) { + previousProjectIdRef.current = projectId; + previousLatestUserEventIdRef.current = undefined; + pinToBottomRef.current = true; + ignoreAnchorScrollRef.current = false; + anchorAnimationRef.current?.stop(); + anchorAnimationRef.current = null; + if (content) content.style.minHeight = ''; + } + + const updatePinFromScroll = () => { + if (ignoreAnchorScrollRef.current) return; + pinToBottomRef.current = isChatTimelineNearBottom( + container.scrollHeight - container.scrollTop - container.clientHeight, + scrollBottomInsetPx + ); + }; + container.addEventListener('scroll', updatePinFromScroll, { + passive: true, + }); + const previousHeight = previousScrollHeightRef.current; const wasNearBottom = previousHeight === 0 || - previousHeight - container.scrollTop - container.clientHeight <= 120; - if (wasNearBottom) { + (pinToBottomRef.current && + isChatTimelineNearBottom( + previousHeight - container.scrollTop - container.clientHeight, + scrollBottomInsetPx + )); + const hadRenderedUserMessage = + previousLatestUserEventIdRef.current !== undefined; + const isNewUserMessage = + latestUserEventId !== undefined && + latestUserEventId !== previousLatestUserEventIdRef.current; + const shouldAnchorNewQuery = + isNewUserMessage && + hadRenderedUserMessage && + userMessageNodes.length >= 2; + + // A follow-up query starts a new reading viewport: its user row aligns just + // below the Session header and streaming output grows beneath it. The first + // query keeps the original bottom reveal behavior. + if (shouldAnchorNewQuery) { + const target = Array.from( + contentRef.current?.querySelectorAll( + '[data-message-role="user"]' + ) || [] + ).at(-1); + if (target) { + anchorAnimationRef.current?.stop(); + ignoreAnchorScrollRef.current = true; + pinToBottomRef.current = false; + anchorAnimationRef.current = animateChatTimelineAnchor( + container, + target, + content, + () => { + ignoreAnchorScrollRef.current = false; + pinToBottomRef.current = false; + } + ); + } + } else if (isNewUserMessage || wasNearBottom) { + pinToBottomRef.current = true; container.scrollTo({ top: container.scrollHeight, behavior: 'auto' }); } + previousLatestUserEventIdRef.current = latestUserEventId; previousScrollHeightRef.current = container.scrollHeight; + + const resizeObserver = + content && + new ResizeObserver(() => { + if (!pinToBottomRef.current) return; + container.scrollTo({ top: container.scrollHeight, behavior: 'auto' }); + previousScrollHeightRef.current = container.scrollHeight; + }); + if (content) resizeObserver?.observe(content); + + return () => { + container.removeEventListener('scroll', updatePinFromScroll); + resizeObserver?.disconnect(); + }; }, [ - latestNode?.eventId, + latestEventId, + latestUserEventId, latestNode?.runSequence, + projectId, scrollBottomInsetPx, scrollContainerRef, + userMessageNodes.length, ]); + useEffect( + () => () => { + anchorAnimationRef.current?.stop(); + }, + [] + ); + return (
diff --git a/src/components/ChatBox/EventTimeline/EventTimeline.tsx b/src/components/ChatBox/EventTimeline/EventTimeline.tsx index 59e037c2..f069da4b 100644 --- a/src/components/ChatBox/EventTimeline/EventTimeline.tsx +++ b/src/components/ChatBox/EventTimeline/EventTimeline.tsx @@ -16,6 +16,7 @@ import type { ChatProjectionNode } from '@/lib/projector/chat'; import { cn } from '@/lib/utils'; import type { ReactNode } from 'react'; +import { groupRepeatedToolCalls } from './activityGrouping'; import { EventRenderer } from './EventRenderer'; import type { EventRendererErrorHandler } from './EventRendererBoundary'; import { @@ -28,6 +29,7 @@ import type { EventRendererRegistry, EventTypeRendererRegistry, } from './rendererRegistry'; +import { RepeatedToolCallGroup } from './RepeatedToolCallGroup'; interface EventTimelineProps { ariaLabel?: string; @@ -63,8 +65,9 @@ export function EventTimeline({ detailLevel, nodes ); + const displayRows = groupRepeatedToolCalls(presentation.nodes); - if (presentation.nodes.length === 0) return <>{emptyState}; + if (displayRows.length === 0) return <>{emptyState}; return (
    - {presentation.nodes.map((node) => ( -
  1. - -
  2. - ))} + {displayRows.map((row) => { + if (row.rowKind === 'repeated-tool-calls') { + return ( +
  3. + +
  4. + ); + } + + const node = row.node; + return ( +
  5. + +
  6. + ); + })}
); } diff --git a/src/components/ChatBox/EventTimeline/RepeatedToolCallGroup.tsx b/src/components/ChatBox/EventTimeline/RepeatedToolCallGroup.tsx new file mode 100644 index 00000000..ce87aa43 --- /dev/null +++ b/src/components/ChatBox/EventTimeline/RepeatedToolCallGroup.tsx @@ -0,0 +1,153 @@ +// ========= 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 { ChevronDown, ChevronRight } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { RepeatedToolCallGroupRow } from './activityGrouping'; + +interface RepeatedToolCallGroupProps { + group: RepeatedToolCallGroupRow; +} + +function displayStatus(status: string): string { + return status.replaceAll('_', ' '); +} + +function groupStatusLabel( + group: RepeatedToolCallGroupRow, + t: ReturnType['t'] +): string { + const statuses = group.calls.map((call) => call.presentedNode.status); + const completed = statuses.filter((status) => status === 'completed').length; + const failed = statuses.filter((status) => status === 'failed').length; + const cancelled = statuses.filter((status) => status === 'cancelled').length; + const active = statuses.filter( + (status) => status === 'pending' || status === 'running' + ).length; + + if (failed > 0) return t('chat.repeated-tool-failed', { count: failed }); + if (active > 0) { + return t('chat.repeated-tool-completed-progress', { + completed, + count: group.calls.length, + }); + } + if (cancelled > 0) { + return t('chat.repeated-tool-cancelled', { count: cancelled }); + } + return t(`chat.tool-status-${group.status}`, { + defaultValue: displayStatus(group.status), + }); +} + +function statusClassName(status: string): string { + if (status === 'failed') return 'text-ds-text-error-default-default'; + if (status === 'running' || status === 'pending') { + return 'text-ds-text-information-default-default'; + } + return 'text-ds-text-neutral-muted-default'; +} + +/** Optional second-level accordion for one consecutive burst of repeat calls. */ +export function RepeatedToolCallGroup({ group }: RepeatedToolCallGroupProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const toolTitle = `${group.toolkitName} · ${group.methodName}`; + const title = t('chat.repeated-tool-events', { + tool: toolTitle, + count: group.calls.length, + }); + + return ( +
+ + + {open ? ( +
+
    + {group.calls.map((call, index) => { + const node = call.presentedNode; + return ( +
  1. +
    + + {toolTitle} + + + {t(`chat.tool-status-${node.status}`, { + defaultValue: displayStatus(node.status), + })} + +
    + {node.detail ? ( +

    + {node.detail} +

    + ) : null} +
  2. + ); + })} +
+
+ ) : null} +
+ ); +} + +export type { RepeatedToolCallGroupProps }; diff --git a/src/components/ChatBox/EventTimeline/activityGrouping.ts b/src/components/ChatBox/EventTimeline/activityGrouping.ts new file mode 100644 index 00000000..4339e037 --- /dev/null +++ b/src/components/ChatBox/EventTimeline/activityGrouping.ts @@ -0,0 +1,243 @@ +// ========= 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 type { + ChatActivityNode, + ChatActivityStatus, + ChatProjectionNode, +} from '@/lib/projector/chat'; + +type TimelineNodeRow = { + rowKind: 'node'; + id: string; + node: ChatProjectionNode; +}; + +export type PresentedToolCall = { + id: string; + nodes: readonly ChatActivityNode[]; + presentedNode: ChatActivityNode; +}; + +export type RepeatedToolCallGroupRow = { + rowKind: 'repeated-tool-calls'; + id: string; + agentId?: string; + agentName?: string; + methodName: string; + runId: string; + toolkitName: string; + calls: readonly PresentedToolCall[]; + status: ChatActivityStatus; +}; + +export type ChatTimelineDisplayRow = TimelineNodeRow | RepeatedToolCallGroupRow; + +type MutableToolCall = { + id: string; + nodes: ChatActivityNode[]; +}; + +type ToolIdentity = { + key: string; + methodName: string; + toolkitName: string; +}; + +const ACTIVE_TOOL_STATUSES = new Set([ + 'pending', + 'running', +]); + +const TERMINAL_TOOL_STATUSES = new Set([ + 'completed', + 'failed', + 'cancelled', +]); + +function normalizeIdentity(value: string | undefined): string { + return (value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ''); +} + +function toolIdentity(node: ChatProjectionNode): ToolIdentity | null { + if (node.kind !== 'activity' || node.activityType !== 'tool') return null; + + const toolkitName = node.toolkitName?.trim() || 'Tool'; + const methodName = + node.methodName?.trim() || node.toolName?.trim() || node.title.trim(); + if (!methodName) return null; + + const agentIdentity = + normalizeIdentity(node.agentId) || normalizeIdentity(node.agentName); + return { + key: JSON.stringify([ + node.runId, + agentIdentity, + normalizeIdentity(toolkitName), + normalizeIdentity(methodName), + ]), + toolkitName, + methodName, + }; +} + +function uniqueDetails(nodes: readonly ChatActivityNode[]): string | undefined { + const details = nodes + .map((node) => node.detail?.trim()) + .filter((detail): detail is string => Boolean(detail)); + const unique = [...new Set(details)]; + return unique.length ? unique.join('\n\n') : undefined; +} + +function callStatus(nodes: readonly ChatActivityNode[]): ChatActivityStatus { + return nodes.at(-1)?.status || 'unknown'; +} + +function presentToolCall(call: MutableToolCall): PresentedToolCall { + const first = call.nodes[0]!; + const last = call.nodes.at(-1)!; + return { + id: call.id, + nodes: call.nodes, + presentedNode: { + ...first, + eventType: last.eventType, + status: callStatus(call.nodes), + title: last.title || first.title, + detail: uniqueDetails(call.nodes), + toolCallId: last.toolCallId || first.toolCallId, + }, + }; +} + +/** + * Fold one uninterrupted toolkit/method segment into logical invocations. + * Explicit backend call IDs are authoritative. Older transports fall back to + * FIFO lifecycle pairing so repeated start/terminal frames count as calls, + * not as twice as many timeline rows. + */ +function buildToolCalls( + nodes: readonly ChatActivityNode[] +): PresentedToolCall[] { + const calls: MutableToolCall[] = []; + const byCallId = new Map(); + const anonymousOpen: MutableToolCall[] = []; + + const createCall = (node: ChatActivityNode): MutableToolCall => { + const call = { id: `tool-call:${node.id}`, nodes: [node] }; + calls.push(call); + return call; + }; + + for (const node of nodes) { + if (node.toolCallId) { + const existing = byCallId.get(node.toolCallId); + if (existing) { + existing.nodes.push(node); + } else { + byCallId.set(node.toolCallId, createCall(node)); + } + continue; + } + + if (ACTIVE_TOOL_STATUSES.has(node.status)) { + anonymousOpen.push(createCall(node)); + continue; + } + + if (TERMINAL_TOOL_STATUSES.has(node.status) && anonymousOpen.length > 0) { + anonymousOpen.shift()!.nodes.push(node); + continue; + } + + createCall(node); + } + + return calls.map(presentToolCall); +} + +function aggregateStatus( + calls: readonly PresentedToolCall[] +): ChatActivityStatus { + const statuses = calls.map((call) => call.presentedNode.status); + if (statuses.includes('failed')) return 'failed'; + if (statuses.includes('running')) return 'running'; + if (statuses.includes('pending')) return 'pending'; + if (statuses.every((status) => status === 'completed')) return 'completed'; + if (statuses.every((status) => status === 'cancelled')) return 'cancelled'; + if (statuses.includes('completed')) return 'completed'; + if (statuses.includes('cancelled')) return 'cancelled'; + return 'unknown'; +} + +/** + * Produce presentation-only rows without modifying the semantic event ledger. + * Only consecutive identical calls are grouped; any different node preserves + * chronology by ending the current segment. + */ +export function groupRepeatedToolCalls( + nodes: readonly ChatProjectionNode[] +): ChatTimelineDisplayRow[] { + const rows: ChatTimelineDisplayRow[] = []; + + for (let index = 0; index < nodes.length; ) { + const node = nodes[index]!; + const identity = toolIdentity(node); + if (!identity || node.kind !== 'activity') { + rows.push({ rowKind: 'node', id: node.id, node }); + index += 1; + continue; + } + + const segment: ChatActivityNode[] = [node]; + let cursor = index + 1; + while (cursor < nodes.length) { + const candidate = nodes[cursor]!; + const candidateIdentity = toolIdentity(candidate); + if ( + !candidateIdentity || + candidateIdentity.key !== identity.key || + candidate.kind !== 'activity' + ) { + break; + } + segment.push(candidate); + cursor += 1; + } + + const calls = buildToolCalls(segment); + if (calls.length === 1) { + const presentedNode = calls[0]!.presentedNode; + rows.push({ rowKind: 'node', id: presentedNode.id, node: presentedNode }); + } else { + rows.push({ + rowKind: 'repeated-tool-calls', + id: `tool-call-group:${calls[0]!.id}`, + runId: node.runId, + agentId: node.agentId, + agentName: node.agentName, + toolkitName: identity.toolkitName, + methodName: identity.methodName, + calls, + status: aggregateStatus(calls), + }); + } + index = cursor; + } + + return rows; +} diff --git a/src/components/ChatBox/EventTimeline/index.ts b/src/components/ChatBox/EventTimeline/index.ts index bc98e7b6..ab4dd14f 100644 --- a/src/components/ChatBox/EventTimeline/index.ts +++ b/src/components/ChatBox/EventTimeline/index.ts @@ -12,6 +12,12 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +export { groupRepeatedToolCalls } from './activityGrouping'; +export type { + ChatTimelineDisplayRow, + PresentedToolCall, + RepeatedToolCallGroupRow, +} from './activityGrouping'; export { EventRenderer } from './EventRenderer'; export type { EventRendererProps } from './EventRenderer'; export { @@ -47,4 +53,5 @@ export type { EventRendererRegistry, EventTypeRendererRegistry, } from './rendererRegistry'; +export { RepeatedToolCallGroup } from './RepeatedToolCallGroup'; export { UnknownEventFallback } from './UnknownEventFallback'; diff --git a/src/components/ChatBox/MessageItem/AgentMessageCard.tsx b/src/components/ChatBox/MessageItem/AgentMessageCard.tsx index a1c85355..179587b0 100644 --- a/src/components/ChatBox/MessageItem/AgentMessageCard.tsx +++ b/src/components/ChatBox/MessageItem/AgentMessageCard.tsx @@ -114,7 +114,7 @@ export function AgentMessageCard({ return (
{showDeferredFileUi && attaches && attaches.length > 0 && ( -
+
{attaches?.map((file) => { return (
)} {showDeferredFileUi && deferredFooter != null && ( -
{deferredFooter}
+
{deferredFooter}
)} {markdownAndTypingComplete && (
diff --git a/src/components/ChatBox/MessageItem/FloatingAction.tsx b/src/components/ChatBox/MessageItem/FloatingAction.tsx index c24d5e93..56e1286b 100644 --- a/src/components/ChatBox/MessageItem/FloatingAction.tsx +++ b/src/components/ChatBox/MessageItem/FloatingAction.tsx @@ -50,11 +50,11 @@ export const FloatingAction = ({ return (
-
+
{/* Always show Stop Task button when running (removed pause/resume logic) */} + + + {open ? ( + +
+ {item.calls.map((call) => ( + + ))} +
+
+ ) : null} +
+
+ ); +}); +RepeatedToolDetailRow.displayName = 'RepeatedToolDetailRow'; + const InlineMessageRow = memo(function InlineMessageRow({ text, source, @@ -1373,6 +1541,10 @@ const AgentGroupRow = memo(function AgentGroupRow({ : (agentDisplay?.icon ?? DEFAULT_BOT_ICON); const useSingleAgentLiveHeader = isSingleAgent && group.agentType === 'single_agent'; + const displayItems = useMemo( + () => groupConsecutiveToolItems(group.items), + [group.items] + ); // Single agent: surface the live in-progress `active_form` in place of the // static "CAMEL Agent" label. Fall back to the static label only when no @@ -1505,7 +1677,7 @@ const AgentGroupRow = memo(function AgentGroupRow({ className="min-w-0 overflow-hidden" >
- {group.items.map((item) => + {displayItems.map((item) => item.kind === 'message' ? ( + ) : item.kind === 'repeated-tool' ? ( + ) : ( +
{attaches && attaches.length > 0 && (
diff --git a/src/components/ChatBox/ProjectChatContainer.tsx b/src/components/ChatBox/ProjectChatContainer.tsx index ea234029..b4101993 100644 --- a/src/components/ChatBox/ProjectChatContainer.tsx +++ b/src/components/ChatBox/ProjectChatContainer.tsx @@ -22,7 +22,11 @@ import React, { useRef, useState, } from 'react'; -import { ProjectSection } from './ProjectSection'; +import { + animateChatTimelineAnchor, + type ChatTimelineScrollAnimation, +} from './chatTimelineScroll'; +import { groupMessagesByQuery, ProjectSection } from './ProjectSection'; interface ProjectChatContainerProps { className?: string; @@ -41,10 +45,12 @@ export const ProjectChatContainer: React.FC = ({ onSkip, isPauseResumeLoading, }) => { - const { projectStore, chatStore } = useChatStoreAdapter(); + const { projectStore } = useChatStoreAdapter(); const [activeQueryId, setActiveQueryId] = useState(null); - const [lastMessageCount, setLastMessageCount] = useState(0); const [, setChatRevision] = useState(0); + const anchorFrameRef = useRef(null); + const anchorAnimationRef = useRef(null); + const contentRef = useRef(null); // Get all chat stores for the active project const activeProjectId = projectStore.activeProjectId; @@ -94,13 +100,6 @@ export const ProjectChatContainer: React.FC = ({ }; }, [chatStores]); - // Extract messages array to avoid complex expression in dependency array - const activeTaskId = chatStore?.activeTaskId as string; - const messages = useMemo( - () => chatStore?.tasks[activeTaskId]?.messages || [], - [chatStore, activeTaskId] - ); - // Scroll to bottom function const scrollToBottom = useCallback(() => { if (!scrollContainerRef.current) return; @@ -114,47 +113,100 @@ export const ProjectChatContainer: React.FC = ({ }, 100); }, [scrollContainerRef]); - // Monitor for new user messages and auto-scroll + const userQueryIds = taskSections.flatMap(({ chatStore, taskId }) => { + const task = chatStore.getState().tasks[taskId]; + return groupMessagesByQuery(task?.messages || []) + .filter((group) => group.userMessage) + .map((group) => group.queryId); + }); + const userQueryCount = userQueryIds.length; + const latestUserQueryId = userQueryIds.at(-1); + const previousUserQueryStateRef = useRef<{ + count: number; + latestId?: string; + }>({ count: 0 }); + const previousQueryProjectIdRef = useRef(activeProjectId); + + const scrollLatestQueryBelowHeader = useCallback( + (queryId: string) => { + if (anchorFrameRef.current !== null) { + cancelAnimationFrame(anchorFrameRef.current); + } + anchorAnimationRef.current?.stop(); + + const run = () => { + const container = scrollContainerRef.current; + if (!container) return; + const targets = Array.from( + container.querySelectorAll('[data-query-id]') + ); + const queryGroup = targets.find( + (candidate) => candidate.dataset.queryId === queryId + ); + const target = + queryGroup?.querySelector('[data-user-query-anchor]') || + queryGroup; + if (target) { + anchorAnimationRef.current = animateChatTimelineAnchor( + container, + target, + contentRef.current + ); + } + }; + + anchorFrameRef.current = requestAnimationFrame(run); + }, + [scrollContainerRef] + ); + + // The first query follows the existing bottom reveal. From the second query + // onward, keep the new user box at the top of the ChatBox viewport so it sits + // below the fixed Session header with the standard 44px top gap. useEffect(() => { - if (!chatStore || !activeProjectId) return; + if (previousQueryProjectIdRef.current !== activeProjectId) { + previousQueryProjectIdRef.current = activeProjectId; + previousUserQueryStateRef.current = { + count: userQueryCount, + latestId: latestUserQueryId, + }; + return; + } - if (!activeTaskId) return; + const previousQueryState = previousUserQueryStateRef.current; + const addedQuery = + latestUserQueryId !== undefined && + userQueryCount > previousQueryState.count && + latestUserQueryId !== previousQueryState.latestId; - const task = chatStore.tasks[activeTaskId]; - if (!task) return; - - const currentMessageCount = messages.length; - - // Check if a new user message was added - if (currentMessageCount > lastMessageCount) { - const lastMessage = messages[messages.length - 1]; - - // If the last message is from user, scroll to bottom - if (lastMessage && lastMessage.role === 'user') { + if (addedQuery) { + if (previousQueryState.count >= 1) { + scrollLatestQueryBelowHeader(latestUserQueryId); + } else { scrollToBottom(); } } - - // Use setTimeout to defer state update and avoid cascading renders - setTimeout(() => { - setLastMessageCount(currentMessageCount); - }, 0); + previousUserQueryStateRef.current = { + count: userQueryCount, + latestId: latestUserQueryId, + }; }, [ - messages, - lastMessageCount, - scrollToBottom, activeProjectId, - chatStore, - activeTaskId, + latestUserQueryId, + scrollLatestQueryBelowHeader, + scrollToBottom, + userQueryCount, ]); - // Reset message count when active task changes - useEffect(() => { - // Use setTimeout to defer state update and avoid cascading renders - setTimeout(() => { - setLastMessageCount(0); - }, 0); - }, [chatStore?.activeTaskId]); + useEffect( + () => () => { + if (anchorFrameRef.current !== null) { + cancelAnimationFrame(anchorFrameRef.current); + } + anchorAnimationRef.current?.stop(); + }, + [] + ); // When switching projects, jump to the latest message (bottom) instead of // staying at the top. Deferred so the switched-to project's messages have @@ -162,6 +214,8 @@ export const ProjectChatContainer: React.FC = ({ // history on every switch. useEffect(() => { if (!activeProjectId) return; + anchorAnimationRef.current?.stop(); + if (contentRef.current) contentRef.current.style.minHeight = ''; const timer = setTimeout(() => { const el = scrollContainerRef.current; if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'auto' }); @@ -354,6 +408,7 @@ export const ProjectChatContainer: React.FC = ({ return (
diff --git a/src/components/ChatBox/ProjectSection.tsx b/src/components/ChatBox/ProjectSection.tsx index c0d655fb..6810dce1 100644 --- a/src/components/ChatBox/ProjectSection.tsx +++ b/src/components/ChatBox/ProjectSection.tsx @@ -105,10 +105,10 @@ export const ProjectSection = React.forwardRef< animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.3 }} - className="relative" + className="relative mb-8" > {/* User Query Groups */} -
+
{queryGroups.map((group, index) => (
diff --git a/src/components/ChatBox/TaskBox/TaskCard.tsx b/src/components/ChatBox/TaskBox/TaskCard.tsx index 57af1bd3..c90dc313 100644 --- a/src/components/ChatBox/TaskBox/TaskCard.tsx +++ b/src/components/ChatBox/TaskBox/TaskCard.tsx @@ -238,19 +238,19 @@ export function TaskCard({ return (
-
-
+
+
{summaryTask && ( -
+
{summaryTask.split('|')[0].replace(/"/g, '')}
)} {summaryTask && ( -
+
{taskType === 1 && ( {taskType === 1 && ( -
+
{taskInfo.map((task, taskIndex) => (
-
+
{filterTasks.map((task: TaskInfo) => { return (
handleFocus(e, true)} - className={`group relative mb-2 flex min-h-2 w-full items-start gap-0 rounded-lg border border-solid p-sm hover:bg-ds-bg-neutral-default-hover ${ + className={`group relative mb-2 flex min-h-2 w-full items-start gap-0 rounded-lg border border-solid p-2 hover:bg-ds-bg-neutral-default-hover ${ isFocus ? 'border-ds-border-neutral-subtle-disabled bg-ds-bg-neutral-subtle-default' : 'border-ds-border-neutral-subtle-default group-hover:border-transparent' }`} > -
+
{taskInfo.id === '' ? (
diff --git a/src/components/ChatBox/UserQueryGroup.tsx b/src/components/ChatBox/UserQueryGroup.tsx index 74369462..5dba1d6f 100644 --- a/src/components/ChatBox/UserQueryGroup.tsx +++ b/src/components/ChatBox/UserQueryGroup.tsx @@ -18,7 +18,7 @@ import { VanillaChatStore } from '@/store/chatStore'; import { usePageTabStore } from '@/store/pageTabStore'; import { AgentStep, ChatTaskStatus, SessionMode } from '@/types/constants'; import { motion } from 'framer-motion'; -import { ChevronDown, FileText } from 'lucide-react'; +import { ChevronDown, FileText, InfoIcon } from 'lucide-react'; import React, { useCallback, useEffect, @@ -445,7 +445,7 @@ export const UserQueryGroup: React.FC = ({ duration: 0.3, delay: index * 0.1, // Stagger animation for multiple groups }} - className="relative" + className="relative flex flex-col gap-3" > {/* User query: always rendered as a regular component in the chat flow. */} {queryGroup.userMessage && ( @@ -453,7 +453,7 @@ export const UserQueryGroup: React.FC = ({ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }} - className="px-sm py-sm" + className="px-2 py-2" > = ({ initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: 0.05 }} - className="px-sm" + className="px-2" > {showPreparingExecute ? : null} @@ -607,7 +607,7 @@ export const UserQueryGroup: React.FC = ({ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} - className="flex flex-col gap-4" + className="flex flex-col" > = ({ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} - className="flex flex-col gap-4" + className="flex flex-col" > = ({ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} - className="px-sm" + className="px-2" > = ({ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} - className="flex flex-col gap-4" + className="flex flex-col" > = ({ initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }} - className="flex flex-col gap-4" + className="flex flex-col" > = ({ + {t('chat.run-no-final-response')} ) : null} @@ -735,7 +736,7 @@ export const UserQueryGroup: React.FC = ({ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} - className="px-sm" + className="px-2" > ; + +export function getChatTimelineAnchorScrollTop({ + containerTop, + currentScrollTop, + targetTop, + topGapPx = CHAT_QUERY_HEADER_GAP_PX, +}: { + containerTop: number; + currentScrollTop: number; + targetTop: number; + topGapPx?: number; +}): number { + return Math.max( + 0, + currentScrollTop + targetTop - containerTop - Math.max(0, topGapPx) + ); +} + +/** + * Ease a timeline item to the top of its scroll viewport. Framer Motion owns + * the numeric scroll position so a new request can stop and retarget the same + * transition without invoking native smooth-scroll or remounting content. + */ +export function animateChatTimelineAnchor( + container: HTMLElement, + target: HTMLElement, + content?: HTMLElement | null, + onComplete?: () => void +): ChatTimelineScrollAnimation { + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const top = getChatTimelineAnchorScrollTop({ + containerTop: containerRect.top, + currentScrollTop: container.scrollTop, + targetTop: targetRect.top, + }); + + // A short response may not naturally leave enough scroll range to place its + // query at the top. Reserve one viewport below the anchor so the requested + // alignment cannot be clamped by the browser. + if (content) { + content.style.minHeight = `${Math.ceil(top + container.clientHeight)}px`; + } + return animate(container.scrollTop, top, { + duration: CHAT_QUERY_SCROLL_DURATION_SECONDS, + ease: CHAT_QUERY_SCROLL_EASE, + onUpdate: (value) => { + container.scrollTop = value; + }, + onComplete, + }); +} diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index d873a0ed..d193ebd9 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -76,6 +76,11 @@ import { InterruptedRunBannerAction, } from './InterruptedRunBanner'; import { ProjectChatContainer } from './ProjectChatContainer'; +import { + isEventNativeRunActionable, + selectActionableInterruptedRun, + selectEventNativeActiveRunId, +} from './runControlArbitration'; import { PLAN_OVERLAY_SLOT_ID } from './TaskBox/PlanTaskBox'; /** Minimum scroll padding under messages (matches previous ~8rem floor). */ @@ -85,7 +90,7 @@ const CHAT_SCROLL_BOTTOM_GAP_PX = 8; const USAGE_WARNING_RATIO = 0.75; const FREE_STARTING_CREDITS = 500; -const ELIGIBLE_EVENT_NATIVE_RUN_STATUSES = new Set([ +const READ_ONLY_EVENT_NATIVE_RUN_STATUSES = new Set([ 'pending', 'running', 'waiting_for_user', @@ -98,10 +103,6 @@ const subscribeToNothing = () => () => undefined; type EventNativeProjectedRun = ProjectEventStoreSnapshot['view']['runs'][string]; -function isEventNativeRunActionable(run: EventNativeProjectedRun): boolean { - return run.origin === 'local' && !run.resumeBlockedReason; -} - function isEventNativeRunReadOnly(run: EventNativeProjectedRun): boolean { return ( (run.origin !== null && run.origin !== 'local') || @@ -130,50 +131,13 @@ function selectLatestReadOnlyEventNativeRun( Object.values(snapshot.view.runs) .filter( (run) => - ELIGIBLE_EVENT_NATIVE_RUN_STATUSES.has(run.status) && + READ_ONLY_EVENT_NATIVE_RUN_STATUSES.has(run.status) && isEventNativeRunReadOnly(run) ) .sort(compareProjectedRunsByRecency)[0] ?? null ); } -/** - * Bridge legacy ownership during cutover, then fall back to durable state for - * typed-only cold hydration. Pending control order is backend/event order. - */ -function selectEventNativeActiveRunId( - snapshot: ProjectEventStoreSnapshot | null, - legacyActiveRunId: string | null | undefined -): string | null { - if (legacyActiveRunId) return legacyActiveRunId; - if (!snapshot) return null; - - for (const interactionId of snapshot.control.orderedInteractionIds) { - const interaction = snapshot.control.interactionById[interactionId]; - const projectedRun = interaction - ? snapshot.view.runs[interaction.runId] - : undefined; - if ( - interaction?.status === 'requested' && - projectedRun && - ELIGIBLE_EVENT_NATIVE_RUN_STATUSES.has(projectedRun.status) && - isEventNativeRunActionable(projectedRun) - ) { - return interaction.runId; - } - } - - return ( - Object.values(snapshot.view.runs) - .filter( - (run) => - ELIGIBLE_EVENT_NATIVE_RUN_STATUSES.has(run.status) && - isEventNativeRunActionable(run) - ) - .sort(compareProjectedRunsByRecency)[0]?.runId ?? null - ); -} - interface SubscriptionLimitInfo { plan_key?: string | null; is_trialing?: boolean | null; @@ -576,7 +540,8 @@ export default function ChatBox(): JSX.Element { activeAskTask.type !== 'share' && activeAskTask.status !== ChatTaskStatus.FINISHED && projectedLegacyRun && - ELIGIBLE_EVENT_NATIVE_RUN_STATUSES.has(projectedLegacyRun.status) && + (projectedLegacyRun.status === 'running' || + projectedLegacyRun.status === 'cancelling') && isEventNativeRunActionable(projectedLegacyRun) ? activeTaskId : null; @@ -590,6 +555,10 @@ export default function ChatBox(): JSX.Element { const eventNativeActiveProjectedRun = eventNativeActiveRunId ? eventNativeProjectSnapshot?.view.runs[eventNativeActiveRunId] : undefined; + const eventNativeInterruptedRun = selectActionableInterruptedRun( + eventNativeProjectSnapshot, + interruptedRun?.run_id + ); const activeAsk = activeAskTask?.activeAsk; const activeAskMessage = activeAskTask?.messages.findLast( (item) => item.step === AgentStep.ASK @@ -829,17 +798,6 @@ export default function ChatBox(): JSX.Element { } }, [skill_prompt, searchParams, setSearchParams]); - const scrollToBottom = useCallback(() => { - if (scrollContainerRef.current) { - setTimeout(() => { - scrollContainerRef.current!.scrollTo({ - top: scrollContainerRef.current!.scrollHeight + 20, - behavior: 'smooth', - }); - }, 200); - } - }, []); - // Handle scrollbar visibility on scroll useEffect(() => { const scrollContainer = scrollContainerRef.current; @@ -1046,17 +1004,12 @@ export default function ChatBox(): JSX.Element { role: 'user', content: displayContent, interactionResponseTo: activeInteraction?.interaction_id, - attaches: - JSON.parse(JSON.stringify(chatStore.tasks[_taskId]?.attaches)) || - [], + attaches: JSON.parse( + JSON.stringify(chatStore.tasks[_taskId]?.attaches || []) + ), }); setMessage(''); - // Scroll to bottom after adding user message - setTimeout(() => { - scrollToBottom(); - }, 200); - chatStore.setIsPending(_taskId, true); let replyResult: any; @@ -1160,8 +1113,9 @@ export default function ChatBox(): JSX.Element { // Pass the message content to startTask instead of adding it to current chatStore const attachesToSend = queuedAttaches || - JSON.parse(JSON.stringify(chatStore.tasks[_taskId]?.attaches)) || - []; + JSON.parse( + JSON.stringify(chatStore.tasks[_taskId]?.attaches || []) + ); try { ensureActiveProjectMode(); await chatStore.startTask( @@ -1215,7 +1169,14 @@ export default function ChatBox(): JSX.Element { nextTaskId ); if (!nextChatResult) { - throw new Error('Unable to prepare the follow-up task.'); + // Every other failure path in this handler surfaces a toast. The + // outer catch only logs, so without this the user would click + // Send and observe nothing at all. + const prepareError = new Error( + t('chat.follow-up-prepare-failed') + ); + toast.error(prepareError.message); + throw prepareError; } const nextChatState = nextChatResult.chatStore.getState(); @@ -1272,15 +1233,12 @@ export default function ChatBox(): JSX.Element { } } } else { - setTimeout(() => { - scrollToBottom(); - }, 200); - // For the very first message, add it to the current chatStore first, then call startTask const attachesToSend = queuedAttaches || - JSON.parse(JSON.stringify(chatStore.tasks[_taskId]?.attaches)) || - []; + JSON.parse( + JSON.stringify(chatStore.tasks[_taskId]?.attaches || []) + ); if (!preserveComposer) setMessage(''); try { ensureActiveProjectMode(); @@ -1857,7 +1815,11 @@ export default function ChatBox(): JSX.Element { }; let eventNativeRunControlVariant: BottomBoxRunControlVariant | null = null; - if (eventNativeTimelineEnabled && interruptedRun) { + if ( + eventNativeTimelineEnabled && + interruptedRun && + (isCloudRestoredRun || eventNativeInterruptedRun) + ) { eventNativeRunControlVariant = { kind: 'run_control', header: { @@ -2040,9 +2002,9 @@ export default function ChatBox(): JSX.Element {
-
+
{interruptedRun && !eventNativeTimelineEnabled && ( = run.runVersion + ); +} + +function snapshotCanIssueControls( + snapshot: ProjectEventStoreSnapshot +): boolean { + return ( + !snapshot.overflowed && + !snapshot.view.needsResync && + !snapshot.view.eventsTruncated + ); +} + +function hasRetainedCanonicalRunEvidence( + snapshot: ProjectEventStoreSnapshot, + runId: string +): boolean { + return snapshot.chat.nodes.some( + (node) => node.runId === runId && !node.eventType.startsWith('legacy.') + ); +} + +function hasTypedCanonicalRequestAuthority( + snapshot: ProjectEventStoreSnapshot, + interactionId: string +): boolean { + const interaction = snapshot.control.interactionById[interactionId]; + return Boolean( + interaction?.requestSource === 'canonical' && + interaction.requestEventType && + TYPED_HUMAN_REQUEST_EVENT_TYPES.has(interaction.requestEventType) + ); +} + +/** Select the one Run allowed to own event-native BottomBox controls. */ +export function selectEventNativeActiveRunId( + snapshot: ProjectEventStoreSnapshot | null, + legacyActiveRunId: string | null | undefined +): string | null { + if (!snapshot || !snapshotCanIssueControls(snapshot)) return null; + + for (const interactionId of snapshot.control.orderedInteractionIds) { + const interaction = snapshot.control.interactionById[interactionId]; + const projectedRun = interaction + ? snapshot.view.runs[interaction.runId] + : undefined; + if ( + interaction?.status === 'requested' && + hasTypedCanonicalRequestAuthority(snapshot, interactionId) && + projectedRun && + PENDING_CONTROL_RUN_STATUSES.has(projectedRun.status) && + isEventNativeRunActionable(projectedRun) + ) { + return interaction.runId; + } + } + + if (!legacyActiveRunId) return null; + const legacyOwnedRun = snapshot.view.runs[legacyActiveRunId]; + return legacyOwnedRun && + LIVE_RUN_STATUSES.has(legacyOwnedRun.status) && + isEventNativeRunActionable(legacyOwnedRun) && + hasRetainedCanonicalRunEvidence(snapshot, legacyOwnedRun.runId) + ? legacyActiveRunId + : null; +} + +export function selectActionableInterruptedRun( + snapshot: ProjectEventStoreSnapshot | null, + runId: string | null | undefined +): EventNativeProjectedRun | null { + if (!snapshot || !runId || !snapshotCanIssueControls(snapshot)) return null; + const run = snapshot.view.runs[runId]; + return run?.status === 'interrupted' && + isEventNativeRunActionable(run) && + hasRetainedCanonicalRunEvidence(snapshot, runId) + ? run + : null; +} diff --git a/src/hooks/useProjectEventStoreHydration.ts b/src/hooks/useProjectEventStoreHydration.ts index 38d99bf4..60577792 100644 --- a/src/hooks/useProjectEventStoreHydration.ts +++ b/src/hooks/useProjectEventStoreHydration.ts @@ -20,6 +20,11 @@ import { getProjectEventStore } from '@/store/projectEventStore'; import { useEffect, useState } from 'react'; const RETRY_DELAY_MS = 1_000; +/** + * Ceiling for exponential retry backoff. A retryable failure that never clears + * (backend down) otherwise re-fetched the whole Project snapshot every second. + */ +const MAX_RETRY_DELAY_MS = 30_000; export type UseProjectEventStoreHydrationOptions = { projectId: string | null | undefined; @@ -82,7 +87,11 @@ export function useProjectEventStoreHydration({ let mounted = true; let running = false; let retryTimer: ReturnType | null = null; - let blockedByContract = false; + let blockedIncarnation: number | null = null; + let consecutiveFailures = 0; + + const isBlockedByContract = () => + blockedIncarnation === store.getIncarnation(); const needsHydration = () => { const snapshot = store.getSnapshot(); @@ -94,17 +103,31 @@ export function useProjectEventStoreHydration({ }; const scheduleRetry = () => { - if (!mounted || retryTimer || blockedByContract) return; + if (!mounted || retryTimer || isBlockedByContract()) return; + const delay = Math.min( + RETRY_DELAY_MS * 2 ** Math.max(0, consecutiveFailures - 1), + MAX_RETRY_DELAY_MS + ); retryTimer = setTimeout(() => { retryTimer = null; requestHydration(); - }, RETRY_DELAY_MS); + }, delay); }; const requestHydration = () => { - if (!mounted || running || blockedByContract || !needsHydration()) { + // A pending retry owns the next attempt. Without this the store + // subscription below could re-enter immediately on any unrelated publish + // and defeat the backoff entirely. + if ( + !mounted || + running || + isBlockedByContract() || + retryTimer || + !needsHydration() + ) { return; } + const requestIncarnation = store.getIncarnation(); running = true; setHydrationState({ status: 'loading', @@ -117,6 +140,7 @@ export function useProjectEventStoreHydration({ store, }) .then((result) => { + consecutiveFailures = 0; if (mounted) { setHydrationState({ status: 'ready', @@ -127,8 +151,9 @@ export function useProjectEventStoreHydration({ }) .catch((error: unknown) => { if (!mounted || isAbortError(error)) return; + consecutiveFailures += 1; if (isNonRetryable(error)) { - blockedByContract = true; + blockedIncarnation = requestIncarnation; setHydrationState({ status: 'error', errorCode: error.code, @@ -151,6 +176,9 @@ export function useProjectEventStoreHydration({ }) .finally(() => { running = false; + if (store.getIncarnation() !== requestIncarnation) { + requestHydration(); + } }); }; diff --git a/src/hooks/useProjectRunEventStreams.ts b/src/hooks/useProjectRunEventStreams.ts index dd8d0f57..dde397c7 100644 --- a/src/hooks/useProjectRunEventStreams.ts +++ b/src/hooks/useProjectRunEventStreams.ts @@ -72,7 +72,13 @@ export function useProjectRunEventStreams({ }; }, [enabled, maxStreams, projectId, reconnectDelayMs, transport]); + // `projectId`/`enabled` are dependencies even though they are unused here: + // they are what construct a new owner above, and effects run in declaration + // order within one commit. Without them a freshly created owner would be fed + // only when the snapshot reference happened to change too, so a Project + // switch that reuses a snapshot reference would open no streams at all until + // the next unrelated store publish. useEffect(() => { if (snapshot) ownerRef.current?.updateSnapshot(snapshot); - }, [snapshot]); + }, [snapshot, projectId, enabled]); } diff --git a/src/hooks/useRemoteControlBridge.test.ts b/src/hooks/useRemoteControlBridge.test.ts deleted file mode 100644 index d0d4f315..00000000 --- a/src/hooks/useRemoteControlBridge.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -// ========= 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 { - __remoteControlBridgeTestHooks, - ackFromDurableExecution, -} from './useRemoteControlBridge'; - -describe('remote command durable ACK replay', () => { - beforeEach(() => { - window.localStorage.clear(); - }); - - it('replays the canonical completed outcome without executing again', () => { - expect( - ackFromDurableExecution('command-1', { - event_type: 'execution.completed', - payload: { result: { run_id: 'run-1' } }, - }) - ).toEqual({ - type: 'command_ack', - command_id: 'command-1', - status: 'acknowledged', - result: { run_id: 'run-1' }, - replayed_from_cache: true, - }); - }); - - it('replays the canonical failure rather than an upload error', () => { - expect( - ackFromDurableExecution('command-1', { - event_type: 'execution.failed', - payload: { error_code: 'TOOL_FAILED', error: 'original failure' }, - }) - ).toMatchObject({ - status: 'failed', - error_code: 'TOOL_FAILED', - error: 'original failure', - }); - }); - - it('preserves a queued execution result when restart reconciliation races it', () => { - const command = { - id: 'command-1', - session_id: 'session-1', - user_id: 1, - source_channel: 'remote_control' as const, - type: 'user_message', - target_project_id: 'project-1', - payload: {}, - }; - const completed = { - status: 'completed' as const, - event_id: 'command-1:execution-result', - result: { run_id: 'run-1' }, - }; - - __remoteControlBridgeTestHooks.queuePendingCommandResult({ - command, - body: completed, - }); - const durable = __remoteControlBridgeTestHooks.queuePendingCommandResult({ - command, - body: { - status: 'failed', - event_id: 'command-1:recovery-outcome-unknown', - result: {}, - error_code: 'COMMAND_OUTCOME_UNKNOWN_AFTER_RESTART', - }, - }); - - expect(durable.body).toEqual(completed); - expect( - __remoteControlBridgeTestHooks.ackFromPendingCommandResult( - command.id, - durable.body - ) - ).toMatchObject({ - status: 'acknowledged', - result: { run_id: 'run-1' }, - }); - }); -}); diff --git a/src/hooks/useRemoteControlBridge.ts b/src/hooks/useRemoteControlBridge.ts index 6c019434..1a8a961f 100644 --- a/src/hooks/useRemoteControlBridge.ts +++ b/src/hooks/useRemoteControlBridge.ts @@ -810,6 +810,11 @@ async function executeRemoteCommand( switch (command.type) { case 'user_message': { const requestId = command.next_task_id || command.id; + // getCommandProjectId falls back to '', which would otherwise be sent as + // a request to /projects//follow-ups. + if (!projectId) { + throw new Error('Remote user_message requires a target Project'); + } const content = String( command.payload.content || command.payload.question || '' ); diff --git a/src/i18n/locales/ar/chat.json b/src/i18n/locales/ar/chat.json index a299edab..93f56687 100644 --- a/src/i18n/locales/ar/chat.json +++ b/src/i18n/locales/ar/chat.json @@ -127,5 +127,17 @@ "run-cancel": "إنهاء التشغيل", "run-cancelling": "جارٍ الإنهاء…", "run-resume-failed": "تعذرت متابعة هذا التشغيل.", - "run-cancel-failed": "تعذر إنهاء هذا التشغيل." + "run-cancel-failed": "تعذر إنهاء هذا التشغيل.", + "repeated-tool-events": "{{tool}} · {{count}} أحداث", + "repeated-tool-calls-label": "استدعاءات الأداة المتكررة: {{tool}}", + "repeated-tool-failed": "فشل {{count}}", + "repeated-tool-completed-progress": "اكتمل {{completed}}/{{count}}", + "repeated-tool-cancelled": "أُلغي {{count}}", + "tool-status-completed": "مكتمل", + "tool-status-failed": "فشل", + "tool-status-cancelled": "ملغى", + "tool-status-running": "قيد التشغيل", + "tool-status-pending": "قيد الانتظار", + "tool-status-unknown": "غير معروف", + "follow-up-prepare-failed": "تعذر إعداد مهمة المتابعة." } diff --git a/src/i18n/locales/ar/setting.json b/src/i18n/locales/ar/setting.json index 5b82f8bd..3b15c730 100644 --- a/src/i18n/locales/ar/setting.json +++ b/src/i18n/locales/ar/setting.json @@ -132,7 +132,7 @@ "are-you-sure-you-want-to-delete": "هل أنت متأكد أنك تريد حذف", "deleting": "جارٍ الحذف...", "delete": "حذف", - "configure {name} Toolkit": "{name} تكوين مجموعة أدوات", + "configure {name} Toolkit": "تكوين مجموعة أدوات {{name}}", "get-it-from": "احصل عليها من", "google-custom-search-api": "Google واجهة برمجة تطبيقات البحث المخصص من", "google-cloud-console": "Google Cloud وحدة تحكم", @@ -337,7 +337,7 @@ "are-you-sure-you-want-to-delete": "هل أنت متأكد أنك تريد حذف", "deleting": "جارٍ الحذف...", "delete": "حذف", - "configure {name} Toolkit": "{name} تكوين مجموعة أدوات", + "configure {name} Toolkit": "تكوين مجموعة أدوات {{name}}", "get-it-from": "احصل عليها من", "google-custom-search-api": "Google واجهة برمجة تطبيقات البحث المخصص من", "google-cloud-console": "Google Cloud وحدة تحكم", diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json index 711536b5..cd9272ed 100644 --- a/src/i18n/locales/de/chat.json +++ b/src/i18n/locales/de/chat.json @@ -127,5 +127,17 @@ "run-cancel": "Ausführung beenden", "run-cancelling": "Wird beendet…", "run-resume-failed": "Die Ausführung konnte nicht fortgesetzt werden.", - "run-cancel-failed": "Die Ausführung konnte nicht beendet werden." + "run-cancel-failed": "Die Ausführung konnte nicht beendet werden.", + "repeated-tool-events": "{{tool}} · {{count}} Ereignisse", + "repeated-tool-calls-label": "Wiederholte Tool-Aufrufe: {{tool}}", + "repeated-tool-failed": "{{count}} fehlgeschlagen", + "repeated-tool-completed-progress": "{{completed}}/{{count}} abgeschlossen", + "repeated-tool-cancelled": "{{count}} abgebrochen", + "tool-status-completed": "Abgeschlossen", + "tool-status-failed": "Fehlgeschlagen", + "tool-status-cancelled": "Abgebrochen", + "tool-status-running": "Läuft", + "tool-status-pending": "Ausstehend", + "tool-status-unknown": "Unbekannt", + "follow-up-prepare-failed": "Die Folgeaufgabe konnte nicht vorbereitet werden." } diff --git a/src/i18n/locales/en-us/chat.json b/src/i18n/locales/en-us/chat.json index b858775c..d068931b 100644 --- a/src/i18n/locales/en-us/chat.json +++ b/src/i18n/locales/en-us/chat.json @@ -127,5 +127,17 @@ "run-cancel": "Cancel Run", "run-cancelling": "Cancelling…", "run-resume-failed": "Failed to resume this Run.", - "run-cancel-failed": "Failed to cancel this Run." + "run-cancel-failed": "Failed to cancel this Run.", + "repeated-tool-events": "{{tool}} · {{count}} events", + "repeated-tool-calls-label": "Repeated tool calls: {{tool}}", + "repeated-tool-failed": "{{count}} failed", + "repeated-tool-completed-progress": "{{completed}}/{{count}} completed", + "repeated-tool-cancelled": "{{count}} cancelled", + "tool-status-completed": "Completed", + "tool-status-failed": "Failed", + "tool-status-cancelled": "Cancelled", + "tool-status-running": "Running", + "tool-status-pending": "Pending", + "tool-status-unknown": "Unknown", + "follow-up-prepare-failed": "Unable to prepare the follow-up task." } diff --git a/src/i18n/locales/es/chat.json b/src/i18n/locales/es/chat.json index c54fe73e..ad6470a3 100644 --- a/src/i18n/locales/es/chat.json +++ b/src/i18n/locales/es/chat.json @@ -127,5 +127,17 @@ "run-cancel": "Cancelar ejecución", "run-cancelling": "Cancelando…", "run-resume-failed": "No se pudo reanudar esta ejecución.", - "run-cancel-failed": "No se pudo cancelar esta ejecución." + "run-cancel-failed": "No se pudo cancelar esta ejecución.", + "repeated-tool-events": "{{tool}} · {{count}} eventos", + "repeated-tool-calls-label": "Llamadas repetidas a la herramienta: {{tool}}", + "repeated-tool-failed": "{{count}} con error", + "repeated-tool-completed-progress": "{{completed}}/{{count}} completadas", + "repeated-tool-cancelled": "{{count}} canceladas", + "tool-status-completed": "Completada", + "tool-status-failed": "Con error", + "tool-status-cancelled": "Cancelada", + "tool-status-running": "En curso", + "tool-status-pending": "Pendiente", + "tool-status-unknown": "Desconocida", + "follow-up-prepare-failed": "No se pudo preparar la tarea de seguimiento." } diff --git a/src/i18n/locales/fr/chat.json b/src/i18n/locales/fr/chat.json index a3de8832..53499198 100644 --- a/src/i18n/locales/fr/chat.json +++ b/src/i18n/locales/fr/chat.json @@ -127,5 +127,17 @@ "run-cancel": "Annuler l’exécution", "run-cancelling": "Annulation…", "run-resume-failed": "Impossible de reprendre cette exécution.", - "run-cancel-failed": "Impossible d’annuler cette exécution." + "run-cancel-failed": "Impossible d’annuler cette exécution.", + "repeated-tool-events": "{{tool}} · {{count}} événements", + "repeated-tool-calls-label": "Appels d’outil répétés : {{tool}}", + "repeated-tool-failed": "{{count}} en échec", + "repeated-tool-completed-progress": "{{completed}}/{{count}} terminés", + "repeated-tool-cancelled": "{{count}} annulés", + "tool-status-completed": "Terminé", + "tool-status-failed": "Échec", + "tool-status-cancelled": "Annulé", + "tool-status-running": "En cours", + "tool-status-pending": "En attente", + "tool-status-unknown": "Inconnu", + "follow-up-prepare-failed": "Impossible de préparer la tâche de suivi." } diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json index 9c2c60c5..a777fe18 100644 --- a/src/i18n/locales/it/chat.json +++ b/src/i18n/locales/it/chat.json @@ -127,5 +127,17 @@ "run-cancel": "Annulla esecuzione", "run-cancelling": "Annullamento…", "run-resume-failed": "Impossibile riprendere questa esecuzione.", - "run-cancel-failed": "Impossibile annullare questa esecuzione." + "run-cancel-failed": "Impossibile annullare questa esecuzione.", + "repeated-tool-events": "{{tool}} · {{count}} eventi", + "repeated-tool-calls-label": "Chiamate ripetute allo strumento: {{tool}}", + "repeated-tool-failed": "{{count}} non riuscite", + "repeated-tool-completed-progress": "{{completed}}/{{count}} completate", + "repeated-tool-cancelled": "{{count}} annullate", + "tool-status-completed": "Completata", + "tool-status-failed": "Non riuscita", + "tool-status-cancelled": "Annullata", + "tool-status-running": "In corso", + "tool-status-pending": "In attesa", + "tool-status-unknown": "Sconosciuta", + "follow-up-prepare-failed": "Impossibile preparare l’attività di follow-up." } diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index b871601d..2b15b857 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -127,5 +127,17 @@ "run-cancel": "実行を終了", "run-cancelling": "終了中…", "run-resume-failed": "この実行を再開できませんでした。", - "run-cancel-failed": "この実行を終了できませんでした。" + "run-cancel-failed": "この実行を終了できませんでした。", + "repeated-tool-events": "{{tool}} · {{count}}件のイベント", + "repeated-tool-calls-label": "繰り返しのツール呼び出し: {{tool}}", + "repeated-tool-failed": "{{count}}件失敗", + "repeated-tool-completed-progress": "{{completed}}/{{count}}件完了", + "repeated-tool-cancelled": "{{count}}件キャンセル", + "tool-status-completed": "完了", + "tool-status-failed": "失敗", + "tool-status-cancelled": "キャンセル済み", + "tool-status-running": "実行中", + "tool-status-pending": "保留中", + "tool-status-unknown": "不明", + "follow-up-prepare-failed": "フォローアップタスクを準備できませんでした。" } diff --git a/src/i18n/locales/ja/setting.json b/src/i18n/locales/ja/setting.json index 26781de6..8ca309e5 100644 --- a/src/i18n/locales/ja/setting.json +++ b/src/i18n/locales/ja/setting.json @@ -209,7 +209,7 @@ "are-you-sure-you-want-to-delete": "本当に削除しますか", "deleting": "削除中...", "delete": "削除", - "configure {name} Toolkit": "{name}ツールキットを構成", + "configure {name} Toolkit": "{{name}}ツールキットを構成", "get-it-from": "から入手", "google-custom-search-api": "Googleカスタム検索API", "google-cloud-console": "Google Cloud Console", diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index 92044fd2..96fe65aa 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -127,5 +127,17 @@ "run-cancel": "실행 종료", "run-cancelling": "종료 중…", "run-resume-failed": "이 실행을 재개하지 못했습니다.", - "run-cancel-failed": "이 실행을 종료하지 못했습니다." + "run-cancel-failed": "이 실행을 종료하지 못했습니다.", + "repeated-tool-events": "{{tool}} · 이벤트 {{count}}개", + "repeated-tool-calls-label": "반복된 도구 호출: {{tool}}", + "repeated-tool-failed": "{{count}}개 실패", + "repeated-tool-completed-progress": "{{completed}}/{{count}}개 완료", + "repeated-tool-cancelled": "{{count}}개 취소됨", + "tool-status-completed": "완료", + "tool-status-failed": "실패", + "tool-status-cancelled": "취소됨", + "tool-status-running": "실행 중", + "tool-status-pending": "대기 중", + "tool-status-unknown": "알 수 없음", + "follow-up-prepare-failed": "후속 작업을 준비하지 못했습니다." } diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index 1dae44ae..3f5c4b99 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -127,5 +127,17 @@ "run-cancel": "Завершить запуск", "run-cancelling": "Завершение…", "run-resume-failed": "Не удалось возобновить этот запуск.", - "run-cancel-failed": "Не удалось завершить этот запуск." + "run-cancel-failed": "Не удалось завершить этот запуск.", + "repeated-tool-events": "{{tool}} · событий: {{count}}", + "repeated-tool-calls-label": "Повторные вызовы инструмента: {{tool}}", + "repeated-tool-failed": "С ошибкой: {{count}}", + "repeated-tool-completed-progress": "Завершено: {{completed}}/{{count}}", + "repeated-tool-cancelled": "Отменено: {{count}}", + "tool-status-completed": "Завершено", + "tool-status-failed": "Ошибка", + "tool-status-cancelled": "Отменено", + "tool-status-running": "Выполняется", + "tool-status-pending": "Ожидает", + "tool-status-unknown": "Неизвестно", + "follow-up-prepare-failed": "Не удалось подготовить следующую задачу." } diff --git a/src/i18n/locales/zh-Hans/chat.json b/src/i18n/locales/zh-Hans/chat.json index 0186c1ec..b5467c63 100644 --- a/src/i18n/locales/zh-Hans/chat.json +++ b/src/i18n/locales/zh-Hans/chat.json @@ -127,5 +127,17 @@ "run-cancel": "结束任务", "run-cancelling": "正在结束…", "run-resume-failed": "恢复任务失败,请重试。", - "run-cancel-failed": "结束任务失败,请重试。" + "run-cancel-failed": "结束任务失败,请重试。", + "repeated-tool-events": "{{tool}} · {{count}} 个事件", + "repeated-tool-calls-label": "重复的工具调用:{{tool}}", + "repeated-tool-failed": "{{count}} 个失败", + "repeated-tool-completed-progress": "已完成 {{completed}}/{{count}}", + "repeated-tool-cancelled": "{{count}} 个已取消", + "tool-status-completed": "已完成", + "tool-status-failed": "失败", + "tool-status-cancelled": "已取消", + "tool-status-running": "运行中", + "tool-status-pending": "等待中", + "tool-status-unknown": "未知", + "follow-up-prepare-failed": "无法准备后续任务。" } diff --git a/src/i18n/locales/zh-Hant/chat.json b/src/i18n/locales/zh-Hant/chat.json index 1baf126b..8c35e4bf 100644 --- a/src/i18n/locales/zh-Hant/chat.json +++ b/src/i18n/locales/zh-Hant/chat.json @@ -127,5 +127,17 @@ "run-cancel": "結束任務", "run-cancelling": "正在結束…", "run-resume-failed": "恢復任務失敗,請重試。", - "run-cancel-failed": "結束任務失敗,請重試。" + "run-cancel-failed": "結束任務失敗,請重試。", + "repeated-tool-events": "{{tool}} · {{count}} 個事件", + "repeated-tool-calls-label": "重複的工具呼叫:{{tool}}", + "repeated-tool-failed": "{{count}} 個失敗", + "repeated-tool-completed-progress": "已完成 {{completed}}/{{count}}", + "repeated-tool-cancelled": "{{count}} 個已取消", + "tool-status-completed": "已完成", + "tool-status-failed": "失敗", + "tool-status-cancelled": "已取消", + "tool-status-running": "執行中", + "tool-status-pending": "等待中", + "tool-status-unknown": "未知", + "follow-up-prepare-failed": "無法準備後續任務。" } diff --git a/src/lib/htmlSanitization.test.ts b/src/lib/htmlSanitization.test.ts deleted file mode 100644 index 0732c7dc..00000000 --- a/src/lib/htmlSanitization.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -// ========= 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 { describe, expect, it } from 'vitest'; -import { - injectPreviewContentSecurityPolicy, - PREVIEW_CONTENT_SECURITY_POLICY, -} from './htmlSanitization'; - -describe('HTML preview CSP', () => { - it('replaces an agent-authored policy with the application policy', () => { - const html = injectPreviewContentSecurityPolicy(` - - - `); - const doc = new DOMParser().parseFromString(html, 'text/html'); - const policies = doc.querySelectorAll( - 'meta[http-equiv="Content-Security-Policy" i]' - ); - - expect(policies).toHaveLength(1); - expect(policies[0].getAttribute('content')).toBe( - PREVIEW_CONTENT_SECURITY_POLICY - ); - expect(PREVIEW_CONTENT_SECURITY_POLICY).toContain("default-src 'none'"); - expect(PREVIEW_CONTENT_SECURITY_POLICY).toContain("connect-src 'none'"); - expect(PREVIEW_CONTENT_SECURITY_POLICY).not.toContain('https:'); - }); -}); diff --git a/src/lib/projector/chat/adapter.ts b/src/lib/projector/chat/adapter.ts index 89277bb1..7767ba6a 100644 --- a/src/lib/projector/chat/adapter.ts +++ b/src/lib/projector/chat/adapter.ts @@ -603,10 +603,43 @@ function activityNode( fallbackStatus: ChatActivityStatus ): ChatActivityNode { const payload = asRecord(data); + const tool = asRecord(payload.tool); const isTypedActivity = !base.eventType.startsWith('legacy.'); - const toolkitName = firstText(payload.toolkit_name, payload.toolkitName); - const methodName = firstText(payload.method_name, payload.methodName); - const toolName = firstText(payload.tool_name, payload.toolName); + const toolkitName = firstText( + payload.toolkit_name, + payload.toolkitName, + tool.toolkit_name, + tool.toolkitName + ); + const methodName = firstText( + payload.method_name, + payload.methodName, + tool.method_name, + tool.methodName + ); + const toolName = firstText( + payload.tool_name, + payload.toolName, + tool.tool_name, + tool.toolName, + tool.name + ); + const toolCallId = firstText( + payload.tool_call_id, + payload.toolCallId, + payload.call_id, + payload.callId, + payload.invocation_id, + payload.invocationId, + payload.tool_use_id, + payload.toolUseId, + tool.tool_call_id, + tool.toolCallId, + tool.call_id, + tool.callId, + tool.invocation_id, + tool.invocationId + ); const isHumanInputActivity = isHumanInputToolkitActivity( toolkitName, methodName, @@ -644,6 +677,8 @@ function activityNode( undefined, toolkitName: toolkitName || undefined, methodName: methodName || undefined, + toolCallId: toolCallId || undefined, + toolName: toolName || undefined, }; } diff --git a/src/lib/projector/chat/types.ts b/src/lib/projector/chat/types.ts index 0631a6b3..ed52d85f 100644 --- a/src/lib/projector/chat/types.ts +++ b/src/lib/projector/chat/types.ts @@ -165,6 +165,10 @@ export interface ChatActivityNode extends ChatProjectionNodeBase { taskId?: string; toolkitName?: string; methodName?: string; + /** Backend correlation for one tool invocation when the transport supplies it. */ + toolCallId?: string; + /** Typed transports may name a tool without a toolkit/method pair. */ + toolName?: string; } export interface ChatArtifactNode extends ChatProjectionNodeBase { diff --git a/src/lib/projector/control/reduce.ts b/src/lib/projector/control/reduce.ts index 232d2a86..af4e21c9 100644 --- a/src/lib/projector/control/reduce.ts +++ b/src/lib/projector/control/reduce.ts @@ -26,6 +26,12 @@ const TERMINAL_STATUSES = new Set([ 'cancelled', ]); +function isTypedRequestEvent(eventType: string | undefined): boolean { + return ( + eventType === 'interaction.requested' || eventType === 'approval.requested' + ); +} + export function createHumanControlProjectionState( projectId: string ): HumanControlProjectionState { @@ -84,6 +90,8 @@ function createInteraction( cloudCursor: update.cloudCursor, lastCloudCursor: update.cloudCursor, requestEventId: update.status === 'requested' ? update.eventId : undefined, + requestEventType: + update.status === 'requested' ? update.eventType : undefined, requestSource: update.source, lastEventId: update.eventId, requestedAt: update.status === 'requested' ? update.createdAt : undefined, @@ -116,7 +124,10 @@ function mergeInteraction( ): HumanControlInteraction { const existingIsTerminal = TERMINAL_STATUSES.has(existing.status); const requestSuppliesIdentity = - update.status === 'requested' && !existing.requestEventId; + update.status === 'requested' && + (!existing.requestEventId || + (isTypedRequestEvent(update.eventType) && + !isTypedRequestEvent(existing.requestEventType))); const updateIsLatest = update.sequence >= existing.lastSequence; return { @@ -132,10 +143,12 @@ function mergeInteraction( updateIsLatest && update.cloudCursor !== null ? update.cloudCursor : existing.lastCloudCursor, - requestEventId: - update.status === 'requested' - ? existing.requestEventId || update.eventId - : existing.requestEventId, + requestEventId: requestSuppliesIdentity + ? update.eventId + : existing.requestEventId, + requestEventType: requestSuppliesIdentity + ? update.eventType + : existing.requestEventType, requestSource: requestSuppliesIdentity ? update.source : existing.requestSource, diff --git a/src/lib/projector/control/types.ts b/src/lib/projector/control/types.ts index e3d35b6a..ffae4dea 100644 --- a/src/lib/projector/control/types.ts +++ b/src/lib/projector/control/types.ts @@ -76,6 +76,8 @@ export interface HumanControlInteraction { cloudCursor: number | null; lastCloudCursor: number | null; requestEventId?: string; + /** Typed event that established the request; legacy ASK mirrors are not command authority. */ + requestEventType?: string; /** Source lane of the request; only canonical sequences are replay cursors. */ requestSource: CanonicalProjectEvent['source']; lastEventId: string; diff --git a/src/service/followUpQueueApi.ts b/src/service/followUpQueueApi.ts index 00ac7e1d..7faef7e3 100644 --- a/src/service/followUpQueueApi.ts +++ b/src/service/followUpQueueApi.ts @@ -79,7 +79,37 @@ function basePath(projectId: string): string { return `/projects/${encodeURIComponent(projectId)}/follow-ups`; } -export function createFollowUpRequest(input: { +/** + * Validate one durable follow-up record at the transport boundary. + * + * The list helpers below already guard their array shape. Without the same + * guard on single-record reads, an empty or error-shaped body flows out under + * a `DurableFollowUpRequest` annotation the runtime never checked, and the + * failure surfaces much later as a property access on `undefined`. + */ +function parseFollowUpRecord( + response: unknown, + context: string +): DurableFollowUpRequest { + const record = response as Partial | null; + if ( + !record || + typeof record !== 'object' || + typeof record.request_id !== 'string' || + !record.request_id || + typeof record.content !== 'string' + ) { + throw new Error(`${context} returned an invalid follow-up record`); + } + return { + ...(record as DurableFollowUpRequest), + attachment_paths: Array.isArray(record.attachment_paths) + ? record.attachment_paths + : [], + }; +} + +export async function createFollowUpRequest(input: { projectId: string; requestId: string; content: string; @@ -88,7 +118,7 @@ export function createFollowUpRequest(input: { sourceCommandId?: string; }): Promise { invalidatePendingFollowUps(input.projectId); - return fetchPost(basePath(input.projectId), { + const response = await fetchPost(basePath(input.projectId), { request_id: input.requestId, content: input.content, attachment_paths: input.attachmentPaths, @@ -96,6 +126,7 @@ export function createFollowUpRequest(input: { source: input.source || 'local', source_command_id: input.sourceCommandId, }); + return parseFollowUpRecord(response, 'createFollowUpRequest'); } export async function listPendingRemoteFollowUpRequests(): Promise< @@ -107,12 +138,13 @@ export async function listPendingRemoteFollowUpRequests(): Promise< return Array.isArray(response?.items) ? response.items : []; } -export function getRemoteFollowUpByCommandId( +export async function getRemoteFollowUpByCommandId( sourceCommandId: string ): Promise { - return fetchGet( + const response = await fetchGet( `/follow-ups/source-command/${encodeURIComponent(sourceCommandId)}` ); + return parseFollowUpRecord(response, 'getRemoteFollowUpByCommandId'); } export async function listPendingFollowUpRequests( @@ -135,32 +167,37 @@ export async function listPendingFollowUpRequests( return request; } -export function prioritizeFollowUpRequest( +export async function prioritizeFollowUpRequest( projectId: string, requestId: string ): Promise { invalidatePendingFollowUps(projectId); - return fetchPost( + const response = await fetchPost( `${basePath(projectId)}/${encodeURIComponent(requestId)}/send-now` ); + return parseFollowUpRecord(response, 'prioritizeFollowUpRequest'); } -export function cancelFollowUpRequest( +export async function cancelFollowUpRequest( projectId: string, requestId: string ): Promise { invalidatePendingFollowUps(projectId); - return fetchDelete(`${basePath(projectId)}/${encodeURIComponent(requestId)}`); + const response = await fetchDelete( + `${basePath(projectId)}/${encodeURIComponent(requestId)}` + ); + return parseFollowUpRecord(response, 'cancelFollowUpRequest'); } -export function markFollowUpRequestAdmitted( +export async function markFollowUpRequestAdmitted( projectId: string, requestId: string, runId: string ): Promise { invalidatePendingFollowUps(projectId); - return fetchPost( + const response = await fetchPost( `${basePath(projectId)}/${encodeURIComponent(requestId)}/admitted`, { run_id: runId } ); + return parseFollowUpRecord(response, 'markFollowUpRequestAdmitted'); } diff --git a/src/service/projectEventStoreHydration.ts b/src/service/projectEventStoreHydration.ts index aafc48eb..e45ecea3 100644 --- a/src/service/projectEventStoreHydration.ts +++ b/src/service/projectEventStoreHydration.ts @@ -261,7 +261,12 @@ async function readRunEvents( } ): Promise<{ lastSequence: number; truncated: boolean }> { let cursor = input.afterSequence; + // Ring buffer over the newest `retainLimit` events. `retainStart` is the + // oldest slot once the buffer is full; it stays 0 while it is still filling. + // The caller guarantees retainLimit >= 1 (it skips Runs with no remaining + // budget), so the modulo below is always well defined. const retainedEvents: CanonicalProjectEvent[] = []; + let retainStart = 0; let truncated = false; while (true) { @@ -385,9 +390,13 @@ async function readRunEvents( input.budget.scannedEvents += 1; input.budget.bytes += bytes; // The hydrated projection never needs to retain the transport envelope. - retainedEvents.push({ ...event, raw: null }); - if (retainedEvents.length > input.retainLimit) { - retainedEvents.shift(); + // Retain the newest tail in a ring so a long Run does not pay a shift() + // per event once the retain limit is reached. + if (retainedEvents.length < input.retainLimit) { + retainedEvents.push({ ...event, raw: null }); + } else { + retainedEvents[retainStart] = { ...event, raw: null }; + retainStart = (retainStart + 1) % input.retainLimit; truncated = true; } } @@ -401,7 +410,11 @@ async function readRunEvents( invalidResponse('Run event replay returned an invalid next_sequence'); } if (response.has_more !== true) { - input.events.push(...retainedEvents); + // Unroll the ring back into ascending sequence order before publishing. + input.events.push( + ...retainedEvents.slice(retainStart), + ...retainedEvents.slice(0, retainStart) + ); input.budget.events += retainedEvents.length; return { lastSequence, truncated }; } diff --git a/src/service/projectRunEventStream.ts b/src/service/projectRunEventStream.ts index f2b76b07..a7277cf3 100644 --- a/src/service/projectRunEventStream.ts +++ b/src/service/projectRunEventStream.ts @@ -23,6 +23,12 @@ import { const DEFAULT_MAX_LIVE_RUN_STREAMS = 4; const DEFAULT_RECONNECT_DELAY_MS = 1_000; +/** + * Ceiling for exponential reconnect backoff. Without it, an endpoint that is + * permanently unavailable (backend down, Run deleted server-side) produced one + * request per second per stream forever. + */ +const MAX_RECONNECT_DELAY_MS = 30_000; const LIVE_RUN_STATUSES = new Set([ 'pending', @@ -265,6 +271,7 @@ export class ProjectRunEventStreamOwner { } private async consumeStream(stream: LiveRunStream): Promise { + let consecutiveFailures = 0; while ( !this.disposed && !stream.controller.signal.aborted && @@ -272,6 +279,7 @@ export class ProjectRunEventStreamOwner { this.streams.get(stream.runId) === stream ) { const url = `/runs/${encodeURIComponent(stream.runId)}/stream?after_sequence=${stream.cursor}`; + const cursorBeforeAttempt = stream.cursor; try { await this.transport({ url, @@ -313,7 +321,17 @@ export class ProjectRunEventStreamOwner { ) { break; } - await waitForReconnect(stream.controller.signal, this.reconnectDelayMs); + // Any forward progress means the connection is healthy, so reset the + // backoff; only repeated no-progress attempts are throttled. + consecutiveFailures = + stream.cursor > cursorBeforeAttempt ? 0 : consecutiveFailures + 1; + await waitForReconnect( + stream.controller.signal, + Math.min( + this.reconnectDelayMs * 2 ** Math.max(0, consecutiveFailures - 1), + MAX_RECONNECT_DELAY_MS + ) + ); } } diff --git a/src/store/chatEventProjectionBridge.ts b/src/store/chatEventProjectionBridge.ts index 0fb9e5f6..82deaf8f 100644 --- a/src/store/chatEventProjectionBridge.ts +++ b/src/store/chatEventProjectionBridge.ts @@ -28,13 +28,16 @@ export type ChatEventProjectionInput = { }; /** - * Shadowing defaults on in development and remains opt-in in packaged builds - * until semantic parity has been measured. The future renderer cutover gets a - * separate flag; transport ingestion must stay centralized here. + * 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. */ export function isChatEventProjectionEnabled(): boolean { return ( - import.meta.env.DEV || 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 @@ -52,19 +55,34 @@ export function isChatEventTimelineEnabled(): boolean { return import.meta.env.VITE_CHATBOX_EVENT_BUS === 'true'; } +/** + * Outcome of one ingest attempt. + * + * `overflowed` is deliberately distinct from `disabled`/`rejected`: it means + * the store dropped its queue and entered needsResync, which also suspends the + * canonical live streams until a fresh snapshot commits. Collapsing it into a + * bare `false` hides a recoverable-but-degraded state behind the same value as + * "the feature is switched off". + */ +export type ChatEventProjectionOutcome = + | 'accepted' + | 'disabled' + | 'rejected' + | 'overflowed'; + /** Never allow migration projection failures to affect the legacy UI path. */ export function enqueueChatEventProjection( input: ChatEventProjectionInput, enabled = isChatEventProjectionEnabled() -): boolean { - if (!enabled || !input.projectId) return false; +): ChatEventProjectionOutcome { + if (!enabled || !input.projectId) return 'disabled'; if ( input.transport === 'legacy_chat' && (!input.raw || typeof input.raw !== 'object' || typeof (input.raw as { step?: unknown }).step !== 'string') ) { - return false; + return 'rejected'; } try { @@ -77,11 +95,13 @@ export function enqueueChatEventProjection( sequence: input.sequence, sourceId: input.sourceId, }); - return getProjectEventStore(input.projectId).enqueue(event); + const store = getProjectEventStore(input.projectId); + if (store.enqueue(event)) return 'accepted'; + return store.getSnapshot().overflowed ? 'overflowed' : 'rejected'; } catch (error) { if (import.meta.env.DEV) { console.warn('[ChatEventProjection] Shadow event was rejected', error); } - return false; + return 'rejected'; } } diff --git a/src/store/pageTabStore.test.ts b/src/store/pageTabStore.test.ts deleted file mode 100644 index eb5b73d3..00000000 --- a/src/store/pageTabStore.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -// ========= 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 { beforeEach, describe, expect, it, vi } from 'vitest'; -import { usePageTabStore } from './pageTabStore'; - -describe('pageTabStore side-panel viewport selection', () => { - beforeEach(() => { - usePageTabStore.setState({ - sidePanelManualUntilByProject: {}, - sidePanelSelectedTurnByProject: {}, - sidePanelViewedTurnByProject: {}, - }); - }); - - it('does not publish duplicate state for repeated observer callbacks', () => { - const listener = vi.fn(); - const unsubscribe = usePageTabStore.subscribe(listener); - - usePageTabStore - .getState() - .setSidePanelViewedTurn('project_one', 'task_one'); - expect(listener).toHaveBeenCalledTimes(1); - - usePageTabStore - .getState() - .setSidePanelViewedTurn('project_one', 'task_one'); - expect(listener).toHaveBeenCalledTimes(1); - - unsubscribe(); - }); -}); diff --git a/src/store/projectEventStore.ts b/src/store/projectEventStore.ts index 1bd3bdef..0a5e4880 100644 --- a/src/store/projectEventStore.ts +++ b/src/store/projectEventStore.ts @@ -241,6 +241,39 @@ function estimateChatNodeBytes( return bytes; } +/** + * Retain the newest `max` dedupe ids. + * + * This relies on `Object.keys` returning plain string keys in insertion order, + * which holds for every id the system currently mints (uuid4, `chat_step_v1:…`, + * `assistant-final:…`). It would NOT hold for an integer-like id such as + * `"1042"`: V8 emits those first in ascending numeric order regardless of + * insertion, so eviction would drop arbitrary ids and weaken dedupe rather than + * dropping the oldest. Guard the invariant in dev so a future id scheme cannot + * regress this silently. + */ +function retainNewestEventIds( + seenEventIds: Record, + max: number +): string[] { + const eventIds = Object.keys(seenEventIds); + if (import.meta.env.DEV && eventIds.length > max) { + const integerLike = eventIds.find((eventId) => + /^(0|[1-9]\d*)$/.test(eventId) + ); + if (integerLike !== undefined) { + console.warn( + '[ProjectEventStore] Integer-like event id breaks insertion-order ' + + 'eviction; dedupe may drop the wrong ids', + { eventId: integerLike } + ); + } + } + return eventIds.length > max + ? eventIds.slice(eventIds.length - max) + : eventIds; +} + function compactView( view: ProjectViewState, maxSeenEventIds: number, @@ -249,10 +282,10 @@ function compactView( maxLegacyBytes: number ): ProjectViewState { const eventIds = Object.keys(view.seenEventIds); - const retainedEventIds = - eventIds.length > maxSeenEventIds - ? eventIds.slice(eventIds.length - maxSeenEventIds) - : eventIds; + const retainedEventIds = retainNewestEventIds( + view.seenEventIds, + maxSeenEventIds + ); const seenEventIds = retainedEventIds.length === eventIds.length ? view.seenEventIds @@ -301,10 +334,10 @@ function compactChatProjection( maxUnknownEvents: number ): ChatProjectionState { const eventIds = Object.keys(state.seenEventIds); - const retainedEventIds = - eventIds.length > maxSeenEventIds - ? eventIds.slice(eventIds.length - maxSeenEventIds) - : eventIds; + const retainedEventIds = retainNewestEventIds( + state.seenEventIds, + maxSeenEventIds + ); const seenEventIds = retainedEventIds.length === eventIds.length ? state.seenEventIds @@ -531,6 +564,7 @@ export class ProjectEventStore { private queueBytes = 0; private cancelScheduledFlush: CancelScheduledFlush | null = null; private disposed = false; + private incarnation = 0; private snapshotReplacementGeneration = 0; private activeSnapshotReplacement: { generation: number; @@ -620,6 +654,10 @@ export class ProjectEventStore { getControlSnapshot = (): HumanControlProjectionState => this.snapshot.control; + getIncarnation(): number { + return this.incarnation; + } + getPendingEventCount(): number { return this.queue.length; } @@ -899,6 +937,24 @@ export class ProjectEventStore { ); } + /** Clear one runtime projection while preserving same-id subscribers. */ + reset(): void { + if (this.disposed) return; + this.cancelScheduledFlush?.(); + this.cancelScheduledFlush = null; + this.activeSnapshotReplacement = null; + this.clearQueue(); + this.incarnation += 1; + this.publish( + createProjectViewState(this.projectId, this.mode), + createChatProjectionState(this.projectId), + createHumanControlProjectionState(this.projectId), + [], + false, + false + ); + } + dispose(): void { if (this.disposed) return; this.disposed = true; @@ -1033,6 +1089,11 @@ export function getProjectEventStore( return created; } +/** Reset an overwritten same-id Project without stranding mounted consumers. */ +export function resetProjectEventStore(projectId: string): void { + projectEventStores.get(projectId)?.reset(); +} + export function releaseProjectEventStore(projectId: string): void { const store = projectEventStores.get(projectId); if (!store) return; diff --git a/src/store/projectStore.ts b/src/store/projectStore.ts index 1091de03..7b1129c7 100644 --- a/src/store/projectStore.ts +++ b/src/store/projectStore.ts @@ -43,7 +43,10 @@ import { type DurableRunDisplayStatus, } from './chatStore'; import { usePageTabStore } from './pageTabStore'; -import { releaseProjectEventStore } from './projectEventStore'; +import { + releaseProjectEventStore, + resetProjectEventStore, +} from './projectEventStore'; import { projectMetaFromServer, useSpaceStore, @@ -391,7 +394,10 @@ interface ProjectStore { setProjectSpace: (projectId: string, spaceId: string) => void; upsertProjectsFromServer: (serverProjects: ServerProject[]) => void; cleanupAutoCreatedEmptyProjects: () => void; - removeProject: (projectId: string) => void; + removeProject: ( + projectId: string, + options?: { preserveEventStore?: boolean } + ) => void; updateProject: ( projectId: string, updates: Partial> @@ -909,6 +915,12 @@ const projectStore = create()((set, get) => ({ }; }); + // Publish Project removal before disposing its event-store subscribers so + // mounted consumers are already scheduled to unmount from this runtime. + for (const projectId of projectIdsToRemove) { + releaseProjectEventStore(projectId); + } + console.warn( `[ProjectStore] Removed ${projectIdsToRemove.length} auto-created empty Project(s).` ); @@ -1156,7 +1168,10 @@ const projectStore = create()((set, get) => ({ get()._evictProjectRuntime(previousProjectId); }, - removeProject: (projectId: string) => { + removeProject: ( + projectId: string, + options?: { preserveEventStore?: boolean } + ) => { const { activeProjectId, projects } = get(); if (!projects[projectId]) { @@ -1186,7 +1201,11 @@ const projectStore = create()((set, get) => ({ staleProjectIds: nextStale, }; }); - releaseProjectEventStore(projectId); + if (options?.preserveEventStore) { + resetProjectEventStore(projectId); + } else { + releaseProjectEventStore(projectId); + } usePageTabStore.getState().removeSessionPreviewProject(projectId); useSpaceStore.getState().removeProjectMeta(projectId); }, @@ -1253,7 +1272,7 @@ const projectStore = create()((set, get) => ({ if (projectId) { if (projects[projectId]) { console.log(`[ProjectStore] Overwriting existing project ${projectId}`); - removeProject(projectId); + removeProject(projectId, { preserveEventStore: true }); } // Create project with the specific naming replayProjectId = createProject( @@ -1358,7 +1377,7 @@ const projectStore = create()((set, get) => ({ console.log( `[ProjectStore] Overwriting existing project ${projectId} for load` ); - removeProject(projectId); + removeProject(projectId, { preserveEventStore: true }); } const loadProjectId = createProject( diff --git a/test/setup.ts b/test/setup.ts index c47cd009..31111fcc 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -19,7 +19,7 @@ import { vi } from 'vitest'; // Mock react-i18next vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string) => { + t: (key: string, options: Record = {}) => { // Map translation keys to English text const translations: Record = { 'chat.welcome-to-eigent': 'Welcome to Eigent', @@ -40,8 +40,23 @@ vi.mock('react-i18next', () => ({ 'Please Help Organize My Desktop', 'chat.no-reply-received-task-continue': 'No reply received, task will continue', + 'chat.repeated-tool-events': '{{tool}} · {{count}} events', + 'chat.repeated-tool-calls-label': 'Repeated tool calls: {{tool}}', + 'chat.repeated-tool-failed': '{{count}} failed', + 'chat.repeated-tool-completed-progress': + '{{completed}}/{{count}} completed', + 'chat.repeated-tool-cancelled': '{{count}} cancelled', + 'chat.tool-status-completed': 'Completed', + 'chat.tool-status-failed': 'Failed', + 'chat.tool-status-cancelled': 'Cancelled', + 'chat.tool-status-running': 'Running', + 'chat.tool-status-pending': 'Pending', + 'chat.tool-status-unknown': 'Unknown', }; - return translations[key] || key; + return (translations[key] || String(options.defaultValue || key)).replace( + /{{(\w+)}}/g, + (_match, name: string) => String(options[name] ?? '') + ); }, i18n: { language: 'en', diff --git a/src/components/ChatBox/BottomBox/BottomBox.test.tsx b/test/unit/components/ChatBox/BottomBox/BottomBox.test.tsx similarity index 84% rename from src/components/ChatBox/BottomBox/BottomBox.test.tsx rename to test/unit/components/ChatBox/BottomBox/BottomBox.test.tsx index 136b4163..c74544cb 100644 --- a/src/components/ChatBox/BottomBox/BottomBox.test.tsx +++ b/test/unit/components/ChatBox/BottomBox/BottomBox.test.tsx @@ -12,11 +12,11 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import BottomBox, { type BottomBoxProps } from '@/components/ChatBox/BottomBox'; import { fireEvent, render, screen, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import BottomBox, { type BottomBoxProps } from '.'; -vi.mock('./BoxFooter', () => ({ +vi.mock('@/components/ChatBox/BottomBox/BoxFooter', () => ({ BoxFooter: ({ disabled }: { disabled?: boolean }) => (
({ ), })); -vi.mock('./InputBox', () => ({ - Inputbox: ({ value }: { value?: string }) => ( -
{value}
+vi.mock('@/components/ChatBox/BottomBox/InputBox', () => ({ + Inputbox: ({ + value, + files = [], + header, + onFilesChange, + }: { + value?: string; + files?: { fileName: string; filePath: string }[]; + header?: { + eyebrow?: string; + title?: string; + description?: string; + }; + onFilesChange?: (files: { fileName: string; filePath: string }[]) => void; + }) => ( +
+ {header && (header.eyebrow || header.title || header.description) ? ( +
+ {header.eyebrow} + {header.title} + {header.description} +
+ ) : null} + {value} + {files.map((file) => ( +
+ {file.fileName} +
+ ))} +
), })); -vi.mock('./PickerPanel', () => ({ +vi.mock('@/components/ChatBox/BottomBox/PickerPanel', () => ({ ConnectorPickerPanel: () =>
, SkillPickerPanel: () =>
, })); @@ -59,7 +96,6 @@ describe('BottomBox structure', () => { const root = container.querySelector('[data-bottom-box]'); const query = container.querySelector('[data-bottom-box-query]'); const main = container.querySelector('[data-bottom-box-main]'); - const header = container.querySelector('[data-bottom-box-header]'); const input = container.querySelector('[data-bottom-box-input]'); const footer = container.querySelector('[data-bottom-box-footer]'); @@ -67,10 +103,12 @@ describe('BottomBox structure', () => { expect(query).toBeInTheDocument(); expect(main).toBeInTheDocument(); expect(root?.firstElementChild).toBe(query); - expect(main).toContainElement(header); expect(main).toContainElement(input); expect(main).toContainElement(footer); expect(input).toHaveAttribute('data-variant', 'input'); + expect( + main?.querySelector(':scope > [data-bottom-box-header]') + ).not.toBeInTheDocument(); expect(screen.getByTestId('text-composer')).toHaveTextContent( 'Draft query' ); @@ -81,6 +119,34 @@ describe('BottomBox structure', () => { expect(onFilesChange).toHaveBeenCalledWith([]); }); + it('renders the composer question inside InputBox instead of BoxHeader', () => { + const { container } = render( + + ); + + const main = container.querySelector('[data-bottom-box-main]'); + const input = container.querySelector('[data-bottom-box-input]'); + const header = input?.querySelector('[data-bottom-box-header]'); + + expect( + main?.querySelector(':scope > [data-bottom-box-header]') + ).not.toBeInTheDocument(); + expect(header).toBeInTheDocument(); + expect(header).toHaveTextContent('Input required'); + expect(header).toHaveTextContent('Which format should I use?'); + }); + it('routes a confirmation request and keeps the project footer mounted', () => { const onConfirm = vi.fn(); const onReject = vi.fn(); diff --git a/src/components/ChatBox/BottomBox/QueuedBox.test.tsx b/test/unit/components/ChatBox/BottomBox/QueuedBox.test.tsx similarity index 97% rename from src/components/ChatBox/BottomBox/QueuedBox.test.tsx rename to test/unit/components/ChatBox/BottomBox/QueuedBox.test.tsx index c48ad48a..fa90a8ac 100644 --- a/src/components/ChatBox/BottomBox/QueuedBox.test.tsx +++ b/test/unit/components/ChatBox/BottomBox/QueuedBox.test.tsx @@ -15,7 +15,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { QueuedBox } from './QueuedBox'; +import { QueuedBox } from '@/components/ChatBox/BottomBox/QueuedBox'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ diff --git a/src/components/ChatBox/BottomBox/useEventNativeHumanControl.test.tsx b/test/unit/components/ChatBox/BottomBox/useEventNativeHumanControl.test.tsx similarity index 99% rename from src/components/ChatBox/BottomBox/useEventNativeHumanControl.test.tsx rename to test/unit/components/ChatBox/BottomBox/useEventNativeHumanControl.test.tsx index cf8c8be8..c76c284d 100644 --- a/src/components/ChatBox/BottomBox/useEventNativeHumanControl.test.tsx +++ b/test/unit/components/ChatBox/BottomBox/useEventNativeHumanControl.test.tsx @@ -12,17 +12,17 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { + getStableDecisionRequestId, + STABLE_DECISION_REQUEST_ID_CACHE_LIMIT, + useEventNativeHumanControl, +} from '@/components/ChatBox/BottomBox/useEventNativeHumanControl'; import type { HumanControlInteraction, HumanControlProjectionState, } from '@/lib/projector/control'; import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - getStableDecisionRequestId, - STABLE_DECISION_REQUEST_ID_CACHE_LIMIT, - useEventNativeHumanControl, -} from './useEventNativeHumanControl'; const mocks = vi.hoisted(() => ({ projection: null as HumanControlProjectionState | null, diff --git a/src/components/ChatBox/EventNativeProjectTimeline.test.tsx b/test/unit/components/ChatBox/EventNativeProjectTimeline.test.tsx similarity index 60% rename from src/components/ChatBox/EventNativeProjectTimeline.test.tsx rename to test/unit/components/ChatBox/EventNativeProjectTimeline.test.tsx index 5c29cbba..c023d2c0 100644 --- a/src/components/ChatBox/EventNativeProjectTimeline.test.tsx +++ b/test/unit/components/ChatBox/EventNativeProjectTimeline.test.tsx @@ -24,8 +24,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { EventNativeProjectTimeline, + isChatTimelineNearBottom, prepareEventNativeTimelineWindow, -} from './EventNativeProjectTimeline'; +} from '@/components/ChatBox/EventNativeProjectTimeline'; const mocks = vi.hoisted(() => ({ projection: null as ChatProjectionState | null, @@ -42,8 +43,28 @@ vi.mock('@/hooks/useProjectEventView', () => ({ vi.mock('@/hooks/useProjectEventStoreHydration', () => ({ useProjectEventStoreHydration: () => mocks.hydration, })); +vi.mock('framer-motion', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + animate: vi.fn( + ( + _from: number, + to: number, + options: { onComplete?: () => void; onUpdate?: (value: number) => void } + ) => { + options.onUpdate?.(to); + options.onComplete?.(); + return { stop: vi.fn() }; + } + ), + }; +}); -function messageNode(index: number): ChatMessageNode { +function messageNode( + index: number, + role: ChatMessageNode['role'] = 'assistant' +): ChatMessageNode { return { id: `message-${index}`, eventId: `event-${index}`, @@ -55,12 +76,43 @@ function messageNode(index: number): ChatMessageNode { eventType: 'message.completed', legacyStep: null, kind: 'message', - role: 'assistant', + role, content: `Message ${index}`, status: 'complete', }; } +function createScrollContainer(options?: { + clientHeight?: number; + scrollHeight?: number; + scrollTop?: number; +}) { + const el = document.createElement('div'); + let scrollTop = options?.scrollTop ?? 0; + const clientHeight = options?.clientHeight ?? 400; + const scrollHeight = options?.scrollHeight ?? 2000; + Object.defineProperties(el, { + clientHeight: { get: () => clientHeight }, + scrollHeight: { get: () => scrollHeight }, + scrollTop: { + get: () => scrollTop, + set: (value: number) => { + scrollTop = value; + }, + }, + }); + el.scrollTo = vi.fn((arg?: ScrollToOptions | number, y?: number) => { + if (typeof arg === 'number') { + scrollTop = y ?? arg; + return; + } + if (arg && typeof arg.top === 'number') { + scrollTop = arg.top; + } + }); + return el; +} + function interactionNode( eventId: string, status: 'requested' | 'responded', @@ -95,6 +147,14 @@ function projection(nodes: ChatProjectionState['nodes']): ChatProjectionState { }; } +describe('isChatTimelineNearBottom', () => { + it('treats the composer inset as still pinned, not a 120px padding zone', () => { + expect(isChatTimelineNearBottom(150, 200)).toBe(true); + expect(isChatTimelineNearBottom(120, 128)).toBe(true); + expect(isChatTimelineNearBottom(400, 200)).toBe(false); + }); +}); + describe('EventNativeProjectTimeline', () => { beforeEach(() => { mocks.projection = projection([]); @@ -103,6 +163,13 @@ describe('EventNativeProjectTimeline', () => { errorCode: null, eventsTruncated: false, }; + if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + } }); it('renders semantic event nodes through the event timeline', () => { @@ -279,4 +346,118 @@ describe('EventNativeProjectTimeline', () => { screen.getByText(/earlier history is outside this local window/) ).toBeInTheDocument(); }); + + it('pins to a new user task even when the user was reading earlier history', () => { + const scrollContainer = createScrollContainer({ + scrollTop: 0, + clientHeight: 400, + scrollHeight: 2000, + }); + const scrollContainerRef = { current: scrollContainer }; + mocks.projection = projection([messageNode(0)]); + + const { rerender } = render( + + ); + + expect(scrollContainer.scrollTo).toHaveBeenCalled(); + vi.mocked(scrollContainer.scrollTo).mockClear(); + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event('scroll')); + + mocks.projection = projection([messageNode(0), messageNode(1, 'user')]); + rerender( + + ); + + expect(scrollContainer.scrollTo).toHaveBeenCalledWith({ + top: 2000, + behavior: 'auto', + }); + }); + + it('anchors the second user query below the Session header gap', () => { + const scrollContainer = createScrollContainer({ + scrollTop: 500, + clientHeight: 400, + scrollHeight: 2000, + }); + const scrollContainerRef = { current: scrollContainer }; + mocks.projection = projection([ + messageNode(0, 'user'), + messageNode(1, 'assistant'), + ]); + + const { rerender } = render( + + ); + + vi.mocked(scrollContainer.scrollTo).mockClear(); + scrollContainer.scrollTop = 500; + scrollContainer.dispatchEvent(new Event('scroll')); + + mocks.projection = projection([ + messageNode(0, 'user'), + messageNode(1, 'assistant'), + messageNode(2, 'user'), + ]); + rerender( + + ); + + expect(scrollContainer.scrollTop).toBe(456); + expect(scrollContainer.scrollTo).not.toHaveBeenCalledWith({ + top: 2000, + behavior: 'auto', + }); + }); + + it('does not follow a new assistant event when the user has scrolled up', () => { + const scrollContainer = createScrollContainer({ + scrollTop: 0, + clientHeight: 400, + scrollHeight: 2000, + }); + const scrollContainerRef = { current: scrollContainer }; + mocks.projection = projection([messageNode(0)]); + + const { rerender } = render( + + ); + + vi.mocked(scrollContainer.scrollTo).mockClear(); + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event('scroll')); + + mocks.projection = projection([messageNode(0), messageNode(1)]); + rerender( + + ); + + expect(scrollContainer.scrollTo).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/ChatBox/EventTimeline/EventTimeline.test.tsx b/test/unit/components/ChatBox/EventTimeline/EventTimeline.test.tsx similarity index 80% rename from src/components/ChatBox/EventTimeline/EventTimeline.test.tsx rename to test/unit/components/ChatBox/EventTimeline/EventTimeline.test.tsx index 03b1c662..a6cfe9d2 100644 --- a/src/components/ChatBox/EventTimeline/EventTimeline.test.tsx +++ b/test/unit/components/ChatBox/EventTimeline/EventTimeline.test.tsx @@ -13,16 +13,16 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import type { ChatProjectionNode } from '@/lib/projector/chat'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { EventRenderer } from './EventRenderer'; -import { EventTimeline } from './EventTimeline'; -import { createChatTimelinePresentationPolicyRegistry } from './presentationPolicy'; +import { EventRenderer } from '@/components/ChatBox/EventTimeline/EventRenderer'; +import { EventTimeline } from '@/components/ChatBox/EventTimeline/EventTimeline'; +import { createChatTimelinePresentationPolicyRegistry } from '@/components/ChatBox/EventTimeline/presentationPolicy'; import { createEventRendererRegistry, createEventTypeRendererRegistry, -} from './rendererRegistry'; +} from '@/components/ChatBox/EventTimeline/rendererRegistry'; const commonNode = { projectId: 'project-1', @@ -101,7 +101,8 @@ function correlatedHumanReplyNode( function activityNode( id: string, - title: string + title: string, + overrides: Partial> = {} ): Extract { return { ...commonNode, @@ -112,6 +113,7 @@ function activityNode( activityType: 'tool', status: 'completed', title, + ...overrides, }; } @@ -304,6 +306,176 @@ describe('EventTimeline', () => { expect(card).toHaveTextContent('Quarterly metrics'); }); + it('groups consecutive duplicate tool calls behind an optional accordion', () => { + render( + + ); + + const group = screen.getByLabelText( + 'Repeated tool calls: WebFetchToolkit · Web_fetch_and_analyze' + ); + const trigger = screen.getByRole('button', { + name: /WebFetchToolkit · Web_fetch_and_analyze · 3 events/, + }); + + expect(screen.getAllByRole('listitem')).toHaveLength(1); + expect(group).toHaveAttribute('data-tool-call-count', '3'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect( + screen.queryByText('WebFetchToolkit · Web_fetch_and_analyze') + ).not.toBeInTheDocument(); + + fireEvent.click(trigger); + + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect( + screen.getAllByText('WebFetchToolkit · Web_fetch_and_analyze') + ).toHaveLength(3); + expect(screen.getByText('Second result')).toBeInTheDocument(); + }); + + it('keeps one tool call as a normal activity row without an accordion', () => { + render( + + ); + + expect(screen.getByText('Fetch one page')).toBeInTheDocument(); + expect( + screen.queryByLabelText(/Repeated tool calls:/) + ).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('does not group matching calls across a chronological boundary', () => { + render( + + ); + + expect(screen.getAllByRole('listitem')).toHaveLength(3); + expect( + screen.queryByLabelText(/Repeated tool calls:/) + ).not.toBeInTheDocument(); + }); + + it('counts paired lifecycle events as calls rather than event frames', () => { + const sourceNodes = [ + activityNode('fetch-1-start', 'Fetch first page', { + eventType: 'tool.started', + status: 'running', + toolkitName: 'WebFetchToolkit', + methodName: 'Web_fetch_and_analyze', + toolCallId: 'fetch-call-1', + detail: 'First request', + }), + activityNode('fetch-1-end', 'Fetch first page', { + eventType: 'tool.completed', + status: 'completed', + toolkitName: 'WebFetchToolkit', + methodName: 'Web_fetch_and_analyze', + toolCallId: 'fetch-call-1', + detail: 'First response', + }), + activityNode('fetch-2-start', 'Fetch second page', { + eventType: 'tool.started', + status: 'running', + toolkitName: 'WebFetchToolkit', + methodName: 'Web_fetch_and_analyze', + toolCallId: 'fetch-call-2', + }), + activityNode('fetch-2-end', 'Fetch second page', { + eventType: 'tool.completed', + status: 'completed', + toolkitName: 'WebFetchToolkit', + methodName: 'Web_fetch_and_analyze', + toolCallId: 'fetch-call-2', + }), + ] as const; + + render(); + + const group = screen.getByLabelText( + 'Repeated tool calls: WebFetchToolkit · Web_fetch_and_analyze' + ); + expect(group).toHaveAttribute('data-tool-call-count', '2'); + expect(group).toHaveTextContent( + 'WebFetchToolkit · Web_fetch_and_analyze · 2 events' + ); + expect(sourceNodes).toHaveLength(4); + expect(sourceNodes[0].status).toBe('running'); + + fireEvent.click(screen.getByRole('button')); + expect( + screen.getByText(/First request\s+First response/) + ).toBeInTheDocument(); + }); + + it('preserves an open repeated-call accordion as late calls arrive', () => { + const first = activityNode('late-fetch-1', 'First call', { + toolkitName: 'WebFetchToolkit', + methodName: 'Web_fetch_and_analyze', + }); + const second = activityNode('late-fetch-2', 'Second call', { + toolkitName: 'WebFetchToolkit', + methodName: 'Web_fetch_and_analyze', + }); + const third = activityNode('late-fetch-3', 'Third call', { + toolkitName: 'WebFetchToolkit', + methodName: 'Web_fetch_and_analyze', + }); + const view = render(); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'); + + view.rerender(); + + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'); + expect( + screen.getAllByText('WebFetchToolkit · Web_fetch_and_analyze') + ).toHaveLength(3); + expect(screen.getByLabelText(/Repeated tool calls:/)).toHaveAttribute( + 'data-tool-call-count', + '3' + ); + }); + it('never merges interaction receipts across ids or runs', () => { render( ; + +function toolNode( + id: string, + overrides: Partial = {} +): ActivityNode { + return { + id, + eventId: `event-${id}`, + projectId: 'project-1', + runId: 'run-1', + createdAt: '2026-08-13T00:00:00Z', + runSequence: 1, + cloudCursor: null, + eventType: 'tool.completed', + legacyStep: null, + kind: 'activity', + activityType: 'tool', + status: 'completed', + title: 'WebFetchToolkit.Web_fetch_and_analyze', + toolkitName: 'WebFetchToolkit', + methodName: 'Web_fetch_and_analyze', + agentId: 'agent-1', + ...overrides, + }; +} + +describe('groupRepeatedToolCalls', () => { + it('groups exact consecutive calls while preserving the source nodes', () => { + const nodes = [toolNode('one'), toolNode('two'), toolNode('three')]; + const rows = groupRepeatedToolCalls(nodes); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + rowKind: 'repeated-tool-calls', + toolkitName: 'WebFetchToolkit', + methodName: 'Web_fetch_and_analyze', + status: 'completed', + }); + if (rows[0]?.rowKind !== 'repeated-tool-calls') { + throw new Error('Expected repeated tool group'); + } + expect(rows[0].calls).toHaveLength(3); + expect(nodes.map((node) => node.id)).toEqual(['one', 'two', 'three']); + }); + + it('uses FIFO lifecycle pairing when older events have no call id', () => { + const rows = groupRepeatedToolCalls([ + toolNode('start-one', { eventType: 'tool.started', status: 'running' }), + toolNode('start-two', { eventType: 'tool.started', status: 'running' }), + toolNode('end-one'), + toolNode('end-two'), + ]); + + expect(rows).toHaveLength(1); + if (rows[0]?.rowKind !== 'repeated-tool-calls') { + throw new Error('Expected repeated tool group'); + } + expect(rows[0].calls).toHaveLength(2); + expect(rows[0].calls.map((call) => call.nodes.length)).toEqual([2, 2]); + }); + + it('does not group the same method across agents or runs', () => { + const rows = groupRepeatedToolCalls([ + toolNode('agent-one'), + toolNode('agent-two', { agentId: 'agent-2' }), + toolNode('run-two', { agentId: 'agent-2', runId: 'run-2' }), + ]); + + expect(rows).toHaveLength(3); + expect(rows.every((row) => row.rowKind === 'node')).toBe(true); + }); + + it('keeps different methods as independent rows', () => { + const rows = groupRepeatedToolCalls([ + toolNode('fetch'), + toolNode('write', { + methodName: 'Todo_write', + title: 'TodoToolkit.Todo_write', + }), + ]); + + expect(rows).toHaveLength(2); + expect(rows.every((row) => row.rowKind === 'node')).toBe(true); + }); + + it('surfaces failure on an otherwise completed group', () => { + const rows = groupRepeatedToolCalls([ + toolNode('complete'), + toolNode('failed', { eventType: 'tool.failed', status: 'failed' }), + ]); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + rowKind: 'repeated-tool-calls', + status: 'failed', + }); + }); +}); diff --git a/src/components/ChatBox/InterruptedRunBanner.test.tsx b/test/unit/components/ChatBox/InterruptedRunBanner.test.tsx similarity index 70% rename from src/components/ChatBox/InterruptedRunBanner.test.tsx rename to test/unit/components/ChatBox/InterruptedRunBanner.test.tsx index 19c70c1d..d5f52636 100644 --- a/src/components/ChatBox/InterruptedRunBanner.test.tsx +++ b/test/unit/components/ChatBox/InterruptedRunBanner.test.tsx @@ -1,7 +1,21 @@ +// ========= 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 { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { InterruptedRunBanner } from './InterruptedRunBanner'; +import { InterruptedRunBanner } from '@/components/ChatBox/InterruptedRunBanner'; const props = { title: 'Run interrupted', diff --git a/src/components/ChatBox/MessageItem/HumanInteractionCard.test.tsx b/test/unit/components/ChatBox/MessageItem/HumanInteractionCard.test.tsx similarity index 99% rename from src/components/ChatBox/MessageItem/HumanInteractionCard.test.tsx rename to test/unit/components/ChatBox/MessageItem/HumanInteractionCard.test.tsx index 3a1d5709..e65e62bf 100644 --- a/src/components/ChatBox/MessageItem/HumanInteractionCard.test.tsx +++ b/test/unit/components/ChatBox/MessageItem/HumanInteractionCard.test.tsx @@ -13,16 +13,15 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { - decideHumanInteraction, isHumanInteractionStillPending, type HumanInteractionPayload, } from '@/service/humanInteractionApi'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { HumanInteractionCard, isHumanInteractionReadOnly, -} from './HumanInteractionCard'; +} from '@/components/ChatBox/MessageItem/HumanInteractionCard'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ decideHumanInteraction: vi.fn(), diff --git a/src/components/ChatBox/MessageItem/UserMessageCard.test.tsx b/test/unit/components/ChatBox/MessageItem/UserMessageCard.test.tsx similarity index 93% rename from src/components/ChatBox/MessageItem/UserMessageCard.test.tsx rename to test/unit/components/ChatBox/MessageItem/UserMessageCard.test.tsx index 9d29941f..09fc59df 100644 --- a/src/components/ChatBox/MessageItem/UserMessageCard.test.tsx +++ b/test/unit/components/ChatBox/MessageItem/UserMessageCard.test.tsx @@ -15,7 +15,7 @@ import { render } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { UserMessageCard } from './UserMessageCard'; +import { UserMessageCard } from '@/components/ChatBox/MessageItem/UserMessageCard'; describe('UserMessageCard', () => { it('renders as a right-aligned chat bubble with a tighter tail corner', () => { diff --git a/src/components/ChatBox/ProjectSection.test.tsx b/test/unit/components/ChatBox/ProjectSection.test.tsx similarity index 97% rename from src/components/ChatBox/ProjectSection.test.tsx rename to test/unit/components/ChatBox/ProjectSection.test.tsx index e2907cfb..23f78e01 100644 --- a/src/components/ChatBox/ProjectSection.test.tsx +++ b/test/unit/components/ChatBox/ProjectSection.test.tsx @@ -15,8 +15,8 @@ import { AgentStep } from '@/types/constants'; import { describe, expect, it } from 'vitest'; -import { groupMessagesByQuery } from './ProjectSection'; -import { isUserMessageReplyToAsk } from './UserQueryGroup'; +import { groupMessagesByQuery } from '@/components/ChatBox/ProjectSection'; +import { isUserMessageReplyToAsk } from '@/components/ChatBox/UserQueryGroup'; const userMessage = (id: string, content: string) => ({ id, diff --git a/src/components/ChatBox/UserQueryGroup.test.tsx b/test/unit/components/ChatBox/UserQueryGroup.test.tsx similarity index 89% rename from src/components/ChatBox/UserQueryGroup.test.tsx rename to test/unit/components/ChatBox/UserQueryGroup.test.tsx index 9c939dcf..df398aa7 100644 --- a/src/components/ChatBox/UserQueryGroup.test.tsx +++ b/test/unit/components/ChatBox/UserQueryGroup.test.tsx @@ -17,42 +17,42 @@ import { AgentStep, ChatTaskStatus, SessionMode } from '@/types/constants'; import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { groupMessagesByQuery } from './ProjectSection'; -import { UserQueryGroup } from './UserQueryGroup'; +import { groupMessagesByQuery } from '@/components/ChatBox/ProjectSection'; +import { UserQueryGroup } from '@/components/ChatBox/UserQueryGroup'; vi.mock('@/store/pageTabStore', () => ({ usePageTabStore: (selector: (state: any) => unknown) => selector({ openFilePreview: vi.fn() }), })); -vi.mock('./MessageItem/TaskWorkLogAccordion', () => ({ +vi.mock('@/components/ChatBox/MessageItem/TaskWorkLogAccordion', () => ({ getTaskRunDisplayStatus: () => undefined, TaskWorkLogAccordion: ({ taskId }: { taskId: string }) => (
), })); -vi.mock('./MessageItem/UserMessageCard', () => ({ +vi.mock('@/components/ChatBox/MessageItem/UserMessageCard', () => ({ UserMessageCard: ({ content }: { content: string }) =>
{content}
, })); -vi.mock('./MessageItem/AgentMessageCard', () => ({ +vi.mock('@/components/ChatBox/MessageItem/AgentMessageCard', () => ({ AgentMessageCard: ({ content }: { content: string }) =>
{content}
, })); -vi.mock('./MessageItem/PreparingToExecuteTasks', () => ({ +vi.mock('@/components/ChatBox/MessageItem/PreparingToExecuteTasks', () => ({ PreparingToExecuteTasks: () =>
, })); -vi.mock('./MessageItem/NoticeCard', () => ({ +vi.mock('@/components/ChatBox/MessageItem/NoticeCard', () => ({ NoticeCard: () =>
, })); -vi.mock('./TaskBox/TaskCard', () => ({ +vi.mock('@/components/ChatBox/TaskBox/TaskCard', () => ({ TaskCard: () =>
, })); -vi.mock('./TaskBox/PlanTaskBox', () => ({ +vi.mock('@/components/ChatBox/TaskBox/PlanTaskBox', () => ({ PlanTaskBox: () =>
, })); diff --git a/test/unit/components/ChatBox/chatTimelineScroll.test.ts b/test/unit/components/ChatBox/chatTimelineScroll.test.ts new file mode 100644 index 00000000..70f7207a --- /dev/null +++ b/test/unit/components/ChatBox/chatTimelineScroll.test.ts @@ -0,0 +1,85 @@ +// ========= 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 { + animateChatTimelineAnchor, + CHAT_QUERY_HEADER_GAP_PX, + CHAT_QUERY_SCROLL_DURATION_SECONDS, + getChatTimelineAnchorScrollTop, +} from '@/components/ChatBox/chatTimelineScroll'; +import { animate } from 'framer-motion'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('framer-motion', () => ({ + animate: vi.fn( + ( + _from: number, + to: number, + options: { onComplete?: () => void; onUpdate: (value: number) => void } + ) => { + options.onUpdate(to); + options.onComplete?.(); + return { stop: vi.fn() }; + } + ), +})); + +describe('chat timeline query anchoring', () => { + it('places the query below the viewport top with the default 44px gap', () => { + expect(CHAT_QUERY_HEADER_GAP_PX).toBe(44); + expect( + getChatTimelineAnchorScrollTop({ + containerTop: 44, + currentScrollTop: 500, + targetTop: 164, + }) + ).toBe(576); + }); + + it('never requests a negative scroll position', () => { + expect( + getChatTimelineAnchorScrollTop({ + containerTop: 44, + currentScrollTop: 0, + targetTop: 50, + }) + ).toBe(0); + }); + + it('reserves enough content height for short replies to keep the alignment', () => { + const container = document.createElement('div'); + const target = document.createElement('div'); + const content = document.createElement('div'); + Object.defineProperties(container, { + clientHeight: { value: 400 }, + scrollTop: { value: 500, writable: true }, + }); + container.getBoundingClientRect = vi.fn(() => ({ top: 44 }) as DOMRect); + target.getBoundingClientRect = vi.fn(() => ({ top: 164 }) as DOMRect); + container.scrollTo = vi.fn(); + + animateChatTimelineAnchor(container, target, content); + + expect(content.style.minHeight).toBe('976px'); + expect(container.scrollTop).toBe(576); + expect(animate).toHaveBeenCalledWith( + 500, + 576, + expect.objectContaining({ + duration: CHAT_QUERY_SCROLL_DURATION_SECONDS, + onUpdate: expect.any(Function), + }) + ); + }); +}); diff --git a/src/components/ChatBox/humanQuestionPersistence.test.ts b/test/unit/components/ChatBox/humanQuestionPersistence.test.ts similarity index 100% rename from src/components/ChatBox/humanQuestionPersistence.test.ts rename to test/unit/components/ChatBox/humanQuestionPersistence.test.ts diff --git a/test/unit/components/ChatBox/runControlArbitration.test.ts b/test/unit/components/ChatBox/runControlArbitration.test.ts new file mode 100644 index 00000000..5da9759b --- /dev/null +++ b/test/unit/components/ChatBox/runControlArbitration.test.ts @@ -0,0 +1,209 @@ +// ========= 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 { + selectActionableInterruptedRun, + selectEventNativeActiveRunId, +} from '@/components/ChatBox/runControlArbitration'; +import { createProjectViewState, type ProjectedRun } from '@/lib/projector'; +import { + createChatProjectionState, + type ChatProjectionNode, +} from '@/lib/projector/chat'; +import { + createHumanControlProjectionState, + type HumanControlInteraction, +} from '@/lib/projector/control'; +import type { ProjectEventStoreSnapshot } from '@/store/projectEventStore'; +import { describe, expect, it } from 'vitest'; + +function run( + runId: string, + status: ProjectedRun['status'], + overrides: Partial = {} +): ProjectedRun { + return { + runId, + status, + lastSequence: 2, + runVersion: 2, + updatedAt: '2026-08-12T10:00:00.000Z', + origin: 'local', + resumeBlockedReason: null, + ...overrides, + }; +} + +function canonicalNode(runId: string, eventType = 'run.attempt_started') { + return { + id: `${runId}:${eventType}`, + eventId: `${runId}:${eventType}`, + projectId: 'project-1', + runId, + createdAt: '2026-08-12T10:00:00.000Z', + runSequence: 1, + cloudCursor: null, + eventType, + legacyStep: null, + kind: 'run_status', + status: 'running', + } as ChatProjectionNode; +} + +function request( + interactionId: string, + runId: string, + requestEventType = 'interaction.requested' +) { + return { + interactionId, + runId, + status: 'requested', + requestSource: 'canonical', + requestEventId: `${runId}:${requestEventType}`, + requestEventType, + } as HumanControlInteraction; +} + +function snapshot({ + runs, + nodes = [], + controls = [], + needsResync = false, + eventsTruncated = false, + overflowed = false, +}: { + runs: ProjectedRun[]; + nodes?: ChatProjectionNode[]; + controls?: HumanControlInteraction[]; + needsResync?: boolean; + eventsTruncated?: boolean; + overflowed?: boolean; +}): ProjectEventStoreSnapshot { + const view = createProjectViewState('project-1', 'live'); + const chat = createChatProjectionState('project-1'); + const control = createHumanControlProjectionState('project-1'); + return { + view: { + ...view, + needsResync, + eventsTruncated, + runs: Object.fromEntries(runs.map((item) => [item.runId, item])), + }, + chat: { + ...chat, + nodes, + nodeById: Object.fromEntries(nodes.map((node) => [node.eventId, node])), + }, + control: { + ...control, + orderedInteractionIds: controls.map((item) => item.interactionId), + interactionById: Object.fromEntries( + controls.map((item) => [item.interactionId, item]) + ), + }, + revision: 1, + hasHydratedSnapshot: true, + overflowed, + lastEffects: [], + }; +} + +describe('event-native Run-control arbitration', () => { + it('lets a typed pending control outrank the legacy-owned live Run', () => { + const state = snapshot({ + runs: [run('legacy-live', 'running'), run('input', 'waiting_for_user')], + nodes: [canonicalNode('legacy-live')], + controls: [request('question-1', 'input')], + }); + + expect(selectEventNativeActiveRunId(state, 'legacy-live')).toBe('input'); + }); + + it('does not give aggregate-only or orphan historical Runs controls', () => { + expect( + selectEventNativeActiveRunId( + snapshot({ runs: [run('past', 'running')] }), + 'past' + ) + ).toBeNull(); + expect( + selectEventNativeActiveRunId( + snapshot({ + runs: [run('past', 'running')], + nodes: [canonicalNode('past')], + }), + null + ) + ).toBeNull(); + }); + + it('fails closed for truncated, resyncing, overflowed, and gapped state', () => { + const pending = run('input', 'waiting_for_user'); + const controls = [request('question-1', 'input')]; + expect( + selectEventNativeActiveRunId( + snapshot({ runs: [pending], controls, eventsTruncated: true }), + null + ) + ).toBeNull(); + expect( + selectEventNativeActiveRunId( + snapshot({ runs: [pending], controls, needsResync: true }), + null + ) + ).toBeNull(); + expect( + selectEventNativeActiveRunId( + snapshot({ runs: [pending], controls, overflowed: true }), + null + ) + ).toBeNull(); + expect( + selectEventNativeActiveRunId( + snapshot({ + runs: [run('input', 'waiting_for_user', { lastSequence: 1 })], + controls, + }), + null + ) + ).toBeNull(); + }); + + it('does not treat a canonical-envelope legacy ASK as typed authority', () => { + const state = snapshot({ + runs: [run('input', 'waiting_for_user')], + controls: [request('question-1', 'input', 'legacy.step')], + }); + + expect(selectEventNativeActiveRunId(state, null)).toBeNull(); + }); + + it('allows only evidenced, actionable local interruptions', () => { + const local = run('local', 'interrupted'); + const blocked = run('blocked', 'interrupted', { + resumeBlockedReason: 'local_workspace_missing', + }); + const state = snapshot({ + runs: [local, blocked], + nodes: [ + canonicalNode(local.runId, 'run.interrupted'), + canonicalNode(blocked.runId, 'run.interrupted'), + ], + }); + + expect(selectActionableInterruptedRun(state, local.runId)).toBe(local); + expect(selectActionableInterruptedRun(state, blocked.runId)).toBeNull(); + }); +}); diff --git a/src/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts b/test/unit/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts similarity index 94% rename from src/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts rename to test/unit/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts index c9a68806..16ad8b86 100644 --- a/src/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts +++ b/test/unit/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts @@ -12,11 +12,11 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import { describe, expect, it } from 'vitest'; import { collectSidePanelOutputFiles, getSidePanelOutputFilesRevision, -} from './collectSidePanelOutputFiles'; +} from '@/components/Session/SidePanelSections/collectSidePanelOutputFiles'; +import { describe, expect, it } from 'vitest'; const reportFile = (): FileInfo => ({ name: 'report.md', diff --git a/src/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx b/test/unit/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx similarity index 98% rename from src/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx rename to test/unit/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx index c2597ffa..5275531f 100644 --- a/src/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx +++ b/test/unit/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx @@ -12,13 +12,13 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { useProjectOutputFiles } from '@/components/Session/SidePanelSections/useProjectOutputFiles'; import { HostProvider } from '@/host'; import { useAuthStore } from '@/store/authStore'; import { ChatTaskStatus } from '@/types/constants'; import { act, renderHook, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useProjectOutputFiles } from './useProjectOutputFiles'; const { fetchGetMock, getBaseURLMock, invokeMock } = vi.hoisted(() => ({ fetchGetMock: vi.fn(), diff --git a/test/unit/components/TaskWorkLogAccordion.test.tsx b/test/unit/components/TaskWorkLogAccordion.test.tsx index 9d0b87b8..8c4fbf2f 100644 --- a/test/unit/components/TaskWorkLogAccordion.test.tsx +++ b/test/unit/components/TaskWorkLogAccordion.test.tsx @@ -13,18 +13,45 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { + TaskWorkLogAccordion, buildAgentBlocks, getBlockHeaderParts, getSingleAgentActiveForm, getTaskRunDisplayStatus, groupBlocksByAgent, + groupConsecutiveToolItems, terminalWorkLogI18nKey, type AgentBlock, type AgentGroup, + type RepeatedToolItem, type TimelineItem, + type ToolItem, } from '@/components/ChatBox/MessageItem/TaskWorkLogAccordion'; -import { AgentStep, TaskStatus, type AgentStepType } from '@/types/constants'; -import { describe, expect, it } from 'vitest'; +import type { VanillaChatStore } from '@/store/chatStore'; +import { + AgentStep, + ChatTaskStatus, + SessionMode, + TaskStatus, + type AgentStepType, +} from '@/types/constants'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('react-i18next', () => ({ + initReactI18next: { + type: '3rdParty', + init: vi.fn(), + }, + Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey, + useTranslation: () => ({ + t: (key: string, options: Record = {}) => + key === 'chat.repeated-tool-events' + ? `${options.tool} · ${options.count} events` + : key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})); type TaggedLog = Parameters[0][number]; @@ -54,6 +81,233 @@ function findMessage(items: TimelineItem[], idx: number) { return messages[idx]; } +function makeToolItem( + id: string, + overrides: Partial> = {} +): ToolItem { + return { + kind: 'tool', + id, + rowTitle: 'Browser Toolkit · Browser visit page', + toolkitName: 'Browser Toolkit', + method: 'Browser visit page', + detail: '', + input: '', + output: '', + status: 'done', + ...overrides, + }; +} + +describe('groupConsecutiveToolItems', () => { + it('keeps a single tool call on the existing row path', () => { + const call = makeToolItem('call-1'); + const result = groupConsecutiveToolItems([call]); + + expect(result).toEqual([call]); + expect(result[0]).toBe(call); + }); + + it('groups adjacent matching toolkit and method calls', () => { + const first = makeToolItem('call-1'); + const second = makeToolItem('call-2', { status: 'running' }); + const result = groupConsecutiveToolItems([first, second]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'repeated-tool', + id: 'repeated-tool:call-1', + rowTitle: 'Browser Toolkit · Browser visit page', + status: 'running', + }); + expect((result[0] as RepeatedToolItem).calls).toEqual([first, second]); + }); + + it('normalizes cosmetic toolkit and method separators', () => { + const first = makeToolItem('call-1'); + const second = makeToolItem('call-2', { + toolkitName: 'browser_toolkit', + method: 'browser-visit-page', + rowTitle: 'browser_toolkit · Browser-visit-page', + }); + + expect(groupConsecutiveToolItems([first, second])).toHaveLength(1); + expect(groupConsecutiveToolItems([first, second])[0]!.kind).toBe( + 'repeated-tool' + ); + }); + + it('treats messages and human-input receipts as chronology boundaries', () => { + const message: TimelineItem = { + kind: 'message', + id: 'message-1', + text: 'Opening the next result', + source: 'reasoning', + running: false, + pairKey: null, + }; + const first = makeToolItem('call-1'); + const second = makeToolItem('call-2'); + + expect(groupConsecutiveToolItems([first, message, second])).toEqual([ + first, + message, + second, + ]); + }); + + it('does not group a different toolkit or method', () => { + const first = makeToolItem('call-1'); + const differentMethod = makeToolItem('call-2', { + method: 'Browser open', + rowTitle: 'Browser Toolkit · Browser open', + }); + const differentToolkit = makeToolItem('call-3', { + toolkitName: 'Search Toolkit', + rowTitle: 'Search Toolkit · Browser visit page', + }); + + expect( + groupConsecutiveToolItems([first, differentMethod, differentToolkit]) + ).toEqual([first, differentMethod, differentToolkit]); + }); + + it('keeps the first-call group identity when another live call arrives', () => { + const calls = [ + makeToolItem('call-1'), + makeToolItem('call-2'), + makeToolItem('call-3'), + ]; + + const firstGroup = groupConsecutiveToolItems(calls.slice(0, 2))[0]; + const updatedGroup = groupConsecutiveToolItems(calls)[0]; + + expect(firstGroup?.id).toBe('repeated-tool:call-1'); + expect(updatedGroup?.id).toBe(firstGroup?.id); + }); + + it('does not mutate the source timeline', () => { + const source = [makeToolItem('call-1'), makeToolItem('call-2')]; + const snapshot = structuredClone(source); + + groupConsecutiveToolItems(source); + + expect(source).toEqual(snapshot); + }); +}); + +describe('TaskWorkLogAccordion repeated tool-call rendering', () => { + function completedCall( + toolkitName: string, + method: string, + request: string, + response: string + ): AgentMessage[] { + return [ + mk(AgentStep.ACTIVATE_TOOLKIT, { + toolkit_name: toolkitName, + method_name: method, + message: request, + }), + mk(AgentStep.DEACTIVATE_TOOLKIT, { + toolkit_name: toolkitName, + method_name: method, + message: response, + }), + ]; + } + + function createWorkLogStore(log: AgentMessage[]): VanillaChatStore { + const state = { + tasks: { + 'task-1': { + status: ChatTaskStatus.RUNNING, + sessionMode: SessionMode.WORKFORCE, + taskTime: 0, + elapsed: 0, + messages: [], + askList: [], + taskAssigning: [ + { + agent_id: 'agent-1', + type: 'browser', + name: 'Researcher', + tasks: [], + log, + }, + ], + }, + }, + }; + + return { + getState: () => state, + subscribe: () => () => undefined, + } as unknown as VanillaChatStore; + } + + it('renders duplicate Browser and Todo calls as expandable count rows', () => { + const log = [ + mk(AgentStep.ACTIVATE_AGENT), + ...completedCall( + 'Browser Toolkit', + 'Browser visit page', + '{"url":"https://example.com/one"}', + 'First page' + ), + ...completedCall( + 'Browser Toolkit', + 'Browser visit page', + '{"url":"https://example.com/two"}', + 'Second page' + ), + ...completedCall( + 'TodoToolkit', + 'Todo_write', + '{"todo":"one"}', + 'Saved first todo' + ), + ...completedCall( + 'TodoToolkit', + 'Todo_write', + '{"todo":"two"}', + 'Saved second todo' + ), + ]; + + render( + + ); + + const browserGroup = screen.getByRole('button', { + name: 'Browser Toolkit · Browser visit page · 2 events', + }); + const todoGroup = screen.getByRole('button', { + name: 'TodoToolkit · Todo_write · 2 events', + }); + + expect(browserGroup).toHaveAttribute('aria-expanded', 'false'); + expect(todoGroup).toHaveAttribute('aria-expanded', 'false'); + expect( + screen.getAllByRole('button', { + name: 'Browser Toolkit · Browser visit page · 2 events', + }) + ).toHaveLength(1); + + fireEvent.click(browserGroup); + + expect(browserGroup).toHaveAttribute('aria-expanded', 'true'); + expect( + screen.getAllByRole('button', { + name: 'Browser Toolkit · Browser visit page', + }) + ).toHaveLength(2); + }); +}); + describe('terminal Run presentation', () => { it('lets a recorded error override a compatibility interrupted status', () => { expect( @@ -898,27 +1152,31 @@ describe('groupBlocksByAgent', () => { expect(group.doneToolCount).toBe(2); }); - it('merges alternating blocks from the same agent (A, B, A) into two groups', () => { + it('preserves alternating blocks from the same agent as A, B, A', () => { const blocks: AgentBlock[] = [ makeBlock('a1', 'dev', 'Dev', [makeTool('t1')], 'done'), makeBlock('a2', 'browser', 'Browser', [makeTool('t2')], 'done'), makeBlock('a1', 'dev', 'Dev', [makeTool('t3')], 'running'), ]; const result = groupBlocksByAgent(blocks); - expect(result).toHaveLength(2); + expect(result).toHaveLength(3); const g1 = result[0] as AgentGroup; expect(g1.kind).toBe('agent-group'); expect(g1.agentId).toBe('a1'); - expect(g1.items).toHaveLength(2); - expect(g1.items.map((i) => i.id)).toEqual(['t1', 't3']); - expect(g1.status).toBe('running'); + expect(g1.items.map((i) => i.id)).toEqual(['t1']); + expect(g1.status).toBe('done'); const g2 = result[1] as AgentGroup; expect(g2.kind).toBe('agent-group'); expect(g2.agentId).toBe('a2'); expect(g2.items).toHaveLength(1); expect(g2.status).toBe('done'); + + const g3 = result[2] as AgentGroup; + expect(g3.agentId).toBe('a1'); + expect(g3.items.map((i) => i.id)).toEqual(['t3']); + expect(g3.status).toBe('running'); }); it('preserves the preparation block at its original position', () => { @@ -978,7 +1236,7 @@ describe('groupBlocksByAgent', () => { expect(group.doneToolCount).toBe(0); }); - it('orders groups by first appearance of the agent', () => { + it('preserves chronological group order', () => { const blocks: AgentBlock[] = [ makeBlock('a2', 'browser', 'Browser', [makeTool('t1')], 'done'), makeBlock('a1', 'dev', 'Dev', [makeTool('t2')], 'done'), @@ -986,10 +1244,11 @@ describe('groupBlocksByAgent', () => { makeBlock('a2', 'browser', 'Browser', [makeTool('t4')], 'running'), ]; const result = groupBlocksByAgent(blocks); - expect(result).toHaveLength(3); + expect(result).toHaveLength(4); expect((result[0] as AgentGroup).agentId).toBe('a2'); expect((result[1] as AgentGroup).agentId).toBe('a1'); expect((result[2] as AgentGroup).agentId).toBe('a3'); + expect((result[3] as AgentGroup).agentId).toBe('a2'); }); it('integrates with buildAgentBlocks for interleaved multi-agent logs', () => { @@ -1050,11 +1309,15 @@ describe('groupBlocksByAgent', () => { const agentGroups = grouped.filter( (e): e is AgentGroup => e.kind === 'agent-group' ); - expect(agentGroups).toHaveLength(2); + expect(agentGroups).toHaveLength(3); expect(agentGroups[0]!.agentId).toBe('a1'); expect(agentGroups[1]!.agentId).toBe('a2'); + expect(agentGroups[2]!.agentId).toBe('a1'); - const devTools = agentGroups[0]!.items.filter((i) => i.kind === 'tool'); + const devTools = agentGroups + .filter((group) => group.agentId === 'a1') + .flatMap((group) => group.items) + .filter((i) => i.kind === 'tool'); expect(devTools).toHaveLength(2); }); }); diff --git a/src/components/WorkspaceBundle/AgentPluginImportWizard.test.tsx b/test/unit/components/WorkspaceBundle/AgentPluginImportWizard.test.tsx similarity index 99% rename from src/components/WorkspaceBundle/AgentPluginImportWizard.test.tsx rename to test/unit/components/WorkspaceBundle/AgentPluginImportWizard.test.tsx index b1b7dfe1..97bb54b9 100644 --- a/src/components/WorkspaceBundle/AgentPluginImportWizard.test.tsx +++ b/test/unit/components/WorkspaceBundle/AgentPluginImportWizard.test.tsx @@ -74,8 +74,8 @@ vi.mock('@/store/projectRuntimeStore', () => ({ selector({ setActiveProject: mocks.setActiveProject }), })); +import { AgentPluginImportWizard } from '@/components/WorkspaceBundle/AgentPluginImportWizard'; import type { AgentPluginInspection } from '@/service/agentPluginImportApi'; -import { AgentPluginImportWizard } from './AgentPluginImportWizard'; const REVIEW_DIGEST = 'a'.repeat(64); diff --git a/src/components/WorkspaceBundle/WorkspaceBundleInstallWizard.test.tsx b/test/unit/components/WorkspaceBundle/WorkspaceBundleInstallWizard.test.tsx similarity index 99% rename from src/components/WorkspaceBundle/WorkspaceBundleInstallWizard.test.tsx rename to test/unit/components/WorkspaceBundle/WorkspaceBundleInstallWizard.test.tsx index 15acfcb2..5580a4a4 100644 --- a/src/components/WorkspaceBundle/WorkspaceBundleInstallWizard.test.tsx +++ b/test/unit/components/WorkspaceBundle/WorkspaceBundleInstallWizard.test.tsx @@ -116,8 +116,8 @@ vi.mock('@/store/pageTabStore', () => ({ selector({ setActiveWorkspaceTab: mocks.setActiveWorkspaceTab }), })); +import { WorkspaceBundleInstallWizard } from '@/components/WorkspaceBundle/WorkspaceBundleInstallWizard'; import type { WorkspaceBundleInstallSnapshot } from '@/service/workspaceBundleInstallApi'; -import { WorkspaceBundleInstallWizard } from './WorkspaceBundleInstallWizard'; const manifest = { apiVersion: 'eigent.ai/v1alpha1', diff --git a/src/components/WorkspaceConfiguration/EnvironmentRequirementsEditor.test.tsx b/test/unit/components/WorkspaceConfiguration/EnvironmentRequirementsEditor.test.tsx similarity index 76% rename from src/components/WorkspaceConfiguration/EnvironmentRequirementsEditor.test.tsx rename to test/unit/components/WorkspaceConfiguration/EnvironmentRequirementsEditor.test.tsx index 0fc8ba72..46266198 100644 --- a/src/components/WorkspaceConfiguration/EnvironmentRequirementsEditor.test.tsx +++ b/test/unit/components/WorkspaceConfiguration/EnvironmentRequirementsEditor.test.tsx @@ -1,9 +1,23 @@ +// ========= 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 { fireEvent, render, screen } from '@testing-library/react'; import { useState } from 'react'; import { describe, expect, it } from 'vitest'; +import { EnvironmentRequirementsEditor } from '@/components/WorkspaceConfiguration/EnvironmentRequirementsEditor'; import type { WorkspaceEnvironmentVariableRequirement } from '@/service/workspaceConfigurationApi'; -import { EnvironmentRequirementsEditor } from './EnvironmentRequirementsEditor'; function Harness({ initial, diff --git a/src/components/WorkspaceConfiguration/WorkspaceBundleSaveDialog.test.tsx b/test/unit/components/WorkspaceConfiguration/WorkspaceBundleSaveDialog.test.tsx similarity index 99% rename from src/components/WorkspaceConfiguration/WorkspaceBundleSaveDialog.test.tsx rename to test/unit/components/WorkspaceConfiguration/WorkspaceBundleSaveDialog.test.tsx index 937c9a1f..5cf4d1be 100644 --- a/src/components/WorkspaceConfiguration/WorkspaceBundleSaveDialog.test.tsx +++ b/test/unit/components/WorkspaceConfiguration/WorkspaceBundleSaveDialog.test.tsx @@ -51,11 +51,11 @@ vi.mock('@/service/workspaceBundleAuthoringApi', () => ({ publishWorkspaceBundleRevision: mocks.publishRevision, })); +import { WorkspaceBundleSaveDialog } from '@/components/WorkspaceConfiguration/WorkspaceBundleSaveDialog'; import type { WorkspaceConfigurationDraft, WorkspaceConfigurationSaveReview, } from '@/service/workspaceConfigurationApi'; -import { WorkspaceBundleSaveDialog } from './WorkspaceBundleSaveDialog'; const digest = 'a'.repeat(64); const cloudDigest = 'c'.repeat(64); diff --git a/src/hooks/useChatStoreAdapter.test.tsx b/test/unit/hooks/useChatStoreAdapter.test.tsx similarity index 98% rename from src/hooks/useChatStoreAdapter.test.tsx rename to test/unit/hooks/useChatStoreAdapter.test.tsx index 8d34104b..9f65f936 100644 --- a/src/hooks/useChatStoreAdapter.test.tsx +++ b/test/unit/hooks/useChatStoreAdapter.test.tsx @@ -12,11 +12,11 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { createChatStoreInstance } from '@/store/chatStore'; import { useProjectStore } from '@/store/projectStore'; import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it } from 'vitest'; -import useChatStoreAdapter from './useChatStoreAdapter'; describe('useChatStoreAdapter', () => { beforeEach(() => { diff --git a/src/hooks/useInterruptedRunStatus.test.tsx b/test/unit/hooks/useInterruptedRunStatus.test.tsx similarity index 99% rename from src/hooks/useInterruptedRunStatus.test.tsx rename to test/unit/hooks/useInterruptedRunStatus.test.tsx index b20af6cb..bab872fe 100644 --- a/src/hooks/useInterruptedRunStatus.test.tsx +++ b/test/unit/hooks/useInterruptedRunStatus.test.tsx @@ -12,6 +12,11 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { + actionableInterruptedRun, + normalizeInterruptedRunState, + useInterruptedRunStatus, +} from '@/hooks/useInterruptedRunStatus'; import { HostProvider } from '@/host'; import { DURABLE_RUN_STATUS_CHANGED_EVENT, @@ -20,11 +25,6 @@ import { import { act, renderHook } from '@testing-library/react'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - actionableInterruptedRun, - normalizeInterruptedRunState, - useInterruptedRunStatus, -} from './useInterruptedRunStatus'; const { fetchGetMock } = vi.hoisted(() => ({ fetchGetMock: vi.fn(), diff --git a/src/hooks/useRemoteControlBridge.followup.test.ts b/test/unit/hooks/useRemoteControlBridge.followup.test.ts similarity index 99% rename from src/hooks/useRemoteControlBridge.followup.test.ts rename to test/unit/hooks/useRemoteControlBridge.followup.test.ts index 638fbb52..4be0979c 100644 --- a/src/hooks/useRemoteControlBridge.followup.test.ts +++ b/test/unit/hooks/useRemoteControlBridge.followup.test.ts @@ -13,13 +13,13 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { fetchGet, fetchPost } from '@/api/http'; +import { __remoteControlBridgeTestHooks } from '@/hooks/useRemoteControlBridge'; import { createFollowUpRequest, listPendingFollowUpRequests, markFollowUpRequestAdmitted, } from '@/service/followUpQueueApi'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { __remoteControlBridgeTestHooks } from './useRemoteControlBridge'; const restoreQueuedMessage = vi.fn(); const removeQueuedMessage = vi.fn(); diff --git a/test/unit/hooks/useRemoteControlBridge.test.ts b/test/unit/hooks/useRemoteControlBridge.test.ts index 868667a9..636d5a65 100644 --- a/test/unit/hooks/useRemoteControlBridge.test.ts +++ b/test/unit/hooks/useRemoteControlBridge.test.ts @@ -49,13 +49,28 @@ vi.mock('@/api/http', () => ({ })); import { fetchGet, fetchPost } from '@/api/http'; -import { __remoteControlBridgeTestHooks } from '@/hooks/useRemoteControlBridge'; +import { + __remoteControlBridgeTestHooks, + ackFromDurableExecution, +} from '@/hooks/useRemoteControlBridge'; import { useProjectStore } from '@/store/projectStore'; import { SPACE_SCHEMA_VERSION, useSpaceStore } from '@/store/spaceStore'; describe('useRemoteControlBridge internals', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(fetchPost).mockImplementation(async (_url, body: any) => ({ + request_id: body?.request_id || 'request-id', + project_id: 'project-target', + content: body?.content || '', + attachment_paths: body?.attachment_paths || [], + delivery_mode: 'wait', + status: 'pending', + source: body?.source || 'remote_control', + source_command_id: body?.source_command_id || null, + created_at: 1, + updated_at: 1, + })); useProjectStore.setState({ activeProjectId: null, projects: {}, @@ -83,32 +98,30 @@ describe('useRemoteControlBridge internals', () => { }); }); - it('durably queues user_message commands for a busy background Project', async () => { + it('allows user_message commands for a non-active Project without switching foreground Project', async () => { const activeProjectId = useProjectStore .getState() .createProject('Active Project', undefined, 'project-active'); - vi.mocked(fetchGet).mockImplementation((path: string) => - Promise.resolve( - path.endsWith('/follow-ups') - ? { - items: [ - { - request_id: 'task-target-next', - project_id: 'project-target', - content: 'Continue the target project in the background', - attachment_paths: [], - delivery_mode: 'wait', - status: 'pending', - source: 'remote_control', - source_command_id: 'rc_cmd_cross_project', - created_at: 1, - updated_at: 1, - }, - ], - } - : { has_lock: true, status: 'running' } - ) + vi.mocked(fetchGet).mockImplementation(async (url) => + url === '/projects/project-target/follow-ups' + ? { + items: [ + { + request_id: 'task-target-next', + project_id: 'project-target', + content: 'Continue the target project in the background', + attachment_paths: [], + delivery_mode: 'wait', + status: 'pending', + source: 'remote_control', + source_command_id: 'rc_cmd_cross_project', + created_at: 1, + updated_at: 1, + }, + ], + } + : { has_lock: true, status: 'running' } ); const ack = await __remoteControlBridgeTestHooks.executeRemoteCommand( @@ -133,17 +146,18 @@ describe('useRemoteControlBridge internals', () => { type: 'command_ack', command_id: 'rc_cmd_cross_project', status: 'acknowledged', + result: { queued: true }, }); expect(useProjectStore.getState().activeProjectId).toBe(activeProjectId); expect(useProjectStore.getState().projects['project-target']).toBeDefined(); expect(fetchGet).toHaveBeenCalledWith( '/projects/project-target/follow-ups' ); - expect(fetchGet).toHaveBeenCalledWith('/chat/project-target/status'); expect(fetchPost).not.toHaveBeenCalledWith( '/chat/project-target', expect.anything() ); + expect(fetchGet).toHaveBeenCalledWith('/chat/project-target/status'); }); it('starts local user_message tasks against the target Project without switching foreground Project', async () => { @@ -167,27 +181,25 @@ describe('useRemoteControlBridge internals', () => { const startTask = vi.fn(() => Promise.resolve()); targetChatStore?.setState({ startTask } as any); - vi.mocked(fetchGet).mockImplementation((path: string) => - Promise.resolve( - path.endsWith('/follow-ups') - ? { - items: [ - { - request_id: 'task-target-next', - project_id: 'project-target', - content: 'Start local background task', - attachment_paths: [], - delivery_mode: 'wait', - status: 'pending', - source: 'remote_control', - source_command_id: 'rc_cmd_local_start', - created_at: 1, - updated_at: 1, - }, - ], - } - : { has_lock: false, status: 'idle' } - ) + vi.mocked(fetchGet).mockImplementation(async (url) => + url === '/projects/project-target/follow-ups' + ? { + items: [ + { + request_id: 'task-target-next', + project_id: 'project-target', + content: 'Start local background task', + attachment_paths: [], + delivery_mode: 'wait', + status: 'pending', + source: 'remote_control', + source_command_id: 'rc_cmd_local_start', + created_at: 1, + updated_at: 1, + }, + ], + } + : { has_lock: false, status: 'idle' } ); const ack = await __remoteControlBridgeTestHooks.executeRemoteCommand( @@ -259,3 +271,79 @@ describe('useRemoteControlBridge internals', () => { expect(project.metadata?.remoteHistoryHydrationPending).toBe(true); }); }); + +describe('remote command durable ACK replay', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('replays the canonical completed outcome without executing again', () => { + expect( + ackFromDurableExecution('command-1', { + event_type: 'execution.completed', + payload: { result: { run_id: 'run-1' } }, + }) + ).toEqual({ + type: 'command_ack', + command_id: 'command-1', + status: 'acknowledged', + result: { run_id: 'run-1' }, + replayed_from_cache: true, + }); + }); + + it('replays the canonical failure rather than an upload error', () => { + expect( + ackFromDurableExecution('command-1', { + event_type: 'execution.failed', + payload: { error_code: 'TOOL_FAILED', error: 'original failure' }, + }) + ).toMatchObject({ + status: 'failed', + error_code: 'TOOL_FAILED', + error: 'original failure', + }); + }); + + it('preserves a queued execution result when restart reconciliation races it', () => { + const command = { + id: 'command-1', + session_id: 'session-1', + user_id: 1, + source_channel: 'remote_control' as const, + type: 'user_message', + target_project_id: 'project-1', + payload: {}, + }; + const completed = { + status: 'completed' as const, + event_id: 'command-1:execution-result', + result: { run_id: 'run-1' }, + }; + + __remoteControlBridgeTestHooks.queuePendingCommandResult({ + command, + body: completed, + }); + const durable = __remoteControlBridgeTestHooks.queuePendingCommandResult({ + command, + body: { + status: 'failed', + event_id: 'command-1:recovery-outcome-unknown', + result: {}, + error_code: 'COMMAND_OUTCOME_UNKNOWN_AFTER_RESTART', + }, + }); + + expect(durable.body).toEqual(completed); + expect( + __remoteControlBridgeTestHooks.ackFromPendingCommandResult( + command.id, + durable.body + ) + ).toMatchObject({ + status: 'acknowledged', + result: { run_id: 'run-1' }, + }); + }); +}); diff --git a/test/unit/lib/chatProjectionActivity.test.ts b/test/unit/lib/chatProjectionActivity.test.ts new file mode 100644 index 00000000..306237db --- /dev/null +++ b/test/unit/lib/chatProjectionActivity.test.ts @@ -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 { adaptChatProjectionEvent } from '@/lib/projector/chat'; +import type { CanonicalProjectEvent } from '@/lib/projector/types'; +import { describe, expect, it } from 'vitest'; + +function event(payload: Record): CanonicalProjectEvent { + return { + eventId: 'tool-event-1', + projectId: 'project-1', + runId: 'run-1', + runSequence: 1, + runVersion: 1, + cloudCursor: 1, + eventType: 'tool.started', + payload, + legacyStep: null, + createdAt: '2026-08-13T00:00:00Z', + source: 'canonical', + raw: payload, + }; +} + +describe('chat activity projection', () => { + it('retains tool identity and backend call correlation for presentation', () => { + const node = adaptChatProjectionEvent( + event({ + toolkit_name: 'WebFetchToolkit', + method_name: 'Web_fetch_and_analyze', + tool_name: 'web_fetch', + tool_call_id: 'call-10', + }) + ); + + expect(node).toMatchObject({ + kind: 'activity', + activityType: 'tool', + status: 'running', + toolkitName: 'WebFetchToolkit', + methodName: 'Web_fetch_and_analyze', + toolName: 'web_fetch', + toolCallId: 'call-10', + }); + }); + + it('accepts nested camel-case tool identity', () => { + const node = adaptChatProjectionEvent( + event({ + tool: { + toolkitName: 'FileToolkit', + methodName: 'read_file', + toolName: 'read', + invocationId: 'read-1', + }, + }) + ); + + expect(node).toMatchObject({ + kind: 'activity', + toolkitName: 'FileToolkit', + methodName: 'read_file', + toolName: 'read', + toolCallId: 'read-1', + }); + }); +}); diff --git a/src/lib/desktopIdentity.test.ts b/test/unit/lib/desktopIdentity.test.ts similarity index 68% rename from src/lib/desktopIdentity.test.ts rename to test/unit/lib/desktopIdentity.test.ts index ee4ef9fe..b294ace9 100644 --- a/src/lib/desktopIdentity.test.ts +++ b/test/unit/lib/desktopIdentity.test.ts @@ -1,3 +1,17 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocked = vi.hoisted(() => ({ @@ -13,7 +27,7 @@ vi.mock('@/host/createHost', () => ({ import { __desktopIdentityTestHooks, getDesktopInstanceId, -} from './desktopIdentity'; +} from '@/lib/desktopIdentity'; describe('desktop identity ownership', () => { beforeEach(() => { diff --git a/test/unit/lib/htmlSanitization.test.ts b/test/unit/lib/htmlSanitization.test.ts index 85e4a7a4..4d26864b 100644 --- a/test/unit/lib/htmlSanitization.test.ts +++ b/test/unit/lib/htmlSanitization.test.ts @@ -14,7 +14,12 @@ import { describe, expect, it } from 'vitest'; -import { isStaticImageSrc, stripScriptBlocks } from '@/lib/htmlSanitization'; +import { + injectPreviewContentSecurityPolicy, + isStaticImageSrc, + PREVIEW_CONTENT_SECURITY_POLICY, + stripScriptBlocks, +} from '@/lib/htmlSanitization'; describe('isStaticImageSrc', () => { it('accepts static relative paths', () => { @@ -40,3 +45,24 @@ describe('stripScriptBlocks', () => { expect(stripScriptBlocks(html)).toContain('assets/home.png'); }); }); + +describe('HTML preview CSP', () => { + it('replaces an agent-authored policy with the application policy', () => { + const html = injectPreviewContentSecurityPolicy(` + + + `); + const doc = new DOMParser().parseFromString(html, 'text/html'); + const policies = doc.querySelectorAll( + 'meta[http-equiv="Content-Security-Policy" i]' + ); + + expect(policies).toHaveLength(1); + expect(policies[0].getAttribute('content')).toBe( + PREVIEW_CONTENT_SECURITY_POLICY + ); + expect(PREVIEW_CONTENT_SECURITY_POLICY).toContain("default-src 'none'"); + expect(PREVIEW_CONTENT_SECURITY_POLICY).toContain("connect-src 'none'"); + expect(PREVIEW_CONTENT_SECURITY_POLICY).not.toContain('https:'); + }); +}); diff --git a/src/lib/localFileSecurity.test.ts b/test/unit/lib/localFileSecurity.test.ts similarity index 98% rename from src/lib/localFileSecurity.test.ts rename to test/unit/lib/localFileSecurity.test.ts index 126fe82f..0a00dc66 100644 --- a/src/lib/localFileSecurity.test.ts +++ b/test/unit/lib/localFileSecurity.test.ts @@ -21,7 +21,7 @@ import { authorizeLocalPreviewPath, isExecutableExternalOpenPath, isMainRendererSender, -} from '../../electron/main/localFileSecurity'; +} from '../../../electron/main/localFileSecurity'; const temporaryDirectories: string[] = []; diff --git a/src/lib/taskDuration.test.ts b/test/unit/lib/taskDuration.test.ts similarity index 61% rename from src/lib/taskDuration.test.ts rename to test/unit/lib/taskDuration.test.ts index 1e38b06c..873d6693 100644 --- a/src/lib/taskDuration.test.ts +++ b/test/unit/lib/taskDuration.test.ts @@ -1,8 +1,22 @@ -import { describe, expect, it } from 'vitest'; +// ========= 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 { resolveHistoricalRunElapsedMs, settleTaskElapsedMs, -} from './taskDuration'; +} from '@/lib/taskDuration'; +import { describe, expect, it } from 'vitest'; describe('settleTaskElapsedMs', () => { it('adds the live attempt to previously settled elapsed time', () => { diff --git a/src/service/agentPluginImportApi.test.ts b/test/unit/service/agentPluginImportApi.test.ts similarity index 98% rename from src/service/agentPluginImportApi.test.ts rename to test/unit/service/agentPluginImportApi.test.ts index 4cf51a65..d1652747 100644 --- a/src/service/agentPluginImportApi.test.ts +++ b/test/unit/service/agentPluginImportApi.test.ts @@ -13,11 +13,11 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { fetchPost } from '@/api/http'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { convertAgentPluginToWorkspaceBundleDraft, inspectAgentPluginSource, -} from './agentPluginImportApi'; +} from '@/service/agentPluginImportApi'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/api/http', () => ({ fetchPost: vi.fn() })); diff --git a/src/service/followUpQueueApi.test.ts b/test/unit/service/followUpQueueApi.test.ts similarity index 85% rename from src/service/followUpQueueApi.test.ts rename to test/unit/service/followUpQueueApi.test.ts index 741fc2e6..2b230100 100644 --- a/src/service/followUpQueueApi.test.ts +++ b/test/unit/service/followUpQueueApi.test.ts @@ -13,7 +13,6 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { fetchDelete, fetchGet, fetchPost } from '@/api/http'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { cancelFollowUpRequest, createFollowUpRequest, @@ -23,7 +22,8 @@ import { markFollowUpRequestAdmitted, prioritizeFollowUpRequest, terminalContinuationAdmissionRejection, -} from './followUpQueueApi'; +} from '@/service/followUpQueueApi'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/api/http', () => ({ fetchDelete: vi.fn(), @@ -35,9 +35,26 @@ describe('followUpQueueApi local Brain routes', () => { beforeEach(() => vi.clearAllMocks()); it('uses local /projects routes without the Cloud /api/v1 prefix', async () => { - vi.mocked(fetchPost).mockResolvedValue({}); - vi.mocked(fetchGet).mockResolvedValue({ items: [] }); - vi.mocked(fetchDelete).mockResolvedValue({}); + // Single-record helpers validate their response at the transport boundary, + // so these fixtures must be well formed even though this test only asserts + // the request URLs. + const record = { + request_id: 'request 1', + project_id: 'project 1', + content: 'Continue', + attachment_paths: [], + delivery_mode: 'wait', + status: 'pending', + source: 'local', + created_at: 0, + updated_at: 0, + }; + vi.mocked(fetchPost).mockResolvedValue(record); + // The source-command route reads one record; the others read collections. + vi.mocked(fetchGet).mockImplementation(async (url: string) => + url.startsWith('/follow-ups/source-command/') ? record : { items: [] } + ); + vi.mocked(fetchDelete).mockResolvedValue(record); await createFollowUpRequest({ projectId: 'project 1', diff --git a/src/service/historyApi.test.ts b/test/unit/service/historyApi.test.ts similarity index 96% rename from src/service/historyApi.test.ts rename to test/unit/service/historyApi.test.ts index 6bdfefe7..6f0aadea 100644 --- a/src/service/historyApi.test.ts +++ b/test/unit/service/historyApi.test.ts @@ -12,8 +12,8 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { fetchGroupedHistoryProjects } from '@/service/historyApi'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { fetchGroupedHistoryProjects } from './historyApi'; const { proxyFetchGetMock } = vi.hoisted(() => ({ proxyFetchGetMock: vi.fn(), diff --git a/src/service/humanInteractionApi.test.ts b/test/unit/service/humanInteractionApi.test.ts similarity index 99% rename from src/service/humanInteractionApi.test.ts rename to test/unit/service/humanInteractionApi.test.ts index 0a1c9ff5..936f10fa 100644 --- a/src/service/humanInteractionApi.test.ts +++ b/test/unit/service/humanInteractionApi.test.ts @@ -30,7 +30,7 @@ import { invalidatePendingHumanInteractions, isHumanInteractionStillPending, type HumanInteractionPayload, -} from './humanInteractionApi'; +} from '@/service/humanInteractionApi'; describe('local HumanInteraction API', () => { beforeEach(() => { diff --git a/src/service/humanInteractionEventReconciliation.test.ts b/test/unit/service/humanInteractionEventReconciliation.test.ts similarity index 98% rename from src/service/humanInteractionEventReconciliation.test.ts rename to test/unit/service/humanInteractionEventReconciliation.test.ts index f55831a4..f9d05ae6 100644 --- a/src/service/humanInteractionEventReconciliation.test.ts +++ b/test/unit/service/humanInteractionEventReconciliation.test.ts @@ -14,12 +14,12 @@ import { fetchGet } from '@/api/http'; import { normalizeLocalRunEvent } from '@/lib/projector'; +import { reconcileHumanInteractionEvents } from '@/service/humanInteractionEventReconciliation'; import { getProjectEventStore, resetProjectEventStoresForTests, } from '@/store/projectEventStore'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { reconcileHumanInteractionEvents } from './humanInteractionEventReconciliation'; vi.mock('@/api/http', () => ({ fetchGet: vi.fn() })); diff --git a/src/service/permissionProfileApi.test.ts b/test/unit/service/permissionProfileApi.test.ts similarity index 98% rename from src/service/permissionProfileApi.test.ts rename to test/unit/service/permissionProfileApi.test.ts index 421bc960..0ee56e36 100644 --- a/src/service/permissionProfileApi.test.ts +++ b/test/unit/service/permissionProfileApi.test.ts @@ -28,7 +28,7 @@ import { __permissionProfileApiTestHooks, getSpacePermissionProfile, putSpacePermissionProfile, -} from './permissionProfileApi'; +} from '@/service/permissionProfileApi'; const profile = { space_id: 'space-1', diff --git a/src/service/projectEventStoreHydration.test.ts b/test/unit/service/projectEventStoreHydration.test.ts similarity index 92% rename from src/service/projectEventStoreHydration.test.ts rename to test/unit/service/projectEventStoreHydration.test.ts index ba331587..affc9597 100644 --- a/src/service/projectEventStoreHydration.test.ts +++ b/test/unit/service/projectEventStoreHydration.test.ts @@ -14,12 +14,12 @@ import { fetchGet } from '@/api/http'; import { normalizeLocalRunEvent } from '@/lib/projector'; -import { ProjectEventStore } from '@/store/projectEventStore'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { hydrateProjectEventStore, ProjectEventStoreHydrationError, -} from './projectEventStoreHydration'; +} from '@/service/projectEventStoreHydration'; +import { ProjectEventStore } from '@/store/projectEventStore'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/api/http', () => ({ fetchGet: vi.fn() })); @@ -426,6 +426,45 @@ describe('hydrateProjectEventStore', () => { }); }); + it('ring-retains the newest tail in order across multiple wraparounds', async () => { + // One wraparound can pass by coincidence; this drops three events from a + // two-slot ring so the unrolled result must come from both ring segments. + const store = new ProjectEventStore('project-1', { + scheduleFlush: () => () => undefined, + }); + fetchGetMock + .mockResolvedValueOnce(runsResponse({ status: 'running', version: 2 })) + .mockResolvedValueOnce({ + run_id: 'run-1', + next_sequence: 5, + has_more: false, + events: [ + localEvent(1), + localEvent(2), + localEvent(3), + localEvent(4), + localEvent(5, 'run-1', { + event_type: 'run.completed', + legacy_step: 'end', + }), + ], + }); + + await expect( + hydrateProjectEventStore({ + projectId: 'project-1', + store, + maxEvents: 2, + eventPageSize: 5, + }) + ).resolves.toMatchObject({ eventCount: 2, eventsTruncated: true }); + + expect(store.getSnapshot().chat.nodes.map((node) => node.eventId)).toEqual([ + 'run-1-event-4', + 'run-1-event-5', + ]); + }); + it('keeps a newer terminal replay status over a stale Run aggregate', async () => { const store = new ProjectEventStore('project-1', { scheduleFlush: () => () => undefined, diff --git a/src/service/projectRunEventStream.test.ts b/test/unit/service/projectRunEventStream.test.ts similarity index 99% rename from src/service/projectRunEventStream.test.ts rename to test/unit/service/projectRunEventStream.test.ts index 436f6851..d082bb0e 100644 --- a/src/service/projectRunEventStream.test.ts +++ b/test/unit/service/projectRunEventStream.test.ts @@ -24,7 +24,7 @@ import { ProjectRunEventStreamOwner, selectCanonicalLiveRuns, type EventStreamTransport, -} from './projectRunEventStream'; +} from '@/service/projectRunEventStream'; type RunInput = { runId: string; diff --git a/src/service/workspaceBundleAuthoringApi.test.ts b/test/unit/service/workspaceBundleAuthoringApi.test.ts similarity index 99% rename from src/service/workspaceBundleAuthoringApi.test.ts rename to test/unit/service/workspaceBundleAuthoringApi.test.ts index 242aac8c..8f330ad1 100644 --- a/src/service/workspaceBundleAuthoringApi.test.ts +++ b/test/unit/service/workspaceBundleAuthoringApi.test.ts @@ -40,7 +40,7 @@ import { getPublicWorkspaceBundleRevision, publishWorkspaceBundleRevision, uploadWorkspaceBundleAsset, -} from './workspaceBundleAuthoringApi'; +} from '@/service/workspaceBundleAuthoringApi'; describe('workspace bundle authoring API', () => { beforeEach(() => { diff --git a/src/service/workspaceBundleInstallApi.test.ts b/test/unit/service/workspaceBundleInstallApi.test.ts similarity index 98% rename from src/service/workspaceBundleInstallApi.test.ts rename to test/unit/service/workspaceBundleInstallApi.test.ts index fdf936d8..c8e4ab65 100644 --- a/src/service/workspaceBundleInstallApi.test.ts +++ b/test/unit/service/workspaceBundleInstallApi.test.ts @@ -27,7 +27,7 @@ vi.mock('@/api/http', () => ({ fetchPut: mocks.fetchPut, })); -vi.mock('./workspaceBundleAuthoringApi', () => ({ +vi.mock('@/service/workspaceBundleAuthoringApi', () => ({ getPublicWorkspaceBundleRevision: mocks.getPublicRevision, })); @@ -37,7 +37,7 @@ import { fetchWorkspaceBundleInstallForSpace, fetchWorkspaceBundleInstallReview, parseWorkspaceBundleHandle, -} from './workspaceBundleInstallApi'; +} from '@/service/workspaceBundleInstallApi'; describe('workspace Bundle install API', () => { beforeEach(() => { diff --git a/src/service/workspaceConfigurationApi.test.ts b/test/unit/service/workspaceConfigurationApi.test.ts similarity index 98% rename from src/service/workspaceConfigurationApi.test.ts rename to test/unit/service/workspaceConfigurationApi.test.ts index 779a18ec..a3ecfcde 100644 --- a/src/service/workspaceConfigurationApi.test.ts +++ b/test/unit/service/workspaceConfigurationApi.test.ts @@ -37,7 +37,7 @@ import { uploadPreparedWorkspaceConfigurationAsset, workspaceEnvironmentVariables, type WorkspaceConfigurationDocument, -} from './workspaceConfigurationApi'; +} from '@/service/workspaceConfigurationApi'; const document: WorkspaceConfigurationDocument = { apiVersion: 'eigent.ai/v1alpha1', @@ -169,7 +169,7 @@ describe('workspace configuration API', () => { const { recordPublishedWorkspaceConfiguration, reviewWorkspaceConfiguration, - } = await import('./workspaceConfigurationApi'); + } = await import('@/service/workspaceConfigurationApi'); fetchGetMock.mockResolvedValue({ draft_version: 4, review: {} }); fetchPostMock.mockResolvedValue({ revision: {}, draft: {} }); diff --git a/src/service/workspaceGitApi.test.ts b/test/unit/service/workspaceGitApi.test.ts similarity index 98% rename from src/service/workspaceGitApi.test.ts rename to test/unit/service/workspaceGitApi.test.ts index c38c57d5..68205231 100644 --- a/src/service/workspaceGitApi.test.ts +++ b/test/unit/service/workspaceGitApi.test.ts @@ -28,7 +28,7 @@ import { executeAdvancedGit, fetchWorkspaceGitHistory, previewAdvancedGit, -} from './workspaceGitApi'; +} from '@/service/workspaceGitApi'; describe('workspace Git advanced API', () => { beforeEach(() => { diff --git a/src/store/chatStore.durableReplay.test.ts b/test/unit/store/chatStore.durableReplay.test.ts similarity index 99% rename from src/store/chatStore.durableReplay.test.ts rename to test/unit/store/chatStore.durableReplay.test.ts index d0999a9d..e75314c0 100644 --- a/src/store/chatStore.durableReplay.test.ts +++ b/test/unit/store/chatStore.durableReplay.test.ts @@ -12,7 +12,6 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import { describe, expect, it, vi } from 'vitest'; import { acceptCanonicalRunEvent, admitDurableRunResume, @@ -25,7 +24,8 @@ import { mergeFileInfoLists, normalizeTaskArtifactFileList, removeResolvedInteractionMessages, -} from './chatStore'; +} from '@/store/chatStore'; +import { describe, expect, it, vi } from 'vitest'; describe('canonical Run replay projection', () => { it('surfaces Resume admission failures before execution starts', async () => { diff --git a/test/unit/store/pageTabStore.test.ts b/test/unit/store/pageTabStore.test.ts index b9fc043d..eda507b7 100644 --- a/test/unit/store/pageTabStore.test.ts +++ b/test/unit/store/pageTabStore.test.ts @@ -13,7 +13,7 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { usePageTabStore } from '@/store/pageTabStore'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; describe('pageTabStore turn selection', () => { beforeEach(() => { @@ -58,3 +58,30 @@ describe('pageTabStore turn selection', () => { }); }); }); + +describe('pageTabStore side-panel viewport selection', () => { + beforeEach(() => { + usePageTabStore.setState({ + sidePanelManualUntilByProject: {}, + sidePanelSelectedTurnByProject: {}, + sidePanelViewedTurnByProject: {}, + }); + }); + + it('does not publish duplicate state for repeated observer callbacks', () => { + const listener = vi.fn(); + const unsubscribe = usePageTabStore.subscribe(listener); + + usePageTabStore + .getState() + .setSidePanelViewedTurn('project_one', 'task_one'); + expect(listener).toHaveBeenCalledTimes(1); + + usePageTabStore + .getState() + .setSidePanelViewedTurn('project_one', 'task_one'); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); +}); diff --git a/src/store/projectStore.test.ts b/test/unit/store/projectStore.test.ts similarity index 95% rename from src/store/projectStore.test.ts rename to test/unit/store/projectStore.test.ts index 02d2fbd3..92177761 100644 --- a/src/store/projectStore.test.ts +++ b/test/unit/store/projectStore.test.ts @@ -13,11 +13,15 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { PROJECT_CACHE_SCHEMA_VERSION } from '@/lib/projectCache'; +import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore'; +import { + getProjectEventStore, + resetProjectEventStoresForTests, +} from '@/store/projectEventStore'; +import { useProjectStore } from '@/store/projectStore'; +import { SPACE_SCHEMA_VERSION, useSpaceStore } from '@/store/spaceStore'; import { normalizeThinkingEffort, ThinkingEffort } from '@/types/constants'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getSessionPreviewSlice, usePageTabStore } from './pageTabStore'; -import { useProjectStore } from './projectStore'; -import { SPACE_SCHEMA_VERSION, useSpaceStore } from './spaceStore'; const { deleteCachedProjectMock, @@ -64,8 +68,8 @@ vi.mock('@/service/spaceApi', async (importOriginal) => { }; }); -vi.mock('./chatStore', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('@/store/chatStore', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, createChatStoreInstance: ( @@ -86,6 +90,7 @@ describe('projectStore runtime shape', () => { beforeEach(() => { vi.clearAllMocks(); + resetProjectEventStoresForTests(); deleteCachedProjectMock.mockResolvedValue(undefined); getCachedProjectMock.mockResolvedValue(null); putCachedProjectMock.mockResolvedValue(undefined); @@ -152,6 +157,24 @@ describe('projectStore runtime shape', () => { ).toBeUndefined(); }); + it('preserves the event-store instance when replay overwrites the same Project id', () => { + const projectId = useProjectStore + .getState() + .createProject('Original', undefined, 'project_same_id'); + const eventStore = getProjectEventStore(projectId); + const initialIncarnation = eventStore.getIncarnation(); + const listener = vi.fn(); + eventStore.subscribe(listener); + + useProjectStore + .getState() + .replayProject(['task-replay'], 'Replay', projectId); + + expect(getProjectEventStore(projectId)).toBe(eventStore); + expect(eventStore.getIncarnation()).toBe(initialIncarnation + 1); + expect(listener).toHaveBeenCalled(); + }); + it('appends project runs into the same primary chat store', () => { const projectId = useProjectStore .getState() @@ -377,7 +400,7 @@ describe('projectStore runtime shape', () => { }); it('replays stale cached history during the same project open', async () => { - const { useAuthStore } = await import('./authStore'); + const { useAuthStore } = await import('@/store/authStore'); const previousUserId = useAuthStore.getState().user_id; useAuthStore.setState({ user_id: 10 }); try { @@ -431,7 +454,7 @@ describe('projectStore runtime shape', () => { }); it('replays canonical local Run history when its cache anchor is stale', async () => { - const { useAuthStore } = await import('./authStore'); + const { useAuthStore } = await import('@/store/authStore'); const previousUserId = useAuthStore.getState().user_id; useAuthStore.setState({ user_id: 10 }); try { @@ -616,7 +639,7 @@ describe('projectStore runtime shape', () => { }); it('hydrates a canonical local snapshot when its SQLite anchor matches', async () => { - const { useAuthStore } = await import('./authStore'); + const { useAuthStore } = await import('@/store/authStore'); const previousUserId = useAuthStore.getState().user_id; useAuthStore.setState({ user_id: 10 }); try { @@ -745,7 +768,7 @@ describe('projectStore runtime shape', () => { }); it('repairs zero duration in a current cache from the canonical local Run', async () => { - const { useAuthStore } = await import('./authStore'); + const { useAuthStore } = await import('@/store/authStore'); const previousUserId = useAuthStore.getState().user_id; useAuthStore.setState({ user_id: 10 }); try { diff --git a/src/store/spaceStore.test.ts b/test/unit/store/spaceStore.test.ts similarity index 99% rename from src/store/spaceStore.test.ts rename to test/unit/store/spaceStore.test.ts index 98b9013c..6777da42 100644 --- a/src/store/spaceStore.test.ts +++ b/test/unit/store/spaceStore.test.ts @@ -13,14 +13,14 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import type { ServerProject } from '@/service/spaceApi'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getSessionPreviewSlice, usePageTabStore } from './pageTabStore'; +import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore'; import { SPACE_SCHEMA_VERSION, type Space, type SpaceSourceType, useSpaceStore, -} from './spaceStore'; +} from '@/store/spaceStore'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const authStoreMock = vi.hoisted(() => ({ state: { diff --git a/vitest.config.ts b/vitest.config.ts index 11d0c660..f967aac4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,10 +24,9 @@ export default defineConfig({ test: { root: __dirname, environment: 'jsdom', - include: [ - 'test/**/*.{test,spec}.?(c|m)[jt]s?(x)', - 'src/**/*.{test,spec}.?(c|m)[jt]s?(x)', - ], + // Tests live under test/ only, mirroring the src/ tree. src/ stays + // production source, so it is deliberately not globbed here. + include: ['test/**/*.{test,spec}.?(c|m)[jt]s?(x)'], exclude: ['test/e2e/**', 'test/performance/**'], testTimeout: 1000 * 29, globals: true,