Feat/refactor session side panel (#1853)

Co-authored-by: 4pmtong <web_chentong@163.com>
This commit is contained in:
Douglas Lai 2026-08-18 18:28:23 +01:00 committed by GitHub
parent 5c565a5a16
commit b439d5ff23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 6219 additions and 3081 deletions

View file

@ -38,6 +38,7 @@ from app.utils.listen.toolkit_listen import (
from app.utils.space_overlay_client import (
path_write_lock,
post_overlay_write,
relative_to_artifact_root,
relative_to_workdir,
run_context_for_task,
sha256_of_file,
@ -116,6 +117,7 @@ class FileToolkit(BaseFileToolkit, AbstractToolkit):
) -> str:
run_context = run_context_for_task(self.api_task_id)
prepared_write = None
relative_path = None
operation_request_id = None
mutation_service = None
if run_context is not None:
@ -154,6 +156,7 @@ class FileToolkit(BaseFileToolkit, AbstractToolkit):
"Content successfully written to file: "
f"{prepared_write.relative_path}"
)
relative_path = prepared_write.relative_path
else:
res = self._write_legacy_or_cloud_overlay(
title=title,
@ -163,6 +166,13 @@ class FileToolkit(BaseFileToolkit, AbstractToolkit):
use_latex=use_latex,
)
if "Content successfully written to file: " in res:
written_path = res.replace(
"Content successfully written to file: ", ""
)
if relative_path is None and run_context is not None:
relative_path = relative_to_artifact_root(
run_context, written_path
)
task_lock = get_task_lock(self.api_task_id)
# Capture ContextVar value before creating async task
current_process_task_id = process_task.get("")
@ -172,9 +182,8 @@ class FileToolkit(BaseFileToolkit, AbstractToolkit):
task_lock,
ActionWriteFileData(
process_task_id=current_process_task_id,
data=res.replace(
"Content successfully written to file: ", ""
),
data=written_path,
relative_path=relative_path,
),
)
return res

View file

@ -29,6 +29,10 @@ from app.utils.listen.toolkit_listen import (
auto_listen_toolkit,
listen_toolkit,
)
from app.utils.space_overlay_client import (
relative_to_artifact_root,
run_context_for_task,
)
@auto_listen_toolkit(BasePPTXToolkit)
@ -50,10 +54,9 @@ class PPTXToolkit(BasePPTXToolkit, AbstractToolkit):
@listen_toolkit(
BasePPTXToolkit.create_presentation,
lambda _,
content,
filename,
template=None: f"create presentation with content: {content}, filename: {filename}, template: {template}",
lambda _, content, filename, template=None: (
f"create presentation with content: {content}, filename: {filename}, template: {template}"
),
)
def create_presentation(
self, content: str, filename: str, template: str | None = None
@ -64,6 +67,12 @@ class PPTXToolkit(BasePPTXToolkit, AbstractToolkit):
file_path = self._resolve_filepath(filename)
res = super().create_presentation(content, filename, template)
if "PowerPoint presentation successfully created" in res:
run_context = run_context_for_task(self.api_task_id)
relative_path = (
relative_to_artifact_root(run_context, file_path)
if run_context is not None
else None
)
task_lock = get_task_lock(self.api_task_id)
# Capture ContextVar value before creating async task
current_process_task_id = process_task.get("")
@ -74,6 +83,7 @@ class PPTXToolkit(BasePPTXToolkit, AbstractToolkit):
ActionWriteFileData(
process_task_id=current_process_task_id,
data=str(file_path),
relative_path=relative_path,
),
)
return res

View file

