diff --git a/backend/app/agent/toolkit/file_write_toolkit.py b/backend/app/agent/toolkit/file_write_toolkit.py index dc07d2ef..5ac4acae 100644 --- a/backend/app/agent/toolkit/file_write_toolkit.py +++ b/backend/app/agent/toolkit/file_write_toolkit.py @@ -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 diff --git a/backend/app/agent/toolkit/pptx_toolkit.py b/backend/app/agent/toolkit/pptx_toolkit.py index 6d54f4de..db08d7a7 100644 --- a/backend/app/agent/toolkit/pptx_toolkit.py +++ b/backend/app/agent/toolkit/pptx_toolkit.py @@ -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 diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index 597783e6..1d78d680 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -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: diff --git a/backend/app/service/single_agent_service.py b/backend/app/service/single_agent_service.py index 89087a34..eda1902a 100644 --- a/backend/app/service/single_agent_service.py +++ b/backend/app/service/single_agent_service.py @@ -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: diff --git a/backend/app/service/task.py b/backend/app/service/task.py index 8bfe3478..a6147f53 100644 --- a/backend/app/service/task.py +++ b/backend/app/service/task.py @@ -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): diff --git a/backend/app/utils/space_overlay_client.py b/backend/app/utils/space_overlay_client.py index 0d364deb..6cae6b9b 100644 --- a/backend/app/utils/space_overlay_client.py +++ b/backend/app/utils/space_overlay_client.py @@ -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 diff --git a/backend/tests/app/agent/toolkit/test_file_write_workspace.py b/backend/tests/app/agent/toolkit/test_file_write_workspace.py index 460828be..3cb90ef7 100644 --- a/backend/tests/app/agent/toolkit/test_file_write_workspace.py +++ b/backend/tests/app/agent/toolkit/test_file_write_workspace.py @@ -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 diff --git a/backend/tests/app/agent/toolkit/test_pptx_workspace.py b/backend/tests/app/agent/toolkit/test_pptx_workspace.py new file mode 100644 index 00000000..babf5ca0 --- /dev/null +++ b/backend/tests/app/agent/toolkit/test_pptx_workspace.py @@ -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" diff --git a/backend/tests/app/service/test_single_agent_service.py b/backend/tests/app/service/test_single_agent_service.py index 146efc46..9d831ebc 100644 --- a/backend/tests/app/service/test_single_agent_service.py +++ b/backend/tests/app/service/test_single_agent_service.py @@ -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 diff --git a/src/components/BrowserAgentWorkspace/index.tsx b/src/components/BrowserAgentWorkspace/index.tsx index d5ed380f..668b9bb7 100644 --- a/src/components/BrowserAgentWorkspace/index.tsx +++ b/src/components/BrowserAgentWorkspace/index.tsx @@ -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; diff --git a/src/components/ChatBox/EventNativeProjectTimeline.tsx b/src/components/ChatBox/EventNativeProjectTimeline.tsx index 64ba4cf3..62149e2b 100644 --- a/src/components/ChatBox/EventNativeProjectTimeline.tsx +++ b/src/components/ChatBox/EventNativeProjectTimeline.tsx @@ -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(null); const contentRef = useRef(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('[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 (
= ({ }; }, [chatStores, scrollContainerRef]); - // Turn viewport observer — updates which turn is visible in the side panel tabs - const setSidePanelViewedTurn = usePageTabStore( - (s) => s.setSidePanelViewedTurn - ); - const turnObserverRef = useRef(null); - const visibleTurnScoresRef = useRef(new Map()); - 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 = ({ 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 diff --git a/src/components/ChatBox/README.md b/src/components/ChatBox/README.md index 809b1efd..e09e8c39 100644 --- a/src/components/ChatBox/README.md +++ b/src/components/ChatBox/README.md @@ -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. diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index b4541355..aca40aa0 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -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; diff --git a/src/components/Dispatch/index.tsx b/src/components/Dispatch/index.tsx index badac5f1..f6e7a35b 100644 --- a/src/components/Dispatch/index.tsx +++ b/src/components/Dispatch/index.tsx @@ -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, diff --git a/src/components/Folder/index.tsx b/src/components/Folder/index.tsx index 50bfb0a1..4f5a073b 100644 --- a/src/components/Folder/index.tsx +++ b/src/components/Folder/index.tsx @@ -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 []; diff --git a/src/components/Session/SidePanel/components/AccordionBox.tsx b/src/components/Session/SidePanel/components/AccordionBox.tsx new file mode 100644 index 00000000..6bbdf2e8 --- /dev/null +++ b/src/components/Session/SidePanel/components/AccordionBox.tsx @@ -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 ( + +
+
+ setOpen((value) => !value)} + > + {title} + +
+ {headerAction ? ( +
+ {headerAction} +
+ ) : null} +
+ + {isRenderProp ? ( + + + {dynamicBody} + + + ) : ( + <> + +
+ {children as ReactNode} +
+
+ {collapsedPreview ? ( + +
+ {collapsedPreview} +
+
+ ) : null} + + )} +
+ ); +} diff --git a/src/components/Session/SidePanel/components/ActivityPanel.tsx b/src/components/Session/SidePanel/components/ActivityPanel.tsx new file mode 100644 index 00000000..85eefa03 --- /dev/null +++ b/src/components/Session/SidePanel/components/ActivityPanel.tsx @@ -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[0], + skills: Parameters[1], + connectors: Parameters[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 | 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({ + items, + render, +}: { + items: T[]; + render: (item: T) => ReactNode; +}) { + return
{items.map(render)}
; +} + +function SectionList({ children }: { children: ReactNode }) { + const sections = Children.toArray(children); + + return ( +
+ {sections.map((section, index) => ( + + {index > 0 ? ( +
+ ) : null} + {section} + + ))} +
+ ); +} + +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[]) => ( + ( + + ) : ( + + ) + } + onClick={() => onSelect(item)} + > + {item.name || + (item.subagent + ? t('layout.session-panel-remote-subagent', { + defaultValue: 'Remote subagent', + }) + : t('agents.agent', { defaultValue: 'Agent' }))} + + )} + /> + ); + + return ( + } + headerAction={headerAction} + defaultOpen={false} + > + {rows(primary)} + {rows(earlier)} + + ); +} + +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[]) => ( + ( + } + completed={isProgressDone(item.task)} + onClick={() => onSelect(item)} + > + {item.task.content} + + )} + /> + ); + + return ( + } + > + {rows(primary)} + {rows(earlier)} + + ); +} + +function ContextSubcategory({ + title, + items, + onSelect, +}: { + title: string; + items: SessionContextItem[]; + onSelect: (item: SessionContextItem) => void; +}) { + if (items.length === 0) return null; + return ( + } + leading={ + + } + rowVariant="subcategory" + > + ( + } + onClick={() => onSelect(item)} + > + {item.label} + + )} + /> + + ); +} + +function ContextSection({ + items, + scope, + onSelect, +}: { + items: SessionContextItem[]; + scope: SessionPanelScope; + onSelect: (item: SessionContextItem) => void; +}) { + const { t } = useTranslation(); + const { primary, earlier } = arrangeSessionPanelItems(items, scope); + const earlierRows = ( + ( + } + onClick={() => onSelect(item)} + > + {item.label} + + )} + /> + ); + + return ( + } + > + item.category === 'skill')} + onSelect={onSelect} + /> + item.category === 'connector')} + onSelect={onSelect} + /> + {earlierRows} + + ); +} + +function ContextItemIcon({ item }: { item: SessionContextItem }) { + const [iconFailed, setIconFailed] = useState(false); + useEffect(() => setIconFailed(false), [item.iconUrl]); + + if (item.iconUrl && !iconFailed) { + return ( + setIconFailed(true)} + /> + ); + } + if (item.icon) return item.icon; + return item.category === 'skill' ? ( + + ) : ( + + ); +} + +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[]) => ( + ( + + ) : ( + + ) + } + trailing={ + item.kind === 'url' ? : null + } + onClick={() => onSelect(item)} + > + {item.label} + + )} + /> + ); + return ( + } + defaultOpen={false} + > + {rows(primary)} + {rows(earlier)} + + ); +} + +function EnvironmentIcon({ label }: { label: string }) { + if (label === 'Terminal') return ; + if (label === 'Browser') return ; + return ; +} + +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[]) => ( + ( + } + onClick={() => onSelect(item)} + > + {item.label} + + )} + /> + ); + + return ( + } + defaultOpen={false} + > + {rows(primary)} + {rows(earlier)} + + ); +} + +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[]) => ( + ( + } + trailing={ + !item.previewable ? ( + + {t('layout.session-panel-file-unavailable', { + defaultValue: 'Preview unavailable', + })} + + ) : null + } + onClick={item.previewable ? () => onSelect(item) : undefined} + > + {item.file.name || item.file.path} + + )} + /> + ); + return ( + } + headerAction={headerAction} + > + {items.length === 0 ? ( +
+ {t('layout.session-panel-files-empty', { + defaultValue: 'No output files yet.', + })} +
+ ) : ( + <> + {rows(primary)} + {rows(earlier)} + + )} +
+ ); +} + +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([]); + 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( + null + ); + const [selectedContext, setSelectedContext] = + useState(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; + 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. */} +
+
+ {projectId && hydration.status === 'error' ? ( +
+ + + + + {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.', + } + )} + + + + +
+ ) : projectId && + (hydration.status === 'loading' || + hydration.status === 'retrying') ? ( +
+ {t( + hydration.status === 'retrying' + ? 'chat.timeline-history-reconnecting' + : 'chat.timeline-history-loading' + )} +
+ ) : null} + + {agents.length > 0 ? ( + + ) : null} + {subagents.length > 0 ? ( + + ) : null} + {panelData.progress.length > 0 ? ( + { + if (!projectId) return; + setScrollToTurnRequest({ projectId, taskId: item.taskId }); + requestTaskBoxFocus(projectId, item.taskId); + }} + /> + ) : null} + {panelData.contextItems.length > 0 ? ( + + ) : null} + {panelData.environments.length > 0 ? ( + { + if (!projectId) return; + setScrollToTurnRequest({ + projectId, + taskId: item.taskId, + }); + }} + /> + ) : null} + {panelData.resources.length > 0 ? ( + { + if (item.kind === 'url' && item.url) { + openBrowserPreview(item.url); + } else if (item.file) { + openFilePreview(item.file); + } + }} + /> + ) : null} + {projectId ? ( + openFilePreview(item.file)} + headerAction={ + + + + + + } + /> + ) : null} + + {panelData.agents.length === 0 && + panelData.progress.length === 0 && + panelData.contextItems.length === 0 && + panelData.environments.length === 0 && + panelData.resources.length === 0 && + files.length === 0 && + !projectId ? ( +
+ {t('layout.session-activity-empty', { + defaultValue: + 'Session activity will appear here as work begins.', + })} +
+ ) : null} +
+
+ + { + if (!open) setSelectedAgent(null); + }} + /> + { + if (!open) setSelectedContext(null); + }} + /> + + ); +} diff --git a/src/components/Session/Workforce/ExpandedOverlay.tsx b/src/components/Session/SidePanel/components/ExpandedOverlay.tsx similarity index 91% rename from src/components/Session/Workforce/ExpandedOverlay.tsx rename to src/components/Session/SidePanel/components/ExpandedOverlay.tsx index b1f6ab75..3e8445c8 100644 --- a/src/components/Session/Workforce/ExpandedOverlay.tsx +++ b/src/components/Session/SidePanel/components/ExpandedOverlay.tsx @@ -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' && (
- +
)} {activeWorkSpace === 'workflow' && ( @@ -82,7 +80,7 @@ function WorkforceOverlayCanvas({ (agent) => agent.agent_id === activeWorkSpace )?.type === 'developer_agent' && (
- +
)} {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" > - +
diff --git a/src/components/Session/SessionSidePanelFoldButton.tsx b/src/components/Session/SidePanel/components/FoldButton.tsx similarity index 94% rename from src/components/Session/SessionSidePanelFoldButton.tsx rename to src/components/Session/SidePanel/components/FoldButton.tsx index d6aaee54..d314e6c3 100644 --- a/src/components/Session/SessionSidePanelFoldButton.tsx +++ b/src/components/Session/SidePanel/components/FoldButton.tsx @@ -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' diff --git a/src/components/Session/Workforce/FoldedPanel/AgentDetailPane.tsx b/src/components/Session/SidePanel/components/FoldedPanel/AgentDetailPane.tsx similarity index 99% rename from src/components/Session/Workforce/FoldedPanel/AgentDetailPane.tsx rename to src/components/Session/SidePanel/components/FoldedPanel/AgentDetailPane.tsx index fa80db3f..2b4048d0 100644 --- a/src/components/Session/Workforce/FoldedPanel/AgentDetailPane.tsx +++ b/src/components/Session/SidePanel/components/FoldedPanel/AgentDetailPane.tsx @@ -81,6 +81,7 @@ const foldedTaskLogContentVariants = { }, }; +/** Legacy workforce detail view retained as a SidePanel implementation detail. */ export function AgentDetailPane({ agent, onTakeManualFollowControl, diff --git a/src/components/Session/Workforce/FoldedPanel/index.tsx b/src/components/Session/SidePanel/components/FoldedPanel/index.tsx similarity index 95% rename from src/components/Session/Workforce/FoldedPanel/index.tsx rename to src/components/Session/SidePanel/components/FoldedPanel/index.tsx index 6da9369a..765e66a4 100644 --- a/src/components/Session/Workforce/FoldedPanel/index.tsx +++ b/src/components/Session/SidePanel/components/FoldedPanel/index.tsx @@ -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 (
-
+
{isTaskLiveLayout ? ( -
-
+
+
{sortedAgents.map((agent) => (
+
)} {showPlanTaskBox && activeTaskId && activeChatStore ? ( -
+
) : null} -
+
{detailAgent ? ( ) : ( -
+
{t('chat.select-agent')}
)} @@ -488,13 +488,13 @@ export default function FoldedPanel({ ) : ( -
+
{showPlanTaskBox && activeTaskId && activeChatStore ? ( 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 ( -
-
- +
+ - + {title}
-
- {start} +
{end != null ? ( -
{end}
+
{end}
) : null}
diff --git a/src/components/Session/SessionGroup.tsx b/src/components/Session/SidePanel/components/SessionGroup.tsx similarity index 95% rename from src/components/Session/SessionGroup.tsx rename to src/components/Session/SidePanel/components/SessionGroup.tsx index 10198e38..6e87726e 100644 --- a/src/components/Session/SessionGroup.tsx +++ b/src/components/Session/SidePanel/components/SessionGroup.tsx @@ -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({ {t('layout.sessions-full-title')}
-
+
{sessions.length === 0 ? (

{t('layout.sessions-create-task-hint')} diff --git a/src/components/Session/Workforce/WorkforceSidePanelHeaderEnd.tsx b/src/components/Session/SidePanel/components/WorkforceHeaderAction.tsx similarity index 93% rename from src/components/Session/Workforce/WorkforceSidePanelHeaderEnd.tsx rename to src/components/Session/SidePanel/components/WorkforceHeaderAction.tsx index 20c3b7c1..87625f54 100644 --- a/src/components/Session/Workforce/WorkforceSidePanelHeaderEnd.tsx +++ b/src/components/Session/SidePanel/components/WorkforceHeaderAction.tsx @@ -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 ( 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('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 (

@@ -73,37 +83,62 @@ export function SessionSidePanel({ 'pointer-events-none opacity-40 transition-opacity duration-200 group-hover:opacity-80' )} > - } end={ - mode === SessionMode.WORKFORCE ? ( - - ) : null + } /> - {mode === SessionMode.WORKFORCE ? ( - + + ) : undefined + } /> - ) : ( - - )} +
+ {mode === SessionMode.WORKFORCE ? ( + + ) : null} + {isFolded && ( 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 ( + + + + + {agent ? ( +
+
+ + + +

+ {agentDescription} +

+
+ {agent.tools.length > 0 ? ( +
+ + {t('layout.capabilities')} + +
+ {agent.tools.map((tool) => ( + + {tool} + + ))} +
+
+ ) : null} +
+ ) : null} +
+
+
+ ); +} + +export function ToolCallsDialog({ + item, + onOpenChange, +}: { + item: SessionContextItem | null; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslation(); + return ( + + + + + {item?.calls.length ? ( +
+ {item.calls.map((call, index) => ( +
+
+ + {getToolkitIcon(call.toolkitName)} + + + {call.method || + t('layout.session-panel-call', { + defaultValue: '{{name}} call {{number}}', + name: item.label, + number: index + 1, + })} + +
+ {call.input ? ( +
+
+ {t('layout.session-panel-request', { + defaultValue: 'Request', + })} +
+ +
+ ) : null} + {call.output ? ( +
+
+ {t('layout.session-panel-response', { + defaultValue: 'Response', + })} +
+ +
+ ) : null} +
+ ))} +
+ ) : ( +

+ {t('layout.session-panel-no-call-details', { + defaultValue: + 'No stored request or response details are available.', + })} +

+ )} +
+
+
+ ); +} diff --git a/src/components/Session/SidePanel/sections/buildContextItems.ts b/src/components/Session/SidePanel/sections/buildContextItems.ts new file mode 100644 index 00000000..4ae116ed --- /dev/null +++ b/src/components/Session/SidePanel/sections/buildContextItems.ts @@ -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 []; +} diff --git a/src/components/Session/SidePanel/sections/buildProjectSessionPanelData.ts b/src/components/Session/SidePanel/sections/buildProjectSessionPanelData.ts new file mode 100644 index 00000000..fa339a7f --- /dev/null +++ b/src/components/Session/SidePanel/sections/buildProjectSessionPanelData.ts @@ -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 { + 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(); +const NODE_HTTP_URL_CACHE = new WeakMap(); + +function normalizedMethodName(value: string): string { + return value.trim().toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' '); +} + +function skillOperation( + call: Pick +): '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(); + const anonymousOpen = new Map(); + + 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(); + 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(); + const todoTaskKeys = new Set(); + 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(); + + const put = ( + category: Exclude, + 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(); + 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(); + 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 +): 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 +): 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, + 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(); + 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(); + 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(); + const byRelativePath = new Map(); + + const addUnique = ( + index: Map, + 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 + ); +} diff --git a/src/components/Session/SidePanelSections/collectSidePanelOutputFiles.ts b/src/components/Session/SidePanel/sections/collectSidePanelOutputFiles.ts similarity index 99% rename from src/components/Session/SidePanelSections/collectSidePanelOutputFiles.ts rename to src/components/Session/SidePanel/sections/collectSidePanelOutputFiles.ts index 13fa7255..b2b3ee7d 100644 --- a/src/components/Session/SidePanelSections/collectSidePanelOutputFiles.ts +++ b/src/components/Session/SidePanel/sections/collectSidePanelOutputFiles.ts @@ -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'; diff --git a/src/components/Session/SidePanel/sections/primitives.tsx b/src/components/Session/SidePanel/sections/primitives.tsx new file mode 100644 index 00000000..19c79f0c --- /dev/null +++ b/src/components/Session/SidePanel/sections/primitives.tsx @@ -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['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 ? ( + + {leading} + + ) : null} + + {children} + + {badge ? ( + {badge} + ) : null} + {chevron ? ( + + ) : null} + {trailing ? ( + {trailing} + ) : null} + + ); + + if (onClick) { + return ( + + ); + } + + return ( +
} className={base}> + {content} +
+ ); + } +); +SessionPanelButton.displayName = 'SessionPanelButton'; + +export function SessionPanelCollapse({ + open, + children, + className, +}: { + open: boolean; + children: ReactNode; + className?: string; +}) { + const shouldReduceMotion = useReducedMotion(); + const collapseRef = useRef(null); + useLayoutEffect(() => { + collapseRef.current?.toggleAttribute('inert', !open); + }, [open]); + return ( + + {children} + + ); +} + +/** + * Small round count/label pill used next to accordion titles. + */ +export function CountPill({ count }: { count: number }) { + return ( + + {count} + + ); +} + +/** + * 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 ( +
+ } + chevron + open={open} + ariaExpanded={open} + onClick={() => setOpen((value) => !value)} + > + {resolvedLabel} + + +
{children}
+
+
+ ); +} + +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( + ( + { + leading, + children, + trailing, + disabled, + onClick, + interactiveHover, + completed, + className, + }, + ref + ) => { + return ( + + {children} + + ); + } +); +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 ( + + {done ? ( + + ) : null} + + ); +} diff --git a/src/components/Session/SidePanel/sections/sessionPanelScope.ts b/src/components/Session/SidePanel/sections/sessionPanelScope.ts new file mode 100644 index 00000000..e3961e55 --- /dev/null +++ b/src/components/Session/SidePanel/sections/sessionPanelScope.ts @@ -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( + 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) }; +} diff --git a/src/components/Session/SidePanelSections/useProjectOutputFiles.ts b/src/components/Session/SidePanel/sections/useProjectOutputFiles.ts similarity index 90% rename from src/components/Session/SidePanelSections/useProjectOutputFiles.ts rename to src/components/Session/SidePanel/sections/useProjectOutputFiles.ts index b4dd388d..74ee116c 100644 --- a/src/components/Session/SidePanelSections/useProjectOutputFiles.ts +++ b/src/components/Session/SidePanel/sections/useProjectOutputFiles.ts @@ -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, diff --git a/src/components/Session/SidePanelAccordionBox.tsx b/src/components/Session/SidePanelAccordionBox.tsx deleted file mode 100644 index c587c18b..00000000 --- a/src/components/Session/SidePanelAccordionBox.tsx +++ /dev/null @@ -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 ( -
- - - {!open && collapsedPreview && !isRenderProp ? ( -
{collapsedPreview}
- ) : null} - - {isRenderProp ? ( - - {dynamicBody != null ? ( -
{dynamicBody}
- ) : null} -
- ) : ( - - {open ? ( - -
{children as ReactNode}
-
- ) : null} -
- )} -
- ); -} diff --git a/src/components/Session/SidePanelSections/AgentFolderSection.tsx b/src/components/Session/SidePanelSections/AgentFolderSection.tsx deleted file mode 100644 index db8a477f..00000000 --- a/src/components/Session/SidePanelSections/AgentFolderSection.tsx +++ /dev/null @@ -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 = { - // 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; - onToggleFolder: (path: string) => void; - onOpenFile: (file: FileInfo) => void; -} - -function AgentFolderTree({ - nodes, - expandedFolders, - onToggleFolder, - onOpenFile, -}: AgentFolderTreeProps) { - return ( - - {nodes.map((node) => { - const isExpanded = - node.isFolder && expandedFolders.has(node.relativePath); - const Icon = node.file ? iconFor(node.file) : File; - - return ( - - - {isExpanded ? ( - - ) : ( - - )} - {isExpanded ? ( - - ) : ( - - )} - - ) : ( - - ) - } - onClick={() => { - if (node.isFolder) { - onToggleFolder(node.relativePath); - } else if (node.file) { - onOpenFile(node.file); - } - }} - > - {node.name} - - - {isExpanded && node.children.length > 0 ? ( -
    - -
- ) : null} -
- ); - })} -
- ); -} - -export function AgentFolderSection({ - title, - files, - onOpenFile, -}: AgentFolderSectionProps) { - const unique = useMemo(() => { - const seen = new Set(); - 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>( - () => 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 ( - - {unique.length === 0 ? ( -
- Files the agent writes or updates during this task appear here so you - can open them. -
- ) : ( - - - - )} -
- ); -} diff --git a/src/components/Session/SidePanelSections/AgentPoolSection.tsx b/src/components/Session/SidePanelSections/AgentPoolSection.tsx deleted file mode 100644 index 92f9647f..00000000 --- a/src/components/Session/SidePanelSections/AgentPoolSection.tsx +++ /dev/null @@ -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 ; - case 'browser_agent': - return ; - case 'document_agent': - return ; - case 'multi_modal_agent': - return ; - case 'social_media_agent': - return ; - default: - return null; - } -} - -function AgentLeadingIcon({ agentType }: { agentType: string }) { - const subIcon = getAgentSubIcon(agentType); - return ( -
- - {subIcon != null && ( - - {subIcon} - - )} -
- ); -} - -/** - * 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; - timers: Map>; - /** Ids that have already been shown and evicted — never re-add. */ - retired: Set; -}; - -function readToolkitEvents( - agent: Pick | 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; - cancel: (handle: ReturnType) => 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(); - 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({ - 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 ( -
- - {focused && ( - - - {getToolkitIcon(focused, 16, '')} - - - - )} - -
- ); -} - -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 ( -
- - - {name} - - -
- ); -} - -function AgentList({ agents }: { agents: Agent[] }) { - return ( - - - {agents.map((agent) => ( - - - - ))} - - - ); -} - -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 = ( -
- No agents yet -
- ); - - return ( - - {({ open }) => { - if (ordered.length === 0) { - return open ? emptyState : null; - } - if (!open) { - const collapsed = - toolingAgents.length > 0 ? toolingAgents : activeAgents; - return collapsed.length > 0 ? : null; - } - return ; - }} - - ); -} diff --git a/src/components/Session/SidePanelSections/ExecutionContextSection.tsx b/src/components/Session/SidePanelSections/ExecutionContextSection.tsx deleted file mode 100644 index 767b77ef..00000000 --- a/src/components/Session/SidePanelSections/ExecutionContextSection.tsx +++ /dev/null @@ -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 = { - 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(); - 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 ( - - {items.length === 0 ? ( -
- Track skills, MCPs and referenced files used in this task. -
- ) : ( -
- {grouped.map(({ category, items: groupItems }) => ( -
- {CATEGORY_LABEL[category]} - - - {groupItems.map((item) => ( - - - {item.label} - - - ))} - - -
- ))} -
- )} -
- ); -} diff --git a/src/components/Session/SidePanelSections/ProgressSection.tsx b/src/components/Session/SidePanelSections/ProgressSection.tsx deleted file mode 100644 index 5a3fce14..00000000 --- a/src/components/Session/SidePanelSections/ProgressSection.tsx +++ /dev/null @@ -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 ? ( -
- - {visibleSubtasks.map((task, idx) => ( - - - {idx < visibleSubtasks.length - 1 ? : null} - - ))} - -
- ) : null; - - return ( - 0 ? : null} - > - {({ open }) => { - if (!open) { - return collapsedStrip; - } - if (count === 0) { - return ( -
- Follow each plan step and its status as this task runs. -
- ); - } - return ( - - - {visibleSubtasks.map((task) => ( - - } - onClick={() => requestTaskBoxFocus(projectId, taskId)} - > - - {task.content} - - - - ))} - - - ); - }} -
- ); -} diff --git a/src/components/Session/SidePanelSections/buildContextItems.ts b/src/components/Session/SidePanelSections/buildContextItems.ts deleted file mode 100644 index 7be5d59a..00000000 --- a/src/components/Session/SidePanelSections/buildContextItems.ts +++ /dev/null @@ -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(); - const connectorHints = new Set(); - - const add = (set: Set, 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 }) - .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(); - 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; -} diff --git a/src/components/Session/SidePanelSections/primitives.tsx b/src/components/Session/SidePanelSections/primitives.tsx deleted file mode 100644 index ba6fc19c..00000000 --- a/src/components/Session/SidePanelSections/primitives.tsx +++ /dev/null @@ -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 ( - - {count} - - ); -} - -/** - * Small muted category label for grouping list items. - */ -export function CategoryLabel({ - children, - className, -}: { - children: ReactNode; - className?: string; -}) { - return ( -
- {children} -
- ); -} - -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( - ( - { - 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 ? ( - {leading} - ) : null} - {children} - {trailing ? ( - {trailing} - ) : null} - - ); - - if (onClick) { - return ( - - ); - } - - return ( -
} className={base}> - {content} -
- ); - } -); -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 ( - - {done ? ( - - ) : null} - - ); -} - -/** - * Thin connector line between two progress circles in the folded strip view. - */ -export function ProgressConnector() { - return ( - - ); -} diff --git a/src/components/Session/SingleAgent/SingleAgentSidePanel.tsx b/src/components/Session/SingleAgent/SingleAgentSidePanel.tsx deleted file mode 100644 index f3212487..00000000 --- a/src/components/Session/SingleAgent/SingleAgentSidePanel.tsx +++ /dev/null @@ -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(); - 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 ( -
-
- - - -
-
- ); -} diff --git a/src/components/Session/SingleAgent/index.tsx b/src/components/Session/SingleAgent/index.tsx deleted file mode 100644 index 65ac8a97..00000000 --- a/src/components/Session/SingleAgent/index.tsx +++ /dev/null @@ -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'; diff --git a/src/components/Session/TurnTabs.tsx b/src/components/Session/TurnTabs.tsx deleted file mode 100644 index 32427ac9..00000000 --- a/src/components/Session/TurnTabs.tsx +++ /dev/null @@ -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 ( - - ); -} - -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 ( - - ); -} - -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(); - 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 ( - - - - - - - {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 ( - handleSelect(turn.taskId)} - className="gap-2" - > - {isLive ? : } - - Run {turnNumber} - - - {preview} - - {isSelected && ( - - )} - - ); - })} - - - ); -} diff --git a/src/components/Session/Workforce/WorkforceSidePanel.tsx b/src/components/Session/Workforce/WorkforceSidePanel.tsx deleted file mode 100644 index f7c81791..00000000 --- a/src/components/Session/Workforce/WorkforceSidePanel.tsx +++ /dev/null @@ -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(); - 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 ( - <> -
-
- - - - -
-
- - - - ); -} diff --git a/src/components/Session/index.tsx b/src/components/Session/index.tsx index 765c3ddc..dc06779e 100644 --- a/src/components/Session/index.tsx +++ b/src/components/Session/index.tsx @@ -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 | 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 ( -
-
- + // 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. + +
- + +
+ +
+
+ +
+ {sessionSidePanel}
+
+ ); + } + + return ( + +
+ {/* Chat content: owns the project header and folds when display opens. */} +
+ +
+ +
+
+ + + {previewOpen && ( + +
+ + {/* Display content: middle column between chat and session. */} +
+ {activeProjectId ? ( + + ) : null} +
+ + )} +
- ); - } - - return ( -
- {/* Chat content: owns the project header and folds when display opens. */} -
- -
- -
-
- - - {previewOpen && ( - -
- - {/* Display content: middle column between chat and session. */} -
- {activeProjectId ? ( - - ) : null} -
- - )} - - -
- {sessionSidePanel} -
-
+ ); } diff --git a/src/components/TerminalAgentWorkspace/index.tsx b/src/components/TerminalAgentWorkspace/index.tsx index 03312861..a023f0b5 100644 --- a/src/components/TerminalAgentWorkspace/index.tsx +++ b/src/components/TerminalAgentWorkspace/index.tsx @@ -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(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 = diff --git a/src/components/Workspace/index.tsx b/src/components/Workspace/index.tsx index 73a62b20..6c31e3df 100644 --- a/src/components/Workspace/index.tsx +++ b/src/components/Workspace/index.tsx @@ -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'; diff --git a/src/hooks/useProjectEventRuntime.tsx b/src/hooks/useProjectEventRuntime.tsx new file mode 100644 index 00000000..1581307b --- /dev/null +++ b/src/hooks/useProjectEventRuntime.tsx @@ -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({ + 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( + () => ({ hydration, projectId: normalizedProjectId, snapshot }), + [hydration, normalizedProjectId, snapshot] + ); + + return ( + + {children} + + ); +} + +export function useProjectEventRuntime(): ProjectEventRuntimeValue { + return useContext(ProjectEventRuntimeContext); +} diff --git a/src/hooks/useProjectEventStoreHydration.ts b/src/hooks/useProjectEventStoreHydration.ts index 0b643123..8a4df4a3 100644 --- a/src/hooks/useProjectEventStoreHydration.ts +++ b/src/hooks/useProjectEventStoreHydration.ts @@ -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 { diff --git a/src/hooks/useProjectSessionOverview.ts b/src/hooks/useProjectSessionOverview.ts new file mode 100644 index 00000000..baccf107 --- /dev/null +++ b/src/hooks/useProjectSessionOverview.ts @@ -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([ + '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(); + 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((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]); +} diff --git a/src/hooks/useSelectedProjectTurn.ts b/src/hooks/useSelectedProjectTurn.ts deleted file mode 100644 index eb022602..00000000 --- a/src/hooks/useSelectedProjectTurn.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/i18n/locales/ar/layout.json b/src/i18n/locales/ar/layout.json index 3e8abd4b..3643b94a 100644 --- a/src/i18n/locales/ar/layout.json +++ b/src/i18n/locales/ar/layout.json @@ -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", diff --git a/src/i18n/locales/de/layout.json b/src/i18n/locales/de/layout.json index e5369fc8..349a419a 100644 --- a/src/i18n/locales/de/layout.json +++ b/src/i18n/locales/de/layout.json @@ -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", diff --git a/src/i18n/locales/en-us/layout.json b/src/i18n/locales/en-us/layout.json index c180c81a..dc76e8bd 100644 --- a/src/i18n/locales/en-us/layout.json +++ b/src/i18n/locales/en-us/layout.json @@ -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", diff --git a/src/i18n/locales/es/layout.json b/src/i18n/locales/es/layout.json index 2e7c9064..a30757aa 100644 --- a/src/i18n/locales/es/layout.json +++ b/src/i18n/locales/es/layout.json @@ -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", diff --git a/src/i18n/locales/fr/layout.json b/src/i18n/locales/fr/layout.json index d44f7d40..62eaeac6 100644 --- a/src/i18n/locales/fr/layout.json +++ b/src/i18n/locales/fr/layout.json @@ -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": "L’activité 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 à l’exécution actuelle", + "session-panel-add-files-unavailable": "Les fichiers peuvent uniquement être joints à l’entrée de l’exécution actuelle.", + "session-panel-file-unavailable": "Aperçu indisponible", + "session-panel-history-unavailable": "Une partie de l’historique de la session n’a pas pu être chargée.", + "session-panel-history-unsupported": "Ce backend ne prend pas encore en charge l’historique 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 à l’aide 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 d’outils", + "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é n’est disponible.", + "session-panel-upload-failed": "Échec de l’importation de {{name}}", "workspace-lets-do-this": "C'est parti", "workspace-work-in-project": "Travailler dans un projet", "workspace-select-project": "Select a project", diff --git a/src/i18n/locales/it/layout.json b/src/i18n/locales/it/layout.json index 724d84e3..0a9f3a11 100644 --- a/src/i18n/locales/it/layout.json +++ b/src/i18n/locales/it/layout.json @@ -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 l’esecuzione più recente", + "session-summary-all": "Tutte", + "session-activity-empty": "L’attività della sessione verrà visualizzata qui all’inizio 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", diff --git a/src/i18n/locales/ja/layout.json b/src/i18n/locales/ja/layout.json index cdc3812a..419ff3a8 100644 --- a/src/i18n/locales/ja/layout.json +++ b/src/i18n/locales/ja/layout.json @@ -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", diff --git a/src/i18n/locales/ko/layout.json b/src/i18n/locales/ko/layout.json index 2f94332c..4a526dd2 100644 --- a/src/i18n/locales/ko/layout.json +++ b/src/i18n/locales/ko/layout.json @@ -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", diff --git a/src/i18n/locales/ru/layout.json b/src/i18n/locales/ru/layout.json index 3749a31e..9247e536 100644 --- a/src/i18n/locales/ru/layout.json +++ b/src/i18n/locales/ru/layout.json @@ -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", diff --git a/src/i18n/locales/zh-Hans/layout.json b/src/i18n/locales/zh-Hans/layout.json index f78969b1..b7a71cb6 100644 --- a/src/i18n/locales/zh-Hans/layout.json +++ b/src/i18n/locales/zh-Hans/layout.json @@ -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", diff --git a/src/i18n/locales/zh-Hant/layout.json b/src/i18n/locales/zh-Hant/layout.json index 08b501f6..9abbb86f 100644 --- a/src/i18n/locales/zh-Hant/layout.json +++ b/src/i18n/locales/zh-Hant/layout.json @@ -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", diff --git a/src/lib/projector/chat/adapter.ts b/src/lib/projector/chat/adapter.ts index 83dddf3c..a92f7194 100644 --- a/src/lib/projector/chat/adapter.ts +++ b/src/lib/projector/chat/adapter.ts @@ -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: diff --git a/src/lib/projector/chat/types.ts b/src/lib/projector/chat/types.ts index 61be5222..11c46e62 100644 --- a/src/lib/projector/chat/types.ts +++ b/src/lib/projector/chat/types.ts @@ -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; diff --git a/src/lib/workspaceRelativePath.ts b/src/lib/workspaceRelativePath.ts index b97778a3..34d76bfa 100644 --- a/src/lib/workspaceRelativePath.ts +++ b/src/lib/workspaceRelativePath.ts @@ -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 diff --git a/src/pages/Workspace.tsx b/src/pages/Workspace.tsx index 001304d2..758045df 100644 --- a/src/pages/Workspace.tsx +++ b/src/pages/Workspace.tsx @@ -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() {
diff --git a/src/store/chatEventProjectionBridge.ts b/src/store/chatEventProjectionBridge.ts index 5fadc3d9..c46ad016 100644 --- a/src/store/chatEventProjectionBridge.ts +++ b/src/store/chatEventProjectionBridge.ts @@ -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(), diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index cb1d3bde..297ccc5e 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -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) => 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 diff --git a/src/store/pageTabStore.ts b/src/store/pageTabStore.ts index 8f97a502..8a104819 100644 --- a/src/store/pageTabStore.ts +++ b/src/store/pageTabStore.ts @@ -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; - /** - * Unix-ms timestamp until which a user tab-click overrides the - * scroll-driven viewport selection, per project. - */ - sidePanelManualUntilByProject: Record; - /** - * Task ID currently visible in the chatbox scroll viewport, per project. - * Written by the IntersectionObserver in ProjectChatContainer. - */ - sidePanelViewedTurnByProject: Record; - 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()( 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 }), diff --git a/test/unit/components/AgentPoolSection.test.tsx b/test/unit/components/AgentPoolSection.test.tsx deleted file mode 100644 index 00eafdad..00000000 --- a/test/unit/components/AgentPoolSection.test.tsx +++ /dev/null @@ -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[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; - }; - const cancel = (h: ReturnType) => { - 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['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']); - }); -}); diff --git a/test/unit/components/ChatBox/EventNativeProjectTimeline.test.tsx b/test/unit/components/ChatBox/EventNativeProjectTimeline.test.tsx index 2753e93f..f0f4ce84 100644 --- a/test/unit/components/ChatBox/EventNativeProjectTimeline.test.tsx +++ b/test/unit/components/ChatBox/EventNativeProjectTimeline.test.tsx @@ -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(); @@ -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( + + ); + + expect(usePageTabStore.getState().scrollToTurnRequest).toBeNull(); + vi.mocked(scrollContainer.scrollTo).mockClear(); + + mocks.projection = projection([ + messageNode(0), + { ...messageNode(1), runId: 'run-not-mounted' }, + ]); + rerender( + + ); + + expect(scrollContainer.scrollTo).not.toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'smooth' }) + ); + }); }); diff --git a/test/unit/components/Session/ActivityPanel.test.tsx b/test/unit/components/Session/ActivityPanel.test.tsx new file mode 100644 index 00000000..3cb80678 --- /dev/null +++ b/test/unit/components/Session/ActivityPanel.test.tsx @@ -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, + 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) => 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); + }) => ( +
+

{title}

+ {headerAction} + {typeof children === 'function' ? children({ open: true }) : children} +
+ ), +})); + +vi.mock('@/components/Session/SidePanel/sections/primitives', () => ({ + CountPill: ({ count }: { count: number }) => {count}, + EarlierItems: ({ children }: { children: ReactNode }) => children, + ProgressCircle: () => null, + SidePanelListRow: ({ + children, + onClick, + trailing, + }: { + children: ReactNode; + onClick?: () => void; + trailing?: ReactNode; + }) => + onClick ? ( + + ) : ( +
+ {children} + {trailing} +
+ ), +})); + +vi.mock( + '@/components/Session/SidePanel/sections/SessionSidePanelDialogs', + () => ({ + AgentInformationDialog: ({ agent }: { agent: any }) => + agent ?
{agent.name}
: null, + ToolCallsDialog: ({ item }: { item: any }) => + item ?
{item.label}
: 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(); + 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(); + 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(); + 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(); + + 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(); + 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(); + + 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(); + + 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(); + + await waitFor(() => + expect(screen.queryByTestId('agent-dialog')).not.toBeInTheDocument() + ); + }); +}); diff --git a/test/unit/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts b/test/unit/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts index 16ad8b86..a6ba1427 100644 --- a/test/unit/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts +++ b/test/unit/components/Session/SidePanelSections/collectSidePanelOutputFiles.test.ts @@ -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 => ({ diff --git a/test/unit/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx b/test/unit/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx index 5275531f..bbf75504 100644 --- a/test/unit/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx +++ b/test/unit/components/Session/SidePanelSections/useProjectOutputFiles.test.tsx @@ -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', + }); }); }); diff --git a/test/unit/components/buildProjectSessionPanelData.test.ts b/test/unit/components/buildProjectSessionPanelData.test.ts new file mode 100644 index 00000000..53796b13 --- /dev/null +++ b/test/unit/components/buildProjectSessionPanelData.test.ts @@ -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 { + 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; +} + +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']); + }); +}); diff --git a/test/unit/components/resolveContextConnector.test.ts b/test/unit/components/resolveContextConnector.test.ts new file mode 100644 index 00000000..731d761f --- /dev/null +++ b/test/unit/components/resolveContextConnector.test.ts @@ -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(); + }); +}); diff --git a/test/unit/components/sessionPanelPrimitives.test.tsx b/test/unit/components/sessionPanelPrimitives.test.tsx new file mode 100644 index 00000000..ff537cbc --- /dev/null +++ b/test/unit/components/sessionPanelPrimitives.test.tsx @@ -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( + + + + ); + + expect(container.firstElementChild).toHaveAttribute('inert'); + expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true'); + + rerender( + + + + ); + + 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( + +
Resource rows
+
+ ); + + 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( + +
Skill rows
+
+ ); + + const trigger = screen.getByRole('button', { name: 'Skills' }); + const header = trigger.parentElement?.parentElement; + expect(header).not.toHaveClass('sticky'); + }); +}); diff --git a/test/unit/components/sessionPanelScope.test.ts b/test/unit/components/sessionPanelScope.test.ts new file mode 100644 index 00000000..6e1dc213 --- /dev/null +++ b/test/unit/components/sessionPanelScope.test.ts @@ -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', + ]); + }); +}); diff --git a/test/unit/hooks/useProjectEventRuntime.test.tsx b/test/unit/hooks/useProjectEventRuntime.test.tsx new file mode 100644 index 00000000..1375157e --- /dev/null +++ b/test/unit/hooks/useProjectEventRuntime.test.tsx @@ -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 ( +
+ {runtime.projectId}:{runtime.snapshot?.revision ?? 'none'}: + {runtime.hydration.status} +
+ ); +} + +describe('ProjectEventRuntimeProvider', () => { + afterEach(() => { + resetProjectEventStoresForTests(); + vi.clearAllMocks(); + }); + + it('owns one hydration and live-stream subscription for its Session', () => { + render( + + + + ); + + 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( + + + + ); + + expect(screen.getByText('project-previous:0:ready')).toBeInTheDocument(); + + rerender( + + + + ); + + 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( + + + + ); + + rerender( + + + + ); + + 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 }), + }) + ); + }); +}); diff --git a/test/unit/hooks/useProjectEventStoreHydration.test.tsx b/test/unit/hooks/useProjectEventStoreHydration.test.tsx new file mode 100644 index 00000000..3a078ab2 --- /dev/null +++ b/test/unit/hooks/useProjectEventStoreHydration.test.tsx @@ -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 { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('useProjectEventStoreHydration', () => { + let warn: ReturnType; + + 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'); + }); +}); diff --git a/test/unit/hooks/useProjectSessionOverview.test.tsx b/test/unit/hooks/useProjectSessionOverview.test.tsx new file mode 100644 index 00000000..5759ad00 --- /dev/null +++ b/test/unit/hooks/useProjectSessionOverview.test.tsx @@ -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: [], + }); + }); +}); diff --git a/test/unit/hooks/useSelectedProjectTurn.test.tsx b/test/unit/hooks/useSelectedProjectTurn.test.tsx deleted file mode 100644 index 640f00b8..00000000 --- a/test/unit/hooks/useSelectedProjectTurn.test.tsx +++ /dev/null @@ -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); - }); -}); diff --git a/test/unit/lib/chatProjectionActivity.test.ts b/test/unit/lib/chatProjectionActivity.test.ts index 10f03c04..cb679c26 100644 --- a/test/unit/lib/chatProjectionActivity.test.ts +++ b/test/unit/lib/chatProjectionActivity.test.ts @@ -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): CanonicalProjectEvent { +function event( + payload: Record, + eventType = 'tool.started' +): CanonicalProjectEvent { return { eventId: 'tool-event-1', projectId: 'project-1', @@ -24,7 +28,7 @@ function event(payload: Record): 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', + }, + }); + }); }); diff --git a/test/unit/lib/workspaceRelativePath.test.ts b/test/unit/lib/workspaceRelativePath.test.ts index 014fdfc1..b29f3c91 100644 --- a/test/unit/lib/workspaceRelativePath.test.ts +++ b/test/unit/lib/workspaceRelativePath.test.ts @@ -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(); + }); }); diff --git a/test/unit/store/chatStore.test.ts b/test/unit/store/chatStore.test.ts index 0b5f569a..424e1f6f 100644 --- a/test/unit/store/chatStore.test.ts +++ b/test/unit/store/chatStore.test.ts @@ -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', () => { diff --git a/test/unit/store/pageTabStore.test.ts b/test/unit/store/pageTabStore.test.ts index eda507b7..25b9a3ea 100644 --- a/test/unit/store/pageTabStore.test.ts +++ b/test/unit/store/pageTabStore.test.ts @@ -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(); }); });