feat/refactor chatbox ui rendering with event bus

This commit is contained in:
Douglas 2026-08-13 10:49:37 +01:00
parent 03f9133174
commit 584242b9b8
103 changed files with 3465 additions and 722 deletions

View file

@ -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({
<section
data-bottom-box-header
aria-label={title || eyebrow || 'Input context'}
className="flex w-full flex-col gap-2 px-3 pt-3"
className={cn('flex w-full flex-col gap-2 px-4 py-2', className)}
>
{hasCopy && (
<div className="flex min-w-0 flex-col gap-0.5">

View file

@ -436,7 +436,7 @@ export function ControlInputRouter({
content = (
<Inputbox
{...inputProps}
showFileAttachments={false}
header={variant.header}
connectorPanelOpen={connectorPanelOpen}
onToggleConnectorPanel={onToggleConnectorPanel}
skillPanelOpen={skillPanelOpen}

View file

@ -35,7 +35,9 @@ import {
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { BoxHeaderDisplay } from './BoxHeader';
import { RichChatInput } from './RichChatInput';
import type { BottomBoxHeaderContent } from './types';
/**
* File attachment object
@ -59,8 +61,10 @@ export interface InputboxProps {
onSend?: () => 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 = ({
</div>
</div>
)}
{/* Layer 1: Input-required question / details */}
{header && <BoxHeaderDisplay {...header} className="px-0 pb-2 pt-0" />}
{/* Layer 2: File attachments (only show if has files) */}
{showFileAttachments && files.length > 0 && (
<div className="relative box-border flex w-full flex-wrap items-start gap-1 pb-2">

View file

@ -350,7 +350,7 @@ export function ConnectorPickerPanel({
renderTag={(item) => (
<span
className={cn(
'rounded px-1 py-px text-xs font-medium',
'rounded px-1 py-[1px] text-xs font-medium',
RICH_CONNECTOR_STYLE_CLASSES
)}
>
@ -418,7 +418,7 @@ export function SkillPickerPanel({
return (
<span
className={cn(
'rounded px-1 py-px text-xs font-medium',
'rounded px-1 py-[1px] text-xs font-medium',
RICH_SKILL_STYLE_CLASSES[clsIdx]
)}
>

View file

@ -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 && <BoxHeaderDisplay {...resolvedHeader} />}
{externalHeader && <BoxHeaderDisplay {...externalHeader} />}
{/* InputBox — controlled router selected by event-derived variant. */}
<ControlInputRouter

View file

@ -12,7 +12,7 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
/** Context displayed above the active human-control input. */
/** Context displayed with the active human-control question. */
export interface BottomBoxContextItem {
id: string;
label: string;
@ -28,7 +28,7 @@ export interface BottomBoxHeaderDetail {
content: string;
}
/** Display-only content for the BoxHeader region. */
/** Display-only question/details. Composer variants render this inside InputBox. */
export interface BottomBoxHeaderContent {
eyebrow?: string;
title?: string;

View file

@ -18,13 +18,33 @@ import {
selectRenderableChatNodes,
type ChatProjectionNode,
} from '@/lib/projector/chat';
import { useLayoutEffect, useMemo, useRef, type RefObject } from 'react';
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
type RefObject,
} from 'react';
import {
animateChatTimelineAnchor,
type ChatTimelineScrollAnimation,
} from './chatTimelineScroll';
import { EventTimeline, type ChatTimelineDetailLevel } from './EventTimeline';
import { presentHumanInteractionReceipts } from './EventTimeline/presentationPolicy';
/** Temporary DOM window until the event timeline has variable-height virtualization. */
const MAX_MOUNTED_EVENT_NODES = 250;
/** Extra slack beyond the BottomBox inset so "last message visible" still counts as pinned. */
const NEAR_BOTTOM_SLACK_PX = 48;
export function isChatTimelineNearBottom(
distanceFromBottom: number,
bottomInsetPx: number,
slackPx = NEAR_BOTTOM_SLACK_PX
): boolean {
return distanceFromBottom <= Math.max(0, bottomInsetPx) + slackPx;
}
interface EventNativeTimelineWindow {
hiddenNodeCount: number;
@ -92,32 +112,129 @@ export function EventNativeProjectTimeline({
);
const { hiddenNodeCount, nodes: visibleNodes } = timelineWindow;
const previousScrollHeightRef = useRef(0);
const previousLatestUserEventIdRef = useRef<string | undefined>(undefined);
const previousProjectIdRef = useRef(projectId);
const pinToBottomRef = useRef(true);
const ignoreAnchorScrollRef = useRef(false);
const anchorAnimationRef = useRef<ChatTimelineScrollAnimation | null>(null);
const contentRef = useRef<HTMLDivElement>(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<HTMLElement>(
'[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 (
<div
className="relative z-10 w-full"
data-chat-timeline-source="durable-events"
>
<div
ref={contentRef}
className="mx-auto w-full max-w-[600px] pt-0"
style={{ paddingBottom: scrollBottomInsetPx }}
>

View file

@ -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 (
<ol
@ -73,38 +76,57 @@ export function EventTimeline({
data-effective-detail-level={presentation.effectiveDetailLevel}
data-requested-detail-level={presentation.requestedDetailLevel}
>
{presentation.nodes.map((node) => (
<li
className="min-w-0"
data-event-node-id={node.id}
data-event-node-kind={node.kind}
data-interaction-id={
node.kind === 'interaction' ? node.interactionId : undefined
}
data-interaction-request-event-id={
node.kind === 'interaction'
? node.requestEventId ||
(node.status === 'requested' ? node.eventId : undefined)
: undefined
}
data-interaction-resolution-event-id={
node.kind === 'interaction' ? node.resolutionEventId : undefined
}
data-interaction-status={
node.kind === 'interaction' ? node.status : undefined
}
data-run-id={node.runId}
key={node.id}
>
<EventRenderer
detailLevel={presentation.effectiveDetailLevel}
eventTypeRegistry={eventTypeRegistry}
node={node}
onRendererError={onRendererError}
registry={registry}
/>
</li>
))}
{displayRows.map((row) => {
if (row.rowKind === 'repeated-tool-calls') {
return (
<li
className="min-w-0"
data-event-node-id={row.id}
data-event-node-kind="activity"
data-run-id={row.runId}
data-tool-call-count={row.calls.length}
key={row.id}
>
<RepeatedToolCallGroup group={row} />
</li>
);
}
const node = row.node;
return (
<li
className="min-w-0"
data-event-node-id={node.id}
data-event-node-kind={node.kind}
data-message-role={node.kind === 'message' ? node.role : undefined}
data-interaction-id={
node.kind === 'interaction' ? node.interactionId : undefined
}
data-interaction-request-event-id={
node.kind === 'interaction'
? node.requestEventId ||
(node.status === 'requested' ? node.eventId : undefined)
: undefined
}
data-interaction-resolution-event-id={
node.kind === 'interaction' ? node.resolutionEventId : undefined
}
data-interaction-status={
node.kind === 'interaction' ? node.status : undefined
}
data-run-id={node.runId}
key={row.id}
>
<EventRenderer
detailLevel={presentation.effectiveDetailLevel}
eventTypeRegistry={eventTypeRegistry}
node={node}
onRendererError={onRendererError}
registry={registry}
/>
</li>
);
})}
</ol>
);
}

View file

@ -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<typeof useTranslation>['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 (
<section
aria-label={t('chat.repeated-tool-calls-label', { tool: toolTitle })}
className="overflow-hidden rounded-xl border border-ds-border-neutral-subtle-default bg-ds-bg-neutral-subtle-default"
data-tool-call-group
data-tool-call-count={group.calls.length}
data-tool-call-method={group.methodName}
data-tool-call-status={group.status}
data-tool-call-toolkit={group.toolkitName}
>
<button
type="button"
aria-expanded={open}
className="group flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-ds-bg-neutral-default-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ds-border-brand-default-focus"
onClick={() => setOpen((current) => !current)}
>
<span className="flex min-w-0 items-center gap-2">
{open ? (
<ChevronDown
aria-hidden
className="size-4 shrink-0 text-ds-icon-neutral-subtle-default opacity-100 transition-opacity duration-200"
/>
) : (
<ChevronRight
aria-hidden
className="size-4 shrink-0 text-ds-icon-neutral-subtle-default opacity-0 transition-opacity duration-200 group-focus-within:opacity-100 group-hover:opacity-100"
/>
)}
<span className="truncate text-body-sm font-medium text-ds-text-neutral-default-default">
{title}
</span>
</span>
<span
className={`shrink-0 text-label-xs ${statusClassName(group.status)}`}
>
{groupStatusLabel(group, t)}
</span>
</button>
{open ? (
<div className="border-x-0 border-b-0 border-t border-solid border-ds-border-neutral-subtle-default px-4 py-2">
<ol className="m-0 flex list-none flex-col gap-2 p-0">
{group.calls.map((call, index) => {
const node = call.presentedNode;
return (
<li
key={call.id}
className="rounded-lg bg-ds-bg-neutral-default-default px-3 py-2"
data-tool-call-id={node.toolCallId || call.id}
data-tool-call-index={index + 1}
data-tool-call-status={node.status}
>
<div className="flex items-center justify-between gap-3">
<span className="text-label-sm font-medium text-ds-text-neutral-default-default">
{toolTitle}
</span>
<span
className={`text-label-xs ${statusClassName(node.status)}`}
>
{t(`chat.tool-status-${node.status}`, {
defaultValue: displayStatus(node.status),
})}
</span>
</div>
{node.detail ? (
<p className="m-0 mt-1 whitespace-pre-wrap break-words text-label-sm text-ds-text-neutral-subtle-default">
{node.detail}
</p>
) : null}
</li>
);
})}
</ol>
</div>
) : null}
</section>
);
}
export type { RepeatedToolCallGroupProps };

View file

@ -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<ChatActivityStatus>([
'pending',
'running',
]);
const TERMINAL_TOOL_STATUSES = new Set<ChatActivityStatus>([
'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<string, MutableToolCall>();
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;
}

View file

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

View file

@ -114,7 +114,7 @@ export function AgentMessageCard({
return (
<div
key={id}
className={`flex w-full flex-col rounded-xl bg-transparent px-sm py-3 ${className || ''} overflow-hidden`}
className={`flex w-full flex-col rounded-xl bg-transparent px-2 py-3 ${className || ''} overflow-hidden`}
>
<MarkDown
content={content}
@ -123,7 +123,7 @@ export function AgentMessageCard({
enableTypewriter={enableTypewriter && typewriter}
/>
{showDeferredFileUi && attaches && attaches.length > 0 && (
<div className="mt-[10px] flex flex-wrap gap-2">
<div className="mt-2.5 flex flex-wrap gap-2">
{attaches?.map((file) => {
return (
<div
@ -151,7 +151,7 @@ export function AgentMessageCard({
</div>
)}
{showDeferredFileUi && deferredFooter != null && (
<div className="mt-[10px] w-full">{deferredFooter}</div>
<div className="mt-2.5 w-full">{deferredFooter}</div>
)}
{markdownAndTypingComplete && (
<div className="mt-3 flex shrink-0 justify-start gap-1">

View file

@ -50,11 +50,11 @@ export const FloatingAction = ({
return (
<div
className={cn(
'bottom-32 left-0 right-0 top-2 mt-4 pointer-events-none sticky z-20 flex w-full items-center justify-center',
'pointer-events-none sticky bottom-40 left-0 right-0 top-2 z-20 mt-4 flex w-full items-center justify-center',
className
)}
>
<div className="gap-2 p-1 backdrop-blur-md border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default shadow-button-shadow pointer-events-auto flex items-center rounded-full border">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default p-1 shadow-button-shadow backdrop-blur-md">
{/* Always show Stop Task button when running (removed pause/resume logic) */}
<Button
variant="outline"

View file

@ -54,7 +54,7 @@ export function NoticeCard() {
return (
<div>
<div className="flex h-auto w-full flex-col gap-2 py-sm">
<div className="flex h-auto w-full flex-col gap-2 py-2">
<div className="relative h-auto w-full overflow-hidden rounded-xl py-3 pr-5 backdrop-blur-[5px]">
<div className="relative">
<Button
@ -85,7 +85,7 @@ export function NoticeCard() {
: 'linear-gradient(to top, black 0%, black 40%, transparent 100%)',
}}
>
<div className="mt-sm flex flex-col gap-2 px-2">
<div className="mt-2 flex flex-col gap-2 px-2">
{cotList.map((cot: string, index: number) => {
return (
<div

View file

@ -216,7 +216,7 @@ function looksLikeNarration(raw: string): boolean {
return true;
}
type ToolItem = {
export type ToolItem = {
kind: 'tool';
id: string;
rowTitle: string;
@ -261,6 +261,91 @@ type HumanInputItem = {
export type TimelineItem = ToolItem | MessageItem | HumanInputItem;
export type RepeatedToolItem = {
kind: 'repeated-tool';
id: string;
rowTitle: string;
toolkitName: string;
method: string;
calls: readonly ToolItem[];
status: 'running' | 'done';
};
export type WorkLogDisplayItem = TimelineItem | RepeatedToolItem;
function normalizedToolIdentity(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
}
function repeatedToolKey(item: ToolItem): string | null {
const toolkit = normalizedToolIdentity(item.toolkitName);
const method = normalizedToolIdentity(item.method);
return toolkit && method ? JSON.stringify([toolkit, method]) : null;
}
/**
* Collapse only consecutive identical toolkit/method rows for presentation.
* Messages and human-input receipts are hard chronology boundaries, and the
* source work-log items remain unchanged.
*/
export function groupConsecutiveToolItems(
items: readonly TimelineItem[]
): WorkLogDisplayItem[] {
const grouped: WorkLogDisplayItem[] = [];
for (let index = 0; index < items.length; ) {
const item = items[index]!;
if (item.kind !== 'tool') {
grouped.push(item);
index += 1;
continue;
}
const identity = repeatedToolKey(item);
if (!identity) {
grouped.push(item);
index += 1;
continue;
}
const calls: ToolItem[] = [item];
let cursor = index + 1;
while (cursor < items.length) {
const candidate = items[cursor]!;
if (
candidate.kind !== 'tool' ||
repeatedToolKey(candidate) !== identity
) {
break;
}
calls.push(candidate);
cursor += 1;
}
if (calls.length === 1) {
grouped.push(item);
} else {
grouped.push({
kind: 'repeated-tool',
id: `repeated-tool:${item.id}`,
rowTitle: item.rowTitle,
toolkitName: item.toolkitName,
method: item.method,
calls,
status: calls.some((call) => call.status === 'running')
? 'running'
: 'done',
});
}
index = cursor;
}
return grouped;
}
/**
* One agent's slice of work a chronological list of inline messages
* (reasoning, notices, toolkit narration) and tool rows. Renders flat: the
@ -1069,6 +1154,89 @@ const ToolDetailRow = memo(function ToolDetailRow({
});
ToolDetailRow.displayName = 'ToolDetailRow';
const RepeatedToolDetailRow = memo(function RepeatedToolDetailRow({
item,
active,
}: {
item: RepeatedToolItem;
active: boolean;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const rowTitle = t('chat.repeated-tool-events', {
tool: item.rowTitle,
count: item.calls.length,
});
const running = active && item.status === 'running';
return (
<div
className="flex w-full min-w-0 flex-col items-start"
data-repeated-tool-group
data-repeated-tool-count={item.calls.length}
data-toolkit={item.toolkitName}
data-method={item.method}
>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
className="group inline-flex min-w-0 max-w-full items-center gap-1 self-start px-0 py-0.5 text-left transition-opacity hover:opacity-80"
>
{running ? (
<ShinyText
text={rowTitle}
speed={2.5}
className="min-w-0 shrink overflow-hidden text-ellipsis whitespace-nowrap !text-label-sm font-normal text-ds-text-neutral-subtle-default"
/>
) : (
<span className="min-w-0 shrink overflow-hidden text-ellipsis whitespace-nowrap !text-label-sm font-normal text-ds-text-neutral-subtle-default">
{rowTitle}
</span>
)}
<ChevronRight
size={16}
aria-hidden
className={cn(
'shrink-0 text-ds-icon-neutral-subtle-default transition-[opacity,transform] duration-200',
open
? 'rotate-90 opacity-100'
: 'rotate-0 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100'
)}
/>
</button>
<AnimatePresence initial={false}>
{open ? (
<motion.div
key="repeated-tool-calls"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={HEIGHT_MOTION}
className="w-full min-w-0 overflow-hidden"
>
<div className="flex w-full min-w-0 flex-col gap-1">
{item.calls.map((call) => (
<ToolDetailRow
key={call.id}
rowTitle={call.rowTitle}
input={call.input}
output={call.output}
status={
active && call.status === 'running' ? 'running' : 'done'
}
/>
))}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
});
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"
>
<div className="flex flex-col gap-2 py-1 pl-6">
{group.items.map((item) =>
{displayItems.map((item) =>
item.kind === 'message' ? (
<InlineMessageRow
key={item.id}
@ -1520,6 +1692,12 @@ const AgentGroupRow = memo(function AgentGroupRow({
readOnly={humanInputReadOnly}
onResolved={onHumanInputResolved}
/>
) : item.kind === 'repeated-tool' ? (
<RepeatedToolDetailRow
key={item.id}
item={item}
active={taskRunning && group.status === 'running'}
/>
) : (
<ToolDetailRow
key={item.id}

View file

@ -130,7 +130,11 @@ export function UserMessageCard({
};
return (
<div key={id} className={cn('group/msg relative w-full pl-16', className)}>
<div
key={id}
className={cn('group/msg relative w-full pl-16', className)}
data-user-query-anchor
>
<div className="w-full overflow-visible rounded-xl rounded-br-sm bg-ds-bg-neutral-strong-default px-4 py-2">
{attaches && attaches.length > 0 && (
<div className="relative mb-2 box-border flex w-full flex-wrap items-start gap-1">

View file

@ -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<ProjectChatContainerProps> = ({
onSkip,
isPauseResumeLoading,
}) => {
const { projectStore, chatStore } = useChatStoreAdapter();
const { projectStore } = useChatStoreAdapter();
const [activeQueryId, setActiveQueryId] = useState<string | null>(null);
const [lastMessageCount, setLastMessageCount] = useState(0);
const [, setChatRevision] = useState(0);
const anchorFrameRef = useRef<number | null>(null);
const anchorAnimationRef = useRef<ChatTimelineScrollAnimation | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
// Get all chat stores for the active project
const activeProjectId = projectStore.activeProjectId;
@ -94,13 +100,6 @@ export const ProjectChatContainer: React.FC<ProjectChatContainerProps> = ({
};
}, [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<ProjectChatContainerProps> = ({
}, 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<string | null>(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<HTMLElement>('[data-query-id]')
);
const queryGroup = targets.find(
(candidate) => candidate.dataset.queryId === queryId
);
const target =
queryGroup?.querySelector<HTMLElement>('[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<ProjectChatContainerProps> = ({
// 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<ProjectChatContainerProps> = ({
return (
<div className={`relative z-10 w-full ${className}`}>
<div
ref={contentRef}
className="mx-auto w-full max-w-[600px] pt-0"
style={{ paddingBottom: scrollBottomInsetPx }}
>

View file

@ -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 */}
<div className="space-y-0">
<div className="space-y-3">
{queryGroups.map((group, index) => (
<UserQueryGroup
key={`${chatId}-${group.queryId}`}

View file

@ -1,110 +1,272 @@
# ChatBox component structure
# ChatBox architecture and spacing
This document describes the ChatBox layout, how the main pieces connect, and where to change behavior. Paths are relative to `src/components/ChatBox/`.
This document describes the current ChatBox structure, layout relationships,
spacing rules, and the planned removal of the legacy ChatStore renderer. Paths
are relative to `src/components/ChatBox/`.
## Overview
## Direction
ChatBox is the main chat surface: it wires **project + chat store** data to **message threads**, **task / workforce UI**, and the **BottomBox** composer. It supports multi-turn flows, task planning, splitting, and execution with scroll and timeline affordances.
The target architecture is the event-native timeline plus the shared
`BottomBox` composer. The older `ProjectChatContainer``ProjectSection`
`UserQueryGroup` renderer remains available behind the event timeline feature
switch while migration is in progress.
## Architecture (folders)
New conversation presentation work should be added to `EventTimeline/`, not to
the legacy `MessageItem/` routing path.
## Current runtime structure
```text
ChatBox
├── scroll viewport
│ └── timeline column (width: 100%; maximum width: 600px)
│ ├── event-native path
│ │ └── EventNativeProjectTimeline
│ │ └── EventTimeline
│ │ └── EventRenderer
│ │ └── semantic event renderer
│ └── legacy fallback path
│ └── ProjectChatContainer
│ └── ProjectSection (one task/run)
│ ├── UserQueryGroup (one user turn)
│ │ ├── UserMessageCard
│ │ ├── PlanTaskBox or TaskCard
│ │ ├── TaskWorkLogAccordion
│ │ └── AgentMessageCard / NoticeCard / interaction UI
│ └── FloatingAction
├── PlanTaskBox overlay portal (legacy only)
└── BottomBox overlay (width: 100%; maximum width: 600px)
├── QueuedBox
├── floating UsageLimitBanner or PickerPanel
└── BoxMain
├── BoxHeader
├── ControlInputRouter
│ ├── InputBox
│ │ └── RichChatInput
│ └── approval / selection / form / feedback / blocked controls
└── BoxFooter
├── project mode
├── approval mode
├── thinking effort
└── model selector
```
## Event-native components to keep
```text
ChatBox/
├── index.tsx # Main shell: layout, chat timeline, send/stop, BottomBox
├── ChatTimeline.tsx # Per-project task/chat rail or popover (narrow layout)
├── ProjectChatContainer.tsx # Scroll region + all chat stores for the active project
├── ProjectSection.tsx # One chat store: query groups, FloatingAction
├── UserQueryGroup.tsx # One user query → messages, task UI, agent results
├── TaskBox/ # Plan / run task UI
│ ├── TaskCard.tsx # Plan list, progress, filter, expand (workforce subtasks)
│ ├── TaskItem.tsx # Editable line in the plan
│ ├── TaskType.tsx # Task type indicator
│ ├── TypeCardSkeleton.tsx # Loading skeleton while the plan is forming
│ └── StreamingTaskList.tsx
├── MessageItem/ # All message- and log-specific UI
│ ├── UserMessageCard.tsx
│ ├── UserMessageRichContent.tsx
│ ├── AgentMessageCard.tsx
│ ├── NoticeCard.tsx
│ ├── FeedbackCard.tsx
│ ├── TaskCompletionCard.tsx
│ ├── SplittingProgressRow.tsx # “Splitting tasks” + token tick during decompose
│ ├── TaskWorkLogAccordion.tsx # Work log: tool / agent lines (workforce)
│ ├── FloatingAction.tsx # Pause / skip (used from ProjectSection)
│ ├── MarkDown.tsx
│ ├── SummaryMarkDown.tsx
│ └── TokenUtils.tsx # Token animation + splitting elapsed formatting
└── BottomBox/ # Composer and chrome above input
├── index.tsx
├── EventNativeProjectTimeline.tsx
├── EventTimeline/
│ ├── index.ts
│ ├── EventTimeline.tsx
│ ├── EventRenderer.tsx
│ ├── EventRendererBoundary.tsx
│ ├── DefaultEventRenderers.tsx
│ ├── UnknownEventFallback.tsx
│ ├── presentationPolicy.ts
│ └── rendererRegistry.ts
└── BottomBox/
├── index.tsx
├── types.ts
├── useEventNativeHumanControl.ts
├── ControlInput.tsx
├── InputBox.tsx
├── RichChatInput.tsx
├── ModelSelect.tsx
├── BoxHeader.tsx
└── QueuedBox.tsx
├── BoxFooter.tsx
├── QueuedBox.tsx
├── PickerPanel.tsx
├── UsageLimitBanner.tsx
├── ApprovalModeSelect.tsx
├── ThinkingEffortSelect.tsx
└── ModelSelect.tsx
```
## Core responsibilities
### Event-native data flow
### `index.tsx`
1. Durable project/run events hydrate the project event store.
2. The chat projector converts transport events into semantic
`ChatProjectionNode` values.
3. `EventNativeProjectTimeline` selects renderable nodes and bounds the mounted
history window.
4. `EventTimeline` applies presentation policy and creates a 12px-spaced list.
5. `EventRenderer` selects a renderer by semantic node kind or exact event type.
6. `BottomBox` renders the event-derived human control or the standard input.
- Composes `ProjectChatContainer`, `BottomBox`, and (when used) `ChatTimeline`.
- Connects to `useChatStoreAdapter`, `projectStore`, navigation, and session chrome (e.g. scroll padding, task time display, pause/resume).
- Owns high-level task operations (send, stop, share, history hooks) and passes props into children.
The default semantic renderer kinds are message, notice, interaction, plan,
activity, artifact, run status, and unknown.
### `ProjectChatContainer.tsx`
### Repeated tool-call presentation
- Renders the stack of perchat-id sections for the active project.
- Owns scroll behavior and “stick to bottom” / padding for the last message and BottomBox.
`EventTimeline/activityGrouping.ts` converts consecutive identical tool
activity nodes into logical calls before rendering. It pairs lifecycle frames
with a backend `toolCallId` when available and uses FIFO pairing for older
events without correlation. The source projection remains immutable.
### `ProjectSection.tsx`
Calls are grouped only when the run, agent, toolkit, and method match without
another timeline node between them. A message, interaction, different tool,
agent change, or Run change ends the group so chronological relationships are
never reordered.
- One **vanilla `chatStore`** instance: subscribes to it and maps **messages** into **query groups** (see `UserQueryGroup`).
- Hosts `FloatingAction` (pause, skip) for the active task.
- One logical call renders as the normal activity row.
- Two or more calls render through `RepeatedToolCallGroup.tsx` as
`Toolkit · method · count events`.
- The repeated group is collapsed by default and expands to show each call's
individual status and safe display detail.
- Aggregate running, completed, cancelled, and failed states appear on the
collapsed row.
- The group key is anchored to its first call so an open accordion stays open
when later calls join the same live burst.
### `UserQueryGroup.tsx`
While the legacy fallback is still enabled, `TaskWorkLogAccordion.tsx` applies
the same consecutive-call rule to action rows through
`groupConsecutiveToolItems`. Its optional inner accordion uses the same
`Toolkit · method · count events` summary. Every expanded child retains the
original `Toolkit · method` name. Preparation/registration rows are
intentionally excluded because that synthetic block can contain calls from
multiple agents. Remove this legacy implementation together with
`TaskWorkLogAccordion.tsx` at final cutover.
- A **single user turn**: user content, then downstream UI driven by `AgentStep` / `ChatTaskStatus` (e.g. splitting, task card, agent completion, notices).
- Imports from `TaskBox/` and `MessageItem/`; this is the main place new message *shapes* are routed.
## Main layout measurements
## TaskBox (`TaskBox/`)
| Relationship | Value |
| ------------------------------------------- | ---------------------------------------------------------------: |
| ChatBox shell | Full width and height; vertical flex layout |
| Scroll viewport | Remaining height; 8px left padding |
| Timeline column | Full width; 600px maximum; horizontally centered |
| BottomBox overlay column | Full width; 600px maximum; 8px horizontal and 4px bottom padding |
| Minimum space below timeline | 128px |
| Dynamic space below timeline | BottomBox measured height + 8px |
| Event-native row spacing | 12px |
| Second and later query/header gap | 44px |
| Follow-up query scroll transition | 800ms Framer Motion eased scroll |
| Legacy query-group spacing | 12px |
| Legacy content spacing inside a query group | 12px |
| Separate legacy task/run sections | 32px bottom margin |
| Folded plan preview | 200px height |
| Expanded plan separation from BottomBox | 8px |
| Footer compact threshold | 460px |
| Composer input height | 40px minimum; 200px maximum |
| Legacy user-message left indentation | 64px |
| Floating legacy controls bottom boundary | 128px |
- **`TaskCard`**: Task type 1/2/3 flows—plan text, `taskRunning` rows, filter tabs, link to the chat that owns a subtask, expand/collapse with session-backed preference.
- **`TaskItem`**: Single plan line edit/delete.
- **`TypeCardSkeleton`**: Shown while the model is decomposing before `to_sub_tasks` is ready.
- **`StreamingTaskList`**: Renders running subtasks during streaming / updates.
The timeline bottom padding is calculated as:
## MessageItem (`MessageItem/`)
```text
maximum(128px, measured BottomBox height + 8px)
```
- **`UserMessageCard` / `UserMessageRichContent`**: User bubble + rich blocks.
- **`AgentMessageCard`**: Assistant markdown, optional typewriter, attachments.
- **`SplittingProgressRow`**: Shown while the task is in the splitting phase; uses store `taskTime` / `elapsed` when present, with a per-session wall-clock fallback when not.
- **`TaskWorkLogAccordion`**: Collapsible work log for running/finished/paused task (tool activate/deactivate, agent lines); Framer `height: auto` for expand, stable segment keys from the merged log.
- **`TaskCompletionCard`**: Completion / summary style card when appropriate.
- **`NoticeCard`**: Chain-of-thought or notice-style content.
- **`FeedbackCard`**: Thumbs / feedback when enabled.
- **`FloatingAction`**: Compact floating controls (wired from `ProjectSection`).
- **`TokenUtils`**: Animated token number and `formatSplittingElapsed` helpers.
This keeps the final timeline item visible above queued messages, banners,
headers, and other BottomBox states whose height changes at runtime.
## BottomBox (`BottomBox/`)
## Numeric spacing convention
- **`index.tsx`**: Wires `BoxHeader`, `InputBox` / `RichChatInput`, `QueuedBox` to task state (pending, running, confirm, etc.).
- **`InputBox` / `RichChatInput` / `ModelSelect`**: Text input, model picker, rich input where applicable.
- **`BoxHeader`**: Task summary, timing, and header affordances.
- **`QueuedBox`**: Queued user messages when the task pipeline is busy.
ChatBox spacing utilities must use numeric Tailwind values. Do not introduce
named spacing utilities such as `p-sm`, `px-sm`, `gap-xs`, or `py-px`.
## Data flow (short)
| Utility value | Rendered size |
| ------------: | ------------: |
| `0` | 0px |
| `0.5` | 2px |
| `1` | 4px |
| `1.5` | 6px |
| `2` | 8px |
| `2.5` | 10px |
| `3` | 12px |
| `4` | 16px |
| `5` | 20px |
| `6` | 24px |
| `8` | 32px |
| `10` | 40px |
| `16` | 64px |
| `32` | 128px |
1. **User input**`BottomBox``index.tsx` / store → API or store updates.
1. **SSE / store updates**`ProjectChatContainer``ProjectSection``UserQueryGroup``MessageItem` / `TaskBox` by step and status.
1. **State****`chatStore`** (per chat), **`projectStore`** (project + which chat is active), plus local component state (expand, scroll, active query).
Use an explicit arbitrary numeric value when Tailwind has no matching scale
entry, for example `py-[1px]`.
## Extending the UI
### Timeline rhythm
- **New agent or system message type**: branch in `UserQueryGroup.tsx` (and possibly `ProjectSection` if grouping changes).
- **New task UI**: add under `TaskBox/` and mount from `UserQueryGroup` or `TaskCard` as needed.
- **New bubble content**: add a card under `MessageItem/` and import it from `UserQueryGroup` (or the parent that owns that message list).
Both timeline paths currently use a real 12px sibling rhythm:
This layout keeps **transport and layout** (`index`, `Project*`) separate from **message rendering** (`MessageItem/`) and **task planning/execution** (`TaskBox/`), and **composer** behavior (`BottomBox/`).
- Event-native: `EventTimeline` uses `gap-3` between list items.
- Legacy: `ProjectSection` uses `space-y-3` between query groups.
- Legacy: `UserQueryGroup` uses `gap-3` between its direct content blocks.
Do not add a gap to a wrapper that contains only one child. It has no visual
effect and obscures which parent owns the relationship between components.
## Legacy cleanup inventory
The following files belong to the legacy ChatStore conversation renderer. They
can be removed after the event-native path is permanent and no fallback is
required:
```text
ProjectChatContainer.tsx
ProjectSection.tsx
UserQueryGroup.tsx
InterruptedRunBanner.tsx
MessageItem/
├── AgentMessageCard.tsx
├── FloatingAction.tsx
├── HumanInteractionCard.tsx
├── NoticeCard.tsx
├── PreparingToExecuteTasks.tsx
├── TaskWorkLogAccordion.tsx
└── UserMessageCard.tsx
```
The following appear unused by production code and should be verified before
deletion:
```text
MessageItem/FeedbackCard.tsx
MessageItem/SummaryMarkDown.tsx
MessageItem/TaskCompletionCard.tsx
TaskBox/TaskType.tsx
```
### Move or replace before deleting
These components still have consumers outside the legacy ChatBox renderer:
| Component | External dependency/action |
| ---------------------------------------- | ---------------------------------------------------------------------------------- |
| `TaskBox/TaskCard.tsx` | Used by `Session/Workforce/FoldedPanel`; move to Workforce or replace |
| `TaskBox/TaskItem.tsx` | Child of `TaskCard`; move with it |
| `TaskBox/PlanTaskBox/` | Used by `Session/Workforce/FoldedPanel`; move or replace with event-native plan UI |
| `MessageItem/MarkDown.tsx` | Used by `Folder`; move to a shared content component location |
| `MessageItem/TokenUtils.tsx` | Used by Session, HistorySidebar, and Dashboard; move to shared utilities |
| `MessageItem/UserMessageRichContent.tsx` | Used by PlanTaskBox; move with its remaining owner or make shared |
### Final cutover changes
After the dependencies above are resolved:
1. Remove the `ProjectChatContainer` branch from `ChatBox/index.tsx`.
2. Remove the legacy interruption-banner branches.
3. Remove `PLAN_OVERLAY_SLOT_ID` and the legacy plan overlay portal.
4. Make `EventNativeProjectTimeline` the only conversation renderer.
5. Remove the legacy/event-native conditional state and handlers from
`ChatBox/index.tsx`.
6. Retire `VITE_CHATBOX_EVENT_BUS` after event-native rendering is the default.
7. Remove legacy projection normalization only after the event store no longer
relies on legacy `/chat` frames as an ingestion source.
8. Delete or update the corresponding legacy tests.
Do not remove the legacy event bridge merely because the legacy visual
components are gone. The visible event-native timeline currently enables that
bridge so legacy transport frames can still be normalized into the event store.
## Extending ChatBox
- Add a new semantic timeline node renderer in `EventTimeline/` and register it
in `rendererRegistry.ts`.
- Add display-density or visibility rules in `presentationPolicy.ts`.
- Add a new human-control state to `BottomBox/types.ts`, route it through
`ControlInputRouter`, and derive it in `useEventNativeHumanControl.ts`.
- Keep transport and store logic outside presentation renderers.
- Preserve the shared 600px timeline/composer alignment and 12px timeline
rhythm unless the overall layout specification changes.

View file

@ -80,7 +80,7 @@ export function FoldedView({
exit={planBlurFadeMotion.exit}
transition={planBlurFadeMotion.transition}
className={cn(
'relative mx-sm flex flex-col overflow-hidden rounded-2xl bg-ds-bg-splitting-subtle-default'
'relative mx-2 flex flex-col overflow-hidden rounded-2xl bg-ds-bg-splitting-subtle-default'
)}
>
<div className="flex items-center gap-2 border-x-0 border-b border-t-0 border-solid border-ds-border-neutral-subtle-default px-3 py-2">

View file

@ -238,19 +238,19 @@ export function TaskCard({
return (
<div>
<div className="flex h-auto w-full flex-col gap-2 px-sm py-2">
<div className="relative h-auto w-full overflow-hidden rounded-xl bg-ds-bg-neutral-default-default py-sm">
<div className="flex h-auto w-full flex-col gap-2 px-2 py-2">
<div className="relative h-auto w-full overflow-hidden rounded-xl bg-ds-bg-neutral-default-default py-2">
<div className="absolute left-0 top-0 w-full bg-transparent">
<Progress value={progressValue} className="h-[2px] w-full" />
</div>
{summaryTask && (
<div className="mb-2.5 px-sm text-sm font-bold leading-13">
<div className="mb-2.5 px-2 text-sm font-bold leading-13">
{summaryTask.split('|')[0].replace(/"/g, '')}
</div>
)}
{summaryTask && (
<div className={`flex items-center justify-between gap-2 px-sm`}>
<div className="flex items-center justify-between gap-2 px-2">
<div className="flex items-center gap-2">
{taskType === 1 && (
<TaskState
@ -381,7 +381,7 @@ export function TaskCard({
<div className="relative">
{taskType === 1 && (
<div className="ease-[cubic-bezier(0.23,1,0.32,1)] mt-sm flex flex-col px-sm duration-200 animate-in fade-in-0 slide-in-from-bottom-4">
<div className="ease-[cubic-bezier(0.23,1,0.32,1)] mt-2 flex flex-col px-2 duration-200 animate-in fade-in-0 slide-in-from-bottom-4">
{taskInfo.map((task, taskIndex) => (
<div
key={`task-${taskIndex}`}
@ -407,7 +407,7 @@ export function TaskCard({
opacity: isExpanded ? 1 : 0,
}}
>
<div className="mt-sm flex flex-col gap-2 px-2">
<div className="mt-2 flex flex-col gap-2 px-2">
{filterTasks.map((task: TaskInfo) => {
return (
<div
@ -452,7 +452,7 @@ export function TaskCard({
}
}}
key={`taskList-${task.id}`}
className={`ease-[cubic-bezier(0.23,1,0.32,1)] flex gap-2 rounded-lg px-sm py-sm transition-[background-color,border-color] duration-200 animate-in fade-in-0 slide-in-from-left-2 ${
className={`ease-[cubic-bezier(0.23,1,0.32,1)] flex gap-2 rounded-lg px-2 py-2 transition-[background-color,border-color] duration-200 animate-in fade-in-0 slide-in-from-left-2 ${
task.status === TaskStatus.COMPLETED
? 'bg-ds-bg-completed-subtle-default'
: task.status === TaskStatus.FAILED

View file

@ -67,13 +67,13 @@ export function TaskItem({
<div key={`task-item-${taskIndex}`} className="w-full">
<div
onDoubleClick={(e) => 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'
}`}
>
<div className="flex h-4 w-7 flex-shrink-0 cursor-pointer items-center justify-center pr-sm pt-0.5">
<div className="flex h-4 w-7 flex-shrink-0 cursor-pointer items-center justify-center pr-2 pt-0.5">
{taskInfo.id === '' ? (
<CircleDashed
size={13}
@ -105,7 +105,7 @@ export function TaskItem({
)}
</div>
<div
className={`absolute right-2 top-2 flex items-center gap-xs group-hover:opacity-100 ${
className={`absolute right-2 top-2 flex items-center gap-1 group-hover:opacity-100 ${
isFocus ? 'opacity-100' : 'opacity-0'
} transition-opacity duration-300`}
>

View file

@ -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<UserQueryGroupProps> = ({
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<UserQueryGroupProps> = ({
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="px-sm py-sm"
className="px-2 py-2"
>
<UserMessageCard
id={queryGroup.userMessage.id}
@ -526,7 +526,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.05 }}
className="px-sm"
className="px-2"
>
{showPreparingExecute ? <PreparingToExecuteTasks /> : null}
<TaskWorkLogAccordion chatStore={chatStore} taskId={activeTaskId} />
@ -607,7 +607,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-col gap-4"
className="flex flex-col"
>
<AgentMessageCard
typewriter={shouldUseLiveAgentTypewriter(task, message.id)}
@ -639,7 +639,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-col gap-4"
className="flex flex-col"
>
<AgentMessageCard
key={message.id}
@ -656,7 +656,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="px-sm"
className="px-2"
>
<AgentResultCard
id={message.id}
@ -674,7 +674,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-col gap-4"
className="flex flex-col"
>
<AgentMessageCard
key={message.id}
@ -694,7 +694,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="flex flex-col gap-4"
className="flex flex-col"
>
<ArtifactChangeList
files={message.fileList}
@ -723,8 +723,9 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
className="bg-ds-bg-neutral-secondary-default mx-6 my-3 rounded-xl border border-ds-border-neutral-default-default px-4 py-3 text-body-sm text-ds-text-neutral-muted-default"
className="mx-2 flex flex-row items-center gap-2 rounded-xl bg-ds-bg-neutral-default-default px-4 py-3 text-body-sm text-ds-text-neutral-muted-default"
>
<InfoIcon className="size-4 text-ds-icon-neutral-default-default" />
{t('chat.run-no-final-response')}
</motion.div>
) : null}
@ -735,7 +736,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="px-sm"
className="px-2"
>
<PlanTaskBox
chatStore={chatStore}

View file

@ -0,0 +1,74 @@
// ========= 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 { animate } from 'framer-motion';
/** Default breathing room below the top border of the ChatBox scroll viewport. */
export const CHAT_QUERY_HEADER_GAP_PX = 44;
export const CHAT_QUERY_SCROLL_DURATION_SECONDS = 0.8;
const CHAT_QUERY_SCROLL_EASE = [0.22, 1, 0.36, 1] as const;
export type ChatTimelineScrollAnimation = ReturnType<typeof animate>;
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,
});
}

View file

@ -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 {
<div
ref={bottomBoxOverlayRef}
data-bottom-box-overlay
className="pointer-events-none absolute inset-x-0 bottom-0 z-30 flex justify-center"
className="pointer-events-none absolute inset-x-0 bottom-0 z-30 flex justify-center px-2"
>
<div className="pointer-events-auto mx-auto w-full max-w-[600px] rounded-t-3xl bg-ds-bg-neutral-subtle-default px-2 pb-1">
<div className="pointer-events-auto mx-auto w-full max-w-[600px] rounded-t-3xl bg-ds-bg-neutral-subtle-default pb-1">
{interruptedRun && !eventNativeTimelineEnabled && (
<InterruptedRunBanner
compact

View file

@ -0,0 +1,119 @@
// ========= 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 { ProjectEventStoreSnapshot } from '@/store/projectEventStore';
export type EventNativeProjectedRun =
ProjectEventStoreSnapshot['view']['runs'][string];
const PENDING_CONTROL_RUN_STATUSES = new Set([
'pending',
'running',
'waiting_for_user',
'cancelling',
]);
const LIVE_RUN_STATUSES = new Set(['running', 'cancelling']);
const TYPED_HUMAN_REQUEST_EVENT_TYPES = new Set([
'interaction.requested',
'approval.requested',
]);
export function isEventNativeRunActionable(
run: EventNativeProjectedRun
): boolean {
return (
run.origin === 'local' &&
!run.resumeBlockedReason &&
run.lastSequence >= 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;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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": "تعذر إعداد مهمة المتابعة."
}

View file

@ -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 وحدة تحكم",

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -127,5 +127,17 @@
"run-cancel": "Annuler lexécution",
"run-cancelling": "Annulation…",
"run-resume-failed": "Impossible de reprendre cette exécution.",
"run-cancel-failed": "Impossible dannuler cette exécution."
"run-cancel-failed": "Impossible dannuler cette exécution.",
"repeated-tool-events": "{{tool}} · {{count}} événements",
"repeated-tool-calls-label": "Appels doutil 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."
}

View file

@ -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 lattività di follow-up."
}

View file

@ -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": "フォローアップタスクを準備できませんでした。"
}

View file

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

View file

@ -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": "후속 작업을 준비하지 못했습니다."
}

View file

@ -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": "Не удалось подготовить следующую задачу."
}

View file

@ -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": "无法准备后续任务。"
}

View file

@ -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": "無法準備後續任務。"
}

View file

@ -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(`<!doctype html>
<html><head>
<meta http-equiv="Content-Security-Policy" content="default-src *">
</head><body><script>fetch('https://attacker.example')</script></body></html>`);
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:');
});
});

View file

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

View file

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

View file

@ -26,6 +26,12 @@ const TERMINAL_STATUSES = new Set<HumanControlInteraction['status']>([
'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,

View file

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

View file

@ -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<DurableFollowUpRequest> | 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<DurableFollowUpRequest> {
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<DurableFollowUpRequest> {
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<DurableFollowUpRequest> {
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<DurableFollowUpRequest> {
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<DurableFollowUpRequest> {
invalidatePendingFollowUps(projectId);
return fetchPost(
const response = await fetchPost(
`${basePath(projectId)}/${encodeURIComponent(requestId)}/admitted`,
{ run_id: runId }
);
return parseFollowUpRecord(response, 'markFollowUpRequestAdmitted');
}

View file

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

View file

@ -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<ProjectedRun['status']>([
'pending',
@ -265,6 +271,7 @@ export class ProjectRunEventStreamOwner {
}
private async consumeStream(stream: LiveRunStream): Promise<void> {
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
)
);
}
}

View file

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

View file

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

View file

@ -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<string, true>,
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;

View file

@ -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<Omit<Project, 'id' | 'createdAt'>>
@ -909,6 +915,12 @@ const projectStore = create<ProjectStore>()((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<ProjectStore>()((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<ProjectStore>()((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<ProjectStore>()((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<ProjectStore>()((set, get) => ({
console.log(
`[ProjectStore] Overwriting existing project ${projectId} for load`
);
removeProject(projectId);
removeProject(projectId, { preserveEventStore: true });
}
const loadProjectId = createProject(

View file

@ -19,7 +19,7 @@ import { vi } from 'vitest';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
t: (key: string, options: Record<string, unknown> = {}) => {
// Map translation keys to English text
const translations: Record<string, string> = {
'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',

View file

@ -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 }) => (
<div
data-testid="project-setup-footer"
@ -25,13 +25,50 @@ vi.mock('./BoxFooter', () => ({
),
}));
vi.mock('./InputBox', () => ({
Inputbox: ({ value }: { value?: string }) => (
<div data-testid="text-composer">{value}</div>
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;
}) => (
<div data-testid="text-composer">
{header && (header.eyebrow || header.title || header.description) ? (
<section data-bottom-box-header>
{header.eyebrow}
{header.title}
{header.description}
</section>
) : null}
{value}
{files.map((file) => (
<div key={file.filePath}>
{file.fileName}
<button
type="button"
aria-label={`Remove ${file.fileName}`}
onClick={() =>
onFilesChange?.(
files.filter((item) => item.filePath !== file.filePath)
)
}
/>
</div>
))}
</div>
),
}));
vi.mock('./PickerPanel', () => ({
vi.mock('@/components/ChatBox/BottomBox/PickerPanel', () => ({
ConnectorPickerPanel: () => <div />,
SkillPickerPanel: () => <div />,
}));
@ -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(
<BottomBox
state="input"
variant={{
kind: 'input',
header: {
eyebrow: 'Input required',
title: 'Which format should I use?',
},
}}
inputProps={{ value: 'PDF' }}
{...footerProps}
/>
);
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();

View file

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

View file

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

View file

@ -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<typeof import('framer-motion')>();
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(
<EventNativeProjectTimeline
projectId="project-1"
scrollContainerRef={scrollContainerRef}
scrollBottomInsetPx={200}
/>
);
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(
<EventNativeProjectTimeline
projectId="project-1"
scrollContainerRef={scrollContainerRef}
scrollBottomInsetPx={200}
/>
);
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(
<EventNativeProjectTimeline
projectId="project-1"
scrollContainerRef={scrollContainerRef}
scrollBottomInsetPx={200}
/>
);
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(
<EventNativeProjectTimeline
projectId="project-1"
scrollContainerRef={scrollContainerRef}
scrollBottomInsetPx={200}
/>
);
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(
<EventNativeProjectTimeline
projectId="project-1"
scrollContainerRef={scrollContainerRef}
scrollBottomInsetPx={200}
/>
);
vi.mocked(scrollContainer.scrollTo).mockClear();
scrollContainer.scrollTop = 0;
scrollContainer.dispatchEvent(new Event('scroll'));
mocks.projection = projection([messageNode(0), messageNode(1)]);
rerender(
<EventNativeProjectTimeline
projectId="project-1"
scrollContainerRef={scrollContainerRef}
scrollBottomInsetPx={200}
/>
);
expect(scrollContainer.scrollTo).not.toHaveBeenCalled();
});
});

View file

@ -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<ChatProjectionNode, { kind: 'activity' }>> = {}
): Extract<ChatProjectionNode, { kind: 'activity' }> {
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(
<EventTimeline
nodes={[
activityNode('web-fetch-1', 'Fetch first page', {
toolkitName: 'WebFetchToolkit',
methodName: 'Web_fetch_and_analyze',
detail: 'First result',
}),
activityNode('web-fetch-2', 'Fetch second page', {
toolkitName: 'WebFetchToolkit',
methodName: 'Web_fetch_and_analyze',
detail: 'Second result',
}),
activityNode('web-fetch-3', 'Fetch third page', {
toolkitName: 'WebFetchToolkit',
methodName: 'Web_fetch_and_analyze',
detail: 'Third result',
}),
]}
/>
);
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(
<EventTimeline
nodes={[
activityNode('single-fetch', 'Fetch one page', {
toolkitName: 'WebFetchToolkit',
methodName: 'Web_fetch_and_analyze',
}),
]}
/>
);
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(
<EventTimeline
nodes={[
activityNode('fetch-before', 'Fetch before notice', {
toolkitName: 'WebFetchToolkit',
methodName: 'Web_fetch_and_analyze',
}),
noticeNode('fetch-boundary', 'Reviewing fetched sources'),
activityNode('fetch-after', 'Fetch after notice', {
toolkitName: 'WebFetchToolkit',
methodName: 'Web_fetch_and_analyze',
}),
]}
/>
);
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(<EventTimeline nodes={sourceNodes} />);
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(<EventTimeline nodes={[first, second]} />);
fireEvent.click(screen.getByRole('button'));
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
view.rerender(<EventTimeline nodes={[first, second, third]} />);
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(
<EventTimeline

View file

@ -0,0 +1,117 @@
// ========= 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 { groupRepeatedToolCalls } from '@/components/ChatBox/EventTimeline/activityGrouping';
import type { ChatProjectionNode } from '@/lib/projector/chat';
import { describe, expect, it } from 'vitest';
type ActivityNode = Extract<ChatProjectionNode, { kind: 'activity' }>;
function toolNode(
id: string,
overrides: Partial<ActivityNode> = {}
): 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',
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -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 }) => (
<div data-testid="task-work-log" data-task-id={taskId} />
),
}));
vi.mock('./MessageItem/UserMessageCard', () => ({
vi.mock('@/components/ChatBox/MessageItem/UserMessageCard', () => ({
UserMessageCard: ({ content }: { content: string }) => <div>{content}</div>,
}));
vi.mock('./MessageItem/AgentMessageCard', () => ({
vi.mock('@/components/ChatBox/MessageItem/AgentMessageCard', () => ({
AgentMessageCard: ({ content }: { content: string }) => <div>{content}</div>,
}));
vi.mock('./MessageItem/PreparingToExecuteTasks', () => ({
vi.mock('@/components/ChatBox/MessageItem/PreparingToExecuteTasks', () => ({
PreparingToExecuteTasks: () => <div data-testid="preparing" />,
}));
vi.mock('./MessageItem/NoticeCard', () => ({
vi.mock('@/components/ChatBox/MessageItem/NoticeCard', () => ({
NoticeCard: () => <div data-testid="notice" />,
}));
vi.mock('./TaskBox/TaskCard', () => ({
vi.mock('@/components/ChatBox/TaskBox/TaskCard', () => ({
TaskCard: () => <div data-testid="task-card" />,
}));
vi.mock('./TaskBox/PlanTaskBox', () => ({
vi.mock('@/components/ChatBox/TaskBox/PlanTaskBox', () => ({
PlanTaskBox: () => <div data-testid="plan-task-box" />,
}));

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, unknown> = {}) =>
key === 'chat.repeated-tool-events'
? `${options.tool} · ${options.count} events`
: key,
i18n: { language: 'en', changeLanguage: vi.fn() },
}),
}));
type TaggedLog = Parameters<typeof buildAgentBlocks>[0][number];
@ -54,6 +81,233 @@ function findMessage(items: TimelineItem[], idx: number) {
return messages[idx];
}
function makeToolItem(
id: string,
overrides: Partial<Omit<ToolItem, 'kind' | 'id'>> = {}
): 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(
<TaskWorkLogAccordion
chatStore={createWorkLogStore(log)}
taskId="task-1"
/>
);
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);
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,78 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { adaptChatProjectionEvent } from '@/lib/projector/chat';
import type { CanonicalProjectEvent } from '@/lib/projector/types';
import { describe, expect, it } from 'vitest';
function event(payload: Record<string, unknown>): 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',
});
});
});

View file

@ -1,3 +1,17 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { 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(() => {

View file

@ -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(`<!doctype html>
<html><head>
<meta http-equiv="Content-Security-Policy" content="default-src *">
</head><body><script>fetch('https://attacker.example')</script></body></html>`);
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:');
});
});

View file

@ -21,7 +21,7 @@ import {
authorizeLocalPreviewPath,
isExecutableExternalOpenPath,
isMainRendererSender,
} from '../../electron/main/localFileSecurity';
} from '../../../electron/main/localFileSecurity';
const temporaryDirectories: string[] = [];

View file

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

View file

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

View file

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

View file

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

View file

@ -30,7 +30,7 @@ import {
invalidatePendingHumanInteractions,
isHumanInteractionStillPending,
type HumanInteractionPayload,
} from './humanInteractionApi';
} from '@/service/humanInteractionApi';
describe('local HumanInteraction API', () => {
beforeEach(() => {

View file

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

View file

@ -28,7 +28,7 @@ import {
__permissionProfileApiTestHooks,
getSpacePermissionProfile,
putSpacePermissionProfile,
} from './permissionProfileApi';
} from '@/service/permissionProfileApi';
const profile = {
space_id: 'space-1',

View file

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

View file

@ -24,7 +24,7 @@ import {
ProjectRunEventStreamOwner,
selectCanonicalLiveRuns,
type EventStreamTransport,
} from './projectRunEventStream';
} from '@/service/projectRunEventStream';
type RunInput = {
runId: string;

View file

@ -40,7 +40,7 @@ import {
getPublicWorkspaceBundleRevision,
publishWorkspaceBundleRevision,
uploadWorkspaceBundleAsset,
} from './workspaceBundleAuthoringApi';
} from '@/service/workspaceBundleAuthoringApi';
describe('workspace bundle authoring API', () => {
beforeEach(() => {

View file

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

View file

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

View file

@ -28,7 +28,7 @@ import {
executeAdvancedGit,
fetchWorkspaceGitHistory,
previewAdvancedGit,
} from './workspaceGitApi';
} from '@/service/workspaceGitApi';
describe('workspace Git advanced API', () => {
beforeEach(() => {

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more