@ -83,6 +83,7 @@ from app.service.task import (
TaskLock,
delete_task_lock,
set_current_task_id,
write_file_event_payload,
)
from app.utils.agent_memory import (
build_memory_context,
@ -1745,13 +1746,7 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
elif item.action == Action.deactivate_toolkit:
yield sse_json("deactivate_toolkit", item.data)
elif item.action == Action.write_file:
yield sse_json(
"write_file",
{
"file_path": item.data,
"process_task_id": item.process_task_id,
},
)
yield sse_json("write_file", write_file_event_payload(item))
elif item.action == Action.ask:
yield sse_json("ask", item.data)
elif item.action == Action.notice:

View file

@ -44,6 +44,7 @@ from app.service.task import (
TaskLock,
delete_task_lock,
set_current_task_id,
write_file_event_payload,
)
from app.utils.agent_memory import (
build_memory_context,
@ -345,13 +346,7 @@ def _action_to_sse(item: ActionData) -> str | None:
if item.action == Action.deactivate_toolkit:
return sse_json("deactivate_toolkit", item.data)
if item.action == Action.write_file:
return sse_json(
"write_file",
{
"file_path": item.data,
"process_task_id": item.process_task_id,
},
)
return sse_json("write_file", write_file_event_payload(item))
if item.action == Action.ask:
return sse_json("ask", item.data)
if item.action == Action.notice:

View file

@ -219,6 +219,17 @@ class ActionWriteFileData(BaseModel):
action: Literal[Action.write_file] = Action.write_file
process_task_id: str
data: str
relative_path: str | None = None
def write_file_event_payload(item: ActionWriteFileData) -> dict[str, str]:
payload = {
"file_path": item.data,
"process_task_id": item.process_task_id,
}
if item.relative_path:
payload["relative_path"] = item.relative_path
return payload
class ActionNoticeData(BaseModel):

View file

@ -145,6 +145,38 @@ def relative_to_workdir(
return normalize_relative_path(rel.as_posix()), target
def relative_to_artifact_root(
context: RunContext, path: str | Path
) -> str | None:
"""Return portable identity only for paths owned by the active Run.
Artifact finalization scans the task output root first and the workspace
root second. Mirror that boundary here so realtime write events can carry
the same relative identity without exposing an absolute local path as
portable identity.
"""
target = Path(path).expanduser()
if not target.is_absolute():
target = context.working_directory.expanduser().resolve() / target
target = target.resolve()
roots = (
context.task_output_root.expanduser().resolve(),
context.working_directory.expanduser().resolve(),
)
seen_roots: set[Path] = set()
for root in roots:
if root in seen_roots:
continue
seen_roots.add(root)
try:
relative_path = target.relative_to(root).as_posix()
return normalize_relative_path(relative_path)
except ValueError:
continue
return None
def should_record_overlay(context: RunContext, target: Path) -> bool:
if not context.server_url or not context.auth_header:
return False

View file

@ -6,7 +6,9 @@ from types import SimpleNamespace
from app.agent.toolkit import file_write_toolkit
from app.agent.toolkit.file_write_toolkit import FileToolkit
from app.run_context import RunContext, run_context_scope
from app.service.task import ActionWriteFileData
from app.utils.listen import toolkit_listen
from app.utils.space_overlay_client import relative_to_artifact_root
def _context(root: Path) -> RunContext:
@ -72,11 +74,11 @@ def test_file_toolkit_routes_git_run_write_before_dispatch(
"_safe_put_queue",
lambda _lock, _event: None,
)
emitted: list[str] = []
emitted: list[ActionWriteFileData] = []
monkeypatch.setattr(
file_write_toolkit,
"_safe_put_queue",
lambda _lock, event: emitted.append(event.data),
lambda _lock, event: emitted.append(event),
)
toolkit = FileToolkit(
"project-1",
@ -95,4 +97,73 @@ def test_file_toolkit_routes_git_run_write_before_dispatch(
assert target.read_text() == "durable output"
assert not (user_root / "report.md").exists()
assert result == "Content successfully written to file: report.md"
assert emitted == ["report.md"]
assert len(emitted) == 1
assert emitted[0].data == "report.md"
assert emitted[0].relative_path == "report.md"
def test_file_toolkit_emits_safe_relative_path_for_legacy_run_write(
tmp_path,
monkeypatch,
):
root = tmp_path / "run"
root.mkdir()
emitted: list[ActionWriteFileData] = []
class _MutationService:
def prepare_file_write(self, **_kwargs):
return None
monkeypatch.setattr(
file_write_toolkit,
"get_default_workspace_mutation_service",
lambda: _MutationService(),
)
monkeypatch.setattr(
file_write_toolkit,
"get_task_lock",
lambda _task_id: object(),
)
monkeypatch.setattr(
toolkit_listen,
"get_task_lock",
lambda _task_id: object(),
)
monkeypatch.setattr(
toolkit_listen,
"_safe_put_queue",
lambda _lock, _event: None,
)
monkeypatch.setattr(
file_write_toolkit,
"_safe_put_queue",
lambda _lock, event: emitted.append(event),
)
toolkit = FileToolkit(
"project-1",
working_directory=str(root),
backup_enabled=False,
)
with run_context_scope(_context(root)):
result = toolkit.write_to_file(
"report",
"durable output",
"reports/report.md",
)
written_path = root / "reports" / "report.md"
assert written_path.read_text() == "durable output"
assert result == f"Content successfully written to file: {written_path}"
assert len(emitted) == 1
assert emitted[0].data == str(written_path)
assert emitted[0].relative_path == "reports/report.md"
def test_artifact_relative_path_rejects_file_outside_run_roots(tmp_path):
root = tmp_path / "run"
root.mkdir()
outside = tmp_path / "outside.md"
outside.write_text("not run-owned")
assert relative_to_artifact_root(_context(root), outside) is None

View file

@ -0,0 +1,92 @@
# ========= 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. =========
from __future__ import annotations
from pathlib import Path
from camel.toolkits import PPTXToolkit as BasePPTXToolkit
from app.agent.toolkit import pptx_toolkit
from app.agent.toolkit.pptx_toolkit import PPTXToolkit
from app.run_context import RunContext, run_context_scope
from app.service.task import ActionWriteFileData
from app.utils.listen import toolkit_listen
def _context(root: Path) -> RunContext:
return RunContext(
space_id="space-1",
project_id="project-1",
run_id="run-1",
task_id="task-1",
email="user@example.com",
user_id="user-1",
working_directory=root,
task_output_root=root,
camel_log_dir=root / ".logs",
binding_source="test",
workdir_mode="direct-write",
browser_port=9222,
)
def test_pptx_toolkit_emits_safe_relative_path(tmp_path, monkeypatch):
root = tmp_path / "run"
root.mkdir()
emitted: list[ActionWriteFileData] = []
def fake_create_presentation(self, _content, filename, _template=None):
path = self._resolve_filepath(filename)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"pptx")
return f"PowerPoint presentation successfully created: {path}"
monkeypatch.setattr(
BasePPTXToolkit,
"create_presentation",
fake_create_presentation,
)
monkeypatch.setattr(
pptx_toolkit,
"get_task_lock",
lambda _task_id: object(),
)
monkeypatch.setattr(
toolkit_listen,
"get_task_lock",
lambda _task_id: object(),
)
monkeypatch.setattr(
toolkit_listen,
"_safe_put_queue",
lambda _lock, _event: None,
)
monkeypatch.setattr(
pptx_toolkit,
"_safe_put_queue",
lambda _lock, event: emitted.append(event),
)
toolkit = PPTXToolkit("project-1", working_directory=str(root))
with run_context_scope(_context(root)):
result = toolkit.create_presentation("# Slides", "decks/briefing")
expected_path = root / "decks" / "briefing.pptx"
assert result == (
f"PowerPoint presentation successfully created: {expected_path}"
)
assert len(emitted) == 1
assert emitted[0].data == str(expected_path)
assert emitted[0].relative_path == "decks/briefing.pptx"

View file

@ -52,6 +52,50 @@ def test_retryable_turn_error_classification(error, expected):
assert _is_retryable_turn_error(error) is expected
def test_write_file_sse_carries_portable_relative_identity():
from app.service.single_agent_service import _action_to_sse
from app.service.task import ActionWriteFileData
line = _action_to_sse(
ActionWriteFileData(
process_task_id="task-1",
data="/private/run/reports/summary.md",
relative_path="reports/summary.md",
)
)
assert line is not None
assert _parse_sse(line) == (
"write_file",
{
"file_path": "/private/run/reports/summary.md",
"process_task_id": "task-1",
"relative_path": "reports/summary.md",
},
)
def test_write_file_sse_omits_untrusted_relative_identity():
from app.service.single_agent_service import _action_to_sse
from app.service.task import ActionWriteFileData
line = _action_to_sse(
ActionWriteFileData(
process_task_id="task-1",
data="/outside/summary.md",
)
)
assert line is not None
assert _parse_sse(line) == (
"write_file",
{
"file_path": "/outside/summary.md",
"process_task_id": "task-1",
},
)
@pytest.mark.asyncio
async def test_retryable_model_error_emits_resume_metadata_and_interrupts():
from app.model.chat import Chat

View file

@ -14,7 +14,6 @@
import { fetchPut } from '@/api/http';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import type { SelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
import { useHost } from '@/host';
import { TaskStatus } from '@/types/constants';
import {
@ -33,11 +32,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TaskState } from '../TaskState';
import { Button } from '../ui/button';
export default function BrowserAgentWorkspace({
selectedTurn,
}: {
selectedTurn?: SelectedProjectTurn;
}) {
export default function BrowserAgentWorkspace() {
//Get Chatstore for the active project's task
const { chatStore, projectStore } = useChatStoreAdapter();
const host = useHost();
@ -101,10 +96,8 @@ export default function BrowserAgentWorkspace({
},
};
// Extract complex expressions to avoid lint error in dependency array
const selectedChatState = selectedTurn?.chatStore?.getState();
const targetChatStore = selectedChatState ?? chatStore;
const activeTaskId =
selectedTurn?.taskId ?? (targetChatStore?.activeTaskId as string);
const targetChatStore = chatStore;
const activeTaskId = targetChatStore?.activeTaskId as string;
const taskAssigning = targetChatStore?.tasks[activeTaskId]?.taskAssigning;
const activeWorkspace = targetChatStore?.tasks[activeTaskId]?.activeWorkspace;

View file

@ -13,12 +13,12 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { Button } from '@/components/ui/button';
import { useProjectEventStoreHydration } from '@/hooks/useProjectEventStoreHydration';
import { useProjectChatProjection } from '@/hooks/useProjectEventView';
import { useProjectEventRuntime } from '@/hooks/useProjectEventRuntime';
import {
selectRenderableChatNodes,
type ChatProjectionNode,
} from '@/lib/projector/chat';
import { usePageTabStore } from '@/store/pageTabStore';
import {
useEffect,
useLayoutEffect,
@ -127,13 +127,12 @@ export function EventNativeProjectTimeline({
scrollBottomInsetPx,
}: EventNativeProjectTimelineProps) {
const { t } = useTranslation();
const hydration = useProjectEventStoreHydration({
projectId,
enabled: true,
});
const projection = useProjectChatProjection(projectId);
const runtime = useProjectEventRuntime();
const hydration = runtime.hydration;
const projection =
runtime.projectId === projectId ? runtime.snapshot?.chat : undefined;
const allNodes = useMemo(
() => selectRenderableChatNodes(projection),
() => (projection ? selectRenderableChatNodes(projection) : []),
[projection]
);
const timelineWindow = useMemo(
@ -148,6 +147,12 @@ export function EventNativeProjectTimeline({
const ignoreAnchorScrollRef = useRef(false);
const anchorAnimationRef = useRef<ChatTimelineScrollAnimation | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const scrollToTurnRequest = usePageTabStore(
(state) => state.scrollToTurnRequest
);
const setScrollToTurnRequest = usePageTabStore(
(state) => state.setScrollToTurnRequest
);
const latestNode = visibleNodes.at(-1);
const latestEventId = latestNode?.eventId;
const userMessageNodes = visibleNodes.filter(
@ -258,6 +263,41 @@ export function EventNativeProjectTimeline({
[]
);
useEffect(() => {
if (
!scrollToTurnRequest ||
scrollToTurnRequest.projectId !== projectId ||
!scrollContainerRef?.current
) {
return;
}
const container = scrollContainerRef.current;
const target = Array.from(
container.querySelectorAll<HTMLElement>('[data-run-id]')
).find(
(element) =>
element.getAttribute('data-run-id') === scrollToTurnRequest.taskId
);
// A request is a one-shot command. Clear it even when the requested Run
// is outside the bounded DOM window so it cannot fire unexpectedly after
// later timeline updates mount that Run.
setScrollToTurnRequest(null);
if (!target) return;
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
container.scrollTo({
top: container.scrollTop + targetRect.top - containerRect.top,
behavior: 'smooth',
});
}, [
projectId,
scrollContainerRef,
scrollToTurnRequest,
setScrollToTurnRequest,
visibleNodes,
]);
return (
<div
className="relative z-10 w-full"

View file

@ -254,63 +254,6 @@ export const ProjectChatContainer: React.FC<ProjectChatContainerProps> = ({
};
}, [chatStores, scrollContainerRef]);
// Turn viewport observer — updates which turn is visible in the side panel tabs
const setSidePanelViewedTurn = usePageTabStore(
(s) => s.setSidePanelViewedTurn
);
const turnObserverRef = useRef<IntersectionObserver | null>(null);
const visibleTurnScoresRef = useRef(new Map<string, number>());
const turnIdsKey = taskSections.map(({ taskId }) => taskId).join('|');
useEffect(() => {
const root = scrollContainerRef.current;
if (!root || !activeProjectId) return;
turnObserverRef.current?.disconnect();
visibleTurnScoresRef.current.clear();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const taskId = entry.target.getAttribute('data-turn-id');
if (!taskId) continue;
if (!entry.isIntersecting) {
visibleTurnScoresRef.current.delete(taskId);
continue;
}
const visibleHeight = entry.intersectionRect.height;
const availableHeight = Math.min(
entry.boundingClientRect.height,
root.clientHeight
);
visibleTurnScoresRef.current.set(
taskId,
availableHeight > 0 ? visibleHeight / availableHeight : 0
);
}
let bestTaskId: string | null = null;
let bestScore = 0;
for (const [taskId, score] of visibleTurnScoresRef.current) {
if (score > bestScore) {
bestTaskId = taskId;
bestScore = score;
}
}
if (bestTaskId) {
setSidePanelViewedTurn(activeProjectId, bestTaskId);
}
},
{ root, threshold: [0, 0.01, 0.25, 0.5, 0.75, 1] }
);
turnObserverRef.current = observer;
root
.querySelectorAll('[data-turn-id]')
.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [turnIdsKey, scrollContainerRef, activeProjectId, setSidePanelViewedTurn]);
// Scroll to a specific query group when triggered from the sidebar
const scrollToQueryId = usePageTabStore((s) => s.scrollToQueryId);
const setScrollToQueryId = usePageTabStore((s) => s.setScrollToQueryId);
@ -332,7 +275,7 @@ export const ProjectChatContainer: React.FC<ProjectChatContainerProps> = ({
setScrollToQueryId(null);
}, [scrollToQueryId, setScrollToQueryId, scrollContainerRef]);
// Scroll to a specific turn when triggered from TurnTabs
// Scroll to a historical Run when requested by the Session side panel.
const scrollToTurnRequest = usePageTabStore((s) => s.scrollToTurnRequest);
const setScrollToTurnRequest = usePageTabStore(
(s) => s.setScrollToTurnRequest

View file

@ -218,9 +218,26 @@ They are not dead code, but nothing exercises them yet:
- `EventTimeline/presentationPolicy.ts` is driven by the `detailLevel` prop.
`EventNativeProjectTimeline` has no caller that passes it, so `'detailed'` is
always in force until a detail-level control is wired up.
- `VITE_CHATBOX_EVENT_BUS` gates the entire event-native read and control path
and is unset in every checked-in env file, so the legacy path is what ships
by default. Set it locally to review the new surfaces.
- `VITE_CHATBOX_EVENT_BUS` gates the ChatBox event-native renderer and control
path and is unset in every checked-in env file, so the legacy conversation
renderer ships by default. It does **not** gate the Session-level Project
event runtime or the new SidePanel.
### Project runtime cutover
`ProjectEventRuntimeProvider` is mounted by the Session shell whenever a
Project is active. The SidePanel uses that durable snapshot as its Run and
activity source even while ChatBox still renders its legacy path. This runtime
ownership is therefore an intentional default cutover, not a staged surface
behind `VITE_CHATBOX_EVENT_BUS`.
An HTTP 404 from Project replay is treated as an unsupported backend
capability and stops automatic retry for that Project-store incarnation;
manual retry remains available. Network and 5xx failures keep the bounded
exponential retry path. The Files lane still watches the scoped legacy
ChatStore task to know when resolver metadata may have changed, but Project
filesystem results may only enrich durable artifact rows and never create Run
ownership.
Remove an entry here as soon as its caller lands.

View file

@ -24,7 +24,7 @@ import { isWeb } from '@/client/platform';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useInterruptedRunStatus } from '@/hooks/useInterruptedRunStatus';
import { useModelConfigCheck } from '@/hooks/useModelConfigCheck';
import { useProjectRunEventStreams } from '@/hooks/useProjectRunEventStreams';
import { useProjectEventRuntime } from '@/hooks/useProjectEventRuntime';
import { useHost } from '@/host';
import { generateUniqueId, SITE_URL } from '@/lib';
import {
@ -46,10 +46,7 @@ import { useAuthStore } from '@/store/authStore';
import { isChatEventTimelineEnabled } from '@/store/chatEventProjectionBridge';
import { buildProjectContinuationContext } from '@/store/chatStore';
import { usePageTabStore } from '@/store/pageTabStore';
import {
getProjectEventStore,
type ProjectEventStoreSnapshot,
} from '@/store/projectEventStore';
import type { ProjectEventStoreSnapshot } from '@/store/projectEventStore';
import { useSpaceStore } from '@/store/spaceStore';
import { ExecutionStatus } from '@/types';
import { AgentStep, ChatTaskStatus, SessionMode } from '@/types/constants';
@ -60,7 +57,6 @@ import {
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
@ -101,8 +97,6 @@ const READ_ONLY_EVENT_NATIVE_RUN_STATUSES = new Set([
'interrupted',
]);
const subscribeToNothing = () => () => undefined;
type EventNativeProjectedRun =
ProjectEventStoreSnapshot['view']['runs'][string];
@ -316,32 +310,12 @@ export default function ChatBox(): JSX.Element {
);
const activeProjectId = projectStore.activeProjectId;
const eventNativeTimelineEnabled = isChatEventTimelineEnabled();
const eventNativeProjectStore = useMemo(
() =>
eventNativeTimelineEnabled && activeProjectId
? getProjectEventStore(activeProjectId)
: null,
[activeProjectId, eventNativeTimelineEnabled]
);
const subscribeToEventNativeProject = useCallback(
(listener: () => void) =>
eventNativeProjectStore?.subscribe(listener) ?? subscribeToNothing(),
[eventNativeProjectStore]
);
const getEventNativeProjectSnapshot = useCallback(
() => eventNativeProjectStore?.getSnapshot() ?? null,
[eventNativeProjectStore]
);
const eventNativeProjectSnapshot = useSyncExternalStore(
subscribeToEventNativeProject,
getEventNativeProjectSnapshot,
getEventNativeProjectSnapshot
);
useProjectRunEventStreams({
projectId: activeProjectId,
snapshot: eventNativeProjectSnapshot,
enabled: eventNativeTimelineEnabled,
});
const { snapshot: sharedProjectEventSnapshot } = useProjectEventRuntime();
const eventNativeProjectSnapshot =
eventNativeTimelineEnabled &&
sharedProjectEventSnapshot?.view.projectId === activeProjectId
? sharedProjectEventSnapshot
: null;
const eventNativeReadOnlyRun = selectLatestReadOnlyEventNativeRun(
eventNativeProjectSnapshot
);
@ -1862,7 +1836,7 @@ export default function ChatBox(): JSX.Element {
const handleEventNativeStopRun = async (runId: string) => {
const currentRunId = selectEventNativeActiveRunId(
eventNativeProjectStore?.getSnapshot() ?? null,
eventNativeProjectSnapshot,
eligibleLegacyActiveRunId
);
if (runId !== currentRunId) return;

View file

@ -16,7 +16,7 @@ import larkIcon from '@/assets/icon/lark.png';
import telegramIcon from '@/assets/icon/telegram.svg';
import whatsappIcon from '@/assets/icon/whatsapp.svg';
import { isDesktop } from '@/client/platform';
import { SESSION_SIDE_PANEL_CONTENT_WIDTH_CLASS } from '@/components/Session/sessionSidePanelLayout';
import { SESSION_SIDE_PANEL_CONTENT_WIDTH_CLASS } from '@/components/Session/SidePanel/layout';
import { Button } from '@/components/ui/button';
import {
createRemoteControlSession,

View file

@ -57,9 +57,8 @@ import FolderComponent from './FolderComponent';
import { fetchGet, getBaseURL } from '@/api/http';
import { MarkDown } from '@/components/ChatBox/MessageItem/MarkDown';
import { getSidePanelOutputFilesRevision } from '@/components/Session/SidePanelSections/collectSidePanelOutputFiles';
import { getSidePanelOutputFilesRevision } from '@/components/Session/SidePanel/sections/collectSidePanelOutputFiles';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
import { useHost } from '@/host';
import { filterVisibleAgentFiles } from '@/lib/agentFileFilters';
import { loadFilePreview } from '@/lib/filePreviewLoader';
@ -898,7 +897,6 @@ export default function Folder({ data: _data }: { data?: Agent }) {
const authStore = useAuthStore();
const activeSpaceId = useSpaceStore((s) => s.activeSpaceId);
const activeProjectId = projectStore.activeProjectId;
const selectedTurn = useSelectedProjectTurn(activeProjectId);
const activeProjectMeta = useSpaceStore((s) =>
activeProjectId ? s.getProjectMeta(activeProjectId) : null
);
@ -957,10 +955,8 @@ export default function Folder({ data: _data }: { data?: Agent }) {
const [isFileSidebarOpen, setIsFileSidebarOpen] = useState(true);
const rememberSelectedFile = (file: FileInfo) => {
if (!selectedTurn.chatStore || !selectedTurn.taskId) return;
selectedTurn.chatStore
.getState()
.setSelectedFile(selectedTurn.taskId, file);
if (!chatStore?.activeTaskId) return;
chatStore.setSelectedFile(chatStore.activeTaskId, file);
};
const filteredFileTree = useMemo(
@ -1056,10 +1052,11 @@ export default function Folder({ data: _data }: { data?: Agent }) {
});
};
const activeTaskId = selectedTurn.taskId ?? undefined;
const activeTaskId = chatStore?.activeTaskId ?? undefined;
const activeTask = activeTaskId ? chatStore?.tasks[activeTaskId] : undefined;
const projectedFileRevision = useMemo(
() => getSidePanelOutputFilesRevision(selectedTurn.task),
[selectedTurn.task]
() => getSidePanelOutputFilesRevision(activeTask),
[activeTask]
);
const projectId = (activeProjectId as string) || activeTaskId || '';
const fileSpaceId = resolvedSpaceId;
@ -1348,7 +1345,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
setFileTree(tree);
// Keep the old structure for compatibility
setFileGroups((prev) => {
const chatStoreSelectedFile = selectedTurn.task?.selectedFile;
const chatStoreSelectedFile = activeTask?.selectedFile;
if (chatStoreSelectedFile) {
const file = findMatchingFile(
nextVisibleFiles,
@ -1436,10 +1433,10 @@ export default function Folder({ data: _data }: { data?: Agent }) {
hasFetchedRemote.current = false;
}, [projectId, activeTaskId]);
const selectedFilePath = selectedTurn.task?.selectedFile?.path;
const selectedFilePath = activeTask?.selectedFile?.path;
useEffect(() => {
const chatStoreSelectedFile = selectedTurn.task?.selectedFile;
const chatStoreSelectedFile = activeTask?.selectedFile;
if (chatStoreSelectedFile && fileGroups[0]?.files) {
const file = findMatchingFile(fileGroups[0].files, chatStoreSelectedFile);
if (file) {
@ -1454,7 +1451,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
setSelectedFile(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedFilePath, fileGroups, isShowSourceCode, selectedTurn.taskId]);
}, [selectedFilePath, fileGroups, isShowSourceCode, activeTaskId]);
const fileBreadcrumbSegments = useMemo(() => {
if (!selectedFile) return [];

View file

@ -0,0 +1,141 @@
// ========= 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 {
SessionPanelButton,
SessionPanelCollapse,
type SessionPanelRowVariant,
} from '@/components/Session/SidePanel/sections/primitives';
import { cn } from '@/lib/utils';
import { motion, useReducedMotion } from 'framer-motion';
import { type ReactNode, useState } from 'react';
const CONTENT_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
const LAYOUT_TRANSITION = {
layout: { duration: 0.28, ease: CONTENT_EASE },
} as const;
export type SidePanelAccordionRenderArgs = { open: boolean };
export type SidePanelAccordionChildren =
ReactNode | ((state: SidePanelAccordionRenderArgs) => ReactNode);
export function SidePanelAccordionBox({
title,
titleSuffix,
headerAction,
leading,
rowVariant = 'section',
contentClassName,
collapsedPreview,
children,
defaultOpen = true,
}: {
title: string;
/** Small adornment rendered right after the title (e.g. count pill). */
titleSuffix?: ReactNode;
/** Independent action rendered beside the section title (never toggles the accordion). */
headerAction?: ReactNode;
/** Optional leading icon; main section rows intentionally omit this. */
leading?: ReactNode;
/** Shared row appearance used by main sections and nested categories. */
rowVariant?: SessionPanelRowVariant;
contentClassName?: string;
/**
* Compact content below the header when collapsed (static `children` only;
* render-prop children control their own open/closed layout).
*/
collapsedPreview?: ReactNode;
/**
* Static: classic accordion body hidden when closed.
* Render prop: body stays in one region; switch layout by `open` (e.g. summary vs full list).
*/
children: SidePanelAccordionChildren;
defaultOpen?: boolean;
}) {
const shouldReduceMotion = useReducedMotion();
const [open, setOpen] = useState(defaultOpen);
const isRenderProp = typeof children === 'function';
const dynamicBody = isRenderProp
? (children as (s: SidePanelAccordionRenderArgs) => ReactNode)({ open })
: null;
const stickyHeader = rowVariant === 'section';
return (
<motion.div
layout={!shouldReduceMotion}
transition={
shouldReduceMotion ? { layout: { duration: 0 } } : LAYOUT_TRANSITION
}
className="z-10 flex min-w-0 shrink-0 flex-col overflow-visible"
>
<div
className={cn(
'flex h-10 min-h-10 w-full shrink-0 items-center',
stickyHeader && 'sticky top-0 z-20 bg-ds-bg-neutral-default-default'
)}
>
<div className="min-w-0 flex-1">
<SessionPanelButton
variant={rowVariant}
leading={leading}
badge={titleSuffix}
chevron
open={open}
ariaExpanded={open}
onClick={() => setOpen((value) => !value)}
>
{title}
</SessionPanelButton>
</div>
{headerAction ? (
<div className="flex h-10 shrink-0 items-center pr-0.5">
{headerAction}
</div>
) : null}
</div>
{isRenderProp ? (
<SessionPanelCollapse open={dynamicBody != null}>
<motion.div
layout={!shouldReduceMotion}
transition={
shouldReduceMotion
? { layout: { duration: 0 } }
: LAYOUT_TRANSITION
}
className={cn('w-full', contentClassName)}
>
{dynamicBody}
</motion.div>
</SessionPanelCollapse>
) : (
<>
<SessionPanelCollapse open={open}>
<div className={cn('w-full', contentClassName)}>
{children as ReactNode}
</div>
</SessionPanelCollapse>
{collapsedPreview ? (
<SessionPanelCollapse open={!open}>
<div className={cn('w-full', contentClassName)}>
{collapsedPreview}
</div>
</SessionPanelCollapse>
) : null}
</>
)}
</motion.div>
);
}

View file

@ -0,0 +1,935 @@
// ========= 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 {
fetchConnectedProviders,
type ConnectorProvider,
} from '@/api/connectors';
import { uploadFileToBrain } from '@/api/http';
import { isWeb } from '@/client/platform';
import { SidePanelAccordionBox } from '@/components/Session/SidePanel/components/AccordionBox';
import {
buildProjectSessionPanelData,
isProgressDone,
mergeProjectFiles,
type ProjectSessionPanelData,
type SessionAgentItem,
type SessionContextItem,
type SessionEnvironmentItem,
type SessionFileItem,
type SessionProgressItem,
type SessionResourceItem,
} from '@/components/Session/SidePanel/sections/buildProjectSessionPanelData';
import {
CountPill,
EarlierItems,
ProgressCircle,
SidePanelListRow,
} from '@/components/Session/SidePanel/sections/primitives';
import {
arrangeSessionPanelItems,
selectSessionPanelRuns,
type SessionPanelScope,
} from '@/components/Session/SidePanel/sections/sessionPanelScope';
import {
AgentInformationDialog,
ToolCallsDialog,
} from '@/components/Session/SidePanel/sections/SessionSidePanelDialogs';
import { useProjectOutputFiles } from '@/components/Session/SidePanel/sections/useProjectOutputFiles';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { TooltipSimple } from '@/components/ui/tooltip';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useProjectEventRuntime } from '@/hooks/useProjectEventRuntime';
import { useProjectSessionOverview } from '@/hooks/useProjectSessionOverview';
import { useHost } from '@/host';
import { usePageTabStore } from '@/store/pageTabStore';
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
import { useSkillsStore } from '@/store/skillsStore';
import {
AlertTriangle,
Bot,
Boxes,
ExternalLink,
FileText,
Globe,
Hammer,
MonitorCog,
Plus,
SquareTerminal,
WandSparkles,
} from 'lucide-react';
import {
Children,
Fragment,
startTransition,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
const EMPTY_PANEL_DATA: ProjectSessionPanelData = {
agents: [],
contextItems: [],
environments: [],
files: [],
progress: [],
resources: [],
toolCalls: [],
};
const PANEL_REBUILD_INTERVAL_MS = 32;
function useDeferredProjectSessionPanelData(
projectId: string | null,
runs: Parameters<typeof buildProjectSessionPanelData>[0],
skills: Parameters<typeof buildProjectSessionPanelData>[1],
connectors: Parameters<typeof buildProjectSessionPanelData>[2]
): ProjectSessionPanelData {
const [state, setState] = useState<{
data: ProjectSessionPanelData;
projectId: string | null;
}>({ data: EMPTY_PANEL_DATA, projectId });
const latestInputRef = useRef({ connectors, projectId, runs, skills });
const pendingRef = useRef<ReturnType<typeof globalThis.setTimeout> | null>(
null
);
useEffect(() => {
latestInputRef.current = { connectors, projectId, runs, skills };
if (pendingRef.current !== null) return;
pendingRef.current = globalThis.setTimeout(() => {
pendingRef.current = null;
const latest = latestInputRef.current;
const data = buildProjectSessionPanelData(
latest.runs,
latest.skills,
latest.connectors
);
startTransition(() => setState({ data, projectId: latest.projectId }));
}, PANEL_REBUILD_INTERVAL_MS);
}, [connectors, projectId, runs, skills]);
useEffect(
() => () => {
if (pendingRef.current !== null) {
globalThis.clearTimeout(pendingRef.current);
}
},
[]
);
return state.projectId === projectId ? state.data : EMPTY_PANEL_DATA;
}
function SimpleRows<T>({
items,
render,
}: {
items: T[];
render: (item: T) => ReactNode;
}) {
return <div className="flex min-w-0 flex-col">{items.map(render)}</div>;
}
function SectionList({ children }: { children: ReactNode }) {
const sections = Children.toArray(children);
return (
<div className="flex min-w-0 flex-col gap-1 py-1">
{sections.map((section, index) => (
<Fragment key={index}>
{index > 0 ? (
<div
role="separator"
className="h-px w-full shrink-0 bg-ds-border-neutral-subtle-disabled"
/>
) : null}
{section}
</Fragment>
))}
</div>
);
}
function AgentCategorySection({
title,
items,
scope,
headerAction,
onSelect,
}: {
title: string;
items: SessionAgentItem[];
scope: SessionPanelScope;
headerAction?: ReactNode;
onSelect: (item: SessionAgentItem) => void;
}) {
const { t } = useTranslation();
const { primary, earlier } = arrangeSessionPanelItems(items, scope);
const rows = (agentItems: SessionAgentItem[]) => (
<SimpleRows
items={agentItems}
render={(item) => (
<SidePanelListRow
key={item.id}
leading={
item.subagent ? (
<Boxes size={16} aria-hidden />
) : (
<Bot size={16} aria-hidden />
)
}
onClick={() => onSelect(item)}
>
{item.name ||
(item.subagent
? t('layout.session-panel-remote-subagent', {
defaultValue: 'Remote subagent',
})
: t('agents.agent', { defaultValue: 'Agent' }))}
</SidePanelListRow>
)}
/>
);
return (
<SidePanelAccordionBox
title={title}
titleSuffix={<CountPill count={primary.length} />}
headerAction={headerAction}
defaultOpen={false}
>
{rows(primary)}
<EarlierItems count={earlier.length}>{rows(earlier)}</EarlierItems>
</SidePanelAccordionBox>
);
}
function ProgressSection({
items,
scope,
onSelect,
}: {
items: SessionProgressItem[];
scope: SessionPanelScope;
onSelect: (item: SessionProgressItem) => void;
}) {
const { t } = useTranslation();
const { primary, earlier } = arrangeSessionPanelItems(items, scope, 'source');
const rows = (progressItems: SessionProgressItem[]) => (
<SimpleRows
items={progressItems}
render={(item) => (
<SidePanelListRow
key={item.key}
leading={<ProgressCircle done={isProgressDone(item.task)} />}
completed={isProgressDone(item.task)}
onClick={() => onSelect(item)}
>
{item.task.content}
</SidePanelListRow>
)}
/>
);
return (
<SidePanelAccordionBox
title={t('layout.workforce-progress')}
titleSuffix={<CountPill count={primary.length} />}
>
{rows(primary)}
<EarlierItems count={earlier.length}>{rows(earlier)}</EarlierItems>
</SidePanelAccordionBox>
);
}
function ContextSubcategory({
title,
items,
onSelect,
}: {
title: string;
items: SessionContextItem[];
onSelect: (item: SessionContextItem) => void;
}) {
if (items.length === 0) return null;
return (
<SidePanelAccordionBox
title={title}
titleSuffix={<CountPill count={items.length} />}
leading={
<span
className="size-1.5 shrink-0 rounded-full bg-ds-border-neutral-default-default"
aria-hidden
/>
}
rowVariant="subcategory"
>
<SimpleRows
items={items}
render={(item) => (
<SidePanelListRow
key={`${item.category}:${item.id}`}
leading={<ContextItemIcon item={item} />}
onClick={() => onSelect(item)}
>
<span className="!text-body-sm">{item.label}</span>
</SidePanelListRow>
)}
/>
</SidePanelAccordionBox>
);
}
function ContextSection({
items,
scope,
onSelect,
}: {
items: SessionContextItem[];
scope: SessionPanelScope;
onSelect: (item: SessionContextItem) => void;
}) {
const { t } = useTranslation();
const { primary, earlier } = arrangeSessionPanelItems(items, scope);
const earlierRows = (
<SimpleRows
items={earlier}
render={(item) => (
<SidePanelListRow
key={`${item.category}:${item.id}`}
leading={<ContextItemIcon item={item} />}
onClick={() => onSelect(item)}
>
<span className="!text-body-sm">{item.label}</span>
</SidePanelListRow>
)}
/>
);
return (
<SidePanelAccordionBox
title={t('layout.execution-context')}
titleSuffix={<CountPill count={primary.length} />}
>
<ContextSubcategory
title={t('layout.session-panel-skills', {
defaultValue: 'Skills',
})}
items={primary.filter((item) => item.category === 'skill')}
onSelect={onSelect}
/>
<ContextSubcategory
title={t('layout.mcp-tools')}
items={primary.filter((item) => item.category === 'connector')}
onSelect={onSelect}
/>
<EarlierItems count={earlier.length}>{earlierRows}</EarlierItems>
</SidePanelAccordionBox>
);
}
function ContextItemIcon({ item }: { item: SessionContextItem }) {
const [iconFailed, setIconFailed] = useState(false);
useEffect(() => setIconFailed(false), [item.iconUrl]);
if (item.iconUrl && !iconFailed) {
return (
<img
src={item.iconUrl}
alt=""
className="h-4 w-4 object-contain"
loading="lazy"
decoding="async"
onError={() => setIconFailed(true)}
/>
);
}
if (item.icon) return item.icon;
return item.category === 'skill' ? (
<WandSparkles size={16} aria-hidden />
) : (
<Hammer size={16} aria-hidden />
);
}
function ResourcesSection({
items,
scope,
onSelect,
}: {
items: SessionResourceItem[];
scope: SessionPanelScope;
onSelect: (item: SessionResourceItem) => void;
}) {
const { t } = useTranslation();
const { primary, earlier } = arrangeSessionPanelItems(items, scope);
const rows = (resources: SessionResourceItem[]) => (
<SimpleRows
items={resources}
render={(item) => (
<SidePanelListRow
key={item.id}
leading={
item.kind === 'url' ? (
<Globe size={16} aria-hidden />
) : (
<FileText size={16} aria-hidden />
)
}
trailing={
item.kind === 'url' ? <ExternalLink size={14} aria-hidden /> : null
}
onClick={() => onSelect(item)}
>
{item.label}
</SidePanelListRow>
)}
/>
);
return (
<SidePanelAccordionBox
title={t('layout.session-panel-resources', {
defaultValue: 'Resources',
})}
titleSuffix={<CountPill count={primary.length} />}
defaultOpen={false}
>
{rows(primary)}
<EarlierItems count={earlier.length}>{rows(earlier)}</EarlierItems>
</SidePanelAccordionBox>
);
}
function EnvironmentIcon({ label }: { label: string }) {
if (label === 'Terminal') return <SquareTerminal size={16} aria-hidden />;
if (label === 'Browser') return <Globe size={16} aria-hidden />;
return <MonitorCog size={16} aria-hidden />;
}
function EnvironmentsSection({
items,
scope,
onSelect,
}: {
items: SessionEnvironmentItem[];
scope: SessionPanelScope;
onSelect: (item: SessionEnvironmentItem) => void;
}) {
const { t } = useTranslation();
const { primary, earlier } = arrangeSessionPanelItems(items, scope);
const rows = (environments: SessionEnvironmentItem[]) => (
<SimpleRows
items={environments}
render={(item) => (
<SidePanelListRow
key={item.id}
leading={<EnvironmentIcon label={item.label} />}
onClick={() => onSelect(item)}
>
{item.label}
</SidePanelListRow>
)}
/>
);
return (
<SidePanelAccordionBox
title={t('layout.session-panel-environments', {
defaultValue: 'Environments',
})}
titleSuffix={<CountPill count={primary.length} />}
defaultOpen={false}
>
{rows(primary)}
<EarlierItems count={earlier.length}>{rows(earlier)}</EarlierItems>
</SidePanelAccordionBox>
);
}
function FilesSection({
items,
scope,
onSelect,
headerAction,
}: {
items: SessionFileItem[];
scope: SessionPanelScope;
onSelect: (item: SessionFileItem) => void;
headerAction?: ReactNode;
}) {
const { t } = useTranslation();
const { primary, earlier } = arrangeSessionPanelItems(items, scope);
const rows = (files: SessionFileItem[]) => (
<SimpleRows
items={files}
render={(item) => (
<SidePanelListRow
key={item.id}
leading={<FileText size={16} aria-hidden />}
trailing={
!item.previewable ? (
<span className="text-label-xs text-ds-text-neutral-muted-default">
{t('layout.session-panel-file-unavailable', {
defaultValue: 'Preview unavailable',
})}
</span>
) : null
}
onClick={item.previewable ? () => onSelect(item) : undefined}
>
{item.file.name || item.file.path}
</SidePanelListRow>
)}
/>
);
return (
<SidePanelAccordionBox
title={t('layout.session-panel-files', {
defaultValue: 'Files',
})}
titleSuffix={<CountPill count={primary.length} />}
headerAction={headerAction}
>
{items.length === 0 ? (
<div className="px-3 py-3 text-body-sm text-ds-text-neutral-muted-default">
{t('layout.session-panel-files-empty', {
defaultValue: 'No output files yet.',
})}
</div>
) : (
<>
{rows(primary)}
<EarlierItems count={earlier.length}>{rows(earlier)}</EarlierItems>
</>
)}
</SidePanelAccordionBox>
);
}
export function SessionActivityPanel({
agentHeaderAction,
scope,
}: {
agentHeaderAction?: ReactNode;
scope: SessionPanelScope;
}) {
const { t } = useTranslation();
const host = useHost();
const { chatStore } = useChatStoreAdapter();
const projectStore = useProjectRuntimeStore();
const { hydration, projectId } = useProjectEventRuntime();
const overview = useProjectSessionOverview(projectId);
const scopedChatStore =
projectId && projectStore.activeProjectId === projectId ? chatStore : null;
const activeTaskId = scopedChatStore?.activeTaskId ?? null;
const composerTaskId =
activeTaskId &&
(!overview.currentRun || overview.currentRun.runId === activeTaskId)
? activeTaskId
: null;
const activeTask = activeTaskId
? scopedChatStore?.tasks[activeTaskId]
: undefined;
const skills = useSkillsStore((state) => state.skills);
const [connectors, setConnectors] = useState<ConnectorProvider[]>([]);
const requestTaskBoxFocus = usePageTabStore(
(state) => state.requestTaskBoxFocus
);
const setScrollToTurnRequest = usePageTabStore(
(state) => state.setScrollToTurnRequest
);
const openFilePreview = usePageTabStore((state) => state.openFilePreview);
const openBrowserPreview = usePageTabStore(
(state) => state.openBrowserPreview
);
const [selectedAgent, setSelectedAgent] = useState<SessionAgentItem | null>(
null
);
const [selectedContext, setSelectedContext] =
useState<SessionContextItem | null>(null);
const [addingFiles, setAddingFiles] = useState(false);
const addFilesOperationRef = useRef(0);
const attachmentScopeRef = useRef({
projectId,
chatStore: scopedChatStore,
taskId: composerTaskId,
});
useEffect(() => {
attachmentScopeRef.current = {
projectId,
chatStore: scopedChatStore,
taskId: composerTaskId,
};
}, [composerTaskId, projectId, scopedChatStore]);
useEffect(() => {
addFilesOperationRef.current += 1;
setSelectedAgent(null);
setSelectedContext(null);
setAddingFiles(false);
}, [projectId]);
useEffect(() => {
let cancelled = false;
void fetchConnectedProviders()
.then((providers) => {
if (!cancelled) setConnectors(providers);
})
.catch(() => {
if (!cancelled) setConnectors([]);
});
return () => {
cancelled = true;
};
}, []);
const scopedRuns = useMemo(
() => selectSessionPanelRuns(overview.runs, scope),
[overview.runs, scope]
);
const panelData = useDeferredProjectSessionPanelData(
projectId,
scopedRuns,
skills,
connectors
);
const agents = useMemo(
() => panelData.agents.filter((agent) => !agent.subagent),
[panelData.agents]
);
const subagents = useMemo(
() => panelData.agents.filter((agent) => agent.subagent),
[panelData.agents]
);
// Compatibility boundary: legacy ChatStore file-list changes still trigger
// resolver refreshes, but mergeProjectFiles may only enrich durable rows.
const projectFiles = useProjectOutputFiles(
projectId,
activeTask,
activeTaskId
);
const files = useMemo(
() => mergeProjectFiles(panelData.files, projectFiles),
[panelData.files, projectFiles]
);
const attachToRun = (
target: {
projectId: string;
chatStore: NonNullable<typeof scopedChatStore>;
taskId: string;
},
selectedFiles: File[]
) => {
const current = attachmentScopeRef.current;
if (
selectedFiles.length === 0 ||
current.projectId !== target.projectId ||
current.chatStore !== target.chatStore ||
current.taskId !== target.taskId
) {
return;
}
// Read attaches at merge time so files added while the picker was open
// are not clobbered.
const existingFiles = target.chatStore.tasks[target.taskId]?.attaches ?? [];
target.chatStore.setAttaches(target.taskId, [
...existingFiles,
...selectedFiles.filter(
(selected) =>
!existingFiles.some(
(existing) => existing.filePath === selected.filePath
)
),
]);
};
const addFiles = async () => {
if (!projectId || !scopedChatStore || !composerTaskId || addingFiles) {
return;
}
const target = {
projectId,
chatStore: scopedChatStore,
taskId: composerTaskId,
};
const operationId = ++addFilesOperationRef.current;
const isCurrentOperation = () => {
const current = attachmentScopeRef.current;
return (
addFilesOperationRef.current === operationId &&
current.projectId === target.projectId &&
current.chatStore === target.chatStore &&
current.taskId === target.taskId
);
};
if (isWeb()) {
// A dismissed file dialog has no dependable signal (`cancel` is not
// fired everywhere), so the pending flag covers only the upload that
// follows an actual selection.
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.onchange = async () => {
const picked = Array.from(input.files ?? []);
if (picked.length === 0 || !isCurrentOperation()) return;
setAddingFiles(true);
try {
const uploads: File[] = [];
for (const file of picked) {
if (!isCurrentOperation()) break;
try {
const result = await uploadFileToBrain(file);
if (!isCurrentOperation()) break;
uploads.push({
fileName: result.filename,
filePath: result.file_id,
fileId: result.file_id,
source: 'upload',
} as File);
} catch (error) {
console.error('Session file upload failed:', error);
toast.error(
t('layout.session-panel-upload-failed', {
defaultValue: 'Failed to upload {{name}}',
name: file.name,
})
);
}
}
attachToRun(target, uploads);
} finally {
if (isCurrentOperation()) setAddingFiles(false);
}
};
input.click();
return;
}
setAddingFiles(true);
try {
const result = await host?.electronAPI?.selectFile({
title: t('chat.select-file'),
filters: [{ name: t('chat.all-files'), extensions: ['*'] }],
});
if (result?.success && Array.isArray(result.files)) {
attachToRun(target, result.files);
}
} catch (error) {
console.error('Select session files failed:', error);
} finally {
if (isCurrentOperation()) setAddingFiles(false);
}
};
const addFilesLabel = t('layout.session-panel-attach-files', {
defaultValue: 'Attach files to the current run',
});
const canAddFiles = Boolean(scopedChatStore && composerTaskId);
const addFilesTooltip = canAddFiles
? addFilesLabel
: t('layout.session-panel-add-files-unavailable', {
defaultValue: 'Files can only be attached to the current run input.',
});
return (
<>
{/* No `flex-1` anywhere in this chain: each level takes its content
height so the panel card hugs, and only shrinks (scrolling here) once
the sections outgrow the column. */}
<div className="relative flex min-h-0 w-full min-w-0 flex-col overflow-hidden">
<div className="scrollbar-always-visible flex min-h-0 min-w-0 flex-col overflow-y-auto overflow-x-hidden">
{projectId && hydration.status === 'error' ? (
<div className="px-3 pt-3">
<Alert tone="warning">
<AlertTriangle className="size-4" aria-hidden />
<AlertDescription className="flex flex-col items-start gap-2">
<span>
{t(
hydration.errorCode === 'unsupported'
? 'layout.session-panel-history-unsupported'
: 'layout.session-panel-history-unavailable',
{
defaultValue:
hydration.errorCode === 'unsupported'
? 'This backend does not support session history yet.'
: 'Some session history could not be loaded.',
}
)}
</span>
<Button
type="button"
variant="secondary"
size="sm"
buttonRadius="full"
onClick={hydration.retry}
>
{t('chat.timeline-history-retry', {
defaultValue: 'Try again',
})}
</Button>
</AlertDescription>
</Alert>
</div>
) : projectId &&
(hydration.status === 'loading' ||
hydration.status === 'retrying') ? (
<div
className="px-3 pt-3 text-body-sm text-ds-text-neutral-muted-default"
role="status"
>
{t(
hydration.status === 'retrying'
? 'chat.timeline-history-reconnecting'
: 'chat.timeline-history-loading'
)}
</div>
) : null}
<SectionList>
{agents.length > 0 ? (
<AgentCategorySection
title={t('layout.agents')}
items={agents}
scope={scope}
headerAction={agentHeaderAction}
onSelect={setSelectedAgent}
/>
) : null}
{subagents.length > 0 ? (
<AgentCategorySection
title={t('agents.sub-agents', {
defaultValue: 'Sub Agents',
})}
items={subagents}
scope={scope}
onSelect={setSelectedAgent}
/>
) : null}
{panelData.progress.length > 0 ? (
<ProgressSection
items={panelData.progress}
scope={scope}
onSelect={(item) => {
if (!projectId) return;
setScrollToTurnRequest({ projectId, taskId: item.taskId });
requestTaskBoxFocus(projectId, item.taskId);
}}
/>
) : null}
{panelData.contextItems.length > 0 ? (
<ContextSection
items={panelData.contextItems}
scope={scope}
onSelect={setSelectedContext}
/>
) : null}
{panelData.environments.length > 0 ? (
<EnvironmentsSection
items={panelData.environments}
scope={scope}
onSelect={(item) => {
if (!projectId) return;
setScrollToTurnRequest({
projectId,
taskId: item.taskId,
});
}}
/>
) : null}
{panelData.resources.length > 0 ? (
<ResourcesSection
items={panelData.resources}
scope={scope}
onSelect={(item) => {
if (item.kind === 'url' && item.url) {
openBrowserPreview(item.url);
} else if (item.file) {
openFilePreview(item.file);
}
}}
/>
) : null}
{projectId ? (
<FilesSection
items={files}
scope={scope}
onSelect={(item) => openFilePreview(item.file)}
headerAction={
<TooltipSimple
content={addFilesTooltip}
variant="instant"
side="bottom"
>
<span
className="inline-flex"
tabIndex={!canAddFiles ? 0 : undefined}
aria-label={!canAddFiles ? addFilesTooltip : undefined}
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
buttonRadius="lg"
disabled={addingFiles || !canAddFiles}
aria-label={addFilesLabel}
onClick={() => void addFiles()}
>
<Plus className="size-4" aria-hidden />
</Button>
</span>
</TooltipSimple>
}
/>
) : null}
</SectionList>
{panelData.agents.length === 0 &&
panelData.progress.length === 0 &&
panelData.contextItems.length === 0 &&
panelData.environments.length === 0 &&
panelData.resources.length === 0 &&
files.length === 0 &&
!projectId ? (
<div className="px-3 py-6 text-center text-body-sm text-ds-text-neutral-muted-default">
{t('layout.session-activity-empty', {
defaultValue:
'Session activity will appear here as work begins.',
})}
</div>
) : null}
</div>
</div>
<AgentInformationDialog
agent={selectedAgent}
onOpenChange={(open) => {
if (!open) setSelectedAgent(null);
}}
/>
<ToolCallsDialog
item={selectedContext}
onOpenChange={(open) => {
if (!open) setSelectedContext(null);
}}
/>
</>
);
}

View file

@ -19,7 +19,7 @@ import Workflow from '@/components/WorkFlow';
import WorkforceMenu from '@/components/WorkforceMenu';
import { Button } from '@/components/ui/button';
import { TooltipSimple } from '@/components/ui/tooltip';
import type { SelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useHost } from '@/host';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { X } from 'lucide-react';
@ -29,21 +29,19 @@ import { useTranslation } from 'react-i18next';
const EDGE_PADDING_PX = 32;
export interface ExpandedOverlayProps {
export interface SidePanelExpandedOverlayProps {
open: boolean;
onClose: () => void;
workforcePanelKey: string;
onToggleSidePanel: () => void;
isSidePanelVisible: boolean;
selectedTurn: SelectedProjectTurn;
}
function WorkforceOverlayCanvas({
selectedTurn,
}: {
selectedTurn: SelectedProjectTurn;
}) {
const activeTask = selectedTurn.task;
function WorkforceOverlayCanvas() {
const { chatStore } = useChatStoreAdapter();
const activeTask = chatStore?.activeTaskId
? chatStore.tasks[chatStore.activeTaskId]
: undefined;
const activeWorkSpace = activeTask?.activeWorkspace;
if (!activeTask || !activeWorkSpace) {
@ -65,7 +63,7 @@ function WorkforceOverlayCanvas({
(agent) => agent.agent_id === activeWorkSpace
)?.type === 'browser_agent' && (
<div className="flex h-full w-full flex-1">
<BrowserAgentWorkspace selectedTurn={selectedTurn} />
<BrowserAgentWorkspace />
</div>
)}
{activeWorkSpace === 'workflow' && (
@ -82,7 +80,7 @@ function WorkforceOverlayCanvas({
(agent) => agent.agent_id === activeWorkSpace
)?.type === 'developer_agent' && (
<div className="flex h-full w-full flex-1">
<TerminalAgentWorkspace selectedTurn={selectedTurn} />
<TerminalAgentWorkspace />
</div>
)}
{activeWorkSpace === 'documentWorkSpace' && (
@ -131,8 +129,8 @@ export default function ExpandedOverlay({
workforcePanelKey,
onToggleSidePanel,
isSidePanelVisible,
selectedTurn,
}: ExpandedOverlayProps) {
}: SidePanelExpandedOverlayProps) {
const { chatStore } = useChatStoreAdapter();
const shouldReduceMotion = useReducedMotion();
const { t } = useTranslation();
const host = useHost();
@ -145,14 +143,13 @@ export default function ExpandedOverlay({
return;
}
if (workflowResetForOpenRef.current) return;
const taskId = selectedTurn.taskId;
const selectedChatState = selectedTurn.chatStore?.getState();
if (!taskId || !selectedChatState) return;
const taskId = chatStore?.activeTaskId;
if (!taskId || !chatStore) return;
workflowResetForOpenRef.current = true;
selectedChatState.setActiveWorkspace(taskId, 'workflow');
selectedChatState.setActiveAgent(taskId, '');
chatStore.setActiveWorkspace(taskId, 'workflow');
chatStore.setActiveAgent(taskId, '');
host?.electronAPI?.hideAllWebview?.();
}, [open, selectedTurn.chatStore, selectedTurn.taskId, host]);
}, [open, chatStore, host]);
useEffect(() => {
if (!open) return;
@ -253,7 +250,7 @@ export default function ExpandedOverlay({
transition={{ duration: 0.2 }}
className="h-full w-full min-w-0"
>
<WorkforceOverlayCanvas selectedTurn={selectedTurn} />
<WorkforceOverlayCanvas />
</motion.div>
</AnimatePresence>
</div>

View file

@ -19,19 +19,19 @@ import type { SessionModeType } from '@/types/constants';
import { PanelRight, PanelRightClose } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export interface SessionSidePanelFoldButtonProps {
export interface SidePanelFoldButtonProps {
sessionSidePanelMode: SessionModeType;
isSidePanelVisible: boolean;
onToggle: () => void;
className?: string;
}
export function SessionSidePanelFoldButton({
export function SidePanelFoldButton({
sessionSidePanelMode,
isSidePanelVisible,
onToggle,
className,
}: SessionSidePanelFoldButtonProps) {
}: SidePanelFoldButtonProps) {
const { t } = useTranslation();
const sessionSidePanelTooltip =
sessionSidePanelMode === 'single-agent'

View file

@ -81,6 +81,7 @@ const foldedTaskLogContentVariants = {
},
};
/** Legacy workforce detail view retained as a SidePanel implementation detail. */
export function AgentDetailPane({
agent,
onTakeManualFollowControl,

View file

@ -90,7 +90,7 @@ function pickLatestWorkingAgentId(agents: Agent[]): string | null {
return null;
}
export interface FoldedPanelProps {
export interface SidePanelFoldedPanelProps {
/** When true, do not push global activeWorkspace/activeAgent from the folded rail (expanded overlay shows workflow-only). */
pauseAgentWorkspaceSync?: boolean;
}
@ -102,7 +102,7 @@ export interface FoldedPanelProps {
*/
export default function FoldedPanel({
pauseAgentWorkspaceSync = false,
}: FoldedPanelProps) {
}: SidePanelFoldedPanelProps) {
const { t } = useTranslation();
const host = useHost();
const { chatStore, projectStore } = useChatStoreAdapter();
@ -393,15 +393,15 @@ export default function FoldedPanel({
return (
<div
className="bg-ds-bg-neutral-default-default min-h-0 min-w-0 flex h-full w-full flex-col"
className="flex h-full min-h-0 w-full min-w-0 flex-col bg-ds-bg-neutral-default-default"
data-workforce-folded={isTaskLiveLayout ? 'task-live' : 'initial'}
>
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
{isTaskLiveLayout ? (
<motion.div
key="task-live"
className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden"
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
@ -409,8 +409,8 @@ export default function FoldedPanel({
onPointerDownCapture={onFoldedPanelEngagement}
onWheelCapture={onFoldedPanelEngagement}
>
<div className="scrollbar scrollbar-always-visible py-2 pl-2 pr-2 shrink-0 overflow-x-auto overflow-y-hidden">
<div className="gap-2 flex w-max min-w-full flex-row flex-nowrap items-center">
<div className="scrollbar scrollbar-always-visible shrink-0 overflow-x-auto overflow-y-hidden py-2 pl-2 pr-2">
<div className="flex w-max min-w-full flex-row flex-nowrap items-center gap-2">
{sortedAgents.map((agent) => (
<div key={agent.agent_id} className="shrink-0">
<FoldedAgentCard
@ -435,7 +435,7 @@ export default function FoldedPanel({
activeTaskId &&
activeTask &&
chatStore && (
<div className="scrollbar scrollbar-always-visible min-h-0 min-w-0 pb-2 flex h-full shrink-0 flex-col overflow-x-hidden overflow-y-auto">
<div className="scrollbar scrollbar-always-visible flex h-full min-h-0 min-w-0 shrink-0 flex-col overflow-y-auto overflow-x-hidden pb-2">
<TaskCard
key={`task-folded-${activeTaskId}`}
chatId={taskPanelChatId}
@ -462,7 +462,7 @@ export default function FoldedPanel({
</div>
)}
{showPlanTaskBox && activeTaskId && activeChatStore ? (
<div className="pb-2 shrink-0">
<div className="shrink-0 pb-2">
<PlanTaskBox
chatStore={activeChatStore}
taskId={activeTaskId}
@ -470,7 +470,7 @@ export default function FoldedPanel({
/>
</div>
) : null}
<div className="min-h-0 min-w-0 px-2 pb-2 flex flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden px-2 pb-2">
{detailAgent ? (
<AgentDetailPane
agent={detailAgent}
@ -479,7 +479,7 @@ export default function FoldedPanel({
}
/>
) : (
<div className="text-ds-text-neutral-muted-default p-3 text-body-sm">
<div className="p-3 text-body-sm text-ds-text-neutral-muted-default">
{t('chat.select-agent')}
</div>
)}
@ -488,13 +488,13 @@ export default function FoldedPanel({
) : (
<motion.div
key="initial"
className="scrollbar scrollbar-always-visible min-h-0 min-w-0 pl-2 pb-2 pt-1 flex-1 overflow-x-hidden overflow-y-auto"
className="scrollbar scrollbar-always-visible min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pb-2 pl-2 pt-1"
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={FOLDED_LAYOUT_TRANSITION}
>
<div className="gap-2 min-w-0 flex w-full max-w-full flex-col opacity-80">
<div className="flex w-full min-w-0 max-w-full flex-col gap-2 opacity-80">
{showPlanTaskBox && activeTaskId && activeChatStore ? (
<PlanTaskBox
chatStore={activeChatStore}

View file

@ -12,46 +12,42 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { SessionSidePanelFoldButton } from '@/components/Session/SessionSidePanelFoldButton';
import { SidePanelFoldButton } from '@/components/Session/SidePanel/components/FoldButton';
import type { SessionModeType } from '@/types/constants';
import type { ReactNode } from 'react';
export interface SessionSidePanelHeaderProps {
export interface SidePanelHeaderProps {
title: string;
mode: SessionModeType;
isSidePanelVisible: boolean;
onToggle: () => void;
/** Optional content rendered immediately after the fold button (left side). */
start?: ReactNode;
/** Optional right-side content (e.g. workforce expand overlay) */
end?: ReactNode;
}
export function SessionSidePanelHeader({
export function SidePanelHeader({
title,
mode,
isSidePanelVisible,
onToggle,
start,
end,
}: SessionSidePanelHeaderProps) {
}: SidePanelHeaderProps) {
return (
<div className="p-2 min-w-0 relative z-50 flex w-full shrink-0 items-center">
<div className="min-w-0 gap-1 flex flex-1 items-center justify-start">
<SessionSidePanelFoldButton
<div className="relative z-50 flex h-11 min-h-11 w-full min-w-0 shrink-0 items-center px-2">
<div className="flex min-w-0 flex-1 items-center justify-start gap-1">
<SidePanelFoldButton
sessionSidePanelMode={mode}
isSidePanelVisible={isSidePanelVisible}
onToggle={onToggle}
/>
<span className="text-ds-text-neutral-default-default min-w-0 text-body-md font-semibold max-w-full truncate text-center">
<span className="min-w-0 max-w-full truncate text-center text-body-md font-semibold text-ds-text-neutral-default-default">
{title}
</span>
</div>
<div className="min-w-0 gap-1 flex flex-1 items-center justify-end">
{start}
<div className="flex min-w-0 flex-1 items-center justify-end gap-1">
{end != null ? (
<div className="gap-1 flex items-center">{end}</div>
<div className="flex items-center gap-1">{end}</div>
) : null}
</div>
</div>

View file

@ -28,7 +28,7 @@ import { ArrowLeft } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export { default as SessionWorkspace } from '.';
export { default as SessionWorkspace } from '../..';
type SessionsProps = {
className?: string;
@ -95,7 +95,7 @@ export default function Sessions({
<span className="truncate">{t('layout.sessions-full-title')}</span>
</div>
</div>
<div className="m-0 mx-auto flex min-h-0 w-full max-w-[800px] flex-1 flex-col gap-0.5 overflow-y-auto p-2">
<div className="scrollbar-always-visible m-0 mx-auto flex min-h-0 w-full max-w-[800px] flex-1 flex-col gap-0.5 overflow-y-auto p-2">
{sessions.length === 0 ? (
<p className="m-0 px-3 py-6 text-center text-body-sm text-ds-text-neutral-muted-default">
{t('layout.sessions-create-task-hint')}

View file

@ -17,15 +17,15 @@ import { TooltipSimple } from '@/components/ui/tooltip';
import { Maximize, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export interface WorkforceSidePanelHeaderEndProps {
export interface WorkforceHeaderActionProps {
isExpandedOverlayOpen: boolean;
onToggleExpandedOverlay: () => void;
}
export function WorkforceSidePanelHeaderEnd({
export function WorkforceHeaderAction({
isExpandedOverlayOpen,
onToggleExpandedOverlay,
}: WorkforceSidePanelHeaderEndProps) {
}: WorkforceHeaderActionProps) {
const { t } = useTranslation();
return (
<TooltipSimple

View file

@ -12,22 +12,29 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { SessionSidePanelHeader } from '@/components/Session/SessionSidePanelHeader';
import { SingleAgentSidePanel } from '@/components/Session/SingleAgent/SingleAgentSidePanel';
import { TurnTabs } from '@/components/Session/TurnTabs';
import { WorkforceSidePanel } from '@/components/Session/Workforce/WorkforceSidePanel';
import { WorkforceSidePanelHeaderEnd } from '@/components/Session/Workforce/WorkforceSidePanelHeaderEnd';
import { SESSION_SIDE_PANEL_CONTENT_WIDTH_CLASS } from '@/components/Session/sessionSidePanelLayout';
import { SessionActivityPanel } from '@/components/Session/SidePanel/components/ActivityPanel';
import ExpandedOverlay from '@/components/Session/SidePanel/components/ExpandedOverlay';
import { SidePanelHeader } from '@/components/Session/SidePanel/components/Header';
import { WorkforceHeaderAction } from '@/components/Session/SidePanel/components/WorkforceHeaderAction';
import { SESSION_SIDE_PANEL_CONTENT_WIDTH_CLASS } from '@/components/Session/SidePanel/layout';
import type { SessionPanelScope } from '@/components/Session/SidePanel/sections/sessionPanelScope';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { TooltipSimple } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { SessionMode, type SessionModeType } from '@/types/constants';
import { ChevronLeft } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
export interface SessionSidePanelProps {
mode: SessionModeType;
workforcePanelKey: string;
hasAnyMessages: boolean;
isSidePanelVisible: boolean;
onToggleSidePanel: () => void;
isExpandedOverlayOpen: boolean;
@ -38,7 +45,6 @@ export interface SessionSidePanelProps {
export function SessionSidePanel({
mode,
workforcePanelKey,
hasAnyMessages,
isSidePanelVisible,
onToggleSidePanel,
isExpandedOverlayOpen,
@ -47,20 +53,24 @@ export function SessionSidePanel({
}: SessionSidePanelProps) {
const { t } = useTranslation();
const isFolded = !isSidePanelVisible;
const [scope, setScope] = useState<SessionPanelScope>('latest');
const headerTitle =
mode === SessionMode.WORKFORCE
? t('layout.aiWorkforce')
: t('layout.workspace-session-single-agent');
const headerTitle = t('layout.session-summary', {
defaultValue: 'Summary',
});
const scopeLabel = t('layout.session-summary-scope', {
defaultValue: 'Summary content',
});
const latestOnlyLabel = t('layout.session-summary-latest-only', {
defaultValue: 'Latest only',
});
const allLabel = t('layout.session-summary-all', {
defaultValue: 'All',
});
const expandFoldedTooltip =
mode === SessionMode.WORKFORCE
? t('layout.show-workforce-panel', {
defaultValue: 'Show workforce panel',
})
: t('layout.show-side-panel', {
defaultValue: 'Show side panel',
});
const expandFoldedTooltip = t('layout.show-side-panel', {
defaultValue: 'Show side panel',
});
return (
<div className="group relative h-full min-h-0 w-full overflow-hidden">
@ -73,37 +83,62 @@ export function SessionSidePanel({
'pointer-events-none opacity-40 transition-opacity duration-200 group-hover:opacity-80'
)}
>
<SessionSidePanelHeader
<SidePanelHeader
title={headerTitle}
mode={mode}
isSidePanelVisible={isSidePanelVisible}
onToggle={onToggleSidePanel}
start={<TurnTabs />}
end={
mode === SessionMode.WORKFORCE ? (
<WorkforceSidePanelHeaderEnd
isExpandedOverlayOpen={isExpandedOverlayOpen}
onToggleExpandedOverlay={onToggleExpandedOverlay}
/>
) : null
<Select
value={scope}
onValueChange={(value) => {
if (value === 'latest' || value === 'all') setScope(value);
}}
>
<SelectTrigger
size="sm"
variant="secondary"
aria-label={scopeLabel}
wrapperClassName="w-fit max-w-full"
className="h-7 w-auto justify-center gap-0 rounded-lg border-transparent px-2 !text-body-xs [&>svg]:hidden"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="latest">{latestOnlyLabel}</SelectItem>
<SelectItem value="all">{allLabel}</SelectItem>
</SelectContent>
</Select>
}
/>
{mode === SessionMode.WORKFORCE ? (
<WorkforceSidePanel
workforcePanelKey={workforcePanelKey}
hasAnyMessages={hasAnyMessages}
isSidePanelVisible={isSidePanelVisible}
onToggleSidePanel={onToggleSidePanel}
isExpandedOverlayOpen={isExpandedOverlayOpen}
onToggleExpandedOverlay={onToggleExpandedOverlay}
onCloseExpandedOverlay={onCloseExpandedOverlay}
{/* Sizes to its content and only shrinks (then scrolls inside) once the
sections outgrow the column. */}
<div className="mx-1 mb-1 flex min-h-0 flex-col overflow-hidden rounded-2xl bg-ds-bg-neutral-default-default pl-2">
<SessionActivityPanel
scope={scope}
agentHeaderAction={
mode === SessionMode.WORKFORCE ? (
<WorkforceHeaderAction
isExpandedOverlayOpen={isExpandedOverlayOpen}
onToggleExpandedOverlay={onToggleExpandedOverlay}
/>
) : undefined
}
/>
) : (
<SingleAgentSidePanel />
)}
</div>
</div>
{mode === SessionMode.WORKFORCE ? (
<ExpandedOverlay
open={isExpandedOverlayOpen}
onClose={onCloseExpandedOverlay}
workforcePanelKey={workforcePanelKey}
onToggleSidePanel={onToggleSidePanel}
isSidePanelVisible={isSidePanelVisible}
/>
) : null}
{isFolded && (
<TooltipSimple
content={expandFoldedTooltip}

View file

@ -12,7 +12,7 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
/** Full logical width of the session side panel content (clipped by the outer when folded to 40px). */
/** Full logical width of SidePanel content (clipped by the outer when folded to 40px). */
export const SESSION_SIDE_PANEL_CONTENT_WIDTH_CLASS =
'w-[min(360px,40vw)] max-w-[400px]';

View file

@ -0,0 +1,197 @@
// ========= 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 { MarkDown } from '@/components/WorkFlow/MarkDown';
import {
Dialog,
DialogContent,
DialogContentSection,
DialogHeader,
} from '@/components/ui/dialog';
import { getToolkitIcon } from '@/lib/toolkitIcons';
import { Bot } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type {
SessionAgentItem,
SessionContextItem,
} from './buildProjectSessionPanelData';
/** Dialogs opened from SidePanel list items. */
export function AgentInformationDialog({
agent,
onOpenChange,
}: {
agent: SessionAgentItem | null;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
const agentName =
agent?.name ||
(agent?.subagent
? t('layout.session-panel-remote-subagent', {
defaultValue: 'Remote subagent',
})
: t('layout.session-panel-agent', { defaultValue: 'Agent' }));
const agentDescription =
agent?.description ||
(agent?.subagent
? t('layout.session-panel-subagent-description', {
defaultValue:
'A delegated agent used for a bounded part of this project.',
})
: t('layout.session-panel-agent-description', {
defaultValue:
'A general-purpose agent that plans and completes the project using the available tools.',
}));
return (
<Dialog open={agent != null} onOpenChange={onOpenChange}>
<DialogContent size="sm" overlayVariant="dimmed">
<DialogHeader
title={agentName}
subtitle={
agent?.subagent
? t('layout.session-panel-subagent', {
defaultValue: 'Subagent',
})
: t('layout.session-panel-agent', { defaultValue: 'Agent' })
}
/>
<DialogContentSection className="scrollbar-always-visible overflow-y-auto">
{agent ? (
<div className="flex min-w-0 flex-col gap-4">
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-ds-bg-neutral-muted-default">
<Bot
size={20}
className="text-ds-icon-neutral-default-default"
aria-hidden
/>
</span>
<p className="m-0 text-body-sm text-ds-text-neutral-default-default">
{agentDescription}
</p>
</div>
{agent.tools.length > 0 ? (
<div className="flex flex-col gap-2">
<span className="text-label-xs font-semibold uppercase tracking-wide text-ds-text-neutral-muted-default">
{t('layout.capabilities')}
</span>
<div className="flex flex-wrap gap-1.5">
{agent.tools.map((tool) => (
<span
key={tool}
className="rounded-md bg-ds-bg-neutral-muted-default px-2 py-1 text-label-xs text-ds-text-neutral-default-default"
>
{tool}
</span>
))}
</div>
</div>
) : null}
</div>
) : null}
</DialogContentSection>
</DialogContent>
</Dialog>
);
}
export function ToolCallsDialog({
item,
onOpenChange,
}: {
item: SessionContextItem | null;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
return (
<Dialog open={item != null} onOpenChange={onOpenChange}>
<DialogContent size="lg" overlayVariant="dimmed">
<DialogHeader
title={
item?.label ??
t('layout.session-panel-tool-calls', {
defaultValue: 'Tool calls',
})
}
subtitle={t('layout.session-panel-tool-history', {
defaultValue: 'Request and response history',
})}
/>
<DialogContentSection className="scrollbar min-h-0 overflow-y-auto">
{item?.calls.length ? (
<div className="flex min-w-0 flex-col gap-3">
{item.calls.map((call, index) => (
<div
key={call.id}
className="flex min-w-0 flex-col gap-2 rounded-xl border border-solid border-ds-border-neutral-subtle-disabled bg-ds-bg-neutral-default-default p-3"
>
<div className="flex min-w-0 items-center gap-2">
<span className="flex shrink-0 items-center">
{getToolkitIcon(call.toolkitName)}
</span>
<span className="min-w-0 truncate text-body-sm font-semibold text-ds-text-neutral-default-default">
{call.method ||
t('layout.session-panel-call', {
defaultValue: '{{name}} call {{number}}',
name: item.label,
number: index + 1,
})}
</span>
</div>
{call.input ? (
<div className="min-w-0 rounded-lg bg-ds-bg-neutral-muted-default p-3">
<div className="mb-1 text-label-xs font-medium uppercase tracking-wide text-ds-text-neutral-muted-default">
{t('layout.session-panel-request', {
defaultValue: 'Request',
})}
</div>
<MarkDown
content={call.input}
enableTypewriter={false}
pTextSize="text-label-xs text-ds-text-neutral-default-default"
/>
</div>
) : null}
{call.output ? (
<div className="min-w-0 rounded-lg bg-ds-bg-neutral-muted-default p-3">
<div className="mb-1 text-label-xs font-medium uppercase tracking-wide text-ds-text-neutral-muted-default">
{t('layout.session-panel-response', {
defaultValue: 'Response',
})}
</div>
<MarkDown
content={call.output}
enableTypewriter={false}
pTextSize="text-label-xs text-ds-text-neutral-default-default"
/>
</div>
) : null}
</div>
))}
</div>
) : (
<p className="m-0 text-body-sm text-ds-text-neutral-muted-default">
{t('layout.session-panel-no-call-details', {
defaultValue:
'No stored request or response details are available.',
})}
</p>
)}
</DialogContentSection>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,247 @@
// ========= 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 { ReactNode } from 'react';
export type ContextCategory = 'skill' | 'connector' | 'file';
export interface ContextItem {
id: string;
label: string;
icon?: ReactNode;
iconUrl?: string;
category: ContextCategory;
onClick?: () => void;
}
/**
* Minimal shape this builder needs from a skill record. Mirrors the
* skillsStore's `Skill` interface but kept local so this module doesn't
* depend on the Zustand store.
*/
export interface ContextSkill {
name: string;
enabled: boolean;
scope?: { isGlobal?: boolean; selectedAgents?: string[] };
}
/**
* Connected-provider fields needed to replace raw `MCPToolkit` runtime names
* with the identity shown in the Open Connectors UI.
*/
export interface ContextConnector {
service: string;
displayName?: string;
iconUrl?: string | null;
connection?: { connectionName?: string } | null;
actions?: Array<{ id?: string; name?: string }>;
}
/**
* Normalize a toolkit/server/skill name for dedup and hint-matching.
* Lowercases, strips whitespace/underscores/hyphens, and drops a trailing
* "toolkit" so e.g. "Google Calendar Toolkit", "google_calendar", and
* "google-calendar" all collapse to the same key.
*/
/** Normalize provider and toolkit names for SidePanel aggregation. */
export function normalizeContextKey(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/\s+toolkit\s*$/i, '')
.replace(/[\s_-]+/g, '');
}
function normalizeConnectorIdentity(name: string): string {
const normalized = normalizeContextKey(name)
.replace(/toolkit$/i, '')
.replace(/mcp$/i, '')
.replace(/connector$/i, '');
return normalized === 'connectorgateway' ? '' : normalized;
}
/**
* True only for the Open Connectors gateway itself. Every gateway call is by
* definition a connected provider, so a lone connector can be assumed. A bare
* `MCPToolkit` also normalizes to an empty identity but may come from any MCP
* server configured outside Open Connectors, so it must not be assumed.
*/
function isConnectorGatewayName(name: string): boolean {
return normalizeContextKey(name) === 'connectorgateway';
}
function connectorAliases(connector: ContextConnector): string[] {
return Array.from(
new Set(
[
connector.service,
connector.displayName,
connector.connection?.connectionName,
]
.filter((value): value is string => Boolean(value?.trim()))
.map(normalizeConnectorIdentity)
.filter(Boolean)
)
);
}
function connectorMatchScore(
connector: ContextConnector,
toolkitKey: string,
methodKey: string,
messageKey: string
): number {
const aliases = connectorAliases(connector);
let score = 0;
for (const alias of aliases) {
if (toolkitKey && toolkitKey === alias) score = Math.max(score, 100);
if (
toolkitKey &&
alias.length >= 3 &&
(toolkitKey.includes(alias) || alias.includes(toolkitKey))
) {
score = Math.max(score, 90);
}
if (alias.length >= 3 && methodKey.includes(alias)) {
score = Math.max(score, 80);
}
if (alias.length >= 4 && messageKey.includes(alias)) {
score = Math.max(score, 50);
}
}
for (const action of connector.actions ?? []) {
for (const raw of [action.id, action.name]) {
if (!raw) continue;
const actionKey = normalizeContextKey(raw);
if (!actionKey || !methodKey) continue;
if (actionKey === methodKey) {
score = Math.max(score, 70);
} else if (
actionKey.length >= 4 &&
(actionKey.includes(methodKey) || methodKey.includes(actionKey))
) {
score = Math.max(score, 60);
}
}
}
return score;
}
/**
* Resolve a runtime MCP call to a connected Open Connector provider. Generic
* `MCPToolkit` calls are identified by their method/action or request payload.
* Ambiguous matches deliberately stay generic instead of displaying the wrong
* provider.
*/
export function resolveContextConnector(
toolkitName: string,
method: string,
message: string,
connectors: ContextConnector[]
): ContextConnector | null {
// Normalize the potentially large display detail once per call, rather
// than once for every configured connector candidate.
const toolkitKey = normalizeConnectorIdentity(toolkitName);
const methodKey = normalizeContextKey(method);
const messageKey = normalizeContextKey(message.slice(0, 2_000));
const ranked = connectors
.map((connector) => ({
connector,
score: connectorMatchScore(connector, toolkitKey, methodKey, messageKey),
}))
.sort((a, b) => b.score - a.score);
const best = ranked[0];
if (best && best.score > 0 && best.score > (ranked[1]?.score ?? 0)) {
return best.connector;
}
return isConnectorGatewayName(toolkitName) && connectors.length === 1
? connectors[0]!
: null;
}
/**
* Pull skill name(s) out of a `SkillToolkit.load_skill(...)` args string.
*
* Two emission paths produce two formats:
* 1. **Agent path** (`listen_chat_agent._aexecute_tool` /
* `_execute_tool`) emits `message = json.dumps(args)`, e.g.
* `{"name":"pdf"}` or `{"name":["pdf","foo"]}`. This is what
* SkillToolkit currently goes through because its `load_skill` /
* `list_skills` methods aren't `@listen_toolkit`-decorated.
* 2. **`@listen_toolkit` path** (other toolkits) emits Python `repr`
* formatted args, e.g. `'pdf'` or `name='pdf'` or `['pdf','foo']`.
* Kept as a fallback in case SkillToolkit ever gets decorated.
*
* Once the tool deactivates, chatStore concatenates the activate args and
* the deactivate result with a `\n`, so the args are on the first line and
* the rest of `message` is the skill body. Backend may also append a
* "(truncated, …)" tail at 500 chars we strip it.
*/
export function extractLoadedSkillNames(message: string): string[] {
if (!message) return [];
// Args (if present) sit on the first line — the deactivate result is
// appended after a newline. Try the head first, then fall back to the
// whole string if the head doesn't yield anything.
const head = message.split(/\r?\n/)[0] ?? '';
const candidates =
head.trim() && head.trim() !== message.trim() ? [head, message] : [message];
for (const candidate of candidates) {
const cleaned = candidate
.replace(/\.\.\.\s*\(truncated[^)]*\)\s*$/i, '')
.trim();
if (!cleaned) continue;
// 1. JSON args from the agent path.
try {
const parsed = JSON.parse(cleaned);
if (parsed && typeof parsed === 'object' && 'name' in parsed) {
const name = (parsed as { name: unknown }).name;
if (Array.isArray(name)) {
const items = name
.filter((n): n is string => typeof n === 'string')
.map((n) => n.trim())
.filter(Boolean);
if (items.length) return items;
} else if (typeof name === 'string' && name.trim()) {
return [name.trim()];
}
}
} catch {
// Not JSON — fall through to repr parsing.
}
// 2. Python-repr fallback (`@listen_toolkit` formatting).
const noKw = cleaned.replace(/^\s*name\s*=\s*/i, '').trim();
if (noKw.startsWith('[')) {
const items: string[] = [];
const re = /['"]([^'"]+?)['"]/g;
let m: RegExpExecArray | null;
while ((m = re.exec(noKw)) !== null) {
const v = m[1].trim();
if (v) items.push(v);
}
if (items.length) return items;
}
const quoted = noKw.match(/^['"]([^'"]+?)['"]/);
if (quoted) return [quoted[1].trim()];
}
return [];
}

View file

@ -0,0 +1,901 @@
// ========= 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 { ProjectSessionRun } from '@/hooks/useProjectSessionOverview';
import type {
ChatActivityNode,
ChatPlanTaskStatus,
ChatProjectionNode,
} from '@/lib/projector/chat';
import { httpUrlOrNull } from '@/lib/richText';
import { normalizeWorkspaceRelativePath } from '@/lib/workspaceRelativePath';
import { TaskStatus, type TaskStatusType } from '@/types/constants';
import {
extractLoadedSkillNames,
normalizeContextKey,
resolveContextConnector,
type ContextCategory,
type ContextConnector,
type ContextItem,
type ContextSkill,
} from './buildContextItems';
export interface SessionProgressItem {
key: string;
task: TaskInfo;
taskId: string;
historical: boolean;
createdAt: number;
updatedAt: number;
}
export interface SessionAgentItem {
id: string;
name: string;
type: string;
description: string;
tools: string[];
historical: boolean;
createdAt: number;
updatedAt: number;
subagent: boolean;
}
export interface SessionToolCall {
id: string;
toolkitName: string;
method: string;
/** Safe semantic detail only; raw durable payloads never enter this model. */
input: string;
/** Safe semantic detail only; raw durable payloads never enter this model. */
output: string;
status: 'running' | 'done';
taskId: string;
agentName: string;
createdAt: number;
updatedAt: number;
skillNames: string[];
}
export interface SessionContextItem extends Omit<ContextItem, 'onClick'> {
historical: boolean;
createdAt: number;
updatedAt: number;
calls: SessionToolCall[];
}
export interface SessionResourceItem {
id: string;
label: string;
kind: 'url' | 'file';
url?: string;
file?: FileInfo;
taskId: string;
historical: boolean;
createdAt: number;
updatedAt: number;
}
export interface SessionFileItem {
id: string;
file: FileInfo;
/** True only after a workspace resolver has supplied an openable location. */
previewable: boolean;
taskId: string;
historical: boolean;
createdAt: number;
updatedAt: number;
}
export interface SessionEnvironmentItem {
id: string;
label: string;
taskId: string;
historical: boolean;
createdAt: number;
updatedAt: number;
}
/** Project-wide data consumed by the locked SidePanel UI. */
export interface ProjectSessionPanelData {
agents: SessionAgentItem[];
contextItems: SessionContextItem[];
environments: SessionEnvironmentItem[];
files: SessionFileItem[];
progress: SessionProgressItem[];
resources: SessionResourceItem[];
toolCalls: SessionToolCall[];
}
const ACTIVE_TOOL_STATUSES = new Set(['pending', 'running']);
const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
const SKILL_LOAD_METHOD = 'load skill';
const SKILL_LIST_METHOD = 'list skills';
const TOOL_IDENTITY_CACHE = new WeakMap<ChatActivityNode, string>();
const NODE_HTTP_URL_CACHE = new WeakMap<ChatProjectionNode, string[]>();
function normalizedMethodName(value: string): string {
return value.trim().toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ');
}
function skillOperation(
call: Pick<SessionToolCall, 'method' | 'toolkitName'>
): 'load' | 'list' | null {
const method = normalizedMethodName(call.method);
if (method === SKILL_LOAD_METHOD) return 'load';
if (method === SKILL_LIST_METHOD) return 'list';
// Canonical tool lifecycle events currently expose tool_name as both the
// toolkit and method when no toolkit metadata is available.
const toolkitOperation = normalizedMethodName(call.toolkitName);
if (toolkitOperation === SKILL_LOAD_METHOD) return 'load';
if (toolkitOperation === SKILL_LIST_METHOD) return 'list';
return null;
}
function nodeTime(node: ChatProjectionNode, fallback = 0): number {
const parsed = node.createdAt ? Date.parse(node.createdAt) : Number.NaN;
return Number.isFinite(parsed) ? parsed : fallback + node.runSequence;
}
function normalizedToolIdentity(node: ChatActivityNode): string {
const cached = TOOL_IDENTITY_CACHE.get(node);
if (cached) return cached;
const identity = JSON.stringify([
node.runId,
normalizeContextKey(node.agentId || node.agentName || ''),
normalizeContextKey(node.toolkitName || node.toolName || 'tool'),
normalizeContextKey(node.methodName || node.toolName || node.title),
]);
TOOL_IDENTITY_CACHE.set(node, identity);
return identity;
}
function safeToolDetail(node: ChatActivityNode): string {
if (node.detail?.trim()) return node.detail.trim();
// Legacy toolkit frames route their already-approved display text through
// the semantic title. Typed durable events never take this compatibility
// path and still require explicit display_detail/display_summary fields.
if (
node.legacyStep === 'activate_toolkit' ||
node.legacyStep === 'deactivate_toolkit'
) {
return node.title.trim();
}
return '';
}
function toolCallFromNode(node: ChatActivityNode): SessionToolCall {
const active = ACTIVE_TOOL_STATUSES.has(node.status);
const detail = safeToolDetail(node);
return {
id: node.toolCallId || `tool-call:${node.eventId}`,
toolkitName: node.toolkitName?.trim() || node.toolName?.trim() || 'Tool',
method:
node.methodName?.trim() || node.toolName?.trim() || node.title.trim(),
input: active ? detail : '',
output: active ? '' : detail,
status: active ? 'running' : 'done',
taskId: node.runId,
agentName: node.agentName?.trim() || '',
createdAt: nodeTime(node),
updatedAt: nodeTime(node),
skillNames: [],
};
}
function mergeToolNode(call: SessionToolCall, node: ChatActivityNode): void {
const detail = safeToolDetail(node);
const active = ACTIVE_TOOL_STATUSES.has(node.status);
if (detail) {
if (active && !call.input) call.input = detail;
if (!active) {
call.output = [call.output, detail].filter(Boolean).join('\n\n');
}
}
call.updatedAt = Math.max(call.updatedAt, nodeTime(node));
if (!active) call.status = 'done';
if (!call.agentName && node.agentName) call.agentName = node.agentName;
}
/**
* Fold immutable semantic tool lifecycle nodes into logical calls. Explicit
* backend call IDs win; older legacy frames use FIFO pairing per identity.
*/
export function collectSessionToolCalls(
runs: ProjectSessionRun[]
): SessionToolCall[] {
const calls: SessionToolCall[] = [];
const byCallId = new Map<string, SessionToolCall>();
const anonymousOpen = new Map<string, SessionToolCall[]>();
for (const run of [...runs].reverse()) {
for (const node of run.nodes) {
if (node.kind !== 'activity' || node.activityType !== 'tool') continue;
const identity = normalizedToolIdentity(node);
if (node.toolCallId) {
const callKey = `${node.runId}:${node.toolCallId}`;
const existing = byCallId.get(callKey);
if (existing) {
mergeToolNode(existing, node);
} else {
const call = toolCallFromNode(node);
byCallId.set(callKey, call);
calls.push(call);
}
continue;
}
if (ACTIVE_TOOL_STATUSES.has(node.status)) {
const call = toolCallFromNode(node);
calls.push(call);
const pending = anonymousOpen.get(identity) ?? [];
pending.push(call);
anonymousOpen.set(identity, pending);
continue;
}
const pending = anonymousOpen.get(identity) ?? [];
const call = TERMINAL_TOOL_STATUSES.has(node.status)
? pending.shift()
: undefined;
if (call) {
mergeToolNode(call, node);
} else {
calls.push(toolCallFromNode(node));
}
}
}
for (const call of calls) {
if (skillOperation(call) === 'load') {
call.skillNames = extractLoadedSkillNames(
[call.input, call.output].filter(Boolean).join('\n')
);
}
}
return calls.sort(
(left, right) =>
left.createdAt - right.createdAt || left.id.localeCompare(right.id)
);
}
function collectAgents(
runs: ProjectSessionRun[],
calls: SessionToolCall[]
): SessionAgentItem[] {
const agents = new Map<string, SessionAgentItem>();
const isInternalAgent = (name: string) =>
normalizeContextKey(name) === 'questionconfirmagent';
const put = (
run: ProjectSessionRun,
identity: string,
name: string,
description: string,
subagent: boolean
) => {
const key = `${subagent ? 'subagent' : 'agent'}:${normalizeContextKey(
identity || name || 'agent'
)}`;
const existing = agents.get(key);
if (!existing) {
agents.set(key, {
id: key,
name,
type: subagent ? 'subagent' : 'agent',
description,
tools: [],
historical: !run.isCurrent,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
subagent,
});
return;
}
if (!existing.name && name) existing.name = name;
if (!existing.description && description)
existing.description = description;
if (run.isCurrent) existing.historical = false;
existing.createdAt = Math.max(existing.createdAt, run.createdAt);
existing.updatedAt = Math.max(existing.updatedAt, run.updatedAt);
};
for (const run of runs) {
for (const node of run.nodes) {
if (node.kind !== 'activity') continue;
if (node.activityType === 'agent') {
const name = node.agentName || node.title;
if (isInternalAgent(name)) continue;
const text = `${node.eventType} ${node.title} ${name}`.toLowerCase();
const subagent = /sub.?agent|remote/.test(text);
put(
run,
// Agent UUIDs are process instances and change on every Run. The
// stable semantic name identifies the logical agent in the panel.
name || node.agentId || 'agent',
name,
node.detail || '',
subagent
);
} else if (node.agentId || node.agentName) {
if (isInternalAgent(node.agentName || '')) continue;
put(
run,
node.agentName || node.agentId || 'agent',
node.agentName || '',
'',
false
);
}
}
}
for (const call of calls) {
const run = runs.find((candidate) => candidate.runId === call.taskId);
if (!run) continue;
const callText = `${call.toolkitName} ${call.method}`.toLowerCase();
const subagent = /sub.?agent|remote/.test(callText);
// Typed tool lifecycle events may not carry agent identity. They are not
// independent agents and must not create a phantom "Remote subagent" row.
if (!call.agentName && !subagent) continue;
if (isInternalAgent(call.agentName)) continue;
const identity = call.agentName || 'remote-subagent';
put(run, identity, call.agentName, '', subagent);
const key = `${subagent ? 'subagent' : 'agent'}:${normalizeContextKey(
identity
)}`;
const agent = agents.get(key);
if (agent && !agent.tools.includes(call.toolkitName)) {
agent.tools.push(call.toolkitName);
}
}
return [...agents.values()].sort(
(left, right) =>
Number(left.historical) - Number(right.historical) ||
Number(left.subagent) - Number(right.subagent) ||
left.name.localeCompare(right.name)
);
}
function planStatus(status: ChatPlanTaskStatus): TaskStatusType {
switch (status) {
case 'completed':
return TaskStatus.COMPLETED;
case 'failed':
return TaskStatus.FAILED;
case 'skipped':
return TaskStatus.SKIPPED;
case 'blocked':
return TaskStatus.BLOCKED;
case 'running':
return TaskStatus.RUNNING;
default:
return TaskStatus.WAITING;
}
}
function activityTaskStatus(node: ChatActivityNode): TaskStatusType {
switch (node.status) {
case 'cancelled':
return TaskStatus.SKIPPED;
case 'timed_out':
return TaskStatus.FAILED;
case 'outcome_unknown':
return TaskStatus.BLOCKED;
default:
return planStatus(node.status);
}
}
function isLegacyTodoState(node: ChatProjectionNode): boolean {
return (
node.kind === 'plan' &&
(node.legacyStep === 'todo_state' || node.eventType === 'legacy.todo_state')
);
}
function startsTodoWrite(node: ChatProjectionNode): boolean {
if (node.kind !== 'activity' || node.activityType !== 'tool') return false;
return (
ACTIVE_TOOL_STATUSES.has(node.status) &&
[node.toolName, node.methodName, node.title].some(
(value) => normalizeContextKey(value || '') === 'todowrite'
)
);
}
function collectProgress(runs: ProjectSessionRun[]): SessionProgressItem[] {
const progress: SessionProgressItem[] = [];
for (const run of runs) {
const tasks = new Map<string, SessionProgressItem>();
const todoTaskKeys = new Set<string>();
let observedTodoWrite = false;
for (const node of run.nodes) {
const time = nodeTime(node, run.createdAt);
if (startsTodoWrite(node)) observedTodoWrite = true;
if (node.kind === 'plan') {
const todoState = isLegacyTodoState(node);
if (todoState) {
// todo_state is a full-list replacement emitted by todo_write. Old
// backends also emitted workspace-loaded todos at Run startup with
// no TodoToolkit call; quarantine those already-durable bad events.
if (!observedTodoWrite) continue;
// Replace only TodoToolkit progress. Typed plans and task activity
// are independent sources and must survive a todo list update.
for (const key of todoTaskKeys) tasks.delete(key);
todoTaskKeys.clear();
observedTodoWrite = false;
}
for (const task of node.tasks) {
const key = task.id || `${node.eventId}:${task.title}`;
const mapKey = todoState ? `todo:${key}` : key;
const existing = tasks.get(mapKey);
tasks.set(mapKey, {
key: `${run.runId}:${mapKey}`,
task: {
id: key,
content: task.title,
status: planStatus(task.status),
},
taskId: run.runId,
historical: !run.isCurrent,
createdAt: existing?.createdAt ?? time,
updatedAt: time,
});
if (todoState) todoTaskKeys.add(mapKey);
}
}
if (node.kind !== 'activity' || node.activityType !== 'task') continue;
const key = node.taskId || node.eventId;
const existing = tasks.get(key);
tasks.set(key, {
key: `${run.runId}:${key}`,
task: {
id: key,
content: existing?.task.content || node.detail || node.title,
status: activityTaskStatus(node),
},
taskId: run.runId,
historical: !run.isCurrent,
createdAt: existing?.createdAt ?? time,
updatedAt: time,
});
}
progress.push(...tasks.values());
}
return progress.sort(
(left, right) =>
Number(left.historical) - Number(right.historical) ||
left.createdAt - right.createdAt
);
}
function displaySkillName(name: string, skills: ContextSkill[]): string {
const match = skills.find(
(skill) => normalizeContextKey(skill.name) === normalizeContextKey(name)
);
return match?.name || name;
}
function collectContext(
runs: ProjectSessionRun[],
calls: SessionToolCall[],
skills: ContextSkill[],
connectors: ContextConnector[]
): SessionContextItem[] {
const runById = new Map(runs.map((run) => [run.runId, run]));
const items = new Map<string, SessionContextItem>();
const put = (
category: Exclude<ContextCategory, 'file'>,
label: string,
iconUrl: string | undefined,
call: SessionToolCall
) => {
const run = runById.get(call.taskId);
if (!run || !label.trim()) return;
const key = `${category}:${normalizeContextKey(label)}`;
const existing = items.get(key);
if (existing) {
if (!existing.calls.some((candidate) => candidate.id === call.id)) {
existing.calls.push(call);
}
if (run.isCurrent) existing.historical = false;
existing.createdAt = Math.max(existing.createdAt, run.createdAt);
existing.updatedAt = Math.max(existing.updatedAt, call.updatedAt);
return;
}
items.set(key, {
id: key,
label,
category,
iconUrl,
historical: !run.isCurrent,
createdAt: run.createdAt,
updatedAt: call.updatedAt,
calls: [call],
});
};
for (const call of calls) {
const operation = skillOperation(call);
if (operation) {
// list_skills is discovery only. Its result contains every installed
// skill's name and description and must never be presented as usage.
// A load_skill call without a safely parsed name is also omitted rather
// than inventing an umbrella "Skill" row.
if (operation !== 'load') continue;
for (const name of call.skillNames) {
put('skill', displaySkillName(name, skills), undefined, call);
}
continue;
}
const connector = resolveContextConnector(
call.toolkitName,
call.method,
[call.input, call.output].filter(Boolean).join('\n'),
connectors
);
if (connector) {
put(
'connector',
connector.displayName || connector.service,
connector.iconUrl || undefined,
call
);
} else if (/mcp|connector/i.test(call.toolkitName)) {
put('connector', call.toolkitName, undefined, call);
}
}
return [...items.values()].sort(
(left, right) =>
Number(left.historical) - Number(right.historical) ||
left.label.localeCompare(right.label)
);
}
const HTTP_URL_PATTERN = /https?:\/\/[^\s<>"'`)\]}]+/gi;
export function extractHttpUrls(value: string): string[] {
const matches = value.match(HTTP_URL_PATTERN) ?? [];
const urls = new Set<string>();
for (const raw of matches) {
const cleaned = raw.replace(/[.,;:!?]+$/, '');
const url = httpUrlOrNull(cleaned);
if (url) urls.add(url);
}
return [...urls];
}
function resourceLabel(url: string): string {
try {
const parsed = new URL(url);
const path = parsed.pathname === '/' ? '' : parsed.pathname;
return `${parsed.hostname}${path}`.replace(/\/$/, '');
} catch {
return url;
}
}
function safeNodeText(node: ChatProjectionNode): string {
switch (node.kind) {
case 'message':
return node.content;
case 'notice':
return `${node.title || ''}\n${node.content}`;
case 'interaction':
return `${node.prompt || ''}\n${node.response || ''}`;
case 'plan':
return `${node.title || ''}\n${node.summary || ''}`;
case 'activity':
return `${node.title}\n${node.detail || ''}`;
case 'artifact':
return node.path;
case 'run_status':
return node.reason || '';
case 'unknown':
return '';
}
}
function nodeHttpUrls(node: ChatProjectionNode): string[] {
const cached = NODE_HTTP_URL_CACHE.get(node);
if (cached) return cached;
const urls = extractHttpUrls(safeNodeText(node));
NODE_HTTP_URL_CACHE.set(node, urls);
return urls;
}
function collectResources(runs: ProjectSessionRun[]): SessionResourceItem[] {
const resources = new Map<string, SessionResourceItem>();
for (const run of runs) {
for (const node of run.nodes) {
for (const url of nodeHttpUrls(node)) {
const existing = resources.get(url);
const time = nodeTime(node, run.createdAt);
const item: SessionResourceItem = {
id: `url:${url}`,
label: resourceLabel(url),
kind: 'url',
url,
taskId: run.runId,
historical: !run.isCurrent,
createdAt: existing?.createdAt ?? time,
updatedAt: Math.max(existing?.updatedAt ?? 0, time),
};
if (!existing || (existing.historical && run.isCurrent)) {
resources.set(url, item);
} else {
existing.updatedAt = item.updatedAt;
}
}
}
}
return [...resources.values()].sort(
(left, right) =>
Number(left.historical) - Number(right.historical) ||
right.updatedAt - left.updatedAt
);
}
function fileInfoFromArtifact(
node: Extract<ChatProjectionNode, { kind: 'artifact' }>
): FileInfo {
const name =
node.name || node.path.split('/').filter(Boolean).at(-1) || node.path;
const relativePath = normalizeWorkspaceRelativePath(node.relativePath);
return {
name,
type: name.includes('.') ? name.split('.').at(-1) || '' : '',
path: node.path,
relativePath: relativePath || undefined,
artifactId: node.artifactId,
artifactChange:
node.operation === 'created'
? 'generated'
: node.operation === 'updated'
? 'changed'
: undefined,
mimeType: node.mimeType,
};
}
function artifactIdentity(
node: Extract<ChatProjectionNode, { kind: 'artifact' }>
): string | null {
const artifactId = node.artifactId?.trim();
if (artifactId) return `artifact:${artifactId}`;
const relativePath = normalizeWorkspaceRelativePath(node.relativePath);
if (relativePath) return `relative:${relativePath}`;
return null;
}
function fileEntryForRunRelativePath(
files: Map<string, SessionFileItem>,
runId: string,
relativePath: string
): [string, SessionFileItem] | null {
for (const entry of files.entries()) {
const [, item] = entry;
if (
item.taskId === runId &&
normalizeWorkspaceRelativePath(item.file.relativePath) === relativePath
) {
return entry;
}
}
return null;
}
function collectFiles(runs: ProjectSessionRun[]): SessionFileItem[] {
const files = new Map<string, SessionFileItem>();
for (const run of [...runs].reverse()) {
for (const node of run.nodes) {
if (node.kind !== 'artifact' || !node.path || httpUrlOrNull(node.path)) {
continue;
}
let key = artifactIdentity(node);
// A display-only basename must never become Run identity or an openable
// workspace path. Wait for an artifact id or a trusted portable path.
if (!key) continue;
const relativePath = normalizeWorkspaceRelativePath(node.relativePath);
const matchingRunPath = relativePath
? fileEntryForRunRelativePath(files, run.runId, relativePath)
: null;
if (node.operation === 'deleted') {
files.delete(key);
if (matchingRunPath) files.delete(matchingRunPath[0]);
continue;
}
const time = nodeTime(node, run.createdAt);
let existing = files.get(key);
if (matchingRunPath && matchingRunPath[0] !== key) {
existing ??= matchingRunPath[1];
if (node.artifactId?.trim()) {
// The terminal manifest upgrades the realtime relative identity to
// its canonical artifact id without creating a second visible row.
files.delete(matchingRunPath[0]);
} else {
// Preserve a canonical artifact key if an older realtime frame is
// replayed after terminal finalization.
key = matchingRunPath[0];
}
}
const file = fileInfoFromArtifact(node);
file.artifactId ??= existing?.file.artifactId;
const id = file.artifactId?.trim() || relativePath || node.eventId;
files.set(key, {
id,
file,
previewable: false,
taskId: run.runId,
historical: existing?.historical === false ? false : !run.isCurrent,
createdAt: existing?.createdAt ?? time,
updatedAt: time,
});
}
}
return [...files.values()].sort(
(left, right) =>
Number(left.historical) - Number(right.historical) ||
right.updatedAt - left.updatedAt
);
}
function collectEnvironments(
runs: ProjectSessionRun[]
): SessionEnvironmentItem[] {
const environments = new Map<string, SessionEnvironmentItem>();
const put = (label: string, run: ProjectSessionRun, time: number) => {
const id = label.toLowerCase();
const existing = environments.get(id);
if (!existing || (existing.historical && run.isCurrent)) {
environments.set(id, {
id,
label,
taskId: run.runId,
historical: !run.isCurrent,
createdAt: existing?.createdAt ?? time,
updatedAt: Math.max(existing?.updatedAt ?? 0, time),
});
} else {
existing.updatedAt = Math.max(existing.updatedAt, time);
}
};
for (const run of runs) {
for (const node of run.nodes) {
if (node.kind !== 'activity') continue;
const time = nodeTime(node, run.createdAt);
const identity = `${node.activityType} ${node.title} ${
node.toolkitName || ''
} ${node.methodName || ''}`.toLowerCase();
if (node.activityType === 'terminal' || /terminal|shell/.test(identity)) {
put('Terminal', run, time);
}
if (/browser|search|scrape/.test(identity)) {
put('Browser', run, time);
}
if (/sub.?agent|remote/.test(identity)) {
put('Remote environment', run, time);
}
}
}
return [...environments.values()].sort(
(left, right) =>
Number(left.historical) - Number(right.historical) ||
left.label.localeCompare(right.label)
);
}
export function buildProjectSessionPanelData(
runs: ProjectSessionRun[],
skills: ContextSkill[],
connectors: ContextConnector[] = []
): ProjectSessionPanelData {
const toolCalls = collectSessionToolCalls(runs);
return {
agents: collectAgents(runs, toolCalls),
contextItems: collectContext(runs, toolCalls, skills, connectors),
environments: collectEnvironments(runs),
files: collectFiles(runs),
progress: collectProgress(runs),
resources: collectResources(runs),
toolCalls,
};
}
export function mergeProjectFiles(
items: SessionFileItem[],
projectFiles: FileInfo[]
): SessionFileItem[] {
type UniqueFile = FileInfo | null;
const byArtifactId = new Map<string, UniqueFile>();
const byRelativePath = new Map<string, UniqueFile>();
const addUnique = (
index: Map<string, UniqueFile>,
key: string | null | undefined,
file: FileInfo
) => {
if (!key) return;
const existing = index.get(key);
if (existing === undefined) {
index.set(key, file);
return;
}
if (existing?.path !== file.path) index.set(key, null);
};
for (const file of projectFiles) {
addUnique(byArtifactId, file.artifactId?.trim(), file);
addUnique(
byRelativePath,
normalizeWorkspaceRelativePath(file.relativePath),
file
);
}
return items.map((item) => {
const artifactId = item.file.artifactId?.trim();
const relativePath = normalizeWorkspaceRelativePath(item.file.relativePath);
const idMatch = artifactId ? byArtifactId.get(artifactId) : undefined;
const pathMatch = relativePath
? byRelativePath.get(relativePath)
: undefined;
// Conflicting or ambiguous resolver identities must fail closed.
if (idMatch === null || pathMatch === null) return item;
if (idMatch && pathMatch && idMatch.path !== pathMatch.path) return item;
const match = idMatch || pathMatch;
if (!match) return item;
if (
artifactId &&
match.artifactId &&
artifactId !== match.artifactId.trim()
) {
return item;
}
return {
...item,
previewable: true,
file: {
...item.file,
...match,
name: item.file.name || match.name,
type: item.file.type || match.type,
path: match.path || item.file.path,
relativePath:
relativePath ||
normalizeWorkspaceRelativePath(match.relativePath) ||
undefined,
artifactId: artifactId || match.artifactId,
artifactChange: item.file.artifactChange,
mimeType: item.file.mimeType || match.mimeType,
},
};
});
}
export function isProgressDone(task: TaskInfo): boolean {
return (
task.status === TaskStatus.COMPLETED || task.status === TaskStatus.FAILED
);
}

View file

@ -16,7 +16,7 @@
* Output files from agent runs can arrive from multiple places:
* `taskAssigning[].tasks[].fileList` for WRITE_FILE events, `messages[].fileList`
* for final-summary extraction, and occasionally task-level mirrors.
* The chat task's top-level `fileList` is not kept in sync, so the side panel
* The chat task's top-level `fileList` is not kept in sync, so SidePanel
* must aggregate every known source.
*/
import { isVisibleAgentFile } from '@/lib/agentFileFilters';

View file

@ -0,0 +1,362 @@
// ========= 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 { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import { motion, useReducedMotion } from 'framer-motion';
import { Check, ChevronDown, History } from 'lucide-react';
import {
forwardRef,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
const SESSION_ROW_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
/** Shared SidePanel row variants. */
export const sessionPanelRowVariants = cva(
[
'group flex h-10 min-h-10 w-full min-w-0 items-center gap-2 rounded-lg px-2 py-0 text-left',
'transition-colors duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus/40',
],
{
variants: {
variant: {
section:
'!text-body-sm font-semibold text-ds-text-neutral-default-default',
subcategory:
'!text-body-sm font-medium text-ds-text-neutral-default-default',
item: '!text-body-sm font-medium text-ds-text-neutral-default-default',
history: '!text-body-sm font-medium text-ds-text-neutral-muted-default',
earlier: '!text-body-sm font-medium text-ds-text-neutral-muted-default',
},
interactive: {
true: 'cursor-pointer',
false: 'cursor-default',
},
},
compoundVariants: [
{
variant: 'item',
interactive: true,
className:
'hover:bg-ds-bg-neutral-subtle-hover active:bg-ds-bg-neutral-subtle-default',
},
],
defaultVariants: {
variant: 'item',
interactive: true,
},
}
);
export type SessionPanelRowVariant = NonNullable<
VariantProps<typeof sessionPanelRowVariants>['variant']
>;
type SessionPanelButtonProps = {
leading?: ReactNode;
children: ReactNode;
badge?: ReactNode;
trailing?: ReactNode;
chevron?: boolean;
open?: boolean;
completed?: boolean;
disabled?: boolean;
onClick?: () => void;
interactiveHover?: boolean;
className?: string;
variant?: SessionPanelRowVariant;
ariaLabel?: string;
ariaExpanded?: boolean;
};
/**
* Shared 40px interaction row for section triggers, nested categories, items,
* and the historical-items disclosure.
*/
export const SessionPanelButton = forwardRef<
HTMLElement,
SessionPanelButtonProps
>(
(
{
leading,
children,
badge,
trailing,
chevron,
open,
completed,
disabled,
onClick,
interactiveHover,
className,
variant = 'item',
ariaLabel,
ariaExpanded,
},
ref
) => {
const interactive = Boolean(onClick || interactiveHover);
const earlierRow = variant === 'earlier';
const disclosureRow = variant !== 'item';
const base = cn(
sessionPanelRowVariants({ variant, interactive }),
disabled && 'pointer-events-none opacity-50',
className
);
const content = (
<>
{leading ? (
<span className="flex size-4 shrink-0 items-center justify-center">
{leading}
</span>
) : null}
<span
className={cn(
'min-w-0 truncate',
disclosureRow && !earlierRow ? 'shrink' : 'flex-1',
completed && 'line-through opacity-70'
)}
>
{children}
</span>
{badge ? (
<span className="flex shrink-0 items-center">{badge}</span>
) : null}
{chevron ? (
<ChevronDown
className={cn(
'size-4 shrink-0 text-ds-text-neutral-muted-default transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none',
earlierRow
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100',
open ? 'rotate-0' : '-rotate-90'
)}
aria-hidden
/>
) : null}
{trailing ? (
<span className="flex shrink-0 items-center">{trailing}</span>
) : null}
</>
);
if (onClick) {
return (
<button
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
onClick={onClick}
disabled={disabled}
className={base}
aria-label={ariaLabel}
aria-expanded={ariaExpanded}
>
{content}
</button>
);
}
return (
<div ref={ref as React.Ref<HTMLDivElement>} className={base}>
{content}
</div>
);
}
);
SessionPanelButton.displayName = 'SessionPanelButton';
export function SessionPanelCollapse({
open,
children,
className,
}: {
open: boolean;
children: ReactNode;
className?: string;
}) {
const shouldReduceMotion = useReducedMotion();
const collapseRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
collapseRef.current?.toggleAttribute('inert', !open);
}, [open]);
return (
<motion.div
ref={collapseRef}
initial={false}
animate={{
height: open ? 'auto' : 0,
opacity: open ? 1 : 0,
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
height: { duration: 0.26, ease: SESSION_ROW_EASE },
opacity: { duration: open ? 0.18 : 0.12, ease: 'easeOut' },
}
}
aria-hidden={!open}
style={{ pointerEvents: open ? 'auto' : 'none' }}
className={cn('min-h-0 w-full overflow-hidden', className)}
>
{children}
</motion.div>
);
}
/**
* Small round count/label pill used next to accordion titles.
*/
export function CountPill({ count }: { count: number }) {
return (
<span className="inline-flex items-center justify-center rounded-full bg-ds-bg-neutral-subtle-default px-1.5 text-label-xs font-bold text-ds-text-neutral-subtle-default">
{count}
</span>
);
}
/**
* Keeps previous-run content available without adding per-item run metadata.
* The owning task id remains on each row's action, so items can still navigate
* back to the correct place in chat.
*/
export function EarlierItems({
children,
count,
label,
}: {
children: ReactNode;
count: number;
label?: string;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
if (count === 0) return null;
const resolvedLabel =
label ??
t('layout.session-panel-earlier', {
defaultValue: 'Earlier',
});
return (
<div className="flex min-w-0 flex-col">
<SessionPanelButton
variant="earlier"
leading={<History size={16} aria-hidden />}
chevron
open={open}
ariaExpanded={open}
onClick={() => setOpen((value) => !value)}
>
{resolvedLabel}
</SessionPanelButton>
<SessionPanelCollapse open={open}>
<div className="min-w-0">{children}</div>
</SessionPanelCollapse>
</div>
);
}
type SidePanelListRowProps = {
leading?: ReactNode;
children: ReactNode;
trailing?: ReactNode;
disabled?: boolean;
onClick?: () => void;
/**
* Pointer + subtle hover/active backgrounds without an action (e.g. read-only list rows).
* When `onClick` is set, focus ring is included; for hover-only rows it is omitted.
*/
interactiveHover?: boolean;
completed?: boolean;
className?: string;
};
/**
* Row primitive used across Agent Pool / Execution Context / Agent Folder sections.
* Rendered as a button when `onClick` is provided, otherwise a div.
*/
export const SidePanelListRow = forwardRef<HTMLElement, SidePanelListRowProps>(
(
{
leading,
children,
trailing,
disabled,
onClick,
interactiveHover,
completed,
className,
},
ref
) => {
return (
<SessionPanelButton
ref={ref}
variant="item"
leading={leading}
trailing={trailing}
disabled={disabled}
onClick={onClick}
interactiveHover={interactiveHover}
completed={completed}
className={className}
>
{children}
</SessionPanelButton>
);
}
);
SidePanelListRow.displayName = 'SidePanelListRow';
/**
* Progress circle. Incomplete: neutral subtle fill so the ring reads on any
* panel background. Complete: filled success (matches primary success button)
* with inverse check mark.
*/
export function ProgressCircle({
done,
size = 14,
}: {
done: boolean;
size?: number;
}) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-full border-[0.5px] border-solid',
done
? 'border-ds-bg-success-default-default bg-ds-bg-success-default-default text-ds-text-success-inverse-default'
: 'border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default'
)}
style={{ width: size, height: size }}
aria-hidden
>
{done ? (
<Check
className="!text-ds-text-success-inverse-default"
size={Math.max(8, size - 6)}
strokeWidth={4}
/>
) : null}
</span>
);
}

View file

@ -0,0 +1,56 @@
// ========= 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. =========
/** Controls whether SidePanel shows only the current run or every run. */
export type SessionPanelScope = 'latest' | 'all';
export interface SessionPanelScopedItem {
historical: boolean;
createdAt: number;
updatedAt: number;
}
export function selectSessionPanelRuns<
T extends { isCurrent: boolean; createdAt: number; updatedAt: number },
>(runs: T[], scope: SessionPanelScope): T[] {
const sorted = [...runs].sort(
(a, b) => b.createdAt - a.createdAt || b.updatedAt - a.updatedAt
);
return scope === 'latest'
? sorted.filter((run) => run.isCurrent).slice(0, 1)
: sorted;
}
/**
* Split items into the current run's rows and a collapsed "earlier runs" group.
* In `latest` scope the panel is already fed only the current run, so anything
* still flagged historical is dropped rather than shown.
*/
export function arrangeSessionPanelItems<T extends SessionPanelScopedItem>(
items: T[],
scope: SessionPanelScope,
order: 'newest' | 'source' = 'newest'
): { primary: T[]; earlier: T[] } {
const sorted =
order === 'source'
? [...items]
: [...items].sort(
(a, b) => b.createdAt - a.createdAt || b.updatedAt - a.updatedAt
);
const primary = sorted.filter((item) => !item.historical);
return scope === 'latest'
? { primary, earlier: [] }
: { primary, earlier: sorted.filter((item) => item.historical) };
}

View file

@ -28,6 +28,8 @@ type SidePanelTask = {
function normalizeRemoteFiles(items: any[], baseURL: string): FileInfo[] {
return items.map((item: any) => {
const filename = item.filename || '';
const relativePath = item.relative_path || item.relativePath;
const artifactId = item.artifact_id || item.artifactId;
const url = item.url?.startsWith('http')
? item.url
: `${baseURL}${item.url || ''}`;
@ -35,7 +37,14 @@ function normalizeRemoteFiles(items: any[], baseURL: string): FileInfo[] {
name: filename,
type: filename.split('.').pop() || '',
path: url,
relativePath: item.relativePath || filename,
relativePath:
typeof relativePath === 'string' && relativePath.trim()
? relativePath
: undefined,
artifactId:
typeof artifactId === 'string' && artifactId.trim()
? artifactId
: undefined,
isRemote: true,
};
});
@ -48,6 +57,7 @@ function sameFileList(left: FileInfo[], right: FileInfo[]): boolean {
return (
file.path === other?.path &&
file.relativePath === other?.relativePath &&
file.artifactId === other?.artifactId &&
file.name === other?.name &&
file.type === other?.type &&
file.isRemote === other?.isRemote
@ -55,6 +65,7 @@ function sameFileList(left: FileInfo[], right: FileInfo[]): boolean {
});
}
/** Loads generated output files for the SidePanel Files section. */
export function useProjectOutputFiles(
projectId: string | null | undefined,
activeTask: SidePanelTask | undefined,

View file

@ -1,134 +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 { cn } from '@/lib/utils';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { ChevronDown } from 'lucide-react';
import { type ReactNode, useState } from 'react';
const CONTENT_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
const REVEAL_EASE: [number, number, number, number] = [0.23, 1, 0.32, 1];
const LAYOUT_TRANSITION = {
layout: { duration: 0.28, ease: CONTENT_EASE },
} as const;
export type SidePanelAccordionRenderArgs = { open: boolean };
export type SidePanelAccordionChildren =
| ReactNode
| ((state: SidePanelAccordionRenderArgs) => ReactNode);
export function SidePanelAccordionBox({
title,
titleSuffix,
collapsedPreview,
children,
defaultOpen = true,
}: {
title: string;
/** Small adornment rendered right after the title (e.g. count pill). */
titleSuffix?: ReactNode;
/**
* Compact content below the header when collapsed (static `children` only;
* render-prop children control their own open/closed layout).
*/
collapsedPreview?: ReactNode;
/**
* Static: classic accordion body hidden when closed.
* Render prop: body stays in one region; switch layout by `open` (e.g. summary vs full list).
*/
children: SidePanelAccordionChildren;
defaultOpen?: boolean;
}) {
const shouldReduceMotion = useReducedMotion();
const [open, setOpen] = useState(defaultOpen);
const isRenderProp = typeof children === 'function';
const dynamicBody = isRenderProp
? (children as (s: SidePanelAccordionRenderArgs) => ReactNode)({ open })
: null;
return (
<div className="z-10 flex min-w-0 shrink-0 flex-col overflow-hidden rounded-xl border border-solid border-ds-border-neutral-subtle-disabled bg-ds-bg-neutral-default-default">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full shrink-0 items-center justify-between gap-2 px-3 py-2.5 text-left transition-colors hover:bg-ds-bg-neutral-default-hover"
aria-expanded={open}
>
<div className="flex min-w-0 items-center gap-2">
<span className="text-body-sm font-semibold text-ds-text-neutral-default-default">
{title}
</span>
{titleSuffix ? (
<span className="flex shrink-0 items-center">{titleSuffix}</span>
) : null}
</div>
<ChevronDown
className={cn(
'h-4 w-4 shrink-0 text-ds-text-neutral-muted-default transition-transform duration-200 ease-out motion-reduce:transition-none',
open ? 'rotate-0' : '-rotate-90'
)}
aria-hidden
/>
</button>
{!open && collapsedPreview && !isRenderProp ? (
<div className="w-full px-2 pb-3">{collapsedPreview}</div>
) : null}
{isRenderProp ? (
<motion.div
layout={!shouldReduceMotion}
transition={
shouldReduceMotion ? { layout: { duration: 0 } } : LAYOUT_TRANSITION
}
className="min-h-0 w-full overflow-hidden"
>
{dynamicBody != null ? (
<div className="w-full px-2 pb-3">{dynamicBody}</div>
) : null}
</motion.div>
) : (
<AnimatePresence initial={false}>
{open ? (
<motion.div
key="static-content"
initial={{
opacity: 0,
transform: shouldReduceMotion
? 'translateY(0px)'
: 'translateY(-8px)',
}}
animate={{ opacity: 1, transform: 'translateY(0px)' }}
exit={{
opacity: 0,
transform: shouldReduceMotion
? 'translateY(0px)'
: 'translateY(-4px)',
transition: {
duration: shouldReduceMotion ? 0.14 : 0.125,
ease: REVEAL_EASE,
},
}}
transition={{ duration: 0.16, ease: REVEAL_EASE }}
className="overflow-hidden"
>
<div className="w-full px-2 pb-3">{children as ReactNode}</div>
</motion.div>
) : null}
</AnimatePresence>
)}
</div>
);
}

View file

@ -1,240 +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 { SidePanelAccordionBox } from '@/components/Session/SidePanelAccordionBox';
import { SidePanelListRow } from '@/components/Session/SidePanelSections/primitives';
import { isVisibleAgentFile } from '@/lib/agentFileFilters';
import { cn } from '@/lib/utils';
import {
buildWorkspaceFileTree,
type WorkspaceFileTreeNode,
} from '@/lib/workspaceFileTree';
import { AnimatePresence, motion } from 'framer-motion';
import {
ChevronDown,
ChevronRight,
File,
FileArchive,
FileAudio,
FileCode,
FileImage,
FileSpreadsheet,
FileText,
FileVideo,
Folder as FolderIcon,
FolderOpen,
type LucideIcon,
} from 'lucide-react';
import { useMemo, useState } from 'react';
const EXT_MAP: Record<string, LucideIcon> = {
// images
png: FileImage,
jpg: FileImage,
jpeg: FileImage,
gif: FileImage,
webp: FileImage,
svg: FileImage,
// video
mp4: FileVideo,
mov: FileVideo,
webm: FileVideo,
// audio
mp3: FileAudio,
wav: FileAudio,
m4a: FileAudio,
// archive
zip: FileArchive,
tar: FileArchive,
gz: FileArchive,
// spreadsheet
csv: FileSpreadsheet,
xlsx: FileSpreadsheet,
xls: FileSpreadsheet,
// code
ts: FileCode,
tsx: FileCode,
js: FileCode,
jsx: FileCode,
py: FileCode,
json: FileCode,
html: FileCode,
// docs
md: FileText,
txt: FileText,
pdf: FileText,
doc: FileText,
docx: FileText,
};
function iconFor(file: FileInfo): LucideIcon {
const name = (file.name || file.path || '').toLowerCase();
const idx = name.lastIndexOf('.');
const ext = idx >= 0 ? name.slice(idx + 1) : '';
return EXT_MAP[ext] ?? File;
}
interface AgentFolderSectionProps {
title: string;
files: FileInfo[];
/** Opens the Folder workspace tab and selects this file (parent supplies navigation). */
onOpenFile: (file: FileInfo) => void;
}
interface AgentFolderTreeProps {
nodes: WorkspaceFileTreeNode[];
expandedFolders: Set<string>;
onToggleFolder: (path: string) => void;
onOpenFile: (file: FileInfo) => void;
}
function AgentFolderTree({
nodes,
expandedFolders,
onToggleFolder,
onOpenFile,
}: AgentFolderTreeProps) {
return (
<AnimatePresence initial={false}>
{nodes.map((node) => {
const isExpanded =
node.isFolder && expandedFolders.has(node.relativePath);
const Icon = node.file ? iconFor(node.file) : File;
return (
<motion.li
key={`${node.isFolder ? 'folder' : 'file'}:${node.relativePath}`}
layout
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="m-0 list-none"
>
<SidePanelListRow
leading={
node.isFolder ? (
<span className="flex items-center gap-1">
{isExpanded ? (
<ChevronDown
size={14}
className="text-ds-icon-neutral-muted-default"
/>
) : (
<ChevronRight
size={14}
className="text-ds-icon-neutral-muted-default"
/>
)}
{isExpanded ? (
<FolderOpen
size={16}
className="text-ds-icon-neutral-default-default"
/>
) : (
<FolderIcon
size={16}
className="text-ds-icon-neutral-default-default"
/>
)}
</span>
) : (
<Icon
size={16}
className={cn('text-ds-icon-neutral-default-default')}
/>
)
}
onClick={() => {
if (node.isFolder) {
onToggleFolder(node.relativePath);
} else if (node.file) {
onOpenFile(node.file);
}
}}
>
<span title={node.relativePath}>{node.name}</span>
</SidePanelListRow>
{isExpanded && node.children.length > 0 ? (
<ul className="m-0 ml-4 list-none border-y-0 border-l border-r-0 border-solid border-ds-border-neutral-subtle-default p-0 pl-1">
<AgentFolderTree
nodes={node.children}
expandedFolders={expandedFolders}
onToggleFolder={onToggleFolder}
onOpenFile={onOpenFile}
/>
</ul>
) : null}
</motion.li>
);
})}
</AnimatePresence>
);
}
export function AgentFolderSection({
title,
files,
onOpenFile,
}: AgentFolderSectionProps) {
const unique = useMemo(() => {
const seen = new Set<string>();
const out: FileInfo[] = [];
for (const f of files) {
if (!isVisibleAgentFile(f)) continue;
const key = f.path || f.name;
if (!key || seen.has(key)) continue;
seen.add(key);
out.push(f);
}
return out;
}, [files]);
const fileTree = useMemo(() => buildWorkspaceFileTree(unique), [unique]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
() => new Set()
);
const toggleFolder = (path: string) => {
setExpandedFolders((current) => {
const next = new Set(current);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
};
return (
<SidePanelAccordionBox title={title}>
{unique.length === 0 ? (
<div className="px-1 py-1 text-body-sm text-ds-text-neutral-subtle-default opacity-60">
Files the agent writes or updates during this task appear here so you
can open them.
</div>
) : (
<motion.ul
layout
className="m-0 max-h-96 list-none overflow-y-auto overscroll-contain p-0 pr-1"
>
<AgentFolderTree
nodes={fileTree}
expandedFolders={expandedFolders}
onToggleFolder={toggleFolder}
onOpenFile={onOpenFile}
/>
</motion.ul>
)}
</SidePanelAccordionBox>
);
}

View file

@ -1,380 +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 { SidePanelAccordionBox } from '@/components/Session/SidePanelAccordionBox';
import ShinyText from '@/components/ui/ShinyText/ShinyText';
import { agentMap, type WorkflowAgentType } from '@/components/WorkFlow/agents';
import { getToolkitIcon } from '@/lib/toolkitIcons';
import { cn } from '@/lib/utils';
import { AgentStatusValue } from '@/types/constants';
import { AnimatePresence, motion } from 'framer-motion';
import { Bird, Bot, CodeXml, FileText, Globe, Image } from 'lucide-react';
import {
type ReactNode,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react';
function hasWork(agent: Agent) {
return Array.isArray(agent.tasks) && agent.tasks.length > 0;
}
function agentHasAnyToolkitsSeen(agent: Agent): boolean {
for (const task of agent.tasks ?? []) {
for (const tk of task.toolkits ?? []) {
if (tk.toolkitName && tk.toolkitName !== 'notice') return true;
}
}
return false;
}
/**
* Mirrors `Workspace/index.tsx` ordering: agents with assigned tasks come
* first, preserving their insertion order within each bucket.
*/
function sortByAssigned(agents: Agent[]): Agent[] {
return [...agents].sort((a, b) => {
const aHas = hasWork(a);
const bHas = hasWork(b);
if (aHas && !bHas) return -1;
if (!aHas && bHas) return 1;
return 0;
});
}
function getAgentSubIcon(agentType: string): ReactNode {
const key = agentType as WorkflowAgentType;
const preset = agentMap[key];
if (!preset) return null;
const iconClass = cn('!h-[10px] !w-[10px] shrink-0', preset.textColor);
switch (key) {
case 'developer_agent':
return <CodeXml className={iconClass} />;
case 'browser_agent':
return <Globe className={iconClass} />;
case 'document_agent':
return <FileText className={iconClass} />;
case 'multi_modal_agent':
return <Image className={iconClass} />;
case 'social_media_agent':
return <Bird className={iconClass} />;
default:
return null;
}
}
function AgentLeadingIcon({ agentType }: { agentType: string }) {
const subIcon = getAgentSubIcon(agentType);
return (
<div className="h-6 w-6 text-ds-text-neutral-muted-default bg-ds-bg-neutral-subtle-default rounded-md relative inline-flex shrink-0 items-center justify-center self-center">
<Bot className="h-5 w-5" strokeWidth={2} aria-hidden />
{subIcon != null && (
<span className="-right-0.5 -top-0.5 absolute inline-flex items-center justify-center [&_svg]:shrink-0">
{subIcon}
</span>
)}
</div>
);
}
/**
* Minimum on-screen time for any toolkit that went RUNNING, so fast tools
* (e.g. Screenshot, Search) remain observable even when they flip to
* COMPLETED within a few milliseconds.
*/
export const TOOLKIT_MIN_DISPLAY_MS = 1500;
/** How long each toolkit stays focused before rotating to the next. */
export const TOOLKIT_ROTATION_MS = 2000;
type ToolkitEntry = {
/** Unique per activation — from `toolkitId` in the store. */
id: string;
name: string;
firstSeenAt: number;
/** Epoch ms when the entry should be dropped; `null` while RUNNING. */
expireAt: number | null;
};
type ToolkitState = {
entries: Map<string, ToolkitEntry>;
timers: Map<string, ReturnType<typeof setTimeout>>;
/** Ids that have already been shown and evicted — never re-add. */
retired: Set<string>;
};
function readToolkitEvents(
agent: Pick<Agent, 'tasks'> | undefined
): Array<{ id: string; name: string; status: string | undefined }> {
const out: Array<{ id: string; name: string; status: string | undefined }> =
[];
for (const task of agent?.tasks ?? []) {
for (const tk of task.toolkits ?? []) {
if (!tk.toolkitName || tk.toolkitName === 'notice') continue;
const id = String(
(tk as { toolkitId?: string }).toolkitId ??
`${tk.toolkitName}:${tk.toolkitMethods}`
);
out.push({ id, name: tk.toolkitName, status: tk.toolkitStatus });
}
}
return out;
}
/**
* Exported for unit tests. Reconciles a single pass of toolkit events against
* the component's local `ToolkitState`, arming timers via the injected
* scheduler when a toolkit flips from RUNNING anything else.
*
* Returns the ordered, deduped toolkit names currently eligible for display.
*/
export function reconcileToolkitState(
state: ToolkitState,
events: Array<{ id: string; name: string; status: string | undefined }>,
opts: {
now: number;
minDisplayMs: number;
schedule: (id: string, delayMs: number) => ReturnType<typeof setTimeout>;
cancel: (handle: ReturnType<typeof setTimeout>) => void;
}
): string[] {
for (const event of events) {
if (state.retired.has(event.id)) continue;
let entry = state.entries.get(event.id);
if (!entry) {
entry = {
id: event.id,
name: event.name,
firstSeenAt: opts.now,
expireAt: null,
};
state.entries.set(event.id, entry);
}
if (event.status === AgentStatusValue.RUNNING) {
if (entry.expireAt !== null) {
entry.expireAt = null;
const t = state.timers.get(event.id);
if (t) {
opts.cancel(t);
state.timers.delete(event.id);
}
}
} else if (entry.expireAt === null) {
const expireAt = Math.max(
opts.now,
entry.firstSeenAt + opts.minDisplayMs
);
entry.expireAt = expireAt;
const delay = Math.max(0, expireAt - opts.now);
state.timers.set(event.id, opts.schedule(event.id, delay));
}
}
// Collect names — dedupe preserving first-seen order, drop ones whose
// timers have already fired and removed them.
const seen = new Set<string>();
const out: string[] = [];
for (const entry of state.entries.values()) {
if (entry.expireAt !== null && entry.expireAt <= opts.now) continue;
if (!seen.has(entry.name)) {
seen.add(entry.name);
out.push(entry.name);
}
}
return out;
}
/**
* Returns the list of toolkit names to display for an agent, honoring a
* minimum display time so short-lived toolkits (< a few hundred ms) remain
* observable. Recomputes on every parent render (cheap) because the store
* mutates `agent.tasks[*].toolkits` in place.
*/
export function useLiveToolkits(
agent: Agent,
minDisplayMs: number = TOOLKIT_MIN_DISPLAY_MS
): string[] {
const [, bump] = useReducer((n: number) => n + 1, 0);
const stateRef = useRef<ToolkitState>({
entries: new Map(),
timers: new Map(),
retired: new Set(),
});
const events = readToolkitEvents(agent);
const names = reconcileToolkitState(stateRef.current, events, {
// Wall-clock read during render is intentional: the reconcile needs
// `now` to filter entries whose min-display window has elapsed, and the
// setTimeout scheduled below forces a re-render exactly when that
// boundary passes — so the result stays consistent across renders.
// eslint-disable-next-line react-hooks/purity
now: Date.now(),
minDisplayMs,
schedule: (id, delay) =>
setTimeout(() => {
stateRef.current.entries.delete(id);
stateRef.current.timers.delete(id);
stateRef.current.retired.add(id);
bump();
}, delay),
cancel: clearTimeout,
});
useEffect(() => {
const state = stateRef.current;
return () => {
state.timers.forEach(clearTimeout);
state.timers.clear();
};
}, []);
return names;
}
/** Single-tag strip that rotates through live toolkits with a roll animation. */
function AgentToolkitTag({ names }: { names: string[] }) {
const [focusIndex, setFocusIndex] = useState(0);
useEffect(() => {
if (names.length <= 1) {
setFocusIndex(0);
return;
}
const id = window.setInterval(() => {
setFocusIndex((i) => (i + 1) % names.length);
}, TOOLKIT_ROTATION_MS);
return () => window.clearInterval(id);
}, [names.length]);
const focused =
names.length > 0 ? names[Math.min(focusIndex, names.length - 1)] : null;
return (
<div className="h-6 min-w-0 inline-flex shrink-0 items-center overflow-hidden">
<AnimatePresence initial={false} mode="popLayout">
{focused && (
<motion.div
key={focused}
initial={{ y: -18, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 18, opacity: 0 }}
transition={{ duration: 0.28, ease: [0.2, 0, 0.2, 1] }}
className={cn(
'gap-1 px-1.5 py-0.5 rounded-md inline-flex max-w-full items-center opacity-80',
'bg-ds-bg-neutral-muted-default'
)}
data-testid="agent-toolkit-tag"
>
<span className="text-ds-text-neutral-default-default [&_svg]:h-4 [&_svg]:w-4 inline-flex shrink-0 items-center">
{getToolkitIcon(focused, 16, '')}
</span>
<ShinyText
text={focused}
speed={2.5}
className="text-label-xs font-medium max-w-[140px] truncate"
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function AgentRow({ agent }: { agent: Agent }) {
const display = agentMap[agent.type as WorkflowAgentType];
const active = hasWork(agent);
const name = display?.name ?? agent.name;
const liveToolkits = useLiveToolkits(agent);
return (
<div
className={cn(
'rounded-lg bg-ds-bg-neutral-subtle-default px-1.5 py-1.5',
'gap-2 min-w-0 flex items-center',
!active && 'opacity-50'
)}
>
<AgentLeadingIcon agentType={agent.type} />
<span
className={cn(
'min-w-0 !text-body-sm font-medium text-ds-text-neutral-default-default flex-1 truncate',
display?.textColor
)}
>
{name}
</span>
<AgentToolkitTag names={liveToolkits} />
</div>
);
}
function AgentList({ agents }: { agents: Agent[] }) {
return (
<motion.ul layout className="gap-2 p-0 m-0 flex list-none flex-col">
<AnimatePresence initial={false} mode="popLayout">
{agents.map((agent) => (
<motion.li
key={agent.agent_id}
layout
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<AgentRow agent={agent} />
</motion.li>
))}
</AnimatePresence>
</motion.ul>
);
}
interface AgentPoolSectionProps {
title: string;
agents: Agent[];
}
export function AgentPoolSection({ title, agents }: AgentPoolSectionProps) {
const ordered = useMemo(() => sortByAssigned(agents), [agents]);
const activeAgents = useMemo(() => ordered.filter(hasWork), [ordered]);
const toolingAgents = useMemo(
() => ordered.filter(agentHasAnyToolkitsSeen),
[ordered]
);
const emptyState = (
<div className="text-ds-text-neutral-subtle-default text-body-sm px-1 py-1">
No agents yet
</div>
);
return (
<SidePanelAccordionBox title={title} defaultOpen={false}>
{({ open }) => {
if (ordered.length === 0) {
return open ? emptyState : null;
}
if (!open) {
const collapsed =
toolingAgents.length > 0 ? toolingAgents : activeAgents;
return collapsed.length > 0 ? <AgentList agents={collapsed} /> : null;
}
return <AgentList agents={ordered} />;
}}
</SidePanelAccordionBox>
);
}

View file

@ -1,101 +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 { SidePanelAccordionBox } from '@/components/Session/SidePanelAccordionBox';
import {
CategoryLabel,
SidePanelListRow,
} from '@/components/Session/SidePanelSections/primitives';
import { AnimatePresence, motion } from 'framer-motion';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
export type ContextCategory = 'skill' | 'connector' | 'file';
export interface ContextItem {
id: string;
label: string;
icon?: ReactNode;
category: ContextCategory;
onClick?: () => void;
}
const CATEGORY_ORDER: ContextCategory[] = ['skill', 'connector', 'file'];
const CATEGORY_LABEL: Record<ContextCategory, string> = {
skill: 'Skills',
connector: 'MCP Tools',
file: 'Referenced Files',
};
interface ExecutionContextSectionProps {
title: string;
items: ContextItem[];
}
export function ExecutionContextSection({
title,
items,
}: ExecutionContextSectionProps) {
const grouped = useMemo(() => {
const map = new Map<ContextCategory, ContextItem[]>();
for (const item of items) {
if (!map.has(item.category)) map.set(item.category, []);
map.get(item.category)!.push(item);
}
return CATEGORY_ORDER.filter((c) => map.has(c)).map((c) => ({
category: c,
items: map.get(c)!,
}));
}, [items]);
return (
<SidePanelAccordionBox title={title}>
{items.length === 0 ? (
<div className="text-ds-text-neutral-subtle-default text-body-sm px-1 py-1 opacity-60">
Track skills, MCPs and referenced files used in this task.
</div>
) : (
<div className="gap-2 flex flex-col">
{grouped.map(({ category, items: groupItems }) => (
<div key={category} className="flex flex-col">
<CategoryLabel>{CATEGORY_LABEL[category]}</CategoryLabel>
<motion.ul layout className="p-0 m-0 space-y-0.5 list-none">
<AnimatePresence initial={false}>
{groupItems.map((item) => (
<motion.li
key={item.id}
layout
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<SidePanelListRow
leading={item.icon}
onClick={item.onClick}
interactiveHover
>
{item.label}
</SidePanelListRow>
</motion.li>
))}
</AnimatePresence>
</motion.ul>
</div>
))}
</div>
)}
</SidePanelAccordionBox>
);
}

View file

@ -1,119 +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 { SidePanelAccordionBox } from '@/components/Session/SidePanelAccordionBox';
import {
CountPill,
ProgressCircle,
ProgressConnector,
SidePanelListRow,
} from '@/components/Session/SidePanelSections/primitives';
import { cn } from '@/lib/utils';
import { usePageTabStore } from '@/store/pageTabStore';
import { TaskStatus } from '@/types/constants';
import { AnimatePresence, motion } from 'framer-motion';
import { useMemo } from 'react';
function isDone(task: TaskInfo) {
return task.status === TaskStatus.COMPLETED;
}
interface ProgressSectionProps {
title: string;
subtasks: TaskInfo[];
projectId?: string | null;
taskId?: string | null;
}
export function ProgressSection({
title,
subtasks,
projectId,
taskId,
}: ProgressSectionProps) {
const visibleSubtasks = useMemo(
() => subtasks.filter((task) => task.content.trim() !== ''),
[subtasks]
);
const count = visibleSubtasks.length;
const requestTaskBoxFocus = usePageTabStore((s) => s.requestTaskBoxFocus);
const collapsedStrip =
count > 0 ? (
<div className="gap-1 min-w-0 mx-1 flex items-center overflow-hidden">
<AnimatePresence initial={false}>
{visibleSubtasks.map((task, idx) => (
<motion.span
key={task.id}
layout
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="gap-1 min-w-0 flex items-center"
>
<ProgressCircle done={isDone(task)} />
{idx < visibleSubtasks.length - 1 ? <ProgressConnector /> : null}
</motion.span>
))}
</AnimatePresence>
</div>
) : null;
return (
<SidePanelAccordionBox
title={title}
titleSuffix={count > 0 ? <CountPill count={count} /> : null}
>
{({ open }) => {
if (!open) {
return collapsedStrip;
}
if (count === 0) {
return (
<div className="text-ds-text-neutral-subtle-default text-body-sm px-1 py-1 opacity-60">
Follow each plan step and its status as this task runs.
</div>
);
}
return (
<motion.ul layout className="p-0 m-0 space-y-0.5 list-none">
<AnimatePresence initial={false}>
{visibleSubtasks.map((task) => (
<motion.li
key={task.id}
layout
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<SidePanelListRow
className="hover:bg-ds-bg-neutral-subtle-default"
leading={<ProgressCircle done={isDone(task)} />}
onClick={() => requestTaskBoxFocus(projectId, taskId)}
>
<span className={cn(isDone(task) && 'line-through')}>
{task.content}
</span>
</SidePanelListRow>
</motion.li>
))}
</AnimatePresence>
</motion.ul>
);
}}
</SidePanelAccordionBox>
);
}

View file

@ -1,346 +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 { FileText, Hammer, WandSparkles } from 'lucide-react';
import { createElement } from 'react';
import type { ContextItem } from './ExecutionContextSection';
/**
* Minimal shape this builder needs from a skill record. Mirrors the
* skillsStore's `Skill` interface but kept local so this module doesn't
* depend on the Zustand store.
*/
export interface ContextSkill {
name: string;
enabled: boolean;
scope?: { isGlobal?: boolean; selectedAgents?: string[] };
}
/**
* Normalize a toolkit/server/skill name for dedup and hint-matching.
* Lowercases, strips whitespace/underscores/hyphens, and drops a trailing
* "toolkit" so e.g. "Google Calendar Toolkit", "google_calendar", and
* "google-calendar" all collapse to the same key.
*/
function normalizeKey(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/\s+toolkit\s*$/i, '')
.replace(/[\s_-]+/g, '');
}
function categoryFromCategoryName(
categoryName: string | undefined
): 'skill' | 'connector' {
return (categoryName ?? '').toLowerCase() === 'skill' ? 'skill' : 'connector';
}
const SKILL_TOOLKIT_NAME = 'SkillToolkit';
/**
* Method names show up in two flavors depending on which path emitted the
* event: `@listen_toolkit` rewrites `_` ` ` ("load skill"), while the
* agent's untracked-tool path passes the raw Python identifier
* ("load_skill"). We normalize before comparing so both flavors match.
*/
const SKILL_LOAD_METHOD = 'load skill';
const SKILL_LIST_METHOD = 'list skills';
function normalizeMethodName(method: string): string {
return method.trim().toLowerCase().replace(/_/g, ' ');
}
/**
* Substring (not word-boundary) checks: backend toolkit class names like
* `SkillToolkit` and `XxxMCPToolkit` don't have a word boundary after
* "skill"/"mcp", so a `\bskill\b` / `\bmcp\b` regex would silently miss them.
*/
function isSkillToolkitName(name: string): boolean {
return /skill/i.test(name);
}
function isMcpToolkitName(name: string): boolean {
return /mcp/i.test(name);
}
/**
* Pull skill name(s) out of a `SkillToolkit.load_skill(...)` args string.
*
* Two emission paths produce two formats:
* 1. **Agent path** (`listen_chat_agent._aexecute_tool` /
* `_execute_tool`) emits `message = json.dumps(args)`, e.g.
* `{"name":"pdf"}` or `{"name":["pdf","foo"]}`. This is what
* SkillToolkit currently goes through because its `load_skill` /
* `list_skills` methods aren't `@listen_toolkit`-decorated.
* 2. **`@listen_toolkit` path** (other toolkits) emits Python `repr`
* formatted args, e.g. `'pdf'` or `name='pdf'` or `['pdf','foo']`.
* Kept as a fallback in case SkillToolkit ever gets decorated.
*
* Once the tool deactivates, chatStore concatenates the activate args and
* the deactivate result with a `\n`, so the args are on the first line and
* the rest of `message` is the skill body. Backend may also append a
* "(truncated, …)" tail at 500 chars we strip it.
*/
function extractLoadedSkillNames(message: string): string[] {
if (!message) return [];
// Args (if present) sit on the first line — the deactivate result is
// appended after a newline. Try the head first, then fall back to the
// whole string if the head doesn't yield anything.
const head = message.split(/\r?\n/)[0] ?? '';
const candidates =
head.trim() && head.trim() !== message.trim() ? [head, message] : [message];
for (const candidate of candidates) {
const cleaned = candidate
.replace(/\.\.\.\s*\(truncated[^)]*\)\s*$/i, '')
.trim();
if (!cleaned) continue;
// 1. JSON args from the agent path.
try {
const parsed = JSON.parse(cleaned);
if (parsed && typeof parsed === 'object' && 'name' in parsed) {
const name = (parsed as { name: unknown }).name;
if (Array.isArray(name)) {
const items = name
.filter((n): n is string => typeof n === 'string')
.map((n) => n.trim())
.filter(Boolean);
if (items.length) return items;
} else if (typeof name === 'string' && name.trim()) {
return [name.trim()];
}
}
} catch {
// Not JSON — fall through to repr parsing.
}
// 2. Python-repr fallback (`@listen_toolkit` formatting).
const noKw = cleaned.replace(/^\s*name\s*=\s*/i, '').trim();
if (noKw.startsWith('[')) {
const items: string[] = [];
const re = /['"]([^'"]+?)['"]/g;
let m: RegExpExecArray | null;
while ((m = re.exec(noKw)) !== null) {
const v = m[1].trim();
if (v) items.push(v);
}
if (items.length) return items;
}
const quoted = noKw.match(/^['"]([^'"]+?)['"]/);
if (quoted) return [quoted[1].trim()];
}
return [];
}
type RuntimeToolkit = {
toolkitName: string;
method: string;
message: string;
};
function forEachRuntimeToolkit(
agents: Agent[],
taskRunning: TaskInfo[] | undefined,
fn: (tk: RuntimeToolkit) => void
) {
for (const agent of agents) {
for (const task of agent.tasks ?? []) {
for (const tk of task.toolkits ?? []) {
const name = tk.toolkitName;
if (!name || name === 'notice') continue;
fn({
toolkitName: name,
method: tk.toolkitMethods ?? '',
message: tk.message ?? '',
});
}
}
}
for (const task of taskRunning ?? []) {
for (const tk of task.toolkits ?? []) {
const name = tk.toolkitName;
if (!name || name === 'notice') continue;
fn({
toolkitName: name,
method: tk.toolkitMethods ?? '',
message: tk.message ?? '',
});
}
}
}
/**
* Collect classification hints from per-agent `workerInfo` and the skills
* store. These are used **only** to bucket runtime toolkit names into
* skill vs connector configured items are not surfaced until they
* actually fire at runtime, so the panel stays scoped to "what was used in
* this task".
*/
function collectHints(agents: Agent[], skills: ContextSkill[]) {
const skillHints = new Set<string>();
const connectorHints = new Set<string>();
const add = (set: Set<string>, raw: string) => {
const k = normalizeKey(raw);
if (k) set.add(k);
};
for (const agent of agents) {
const info = agent.workerInfo;
if (!info) continue;
const mcp: unknown = info.mcp_tools;
if (mcp && typeof mcp === 'object') {
const servers = (mcp as { mcpServers?: Record<string, unknown> })
.mcpServers;
if (servers && typeof servers === 'object') {
for (const name of Object.keys(servers)) add(connectorHints, name);
}
}
const selected: unknown = info.selectedTools;
if (Array.isArray(selected)) {
for (const raw of selected) {
if (!raw || typeof raw !== 'object') continue;
const item = raw as {
name?: string;
key?: string;
toolkit?: string;
category?: { name?: string };
};
const label = item.name ?? item.key ?? item.toolkit;
if (!label) continue;
const cat = categoryFromCategoryName(item.category?.name);
const set = cat === 'skill' ? skillHints : connectorHints;
add(set, label);
if (item.toolkit) add(set, item.toolkit);
}
}
}
for (const skill of skills) {
if (!skill.enabled) continue;
add(skillHints, skill.name);
}
return { skillHints, connectorHints };
}
/**
* Derive a flat, deduplicated list of context items (skills / MCP tools /
* referenced files) for the **active task**. Only items the task has
* actually used at runtime appear here configured-but-unused skills and
* MCP servers are intentionally hidden so the panel reflects work in
* flight, not the user's library.
*
* Sources:
* 1. Runtime toolkit usage from `task.toolkits` /
* `taskRunning[].toolkits` (ACTIVATE_TOOLKIT). For `SkillToolkit` the
* *method* is the skill name and is surfaced one row per skill;
* everything else surfaces by toolkit name. Classification falls
* back to substring tests against the toolkit name itself, so
* `SkillToolkit` / `XxxMCPToolkit` always classify even with no
* hints.
* 2. Uploaded files referenced on user messages.
*
* The `skills` and `workerInfo` data are read **only** to seed
* classification hints for ambiguous toolkit names they never produce
* standalone rows.
*/
export function buildContextItems(
agents: Agent[],
taskRunning?: TaskInfo[],
uploadedFiles: File[] = [],
skills: ContextSkill[] = []
): ContextItem[] {
const seen = new Set<string>();
const out: ContextItem[] = [];
const push = (item: ContextItem) => {
const key = `${item.category}:${normalizeKey(item.id)}`;
if (seen.has(key)) return;
seen.add(key);
out.push(item);
};
const { skillHints, connectorHints } = collectHints(agents, skills);
forEachRuntimeToolkit(
agents,
taskRunning,
({ toolkitName, method, message }) => {
// SkillToolkit only exposes two methods to the agent: `list_skills`
// and `load_skill(name)`. The skill the user actually invoked is the
// *argument* to `load_skill`, never the method name. So we ignore
// `list skills` (it's just enumeration) and parse the args of
// `load skill` to surface one row per loaded skill.
if (toolkitName === SKILL_TOOLKIT_NAME) {
const m = normalizeMethodName(method);
if (m === SKILL_LIST_METHOD) return;
if (m === SKILL_LOAD_METHOD) {
for (const skillName of extractLoadedSkillNames(message)) {
push({
id: `skill:${skillName}`,
label: skillName,
category: 'skill',
icon: createElement(WandSparkles, { size: 16 }),
});
}
return;
}
// Unknown method on SkillToolkit — never surface the umbrella row.
// If a real skill was invoked we'd have hit the `load_skill` branch
// above; falling through here would just re-display "SkillToolkit".
return;
}
const norm = normalizeKey(toolkitName);
let category: ContextItem['category'] | null = null;
if (skillHints.has(norm) || isSkillToolkitName(toolkitName)) {
category = 'skill';
} else if (connectorHints.has(norm) || isMcpToolkitName(toolkitName)) {
category = 'connector';
}
if (!category) return;
push({
id: toolkitName,
label: toolkitName,
category,
icon:
category === 'skill'
? createElement(WandSparkles, { size: 16 })
: createElement(Hammer, { size: 16 }),
});
}
);
for (const file of uploadedFiles) {
const filePath = file.filePath?.trim();
if (!filePath) continue;
const fallbackName = filePath.split('/').pop() || filePath;
const label = file.fileName?.trim() || fallbackName;
push({
id: filePath,
label,
category: 'file',
icon: createElement(FileText, { size: 16 }),
});
}
return out;
}

View file

@ -1,179 +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 { cn } from '@/lib/utils';
import { Check } from 'lucide-react';
import { type ReactNode, forwardRef } from 'react';
/**
* Small round count/label pill used next to accordion titles.
*/
export function CountPill({ count }: { count: number }) {
return (
<span className="bg-ds-bg-neutral-subtle-default text-ds-text-neutral-subtle-default text-label-xs font-bold px-1.5 inline-flex items-center justify-center rounded-full">
{count}
</span>
);
}
/**
* Small muted category label for grouping list items.
*/
export function CategoryLabel({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'text-ds-text-neutral-muted-default text-body-xs px-1 pb-1 pt-2 first:pt-0',
className
)}
>
{children}
</div>
);
}
type SidePanelListRowProps = {
leading?: ReactNode;
children: ReactNode;
trailing?: ReactNode;
disabled?: boolean;
onClick?: () => void;
/**
* Pointer + subtle hover/active backgrounds without an action (e.g. read-only list rows).
* When `onClick` is set, focus ring is included; for hover-only rows it is omitted.
*/
interactiveHover?: boolean;
className?: string;
};
/**
* Row primitive used across Agent Pool / Execution Context / Agent Folder sections.
* Rendered as a button when `onClick` is provided, otherwise a div.
*/
export const SidePanelListRow = forwardRef<HTMLElement, SidePanelListRowProps>(
(
{
leading,
children,
trailing,
disabled,
onClick,
interactiveHover,
className,
},
ref
) => {
const showAffordance = Boolean(onClick || interactiveHover);
const base = cn(
'group gap-2 px-1.5 py-1.5 rounded-md min-w-0 w-full flex items-center',
'text-ds-text-neutral-default-default text-body-sm text-left',
'transition-colors',
disabled
? 'opacity-50 pointer-events-none'
: showAffordance
? cn(
'cursor-pointer hover:bg-ds-bg-neutral-subtle-default active:bg-ds-bg-neutral-subtle-hover',
onClick &&
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus/40'
)
: '',
className
);
const content = (
<>
{leading ? (
<span className="flex shrink-0 items-center">{leading}</span>
) : null}
<span className="min-w-0 flex-1 truncate">{children}</span>
{trailing ? (
<span className="flex shrink-0 items-center">{trailing}</span>
) : null}
</>
);
if (onClick) {
return (
<button
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
onClick={onClick}
disabled={disabled}
className={base}
>
{content}
</button>
);
}
return (
<div ref={ref as React.Ref<HTMLDivElement>} className={base}>
{content}
</div>
);
}
);
SidePanelListRow.displayName = 'SidePanelListRow';
/**
* Progress circle. Incomplete: neutral subtle fill so the ring reads on any
* panel background. Complete: filled success (matches primary success button)
* with inverse check mark.
*/
export function ProgressCircle({
done,
size = 14,
}: {
done: boolean;
size?: number;
}) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-full border-[0.5px] border-solid',
done
? 'border-ds-bg-success-default-default bg-ds-bg-success-default-default text-ds-text-success-inverse-default'
: 'border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default'
)}
style={{ width: size, height: size }}
aria-hidden
>
{done ? (
<Check
className="!text-ds-text-success-inverse-default"
size={Math.max(8, size - 6)}
strokeWidth={4}
/>
) : null}
</span>
);
}
/**
* Thin connector line between two progress circles in the folded strip view.
*/
export function ProgressConnector() {
return (
<span
className="bg-ds-border-neutral-default-default h-px min-w-[6px] flex-1"
aria-hidden
/>
);
}

View file

@ -1,141 +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 { AgentFolderSection } from '@/components/Session/SidePanelSections/AgentFolderSection';
import { ExecutionContextSection } from '@/components/Session/SidePanelSections/ExecutionContextSection';
import { ProgressSection } from '@/components/Session/SidePanelSections/ProgressSection';
import { buildContextItems } from '@/components/Session/SidePanelSections/buildContextItems';
import {
collectSidePanelOutputFiles,
mergeSidePanelOutputFiles,
} from '@/components/Session/SidePanelSections/collectSidePanelOutputFiles';
import { useProjectOutputFiles } from '@/components/Session/SidePanelSections/useProjectOutputFiles';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
import { cn } from '@/lib/utils';
import { usePageTabStore } from '@/store/pageTabStore';
import { useSkillsStore } from '@/store/skillsStore';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export function SingleAgentSidePanel() {
const { t } = useTranslation();
const { chatStore, projectStore } = useChatStoreAdapter();
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
const selectedTurn = useSelectedProjectTurn(projectStore.activeProjectId);
const selectedTask = selectedTurn.task;
const selectedTaskId = selectedTurn.taskId;
// The visible turn follows chat scrolling, but the filesystem scan is
// project-level. Drive it from the active Run so scrolling across history
// never turns into a series of identical /files requests.
const activeProjectTaskId = chatStore?.activeTaskId ?? null;
const activeProjectTask = activeProjectTaskId
? chatStore?.tasks[activeProjectTaskId]
: undefined;
const agents = useMemo(
() => selectedTask?.taskAssigning ?? [],
[selectedTask?.taskAssigning]
);
const projectFiles = useProjectOutputFiles(
projectStore.activeProjectId,
activeProjectTask,
activeProjectTaskId
);
/** Prefer live `taskRunning` status (updated on TASK_STATE), keep plan order/text from agent tasks or taskInfo. */
const subtasks = useMemo(() => {
const base = agents[0]?.tasks ?? selectedTask?.taskInfo ?? [];
const taskRunning = selectedTask?.taskRunning ?? [];
if (taskRunning.length === 0) return base;
return base.map((t) => {
const live = taskRunning.find((r) => r.id === t.id);
if (!live) return t;
return { ...t, ...live, content: t.content || live.content };
});
}, [agents, selectedTask?.taskInfo, selectedTask?.taskRunning]);
const files = useMemo(
() =>
mergeSidePanelOutputFiles(
collectSidePanelOutputFiles(selectedTask),
projectFiles
),
[selectedTask, projectFiles]
);
const uploadedFiles = useMemo(() => {
if (!selectedTask) return [];
const all = [
...(selectedTask.messages ?? [])
.filter((m) => m.role === 'user')
.flatMap((m) => m.attaches ?? []),
...(selectedTask.attaches ?? []),
];
const seen = new Set<string>();
return all.filter((file) => {
const key = file.filePath;
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}, [selectedTask]);
const skills = useSkillsStore((s) => s.skills);
const contextItems = useMemo(
() =>
buildContextItems(
agents,
selectedTask?.taskRunning,
uploadedFiles,
skills
),
[agents, selectedTask?.taskRunning, uploadedFiles, skills]
);
const handleOpenAgentFile = useCallback(
(file: FileInfo) => {
openFilePreview(file);
},
[openFilePreview]
);
return (
<div
className={cn(
'flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden',
'relative'
)}
>
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden px-2 pb-2">
<ProgressSection
title={t('layout.workforce-progress', { defaultValue: 'Progress' })}
subtasks={subtasks}
projectId={projectStore.activeProjectId}
taskId={selectedTaskId}
/>
<ExecutionContextSection
title={t('layout.execution-context', {
defaultValue: 'Execution Context',
})}
items={contextItems}
/>
<AgentFolderSection
title={t('layout.workforce-agent-folder', {
defaultValue: 'Agent Folder',
})}
files={files}
onOpenFile={handleOpenAgentFile}
/>
</div>
</div>
);
}

View file

@ -1,15 +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. =========
export { SingleAgentSidePanel } from './SingleAgentSidePanel';

View file

@ -1,193 +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 { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { usePageTabStore } from '@/store/pageTabStore';
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
import { ChatTaskStatus } from '@/types/constants';
import { Check, ChevronDown } from 'lucide-react';
import { useEffect, useReducer } from 'react';
function LiveDot() {
return (
<span
className="h-2 w-2 bg-ds-bg-brand-default-default animate-pulse inline-flex shrink-0 rounded-full"
aria-hidden
/>
);
}
function StatusDot({ status }: { status: string }) {
const cls =
status === ChatTaskStatus.FINISHED
? 'bg-ds-bg-success-default-default'
: status === 'failed'
? 'bg-ds-bg-error-default-default'
: 'bg-ds-border-neutral-default-default';
return (
<span
className={cn('h-2 w-2 inline-flex shrink-0 rounded-full', cls)}
aria-hidden
/>
);
}
interface TurnEntry {
taskId: string;
status: string;
prompt: string;
createdAt: number;
}
/**
* Dropdown button showing "Run N ▼" next to the fold button.
* Hidden when the project has 1 turn.
*/
export function TurnTabs() {
const projectStore = useProjectRuntimeStore();
const activeProjectId = projectStore.activeProjectId;
const [, forceUpdate] = useReducer((n: number) => n + 1, 0);
useEffect(() => {
if (!activeProjectId) return;
const stores = projectStore.getAllChatStores(activeProjectId);
if (!stores.length) return;
const unsubs = stores.map(({ chatStore }) =>
chatStore.subscribe(forceUpdate)
);
return () => unsubs.forEach((fn) => fn());
}, [activeProjectId, projectStore]);
// Collect all visible turns, sorted oldest-first for chronological numbering
const turns: TurnEntry[] = (() => {
if (!activeProjectId) return [];
const stores = projectStore.getAllChatStores(activeProjectId);
const seen = new Set<string>();
const result: TurnEntry[] = [];
for (const { chatStore } of stores) {
const state = chatStore.getState();
for (const [taskId, task] of Object.entries(state.tasks)) {
if (seen.has(taskId)) continue;
const userMsg = (task.messages ?? []).find(
(m: any) => m.role === 'user' && m.content
);
if (!userMsg) continue;
seen.add(taskId);
result.push({
taskId,
status: task.status ?? ChatTaskStatus.PENDING,
prompt: String(userMsg.content ?? ''),
createdAt: task.createdAt ?? 0,
});
}
}
result.sort((a, b) => a.createdAt - b.createdAt);
return result;
})();
const activeChatStore = projectStore.getActiveChatStore(
activeProjectId ?? undefined
);
const activeTaskId = activeChatStore?.getState().activeTaskId ?? null;
const selectedByProject = usePageTabStore(
(s) => s.sidePanelSelectedTurnByProject
);
const setSidePanelSelectedTurn = usePageTabStore(
(s) => s.setSidePanelSelectedTurn
);
const setScrollToTurnRequest = usePageTabStore(
(s) => s.setScrollToTurnRequest
);
const projectId = activeProjectId ?? '';
// Both tab clicks and (post-manual-window) viewport scrolls write here,
// so there is no Date.now() needed at render time.
const effectiveSelectedId = selectedByProject[projectId] ?? activeTaskId;
if (turns.length <= 1) return null;
// 1-based turn number of the currently selected turn
const selectedIndex = turns.findIndex(
(t) => t.taskId === effectiveSelectedId
);
const selectedTurnNumber =
selectedIndex >= 0 ? selectedIndex + 1 : turns.length;
const handleSelect = (taskId: string) => {
if (!projectId) return;
setSidePanelSelectedTurn(projectId, taskId, 5000);
setScrollToTurnRequest({ projectId, taskId });
};
// Display newest-first in the dropdown
const displayTurns = [...turns].reverse();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
buttonRadius="lg"
className="opacity-60"
aria-label={`Run ${selectedTurnNumber}, click to switch turn`}
>
Run {selectedTurnNumber}
<ChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{displayTurns.map((turn, displayIdx) => {
const turnNumber = turns.length - displayIdx;
const isSelected = turn.taskId === effectiveSelectedId;
const isLive =
turn.status === ChatTaskStatus.RUNNING ||
turn.status === ChatTaskStatus.PENDING;
const preview =
turn.prompt.length > 28
? turn.prompt.slice(0, 28) + '…'
: turn.prompt;
return (
<DropdownMenuItem
key={turn.taskId}
onSelect={() => handleSelect(turn.taskId)}
className="gap-2"
>
{isLive ? <LiveDot /> : <StatusDot status={turn.status} />}
<span className="text-ds-text-neutral-muted-default text-label-xs font-semibold shrink-0">
Run {turnNumber}
</span>
<span className="text-ds-text-neutral-subtle-default text-label-xs min-w-0 flex-1 truncate">
{preview}
</span>
{isSelected && (
<Check className="size-3 text-ds-icon-brand-default-default ml-auto shrink-0" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -1,183 +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 { AgentFolderSection } from '@/components/Session/SidePanelSections/AgentFolderSection';
import { AgentPoolSection } from '@/components/Session/SidePanelSections/AgentPoolSection';
import { buildContextItems } from '@/components/Session/SidePanelSections/buildContextItems';
import {
collectSidePanelOutputFiles,
mergeSidePanelOutputFiles,
} from '@/components/Session/SidePanelSections/collectSidePanelOutputFiles';
import { ExecutionContextSection } from '@/components/Session/SidePanelSections/ExecutionContextSection';
import { ProgressSection } from '@/components/Session/SidePanelSections/ProgressSection';
import { useProjectOutputFiles } from '@/components/Session/SidePanelSections/useProjectOutputFiles';
import ExpandedOverlay from '@/components/Session/Workforce/ExpandedOverlay';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
import { cn } from '@/lib/utils';
import { usePageTabStore } from '@/store/pageTabStore';
import { useSkillsStore } from '@/store/skillsStore';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
/** Main column under `SessionSidePanel` header: fills remaining height in flex parent */
export const WORKFORCE_MAIN_SURFACE_CLASS =
'min-w-0 flex w-full min-h-0 flex-1 flex-col overflow-hidden';
export interface WorkforceSidePanelProps {
workforcePanelKey: string;
hasAnyMessages: boolean;
isSidePanelVisible: boolean;
onToggleSidePanel: () => void;
/** Controlled: whether the full-screen workforce overlay is open. */
isExpandedOverlayOpen: boolean;
onToggleExpandedOverlay: () => void;
onCloseExpandedOverlay: () => void;
}
export function WorkforceSidePanel({
workforcePanelKey,
hasAnyMessages: _hasAnyMessages,
isSidePanelVisible,
onToggleSidePanel,
isExpandedOverlayOpen,
onToggleExpandedOverlay: _onToggleExpandedOverlay,
onCloseExpandedOverlay,
}: WorkforceSidePanelProps) {
const { t } = useTranslation();
const { chatStore, projectStore } = useChatStoreAdapter();
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
const selectedTurn = useSelectedProjectTurn(projectStore.activeProjectId);
const selectedTask = selectedTurn.task;
const selectedTaskId = selectedTurn.taskId;
// Viewport scrolling selects a historical turn for display only. Project
// file discovery follows the active Run, otherwise every turn boundary
// crossed while scrolling causes the same project directory to be scanned.
const activeProjectTaskId = chatStore?.activeTaskId ?? null;
const activeProjectTask = activeProjectTaskId
? chatStore?.tasks[activeProjectTaskId]
: undefined;
const agents = useMemo(
() => selectedTask?.taskAssigning ?? [],
[selectedTask?.taskAssigning]
);
const projectFiles = useProjectOutputFiles(
projectStore.activeProjectId,
activeProjectTask,
activeProjectTaskId
);
/** Subtask status is updated in `taskRunning` (e.g. TASK_STATE); `taskInfo` keeps plan text/order. */
const subtasks = useMemo(() => {
const taskInfo = selectedTask?.taskInfo ?? [];
const taskRunning = selectedTask?.taskRunning ?? [];
if (taskRunning.length === 0) return taskInfo;
const runById = new Map(
taskRunning.map((r) => [r.id, r] as [string, TaskInfo])
);
return taskInfo.map((t) => {
const live = runById.get(t.id);
if (!live) return t;
return { ...t, ...live, content: t.content || live.content };
});
}, [selectedTask?.taskInfo, selectedTask?.taskRunning]);
const files = useMemo(
() =>
mergeSidePanelOutputFiles(
collectSidePanelOutputFiles(selectedTask),
projectFiles
),
[selectedTask, projectFiles]
);
const uploadedFiles = useMemo(() => {
if (!selectedTask) return [];
const all = [
...(selectedTask.messages ?? [])
.filter((m) => m.role === 'user')
.flatMap((m) => m.attaches ?? []),
...(selectedTask.attaches ?? []),
];
const seen = new Set<string>();
return all.filter((file) => {
const key = file.filePath;
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}, [selectedTask]);
const skills = useSkillsStore((s) => s.skills);
const contextItems = useMemo(
() =>
buildContextItems(
agents,
selectedTask?.taskRunning,
uploadedFiles,
skills
),
[agents, selectedTask?.taskRunning, uploadedFiles, skills]
);
const handleOpenAgentFile = useCallback(
(file: FileInfo) => {
openFilePreview(file);
},
[openFilePreview]
);
return (
<>
<div className={cn(WORKFORCE_MAIN_SURFACE_CLASS, 'relative')}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden px-2 pb-2">
<AgentPoolSection
title={t('layout.workforce-agent-pool', {
defaultValue: 'Agent Pool',
})}
agents={agents}
/>
<ProgressSection
title={t('layout.workforce-progress', {
defaultValue: 'Progress',
})}
subtasks={subtasks}
projectId={projectStore.activeProjectId}
taskId={selectedTaskId}
/>
<ExecutionContextSection
title={t('layout.execution-context', {
defaultValue: 'Execution Context',
})}
items={contextItems}
/>
<AgentFolderSection
title={t('layout.workforce-agent-folder', {
defaultValue: 'Agent Folder',
})}
files={files}
onOpenFile={handleOpenAgentFile}
/>
</div>
</div>
<ExpandedOverlay
open={isExpandedOverlayOpen}
onClose={onCloseExpandedOverlay}
workforcePanelKey={workforcePanelKey}
onToggleSidePanel={onToggleSidePanel}
isSidePanelVisible={isSidePanelVisible}
selectedTurn={selectedTurn}
/>
</>
);
}

View file

@ -17,7 +17,7 @@ import { HeaderBox } from '@/components/Session/HeaderBox';
import { PreviewPanel } from '@/components/Session/PreviewPanel';
import Workspace from '@/components/Workspace';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
import { ProjectEventRuntimeProvider } from '@/hooks/useProjectEventRuntime';
import { inferSessionModeFromTask } from '@/lib/sessionMode';
import { cn } from '@/lib/utils';
import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore';
@ -30,11 +30,11 @@ import {
} from '@/types/constants';
import { AnimatePresence, motion } from 'framer-motion';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { SessionSidePanel } from './SessionSidePanel';
import { SessionSidePanel } from './SidePanel';
import {
SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS,
SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS,
} from './sessionSidePanelLayout';
} from './SidePanel/layout';
/** Maximum width the resizable chat column can reclaim while display is open. */
const CHAT_PRIORITY_WIDTH = 680;
@ -109,21 +109,6 @@ export default function Session({ isNewProject = false }: SessionProps) {
return projectStore.getAllChatStores(projectStore.activeProjectId);
}, [projectStore]);
const hasAnyMessages = useMemo(() => {
const hasMessages = (store: typeof chatStore) =>
!!store &&
Object.values(store.tasks).some(
(task) => (task.messages?.length || 0) > 0 || task.hasMessages
);
if (hasMessages(chatStore)) return true;
return getAllChatStoresMemoized.some(({ chatStore: store }) => {
const state = store.getState();
return Object.values(state.tasks).some(
(task) => (task.messages?.length || 0) > 0 || task.hasMessages
);
});
}, [chatStore, getAllChatStoresMemoized]);
const workforcePanelKey = chatStore?.activeTaskId ?? '';
const hasSessionStarted = useMemo(() => {
@ -137,7 +122,7 @@ export default function Session({ isNewProject = false }: SessionProps) {
// assume the project never started, and bounce the user back to the
// workforce shell — even though the project chatStore already has
// task content. Cross-check live state via `getAllChatStores` (same
// pattern as `hasAnyMessages` above) to avoid that race.
// pattern as the project-wide store lookup above) to avoid that race.
const checkTasks = (tasksRecord: Record<string, unknown> | undefined) => {
if (!tasksRecord) return false;
return Object.values(tasksRecord).some((task) => {
@ -323,20 +308,17 @@ export default function Session({ isNewProject = false }: SessionProps) {
);
// "Context" breadcrumb / empty-state action: open the Inbox tab for this file.
const selectedTurn = useSelectedProjectTurn(activeProjectId);
const handleJumpToContext = useCallback(
(file: FileInfo | null) => {
if (file && selectedTurn.taskId && selectedTurn.chatStore) {
selectedTurn.chatStore
.getState()
.setSelectedFile(selectedTurn.taskId, file);
if (file && chatStore?.activeTaskId) {
chatStore.setSelectedFile(chatStore.activeTaskId, file);
}
setActiveWorkspaceTab('inbox', {
clearInboxForProjectId: activeProjectId ?? null,
});
closeSessionPreview();
},
[selectedTurn, setActiveWorkspaceTab, activeProjectId, closeSessionPreview]
[chatStore, setActiveWorkspaceTab, activeProjectId, closeSessionPreview]
);
const toggleExpandedOverlay = useCallback(() => {
@ -356,7 +338,6 @@ export default function Session({ isNewProject = false }: SessionProps) {
key={displaySessionMode}
mode={displaySessionMode}
workforcePanelKey={workforcePanelKey}
hasAnyMessages={hasAnyMessages}
isSidePanelVisible={isSidePanelVisible}
onToggleSidePanel={toggleSidePanel}
isExpandedOverlayOpen={isExpandedOverlayOpen}
@ -364,21 +345,120 @@ export default function Session({ isNewProject = false }: SessionProps) {
onCloseExpandedOverlay={closeExpandedOverlay}
/>
) : null;
if (isNewProject) {
return (
<div className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<HeaderBox empty />
// The new-project tab deliberately preserves the last active Project in
// ProjectStore until the first message creates its replacement. Do not
// let that navigation convenience scope the SidePanel to the old Run.
<ProjectEventRuntimeProvider projectId={null}>
<div className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<Workspace
variant="new-project"
embedded
sessionMode={displaySessionMode ?? SessionMode.SINGLE_AGENT}
onSessionModeChange={handleNewProjectSessionModeChange}
/>
<HeaderBox empty />
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<Workspace
variant="new-project"
embedded
sessionMode={displaySessionMode ?? SessionMode.SINGLE_AGENT}
onSessionModeChange={handleNewProjectSessionModeChange}
/>
</div>
</div>
<div
id="session-side-panel"
className={cn(
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-out',
isSidePanelVisible
? SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS
: cn(SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS, 'rounded-l-xl')
)}
>
{sessionSidePanel}
</div>
</div>
</ProjectEventRuntimeProvider>
);
}
return (
<ProjectEventRuntimeProvider projectId={activeProjectId}>
<div
ref={chatRowRef}
className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden"
>
{/* Chat content: owns the project header and folds when display opens. */}
<div
style={previewOpen ? { width: chatWidth } : undefined}
className={cn(
'flex min-h-0 min-w-0 flex-col overflow-hidden',
previewOpen ? 'shrink-0' : 'flex-1',
!isResizingPreview && 'transition-[width] duration-200 ease-out'
)}
>
<HeaderBox
projectName={activeProjectMeta?.name}
totalTokens={
chatStore.activeTaskId
? chatStore.tasks[chatStore.activeTaskId]?.tokens || 0
: 0
}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatBox />
</div>
</div>
<AnimatePresence initial={false}>
{previewOpen && (
<motion.div
key="session-display-content"
initial={{
clipPath: 'inset(0 0 0 100%)',
opacity: 0,
}}
animate={{
clipPath: 'inset(0 0 0 0%)',
opacity: 1,
flexGrow: 1,
}}
exit={{
clipPath: 'inset(0 0 0 100%)',
opacity: 0,
flexGrow: 0,
}}
transition={{ duration: 0.3, ease: DISPLAY_PANEL_EASE }}
style={{ transformOrigin: 'right center' }}
className="flex min-h-0 min-w-0 flex-1 overflow-hidden"
>
<div
onPointerDown={handlePreviewResizeStart}
role="separator"
aria-orientation="vertical"
data-resize-handle-state={
isResizingPreview ? 'drag' : 'inactive'
}
className={cn(
// Transparent 2px rail with a centered line and wider hit area.
'relative z-10 flex w-[2px] shrink-0 cursor-col-resize items-center justify-center bg-transparent transition-colors hover:bg-ds-bg-brand-subtle-default',
"before:absolute before:inset-y-0 before:-left-1 before:-right-1 before:content-['']",
'after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 after:bg-ds-bg-neutral-default-default after:transition-colors',
isResizingPreview &&
'bg-ds-bg-brand-subtle-default after:bg-ds-bg-brand-default-focus'
)}
/>
{/* Display content: middle column between chat and session. */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{activeProjectId ? (
<PreviewPanel
displaySettled={displaySettled}
onJumpToContext={handleJumpToContext}
/>
) : null}
</div>
</motion.div>
)}
</AnimatePresence>
<div
id="session-side-panel"
@ -392,97 +472,6 @@ export default function Session({ isNewProject = false }: SessionProps) {
{sessionSidePanel}
</div>
</div>
);
}
return (
<div
ref={chatRowRef}
className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden"
>
{/* Chat content: owns the project header and folds when display opens. */}
<div
style={previewOpen ? { width: chatWidth } : undefined}
className={cn(
'flex min-h-0 min-w-0 flex-col overflow-hidden',
previewOpen ? 'shrink-0' : 'flex-1',
!isResizingPreview && 'transition-[width] duration-200 ease-out'
)}
>
<HeaderBox
projectName={activeProjectMeta?.name}
totalTokens={
chatStore.activeTaskId
? chatStore.tasks[chatStore.activeTaskId]?.tokens || 0
: 0
}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatBox />
</div>
</div>
<AnimatePresence initial={false}>
{previewOpen && (
<motion.div
key="session-display-content"
initial={{
clipPath: 'inset(0 0 0 100%)',
opacity: 0,
}}
animate={{
clipPath: 'inset(0 0 0 0%)',
opacity: 1,
flexGrow: 1,
}}
exit={{
clipPath: 'inset(0 0 0 100%)',
opacity: 0,
flexGrow: 0,
}}
transition={{ duration: 0.3, ease: DISPLAY_PANEL_EASE }}
style={{ transformOrigin: 'right center' }}
className="flex min-h-0 min-w-0 flex-1 overflow-hidden"
>
<div
onPointerDown={handlePreviewResizeStart}
role="separator"
aria-orientation="vertical"
data-resize-handle-state={isResizingPreview ? 'drag' : 'inactive'}
className={cn(
// Transparent 2px rail with a centered line and wider hit area.
'relative z-10 flex w-[2px] shrink-0 cursor-col-resize items-center justify-center bg-transparent transition-colors hover:bg-ds-bg-brand-subtle-default',
"before:absolute before:inset-y-0 before:-left-1 before:-right-1 before:content-['']",
'after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 after:bg-ds-bg-neutral-default-default after:transition-colors',
isResizingPreview &&
'bg-ds-bg-brand-subtle-default after:bg-ds-bg-brand-default-focus'
)}
/>
{/* Display content: middle column between chat and session. */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{activeProjectId ? (
<PreviewPanel
displaySettled={displaySettled}
onJumpToContext={handleJumpToContext}
/>
) : null}
</div>
</motion.div>
)}
</AnimatePresence>
<div
id="session-side-panel"
className={cn(
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-out',
isSidePanelVisible
? SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS
: cn(SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS, 'rounded-l-xl')
)}
>
{sessionSidePanel}
</div>
</div>
</ProjectEventRuntimeProvider>
);
}

View file

@ -15,7 +15,6 @@
import { fetchPut } from '@/api/http';
import Terminal from '@/components/Terminal';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import type { SelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
import { useHost } from '@/host';
import {
ArrowDown,
@ -34,11 +33,7 @@ import { useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../ui/button';
export default function TerminalAgentWorkspace({
selectedTurn,
}: {
selectedTurn?: SelectedProjectTurn;
}) {
export default function TerminalAgentWorkspace() {
//Get Chatstore for the active project's task
const host = useHost();
const electronAPI = host?.electronAPI;
@ -48,9 +43,8 @@ export default function TerminalAgentWorkspace({
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isTakeControl, setIsTakeControl] = useState(false);
const selectedChatState = selectedTurn?.chatStore?.getState();
const targetChatStore = selectedChatState ?? chatStore;
const activeTaskId = selectedTurn?.taskId ?? targetChatStore?.activeTaskId;
const targetChatStore = chatStore;
const activeTaskId = targetChatStore?.activeTaskId;
const taskAssigning =
targetChatStore?.tasks[activeTaskId as string]?.taskAssigning;
const activeWorkspace =

View file

@ -14,7 +14,7 @@
import { AddWorker } from '@/components/AddWorker';
import BottomBox, { type FileAttachment } from '@/components/ChatBox/BottomBox';
import { SESSION_SIDE_PANEL_CONTENT_WIDTH_CLASS } from '@/components/Session/sessionSidePanelLayout';
import { SESSION_SIDE_PANEL_CONTENT_WIDTH_CLASS } from '@/components/Session/SidePanel/layout';
import { Button } from '@/components/ui/button';
import { BASE_WORKFLOW_AGENTS } from '@/components/WorkFlow/baseWorkers';
import { isBaseWorkflowAgent } from '@/components/Workspace/FoldedAgentCard';

View file

@ -0,0 +1,112 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import {
useProjectEventStoreHydration,
type ProjectEventStoreHydrationState,
} from '@/hooks/useProjectEventStoreHydration';
import { useProjectRunEventStreams } from '@/hooks/useProjectRunEventStreams';
import {
getProjectEventStore,
type ProjectEventStoreSnapshot,
} from '@/store/projectEventStore';
import {
createContext,
useCallback,
useContext,
useMemo,
useSyncExternalStore,
type ReactNode,
} from 'react';
const subscribeToNothing = () => () => undefined;
const getNullSnapshot = () => null;
const retryNothing = () => undefined;
const IDLE_HYDRATION: ProjectEventStoreHydrationState = {
status: 'idle',
errorCode: null,
eventsTruncated: false,
retry: retryNothing,
};
export interface ProjectEventRuntimeValue {
hydration: ProjectEventStoreHydrationState;
projectId: string | null;
snapshot: ProjectEventStoreSnapshot | null;
}
const ProjectEventRuntimeContext = createContext<ProjectEventRuntimeValue>({
hydration: IDLE_HYDRATION,
projectId: null,
snapshot: null,
});
/**
* Own the durable Project event runtime once for the whole Session shell.
* ChatBox and SessionSidePanel are read-only consumers of this shared owner.
*/
export function ProjectEventRuntimeProvider({
children,
projectId,
}: {
children: ReactNode;
projectId: string | null | undefined;
}) {
const normalizedProjectId = projectId || null;
const runtimeEnabled = Boolean(normalizedProjectId);
const store = useMemo(
() =>
runtimeEnabled && normalizedProjectId
? getProjectEventStore(normalizedProjectId)
: null,
[normalizedProjectId, runtimeEnabled]
);
const subscribe = useCallback(
(listener: () => void) =>
store?.subscribe(listener) ?? subscribeToNothing(),
[store]
);
const getSnapshot = useCallback(() => store?.getSnapshot() ?? null, [store]);
const snapshot = useSyncExternalStore(
subscribe,
getSnapshot,
getNullSnapshot
);
const hydration = useProjectEventStoreHydration({
projectId: normalizedProjectId,
enabled: runtimeEnabled,
});
useProjectRunEventStreams({
projectId: normalizedProjectId,
snapshot,
enabled: runtimeEnabled,
});
const value = useMemo<ProjectEventRuntimeValue>(
() => ({ hydration, projectId: normalizedProjectId, snapshot }),
[hydration, normalizedProjectId, snapshot]
);
return (
<ProjectEventRuntimeContext.Provider value={value}>
{children}
</ProjectEventRuntimeContext.Provider>
);
}
export function useProjectEventRuntime(): ProjectEventRuntimeValue {
return useContext(ProjectEventRuntimeContext);
}

View file

@ -33,7 +33,11 @@ export type UseProjectEventStoreHydrationOptions = {
export type ProjectEventStoreHydrationState = {
status: 'idle' | 'loading' | 'retrying' | 'ready' | 'error';
errorCode: ProjectEventStoreHydrationError['code'] | 'request_failed' | null;
errorCode:
| ProjectEventStoreHydrationError['code']
| 'request_failed'
| 'unsupported'
| null;
eventsTruncated: boolean;
/**
* Starts a fresh attempt, including after a non-retryable failure that has
@ -51,20 +55,36 @@ function isAbortError(error: unknown): boolean {
);
}
function isNonRetryable(
function requestStatus(error: unknown): number | null {
if (!error || typeof error !== 'object') return null;
const candidate = error as {
status?: unknown;
response?: { status?: unknown };
};
const status = candidate.status ?? candidate.response?.status;
return typeof status === 'number' && Number.isInteger(status) ? status : null;
}
function nonRetryableErrorCode(
error: unknown
): error is ProjectEventStoreHydrationError {
return (
): ProjectEventStoreHydrationState['errorCode'] {
if (
error instanceof ProjectEventStoreHydrationError &&
(error.code === 'invalid_response' || error.code === 'limit_exceeded')
);
) {
return error.code;
}
// Older/local Brain deployments may not expose the event replay API yet.
// Retrying an unsupported capability forever cannot make it appear.
if (requestStatus(error) === 404) return 'unsupported';
return null;
}
/**
* Own one authoritative initial snapshot per fresh store and later fail-closed
* rebuilds. Live-only projection does not mark that checkpoint complete. The
* hook is mounted only for the feature-flagged ChatBox path and reuses its
* existing SSE ingest owner; it never opens another live connection.
* shared Project runtime owns this hook and reuses the existing SSE ingest
* owner; it never opens another live connection.
*/
export function useProjectEventStoreHydration({
projectId,
@ -161,11 +181,12 @@ export function useProjectEventStoreHydration({
.catch((error: unknown) => {
if (!mounted || isAbortError(error)) return;
consecutiveFailures += 1;
if (isNonRetryable(error)) {
const nonRetryableCode = nonRetryableErrorCode(error);
if (nonRetryableCode) {
blockedIncarnation = requestIncarnation;
setHydrationState({
status: 'error',
errorCode: error.code,
errorCode: nonRetryableCode,
eventsTruncated: false,
});
} else {

View file

@ -0,0 +1,143 @@
// ========= 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 { useProjectEventRuntime } from '@/hooks/useProjectEventRuntime';
import type { ProjectedRun } from '@/lib/projector';
import type { ChatProjectionNode } from '@/lib/projector/chat';
import type { ProjectEventStoreSnapshot } from '@/store/projectEventStore';
import { useMemo } from 'react';
const LIVE_RUN_STATUSES = new Set<ProjectedRun['status']>([
'pending',
'running',
'waiting_for_user',
'cancelling',
]);
export interface ProjectSessionRun {
/** Durable Run identity. `taskId` remains as a UI compatibility alias. */
runId: string;
taskId: string;
status: ProjectedRun['status'];
nodes: ChatProjectionNode[];
createdAt: number;
updatedAt: number;
isCurrent: boolean;
}
export interface ProjectSessionOverview {
currentRun: ProjectSessionRun | null;
historicalRuns: ProjectSessionRun[];
runs: ProjectSessionRun[];
}
function timestamp(value: string | null | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function compareNodes(left: ChatProjectionNode, right: ChatProjectionNode) {
// Callers sort one Run at a time. Its durable sequence is the canonical
// fact order; wall-clock timestamps are display metadata and may tie or
// arrive skewed after restore/import.
return (
left.runSequence - right.runSequence ||
timestamp(left.createdAt) - timestamp(right.createdAt) ||
left.eventId.localeCompare(right.eventId)
);
}
function compareRuns(left: ProjectSessionRun, right: ProjectSessionRun) {
return (
right.updatedAt - left.updatedAt ||
right.createdAt - left.createdAt ||
right.runId.localeCompare(left.runId)
);
}
/** Build the SidePanel Run view exclusively from the bounded durable bus. */
export function buildProjectSessionOverview(
snapshot: ProjectEventStoreSnapshot | null
): ProjectSessionOverview {
if (!snapshot) {
return { currentRun: null, historicalRuns: [], runs: [] };
}
const nodesByRun = new Map<string, ChatProjectionNode[]>();
for (const node of snapshot.chat.nodes) {
const nodes = nodesByRun.get(node.runId) ?? [];
nodes.push(node);
nodesByRun.set(node.runId, nodes);
}
const runIds = new Set([
...Object.keys(snapshot.view.runs),
...nodesByRun.keys(),
]);
const runs = [...runIds].map<ProjectSessionRun>((runId) => {
const projectedRun = snapshot.view.runs[runId];
const nodes = [...(nodesByRun.get(runId) ?? [])].sort(compareNodes);
const nodeTimes = nodes
.map((node) => timestamp(node.createdAt))
.filter((value) => value > 0);
const aggregateTime = timestamp(projectedRun?.updatedAt);
const createdAt =
nodeTimes.length > 0 ? Math.min(...nodeTimes) : aggregateTime;
const updatedAt = Math.max(aggregateTime, ...nodeTimes, createdAt);
return {
runId,
taskId: runId,
status: projectedRun?.status ?? 'unknown',
nodes,
createdAt,
updatedAt,
isCurrent: false,
};
});
runs.sort(compareRuns);
const current =
runs
.filter((run) => LIVE_RUN_STATUSES.has(run.status))
.sort(compareRuns)[0] ??
runs[0] ??
null;
const normalizedRuns = runs.map((run) => ({
...run,
isCurrent: run.runId === current?.runId,
}));
const currentRun =
normalizedRuns.find((run) => run.isCurrent) ?? current ?? null;
return {
currentRun,
historicalRuns: normalizedRuns.filter((run) => !run.isCurrent),
runs: normalizedRuns,
};
}
/**
* Project-level SidePanel view backed by the SQLite journal projection.
* ChatStore is intentionally not consulted here.
*/
export function useProjectSessionOverview(
projectId: string | null | undefined
): ProjectSessionOverview {
const runtime = useProjectEventRuntime();
const snapshot =
projectId && runtime.projectId === projectId ? runtime.snapshot : null;
return useMemo(() => buildProjectSessionOverview(snapshot), [snapshot]);
}

View file

@ -1,79 +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 type { ChatStore, VanillaChatStore } from '@/store/chatStore';
import { usePageTabStore } from '@/store/pageTabStore';
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
import { useCallback, useSyncExternalStore } from 'react';
export interface SelectedProjectTurn {
chatId: string | null;
chatStore: VanillaChatStore | null;
task: ChatStore['tasks'][string] | undefined;
taskId: string | null;
}
export function useSelectedProjectTurn(
projectId: string | null | undefined
): SelectedProjectTurn {
const projectStore = useProjectRuntimeStore();
const selectedTaskId = usePageTabStore((state) =>
projectId ? state.sidePanelSelectedTurnByProject[projectId] : undefined
);
let chatId: string | null = null;
let chatStore: VanillaChatStore | null = null;
let taskId = selectedTaskId ?? null;
if (projectId && taskId) {
for (const entry of projectStore.getAllChatStores(projectId)) {
if (entry.chatStore.getState().tasks[taskId]) {
chatId = entry.chatId;
chatStore = entry.chatStore;
break;
}
}
}
if (projectId && !chatStore) {
const activeStore = projectStore.getActiveChatStore(projectId);
const activeTaskId = activeStore?.getState().activeTaskId ?? null;
if (activeStore && activeTaskId) {
const project = projectStore.projects[projectId];
chatId =
Object.entries(project?.chatStores ?? {}).find(
([, store]) => store === activeStore
)?.[0] ?? null;
chatStore = activeStore;
taskId = activeTaskId;
}
}
const subscribe = useCallback(
(listener: () => void) => chatStore?.subscribe(listener) ?? (() => {}),
[chatStore]
);
const getSnapshot = useCallback(
() => chatStore?.getState() ?? null,
[chatStore]
);
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return {
chatId,
chatStore,
task: taskId ? state?.tasks[taskId] : undefined,
taskId,
};
}

View file

@ -335,6 +335,34 @@
"sessions-collapse-list": "طي قائمة الجلسات",
"sessions-session-menu": "خيارات الجلسة",
"sessions-untitled": "جلسة بلا عنوان",
"session-summary": "ملخص",
"session-summary-scope": "محتوى الملخص",
"session-summary-latest-only": "أحدث تشغيل فقط",
"session-summary-all": "الكل",
"session-activity-empty": "سيظهر نشاط الجلسة هنا عند بدء العمل.",
"session-panel-skills": "المهارات",
"session-panel-resources": "الموارد",
"session-panel-files": "الملفات",
"session-panel-files-empty": "لا توجد ملفات مخرجات بعد.",
"session-panel-attach-files": "إرفاق ملفات بالتشغيل الحالي",
"session-panel-add-files-unavailable": "لا يمكن إرفاق الملفات إلا بمدخلات التشغيل الحالي.",
"session-panel-file-unavailable": "المعاينة غير متاحة",
"session-panel-history-unavailable": "تعذّر تحميل بعض سجل الجلسة.",
"session-panel-history-unsupported": "لا تدعم هذه الواجهة الخلفية سجل الجلسة بعد.",
"session-panel-environments": "البيئات",
"session-panel-earlier": "عمليات التشغيل السابقة",
"session-panel-agent": "وكيل",
"session-panel-subagent": "وكيل فرعي",
"session-panel-remote-subagent": "وكيل فرعي بعيد",
"session-panel-agent-description": "وكيل متعدد الأغراض يخطط للمشروع وينجزه باستخدام الأدوات المتاحة.",
"session-panel-subagent-description": "وكيل مفوَّض يُستخدم لجزء محدد من هذا المشروع.",
"session-panel-tool-calls": "استدعاءات الأدوات",
"session-panel-tool-history": "سجل الطلبات والاستجابات",
"session-panel-call": "استدعاء {{name}} رقم {{number}}",
"session-panel-request": "الطلب",
"session-panel-response": "الاستجابة",
"session-panel-no-call-details": "لا تتوفر تفاصيل محفوظة للطلب أو الاستجابة.",
"session-panel-upload-failed": "تعذر تحميل {{name}}",
"workspace-lets-do-this": "لنبدأ",
"workspace-work-in-project": "العمل في مشروع",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,34 @@
"sessions-collapse-list": "Sitzungsliste einklappen",
"sessions-session-menu": "Sitzungsoptionen",
"sessions-untitled": "Unbenannte Sitzung",
"session-summary": "Zusammenfassung",
"session-summary-scope": "Inhalt der Zusammenfassung",
"session-summary-latest-only": "Nur der neueste Lauf",
"session-summary-all": "Alle",
"session-activity-empty": "Die Sitzungsaktivität wird hier angezeigt, sobald die Arbeit beginnt.",
"session-panel-skills": "Skills",
"session-panel-resources": "Ressourcen",
"session-panel-files": "Dateien",
"session-panel-files-empty": "Noch keine Ausgabedateien.",
"session-panel-attach-files": "Dateien an den aktuellen Lauf anhängen",
"session-panel-add-files-unavailable": "Dateien können nur an die Eingabe des aktuellen Laufs angehängt werden.",
"session-panel-file-unavailable": "Vorschau nicht verfügbar",
"session-panel-history-unavailable": "Ein Teil des Sitzungsverlaufs konnte nicht geladen werden.",
"session-panel-history-unsupported": "Dieses Backend unterstützt den Sitzungsverlauf noch nicht.",
"session-panel-environments": "Umgebungen",
"session-panel-earlier": "Frühere Läufe",
"session-panel-agent": "Agent",
"session-panel-subagent": "Subagent",
"session-panel-remote-subagent": "Remote-Subagent",
"session-panel-agent-description": "Ein universeller Agent, der das Projekt mit den verfügbaren Tools plant und abschließt.",
"session-panel-subagent-description": "Ein delegierter Agent für einen abgegrenzten Teil dieses Projekts.",
"session-panel-tool-calls": "Tool-Aufrufe",
"session-panel-tool-history": "Anfrage- und Antwortverlauf",
"session-panel-call": "{{name}}-Aufruf {{number}}",
"session-panel-request": "Anfrage",
"session-panel-response": "Antwort",
"session-panel-no-call-details": "Es sind keine gespeicherten Anfrage- oder Antwortdetails verfügbar.",
"session-panel-upload-failed": "{{name}} konnte nicht hochgeladen werden",
"workspace-lets-do-this": "Los geht's",
"workspace-work-in-project": "In einem Projekt arbeiten",
"workspace-select-project": "Select a project",

View file

@ -344,6 +344,34 @@
"sessions-collapse-list": "Collapse runs list",
"sessions-session-menu": "Run options",
"sessions-untitled": "Untitled run",
"session-summary": "Summary",
"session-summary-scope": "Summary content",
"session-summary-latest-only": "Latest only",
"session-summary-all": "All",
"session-activity-empty": "Session activity will appear here as work begins.",
"session-panel-skills": "Skills",
"session-panel-resources": "Resources",
"session-panel-files": "Files",
"session-panel-files-empty": "No output files yet.",
"session-panel-attach-files": "Attach files to the current run",
"session-panel-add-files-unavailable": "Files can only be attached to the current run input.",
"session-panel-file-unavailable": "Preview unavailable",
"session-panel-history-unavailable": "Some session history could not be loaded.",
"session-panel-history-unsupported": "This backend does not support session history yet.",
"session-panel-environments": "Environments",
"session-panel-earlier": "Earlier runs",
"session-panel-agent": "Agent",
"session-panel-subagent": "Subagent",
"session-panel-remote-subagent": "Remote subagent",
"session-panel-agent-description": "A general-purpose agent that plans and completes the project using the available tools.",
"session-panel-subagent-description": "A delegated agent used for a bounded part of this project.",
"session-panel-tool-calls": "Tool calls",
"session-panel-tool-history": "Request and response history",
"session-panel-call": "{{name}} call {{number}}",
"session-panel-request": "Request",
"session-panel-response": "Response",
"session-panel-no-call-details": "No stored request or response details are available.",
"session-panel-upload-failed": "Failed to upload {{name}}",
"workspace-lets-do-this": "Let's do this",
"workspace-work-in-project": "Work in a project",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,34 @@
"sessions-collapse-list": "Contraer lista de sesiones",
"sessions-session-menu": "Opciones de sesión",
"sessions-untitled": "Sesión sin título",
"session-summary": "Resumen",
"session-summary-scope": "Contenido del resumen",
"session-summary-latest-only": "Solo la ejecución más reciente",
"session-summary-all": "Todo",
"session-activity-empty": "La actividad de la sesión aparecerá aquí cuando comience el trabajo.",
"session-panel-skills": "Habilidades",
"session-panel-resources": "Recursos",
"session-panel-files": "Archivos",
"session-panel-files-empty": "Aún no hay archivos de salida.",
"session-panel-attach-files": "Adjuntar archivos a la ejecución actual",
"session-panel-add-files-unavailable": "Los archivos solo se pueden adjuntar a la entrada de la ejecución actual.",
"session-panel-file-unavailable": "Vista previa no disponible",
"session-panel-history-unavailable": "No se pudo cargar parte del historial de la sesión.",
"session-panel-history-unsupported": "Este backend todavía no admite el historial de sesiones.",
"session-panel-environments": "Entornos",
"session-panel-earlier": "Ejecuciones anteriores",
"session-panel-agent": "Agente",
"session-panel-subagent": "Subagente",
"session-panel-remote-subagent": "Subagente remoto",
"session-panel-agent-description": "Un agente de propósito general que planifica y completa el proyecto con las herramientas disponibles.",
"session-panel-subagent-description": "Un agente delegado para una parte delimitada de este proyecto.",
"session-panel-tool-calls": "Llamadas a herramientas",
"session-panel-tool-history": "Historial de solicitudes y respuestas",
"session-panel-call": "Llamada {{number}} de {{name}}",
"session-panel-request": "Solicitud",
"session-panel-response": "Respuesta",
"session-panel-no-call-details": "No hay detalles guardados de solicitudes o respuestas.",
"session-panel-upload-failed": "No se pudo subir {{name}}",
"workspace-lets-do-this": "Vamos a ello",
"workspace-work-in-project": "Trabajar en un proyecto",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,34 @@
"sessions-collapse-list": "Réduire la liste des sessions",
"sessions-session-menu": "Options de session",
"sessions-untitled": "Session sans titre",
"session-summary": "Résumé",
"session-summary-scope": "Contenu du résumé",
"session-summary-latest-only": "Dernière exécution uniquement",
"session-summary-all": "Tout",
"session-activity-empty": "Lactivité de la session apparaîtra ici au début du travail.",
"session-panel-skills": "Compétences",
"session-panel-resources": "Ressources",
"session-panel-files": "Fichiers",
"session-panel-files-empty": "Aucun fichier de sortie pour le moment.",
"session-panel-attach-files": "Joindre des fichiers à lexécution actuelle",
"session-panel-add-files-unavailable": "Les fichiers peuvent uniquement être joints à lentrée de lexécution actuelle.",
"session-panel-file-unavailable": "Aperçu indisponible",
"session-panel-history-unavailable": "Une partie de lhistorique de la session na pas pu être chargée.",
"session-panel-history-unsupported": "Ce backend ne prend pas encore en charge lhistorique des sessions.",
"session-panel-environments": "Environnements",
"session-panel-earlier": "Exécutions précédentes",
"session-panel-agent": "Agent",
"session-panel-subagent": "Sous-agent",
"session-panel-remote-subagent": "Sous-agent distant",
"session-panel-agent-description": "Un agent polyvalent qui planifie et réalise le projet à laide des outils disponibles.",
"session-panel-subagent-description": "Un agent délégué pour une partie délimitée de ce projet.",
"session-panel-tool-calls": "Appels doutils",
"session-panel-tool-history": "Historique des requêtes et réponses",
"session-panel-call": "Appel {{number}} de {{name}}",
"session-panel-request": "Requête",
"session-panel-response": "Réponse",
"session-panel-no-call-details": "Aucun détail de requête ou de réponse enregistré nest disponible.",
"session-panel-upload-failed": "Échec de limportation de {{name}}",
"workspace-lets-do-this": "C'est parti",
"workspace-work-in-project": "Travailler dans un projet",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,34 @@
"sessions-collapse-list": "Comprimi elenco sessioni",
"sessions-session-menu": "Opzioni sessione",
"sessions-untitled": "Sessione senza titolo",
"session-summary": "Riepilogo",
"session-summary-scope": "Contenuto del riepilogo",
"session-summary-latest-only": "Solo lesecuzione più recente",
"session-summary-all": "Tutte",
"session-activity-empty": "Lattività della sessione verrà visualizzata qui allinizio del lavoro.",
"session-panel-skills": "Competenze",
"session-panel-resources": "Risorse",
"session-panel-files": "File",
"session-panel-files-empty": "Ancora nessun file di output.",
"session-panel-attach-files": "Allega file all'esecuzione corrente",
"session-panel-add-files-unavailable": "I file possono essere allegati solo all'input dell'esecuzione corrente.",
"session-panel-file-unavailable": "Anteprima non disponibile",
"session-panel-history-unavailable": "Non è stato possibile caricare parte della cronologia della sessione.",
"session-panel-history-unsupported": "Questo backend non supporta ancora la cronologia delle sessioni.",
"session-panel-environments": "Ambienti",
"session-panel-earlier": "Esecuzioni precedenti",
"session-panel-agent": "Agente",
"session-panel-subagent": "Sottoagente",
"session-panel-remote-subagent": "Sottoagente remoto",
"session-panel-agent-description": "Un agente generico che pianifica e completa il progetto utilizzando gli strumenti disponibili.",
"session-panel-subagent-description": "Un agente delegato utilizzato per una parte circoscritta di questo progetto.",
"session-panel-tool-calls": "Chiamate agli strumenti",
"session-panel-tool-history": "Cronologia di richieste e risposte",
"session-panel-call": "Chiamata {{number}} di {{name}}",
"session-panel-request": "Richiesta",
"session-panel-response": "Risposta",
"session-panel-no-call-details": "Non sono disponibili dettagli salvati per richieste o risposte.",
"session-panel-upload-failed": "Impossibile caricare {{name}}",
"workspace-lets-do-this": "Iniziamo",
"workspace-work-in-project": "Lavora in un progetto",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,34 @@
"sessions-collapse-list": "セッション一覧を折りたたむ",
"sessions-session-menu": "セッションのオプション",
"sessions-untitled": "無題のセッション",
"session-summary": "概要",
"session-summary-scope": "概要の内容",
"session-summary-latest-only": "最新の実行のみ",
"session-summary-all": "すべて",
"session-activity-empty": "作業を開始すると、セッションのアクティビティがここに表示されます。",
"session-panel-skills": "スキル",
"session-panel-resources": "リソース",
"session-panel-files": "ファイル",
"session-panel-files-empty": "出力ファイルはまだありません。",
"session-panel-attach-files": "現在の実行にファイルを添付",
"session-panel-add-files-unavailable": "ファイルは現在の実行の入力にのみ添付できます。",
"session-panel-file-unavailable": "プレビューできません",
"session-panel-history-unavailable": "セッション履歴の一部を読み込めませんでした。",
"session-panel-history-unsupported": "このバックエンドはまだセッション履歴に対応していません。",
"session-panel-environments": "環境",
"session-panel-earlier": "以前の実行",
"session-panel-agent": "エージェント",
"session-panel-subagent": "サブエージェント",
"session-panel-remote-subagent": "リモートサブエージェント",
"session-panel-agent-description": "利用可能なツールを使用してプロジェクトを計画し、完了する汎用エージェントです。",
"session-panel-subagent-description": "このプロジェクトの限定された部分を担当する委任エージェントです。",
"session-panel-tool-calls": "ツール呼び出し",
"session-panel-tool-history": "リクエストとレスポンスの履歴",
"session-panel-call": "{{name}} 呼び出し {{number}}",
"session-panel-request": "リクエスト",
"session-panel-response": "レスポンス",
"session-panel-no-call-details": "保存されたリクエストまたはレスポンスの詳細はありません。",
"session-panel-upload-failed": "{{name}} のアップロードに失敗しました",
"workspace-lets-do-this": "さあ、始めましょう",
"workspace-work-in-project": "プロジェクトで作業",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,34 @@
"sessions-collapse-list": "세션 목록 접기",
"sessions-session-menu": "세션 옵션",
"sessions-untitled": "제목 없는 세션",
"session-summary": "요약",
"session-summary-scope": "요약 내용",
"session-summary-latest-only": "최신 실행만",
"session-summary-all": "모두",
"session-activity-empty": "작업이 시작되면 세션 활동이 여기에 표시됩니다.",
"session-panel-skills": "스킬",
"session-panel-resources": "리소스",
"session-panel-files": "파일",
"session-panel-files-empty": "아직 출력 파일이 없습니다.",
"session-panel-attach-files": "현재 실행에 파일 첨부",
"session-panel-add-files-unavailable": "파일은 현재 실행 입력에만 첨부할 수 있습니다.",
"session-panel-file-unavailable": "미리보기를 사용할 수 없음",
"session-panel-history-unavailable": "일부 세션 기록을 불러오지 못했습니다.",
"session-panel-history-unsupported": "이 백엔드는 아직 세션 기록을 지원하지 않습니다.",
"session-panel-environments": "환경",
"session-panel-earlier": "이전 실행",
"session-panel-agent": "에이전트",
"session-panel-subagent": "하위 에이전트",
"session-panel-remote-subagent": "원격 하위 에이전트",
"session-panel-agent-description": "사용 가능한 도구로 프로젝트를 계획하고 완료하는 범용 에이전트입니다.",
"session-panel-subagent-description": "이 프로젝트의 제한된 부분을 담당하도록 위임된 에이전트입니다.",
"session-panel-tool-calls": "도구 호출",
"session-panel-tool-history": "요청 및 응답 기록",
"session-panel-call": "{{name}} 호출 {{number}}",
"session-panel-request": "요청",
"session-panel-response": "응답",
"session-panel-no-call-details": "저장된 요청 또는 응답 세부 정보가 없습니다.",
"session-panel-upload-failed": "{{name}} 업로드에 실패했습니다",
"workspace-lets-do-this": "시작해요",
"workspace-work-in-project": "프로젝트에서 작업",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,34 @@
"sessions-collapse-list": "Свернуть список сессий",
"sessions-session-menu": "Параметры сессии",
"sessions-untitled": "Сессия без названия",
"session-summary": "Сводка",
"session-summary-scope": "Содержимое сводки",
"session-summary-latest-only": "Только последний запуск",
"session-summary-all": "Все",
"session-activity-empty": "Активность сессии появится здесь после начала работы.",
"session-panel-skills": "Навыки",
"session-panel-resources": "Ресурсы",
"session-panel-files": "Файлы",
"session-panel-files-empty": "Выходных файлов пока нет.",
"session-panel-attach-files": "Прикрепить файлы к текущему запуску",
"session-panel-add-files-unavailable": "Файлы можно прикреплять только к входным данным текущего запуска.",
"session-panel-file-unavailable": "Предпросмотр недоступен",
"session-panel-history-unavailable": "Не удалось загрузить часть истории сеанса.",
"session-panel-history-unsupported": "Этот сервер пока не поддерживает историю сеансов.",
"session-panel-environments": "Среды",
"session-panel-earlier": "Предыдущие запуски",
"session-panel-agent": "Агент",
"session-panel-subagent": "Субагент",
"session-panel-remote-subagent": "Удалённый субагент",
"session-panel-agent-description": "Универсальный агент, который планирует и выполняет проект с помощью доступных инструментов.",
"session-panel-subagent-description": "Делегированный агент для выполнения ограниченной части этого проекта.",
"session-panel-tool-calls": "Вызовы инструментов",
"session-panel-tool-history": "История запросов и ответов",
"session-panel-call": "Вызов {{name}} № {{number}}",
"session-panel-request": "Запрос",
"session-panel-response": "Ответ",
"session-panel-no-call-details": "Сохранённые сведения о запросе или ответе отсутствуют.",
"session-panel-upload-failed": "Не удалось загрузить {{name}}",
"workspace-lets-do-this": "Давайте начнём",
"workspace-work-in-project": "Работа в проекте",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,34 @@
"sessions-collapse-list": "收起运行列表",
"sessions-session-menu": "运行选项",
"sessions-untitled": "未命名运行",
"session-summary": "摘要",
"session-summary-scope": "摘要内容",
"session-summary-latest-only": "仅最新运行",
"session-summary-all": "全部",
"session-activity-empty": "工作开始后,会话活动将显示在这里。",
"session-panel-skills": "技能",
"session-panel-resources": "资源",
"session-panel-files": "文件",
"session-panel-files-empty": "暂无输出文件。",
"session-panel-attach-files": "向当前运行附加文件",
"session-panel-add-files-unavailable": "只能将文件附加到当前运行的输入。",
"session-panel-file-unavailable": "预览不可用",
"session-panel-history-unavailable": "部分会话历史加载失败。",
"session-panel-history-unsupported": "当前后端尚不支持会话历史。",
"session-panel-environments": "环境",
"session-panel-earlier": "之前的运行",
"session-panel-agent": "智能体",
"session-panel-subagent": "子智能体",
"session-panel-remote-subagent": "远程子智能体",
"session-panel-agent-description": "使用可用工具规划并完成项目的通用智能体。",
"session-panel-subagent-description": "用于处理此项目中限定部分的委派智能体。",
"session-panel-tool-calls": "工具调用",
"session-panel-tool-history": "请求和响应历史",
"session-panel-call": "{{name}} 调用 {{number}}",
"session-panel-request": "请求",
"session-panel-response": "响应",
"session-panel-no-call-details": "没有已保存的请求或响应详情。",
"session-panel-upload-failed": "无法上传 {{name}}",
"workspace-lets-do-this": "开始吧",
"workspace-work-in-project": "在项目中工作",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,34 @@
"sessions-collapse-list": "收合執行清單",
"sessions-session-menu": "執行選項",
"sessions-untitled": "未命名執行",
"session-summary": "摘要",
"session-summary-scope": "摘要內容",
"session-summary-latest-only": "僅最新執行",
"session-summary-all": "全部",
"session-activity-empty": "工作開始後,工作階段活動將顯示在這裡。",
"session-panel-skills": "技能",
"session-panel-resources": "資源",
"session-panel-files": "檔案",
"session-panel-files-empty": "尚無輸出檔案。",
"session-panel-attach-files": "向目前的執行附加檔案",
"session-panel-add-files-unavailable": "只能將檔案附加到目前執行的輸入。",
"session-panel-file-unavailable": "無法預覽",
"session-panel-history-unavailable": "部分工作階段記錄載入失敗。",
"session-panel-history-unsupported": "目前的後端尚不支援工作階段記錄。",
"session-panel-environments": "環境",
"session-panel-earlier": "先前的執行",
"session-panel-agent": "智能體",
"session-panel-subagent": "子智能體",
"session-panel-remote-subagent": "遠端子智能體",
"session-panel-agent-description": "使用可用工具規劃並完成專案的通用智能體。",
"session-panel-subagent-description": "用於處理此專案中限定部分的委派智能體。",
"session-panel-tool-calls": "工具呼叫",
"session-panel-tool-history": "請求和回應歷史",
"session-panel-call": "{{name}} 呼叫 {{number}}",
"session-panel-request": "請求",
"session-panel-response": "回應",
"session-panel-no-call-details": "沒有已儲存的請求或回應詳細資料。",
"session-panel-upload-failed": "無法上傳 {{name}}",
"workspace-lets-do-this": "開始吧",
"workspace-work-in-project": "在專案中工作",
"workspace-select-project": "Select a project",

View file

@ -717,7 +717,9 @@ function activityNode(
kind: 'activity',
activityType,
status: normalizeActivityStatus(
payload.status ?? payload.state ?? base.eventType.split('.').at(-1),
payload.status ??
payload.state ??
(isTypedActivity ? base.eventType.split('.').at(-1) : undefined),
fallbackStatus
),
title,
@ -774,17 +776,21 @@ function artifactNode(
const isTypedArtifact = !base.eventType.startsWith('legacy.');
// Shared typed projections carry only portable identity. A Desktop-local
// absolute path belongs in the resolver/transport layer, never in this node.
const explicitRelativePath = firstText(
payload.relative_path,
payload.relativePath
);
const rawPath = isTypedArtifact
? firstText(payload.relative_path, payload.relativePath)
? explicitRelativePath
: firstText(
payload.relative_path,
payload.relativePath,
explicitRelativePath,
payload.file_path,
payload.filePath,
payload.path
);
const portablePath = portableRelativePath(rawPath);
const name = safeArtifactBasename(payload.name, rawPath);
const path = portableRelativePath(rawPath) || name;
const path = portablePath || name;
return {
...base,
kind: 'artifact',
@ -795,6 +801,8 @@ function artifactNode(
artifactId: firstText(payload.artifact_id, payload.artifactId) || undefined,
path,
name: name || safeArtifactBasename(path) || undefined,
relativePath:
portableRelativePath(explicitRelativePath) || portablePath || undefined,
mimeType: firstText(payload.mime_type, payload.mimeType) || undefined,
agentId: firstText(payload.agent_id, payload.agentId) || undefined,
taskId:

View file

@ -168,8 +168,11 @@ export interface ChatActivityNode extends ChatProjectionNodeBase {
export interface ChatArtifactNode extends ChatProjectionNodeBase {
kind: 'artifact';
operation: ChatArtifactOperation;
artifactId?: string;
path: string;
/** Stable backend-owned identity for one generated artifact. */
artifactId?: string;
/** Workspace-scoped identity; never inferred from an absolute local path. */
relativePath?: string;
name?: string;
mimeType?: string;
agentId?: string;

View file

@ -16,20 +16,50 @@ function normalizeRelativePath(value: string): string {
return value.replace(/\\/g, '/').replace(/^\.\/+/, '');
}
/**
* Normalize a backend-owned workspace-relative file identity.
*
* This deliberately rejects absolute paths, URLs and traversal instead of
* guessing a relative path from a local machine path or basename.
*/
export function normalizeWorkspaceRelativePath(
value: string | null | undefined
): string | null {
const normalized = normalizeRelativePath((value || '').trim());
if (!normalized || normalized.startsWith('/')) return null;
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(normalized)) return null;
const segments: string[] = [];
for (const segment of normalized.split('/')) {
if (!segment || segment === '.') continue;
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
return null;
}
if (
decoded === '.' ||
decoded === '..' ||
decoded.includes('/') ||
decoded.includes('\\') ||
decoded.includes('\0')
) {
return null;
}
segments.push(segment);
}
return segments.length > 0 ? segments.join('/') : null;
}
/**
* Return a display path scoped to the current workspace root.
* Absolute local paths and remote preview URLs are intentionally never shown.
*/
export function getWorkspaceRelativeFilePath(file: FileInfo): string {
const relativePath = normalizeRelativePath((file.relativePath || '').trim());
const relativeSegments = relativePath.split('/').filter(Boolean);
if (
relativePath &&
!relativePath.startsWith('/') &&
!/^[A-Za-z]:\//.test(relativePath) &&
!relativePath.includes('://') &&
!relativeSegments.includes('..')
) {
const relativePath = normalizeWorkspaceRelativePath(file.relativePath);
if (relativePath) {
const relativeSegments = relativePath.split('/');
const normalizedName = normalizeRelativePath((file.name || '').trim());
const basename = relativeSegments.at(-1);
return normalizedName && basename !== normalizedName

View file

@ -27,7 +27,7 @@ import {
PROJECT_SIDEBAR_FOLD_SPRING,
PROJECT_SIDEBAR_RAIL_WIDTH_PX,
} from '@/components/ProjectPageSidebar/constants';
import SessionGroup from '@/components/Session/SessionGroup';
import SessionGroup from '@/components/Session/SidePanel/components/SessionGroup';
import TriggerPanel from '@/components/Trigger';
import Workspace from '@/components/Workspace';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
@ -763,7 +763,7 @@ export default function WorkspacePage() {
<div className="flex h-full min-h-0 flex-row overflow-hidden px-1 pb-1 pt-10">
<div
ref={shellPanelGroupRef}
className="h-full min-h-0 w-full min-w-0 flex-1 rounded-2xl bg-ds-bg-neutral-subtle-default"
className="h-full min-h-0 w-full min-w-0 flex-1"
>
<ResizablePanelGroup
ref={shellPanelGroupImperativeRef}
@ -777,7 +777,7 @@ export default function WorkspacePage() {
defaultSize={defaultPanelLayout[0]}
minSize={sidebarPct.rail}
maxSize={sidebarPct.max}
className="min-h-0 min-w-0 py-1 pl-1"
className="min-h-0 min-w-0"
>
<ProjectPageSidebar chatStore={chatStore} />
</ResizablePanel>

View file

@ -28,16 +28,13 @@ export type ChatEventProjectionInput = {
};
/**
* Shadowing is opt-in everywhere, including development.
*
* It was previously on for every dev build. Shadow ingestion builds a full
* parallel per-Project event store (multi-megabyte queue, legacy and chat
* budgets) that no visible UI reads while the timeline flag is off, so paying
* that cost in every dev session is not worth the parity signal. Set
* VITE_CHATBOX_EVENT_SHADOW=true to measure parity.
* The Session side panel is event-native, so the migration bridge is enabled
* by default even while the ChatBox renderer remains behind its own cutover
* flag. Set VITE_SESSION_SIDE_PANEL_EVENT_BUS=false only for emergency rollback.
*/
export function isChatEventProjectionEnabled(): boolean {
return (
import.meta.env.VITE_SESSION_SIDE_PANEL_EVENT_BUS !== 'false' ||
import.meta.env.VITE_CHATBOX_EVENT_SHADOW === 'true' ||
// The visible read path still needs not-yet-migrated legacy families.
// Canonical-owned families are filtered below before they can become a
@ -84,7 +81,7 @@ export function shouldProjectLegacyChatStep(
);
}
/** Never allow migration projection failures to affect the legacy UI path. */
/** Never allow projection failures to affect the source chat transport. */
export function enqueueChatEventProjection(
input: ChatEventProjectionInput,
enabled = isChatEventProjectionEnabled(),

View file

@ -771,7 +771,7 @@ interface Task {
// Trigger execution ID for tracking trigger task completion
executionId?: string;
nextExecutionId?: string;
/** Unix ms timestamp when this task was created — used for TurnTabs ordering. */
/** Unix ms timestamp when this task was created — used for Run ordering. */
createdAt: number;
}
@ -2027,18 +2027,18 @@ const chatStore = (initial?: Partial<ChatStore>) =>
return taskId;
},
computedProgressValue(taskId: string) {
const { tasks, setProgressValue, activeTaskId } = get();
const { tasks, setProgressValue } = get();
const taskRunning = [...tasks[taskId].taskRunning];
const finishedTask = taskRunning?.filter(
(task) =>
task.status === TaskStatus.COMPLETED ||
task.status === TaskStatus.FAILED
).length;
const taskProgress = (
((finishedTask || 0) / (taskRunning?.length || 0)) *
100
).toFixed(2);
setProgressValue(activeTaskId as string, Number(taskProgress));
const taskProgress =
taskRunning.length > 0
? Number(((finishedTask / taskRunning.length) * 100).toFixed(2))
: 0;
setProgressValue(taskId, taskProgress);
},
removeTask(taskId: string) {
// Clean up any pending auto-confirm timers when removing a task

View file

@ -364,29 +364,7 @@ interface PageTabState {
triggerSelectRequestId: number;
requestSelectTrigger: (triggerId: number) => void;
// ── TurnTabs: per-project turn selection ─────────────────────────────────
/**
* Which task (turn) is currently highlighted in the side-panel TurnTabs,
* per project. `null` / absent default to the chatStore's activeTaskId.
*/
sidePanelSelectedTurnByProject: Record<string, string>;
/**
* Unix-ms timestamp until which a user tab-click overrides the
* scroll-driven viewport selection, per project.
*/
sidePanelManualUntilByProject: Record<string, number>;
/**
* Task ID currently visible in the chatbox scroll viewport, per project.
* Written by the IntersectionObserver in ProjectChatContainer.
*/
sidePanelViewedTurnByProject: Record<string, string>;
setSidePanelSelectedTurn: (
projectId: string,
taskId: string,
manualDurationMs?: number
) => void;
setSidePanelViewedTurn: (projectId: string, taskId: string) => void;
/** Set by TurnTabs to tell the matching ProjectChatContainer to scroll. */
/** One-shot command used by historical rows in the Session side panel. */
scrollToTurnRequest: { projectId: string; taskId: string } | null;
setScrollToTurnRequest: (
request: { projectId: string; taskId: string } | null
@ -638,56 +616,6 @@ export const usePageTabStore = create<PageTabState>()(
triggerSelectRequestId: state.triggerSelectRequestId + 1,
})),
sidePanelSelectedTurnByProject: {},
sidePanelManualUntilByProject: {},
sidePanelViewedTurnByProject: {},
setSidePanelSelectedTurn: (projectId, taskId, manualDurationMs = 1500) =>
set((state) => ({
sidePanelSelectedTurnByProject: {
...state.sidePanelSelectedTurnByProject,
[projectId]: taskId,
},
sidePanelManualUntilByProject: {
...state.sidePanelManualUntilByProject,
[projectId]: Date.now() + manualDurationMs,
},
})),
setSidePanelViewedTurn: (projectId, taskId) =>
set((state) => {
const manualUntil =
state.sidePanelManualUntilByProject[projectId] ?? 0;
// Suppress viewport updates during the manual-selection window so a
// tab click isn't immediately overwritten by an in-flight observer
// firing while the chatbox is mid-scroll.
const selectedTaskId =
state.sidePanelSelectedTurnByProject[projectId];
if (Date.now() < manualUntil && selectedTaskId !== taskId) {
return state;
}
if (
state.sidePanelViewedTurnByProject[projectId] === taskId &&
selectedTaskId === taskId &&
manualUntil === 0
) {
return state;
}
// Once the window expires, drive both fields so components only need
// to read `sidePanelSelectedTurnByProject` — no Date.now() in render.
return {
sidePanelViewedTurnByProject: {
...state.sidePanelViewedTurnByProject,
[projectId]: taskId,
},
sidePanelSelectedTurnByProject: {
...state.sidePanelSelectedTurnByProject,
[projectId]: taskId,
},
sidePanelManualUntilByProject: {
...state.sidePanelManualUntilByProject,
[projectId]: 0,
},
};
}),
scrollToTurnRequest: null,
setScrollToTurnRequest: (request) =>
set({ scrollToTurnRequest: request }),

View file

@ -1,306 +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 {
reconcileToolkitState,
TOOLKIT_MIN_DISPLAY_MS,
} from '@/components/Session/SidePanelSections/AgentPoolSection';
import { AgentStatusValue } from '@/types/constants';
import { describe, expect, it } from 'vitest';
type State = Parameters<typeof reconcileToolkitState>[0];
function makeState(): State {
return { entries: new Map(), timers: new Map(), retired: new Set() };
}
function makeScheduler() {
const scheduled: Array<{
id: string;
delay: number;
cancelled: boolean;
handle: number;
}> = [];
let nextHandle = 1;
const schedule = (id: string, delay: number) => {
const handle = nextHandle++;
const record = { id, delay, cancelled: false, handle };
scheduled.push(record);
return handle as unknown as ReturnType<typeof setTimeout>;
};
const cancel = (h: ReturnType<typeof setTimeout>) => {
const record = scheduled.find(
(r) => r.handle === (h as unknown as number) && !r.cancelled
);
if (record) record.cancelled = true;
};
return { scheduled, schedule, cancel };
}
function runExpired(
state: State,
_scheduled: ReturnType<typeof makeScheduler>['scheduled'],
now: number
) {
for (const entry of [...state.entries.values()]) {
if (entry.expireAt !== null && entry.expireAt <= now) {
state.entries.delete(entry.id);
state.timers.delete(entry.id);
state.retired.add(entry.id);
}
}
}
describe('reconcileToolkitState', () => {
it('shows a RUNNING toolkit immediately', () => {
const state = makeState();
const { schedule, cancel } = makeScheduler();
const names = reconcileToolkitState(
state,
[{ id: 'a1', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING }],
{ now: 0, minDisplayMs: TOOLKIT_MIN_DISPLAY_MS, schedule, cancel }
);
expect(names).toEqual(['Browser Toolkit']);
});
it('hides a RUNNING toolkit when it completes — but only after minDisplayMs', () => {
const state = makeState();
const { scheduled, schedule, cancel } = makeScheduler();
const minDisplayMs = 1500;
// t=0 → ACTIVATE
let names = reconcileToolkitState(
state,
[{ id: 'a1', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING }],
{ now: 0, minDisplayMs, schedule, cancel }
);
expect(names).toEqual(['Browser Toolkit']);
// t=50 → DEACTIVATE — still in min-display window
names = reconcileToolkitState(
state,
[
{
id: 'a1',
name: 'Browser Toolkit',
status: AgentStatusValue.COMPLETED,
},
],
{ now: 50, minDisplayMs, schedule, cancel }
);
expect(names).toEqual(['Browser Toolkit']);
expect(scheduled).toHaveLength(1);
expect(scheduled[0]?.delay).toBeGreaterThanOrEqual(1400);
// t=1500 → timer fires, entry evicted
runExpired(state, scheduled, 1500);
names = reconcileToolkitState(state, [], {
now: 1500,
minDisplayMs,
schedule,
cancel,
});
expect(names).toEqual([]);
});
it('surfaces a fast toolkit that ACTIVATEs and DEACTIVATEs within a single render pass', () => {
// This mirrors the user report: a browser_agent fires Browser Toolkit,
// Search Toolkit, and Screenshot Toolkit. Search/Screenshot can flip
// RUNNING → COMPLETED very quickly. All three must still appear.
const state = makeState();
const { schedule, cancel } = makeScheduler();
const minDisplayMs = 1500;
const names = reconcileToolkitState(
state,
[
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
{
id: 's',
name: 'Search Toolkit',
status: AgentStatusValue.COMPLETED,
},
{
id: 'p',
name: 'Screenshot Toolkit',
status: AgentStatusValue.COMPLETED,
},
],
{ now: 100, minDisplayMs, schedule, cancel }
);
expect(names).toEqual([
'Browser Toolkit',
'Search Toolkit',
'Screenshot Toolkit',
]);
});
it('re-arming: a toolkit that flips back to RUNNING cancels its pending removal', () => {
const state = makeState();
const { scheduled, schedule, cancel } = makeScheduler();
const minDisplayMs = 1500;
reconcileToolkitState(
state,
[{ id: 'a', name: 'Search Toolkit', status: AgentStatusValue.RUNNING }],
{ now: 0, minDisplayMs, schedule, cancel }
);
reconcileToolkitState(
state,
[{ id: 'a', name: 'Search Toolkit', status: AgentStatusValue.COMPLETED }],
{ now: 100, minDisplayMs, schedule, cancel }
);
expect(scheduled.filter((s) => !s.cancelled)).toHaveLength(1);
reconcileToolkitState(
state,
[{ id: 'a', name: 'Search Toolkit', status: AgentStatusValue.RUNNING }],
{ now: 120, minDisplayMs, schedule, cancel }
);
expect(scheduled.filter((s) => !s.cancelled)).toHaveLength(0);
const entry = state.entries.get('a');
expect(entry?.expireAt).toBeNull();
});
it('dedupes by name while preserving first-seen order', () => {
// Each ACTIVATE of the same toolkit name gets a unique id from the store,
// but users should only see one tag per name.
const state = makeState();
const { schedule, cancel } = makeScheduler();
const minDisplayMs = 1500;
const names = reconcileToolkitState(
state,
[
{
id: '1',
name: 'Browser Toolkit',
status: AgentStatusValue.RUNNING,
},
{
id: '2',
name: 'Search Toolkit',
status: AgentStatusValue.RUNNING,
},
{
id: '3',
name: 'Browser Toolkit',
status: AgentStatusValue.RUNNING,
},
],
{ now: 0, minDisplayMs, schedule, cancel }
);
expect(names).toEqual(['Browser Toolkit', 'Search Toolkit']);
});
it('ignores the "notice" placeholder', () => {
// Consumers filter `toolkitName === "notice"` before passing events in.
// This test asserts reconcile behaves correctly when callers hand it only
// real toolkit events (the helper itself is caller-filtered).
const state = makeState();
const { schedule, cancel } = makeScheduler();
const names = reconcileToolkitState(
state,
[{ id: '1', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING }],
{ now: 0, minDisplayMs: 1500, schedule, cancel }
);
expect(names).toEqual(['Browser Toolkit']);
});
it('full sequence: browser stays, search and screenshot come and go', () => {
const state = makeState();
const { scheduled, schedule, cancel } = makeScheduler();
const minDisplayMs = 1500;
// t=0: browser starts
let names = reconcileToolkitState(
state,
[{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING }],
{ now: 0, minDisplayMs, schedule, cancel }
);
expect(names).toEqual(['Browser Toolkit']);
// t=200: search starts (while browser still running)
names = reconcileToolkitState(
state,
[
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
{ id: 's1', name: 'Search Toolkit', status: AgentStatusValue.RUNNING },
],
{ now: 200, minDisplayMs, schedule, cancel }
);
expect(names).toEqual(['Browser Toolkit', 'Search Toolkit']);
// t=250: search finishes fast
names = reconcileToolkitState(
state,
[
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
{
id: 's1',
name: 'Search Toolkit',
status: AgentStatusValue.COMPLETED,
},
],
{ now: 250, minDisplayMs, schedule, cancel }
);
expect(names).toEqual(['Browser Toolkit', 'Search Toolkit']);
// t=400: screenshot starts
names = reconcileToolkitState(
state,
[
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
{
id: 's1',
name: 'Search Toolkit',
status: AgentStatusValue.COMPLETED,
},
{
id: 'p1',
name: 'Screenshot Toolkit',
status: AgentStatusValue.RUNNING,
},
],
{ now: 400, minDisplayMs, schedule, cancel }
);
expect(names).toEqual([
'Browser Toolkit',
'Search Toolkit',
'Screenshot Toolkit',
]);
// t=1700: search's min-display elapsed (firstSeen=200 + 1500) → evicted
runExpired(state, scheduled, 1700);
names = reconcileToolkitState(
state,
[
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
{
id: 's1',
name: 'Search Toolkit',
status: AgentStatusValue.COMPLETED,
},
{
id: 'p1',
name: 'Screenshot Toolkit',
status: AgentStatusValue.RUNNING,
},
],
{ now: 1700, minDisplayMs, schedule, cancel }
);
expect(names).toEqual(['Browser Toolkit', 'Screenshot Toolkit']);
});
});

View file

@ -27,6 +27,7 @@ import {
isChatTimelineNearBottom,
prepareEventNativeTimelineWindow,
} from '@/components/ChatBox/EventNativeProjectTimeline';
import { usePageTabStore } from '@/store/pageTabStore';
const mocks = vi.hoisted(() => ({
projection: null as ChatProjectionState | null,
@ -39,11 +40,12 @@ const mocks = vi.hoisted(() => ({
} as ProjectEventStoreHydrationState,
}));
vi.mock('@/hooks/useProjectEventView', () => ({
useProjectChatProjection: () => mocks.projection,
}));
vi.mock('@/hooks/useProjectEventStoreHydration', () => ({
useProjectEventStoreHydration: () => mocks.hydration,
vi.mock('@/hooks/useProjectEventRuntime', () => ({
useProjectEventRuntime: () => ({
projectId: 'project-1',
hydration: mocks.hydration,
snapshot: mocks.projection ? { chat: mocks.projection } : null,
}),
}));
vi.mock('framer-motion', async (importOriginal) => {
const actual = await importOriginal<typeof import('framer-motion')>();
@ -167,6 +169,7 @@ describe('EventNativeProjectTimeline', () => {
eventsTruncated: false,
retry: mocks.retry,
};
usePageTabStore.getState().setScrollToTurnRequest(null);
if (!globalThis.ResizeObserver) {
globalThis.ResizeObserver = class {
observe() {}
@ -471,4 +474,41 @@ describe('EventNativeProjectTimeline', () => {
expect(scrollContainer.scrollTo).not.toHaveBeenCalled();
});
it('consumes a missed scroll-to-run request only once', () => {
const scrollContainer = createScrollContainer();
const scrollContainerRef = { current: scrollContainer };
mocks.projection = projection([messageNode(0)]);
usePageTabStore.getState().setScrollToTurnRequest({
projectId: 'project-1',
taskId: 'run-not-mounted',
});
const { rerender } = render(
<EventNativeProjectTimeline
projectId="project-1"
scrollContainerRef={scrollContainerRef}
scrollBottomInsetPx={128}
/>
);
expect(usePageTabStore.getState().scrollToTurnRequest).toBeNull();
vi.mocked(scrollContainer.scrollTo).mockClear();
mocks.projection = projection([
messageNode(0),
{ ...messageNode(1), runId: 'run-not-mounted' },
]);
rerender(
<EventNativeProjectTimeline
projectId="project-1"
scrollContainerRef={scrollContainerRef}
scrollBottomInsetPx={128}
/>
);
expect(scrollContainer.scrollTo).not.toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'smooth' })
);
});
});

View file

@ -0,0 +1,348 @@
// ========= 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 { SessionActivityPanel } from '@/components/Session/SidePanel/components/ActivityPanel';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
activeProjectId: 'project-1' as string,
chatStore: {} as any,
hydration: {} as any,
projectId: 'project-1' as string | null,
projectFiles: [] as any[],
overviews: {} as Record<string, any>,
selectFile: vi.fn(),
setScrollToTurnRequest: vi.fn(),
requestTaskBoxFocus: vi.fn(),
openFilePreview: vi.fn(),
openBrowserPreview: vi.fn(),
}));
vi.mock('@/api/connectors', () => ({
fetchConnectedProviders: vi.fn().mockResolvedValue([]),
}));
vi.mock('@/client/platform', () => ({ isWeb: () => false }));
vi.mock('@/hooks/useChatStoreAdapter', () => ({
default: () => ({ chatStore: mocks.chatStore }),
}));
vi.mock('@/hooks/useProjectEventRuntime', () => ({
useProjectEventRuntime: () => ({
projectId: mocks.projectId,
snapshot: null,
hydration: mocks.hydration,
}),
}));
vi.mock('@/hooks/useProjectSessionOverview', () => ({
useProjectSessionOverview: (projectId: string | null) =>
(projectId && mocks.overviews[projectId]) || {
currentRun: null,
historicalRuns: [],
runs: [],
},
}));
vi.mock('@/host', () => ({
useHost: () => ({ electronAPI: { selectFile: mocks.selectFile } }),
}));
vi.mock('@/store/projectRuntimeStore', () => ({
useProjectRuntimeStore: () => ({
activeProjectId: mocks.activeProjectId,
}),
}));
vi.mock('@/store/skillsStore', () => ({
useSkillsStore: (selector: (state: { skills: never[] }) => unknown) =>
selector({ skills: [] }),
}));
vi.mock('@/store/pageTabStore', () => ({
usePageTabStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
requestTaskBoxFocus: mocks.requestTaskBoxFocus,
setScrollToTurnRequest: mocks.setScrollToTurnRequest,
openFilePreview: mocks.openFilePreview,
openBrowserPreview: mocks.openBrowserPreview,
}),
}));
vi.mock(
'@/components/Session/SidePanel/sections/useProjectOutputFiles',
() => ({ useProjectOutputFiles: () => mocks.projectFiles })
);
vi.mock('@/components/ui/tooltip', () => ({
TooltipSimple: ({ children }: { children: ReactNode }) => children,
}));
vi.mock('@/components/Session/SidePanel/components/AccordionBox', () => ({
SidePanelAccordionBox: ({
title,
headerAction,
children,
}: {
title: string;
headerAction?: ReactNode;
children: ReactNode | ((state: { open: boolean }) => ReactNode);
}) => (
<section>
<h2>{title}</h2>
{headerAction}
{typeof children === 'function' ? children({ open: true }) : children}
</section>
),
}));
vi.mock('@/components/Session/SidePanel/sections/primitives', () => ({
CountPill: ({ count }: { count: number }) => <span>{count}</span>,
EarlierItems: ({ children }: { children: ReactNode }) => children,
ProgressCircle: () => null,
SidePanelListRow: ({
children,
onClick,
trailing,
}: {
children: ReactNode;
onClick?: () => void;
trailing?: ReactNode;
}) =>
onClick ? (
<button onClick={onClick}>
{children}
{trailing}
</button>
) : (
<div>
{children}
{trailing}
</div>
),
}));
vi.mock(
'@/components/Session/SidePanel/sections/SessionSidePanelDialogs',
() => ({
AgentInformationDialog: ({ agent }: { agent: any }) =>
agent ? <div data-testid="agent-dialog">{agent.name}</div> : null,
ToolCallsDialog: ({ item }: { item: any }) =>
item ? <div data-testid="tool-dialog">{item.label}</div> : null,
})
);
function chatStore(taskId: string | null) {
return {
activeTaskId: taskId,
tasks: taskId ? { [taskId]: { attaches: [] } } : {},
setAttaches: vi.fn(),
};
}
function overview(runId: string, withAgent = false) {
const run = {
runId,
taskId: runId,
status: 'running',
createdAt: 1_000,
updatedAt: 1_000,
isCurrent: true,
nodes: withAgent
? [
{
id: 'agent-1',
eventId: 'agent-1',
eventType: 'agent.started',
projectId: 'project-1',
runId,
runSequence: 1,
cloudCursor: 1,
createdAt: new Date(1_000).toISOString(),
legacyStep: null,
kind: 'activity',
activityType: 'agent',
status: 'running',
title: 'Agent One',
agentId: 'agent-1',
agentName: 'Agent One',
},
]
: [],
};
return { currentRun: run, historicalRuns: [], runs: [run] };
}
describe('SessionActivityPanel project scope', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.projectId = 'project-1';
mocks.activeProjectId = 'project-1';
mocks.chatStore = chatStore(null);
mocks.projectFiles = [];
mocks.hydration = {
status: 'ready',
errorCode: null,
eventsTruncated: false,
retry: vi.fn(),
};
mocks.overviews = {
'project-1': { currentRun: null, historicalRuns: [], runs: [] },
'project-2': { currentRun: null, historicalRuns: [], runs: [] },
};
});
it('keeps an empty Files section visible and fails closed without a composer target', async () => {
render(<SessionActivityPanel scope="latest" />);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 40));
});
expect(screen.getByRole('heading', { name: 'Files' })).toBeInTheDocument();
expect(screen.getByText('No output files yet.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Attach files/ })).toBeDisabled();
});
it('shows unsupported history as degraded and exposes manual retry', async () => {
mocks.hydration = {
status: 'error',
errorCode: 'unsupported',
eventsTruncated: false,
retry: vi.fn(),
};
render(<SessionActivityPanel scope="latest" />);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 40));
});
expect(screen.getByRole('alert')).toHaveTextContent(
'This backend does not support session history yet.'
);
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(mocks.hydration.retry).toHaveBeenCalledTimes(1);
});
it('keeps an artifact noninteractive until the workspace resolver matches it', async () => {
mocks.chatStore = chatStore('run-1');
const scopedOverview = overview('run-1');
scopedOverview.currentRun.nodes = [
{
id: 'artifact-1',
eventId: 'artifact-1',
eventType: 'artifact.created',
projectId: 'project-1',
runId: 'run-1',
runSequence: 1,
cloudCursor: 1,
createdAt: new Date(1_000).toISOString(),
legacyStep: null,
kind: 'artifact',
operation: 'created',
path: 'outputs/report.md',
relativePath: 'outputs/report.md',
name: 'report.md',
},
];
mocks.overviews['project-1'] = scopedOverview;
const { rerender } = render(<SessionActivityPanel scope="latest" />);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 40));
});
expect(screen.getByText('Preview unavailable')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /report\.md/ })
).not.toBeInTheDocument();
mocks.projectFiles = [
{
name: 'report.md',
type: 'md',
path: '/workspace/outputs/report.md',
relativePath: 'outputs/report.md',
},
];
rerender(<SessionActivityPanel scope="latest" />);
fireEvent.click(await screen.findByRole('button', { name: /report\.md/ }));
expect(mocks.openFilePreview).toHaveBeenCalledWith(
expect.objectContaining({ path: '/workspace/outputs/report.md' })
);
});
it('does not attach a delayed picker result after switching Projects', async () => {
let resolvePicker!: (value: unknown) => void;
mocks.chatStore = chatStore('run-1');
const oldStore = mocks.chatStore;
mocks.overviews['project-1'] = overview('run-1');
mocks.selectFile.mockReturnValue(
new Promise((resolve) => {
resolvePicker = resolve;
})
);
const { rerender } = render(<SessionActivityPanel scope="latest" />);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 40));
});
fireEvent.click(screen.getByRole('button', { name: /Attach files/ }));
expect(mocks.selectFile).toHaveBeenCalledTimes(1);
mocks.projectId = 'project-2';
mocks.activeProjectId = 'project-2';
mocks.chatStore = chatStore('run-2');
const newStore = mocks.chatStore;
mocks.overviews['project-2'] = overview('run-2');
rerender(<SessionActivityPanel scope="latest" />);
await act(async () => {
resolvePicker({
success: true,
files: [{ fileName: 'late.txt', filePath: '/late.txt' }],
});
await Promise.resolve();
});
expect(oldStore.setAttaches).not.toHaveBeenCalled();
expect(newStore.setAttaches).not.toHaveBeenCalled();
});
it('closes an Agent dialog when the Project incarnation changes', async () => {
mocks.chatStore = chatStore('run-1');
mocks.overviews['project-1'] = overview('run-1', true);
const { rerender } = render(<SessionActivityPanel scope="latest" />);
fireEvent.click(await screen.findByRole('button', { name: 'Agent One' }));
expect(screen.getByTestId('agent-dialog')).toHaveTextContent('Agent One');
mocks.projectId = 'project-2';
mocks.activeProjectId = 'project-2';
mocks.chatStore = chatStore(null);
rerender(<SessionActivityPanel scope="latest" />);
await waitFor(() =>
expect(screen.queryByTestId('agent-dialog')).not.toBeInTheDocument()
);
});
});

View file

@ -15,7 +15,7 @@
import {
collectSidePanelOutputFiles,
getSidePanelOutputFilesRevision,
} from '@/components/Session/SidePanelSections/collectSidePanelOutputFiles';
} from '@/components/Session/SidePanel/sections/collectSidePanelOutputFiles';
import { describe, expect, it } from 'vitest';
const reportFile = (): FileInfo => ({

View file

@ -12,7 +12,7 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { useProjectOutputFiles } from '@/components/Session/SidePanelSections/useProjectOutputFiles';
import { useProjectOutputFiles } from '@/components/Session/SidePanel/sections/useProjectOutputFiles';
import { HostProvider } from '@/host';
import { useAuthStore } from '@/store/authStore';
import { ChatTaskStatus } from '@/types/constants';
@ -186,5 +186,34 @@ describe('useProjectOutputFiles', () => {
isRemote: true,
})
);
expect(result.current[0].relativePath).toBeUndefined();
});
it('preserves only explicit remote artifact identities', async () => {
invokeMock.mockRejectedValue(new Error('IPC unavailable'));
fetchGetMock.mockResolvedValue([
{
artifact_id: 'artifact-1',
filename: 'remote-report.md',
relative_path: 'reports/remote-report.md',
url: '/files/remote-report.md',
},
]);
const { result } = renderHook(
() =>
useProjectOutputFiles(
'project_one',
{ status: ChatTaskStatus.FINISHED, taskAssigning: [] },
'task_one'
),
{ wrapper }
);
await waitFor(() => expect(result.current).toHaveLength(1));
expect(result.current[0]).toMatchObject({
artifactId: 'artifact-1',
relativePath: 'reports/remote-report.md',
});
});
});

View file

@ -0,0 +1,895 @@
// ========= 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 {
buildProjectSessionPanelData,
collectSessionToolCalls,
extractHttpUrls,
mergeProjectFiles,
} from '@/components/Session/SidePanel/sections/buildProjectSessionPanelData';
import type { ProjectSessionRun } from '@/hooks/useProjectSessionOverview';
import { normalizeLegacyChatStep } from '@/lib/projector';
import {
adaptChatProjectionEvent,
type ChatActivityNode,
type ChatArtifactNode,
type ChatPlanNode,
type ChatProjectionNode,
} from '@/lib/projector/chat';
import { describe, expect, it } from 'vitest';
function baseNode(
runId: string,
eventId: string,
runSequence: number
): Omit<ChatProjectionNode, 'kind'> {
return {
id: eventId,
eventId,
projectId: 'project-1',
runId,
createdAt: new Date(runSequence * 1_000).toISOString(),
runSequence,
cloudCursor: null,
eventType: 'test.event',
legacyStep: null,
} as Omit<ChatProjectionNode, 'kind'>;
}
function toolNode(
runId: string,
eventId: string,
sequence: number,
status: ChatActivityNode['status'],
detail: string,
toolCallId?: string
): ChatActivityNode {
return {
...baseNode(runId, eventId, sequence),
kind: 'activity',
activityType: 'tool',
status,
title: 'Notion search',
detail,
agentId: 'agent-1',
agentName: 'Research Agent',
toolkitName: 'MCPToolkit',
methodName: 'notion_search',
toolCallId,
};
}
function agentNode(
runId: string,
eventId: string,
sequence: number,
agentId: string,
agentName: string,
eventType = 'legacy.create_agent',
title = agentName
): ChatActivityNode {
return {
...baseNode(runId, eventId, sequence),
eventType,
legacyStep: eventType.startsWith('legacy.')
? eventType.replace('legacy.', '')
: null,
kind: 'activity',
activityType: 'agent',
status: 'running',
title,
agentId,
agentName,
};
}
function todoActivity(
eventId: string,
sequence: number,
status: ChatActivityNode['status'],
eventType: string,
legacyStep: string | null = null
): ChatActivityNode {
return {
...baseNode('run-current', eventId, sequence),
eventType,
legacyStep,
kind: 'activity',
activityType: 'tool',
status,
title: 'todo_write',
toolName: 'todo_write',
toolkitName: 'TodoToolkit',
methodName: 'todo_write',
};
}
function todoPlan(
eventId: string,
sequence: number,
title: string
): ChatPlanNode {
return {
...baseNode('run-current', eventId, sequence),
eventType: 'legacy.todo_state',
legacyStep: 'todo_state',
kind: 'plan',
tasks: [{ id: 'todo_1', title, status: 'running' }],
};
}
function skillToolkitNode(
eventId: string,
sequence: number,
status: ChatActivityNode['status'],
methodName: 'list_skills' | 'load_skill',
detail: string
): ChatActivityNode {
const active = status === 'running';
return {
...toolNode('run-current', eventId, sequence, status, detail),
eventType: active ? 'legacy.activate_toolkit' : 'legacy.deactivate_toolkit',
legacyStep: active ? 'activate_toolkit' : 'deactivate_toolkit',
title: detail,
toolkitName: 'SkillToolkit',
methodName,
toolName: undefined,
toolCallId: undefined,
};
}
function makeRun(
runId: string,
isCurrent: boolean,
nodes: ChatProjectionNode[]
): ProjectSessionRun {
return {
runId,
taskId: runId,
status: isCurrent ? 'running' : 'completed',
nodes,
createdAt: isCurrent ? 2_000 : 1_000,
updatedAt: isCurrent ? 20_000 : 10_000,
isCurrent,
};
}
describe('buildProjectSessionPanelData', () => {
it('deduplicates logical agents across Runs and skips anonymous tool frames', () => {
const oldRun = makeRun('run-old', false, [
agentNode(
'run-old',
'question-confirm',
1,
'confirm-agent-id',
'question_confirm_agent'
),
agentNode(
'run-old',
'single-agent-old',
2,
'single-agent-instance-a',
'single_agent'
),
]);
const currentRun = makeRun('run-current', true, [
agentNode(
'run-current',
'single-agent-current',
1,
'single-agent-instance-b',
'single_agent'
),
{
...toolNode(
'run-current',
'named-tool-frame',
2,
'running',
'Registering agent'
),
agentId: undefined,
agentName: 'single_agent',
},
{
...toolNode(
'run-current',
'anonymous-canonical-tool',
3,
'running',
'Tool without agent identity'
),
agentId: undefined,
agentName: undefined,
},
]);
const data = buildProjectSessionPanelData([oldRun, currentRun], []);
expect(data.agents).toMatchObject([
{
id: 'agent:singleagent',
name: 'single_agent',
historical: false,
subagent: false,
},
]);
});
it('classifies remote delegated agents separately from primary agents', () => {
const run = makeRun('run-current', true, [
agentNode(
'run-current',
'primary-agent',
1,
'primary-instance',
'single_agent'
),
agentNode(
'run-current',
'remote-agent',
2,
'remote-instance',
'research_helper',
'agent.remote_started',
'Remote subagent research_helper'
),
]);
expect(buildProjectSessionPanelData([run], []).agents).toMatchObject([
{ name: 'single_agent', type: 'agent', subagent: false },
{ name: 'research_helper', type: 'subagent', subagent: true },
]);
});
it('collects terminal, browser, and remote execution environments', () => {
const terminal: ChatActivityNode = {
...baseNode('run-current', 'terminal', 1),
kind: 'activity',
activityType: 'terminal',
status: 'running',
title: 'Run shell command',
};
const browser = {
...toolNode('run-current', 'browser', 2, 'running', 'Open page'),
toolkitName: 'BrowserToolkit',
methodName: 'browser_navigate',
};
const remote = agentNode(
'run-current',
'remote',
3,
'remote-agent',
'research_helper',
'agent.remote_started',
'Remote subagent research_helper'
);
expect(
buildProjectSessionPanelData(
[makeRun('run-current', true, [terminal, browser, remote])],
[]
).environments.map((item) => item.label)
).toEqual(['Browser', 'Remote environment', 'Terminal']);
});
it('pairs semantic tool lifecycle events by durable call id', () => {
const run = makeRun('run-1', true, [
toolNode('run-1', 'tool-start', 1, 'running', 'Searching', 'call-1'),
toolNode('run-1', 'tool-end', 2, 'completed', '3 results', 'call-1'),
]);
expect(collectSessionToolCalls([run])).toMatchObject([
{
id: 'call-1',
toolkitName: 'MCPToolkit',
method: 'notion_search',
input: 'Searching',
output: '3 results',
status: 'done',
taskId: 'run-1',
},
]);
});
it('scopes durable call ids to their owning Run', () => {
const current = makeRun('run-current', true, [
toolNode(
'run-current',
'current-start',
1,
'running',
'current input',
'call-1'
),
toolNode(
'run-current',
'current-end',
2,
'completed',
'current output',
'call-1'
),
]);
const historical = makeRun('run-old', false, [
toolNode('run-old', 'old-start', 1, 'running', 'old input', 'call-1'),
toolNode('run-old', 'old-end', 2, 'completed', 'old output', 'call-1'),
]);
expect(collectSessionToolCalls([current, historical])).toMatchObject([
{ taskId: 'run-old', input: 'old input', output: 'old output' },
{
taskId: 'run-current',
input: 'current input',
output: 'current output',
},
]);
});
it('uses FIFO fallback for older tool frames without correlation ids', () => {
const run = makeRun('run-1', true, [
toolNode('run-1', 'start-1', 1, 'running', 'first'),
toolNode('run-1', 'start-2', 2, 'running', 'second'),
toolNode('run-1', 'end-1', 3, 'completed', 'first result'),
toolNode('run-1', 'end-2', 4, 'completed', 'second result'),
]);
expect(collectSessionToolCalls([run])).toMatchObject([
{ input: 'first', output: 'first result', status: 'done' },
{ input: 'second', output: 'second result', status: 'done' },
]);
});
it('projects plans, artifacts and safe URL resources across Runs', () => {
const plan: ChatPlanNode = {
...baseNode('run-current', 'plan', 3),
kind: 'plan',
tasks: [{ id: 'task-1', title: 'Build report', status: 'running' }],
};
const artifact: ChatArtifactNode = {
...baseNode('run-current', 'artifact', 4),
kind: 'artifact',
operation: 'created',
path: 'outputs/report.md',
relativePath: 'outputs/report.md',
name: 'report.md',
};
const current = makeRun('run-current', true, [plan, artifact]);
const historical = makeRun('run-old', false, [
{
...baseNode('run-old', 'message', 1),
kind: 'message',
role: 'assistant',
content: 'Read https://old.example.com/research.',
status: 'complete',
},
]);
const data = buildProjectSessionPanelData([current, historical], []);
expect(data.progress).toMatchObject([
{
taskId: 'run-current',
historical: false,
task: { id: 'task-1', content: 'Build report', status: 'running' },
},
]);
expect(data.files).toMatchObject([
{
id: 'outputs/report.md',
previewable: false,
taskId: 'run-current',
historical: false,
file: { name: 'report.md', artifactChange: 'generated' },
},
]);
expect(data.resources).toMatchObject([
{ taskId: 'run-old', historical: true },
]);
});
it('maps terminal task activity statuses into side-panel task statuses', () => {
const nodes = (
[
['timed-out', 'timed_out'],
['unknown-outcome', 'outcome_unknown'],
['cancelled', 'cancelled'],
] as const
).map(([taskId, status], index): ChatActivityNode => ({
...baseNode('run-current', `task-${taskId}`, index + 1),
kind: 'activity',
activityType: 'task',
status,
title: taskId,
taskId,
}));
expect(
buildProjectSessionPanelData(
[makeRun('run-current', true, nodes)],
[]
).progress.map((item) => item.task.status)
).toEqual(['failed', 'blocked', 'skipped']);
});
it('uses durable artifact identity and only enriches matching project files', () => {
const created: ChatArtifactNode = {
...baseNode('run-current', 'artifact-created', 1),
kind: 'artifact',
operation: 'created',
artifactId: 'artifact-1',
relativePath: 'outputs/report.md',
path: '/private/workspace/outputs/report.md',
name: 'report.md',
};
const updated: ChatArtifactNode = {
...baseNode('run-current', 'artifact-updated', 2),
kind: 'artifact',
operation: 'updated',
artifactId: 'artifact-1',
relativePath: './outputs\\report.md',
path: '/different-machine/workspace/outputs/report.md',
name: 'report.md',
};
const data = buildProjectSessionPanelData(
[makeRun('run-current', true, [created, updated])],
[]
);
const merged = mergeProjectFiles(data.files, [
{
name: 'report.md',
type: 'md',
path: 'https://files.example.test/outputs/report.md',
relativePath: 'outputs/report.md',
artifactId: 'artifact-1',
isRemote: true,
},
{
name: 'unrelated.txt',
type: 'txt',
path: '/workspace/unrelated.txt',
relativePath: 'unrelated.txt',
},
]);
expect(merged).toHaveLength(1);
expect(merged[0]).toMatchObject({
id: 'artifact-1',
previewable: true,
taskId: 'run-current',
file: {
artifactId: 'artifact-1',
relativePath: 'outputs/report.md',
path: 'https://files.example.test/outputs/report.md',
artifactChange: 'changed',
},
});
});
it('does not enrich by basename or an unsafe relative path', () => {
const item = buildProjectSessionPanelData(
[
makeRun('run-current', true, [
{
...baseNode('run-current', 'artifact', 1),
kind: 'artifact',
operation: 'created',
path: 'reports/quarterly/report.md',
relativePath: 'reports/quarterly/report.md',
name: 'report.md',
},
]),
],
[]
).files[0];
const merged = mergeProjectFiles(
[item],
[
{
name: 'report.md',
type: 'md',
path: '/workspace/archive/report.md',
relativePath: 'archive/report.md',
},
{
name: 'report.md',
type: 'md',
path: '/outside/report.md',
relativePath: '../report.md',
},
]
);
expect(merged).toEqual([item]);
});
it('does not turn a display-only basename into durable file identity', () => {
const decision = adaptChatProjectionEvent(
normalizeLegacyChatStep(
{
step: 'write_file',
data: {
file_path: '/Users/me/eigent/proj/reports/summary.md',
},
},
{
projectId: 'project-1',
runId: 'run-current',
sequence: 1,
sourceId: 'legacy-stream',
createdAt: 1_000,
}
)
);
expect(decision).toMatchObject({
kind: 'display',
node: {
kind: 'artifact',
path: 'summary.md',
relativePath: undefined,
},
});
if (decision.kind !== 'display') throw new Error('Expected artifact node');
const data = buildProjectSessionPanelData(
[makeRun('run-current', true, [decision.node])],
[]
);
expect(data.files).toEqual([]);
expect(
mergeProjectFiles(data.files, [
{
name: 'summary.md',
type: 'md',
path: '/workspace/summary.md',
relativePath: 'summary.md',
},
])
).toEqual([]);
});
it('shows a trusted realtime write and converges on its terminal artifact', () => {
const liveDecision = adaptChatProjectionEvent(
normalizeLegacyChatStep(
{
step: 'write_file',
data: {
file_path: '/private/run/reports/summary.md',
relative_path: 'reports/summary.md',
},
},
{
projectId: 'project-1',
runId: 'run-current',
sequence: 1,
sourceId: 'legacy-stream',
createdAt: 1_000,
}
)
);
expect(liveDecision).toMatchObject({
kind: 'display',
node: {
kind: 'artifact',
path: 'reports/summary.md',
relativePath: 'reports/summary.md',
},
});
if (liveDecision.kind !== 'display') {
throw new Error('Expected artifact node');
}
const liveData = buildProjectSessionPanelData(
[makeRun('run-current', true, [liveDecision.node])],
[]
);
expect(liveData.files).toMatchObject([
{
id: 'reports/summary.md',
previewable: false,
taskId: 'run-current',
file: { relativePath: 'reports/summary.md' },
},
]);
const terminalArtifact: ChatArtifactNode = {
...baseNode('run-current', 'artifact-terminal', 2),
eventType: 'artifact.created',
kind: 'artifact',
operation: 'created',
artifactId: 'artifact-summary',
path: 'reports/summary.md',
relativePath: 'reports/summary.md',
name: 'summary.md',
};
const finalized = buildProjectSessionPanelData(
[makeRun('run-current', true, [liveDecision.node, terminalArtifact])],
[]
);
expect(finalized.files).toHaveLength(1);
expect(finalized.files[0]).toMatchObject({
id: 'artifact-summary',
file: {
artifactId: 'artifact-summary',
relativePath: 'reports/summary.md',
},
});
expect(
mergeProjectFiles(finalized.files, [
{
name: 'summary.md',
type: 'md',
path: '/workspace/reports/summary.md',
relativePath: 'reports/summary.md',
},
])
).toMatchObject([
{
previewable: true,
file: { path: '/workspace/reports/summary.md' },
},
]);
});
it('quarantines workspace-loaded todo state without a todo_write call', () => {
const staleStartupPlan: ChatPlanNode = {
...baseNode('run-current', 'stale-todos', 2),
eventType: 'legacy.todo_state',
legacyStep: 'todo_state',
kind: 'plan',
tasks: [
{
id: 'todo_1',
title: 'Task from a different Project',
status: 'completed',
},
],
};
expect(
buildProjectSessionPanelData(
[makeRun('run-current', true, [staleStartupPlan])],
[]
).progress
).toEqual([]);
});
it('accepts a real todo lifecycle without authorizing a later stale state', () => {
const typedPlan: ChatPlanNode = {
...baseNode('run-current', 'typed-plan', 1),
eventType: 'plan.created',
kind: 'plan',
tasks: [
{
id: 'plan-task',
title: 'Typed plan task',
status: 'running',
},
],
};
const run = makeRun('run-current', true, [
typedPlan,
todoActivity('todo-prepared-1', 2, 'running', 'tool.prepared'),
todoActivity('todo-completed-1', 3, 'completed', 'tool.completed'),
todoActivity(
'todo-activate-1',
4,
'running',
'legacy.activate_toolkit',
'activate_toolkit'
),
todoPlan('todo-state-1', 5, 'First current task'),
todoActivity(
'todo-deactivate-1',
6,
'completed',
'legacy.deactivate_toolkit',
'deactivate_toolkit'
),
todoPlan('unpaired-state', 7, 'Must stay quarantined'),
todoActivity('todo-prepared-2', 8, 'running', 'tool.prepared'),
todoPlan('todo-state-2', 9, 'Replacement current task'),
]);
expect(buildProjectSessionPanelData([run], []).progress).toMatchObject([
{ task: { content: 'Typed plan task' } },
{ task: { content: 'Replacement current task' } },
]);
});
it('accepts legacy-only activate_toolkit followed by todo_state', () => {
const run = makeRun('run-current', true, [
todoActivity(
'todo-activate',
1,
'running',
'legacy.activate_toolkit',
'activate_toolkit'
),
todoPlan('todo-state', 2, 'Legacy current task'),
]);
expect(buildProjectSessionPanelData([run], []).progress).toMatchObject([
{ task: { content: 'Legacy current task' } },
]);
});
it('preserves legacy tool lifecycle status through the shipping bridge', () => {
const rawSteps = [
{
step: 'activate_toolkit',
data: {
toolkit_name: 'TodoToolkit',
method_name: 'todo_write',
tool_name: 'todo_write',
message: '{"todos":["first","second"]}',
},
},
{
step: 'todo_state',
data: {
todos: [
{ id: 'todo-1', content: 'First task', status: 'in_progress' },
{ id: 'todo-2', content: 'Second task', status: 'pending' },
],
},
},
{
step: 'deactivate_toolkit',
data: {
toolkit_name: 'TodoToolkit',
method_name: 'todo_write',
tool_name: 'todo_write',
message: 'Todos updated',
},
},
];
const nodes = rawSteps.flatMap((raw, index) => {
const decision = adaptChatProjectionEvent(
normalizeLegacyChatStep(raw, {
projectId: 'project-1',
runId: 'run-current',
sequence: index + 1,
sourceId: 'test-stream',
createdAt: (index + 1) * 1_000,
})
);
return decision.kind === 'display' ? [decision.node] : [];
});
expect(
nodes.map((node) => ('status' in node ? node.status : undefined))
).toEqual(['running', undefined, 'completed']);
const data = buildProjectSessionPanelData(
[makeRun('run-current', true, nodes)],
[]
);
expect(data.progress.map((item) => item.task.content)).toEqual([
'First task',
'Second task',
]);
expect(data.toolCalls).toMatchObject([
{
toolkitName: 'TodoToolkit',
method: 'todo_write',
input: '{"todos":["first","second"]}',
output: 'Todos updated',
status: 'done',
},
]);
});
it('uses connector identity without reading a raw event payload', () => {
const current = makeRun('run-current', true, [
toolNode(
'run-current',
'tool-start',
1,
'running',
'roadmap',
'notion-call'
),
toolNode(
'run-current',
'tool-end',
2,
'completed',
'done',
'notion-call'
),
]);
const data = buildProjectSessionPanelData(
[current],
[],
[
{
service: 'notion',
displayName: 'Notion',
iconUrl: 'https://cdn.example.com/notion.svg',
actions: [{ id: 'notion_search', name: 'Search Notion' }],
},
]
);
expect(data.contextItems).toMatchObject([
{
id: 'connector:notion',
label: 'Notion',
iconUrl: 'https://cdn.example.com/notion.svg',
historical: false,
calls: [{ id: 'notion-call' }],
},
]);
expect(JSON.stringify(data)).not.toContain('__legacy_data');
});
it('shows only explicitly loaded skills and ignores skill discovery', () => {
const availableSkills =
"[{'name': 'skill-security-auditor', 'description': " +
"'Security auditing for code, configs, and infrastructure.'}]";
const run = makeRun('run-current', true, [
skillToolkitNode(
'list-start',
1,
'running',
'list_skills',
JSON.stringify({ message_title: 'List Skills' })
),
skillToolkitNode(
'list-end',
2,
'completed',
'list_skills',
availableSkills
),
skillToolkitNode(
'load-start',
3,
'running',
'load_skill',
JSON.stringify({ name: 'pdf', message_title: 'Load Skill' })
),
skillToolkitNode(
'load-end',
4,
'completed',
'load_skill',
'## Skill: pdf\n\n# PDF Processing Guide'
),
]);
expect(buildProjectSessionPanelData([run], []).contextItems).toMatchObject([
{
id: 'skill:pdf',
label: 'pdf',
category: 'skill',
historical: false,
},
]);
});
it('extracts unique searched URLs without trailing punctuation', () => {
expect(
extractHttpUrls(
'Read https://example.com/a, then https://example.com/a and https://docs.example.com/page).'
)
).toEqual(['https://example.com/a', 'https://docs.example.com/page']);
});
});

View file

@ -0,0 +1,66 @@
// ========= 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 {
resolveContextConnector,
type ContextConnector,
} from '@/components/Session/SidePanel/sections/buildContextItems';
import { describe, expect, it } from 'vitest';
const notion: ContextConnector = {
service: 'notion',
displayName: 'Notion',
};
const slack: ContextConnector = {
service: 'slack',
displayName: 'Slack',
};
describe('resolveContextConnector', () => {
it('matches a provider-prefixed toolkit by name', () => {
expect(
resolveContextConnector('NotionMCPToolkit', 'search', '', [notion, slack])
).toBe(notion);
});
it('matches a provider by method when the toolkit is generic', () => {
expect(
resolveContextConnector('MCPToolkit', 'slack_send_message', '', [
notion,
slack,
])
).toBe(slack);
});
it('assumes the only connector for a connector gateway call', () => {
expect(
resolveContextConnector('ConnectorGateway', 'call', '', [slack])
).toBe(slack);
expect(
resolveContextConnector('connector_gateway', 'call', '', [slack])
).toBe(slack);
});
it('stays generic for an unidentified MCP call even with one connector', () => {
expect(
resolveContextConnector('MCPToolkit', 'call', '', [slack])
).toBeNull();
});
it('stays generic when a gateway call cannot pick between connectors', () => {
expect(
resolveContextConnector('ConnectorGateway', 'call', '', [notion, slack])
).toBeNull();
});
});

View file

@ -0,0 +1,67 @@
// ========= 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 { SidePanelAccordionBox } from '@/components/Session/SidePanel/components/AccordionBox';
import { SessionPanelCollapse } from '@/components/Session/SidePanel/sections/primitives';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
describe('SessionPanelCollapse', () => {
it('removes collapsed content from keyboard navigation', () => {
const { container, rerender } = render(
<SessionPanelCollapse open={false}>
<button type="button">Hidden action</button>
</SessionPanelCollapse>
);
expect(container.firstElementChild).toHaveAttribute('inert');
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
rerender(
<SessionPanelCollapse open>
<button type="button">Visible action</button>
</SessionPanelCollapse>
);
expect(container.firstElementChild).not.toHaveAttribute('inert');
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'false');
});
});
describe('SidePanelAccordionBox', () => {
it('keeps top-level section headers sticky within their section', () => {
const { container } = render(
<SidePanelAccordionBox title="Resources">
<div>Resource rows</div>
</SidePanelAccordionBox>
);
const trigger = screen.getByRole('button', { name: 'Resources' });
const header = trigger.parentElement?.parentElement;
expect(header).toHaveClass('sticky', 'top-0');
expect(container.firstElementChild).toHaveClass('overflow-visible');
});
it('does not pin nested subcategory headers', () => {
render(
<SidePanelAccordionBox title="Skills" rowVariant="subcategory">
<div>Skill rows</div>
</SidePanelAccordionBox>
);
const trigger = screen.getByRole('button', { name: 'Skills' });
const header = trigger.parentElement?.parentElement;
expect(header).not.toHaveClass('sticky');
});
});

View file

@ -0,0 +1,113 @@
// ========= 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 {
arrangeSessionPanelItems,
selectSessionPanelRuns,
} from '@/components/Session/SidePanel/sections/sessionPanelScope';
import { describe, expect, it } from 'vitest';
const items = [
{
id: 'current-recently-updated',
historical: false,
createdAt: 400,
updatedAt: 500,
},
{
id: 'newest-history',
historical: true,
createdAt: 300,
updatedAt: 100,
},
{
id: 'current-less-recently-updated',
historical: false,
createdAt: 400,
updatedAt: 200,
},
{
id: 'oldest-history',
historical: true,
createdAt: 100,
updatedAt: 400,
},
];
describe('arrangeSessionPanelItems', () => {
it('shows only the current run in latest mode', () => {
const result = arrangeSessionPanelItems(items, 'latest');
expect(result.primary.map((item) => item.id)).toEqual([
'current-recently-updated',
'current-less-recently-updated',
]);
expect(result.earlier).toEqual([]);
});
it('groups previous-run items under earlier in all mode', () => {
const result = arrangeSessionPanelItems(items, 'all');
expect(result.primary.map((item) => item.id)).toEqual([
'current-recently-updated',
'current-less-recently-updated',
]);
expect(result.earlier.map((item) => item.id)).toEqual([
'newest-history',
'oldest-history',
]);
});
it('keeps every item reachable in all mode', () => {
const result = arrangeSessionPanelItems(items, 'all');
expect([...result.primary, ...result.earlier]).toHaveLength(items.length);
});
it('can preserve a deliberately chronological source order', () => {
const chronological = [...items].reverse();
const result = arrangeSessionPanelItems(chronological, 'all', 'source');
expect(result.primary.map((item) => item.id)).toEqual([
'current-less-recently-updated',
'current-recently-updated',
]);
expect(result.earlier.map((item) => item.id)).toEqual([
'oldest-history',
'newest-history',
]);
});
});
describe('selectSessionPanelRuns', () => {
const runs = [
{ id: 'old', isCurrent: false, createdAt: 100, updatedAt: 500 },
{ id: 'current', isCurrent: true, createdAt: 400, updatedAt: 200 },
{ id: 'previous', isCurrent: false, createdAt: 300, updatedAt: 100 },
];
it('returns only the current run in latest mode', () => {
expect(selectSessionPanelRuns(runs, 'latest').map((run) => run.id)).toEqual(
['current']
);
});
it('returns every run by creation time in all mode', () => {
expect(selectSessionPanelRuns(runs, 'all').map((run) => run.id)).toEqual([
'current',
'previous',
'old',
]);
});
});

View file

@ -0,0 +1,134 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import {
ProjectEventRuntimeProvider,
useProjectEventRuntime,
} from '@/hooks/useProjectEventRuntime';
import { resetProjectEventStoresForTests } from '@/store/projectEventStore';
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
hydration: vi.fn(() => ({
status: 'ready' as const,
errorCode: null,
eventsTruncated: false,
})),
streams: vi.fn(),
}));
vi.mock('@/hooks/useProjectEventStoreHydration', () => ({
useProjectEventStoreHydration: mocks.hydration,
}));
vi.mock('@/hooks/useProjectRunEventStreams', () => ({
useProjectRunEventStreams: mocks.streams,
}));
function Consumer() {
const runtime = useProjectEventRuntime();
return (
<div>
{runtime.projectId}:{runtime.snapshot?.revision ?? 'none'}:
{runtime.hydration.status}
</div>
);
}
describe('ProjectEventRuntimeProvider', () => {
afterEach(() => {
resetProjectEventStoresForTests();
vi.clearAllMocks();
});
it('owns one hydration and live-stream subscription for its Session', () => {
render(
<ProjectEventRuntimeProvider projectId="project-1">
<Consumer />
</ProjectEventRuntimeProvider>
);
expect(screen.getByText('project-1:0:ready')).toBeInTheDocument();
expect(mocks.hydration).toHaveBeenCalledTimes(1);
expect(mocks.hydration).toHaveBeenCalledWith({
projectId: 'project-1',
enabled: true,
});
expect(mocks.streams).toHaveBeenCalledTimes(1);
expect(mocks.streams).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'project-1',
enabled: true,
snapshot: expect.objectContaining({ revision: 0 }),
})
);
});
it('detaches the previous Project snapshot when the Session has no scope', () => {
const { rerender } = render(
<ProjectEventRuntimeProvider projectId="project-previous">
<Consumer />
</ProjectEventRuntimeProvider>
);
expect(screen.getByText('project-previous:0:ready')).toBeInTheDocument();
rerender(
<ProjectEventRuntimeProvider projectId={null}>
<Consumer />
</ProjectEventRuntimeProvider>
);
expect(screen.getByText(':none:ready')).toBeInTheDocument();
expect(mocks.hydration).toHaveBeenLastCalledWith({
projectId: null,
enabled: false,
});
expect(mocks.streams).toHaveBeenLastCalledWith(
expect.objectContaining({
projectId: null,
enabled: false,
snapshot: null,
})
);
});
it('rebinds hydration and streams when the active Project changes', () => {
const { rerender } = render(
<ProjectEventRuntimeProvider projectId="project-1">
<Consumer />
</ProjectEventRuntimeProvider>
);
rerender(
<ProjectEventRuntimeProvider projectId="project-2">
<Consumer />
</ProjectEventRuntimeProvider>
);
expect(screen.getByText('project-2:0:ready')).toBeInTheDocument();
expect(mocks.hydration).toHaveBeenLastCalledWith({
projectId: 'project-2',
enabled: true,
});
expect(mocks.streams).toHaveBeenLastCalledWith(
expect.objectContaining({
projectId: 'project-2',
enabled: true,
snapshot: expect.objectContaining({ revision: 0 }),
})
);
});
});

View file

@ -0,0 +1,124 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { useProjectEventStoreHydration } from '@/hooks/useProjectEventStoreHydration';
import { resetProjectEventStoresForTests } from '@/store/projectEventStore';
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
hydrate: vi.fn(),
}));
vi.mock('@/service/projectEventStoreHydration', async (importOriginal) => {
const original =
await importOriginal<
typeof import('@/service/projectEventStoreHydration')
>();
return {
...original,
hydrateProjectEventStore: mocks.hydrate,
};
});
const hydrated = {
projectId: 'project-1',
runCount: 0,
eventCount: 0,
pageCount: 1,
byteCount: 0,
eventsTruncated: false,
};
async function flushHydration(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('useProjectEventStoreHydration', () => {
let warn: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
warn.mockRestore();
vi.useRealTimers();
vi.clearAllMocks();
resetProjectEventStoresForTests();
});
it('blocks automatic retries when the replay API is unsupported', async () => {
vi.useFakeTimers();
mocks.hydrate.mockRejectedValue(
Object.assign(new Error('Not found'), {
status: 404,
})
);
const { result } = renderHook(() =>
useProjectEventStoreHydration({
projectId: 'project-1',
enabled: true,
})
);
await flushHydration();
expect(result.current).toMatchObject({
status: 'error',
errorCode: 'unsupported',
});
expect(mocks.hydrate).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(120_000);
});
expect(mocks.hydrate).toHaveBeenCalledTimes(1);
act(() => result.current.retry());
await flushHydration();
expect(mocks.hydrate).toHaveBeenCalledTimes(2);
});
it('keeps bounded backoff for transient request failures', async () => {
vi.useFakeTimers();
mocks.hydrate
.mockRejectedValueOnce(
Object.assign(new Error('Unavailable'), {
status: 503,
})
)
.mockResolvedValueOnce(hydrated);
const { result } = renderHook(() =>
useProjectEventStoreHydration({
projectId: 'project-1',
enabled: true,
})
);
await flushHydration();
expect(result.current.status).toBe('retrying');
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000);
});
await flushHydration();
expect(mocks.hydrate).toHaveBeenCalledTimes(2);
expect(result.current.status).toBe('ready');
});
});

View file

@ -0,0 +1,150 @@
// ========= 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 { buildProjectSessionOverview } from '@/hooks/useProjectSessionOverview';
import {
createChatProjectionState,
type ChatMessageNode,
} from '@/lib/projector/chat';
import {
createHumanControlProjectionState,
type HumanControlProjectionState,
} from '@/lib/projector/control';
import type { ProjectEventStoreSnapshot } from '@/store/projectEventStore';
import { describe, expect, it } from 'vitest';
function message(
runId: string,
sequence: number,
createdAt = new Date(sequence * 1_000).toISOString()
): ChatMessageNode {
return {
id: `${runId}:${sequence}`,
eventId: `${runId}:${sequence}`,
projectId: 'project-1',
runId,
createdAt,
runSequence: sequence,
cloudCursor: null,
eventType: 'message.completed',
legacyStep: null,
kind: 'message',
role: 'assistant',
content: runId,
status: 'complete',
};
}
function snapshot(): ProjectEventStoreSnapshot {
const nodes = [message('run-complete', 4), message('run-live', 1)];
return {
view: {
projectId: 'project-1',
mode: 'live',
seenEventIds: {},
currentCursor: 0,
eventsTruncated: false,
lastSyncedAt: null,
needsResync: false,
resyncReason: null,
resyncTargetCursor: null,
runs: {
'run-complete': {
runId: 'run-complete',
status: 'completed',
lastSequence: 4,
runVersion: 4,
updatedAt: new Date(4_000).toISOString(),
origin: 'local',
resumeBlockedReason: null,
},
'run-live': {
runId: 'run-live',
status: 'running',
lastSequence: 1,
runVersion: 1,
updatedAt: new Date(1_000).toISOString(),
origin: 'local',
resumeBlockedReason: null,
},
},
legacySteps: [],
unknownEvents: [],
},
chat: {
...createChatProjectionState('project-1'),
nodes,
nodeById: Object.fromEntries(nodes.map((node) => [node.id, node])),
seenEventIds: Object.fromEntries(
nodes.map((node) => [node.eventId, true as const])
),
},
control: createHumanControlProjectionState(
'project-1'
) as HumanControlProjectionState,
revision: 1,
hasHydratedSnapshot: true,
overflowed: false,
lastEffects: [],
};
}
describe('buildProjectSessionOverview', () => {
it('keeps an active durable Run current even when history is newer', () => {
const overview = buildProjectSessionOverview(snapshot());
expect(overview.currentRun?.runId).toBe('run-live');
expect(overview.historicalRuns.map((run) => run.runId)).toContain(
'run-complete'
);
});
it('groups semantic nodes by Run without consulting ChatStore', () => {
const overview = buildProjectSessionOverview(snapshot());
expect(overview.runs).toHaveLength(2);
expect(
overview.runs.find((run) => run.runId === 'run-complete')?.nodes
).toMatchObject([{ content: 'run-complete' }]);
});
it('keeps canonical Run sequence order when timestamps are reversed', () => {
const input = snapshot();
const nodes = [
message('run-live', 2, new Date(1_000).toISOString()),
message('run-live', 1, new Date(2_000).toISOString()),
];
input.chat.nodes = nodes;
input.chat.nodeById = Object.fromEntries(
nodes.map((node) => [node.id, node])
);
input.chat.seenEventIds = Object.fromEntries(
nodes.map((node) => [node.eventId, true as const])
);
const overview = buildProjectSessionOverview(input);
expect(overview.currentRun?.nodes.map((node) => node.runSequence)).toEqual([
1, 2,
]);
});
it('returns an empty view before durable hydration has a snapshot', () => {
expect(buildProjectSessionOverview(null)).toEqual({
currentRun: null,
historicalRuns: [],
runs: [],
});
});
});

View file

@ -1,102 +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 { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
import { usePageTabStore } from '@/store/pageTabStore';
import { ProjectType, useProjectStore } from '@/store/projectStore';
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
describe('useSelectedProjectTurn', () => {
beforeEach(() => {
useProjectStore.setState({
activeProjectId: null,
projects: {},
navLeadByProjectId: {},
historyLoadingProjectIds: {},
});
usePageTabStore.setState({
sidePanelSelectedTurnByProject: {},
sidePanelManualUntilByProject: {},
sidePanelViewedTurnByProject: {},
});
});
it('subscribes to and updates the selected turn owning store', () => {
const projectStore = useProjectStore.getState();
const projectId = projectStore.createProject(
'History',
undefined,
'project-history',
ProjectType.REPLAY
);
const oldChatId = projectStore.createChatStore(projectId, 'Old');
const latestChatId = projectStore.createChatStore(projectId, 'Latest');
const oldStore = projectStore.getChatStore(projectId, oldChatId!);
const latestStore = projectStore.getChatStore(projectId, latestChatId!);
const oldTaskId = oldStore!.getState().create('task-old');
const latestTaskId = latestStore!.getState().create('task-latest');
projectStore.setActiveChatStore(projectId, latestChatId!);
act(() => {
usePageTabStore.getState().setSidePanelSelectedTurn(projectId, oldTaskId);
});
const { result } = renderHook(() => useSelectedProjectTurn(projectId));
expect(result.current.chatStore).toBe(oldStore);
expect(result.current.taskId).toBe(oldTaskId);
act(() => {
oldStore!.getState().setSummaryTask(oldTaskId, 'Updated old run');
});
expect(result.current.task?.summaryTask).toBe('Updated old run');
act(() => {
result.current.chatStore?.getState().setSelectedFile(oldTaskId, {
name: 'old.md',
path: '/old.md',
type: 'md',
});
});
expect(oldStore!.getState().tasks[oldTaskId].selectedFile?.name).toBe(
'old.md'
);
expect(latestStore!.getState().tasks[latestTaskId].selectedFile).toBeNull();
});
it('falls back to the active turn when the saved selection is unavailable', () => {
const projectStore = useProjectStore.getState();
const projectId = projectStore.createProject(
'Project',
undefined,
'project-active'
);
const activeStore = projectStore.getActiveChatStore(projectId)!;
const activeTaskId = activeStore.getState().activeTaskId!;
usePageTabStore.setState({
sidePanelSelectedTurnByProject: {
[projectId]: 'missing-task',
},
});
const { result } = renderHook(() => useSelectedProjectTurn(projectId));
expect(result.current.chatStore).toBe(activeStore);
expect(result.current.taskId).toBe(activeTaskId);
});
});

View file

@ -12,11 +12,15 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { normalizeLegacyChatStep } from '@/lib/projector';
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 {
function event(
payload: Record<string, unknown>,
eventType = 'tool.started'
): CanonicalProjectEvent {
return {
eventId: 'tool-event-1',
projectId: 'project-1',
@ -24,7 +28,7 @@ function event(payload: Record<string, unknown>): CanonicalProjectEvent {
runSequence: 1,
runVersion: 1,
cloudCursor: 1,
eventType: 'tool.started',
eventType,
payload,
legacyStep: null,
createdAt: '2026-08-13T00:00:00Z',
@ -81,4 +85,55 @@ describe('chat activity projection', () => {
},
});
});
it('keeps artifact identity separate from a machine-local path', () => {
const node = adaptChatProjectionEvent(
event(
{
artifact_id: 'artifact-1',
file_path: '/private/workspace/outputs/report.md',
relative_path: 'outputs/report.md',
name: 'report.md',
},
'artifact.created'
)
);
expect(node).toMatchObject({
kind: 'display',
node: {
kind: 'artifact',
artifactId: 'artifact-1',
path: 'outputs/report.md',
relativePath: 'outputs/report.md',
},
});
});
it('preserves a portable legacy file path as artifact identity', () => {
const node = adaptChatProjectionEvent(
normalizeLegacyChatStep(
{
step: 'write_file',
data: { file_path: 'reports/quarterly/summary.md' },
},
{
projectId: 'project-1',
runId: 'run-1',
sequence: 1,
sourceId: 'legacy-stream',
createdAt: 1_000,
}
)
);
expect(node).toMatchObject({
kind: 'display',
node: {
kind: 'artifact',
path: 'reports/quarterly/summary.md',
relativePath: 'reports/quarterly/summary.md',
},
});
});
});

View file

@ -12,7 +12,10 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { getWorkspaceRelativeFilePath } from '@/lib/workspaceRelativePath';
import {
getWorkspaceRelativeFilePath,
normalizeWorkspaceRelativePath,
} from '@/lib/workspaceRelativePath';
import { describe, expect, it } from 'vitest';
describe('getWorkspaceRelativeFilePath', () => {
@ -79,4 +82,16 @@ describe('getWorkspaceRelativeFilePath', () => {
})
).toBe('stream');
});
it('normalizes only safe workspace-relative identities', () => {
expect(normalizeWorkspaceRelativePath('./reports\\final.md')).toBe(
'reports/final.md'
);
expect(normalizeWorkspaceRelativePath('/workspace/final.md')).toBeNull();
expect(
normalizeWorkspaceRelativePath('https://example.test/final.md')
).toBeNull();
expect(normalizeWorkspaceRelativePath('%2e%2e/secret.txt')).toBeNull();
expect(normalizeWorkspaceRelativePath('reports/%2fsecret.txt')).toBeNull();
});
});

View file

@ -118,6 +118,10 @@ vi.mock('../../../src/store/projectStore', () => ({
getState: vi.fn(() => ({
activeProjectId: null,
getHistoryId: () => null,
getProjectById: (projectId: string) => ({
id: projectId,
mode: 'single-agent',
}),
})),
},
}));
@ -1048,6 +1052,28 @@ describe('ChatStore - Core Functionality', () => {
expect(result.current.getState().tasks[taskId].progressValue).toBe(50);
});
});
it('writes computed progress to the requested run instead of the active run', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
const historicalTaskId = result.current.getState().create('historical');
const activeTaskId = result.current.getState().create('active');
result.current.getState().setTaskRunning(historicalTaskId, [
{ id: '1', content: 'Done', status: 'completed' },
{ id: '2', content: 'Waiting', status: 'waiting' },
] as any);
result.current.getState().computedProgressValue(historicalTaskId);
expect(
result.current.getState().tasks[historicalTaskId].progressValue
).toBe(50);
expect(
result.current.getState().tasks[activeTaskId].progressValue
).toBe(0);
});
});
});
describe('Update Counter', () => {

View file

@ -13,14 +13,11 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { usePageTabStore } from '@/store/pageTabStore';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
describe('pageTabStore turn selection', () => {
describe('pageTabStore side-panel requests', () => {
beforeEach(() => {
usePageTabStore.setState({
sidePanelSelectedTurnByProject: {},
sidePanelManualUntilByProject: {},
sidePanelViewedTurnByProject: {},
taskBoxFocusRequestId: 0,
taskBoxFocusProjectId: null,
taskBoxFocusTaskId: null,
@ -28,26 +25,6 @@ describe('pageTabStore turn selection', () => {
});
});
it('holds manual selection until the selected turn reaches the viewport', () => {
const store = usePageTabStore.getState();
store.setSidePanelSelectedTurn('project-1', 'task-2', 5000);
store.setSidePanelViewedTurn('project-1', 'task-1');
expect(
usePageTabStore.getState().sidePanelSelectedTurnByProject['project-1']
).toBe('task-2');
store.setSidePanelViewedTurn('project-1', 'task-2');
expect(
usePageTabStore.getState().sidePanelManualUntilByProject['project-1']
).toBe(0);
store.setSidePanelViewedTurn('project-1', 'task-1');
expect(
usePageTabStore.getState().sidePanelSelectedTurnByProject['project-1']
).toBe('task-1');
});
it('scopes task-card focus requests to a project and task', () => {
usePageTabStore.getState().requestTaskBoxFocus('project-1', 'task-2');
@ -57,31 +34,13 @@ describe('pageTabStore turn selection', () => {
taskBoxFocusTaskId: 'task-2',
});
});
});
describe('pageTabStore side-panel viewport selection', () => {
beforeEach(() => {
usePageTabStore.setState({
sidePanelManualUntilByProject: {},
sidePanelSelectedTurnByProject: {},
sidePanelViewedTurnByProject: {},
});
});
it('stores a one-shot historical Run scroll request', () => {
const request = { projectId: 'project-1', taskId: 'task-1' };
usePageTabStore.getState().setScrollToTurnRequest(request);
expect(usePageTabStore.getState().scrollToTurnRequest).toEqual(request);
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();
usePageTabStore.getState().setScrollToTurnRequest(null);
expect(usePageTabStore.getState().scrollToTurnRequest).toBeNull();
});
});