mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-21 06:33:35 +00:00
Feat/refactor session side panel (#1853)
Co-authored-by: 4pmtong <web_chentong@163.com>
This commit is contained in:
parent
5c565a5a16
commit
b439d5ff23
87 changed files with 6219 additions and 3081 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
92
backend/tests/app/agent/toolkit/test_pptx_workspace.py
Normal file
92
backend/tests/app/agent/toolkit/test_pptx_workspace.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from camel.toolkits import PPTXToolkit as BasePPTXToolkit
|
||||
|
||||
from app.agent.toolkit import pptx_toolkit
|
||||
from app.agent.toolkit.pptx_toolkit import PPTXToolkit
|
||||
from app.run_context import RunContext, run_context_scope
|
||||
from app.service.task import ActionWriteFileData
|
||||
from app.utils.listen import toolkit_listen
|
||||
|
||||
|
||||
def _context(root: Path) -> RunContext:
|
||||
return RunContext(
|
||||
space_id="space-1",
|
||||
project_id="project-1",
|
||||
run_id="run-1",
|
||||
task_id="task-1",
|
||||
email="user@example.com",
|
||||
user_id="user-1",
|
||||
working_directory=root,
|
||||
task_output_root=root,
|
||||
camel_log_dir=root / ".logs",
|
||||
binding_source="test",
|
||||
workdir_mode="direct-write",
|
||||
browser_port=9222,
|
||||
)
|
||||
|
||||
|
||||
def test_pptx_toolkit_emits_safe_relative_path(tmp_path, monkeypatch):
|
||||
root = tmp_path / "run"
|
||||
root.mkdir()
|
||||
emitted: list[ActionWriteFileData] = []
|
||||
|
||||
def fake_create_presentation(self, _content, filename, _template=None):
|
||||
path = self._resolve_filepath(filename)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"pptx")
|
||||
return f"PowerPoint presentation successfully created: {path}"
|
||||
|
||||
monkeypatch.setattr(
|
||||
BasePPTXToolkit,
|
||||
"create_presentation",
|
||||
fake_create_presentation,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pptx_toolkit,
|
||||
"get_task_lock",
|
||||
lambda _task_id: object(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
toolkit_listen,
|
||||
"get_task_lock",
|
||||
lambda _task_id: object(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
toolkit_listen,
|
||||
"_safe_put_queue",
|
||||
lambda _lock, _event: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pptx_toolkit,
|
||||
"_safe_put_queue",
|
||||
lambda _lock, event: emitted.append(event),
|
||||
)
|
||||
toolkit = PPTXToolkit("project-1", working_directory=str(root))
|
||||
|
||||
with run_context_scope(_context(root)):
|
||||
result = toolkit.create_presentation("# Slides", "decks/briefing")
|
||||
|
||||
expected_path = root / "decks" / "briefing.pptx"
|
||||
assert result == (
|
||||
f"PowerPoint presentation successfully created: {expected_path}"
|
||||
)
|
||||
assert len(emitted) == 1
|
||||
assert emitted[0].data == str(expected_path)
|
||||
assert emitted[0].relative_path == "decks/briefing.pptx"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useProjectEventStoreHydration } from '@/hooks/useProjectEventStoreHydration';
|
||||
import { useProjectChatProjection } from '@/hooks/useProjectEventView';
|
||||
import { useProjectEventRuntime } from '@/hooks/useProjectEventRuntime';
|
||||
import {
|
||||
selectRenderableChatNodes,
|
||||
type ChatProjectionNode,
|
||||
} from '@/lib/projector/chat';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
|
|
@ -127,13 +127,12 @@ export function EventNativeProjectTimeline({
|
|||
scrollBottomInsetPx,
|
||||
}: EventNativeProjectTimelineProps) {
|
||||
const { t } = useTranslation();
|
||||
const hydration = useProjectEventStoreHydration({
|
||||
projectId,
|
||||
enabled: true,
|
||||
});
|
||||
const projection = useProjectChatProjection(projectId);
|
||||
const runtime = useProjectEventRuntime();
|
||||
const hydration = runtime.hydration;
|
||||
const projection =
|
||||
runtime.projectId === projectId ? runtime.snapshot?.chat : undefined;
|
||||
const allNodes = useMemo(
|
||||
() => selectRenderableChatNodes(projection),
|
||||
() => (projection ? selectRenderableChatNodes(projection) : []),
|
||||
[projection]
|
||||
);
|
||||
const timelineWindow = useMemo(
|
||||
|
|
@ -148,6 +147,12 @@ export function EventNativeProjectTimeline({
|
|||
const ignoreAnchorScrollRef = useRef(false);
|
||||
const anchorAnimationRef = useRef<ChatTimelineScrollAnimation | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const scrollToTurnRequest = usePageTabStore(
|
||||
(state) => state.scrollToTurnRequest
|
||||
);
|
||||
const setScrollToTurnRequest = usePageTabStore(
|
||||
(state) => state.setScrollToTurnRequest
|
||||
);
|
||||
const latestNode = visibleNodes.at(-1);
|
||||
const latestEventId = latestNode?.eventId;
|
||||
const userMessageNodes = visibleNodes.filter(
|
||||
|
|
@ -258,6 +263,41 @@ export function EventNativeProjectTimeline({
|
|||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!scrollToTurnRequest ||
|
||||
scrollToTurnRequest.projectId !== projectId ||
|
||||
!scrollContainerRef?.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const container = scrollContainerRef.current;
|
||||
const target = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[data-run-id]')
|
||||
).find(
|
||||
(element) =>
|
||||
element.getAttribute('data-run-id') === scrollToTurnRequest.taskId
|
||||
);
|
||||
// A request is a one-shot command. Clear it even when the requested Run
|
||||
// is outside the bounded DOM window so it cannot fire unexpectedly after
|
||||
// later timeline updates mount that Run.
|
||||
setScrollToTurnRequest(null);
|
||||
if (!target) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
container.scrollTo({
|
||||
top: container.scrollTop + targetRect.top - containerRect.top,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [
|
||||
projectId,
|
||||
scrollContainerRef,
|
||||
scrollToTurnRequest,
|
||||
setScrollToTurnRequest,
|
||||
visibleNodes,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative z-10 w-full"
|
||||
|
|
|
|||
|
|
@ -254,63 +254,6 @@ export const ProjectChatContainer: React.FC<ProjectChatContainerProps> = ({
|
|||
};
|
||||
}, [chatStores, scrollContainerRef]);
|
||||
|
||||
// Turn viewport observer — updates which turn is visible in the side panel tabs
|
||||
const setSidePanelViewedTurn = usePageTabStore(
|
||||
(s) => s.setSidePanelViewedTurn
|
||||
);
|
||||
const turnObserverRef = useRef<IntersectionObserver | null>(null);
|
||||
const visibleTurnScoresRef = useRef(new Map<string, number>());
|
||||
const turnIdsKey = taskSections.map(({ taskId }) => taskId).join('|');
|
||||
|
||||
useEffect(() => {
|
||||
const root = scrollContainerRef.current;
|
||||
if (!root || !activeProjectId) return;
|
||||
|
||||
turnObserverRef.current?.disconnect();
|
||||
visibleTurnScoresRef.current.clear();
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const taskId = entry.target.getAttribute('data-turn-id');
|
||||
if (!taskId) continue;
|
||||
if (!entry.isIntersecting) {
|
||||
visibleTurnScoresRef.current.delete(taskId);
|
||||
continue;
|
||||
}
|
||||
const visibleHeight = entry.intersectionRect.height;
|
||||
const availableHeight = Math.min(
|
||||
entry.boundingClientRect.height,
|
||||
root.clientHeight
|
||||
);
|
||||
visibleTurnScoresRef.current.set(
|
||||
taskId,
|
||||
availableHeight > 0 ? visibleHeight / availableHeight : 0
|
||||
);
|
||||
}
|
||||
let bestTaskId: string | null = null;
|
||||
let bestScore = 0;
|
||||
for (const [taskId, score] of visibleTurnScoresRef.current) {
|
||||
if (score > bestScore) {
|
||||
bestTaskId = taskId;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
if (bestTaskId) {
|
||||
setSidePanelViewedTurn(activeProjectId, bestTaskId);
|
||||
}
|
||||
},
|
||||
{ root, threshold: [0, 0.01, 0.25, 0.5, 0.75, 1] }
|
||||
);
|
||||
|
||||
turnObserverRef.current = observer;
|
||||
root
|
||||
.querySelectorAll('[data-turn-id]')
|
||||
.forEach((el) => observer.observe(el));
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [turnIdsKey, scrollContainerRef, activeProjectId, setSidePanelViewedTurn]);
|
||||
|
||||
// Scroll to a specific query group when triggered from the sidebar
|
||||
const scrollToQueryId = usePageTabStore((s) => s.scrollToQueryId);
|
||||
const setScrollToQueryId = usePageTabStore((s) => s.setScrollToQueryId);
|
||||
|
|
@ -332,7 +275,7 @@ export const ProjectChatContainer: React.FC<ProjectChatContainerProps> = ({
|
|||
setScrollToQueryId(null);
|
||||
}, [scrollToQueryId, setScrollToQueryId, scrollContainerRef]);
|
||||
|
||||
// Scroll to a specific turn when triggered from TurnTabs
|
||||
// Scroll to a historical Run when requested by the Session side panel.
|
||||
const scrollToTurnRequest = usePageTabStore((s) => s.scrollToTurnRequest);
|
||||
const setScrollToTurnRequest = usePageTabStore(
|
||||
(s) => s.setScrollToTurnRequest
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
141
src/components/Session/SidePanel/components/AccordionBox.tsx
Normal file
141
src/components/Session/SidePanel/components/AccordionBox.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import {
|
||||
SessionPanelButton,
|
||||
SessionPanelCollapse,
|
||||
type SessionPanelRowVariant,
|
||||
} from '@/components/Session/SidePanel/sections/primitives';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
const CONTENT_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
||||
const LAYOUT_TRANSITION = {
|
||||
layout: { duration: 0.28, ease: CONTENT_EASE },
|
||||
} as const;
|
||||
|
||||
export type SidePanelAccordionRenderArgs = { open: boolean };
|
||||
|
||||
export type SidePanelAccordionChildren =
|
||||
ReactNode | ((state: SidePanelAccordionRenderArgs) => ReactNode);
|
||||
|
||||
export function SidePanelAccordionBox({
|
||||
title,
|
||||
titleSuffix,
|
||||
headerAction,
|
||||
leading,
|
||||
rowVariant = 'section',
|
||||
contentClassName,
|
||||
collapsedPreview,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
title: string;
|
||||
/** Small adornment rendered right after the title (e.g. count pill). */
|
||||
titleSuffix?: ReactNode;
|
||||
/** Independent action rendered beside the section title (never toggles the accordion). */
|
||||
headerAction?: ReactNode;
|
||||
/** Optional leading icon; main section rows intentionally omit this. */
|
||||
leading?: ReactNode;
|
||||
/** Shared row appearance used by main sections and nested categories. */
|
||||
rowVariant?: SessionPanelRowVariant;
|
||||
contentClassName?: string;
|
||||
/**
|
||||
* Compact content below the header when collapsed (static `children` only;
|
||||
* render-prop children control their own open/closed layout).
|
||||
*/
|
||||
collapsedPreview?: ReactNode;
|
||||
/**
|
||||
* Static: classic accordion — body hidden when closed.
|
||||
* Render prop: body stays in one region; switch layout by `open` (e.g. summary vs full list).
|
||||
*/
|
||||
children: SidePanelAccordionChildren;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const isRenderProp = typeof children === 'function';
|
||||
const dynamicBody = isRenderProp
|
||||
? (children as (s: SidePanelAccordionRenderArgs) => ReactNode)({ open })
|
||||
: null;
|
||||
const stickyHeader = rowVariant === 'section';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout={!shouldReduceMotion}
|
||||
transition={
|
||||
shouldReduceMotion ? { layout: { duration: 0 } } : LAYOUT_TRANSITION
|
||||
}
|
||||
className="z-10 flex min-w-0 shrink-0 flex-col overflow-visible"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 min-h-10 w-full shrink-0 items-center',
|
||||
stickyHeader && 'sticky top-0 z-20 bg-ds-bg-neutral-default-default'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SessionPanelButton
|
||||
variant={rowVariant}
|
||||
leading={leading}
|
||||
badge={titleSuffix}
|
||||
chevron
|
||||
open={open}
|
||||
ariaExpanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{title}
|
||||
</SessionPanelButton>
|
||||
</div>
|
||||
{headerAction ? (
|
||||
<div className="flex h-10 shrink-0 items-center pr-0.5">
|
||||
{headerAction}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isRenderProp ? (
|
||||
<SessionPanelCollapse open={dynamicBody != null}>
|
||||
<motion.div
|
||||
layout={!shouldReduceMotion}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { layout: { duration: 0 } }
|
||||
: LAYOUT_TRANSITION
|
||||
}
|
||||
className={cn('w-full', contentClassName)}
|
||||
>
|
||||
{dynamicBody}
|
||||
</motion.div>
|
||||
</SessionPanelCollapse>
|
||||
) : (
|
||||
<>
|
||||
<SessionPanelCollapse open={open}>
|
||||
<div className={cn('w-full', contentClassName)}>
|
||||
{children as ReactNode}
|
||||
</div>
|
||||
</SessionPanelCollapse>
|
||||
{collapsedPreview ? (
|
||||
<SessionPanelCollapse open={!open}>
|
||||
<div className={cn('w-full', contentClassName)}>
|
||||
{collapsedPreview}
|
||||
</div>
|
||||
</SessionPanelCollapse>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
935
src/components/Session/SidePanel/components/ActivityPanel.tsx
Normal file
935
src/components/Session/SidePanel/components/ActivityPanel.tsx
Normal file
|
|
@ -0,0 +1,935 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import {
|
||||
fetchConnectedProviders,
|
||||
type ConnectorProvider,
|
||||
} from '@/api/connectors';
|
||||
import { uploadFileToBrain } from '@/api/http';
|
||||
import { isWeb } from '@/client/platform';
|
||||
import { SidePanelAccordionBox } from '@/components/Session/SidePanel/components/AccordionBox';
|
||||
import {
|
||||
buildProjectSessionPanelData,
|
||||
isProgressDone,
|
||||
mergeProjectFiles,
|
||||
type ProjectSessionPanelData,
|
||||
type SessionAgentItem,
|
||||
type SessionContextItem,
|
||||
type SessionEnvironmentItem,
|
||||
type SessionFileItem,
|
||||
type SessionProgressItem,
|
||||
type SessionResourceItem,
|
||||
} from '@/components/Session/SidePanel/sections/buildProjectSessionPanelData';
|
||||
import {
|
||||
CountPill,
|
||||
EarlierItems,
|
||||
ProgressCircle,
|
||||
SidePanelListRow,
|
||||
} from '@/components/Session/SidePanel/sections/primitives';
|
||||
import {
|
||||
arrangeSessionPanelItems,
|
||||
selectSessionPanelRuns,
|
||||
type SessionPanelScope,
|
||||
} from '@/components/Session/SidePanel/sections/sessionPanelScope';
|
||||
import {
|
||||
AgentInformationDialog,
|
||||
ToolCallsDialog,
|
||||
} from '@/components/Session/SidePanel/sections/SessionSidePanelDialogs';
|
||||
import { useProjectOutputFiles } from '@/components/Session/SidePanel/sections/useProjectOutputFiles';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TooltipSimple } from '@/components/ui/tooltip';
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import { useProjectEventRuntime } from '@/hooks/useProjectEventRuntime';
|
||||
import { useProjectSessionOverview } from '@/hooks/useProjectSessionOverview';
|
||||
import { useHost } from '@/host';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
|
||||
import { useSkillsStore } from '@/store/skillsStore';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Boxes,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Globe,
|
||||
Hammer,
|
||||
MonitorCog,
|
||||
Plus,
|
||||
SquareTerminal,
|
||||
WandSparkles,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Children,
|
||||
Fragment,
|
||||
startTransition,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const EMPTY_PANEL_DATA: ProjectSessionPanelData = {
|
||||
agents: [],
|
||||
contextItems: [],
|
||||
environments: [],
|
||||
files: [],
|
||||
progress: [],
|
||||
resources: [],
|
||||
toolCalls: [],
|
||||
};
|
||||
const PANEL_REBUILD_INTERVAL_MS = 32;
|
||||
|
||||
function useDeferredProjectSessionPanelData(
|
||||
projectId: string | null,
|
||||
runs: Parameters<typeof buildProjectSessionPanelData>[0],
|
||||
skills: Parameters<typeof buildProjectSessionPanelData>[1],
|
||||
connectors: Parameters<typeof buildProjectSessionPanelData>[2]
|
||||
): ProjectSessionPanelData {
|
||||
const [state, setState] = useState<{
|
||||
data: ProjectSessionPanelData;
|
||||
projectId: string | null;
|
||||
}>({ data: EMPTY_PANEL_DATA, projectId });
|
||||
const latestInputRef = useRef({ connectors, projectId, runs, skills });
|
||||
const pendingRef = useRef<ReturnType<typeof globalThis.setTimeout> | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
latestInputRef.current = { connectors, projectId, runs, skills };
|
||||
if (pendingRef.current !== null) return;
|
||||
pendingRef.current = globalThis.setTimeout(() => {
|
||||
pendingRef.current = null;
|
||||
const latest = latestInputRef.current;
|
||||
const data = buildProjectSessionPanelData(
|
||||
latest.runs,
|
||||
latest.skills,
|
||||
latest.connectors
|
||||
);
|
||||
startTransition(() => setState({ data, projectId: latest.projectId }));
|
||||
}, PANEL_REBUILD_INTERVAL_MS);
|
||||
}, [connectors, projectId, runs, skills]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (pendingRef.current !== null) {
|
||||
globalThis.clearTimeout(pendingRef.current);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return state.projectId === projectId ? state.data : EMPTY_PANEL_DATA;
|
||||
}
|
||||
|
||||
function SimpleRows<T>({
|
||||
items,
|
||||
render,
|
||||
}: {
|
||||
items: T[];
|
||||
render: (item: T) => ReactNode;
|
||||
}) {
|
||||
return <div className="flex min-w-0 flex-col">{items.map(render)}</div>;
|
||||
}
|
||||
|
||||
function SectionList({ children }: { children: ReactNode }) {
|
||||
const sections = Children.toArray(children);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1 py-1">
|
||||
{sections.map((section, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 ? (
|
||||
<div
|
||||
role="separator"
|
||||
className="h-px w-full shrink-0 bg-ds-border-neutral-subtle-disabled"
|
||||
/>
|
||||
) : null}
|
||||
{section}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentCategorySection({
|
||||
title,
|
||||
items,
|
||||
scope,
|
||||
headerAction,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
items: SessionAgentItem[];
|
||||
scope: SessionPanelScope;
|
||||
headerAction?: ReactNode;
|
||||
onSelect: (item: SessionAgentItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { primary, earlier } = arrangeSessionPanelItems(items, scope);
|
||||
const rows = (agentItems: SessionAgentItem[]) => (
|
||||
<SimpleRows
|
||||
items={agentItems}
|
||||
render={(item) => (
|
||||
<SidePanelListRow
|
||||
key={item.id}
|
||||
leading={
|
||||
item.subagent ? (
|
||||
<Boxes size={16} aria-hidden />
|
||||
) : (
|
||||
<Bot size={16} aria-hidden />
|
||||
)
|
||||
}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
{item.name ||
|
||||
(item.subagent
|
||||
? t('layout.session-panel-remote-subagent', {
|
||||
defaultValue: 'Remote subagent',
|
||||
})
|
||||
: t('agents.agent', { defaultValue: 'Agent' }))}
|
||||
</SidePanelListRow>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidePanelAccordionBox
|
||||
title={title}
|
||||
titleSuffix={<CountPill count={primary.length} />}
|
||||
headerAction={headerAction}
|
||||
defaultOpen={false}
|
||||
>
|
||||
{rows(primary)}
|
||||
<EarlierItems count={earlier.length}>{rows(earlier)}</EarlierItems>
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressSection({
|
||||
items,
|
||||
scope,
|
||||
onSelect,
|
||||
}: {
|
||||
items: SessionProgressItem[];
|
||||
scope: SessionPanelScope;
|
||||
onSelect: (item: SessionProgressItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { primary, earlier } = arrangeSessionPanelItems(items, scope, 'source');
|
||||
const rows = (progressItems: SessionProgressItem[]) => (
|
||||
<SimpleRows
|
||||
items={progressItems}
|
||||
render={(item) => (
|
||||
<SidePanelListRow
|
||||
key={item.key}
|
||||
leading={<ProgressCircle done={isProgressDone(item.task)} />}
|
||||
completed={isProgressDone(item.task)}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
{item.task.content}
|
||||
</SidePanelListRow>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidePanelAccordionBox
|
||||
title={t('layout.workforce-progress')}
|
||||
titleSuffix={<CountPill count={primary.length} />}
|
||||
>
|
||||
{rows(primary)}
|
||||
<EarlierItems count={earlier.length}>{rows(earlier)}</EarlierItems>
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextSubcategory({
|
||||
title,
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
items: SessionContextItem[];
|
||||
onSelect: (item: SessionContextItem) => void;
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<SidePanelAccordionBox
|
||||
title={title}
|
||||
titleSuffix={<CountPill count={items.length} />}
|
||||
leading={
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full bg-ds-border-neutral-default-default"
|
||||
aria-hidden
|
||||
/>
|
||||
}
|
||||
rowVariant="subcategory"
|
||||
>
|
||||
<SimpleRows
|
||||
items={items}
|
||||
render={(item) => (
|
||||
<SidePanelListRow
|
||||
key={`${item.category}:${item.id}`}
|
||||
leading={<ContextItemIcon item={item} />}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<span className="!text-body-sm">{item.label}</span>
|
||||
</SidePanelListRow>
|
||||
)}
|
||||
/>
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextSection({
|
||||
items,
|
||||
scope,
|
||||
onSelect,
|
||||
}: {
|
||||
items: SessionContextItem[];
|
||||
scope: SessionPanelScope;
|
||||
onSelect: (item: SessionContextItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { primary, earlier } = arrangeSessionPanelItems(items, scope);
|
||||
const earlierRows = (
|
||||
<SimpleRows
|
||||
items={earlier}
|
||||
render={(item) => (
|
||||
<SidePanelListRow
|
||||
key={`${item.category}:${item.id}`}
|
||||
leading={<ContextItemIcon item={item} />}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<span className="!text-body-sm">{item.label}</span>
|
||||
</SidePanelListRow>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidePanelAccordionBox
|
||||
title={t('layout.execution-context')}
|
||||
titleSuffix={<CountPill count={primary.length} />}
|
||||
>
|
||||
<ContextSubcategory
|
||||
title={t('layout.session-panel-skills', {
|
||||
defaultValue: 'Skills',
|
||||
})}
|
||||
items={primary.filter((item) => item.category === 'skill')}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
<ContextSubcategory
|
||||
title={t('layout.mcp-tools')}
|
||||
items={primary.filter((item) => item.category === 'connector')}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
<EarlierItems count={earlier.length}>{earlierRows}</EarlierItems>
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextItemIcon({ item }: { item: SessionContextItem }) {
|
||||
const [iconFailed, setIconFailed] = useState(false);
|
||||
useEffect(() => setIconFailed(false), [item.iconUrl]);
|
||||
|
||||
if (item.iconUrl && !iconFailed) {
|
||||
return (
|
||||
<img
|
||||
src={item.iconUrl}
|
||||
alt=""
|
||||
className="h-4 w-4 object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setIconFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.icon) return item.icon;
|
||||
return item.category === 'skill' ? (
|
||||
<WandSparkles size={16} aria-hidden />
|
||||
) : (
|
||||
<Hammer size={16} aria-hidden />
|
||||
);
|
||||
}
|
||||
|
||||
function ResourcesSection({
|
||||
items,
|
||||
scope,
|
||||
onSelect,
|
||||
}: {
|
||||
items: SessionResourceItem[];
|
||||
scope: SessionPanelScope;
|
||||
onSelect: (item: SessionResourceItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { primary, earlier } = arrangeSessionPanelItems(items, scope);
|
||||
const rows = (resources: SessionResourceItem[]) => (
|
||||
<SimpleRows
|
||||
items={resources}
|
||||
render={(item) => (
|
||||
<SidePanelListRow
|
||||
key={item.id}
|
||||
leading={
|
||||
item.kind === 'url' ? (
|
||||
<Globe size={16} aria-hidden />
|
||||
) : (
|
||||
<FileText size={16} aria-hidden />
|
||||
)
|
||||
}
|
||||
trailing={
|
||||
item.kind === 'url' ? <ExternalLink size={14} aria-hidden /> : null
|
||||
}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
{item.label}
|
||||
</SidePanelListRow>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<SidePanelAccordionBox
|
||||
title={t('layout.session-panel-resources', {
|
||||
defaultValue: 'Resources',
|
||||
})}
|
||||
titleSuffix={<CountPill count={primary.length} />}
|
||||
defaultOpen={false}
|
||||
>
|
||||
{rows(primary)}
|
||||
<EarlierItems count={earlier.length}>{rows(earlier)}</EarlierItems>
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvironmentIcon({ label }: { label: string }) {
|
||||
if (label === 'Terminal') return <SquareTerminal size={16} aria-hidden />;
|
||||
if (label === 'Browser') return <Globe size={16} aria-hidden />;
|
||||
return <MonitorCog size={16} aria-hidden />;
|
||||
}
|
||||
|
||||
function EnvironmentsSection({
|
||||
items,
|
||||
scope,
|
||||
onSelect,
|
||||
}: {
|
||||
items: SessionEnvironmentItem[];
|
||||
scope: SessionPanelScope;
|
||||
onSelect: (item: SessionEnvironmentItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { primary, earlier } = arrangeSessionPanelItems(items, scope);
|
||||
const rows = (environments: SessionEnvironmentItem[]) => (
|
||||
<SimpleRows
|
||||
items={environments}
|
||||
render={(item) => (
|
||||
<SidePanelListRow
|
||||
key={item.id}
|
||||
leading={<EnvironmentIcon label={item.label} />}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
{item.label}
|
||||
</SidePanelListRow>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidePanelAccordionBox
|
||||
title={t('layout.session-panel-environments', {
|
||||
defaultValue: 'Environments',
|
||||
})}
|
||||
titleSuffix={<CountPill count={primary.length} />}
|
||||
defaultOpen={false}
|
||||
>
|
||||
{rows(primary)}
|
||||
<EarlierItems count={earlier.length}>{rows(earlier)}</EarlierItems>
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
}
|
||||
|
||||
function FilesSection({
|
||||
items,
|
||||
scope,
|
||||
onSelect,
|
||||
headerAction,
|
||||
}: {
|
||||
items: SessionFileItem[];
|
||||
scope: SessionPanelScope;
|
||||
onSelect: (item: SessionFileItem) => void;
|
||||
headerAction?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { primary, earlier } = arrangeSessionPanelItems(items, scope);
|
||||
const rows = (files: SessionFileItem[]) => (
|
||||
<SimpleRows
|
||||
items={files}
|
||||
render={(item) => (
|
||||
<SidePanelListRow
|
||||
key={item.id}
|
||||
leading={<FileText size={16} aria-hidden />}
|
||||
trailing={
|
||||
!item.previewable ? (
|
||||
<span className="text-label-xs text-ds-text-neutral-muted-default">
|
||||
{t('layout.session-panel-file-unavailable', {
|
||||
defaultValue: 'Preview unavailable',
|
||||
})}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
onClick={item.previewable ? () => onSelect(item) : undefined}
|
||||
>
|
||||
{item.file.name || item.file.path}
|
||||
</SidePanelListRow>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<SidePanelAccordionBox
|
||||
title={t('layout.session-panel-files', {
|
||||
defaultValue: 'Files',
|
||||
})}
|
||||
titleSuffix={<CountPill count={primary.length} />}
|
||||
headerAction={headerAction}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className="px-3 py-3 text-body-sm text-ds-text-neutral-muted-default">
|
||||
{t('layout.session-panel-files-empty', {
|
||||
defaultValue: 'No output files yet.',
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{rows(primary)}
|
||||
<EarlierItems count={earlier.length}>{rows(earlier)}</EarlierItems>
|
||||
</>
|
||||
)}
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionActivityPanel({
|
||||
agentHeaderAction,
|
||||
scope,
|
||||
}: {
|
||||
agentHeaderAction?: ReactNode;
|
||||
scope: SessionPanelScope;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHost();
|
||||
const { chatStore } = useChatStoreAdapter();
|
||||
const projectStore = useProjectRuntimeStore();
|
||||
const { hydration, projectId } = useProjectEventRuntime();
|
||||
const overview = useProjectSessionOverview(projectId);
|
||||
const scopedChatStore =
|
||||
projectId && projectStore.activeProjectId === projectId ? chatStore : null;
|
||||
const activeTaskId = scopedChatStore?.activeTaskId ?? null;
|
||||
const composerTaskId =
|
||||
activeTaskId &&
|
||||
(!overview.currentRun || overview.currentRun.runId === activeTaskId)
|
||||
? activeTaskId
|
||||
: null;
|
||||
const activeTask = activeTaskId
|
||||
? scopedChatStore?.tasks[activeTaskId]
|
||||
: undefined;
|
||||
const skills = useSkillsStore((state) => state.skills);
|
||||
const [connectors, setConnectors] = useState<ConnectorProvider[]>([]);
|
||||
const requestTaskBoxFocus = usePageTabStore(
|
||||
(state) => state.requestTaskBoxFocus
|
||||
);
|
||||
const setScrollToTurnRequest = usePageTabStore(
|
||||
(state) => state.setScrollToTurnRequest
|
||||
);
|
||||
const openFilePreview = usePageTabStore((state) => state.openFilePreview);
|
||||
const openBrowserPreview = usePageTabStore(
|
||||
(state) => state.openBrowserPreview
|
||||
);
|
||||
const [selectedAgent, setSelectedAgent] = useState<SessionAgentItem | null>(
|
||||
null
|
||||
);
|
||||
const [selectedContext, setSelectedContext] =
|
||||
useState<SessionContextItem | null>(null);
|
||||
const [addingFiles, setAddingFiles] = useState(false);
|
||||
const addFilesOperationRef = useRef(0);
|
||||
const attachmentScopeRef = useRef({
|
||||
projectId,
|
||||
chatStore: scopedChatStore,
|
||||
taskId: composerTaskId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
attachmentScopeRef.current = {
|
||||
projectId,
|
||||
chatStore: scopedChatStore,
|
||||
taskId: composerTaskId,
|
||||
};
|
||||
}, [composerTaskId, projectId, scopedChatStore]);
|
||||
|
||||
useEffect(() => {
|
||||
addFilesOperationRef.current += 1;
|
||||
setSelectedAgent(null);
|
||||
setSelectedContext(null);
|
||||
setAddingFiles(false);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchConnectedProviders()
|
||||
.then((providers) => {
|
||||
if (!cancelled) setConnectors(providers);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setConnectors([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scopedRuns = useMemo(
|
||||
() => selectSessionPanelRuns(overview.runs, scope),
|
||||
[overview.runs, scope]
|
||||
);
|
||||
const panelData = useDeferredProjectSessionPanelData(
|
||||
projectId,
|
||||
scopedRuns,
|
||||
skills,
|
||||
connectors
|
||||
);
|
||||
const agents = useMemo(
|
||||
() => panelData.agents.filter((agent) => !agent.subagent),
|
||||
[panelData.agents]
|
||||
);
|
||||
const subagents = useMemo(
|
||||
() => panelData.agents.filter((agent) => agent.subagent),
|
||||
[panelData.agents]
|
||||
);
|
||||
// Compatibility boundary: legacy ChatStore file-list changes still trigger
|
||||
// resolver refreshes, but mergeProjectFiles may only enrich durable rows.
|
||||
const projectFiles = useProjectOutputFiles(
|
||||
projectId,
|
||||
activeTask,
|
||||
activeTaskId
|
||||
);
|
||||
const files = useMemo(
|
||||
() => mergeProjectFiles(panelData.files, projectFiles),
|
||||
[panelData.files, projectFiles]
|
||||
);
|
||||
|
||||
const attachToRun = (
|
||||
target: {
|
||||
projectId: string;
|
||||
chatStore: NonNullable<typeof scopedChatStore>;
|
||||
taskId: string;
|
||||
},
|
||||
selectedFiles: File[]
|
||||
) => {
|
||||
const current = attachmentScopeRef.current;
|
||||
if (
|
||||
selectedFiles.length === 0 ||
|
||||
current.projectId !== target.projectId ||
|
||||
current.chatStore !== target.chatStore ||
|
||||
current.taskId !== target.taskId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Read attaches at merge time so files added while the picker was open
|
||||
// are not clobbered.
|
||||
const existingFiles = target.chatStore.tasks[target.taskId]?.attaches ?? [];
|
||||
target.chatStore.setAttaches(target.taskId, [
|
||||
...existingFiles,
|
||||
...selectedFiles.filter(
|
||||
(selected) =>
|
||||
!existingFiles.some(
|
||||
(existing) => existing.filePath === selected.filePath
|
||||
)
|
||||
),
|
||||
]);
|
||||
};
|
||||
|
||||
const addFiles = async () => {
|
||||
if (!projectId || !scopedChatStore || !composerTaskId || addingFiles) {
|
||||
return;
|
||||
}
|
||||
const target = {
|
||||
projectId,
|
||||
chatStore: scopedChatStore,
|
||||
taskId: composerTaskId,
|
||||
};
|
||||
const operationId = ++addFilesOperationRef.current;
|
||||
const isCurrentOperation = () => {
|
||||
const current = attachmentScopeRef.current;
|
||||
return (
|
||||
addFilesOperationRef.current === operationId &&
|
||||
current.projectId === target.projectId &&
|
||||
current.chatStore === target.chatStore &&
|
||||
current.taskId === target.taskId
|
||||
);
|
||||
};
|
||||
|
||||
if (isWeb()) {
|
||||
// A dismissed file dialog has no dependable signal (`cancel` is not
|
||||
// fired everywhere), so the pending flag covers only the upload that
|
||||
// follows an actual selection.
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.onchange = async () => {
|
||||
const picked = Array.from(input.files ?? []);
|
||||
if (picked.length === 0 || !isCurrentOperation()) return;
|
||||
setAddingFiles(true);
|
||||
try {
|
||||
const uploads: File[] = [];
|
||||
for (const file of picked) {
|
||||
if (!isCurrentOperation()) break;
|
||||
try {
|
||||
const result = await uploadFileToBrain(file);
|
||||
if (!isCurrentOperation()) break;
|
||||
uploads.push({
|
||||
fileName: result.filename,
|
||||
filePath: result.file_id,
|
||||
fileId: result.file_id,
|
||||
source: 'upload',
|
||||
} as File);
|
||||
} catch (error) {
|
||||
console.error('Session file upload failed:', error);
|
||||
toast.error(
|
||||
t('layout.session-panel-upload-failed', {
|
||||
defaultValue: 'Failed to upload {{name}}',
|
||||
name: file.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
attachToRun(target, uploads);
|
||||
} finally {
|
||||
if (isCurrentOperation()) setAddingFiles(false);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
return;
|
||||
}
|
||||
|
||||
setAddingFiles(true);
|
||||
try {
|
||||
const result = await host?.electronAPI?.selectFile({
|
||||
title: t('chat.select-file'),
|
||||
filters: [{ name: t('chat.all-files'), extensions: ['*'] }],
|
||||
});
|
||||
if (result?.success && Array.isArray(result.files)) {
|
||||
attachToRun(target, result.files);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Select session files failed:', error);
|
||||
} finally {
|
||||
if (isCurrentOperation()) setAddingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addFilesLabel = t('layout.session-panel-attach-files', {
|
||||
defaultValue: 'Attach files to the current run',
|
||||
});
|
||||
const canAddFiles = Boolean(scopedChatStore && composerTaskId);
|
||||
const addFilesTooltip = canAddFiles
|
||||
? addFilesLabel
|
||||
: t('layout.session-panel-add-files-unavailable', {
|
||||
defaultValue: 'Files can only be attached to the current run input.',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* No `flex-1` anywhere in this chain: each level takes its content
|
||||
height so the panel card hugs, and only shrinks (scrolling here) once
|
||||
the sections outgrow the column. */}
|
||||
<div className="relative flex min-h-0 w-full min-w-0 flex-col overflow-hidden">
|
||||
<div className="scrollbar-always-visible flex min-h-0 min-w-0 flex-col overflow-y-auto overflow-x-hidden">
|
||||
{projectId && hydration.status === 'error' ? (
|
||||
<div className="px-3 pt-3">
|
||||
<Alert tone="warning">
|
||||
<AlertTriangle className="size-4" aria-hidden />
|
||||
<AlertDescription className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
{t(
|
||||
hydration.errorCode === 'unsupported'
|
||||
? 'layout.session-panel-history-unsupported'
|
||||
: 'layout.session-panel-history-unavailable',
|
||||
{
|
||||
defaultValue:
|
||||
hydration.errorCode === 'unsupported'
|
||||
? 'This backend does not support session history yet.'
|
||||
: 'Some session history could not be loaded.',
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
buttonRadius="full"
|
||||
onClick={hydration.retry}
|
||||
>
|
||||
{t('chat.timeline-history-retry', {
|
||||
defaultValue: 'Try again',
|
||||
})}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : projectId &&
|
||||
(hydration.status === 'loading' ||
|
||||
hydration.status === 'retrying') ? (
|
||||
<div
|
||||
className="px-3 pt-3 text-body-sm text-ds-text-neutral-muted-default"
|
||||
role="status"
|
||||
>
|
||||
{t(
|
||||
hydration.status === 'retrying'
|
||||
? 'chat.timeline-history-reconnecting'
|
||||
: 'chat.timeline-history-loading'
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<SectionList>
|
||||
{agents.length > 0 ? (
|
||||
<AgentCategorySection
|
||||
title={t('layout.agents')}
|
||||
items={agents}
|
||||
scope={scope}
|
||||
headerAction={agentHeaderAction}
|
||||
onSelect={setSelectedAgent}
|
||||
/>
|
||||
) : null}
|
||||
{subagents.length > 0 ? (
|
||||
<AgentCategorySection
|
||||
title={t('agents.sub-agents', {
|
||||
defaultValue: 'Sub Agents',
|
||||
})}
|
||||
items={subagents}
|
||||
scope={scope}
|
||||
onSelect={setSelectedAgent}
|
||||
/>
|
||||
) : null}
|
||||
{panelData.progress.length > 0 ? (
|
||||
<ProgressSection
|
||||
items={panelData.progress}
|
||||
scope={scope}
|
||||
onSelect={(item) => {
|
||||
if (!projectId) return;
|
||||
setScrollToTurnRequest({ projectId, taskId: item.taskId });
|
||||
requestTaskBoxFocus(projectId, item.taskId);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{panelData.contextItems.length > 0 ? (
|
||||
<ContextSection
|
||||
items={panelData.contextItems}
|
||||
scope={scope}
|
||||
onSelect={setSelectedContext}
|
||||
/>
|
||||
) : null}
|
||||
{panelData.environments.length > 0 ? (
|
||||
<EnvironmentsSection
|
||||
items={panelData.environments}
|
||||
scope={scope}
|
||||
onSelect={(item) => {
|
||||
if (!projectId) return;
|
||||
setScrollToTurnRequest({
|
||||
projectId,
|
||||
taskId: item.taskId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{panelData.resources.length > 0 ? (
|
||||
<ResourcesSection
|
||||
items={panelData.resources}
|
||||
scope={scope}
|
||||
onSelect={(item) => {
|
||||
if (item.kind === 'url' && item.url) {
|
||||
openBrowserPreview(item.url);
|
||||
} else if (item.file) {
|
||||
openFilePreview(item.file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{projectId ? (
|
||||
<FilesSection
|
||||
items={files}
|
||||
scope={scope}
|
||||
onSelect={(item) => openFilePreview(item.file)}
|
||||
headerAction={
|
||||
<TooltipSimple
|
||||
content={addFilesTooltip}
|
||||
variant="instant"
|
||||
side="bottom"
|
||||
>
|
||||
<span
|
||||
className="inline-flex"
|
||||
tabIndex={!canAddFiles ? 0 : undefined}
|
||||
aria-label={!canAddFiles ? addFilesTooltip : undefined}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
buttonContent="icon-only"
|
||||
buttonRadius="lg"
|
||||
disabled={addingFiles || !canAddFiles}
|
||||
aria-label={addFilesLabel}
|
||||
onClick={() => void addFiles()}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</SectionList>
|
||||
{panelData.agents.length === 0 &&
|
||||
panelData.progress.length === 0 &&
|
||||
panelData.contextItems.length === 0 &&
|
||||
panelData.environments.length === 0 &&
|
||||
panelData.resources.length === 0 &&
|
||||
files.length === 0 &&
|
||||
!projectId ? (
|
||||
<div className="px-3 py-6 text-center text-body-sm text-ds-text-neutral-muted-default">
|
||||
{t('layout.session-activity-empty', {
|
||||
defaultValue:
|
||||
'Session activity will appear here as work begins.',
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AgentInformationDialog
|
||||
agent={selectedAgent}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedAgent(null);
|
||||
}}
|
||||
/>
|
||||
<ToolCallsDialog
|
||||
item={selectedContext}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedContext(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ import Workflow from '@/components/WorkFlow';
|
|||
import WorkforceMenu from '@/components/WorkforceMenu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TooltipSimple } from '@/components/ui/tooltip';
|
||||
import type { SelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import { useHost } from '@/host';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
|
|
@ -29,21 +29,19 @@ import { useTranslation } from 'react-i18next';
|
|||
|
||||
const EDGE_PADDING_PX = 32;
|
||||
|
||||
export interface ExpandedOverlayProps {
|
||||
export interface SidePanelExpandedOverlayProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
workforcePanelKey: string;
|
||||
onToggleSidePanel: () => void;
|
||||
isSidePanelVisible: boolean;
|
||||
selectedTurn: SelectedProjectTurn;
|
||||
}
|
||||
|
||||
function WorkforceOverlayCanvas({
|
||||
selectedTurn,
|
||||
}: {
|
||||
selectedTurn: SelectedProjectTurn;
|
||||
}) {
|
||||
const activeTask = selectedTurn.task;
|
||||
function WorkforceOverlayCanvas() {
|
||||
const { chatStore } = useChatStoreAdapter();
|
||||
const activeTask = chatStore?.activeTaskId
|
||||
? chatStore.tasks[chatStore.activeTaskId]
|
||||
: undefined;
|
||||
const activeWorkSpace = activeTask?.activeWorkspace;
|
||||
|
||||
if (!activeTask || !activeWorkSpace) {
|
||||
|
|
@ -65,7 +63,7 @@ function WorkforceOverlayCanvas({
|
|||
(agent) => agent.agent_id === activeWorkSpace
|
||||
)?.type === 'browser_agent' && (
|
||||
<div className="flex h-full w-full flex-1">
|
||||
<BrowserAgentWorkspace selectedTurn={selectedTurn} />
|
||||
<BrowserAgentWorkspace />
|
||||
</div>
|
||||
)}
|
||||
{activeWorkSpace === 'workflow' && (
|
||||
|
|
@ -82,7 +80,7 @@ function WorkforceOverlayCanvas({
|
|||
(agent) => agent.agent_id === activeWorkSpace
|
||||
)?.type === 'developer_agent' && (
|
||||
<div className="flex h-full w-full flex-1">
|
||||
<TerminalAgentWorkspace selectedTurn={selectedTurn} />
|
||||
<TerminalAgentWorkspace />
|
||||
</div>
|
||||
)}
|
||||
{activeWorkSpace === 'documentWorkSpace' && (
|
||||
|
|
@ -131,8 +129,8 @@ export default function ExpandedOverlay({
|
|||
workforcePanelKey,
|
||||
onToggleSidePanel,
|
||||
isSidePanelVisible,
|
||||
selectedTurn,
|
||||
}: ExpandedOverlayProps) {
|
||||
}: SidePanelExpandedOverlayProps) {
|
||||
const { chatStore } = useChatStoreAdapter();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const { t } = useTranslation();
|
||||
const host = useHost();
|
||||
|
|
@ -145,14 +143,13 @@ export default function ExpandedOverlay({
|
|||
return;
|
||||
}
|
||||
if (workflowResetForOpenRef.current) return;
|
||||
const taskId = selectedTurn.taskId;
|
||||
const selectedChatState = selectedTurn.chatStore?.getState();
|
||||
if (!taskId || !selectedChatState) return;
|
||||
const taskId = chatStore?.activeTaskId;
|
||||
if (!taskId || !chatStore) return;
|
||||
workflowResetForOpenRef.current = true;
|
||||
selectedChatState.setActiveWorkspace(taskId, 'workflow');
|
||||
selectedChatState.setActiveAgent(taskId, '');
|
||||
chatStore.setActiveWorkspace(taskId, 'workflow');
|
||||
chatStore.setActiveAgent(taskId, '');
|
||||
host?.electronAPI?.hideAllWebview?.();
|
||||
}, [open, selectedTurn.chatStore, selectedTurn.taskId, host]);
|
||||
}, [open, chatStore, host]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -253,7 +250,7 @@ export default function ExpandedOverlay({
|
|||
transition={{ duration: 0.2 }}
|
||||
className="h-full w-full min-w-0"
|
||||
>
|
||||
<WorkforceOverlayCanvas selectedTurn={selectedTurn} />
|
||||
<WorkforceOverlayCanvas />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
|
@ -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'
|
||||
|
|
@ -81,6 +81,7 @@ const foldedTaskLogContentVariants = {
|
|||
},
|
||||
};
|
||||
|
||||
/** Legacy workforce detail view retained as a SidePanel implementation detail. */
|
||||
export function AgentDetailPane({
|
||||
agent,
|
||||
onTakeManualFollowControl,
|
||||
|
|
@ -90,7 +90,7 @@ function pickLatestWorkingAgentId(agents: Agent[]): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
export interface FoldedPanelProps {
|
||||
export interface SidePanelFoldedPanelProps {
|
||||
/** When true, do not push global activeWorkspace/activeAgent from the folded rail (expanded overlay shows workflow-only). */
|
||||
pauseAgentWorkspaceSync?: boolean;
|
||||
}
|
||||
|
|
@ -102,7 +102,7 @@ export interface FoldedPanelProps {
|
|||
*/
|
||||
export default function FoldedPanel({
|
||||
pauseAgentWorkspaceSync = false,
|
||||
}: FoldedPanelProps) {
|
||||
}: SidePanelFoldedPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHost();
|
||||
const { chatStore, projectStore } = useChatStoreAdapter();
|
||||
|
|
@ -393,15 +393,15 @@ export default function FoldedPanel({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="bg-ds-bg-neutral-default-default min-h-0 min-w-0 flex h-full w-full flex-col"
|
||||
className="flex h-full min-h-0 w-full min-w-0 flex-col bg-ds-bg-neutral-default-default"
|
||||
data-workforce-folded={isTaskLiveLayout ? 'task-live' : 'initial'}
|
||||
>
|
||||
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{isTaskLiveLayout ? (
|
||||
<motion.div
|
||||
key="task-live"
|
||||
className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden"
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
|
|
@ -409,8 +409,8 @@ export default function FoldedPanel({
|
|||
onPointerDownCapture={onFoldedPanelEngagement}
|
||||
onWheelCapture={onFoldedPanelEngagement}
|
||||
>
|
||||
<div className="scrollbar scrollbar-always-visible py-2 pl-2 pr-2 shrink-0 overflow-x-auto overflow-y-hidden">
|
||||
<div className="gap-2 flex w-max min-w-full flex-row flex-nowrap items-center">
|
||||
<div className="scrollbar scrollbar-always-visible shrink-0 overflow-x-auto overflow-y-hidden py-2 pl-2 pr-2">
|
||||
<div className="flex w-max min-w-full flex-row flex-nowrap items-center gap-2">
|
||||
{sortedAgents.map((agent) => (
|
||||
<div key={agent.agent_id} className="shrink-0">
|
||||
<FoldedAgentCard
|
||||
|
|
@ -435,7 +435,7 @@ export default function FoldedPanel({
|
|||
activeTaskId &&
|
||||
activeTask &&
|
||||
chatStore && (
|
||||
<div className="scrollbar scrollbar-always-visible min-h-0 min-w-0 pb-2 flex h-full shrink-0 flex-col overflow-x-hidden overflow-y-auto">
|
||||
<div className="scrollbar scrollbar-always-visible flex h-full min-h-0 min-w-0 shrink-0 flex-col overflow-y-auto overflow-x-hidden pb-2">
|
||||
<TaskCard
|
||||
key={`task-folded-${activeTaskId}`}
|
||||
chatId={taskPanelChatId}
|
||||
|
|
@ -462,7 +462,7 @@ export default function FoldedPanel({
|
|||
</div>
|
||||
)}
|
||||
{showPlanTaskBox && activeTaskId && activeChatStore ? (
|
||||
<div className="pb-2 shrink-0">
|
||||
<div className="shrink-0 pb-2">
|
||||
<PlanTaskBox
|
||||
chatStore={activeChatStore}
|
||||
taskId={activeTaskId}
|
||||
|
|
@ -470,7 +470,7 @@ export default function FoldedPanel({
|
|||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 min-w-0 px-2 pb-2 flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden px-2 pb-2">
|
||||
{detailAgent ? (
|
||||
<AgentDetailPane
|
||||
agent={detailAgent}
|
||||
|
|
@ -479,7 +479,7 @@ export default function FoldedPanel({
|
|||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-ds-text-neutral-muted-default p-3 text-body-sm">
|
||||
<div className="p-3 text-body-sm text-ds-text-neutral-muted-default">
|
||||
{t('chat.select-agent')}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -488,13 +488,13 @@ export default function FoldedPanel({
|
|||
) : (
|
||||
<motion.div
|
||||
key="initial"
|
||||
className="scrollbar scrollbar-always-visible min-h-0 min-w-0 pl-2 pb-2 pt-1 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
className="scrollbar scrollbar-always-visible min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pb-2 pl-2 pt-1"
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={FOLDED_LAYOUT_TRANSITION}
|
||||
>
|
||||
<div className="gap-2 min-w-0 flex w-full max-w-full flex-col opacity-80">
|
||||
<div className="flex w-full min-w-0 max-w-full flex-col gap-2 opacity-80">
|
||||
{showPlanTaskBox && activeTaskId && activeChatStore ? (
|
||||
<PlanTaskBox
|
||||
chatStore={activeChatStore}
|
||||
|
|
@ -12,46 +12,42 @@
|
|||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { SessionSidePanelFoldButton } from '@/components/Session/SessionSidePanelFoldButton';
|
||||
import { SidePanelFoldButton } from '@/components/Session/SidePanel/components/FoldButton';
|
||||
import type { SessionModeType } from '@/types/constants';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface SessionSidePanelHeaderProps {
|
||||
export interface SidePanelHeaderProps {
|
||||
title: string;
|
||||
mode: SessionModeType;
|
||||
isSidePanelVisible: boolean;
|
||||
onToggle: () => void;
|
||||
/** Optional content rendered immediately after the fold button (left side). */
|
||||
start?: ReactNode;
|
||||
/** Optional right-side content (e.g. workforce expand overlay) */
|
||||
end?: ReactNode;
|
||||
}
|
||||
|
||||
export function SessionSidePanelHeader({
|
||||
export function SidePanelHeader({
|
||||
title,
|
||||
mode,
|
||||
isSidePanelVisible,
|
||||
onToggle,
|
||||
start,
|
||||
end,
|
||||
}: SessionSidePanelHeaderProps) {
|
||||
}: SidePanelHeaderProps) {
|
||||
return (
|
||||
<div className="p-2 min-w-0 relative z-50 flex w-full shrink-0 items-center">
|
||||
<div className="min-w-0 gap-1 flex flex-1 items-center justify-start">
|
||||
<SessionSidePanelFoldButton
|
||||
<div className="relative z-50 flex h-11 min-h-11 w-full min-w-0 shrink-0 items-center px-2">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-start gap-1">
|
||||
<SidePanelFoldButton
|
||||
sessionSidePanelMode={mode}
|
||||
isSidePanelVisible={isSidePanelVisible}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
<span className="text-ds-text-neutral-default-default min-w-0 text-body-md font-semibold max-w-full truncate text-center">
|
||||
<span className="min-w-0 max-w-full truncate text-center text-body-md font-semibold text-ds-text-neutral-default-default">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 gap-1 flex flex-1 items-center justify-end">
|
||||
{start}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1">
|
||||
{end != null ? (
|
||||
<div className="gap-1 flex items-center">{end}</div>
|
||||
<div className="flex items-center gap-1">{end}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -28,7 +28,7 @@ import { ArrowLeft } from 'lucide-react';
|
|||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export { default as SessionWorkspace } from '.';
|
||||
export { default as SessionWorkspace } from '../..';
|
||||
|
||||
type SessionsProps = {
|
||||
className?: string;
|
||||
|
|
@ -95,7 +95,7 @@ export default function Sessions({
|
|||
<span className="truncate">{t('layout.sessions-full-title')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="m-0 mx-auto flex min-h-0 w-full max-w-[800px] flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
<div className="scrollbar-always-visible m-0 mx-auto flex min-h-0 w-full max-w-[800px] flex-1 flex-col gap-0.5 overflow-y-auto p-2">
|
||||
{sessions.length === 0 ? (
|
||||
<p className="m-0 px-3 py-6 text-center text-body-sm text-ds-text-neutral-muted-default">
|
||||
{t('layout.sessions-create-task-hint')}
|
||||
|
|
@ -17,15 +17,15 @@ import { TooltipSimple } from '@/components/ui/tooltip';
|
|||
import { Maximize, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface WorkforceSidePanelHeaderEndProps {
|
||||
export interface WorkforceHeaderActionProps {
|
||||
isExpandedOverlayOpen: boolean;
|
||||
onToggleExpandedOverlay: () => void;
|
||||
}
|
||||
|
||||
export function WorkforceSidePanelHeaderEnd({
|
||||
export function WorkforceHeaderAction({
|
||||
isExpandedOverlayOpen,
|
||||
onToggleExpandedOverlay,
|
||||
}: WorkforceSidePanelHeaderEndProps) {
|
||||
}: WorkforceHeaderActionProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipSimple
|
||||
|
|
@ -12,22 +12,29 @@
|
|||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { SessionSidePanelHeader } from '@/components/Session/SessionSidePanelHeader';
|
||||
import { SingleAgentSidePanel } from '@/components/Session/SingleAgent/SingleAgentSidePanel';
|
||||
import { TurnTabs } from '@/components/Session/TurnTabs';
|
||||
import { WorkforceSidePanel } from '@/components/Session/Workforce/WorkforceSidePanel';
|
||||
import { WorkforceSidePanelHeaderEnd } from '@/components/Session/Workforce/WorkforceSidePanelHeaderEnd';
|
||||
import { SESSION_SIDE_PANEL_CONTENT_WIDTH_CLASS } from '@/components/Session/sessionSidePanelLayout';
|
||||
import { SessionActivityPanel } from '@/components/Session/SidePanel/components/ActivityPanel';
|
||||
import ExpandedOverlay from '@/components/Session/SidePanel/components/ExpandedOverlay';
|
||||
import { SidePanelHeader } from '@/components/Session/SidePanel/components/Header';
|
||||
import { WorkforceHeaderAction } from '@/components/Session/SidePanel/components/WorkforceHeaderAction';
|
||||
import { SESSION_SIDE_PANEL_CONTENT_WIDTH_CLASS } from '@/components/Session/SidePanel/layout';
|
||||
import type { SessionPanelScope } from '@/components/Session/SidePanel/sections/sessionPanelScope';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { TooltipSimple } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SessionMode, type SessionModeType } from '@/types/constants';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface SessionSidePanelProps {
|
||||
mode: SessionModeType;
|
||||
workforcePanelKey: string;
|
||||
hasAnyMessages: boolean;
|
||||
isSidePanelVisible: boolean;
|
||||
onToggleSidePanel: () => void;
|
||||
isExpandedOverlayOpen: boolean;
|
||||
|
|
@ -38,7 +45,6 @@ export interface SessionSidePanelProps {
|
|||
export function SessionSidePanel({
|
||||
mode,
|
||||
workforcePanelKey,
|
||||
hasAnyMessages,
|
||||
isSidePanelVisible,
|
||||
onToggleSidePanel,
|
||||
isExpandedOverlayOpen,
|
||||
|
|
@ -47,20 +53,24 @@ export function SessionSidePanel({
|
|||
}: SessionSidePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const isFolded = !isSidePanelVisible;
|
||||
const [scope, setScope] = useState<SessionPanelScope>('latest');
|
||||
|
||||
const headerTitle =
|
||||
mode === SessionMode.WORKFORCE
|
||||
? t('layout.aiWorkforce')
|
||||
: t('layout.workspace-session-single-agent');
|
||||
const headerTitle = t('layout.session-summary', {
|
||||
defaultValue: 'Summary',
|
||||
});
|
||||
const scopeLabel = t('layout.session-summary-scope', {
|
||||
defaultValue: 'Summary content',
|
||||
});
|
||||
const latestOnlyLabel = t('layout.session-summary-latest-only', {
|
||||
defaultValue: 'Latest only',
|
||||
});
|
||||
const allLabel = t('layout.session-summary-all', {
|
||||
defaultValue: 'All',
|
||||
});
|
||||
|
||||
const expandFoldedTooltip =
|
||||
mode === SessionMode.WORKFORCE
|
||||
? t('layout.show-workforce-panel', {
|
||||
defaultValue: 'Show workforce panel',
|
||||
})
|
||||
: t('layout.show-side-panel', {
|
||||
defaultValue: 'Show side panel',
|
||||
});
|
||||
const expandFoldedTooltip = t('layout.show-side-panel', {
|
||||
defaultValue: 'Show side panel',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="group relative h-full min-h-0 w-full overflow-hidden">
|
||||
|
|
@ -73,37 +83,62 @@ export function SessionSidePanel({
|
|||
'pointer-events-none opacity-40 transition-opacity duration-200 group-hover:opacity-80'
|
||||
)}
|
||||
>
|
||||
<SessionSidePanelHeader
|
||||
<SidePanelHeader
|
||||
title={headerTitle}
|
||||
mode={mode}
|
||||
isSidePanelVisible={isSidePanelVisible}
|
||||
onToggle={onToggleSidePanel}
|
||||
start={<TurnTabs />}
|
||||
end={
|
||||
mode === SessionMode.WORKFORCE ? (
|
||||
<WorkforceSidePanelHeaderEnd
|
||||
isExpandedOverlayOpen={isExpandedOverlayOpen}
|
||||
onToggleExpandedOverlay={onToggleExpandedOverlay}
|
||||
/>
|
||||
) : null
|
||||
<Select
|
||||
value={scope}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'latest' || value === 'all') setScope(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
aria-label={scopeLabel}
|
||||
wrapperClassName="w-fit max-w-full"
|
||||
className="h-7 w-auto justify-center gap-0 rounded-lg border-transparent px-2 !text-body-xs [&>svg]:hidden"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="latest">{latestOnlyLabel}</SelectItem>
|
||||
<SelectItem value="all">{allLabel}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
|
||||
{mode === SessionMode.WORKFORCE ? (
|
||||
<WorkforceSidePanel
|
||||
workforcePanelKey={workforcePanelKey}
|
||||
hasAnyMessages={hasAnyMessages}
|
||||
isSidePanelVisible={isSidePanelVisible}
|
||||
onToggleSidePanel={onToggleSidePanel}
|
||||
isExpandedOverlayOpen={isExpandedOverlayOpen}
|
||||
onToggleExpandedOverlay={onToggleExpandedOverlay}
|
||||
onCloseExpandedOverlay={onCloseExpandedOverlay}
|
||||
{/* Sizes to its content and only shrinks (then scrolls inside) once the
|
||||
sections outgrow the column. */}
|
||||
<div className="mx-1 mb-1 flex min-h-0 flex-col overflow-hidden rounded-2xl bg-ds-bg-neutral-default-default pl-2">
|
||||
<SessionActivityPanel
|
||||
scope={scope}
|
||||
agentHeaderAction={
|
||||
mode === SessionMode.WORKFORCE ? (
|
||||
<WorkforceHeaderAction
|
||||
isExpandedOverlayOpen={isExpandedOverlayOpen}
|
||||
onToggleExpandedOverlay={onToggleExpandedOverlay}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<SingleAgentSidePanel />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === SessionMode.WORKFORCE ? (
|
||||
<ExpandedOverlay
|
||||
open={isExpandedOverlayOpen}
|
||||
onClose={onCloseExpandedOverlay}
|
||||
workforcePanelKey={workforcePanelKey}
|
||||
onToggleSidePanel={onToggleSidePanel}
|
||||
isSidePanelVisible={isSidePanelVisible}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isFolded && (
|
||||
<TooltipSimple
|
||||
content={expandFoldedTooltip}
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
/** Full logical width of the session side panel content (clipped by the outer when folded to 40px). */
|
||||
/** Full logical width of SidePanel content (clipped by the outer when folded to 40px). */
|
||||
export const SESSION_SIDE_PANEL_CONTENT_WIDTH_CLASS =
|
||||
'w-[min(360px,40vw)] max-w-[400px]';
|
||||
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { MarkDown } from '@/components/WorkFlow/MarkDown';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogContentSection,
|
||||
DialogHeader,
|
||||
} from '@/components/ui/dialog';
|
||||
import { getToolkitIcon } from '@/lib/toolkitIcons';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
SessionAgentItem,
|
||||
SessionContextItem,
|
||||
} from './buildProjectSessionPanelData';
|
||||
|
||||
/** Dialogs opened from SidePanel list items. */
|
||||
export function AgentInformationDialog({
|
||||
agent,
|
||||
onOpenChange,
|
||||
}: {
|
||||
agent: SessionAgentItem | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const agentName =
|
||||
agent?.name ||
|
||||
(agent?.subagent
|
||||
? t('layout.session-panel-remote-subagent', {
|
||||
defaultValue: 'Remote subagent',
|
||||
})
|
||||
: t('layout.session-panel-agent', { defaultValue: 'Agent' }));
|
||||
const agentDescription =
|
||||
agent?.description ||
|
||||
(agent?.subagent
|
||||
? t('layout.session-panel-subagent-description', {
|
||||
defaultValue:
|
||||
'A delegated agent used for a bounded part of this project.',
|
||||
})
|
||||
: t('layout.session-panel-agent-description', {
|
||||
defaultValue:
|
||||
'A general-purpose agent that plans and completes the project using the available tools.',
|
||||
}));
|
||||
|
||||
return (
|
||||
<Dialog open={agent != null} onOpenChange={onOpenChange}>
|
||||
<DialogContent size="sm" overlayVariant="dimmed">
|
||||
<DialogHeader
|
||||
title={agentName}
|
||||
subtitle={
|
||||
agent?.subagent
|
||||
? t('layout.session-panel-subagent', {
|
||||
defaultValue: 'Subagent',
|
||||
})
|
||||
: t('layout.session-panel-agent', { defaultValue: 'Agent' })
|
||||
}
|
||||
/>
|
||||
<DialogContentSection className="scrollbar-always-visible overflow-y-auto">
|
||||
{agent ? (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-ds-bg-neutral-muted-default">
|
||||
<Bot
|
||||
size={20}
|
||||
className="text-ds-icon-neutral-default-default"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
<p className="m-0 text-body-sm text-ds-text-neutral-default-default">
|
||||
{agentDescription}
|
||||
</p>
|
||||
</div>
|
||||
{agent.tools.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-label-xs font-semibold uppercase tracking-wide text-ds-text-neutral-muted-default">
|
||||
{t('layout.capabilities')}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{agent.tools.map((tool) => (
|
||||
<span
|
||||
key={tool}
|
||||
className="rounded-md bg-ds-bg-neutral-muted-default px-2 py-1 text-label-xs text-ds-text-neutral-default-default"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContentSection>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolCallsDialog({
|
||||
item,
|
||||
onOpenChange,
|
||||
}: {
|
||||
item: SessionContextItem | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={item != null} onOpenChange={onOpenChange}>
|
||||
<DialogContent size="lg" overlayVariant="dimmed">
|
||||
<DialogHeader
|
||||
title={
|
||||
item?.label ??
|
||||
t('layout.session-panel-tool-calls', {
|
||||
defaultValue: 'Tool calls',
|
||||
})
|
||||
}
|
||||
subtitle={t('layout.session-panel-tool-history', {
|
||||
defaultValue: 'Request and response history',
|
||||
})}
|
||||
/>
|
||||
<DialogContentSection className="scrollbar min-h-0 overflow-y-auto">
|
||||
{item?.calls.length ? (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{item.calls.map((call, index) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="flex min-w-0 flex-col gap-2 rounded-xl border border-solid border-ds-border-neutral-subtle-disabled bg-ds-bg-neutral-default-default p-3"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex shrink-0 items-center">
|
||||
{getToolkitIcon(call.toolkitName)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-body-sm font-semibold text-ds-text-neutral-default-default">
|
||||
{call.method ||
|
||||
t('layout.session-panel-call', {
|
||||
defaultValue: '{{name}} call {{number}}',
|
||||
name: item.label,
|
||||
number: index + 1,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{call.input ? (
|
||||
<div className="min-w-0 rounded-lg bg-ds-bg-neutral-muted-default p-3">
|
||||
<div className="mb-1 text-label-xs font-medium uppercase tracking-wide text-ds-text-neutral-muted-default">
|
||||
{t('layout.session-panel-request', {
|
||||
defaultValue: 'Request',
|
||||
})}
|
||||
</div>
|
||||
<MarkDown
|
||||
content={call.input}
|
||||
enableTypewriter={false}
|
||||
pTextSize="text-label-xs text-ds-text-neutral-default-default"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{call.output ? (
|
||||
<div className="min-w-0 rounded-lg bg-ds-bg-neutral-muted-default p-3">
|
||||
<div className="mb-1 text-label-xs font-medium uppercase tracking-wide text-ds-text-neutral-muted-default">
|
||||
{t('layout.session-panel-response', {
|
||||
defaultValue: 'Response',
|
||||
})}
|
||||
</div>
|
||||
<MarkDown
|
||||
content={call.output}
|
||||
enableTypewriter={false}
|
||||
pTextSize="text-label-xs text-ds-text-neutral-default-default"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="m-0 text-body-sm text-ds-text-neutral-muted-default">
|
||||
{t('layout.session-panel-no-call-details', {
|
||||
defaultValue:
|
||||
'No stored request or response details are available.',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</DialogContentSection>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
247
src/components/Session/SidePanel/sections/buildContextItems.ts
Normal file
247
src/components/Session/SidePanel/sections/buildContextItems.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type ContextCategory = 'skill' | 'connector' | 'file';
|
||||
|
||||
export interface ContextItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
iconUrl?: string;
|
||||
category: ContextCategory;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal shape this builder needs from a skill record. Mirrors the
|
||||
* skillsStore's `Skill` interface but kept local so this module doesn't
|
||||
* depend on the Zustand store.
|
||||
*/
|
||||
export interface ContextSkill {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
scope?: { isGlobal?: boolean; selectedAgents?: string[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected-provider fields needed to replace raw `MCPToolkit` runtime names
|
||||
* with the identity shown in the Open Connectors UI.
|
||||
*/
|
||||
export interface ContextConnector {
|
||||
service: string;
|
||||
displayName?: string;
|
||||
iconUrl?: string | null;
|
||||
connection?: { connectionName?: string } | null;
|
||||
actions?: Array<{ id?: string; name?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a toolkit/server/skill name for dedup and hint-matching.
|
||||
* Lowercases, strips whitespace/underscores/hyphens, and drops a trailing
|
||||
* "toolkit" so e.g. "Google Calendar Toolkit", "google_calendar", and
|
||||
* "google-calendar" all collapse to the same key.
|
||||
*/
|
||||
/** Normalize provider and toolkit names for SidePanel aggregation. */
|
||||
export function normalizeContextKey(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+toolkit\s*$/i, '')
|
||||
.replace(/[\s_-]+/g, '');
|
||||
}
|
||||
|
||||
function normalizeConnectorIdentity(name: string): string {
|
||||
const normalized = normalizeContextKey(name)
|
||||
.replace(/toolkit$/i, '')
|
||||
.replace(/mcp$/i, '')
|
||||
.replace(/connector$/i, '');
|
||||
return normalized === 'connectorgateway' ? '' : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* True only for the Open Connectors gateway itself. Every gateway call is by
|
||||
* definition a connected provider, so a lone connector can be assumed. A bare
|
||||
* `MCPToolkit` also normalizes to an empty identity but may come from any MCP
|
||||
* server configured outside Open Connectors, so it must not be assumed.
|
||||
*/
|
||||
function isConnectorGatewayName(name: string): boolean {
|
||||
return normalizeContextKey(name) === 'connectorgateway';
|
||||
}
|
||||
|
||||
function connectorAliases(connector: ContextConnector): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[
|
||||
connector.service,
|
||||
connector.displayName,
|
||||
connector.connection?.connectionName,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value?.trim()))
|
||||
.map(normalizeConnectorIdentity)
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function connectorMatchScore(
|
||||
connector: ContextConnector,
|
||||
toolkitKey: string,
|
||||
methodKey: string,
|
||||
messageKey: string
|
||||
): number {
|
||||
const aliases = connectorAliases(connector);
|
||||
let score = 0;
|
||||
|
||||
for (const alias of aliases) {
|
||||
if (toolkitKey && toolkitKey === alias) score = Math.max(score, 100);
|
||||
if (
|
||||
toolkitKey &&
|
||||
alias.length >= 3 &&
|
||||
(toolkitKey.includes(alias) || alias.includes(toolkitKey))
|
||||
) {
|
||||
score = Math.max(score, 90);
|
||||
}
|
||||
if (alias.length >= 3 && methodKey.includes(alias)) {
|
||||
score = Math.max(score, 80);
|
||||
}
|
||||
if (alias.length >= 4 && messageKey.includes(alias)) {
|
||||
score = Math.max(score, 50);
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of connector.actions ?? []) {
|
||||
for (const raw of [action.id, action.name]) {
|
||||
if (!raw) continue;
|
||||
const actionKey = normalizeContextKey(raw);
|
||||
if (!actionKey || !methodKey) continue;
|
||||
if (actionKey === methodKey) {
|
||||
score = Math.max(score, 70);
|
||||
} else if (
|
||||
actionKey.length >= 4 &&
|
||||
(actionKey.includes(methodKey) || methodKey.includes(actionKey))
|
||||
) {
|
||||
score = Math.max(score, 60);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a runtime MCP call to a connected Open Connector provider. Generic
|
||||
* `MCPToolkit` calls are identified by their method/action or request payload.
|
||||
* Ambiguous matches deliberately stay generic instead of displaying the wrong
|
||||
* provider.
|
||||
*/
|
||||
export function resolveContextConnector(
|
||||
toolkitName: string,
|
||||
method: string,
|
||||
message: string,
|
||||
connectors: ContextConnector[]
|
||||
): ContextConnector | null {
|
||||
// Normalize the potentially large display detail once per call, rather
|
||||
// than once for every configured connector candidate.
|
||||
const toolkitKey = normalizeConnectorIdentity(toolkitName);
|
||||
const methodKey = normalizeContextKey(method);
|
||||
const messageKey = normalizeContextKey(message.slice(0, 2_000));
|
||||
const ranked = connectors
|
||||
.map((connector) => ({
|
||||
connector,
|
||||
score: connectorMatchScore(connector, toolkitKey, methodKey, messageKey),
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
const best = ranked[0];
|
||||
if (best && best.score > 0 && best.score > (ranked[1]?.score ?? 0)) {
|
||||
return best.connector;
|
||||
}
|
||||
|
||||
return isConnectorGatewayName(toolkitName) && connectors.length === 1
|
||||
? connectors[0]!
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull skill name(s) out of a `SkillToolkit.load_skill(...)` args string.
|
||||
*
|
||||
* Two emission paths produce two formats:
|
||||
* 1. **Agent path** (`listen_chat_agent._aexecute_tool` /
|
||||
* `_execute_tool`) emits `message = json.dumps(args)`, e.g.
|
||||
* `{"name":"pdf"}` or `{"name":["pdf","foo"]}`. This is what
|
||||
* SkillToolkit currently goes through because its `load_skill` /
|
||||
* `list_skills` methods aren't `@listen_toolkit`-decorated.
|
||||
* 2. **`@listen_toolkit` path** (other toolkits) emits Python `repr`
|
||||
* formatted args, e.g. `'pdf'` or `name='pdf'` or `['pdf','foo']`.
|
||||
* Kept as a fallback in case SkillToolkit ever gets decorated.
|
||||
*
|
||||
* Once the tool deactivates, chatStore concatenates the activate args and
|
||||
* the deactivate result with a `\n`, so the args are on the first line and
|
||||
* the rest of `message` is the skill body. Backend may also append a
|
||||
* "(truncated, …)" tail at 500 chars — we strip it.
|
||||
*/
|
||||
export function extractLoadedSkillNames(message: string): string[] {
|
||||
if (!message) return [];
|
||||
|
||||
// Args (if present) sit on the first line — the deactivate result is
|
||||
// appended after a newline. Try the head first, then fall back to the
|
||||
// whole string if the head doesn't yield anything.
|
||||
const head = message.split(/\r?\n/)[0] ?? '';
|
||||
const candidates =
|
||||
head.trim() && head.trim() !== message.trim() ? [head, message] : [message];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const cleaned = candidate
|
||||
.replace(/\.\.\.\s*\(truncated[^)]*\)\s*$/i, '')
|
||||
.trim();
|
||||
if (!cleaned) continue;
|
||||
|
||||
// 1. JSON args from the agent path.
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (parsed && typeof parsed === 'object' && 'name' in parsed) {
|
||||
const name = (parsed as { name: unknown }).name;
|
||||
if (Array.isArray(name)) {
|
||||
const items = name
|
||||
.filter((n): n is string => typeof n === 'string')
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean);
|
||||
if (items.length) return items;
|
||||
} else if (typeof name === 'string' && name.trim()) {
|
||||
return [name.trim()];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — fall through to repr parsing.
|
||||
}
|
||||
|
||||
// 2. Python-repr fallback (`@listen_toolkit` formatting).
|
||||
const noKw = cleaned.replace(/^\s*name\s*=\s*/i, '').trim();
|
||||
if (noKw.startsWith('[')) {
|
||||
const items: string[] = [];
|
||||
const re = /['"]([^'"]+?)['"]/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(noKw)) !== null) {
|
||||
const v = m[1].trim();
|
||||
if (v) items.push(v);
|
||||
}
|
||||
if (items.length) return items;
|
||||
}
|
||||
const quoted = noKw.match(/^['"]([^'"]+?)['"]/);
|
||||
if (quoted) return [quoted[1].trim()];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
|
@ -0,0 +1,901 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import type { ProjectSessionRun } from '@/hooks/useProjectSessionOverview';
|
||||
import type {
|
||||
ChatActivityNode,
|
||||
ChatPlanTaskStatus,
|
||||
ChatProjectionNode,
|
||||
} from '@/lib/projector/chat';
|
||||
import { httpUrlOrNull } from '@/lib/richText';
|
||||
import { normalizeWorkspaceRelativePath } from '@/lib/workspaceRelativePath';
|
||||
import { TaskStatus, type TaskStatusType } from '@/types/constants';
|
||||
import {
|
||||
extractLoadedSkillNames,
|
||||
normalizeContextKey,
|
||||
resolveContextConnector,
|
||||
type ContextCategory,
|
||||
type ContextConnector,
|
||||
type ContextItem,
|
||||
type ContextSkill,
|
||||
} from './buildContextItems';
|
||||
|
||||
export interface SessionProgressItem {
|
||||
key: string;
|
||||
task: TaskInfo;
|
||||
taskId: string;
|
||||
historical: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SessionAgentItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
tools: string[];
|
||||
historical: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
subagent: boolean;
|
||||
}
|
||||
|
||||
export interface SessionToolCall {
|
||||
id: string;
|
||||
toolkitName: string;
|
||||
method: string;
|
||||
/** Safe semantic detail only; raw durable payloads never enter this model. */
|
||||
input: string;
|
||||
/** Safe semantic detail only; raw durable payloads never enter this model. */
|
||||
output: string;
|
||||
status: 'running' | 'done';
|
||||
taskId: string;
|
||||
agentName: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
skillNames: string[];
|
||||
}
|
||||
|
||||
export interface SessionContextItem extends Omit<ContextItem, 'onClick'> {
|
||||
historical: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
calls: SessionToolCall[];
|
||||
}
|
||||
|
||||
export interface SessionResourceItem {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: 'url' | 'file';
|
||||
url?: string;
|
||||
file?: FileInfo;
|
||||
taskId: string;
|
||||
historical: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SessionFileItem {
|
||||
id: string;
|
||||
file: FileInfo;
|
||||
/** True only after a workspace resolver has supplied an openable location. */
|
||||
previewable: boolean;
|
||||
taskId: string;
|
||||
historical: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SessionEnvironmentItem {
|
||||
id: string;
|
||||
label: string;
|
||||
taskId: string;
|
||||
historical: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** Project-wide data consumed by the locked SidePanel UI. */
|
||||
export interface ProjectSessionPanelData {
|
||||
agents: SessionAgentItem[];
|
||||
contextItems: SessionContextItem[];
|
||||
environments: SessionEnvironmentItem[];
|
||||
files: SessionFileItem[];
|
||||
progress: SessionProgressItem[];
|
||||
resources: SessionResourceItem[];
|
||||
toolCalls: SessionToolCall[];
|
||||
}
|
||||
|
||||
const ACTIVE_TOOL_STATUSES = new Set(['pending', 'running']);
|
||||
const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
|
||||
const SKILL_LOAD_METHOD = 'load skill';
|
||||
const SKILL_LIST_METHOD = 'list skills';
|
||||
const TOOL_IDENTITY_CACHE = new WeakMap<ChatActivityNode, string>();
|
||||
const NODE_HTTP_URL_CACHE = new WeakMap<ChatProjectionNode, string[]>();
|
||||
|
||||
function normalizedMethodName(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function skillOperation(
|
||||
call: Pick<SessionToolCall, 'method' | 'toolkitName'>
|
||||
): 'load' | 'list' | null {
|
||||
const method = normalizedMethodName(call.method);
|
||||
if (method === SKILL_LOAD_METHOD) return 'load';
|
||||
if (method === SKILL_LIST_METHOD) return 'list';
|
||||
|
||||
// Canonical tool lifecycle events currently expose tool_name as both the
|
||||
// toolkit and method when no toolkit metadata is available.
|
||||
const toolkitOperation = normalizedMethodName(call.toolkitName);
|
||||
if (toolkitOperation === SKILL_LOAD_METHOD) return 'load';
|
||||
if (toolkitOperation === SKILL_LIST_METHOD) return 'list';
|
||||
return null;
|
||||
}
|
||||
|
||||
function nodeTime(node: ChatProjectionNode, fallback = 0): number {
|
||||
const parsed = node.createdAt ? Date.parse(node.createdAt) : Number.NaN;
|
||||
return Number.isFinite(parsed) ? parsed : fallback + node.runSequence;
|
||||
}
|
||||
|
||||
function normalizedToolIdentity(node: ChatActivityNode): string {
|
||||
const cached = TOOL_IDENTITY_CACHE.get(node);
|
||||
if (cached) return cached;
|
||||
const identity = JSON.stringify([
|
||||
node.runId,
|
||||
normalizeContextKey(node.agentId || node.agentName || ''),
|
||||
normalizeContextKey(node.toolkitName || node.toolName || 'tool'),
|
||||
normalizeContextKey(node.methodName || node.toolName || node.title),
|
||||
]);
|
||||
TOOL_IDENTITY_CACHE.set(node, identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
function safeToolDetail(node: ChatActivityNode): string {
|
||||
if (node.detail?.trim()) return node.detail.trim();
|
||||
// Legacy toolkit frames route their already-approved display text through
|
||||
// the semantic title. Typed durable events never take this compatibility
|
||||
// path and still require explicit display_detail/display_summary fields.
|
||||
if (
|
||||
node.legacyStep === 'activate_toolkit' ||
|
||||
node.legacyStep === 'deactivate_toolkit'
|
||||
) {
|
||||
return node.title.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function toolCallFromNode(node: ChatActivityNode): SessionToolCall {
|
||||
const active = ACTIVE_TOOL_STATUSES.has(node.status);
|
||||
const detail = safeToolDetail(node);
|
||||
return {
|
||||
id: node.toolCallId || `tool-call:${node.eventId}`,
|
||||
toolkitName: node.toolkitName?.trim() || node.toolName?.trim() || 'Tool',
|
||||
method:
|
||||
node.methodName?.trim() || node.toolName?.trim() || node.title.trim(),
|
||||
input: active ? detail : '',
|
||||
output: active ? '' : detail,
|
||||
status: active ? 'running' : 'done',
|
||||
taskId: node.runId,
|
||||
agentName: node.agentName?.trim() || '',
|
||||
createdAt: nodeTime(node),
|
||||
updatedAt: nodeTime(node),
|
||||
skillNames: [],
|
||||
};
|
||||
}
|
||||
|
||||
function mergeToolNode(call: SessionToolCall, node: ChatActivityNode): void {
|
||||
const detail = safeToolDetail(node);
|
||||
const active = ACTIVE_TOOL_STATUSES.has(node.status);
|
||||
if (detail) {
|
||||
if (active && !call.input) call.input = detail;
|
||||
if (!active) {
|
||||
call.output = [call.output, detail].filter(Boolean).join('\n\n');
|
||||
}
|
||||
}
|
||||
call.updatedAt = Math.max(call.updatedAt, nodeTime(node));
|
||||
if (!active) call.status = 'done';
|
||||
if (!call.agentName && node.agentName) call.agentName = node.agentName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold immutable semantic tool lifecycle nodes into logical calls. Explicit
|
||||
* backend call IDs win; older legacy frames use FIFO pairing per identity.
|
||||
*/
|
||||
export function collectSessionToolCalls(
|
||||
runs: ProjectSessionRun[]
|
||||
): SessionToolCall[] {
|
||||
const calls: SessionToolCall[] = [];
|
||||
const byCallId = new Map<string, SessionToolCall>();
|
||||
const anonymousOpen = new Map<string, SessionToolCall[]>();
|
||||
|
||||
for (const run of [...runs].reverse()) {
|
||||
for (const node of run.nodes) {
|
||||
if (node.kind !== 'activity' || node.activityType !== 'tool') continue;
|
||||
const identity = normalizedToolIdentity(node);
|
||||
|
||||
if (node.toolCallId) {
|
||||
const callKey = `${node.runId}:${node.toolCallId}`;
|
||||
const existing = byCallId.get(callKey);
|
||||
if (existing) {
|
||||
mergeToolNode(existing, node);
|
||||
} else {
|
||||
const call = toolCallFromNode(node);
|
||||
byCallId.set(callKey, call);
|
||||
calls.push(call);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ACTIVE_TOOL_STATUSES.has(node.status)) {
|
||||
const call = toolCallFromNode(node);
|
||||
calls.push(call);
|
||||
const pending = anonymousOpen.get(identity) ?? [];
|
||||
pending.push(call);
|
||||
anonymousOpen.set(identity, pending);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pending = anonymousOpen.get(identity) ?? [];
|
||||
const call = TERMINAL_TOOL_STATUSES.has(node.status)
|
||||
? pending.shift()
|
||||
: undefined;
|
||||
if (call) {
|
||||
mergeToolNode(call, node);
|
||||
} else {
|
||||
calls.push(toolCallFromNode(node));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const call of calls) {
|
||||
if (skillOperation(call) === 'load') {
|
||||
call.skillNames = extractLoadedSkillNames(
|
||||
[call.input, call.output].filter(Boolean).join('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
return calls.sort(
|
||||
(left, right) =>
|
||||
left.createdAt - right.createdAt || left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function collectAgents(
|
||||
runs: ProjectSessionRun[],
|
||||
calls: SessionToolCall[]
|
||||
): SessionAgentItem[] {
|
||||
const agents = new Map<string, SessionAgentItem>();
|
||||
const isInternalAgent = (name: string) =>
|
||||
normalizeContextKey(name) === 'questionconfirmagent';
|
||||
const put = (
|
||||
run: ProjectSessionRun,
|
||||
identity: string,
|
||||
name: string,
|
||||
description: string,
|
||||
subagent: boolean
|
||||
) => {
|
||||
const key = `${subagent ? 'subagent' : 'agent'}:${normalizeContextKey(
|
||||
identity || name || 'agent'
|
||||
)}`;
|
||||
const existing = agents.get(key);
|
||||
if (!existing) {
|
||||
agents.set(key, {
|
||||
id: key,
|
||||
name,
|
||||
type: subagent ? 'subagent' : 'agent',
|
||||
description,
|
||||
tools: [],
|
||||
historical: !run.isCurrent,
|
||||
createdAt: run.createdAt,
|
||||
updatedAt: run.updatedAt,
|
||||
subagent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!existing.name && name) existing.name = name;
|
||||
if (!existing.description && description)
|
||||
existing.description = description;
|
||||
if (run.isCurrent) existing.historical = false;
|
||||
existing.createdAt = Math.max(existing.createdAt, run.createdAt);
|
||||
existing.updatedAt = Math.max(existing.updatedAt, run.updatedAt);
|
||||
};
|
||||
|
||||
for (const run of runs) {
|
||||
for (const node of run.nodes) {
|
||||
if (node.kind !== 'activity') continue;
|
||||
if (node.activityType === 'agent') {
|
||||
const name = node.agentName || node.title;
|
||||
if (isInternalAgent(name)) continue;
|
||||
const text = `${node.eventType} ${node.title} ${name}`.toLowerCase();
|
||||
const subagent = /sub.?agent|remote/.test(text);
|
||||
put(
|
||||
run,
|
||||
// Agent UUIDs are process instances and change on every Run. The
|
||||
// stable semantic name identifies the logical agent in the panel.
|
||||
name || node.agentId || 'agent',
|
||||
name,
|
||||
node.detail || '',
|
||||
subagent
|
||||
);
|
||||
} else if (node.agentId || node.agentName) {
|
||||
if (isInternalAgent(node.agentName || '')) continue;
|
||||
put(
|
||||
run,
|
||||
node.agentName || node.agentId || 'agent',
|
||||
node.agentName || '',
|
||||
'',
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const call of calls) {
|
||||
const run = runs.find((candidate) => candidate.runId === call.taskId);
|
||||
if (!run) continue;
|
||||
const callText = `${call.toolkitName} ${call.method}`.toLowerCase();
|
||||
const subagent = /sub.?agent|remote/.test(callText);
|
||||
// Typed tool lifecycle events may not carry agent identity. They are not
|
||||
// independent agents and must not create a phantom "Remote subagent" row.
|
||||
if (!call.agentName && !subagent) continue;
|
||||
if (isInternalAgent(call.agentName)) continue;
|
||||
const identity = call.agentName || 'remote-subagent';
|
||||
put(run, identity, call.agentName, '', subagent);
|
||||
const key = `${subagent ? 'subagent' : 'agent'}:${normalizeContextKey(
|
||||
identity
|
||||
)}`;
|
||||
const agent = agents.get(key);
|
||||
if (agent && !agent.tools.includes(call.toolkitName)) {
|
||||
agent.tools.push(call.toolkitName);
|
||||
}
|
||||
}
|
||||
|
||||
return [...agents.values()].sort(
|
||||
(left, right) =>
|
||||
Number(left.historical) - Number(right.historical) ||
|
||||
Number(left.subagent) - Number(right.subagent) ||
|
||||
left.name.localeCompare(right.name)
|
||||
);
|
||||
}
|
||||
|
||||
function planStatus(status: ChatPlanTaskStatus): TaskStatusType {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return TaskStatus.COMPLETED;
|
||||
case 'failed':
|
||||
return TaskStatus.FAILED;
|
||||
case 'skipped':
|
||||
return TaskStatus.SKIPPED;
|
||||
case 'blocked':
|
||||
return TaskStatus.BLOCKED;
|
||||
case 'running':
|
||||
return TaskStatus.RUNNING;
|
||||
default:
|
||||
return TaskStatus.WAITING;
|
||||
}
|
||||
}
|
||||
|
||||
function activityTaskStatus(node: ChatActivityNode): TaskStatusType {
|
||||
switch (node.status) {
|
||||
case 'cancelled':
|
||||
return TaskStatus.SKIPPED;
|
||||
case 'timed_out':
|
||||
return TaskStatus.FAILED;
|
||||
case 'outcome_unknown':
|
||||
return TaskStatus.BLOCKED;
|
||||
default:
|
||||
return planStatus(node.status);
|
||||
}
|
||||
}
|
||||
|
||||
function isLegacyTodoState(node: ChatProjectionNode): boolean {
|
||||
return (
|
||||
node.kind === 'plan' &&
|
||||
(node.legacyStep === 'todo_state' || node.eventType === 'legacy.todo_state')
|
||||
);
|
||||
}
|
||||
|
||||
function startsTodoWrite(node: ChatProjectionNode): boolean {
|
||||
if (node.kind !== 'activity' || node.activityType !== 'tool') return false;
|
||||
return (
|
||||
ACTIVE_TOOL_STATUSES.has(node.status) &&
|
||||
[node.toolName, node.methodName, node.title].some(
|
||||
(value) => normalizeContextKey(value || '') === 'todowrite'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function collectProgress(runs: ProjectSessionRun[]): SessionProgressItem[] {
|
||||
const progress: SessionProgressItem[] = [];
|
||||
for (const run of runs) {
|
||||
const tasks = new Map<string, SessionProgressItem>();
|
||||
const todoTaskKeys = new Set<string>();
|
||||
let observedTodoWrite = false;
|
||||
for (const node of run.nodes) {
|
||||
const time = nodeTime(node, run.createdAt);
|
||||
if (startsTodoWrite(node)) observedTodoWrite = true;
|
||||
if (node.kind === 'plan') {
|
||||
const todoState = isLegacyTodoState(node);
|
||||
if (todoState) {
|
||||
// todo_state is a full-list replacement emitted by todo_write. Old
|
||||
// backends also emitted workspace-loaded todos at Run startup with
|
||||
// no TodoToolkit call; quarantine those already-durable bad events.
|
||||
if (!observedTodoWrite) continue;
|
||||
// Replace only TodoToolkit progress. Typed plans and task activity
|
||||
// are independent sources and must survive a todo list update.
|
||||
for (const key of todoTaskKeys) tasks.delete(key);
|
||||
todoTaskKeys.clear();
|
||||
observedTodoWrite = false;
|
||||
}
|
||||
for (const task of node.tasks) {
|
||||
const key = task.id || `${node.eventId}:${task.title}`;
|
||||
const mapKey = todoState ? `todo:${key}` : key;
|
||||
const existing = tasks.get(mapKey);
|
||||
tasks.set(mapKey, {
|
||||
key: `${run.runId}:${mapKey}`,
|
||||
task: {
|
||||
id: key,
|
||||
content: task.title,
|
||||
status: planStatus(task.status),
|
||||
},
|
||||
taskId: run.runId,
|
||||
historical: !run.isCurrent,
|
||||
createdAt: existing?.createdAt ?? time,
|
||||
updatedAt: time,
|
||||
});
|
||||
if (todoState) todoTaskKeys.add(mapKey);
|
||||
}
|
||||
}
|
||||
if (node.kind !== 'activity' || node.activityType !== 'task') continue;
|
||||
const key = node.taskId || node.eventId;
|
||||
const existing = tasks.get(key);
|
||||
tasks.set(key, {
|
||||
key: `${run.runId}:${key}`,
|
||||
task: {
|
||||
id: key,
|
||||
content: existing?.task.content || node.detail || node.title,
|
||||
status: activityTaskStatus(node),
|
||||
},
|
||||
taskId: run.runId,
|
||||
historical: !run.isCurrent,
|
||||
createdAt: existing?.createdAt ?? time,
|
||||
updatedAt: time,
|
||||
});
|
||||
}
|
||||
progress.push(...tasks.values());
|
||||
}
|
||||
return progress.sort(
|
||||
(left, right) =>
|
||||
Number(left.historical) - Number(right.historical) ||
|
||||
left.createdAt - right.createdAt
|
||||
);
|
||||
}
|
||||
|
||||
function displaySkillName(name: string, skills: ContextSkill[]): string {
|
||||
const match = skills.find(
|
||||
(skill) => normalizeContextKey(skill.name) === normalizeContextKey(name)
|
||||
);
|
||||
return match?.name || name;
|
||||
}
|
||||
|
||||
function collectContext(
|
||||
runs: ProjectSessionRun[],
|
||||
calls: SessionToolCall[],
|
||||
skills: ContextSkill[],
|
||||
connectors: ContextConnector[]
|
||||
): SessionContextItem[] {
|
||||
const runById = new Map(runs.map((run) => [run.runId, run]));
|
||||
const items = new Map<string, SessionContextItem>();
|
||||
|
||||
const put = (
|
||||
category: Exclude<ContextCategory, 'file'>,
|
||||
label: string,
|
||||
iconUrl: string | undefined,
|
||||
call: SessionToolCall
|
||||
) => {
|
||||
const run = runById.get(call.taskId);
|
||||
if (!run || !label.trim()) return;
|
||||
const key = `${category}:${normalizeContextKey(label)}`;
|
||||
const existing = items.get(key);
|
||||
if (existing) {
|
||||
if (!existing.calls.some((candidate) => candidate.id === call.id)) {
|
||||
existing.calls.push(call);
|
||||
}
|
||||
if (run.isCurrent) existing.historical = false;
|
||||
existing.createdAt = Math.max(existing.createdAt, run.createdAt);
|
||||
existing.updatedAt = Math.max(existing.updatedAt, call.updatedAt);
|
||||
return;
|
||||
}
|
||||
items.set(key, {
|
||||
id: key,
|
||||
label,
|
||||
category,
|
||||
iconUrl,
|
||||
historical: !run.isCurrent,
|
||||
createdAt: run.createdAt,
|
||||
updatedAt: call.updatedAt,
|
||||
calls: [call],
|
||||
});
|
||||
};
|
||||
|
||||
for (const call of calls) {
|
||||
const operation = skillOperation(call);
|
||||
if (operation) {
|
||||
// list_skills is discovery only. Its result contains every installed
|
||||
// skill's name and description and must never be presented as usage.
|
||||
// A load_skill call without a safely parsed name is also omitted rather
|
||||
// than inventing an umbrella "Skill" row.
|
||||
if (operation !== 'load') continue;
|
||||
for (const name of call.skillNames) {
|
||||
put('skill', displaySkillName(name, skills), undefined, call);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const connector = resolveContextConnector(
|
||||
call.toolkitName,
|
||||
call.method,
|
||||
[call.input, call.output].filter(Boolean).join('\n'),
|
||||
connectors
|
||||
);
|
||||
if (connector) {
|
||||
put(
|
||||
'connector',
|
||||
connector.displayName || connector.service,
|
||||
connector.iconUrl || undefined,
|
||||
call
|
||||
);
|
||||
} else if (/mcp|connector/i.test(call.toolkitName)) {
|
||||
put('connector', call.toolkitName, undefined, call);
|
||||
}
|
||||
}
|
||||
|
||||
return [...items.values()].sort(
|
||||
(left, right) =>
|
||||
Number(left.historical) - Number(right.historical) ||
|
||||
left.label.localeCompare(right.label)
|
||||
);
|
||||
}
|
||||
|
||||
const HTTP_URL_PATTERN = /https?:\/\/[^\s<>"'`)\]}]+/gi;
|
||||
|
||||
export function extractHttpUrls(value: string): string[] {
|
||||
const matches = value.match(HTTP_URL_PATTERN) ?? [];
|
||||
const urls = new Set<string>();
|
||||
for (const raw of matches) {
|
||||
const cleaned = raw.replace(/[.,;:!?]+$/, '');
|
||||
const url = httpUrlOrNull(cleaned);
|
||||
if (url) urls.add(url);
|
||||
}
|
||||
return [...urls];
|
||||
}
|
||||
|
||||
function resourceLabel(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const path = parsed.pathname === '/' ? '' : parsed.pathname;
|
||||
return `${parsed.hostname}${path}`.replace(/\/$/, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function safeNodeText(node: ChatProjectionNode): string {
|
||||
switch (node.kind) {
|
||||
case 'message':
|
||||
return node.content;
|
||||
case 'notice':
|
||||
return `${node.title || ''}\n${node.content}`;
|
||||
case 'interaction':
|
||||
return `${node.prompt || ''}\n${node.response || ''}`;
|
||||
case 'plan':
|
||||
return `${node.title || ''}\n${node.summary || ''}`;
|
||||
case 'activity':
|
||||
return `${node.title}\n${node.detail || ''}`;
|
||||
case 'artifact':
|
||||
return node.path;
|
||||
case 'run_status':
|
||||
return node.reason || '';
|
||||
case 'unknown':
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function nodeHttpUrls(node: ChatProjectionNode): string[] {
|
||||
const cached = NODE_HTTP_URL_CACHE.get(node);
|
||||
if (cached) return cached;
|
||||
const urls = extractHttpUrls(safeNodeText(node));
|
||||
NODE_HTTP_URL_CACHE.set(node, urls);
|
||||
return urls;
|
||||
}
|
||||
|
||||
function collectResources(runs: ProjectSessionRun[]): SessionResourceItem[] {
|
||||
const resources = new Map<string, SessionResourceItem>();
|
||||
for (const run of runs) {
|
||||
for (const node of run.nodes) {
|
||||
for (const url of nodeHttpUrls(node)) {
|
||||
const existing = resources.get(url);
|
||||
const time = nodeTime(node, run.createdAt);
|
||||
const item: SessionResourceItem = {
|
||||
id: `url:${url}`,
|
||||
label: resourceLabel(url),
|
||||
kind: 'url',
|
||||
url,
|
||||
taskId: run.runId,
|
||||
historical: !run.isCurrent,
|
||||
createdAt: existing?.createdAt ?? time,
|
||||
updatedAt: Math.max(existing?.updatedAt ?? 0, time),
|
||||
};
|
||||
if (!existing || (existing.historical && run.isCurrent)) {
|
||||
resources.set(url, item);
|
||||
} else {
|
||||
existing.updatedAt = item.updatedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...resources.values()].sort(
|
||||
(left, right) =>
|
||||
Number(left.historical) - Number(right.historical) ||
|
||||
right.updatedAt - left.updatedAt
|
||||
);
|
||||
}
|
||||
|
||||
function fileInfoFromArtifact(
|
||||
node: Extract<ChatProjectionNode, { kind: 'artifact' }>
|
||||
): FileInfo {
|
||||
const name =
|
||||
node.name || node.path.split('/').filter(Boolean).at(-1) || node.path;
|
||||
const relativePath = normalizeWorkspaceRelativePath(node.relativePath);
|
||||
return {
|
||||
name,
|
||||
type: name.includes('.') ? name.split('.').at(-1) || '' : '',
|
||||
path: node.path,
|
||||
relativePath: relativePath || undefined,
|
||||
artifactId: node.artifactId,
|
||||
artifactChange:
|
||||
node.operation === 'created'
|
||||
? 'generated'
|
||||
: node.operation === 'updated'
|
||||
? 'changed'
|
||||
: undefined,
|
||||
mimeType: node.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
function artifactIdentity(
|
||||
node: Extract<ChatProjectionNode, { kind: 'artifact' }>
|
||||
): string | null {
|
||||
const artifactId = node.artifactId?.trim();
|
||||
if (artifactId) return `artifact:${artifactId}`;
|
||||
const relativePath = normalizeWorkspaceRelativePath(node.relativePath);
|
||||
if (relativePath) return `relative:${relativePath}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function fileEntryForRunRelativePath(
|
||||
files: Map<string, SessionFileItem>,
|
||||
runId: string,
|
||||
relativePath: string
|
||||
): [string, SessionFileItem] | null {
|
||||
for (const entry of files.entries()) {
|
||||
const [, item] = entry;
|
||||
if (
|
||||
item.taskId === runId &&
|
||||
normalizeWorkspaceRelativePath(item.file.relativePath) === relativePath
|
||||
) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectFiles(runs: ProjectSessionRun[]): SessionFileItem[] {
|
||||
const files = new Map<string, SessionFileItem>();
|
||||
for (const run of [...runs].reverse()) {
|
||||
for (const node of run.nodes) {
|
||||
if (node.kind !== 'artifact' || !node.path || httpUrlOrNull(node.path)) {
|
||||
continue;
|
||||
}
|
||||
let key = artifactIdentity(node);
|
||||
// A display-only basename must never become Run identity or an openable
|
||||
// workspace path. Wait for an artifact id or a trusted portable path.
|
||||
if (!key) continue;
|
||||
const relativePath = normalizeWorkspaceRelativePath(node.relativePath);
|
||||
const matchingRunPath = relativePath
|
||||
? fileEntryForRunRelativePath(files, run.runId, relativePath)
|
||||
: null;
|
||||
if (node.operation === 'deleted') {
|
||||
files.delete(key);
|
||||
if (matchingRunPath) files.delete(matchingRunPath[0]);
|
||||
continue;
|
||||
}
|
||||
const time = nodeTime(node, run.createdAt);
|
||||
let existing = files.get(key);
|
||||
if (matchingRunPath && matchingRunPath[0] !== key) {
|
||||
existing ??= matchingRunPath[1];
|
||||
if (node.artifactId?.trim()) {
|
||||
// The terminal manifest upgrades the realtime relative identity to
|
||||
// its canonical artifact id without creating a second visible row.
|
||||
files.delete(matchingRunPath[0]);
|
||||
} else {
|
||||
// Preserve a canonical artifact key if an older realtime frame is
|
||||
// replayed after terminal finalization.
|
||||
key = matchingRunPath[0];
|
||||
}
|
||||
}
|
||||
const file = fileInfoFromArtifact(node);
|
||||
file.artifactId ??= existing?.file.artifactId;
|
||||
const id = file.artifactId?.trim() || relativePath || node.eventId;
|
||||
files.set(key, {
|
||||
id,
|
||||
file,
|
||||
previewable: false,
|
||||
taskId: run.runId,
|
||||
historical: existing?.historical === false ? false : !run.isCurrent,
|
||||
createdAt: existing?.createdAt ?? time,
|
||||
updatedAt: time,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...files.values()].sort(
|
||||
(left, right) =>
|
||||
Number(left.historical) - Number(right.historical) ||
|
||||
right.updatedAt - left.updatedAt
|
||||
);
|
||||
}
|
||||
|
||||
function collectEnvironments(
|
||||
runs: ProjectSessionRun[]
|
||||
): SessionEnvironmentItem[] {
|
||||
const environments = new Map<string, SessionEnvironmentItem>();
|
||||
const put = (label: string, run: ProjectSessionRun, time: number) => {
|
||||
const id = label.toLowerCase();
|
||||
const existing = environments.get(id);
|
||||
if (!existing || (existing.historical && run.isCurrent)) {
|
||||
environments.set(id, {
|
||||
id,
|
||||
label,
|
||||
taskId: run.runId,
|
||||
historical: !run.isCurrent,
|
||||
createdAt: existing?.createdAt ?? time,
|
||||
updatedAt: Math.max(existing?.updatedAt ?? 0, time),
|
||||
});
|
||||
} else {
|
||||
existing.updatedAt = Math.max(existing.updatedAt, time);
|
||||
}
|
||||
};
|
||||
|
||||
for (const run of runs) {
|
||||
for (const node of run.nodes) {
|
||||
if (node.kind !== 'activity') continue;
|
||||
const time = nodeTime(node, run.createdAt);
|
||||
const identity = `${node.activityType} ${node.title} ${
|
||||
node.toolkitName || ''
|
||||
} ${node.methodName || ''}`.toLowerCase();
|
||||
if (node.activityType === 'terminal' || /terminal|shell/.test(identity)) {
|
||||
put('Terminal', run, time);
|
||||
}
|
||||
if (/browser|search|scrape/.test(identity)) {
|
||||
put('Browser', run, time);
|
||||
}
|
||||
if (/sub.?agent|remote/.test(identity)) {
|
||||
put('Remote environment', run, time);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...environments.values()].sort(
|
||||
(left, right) =>
|
||||
Number(left.historical) - Number(right.historical) ||
|
||||
left.label.localeCompare(right.label)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProjectSessionPanelData(
|
||||
runs: ProjectSessionRun[],
|
||||
skills: ContextSkill[],
|
||||
connectors: ContextConnector[] = []
|
||||
): ProjectSessionPanelData {
|
||||
const toolCalls = collectSessionToolCalls(runs);
|
||||
return {
|
||||
agents: collectAgents(runs, toolCalls),
|
||||
contextItems: collectContext(runs, toolCalls, skills, connectors),
|
||||
environments: collectEnvironments(runs),
|
||||
files: collectFiles(runs),
|
||||
progress: collectProgress(runs),
|
||||
resources: collectResources(runs),
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeProjectFiles(
|
||||
items: SessionFileItem[],
|
||||
projectFiles: FileInfo[]
|
||||
): SessionFileItem[] {
|
||||
type UniqueFile = FileInfo | null;
|
||||
const byArtifactId = new Map<string, UniqueFile>();
|
||||
const byRelativePath = new Map<string, UniqueFile>();
|
||||
|
||||
const addUnique = (
|
||||
index: Map<string, UniqueFile>,
|
||||
key: string | null | undefined,
|
||||
file: FileInfo
|
||||
) => {
|
||||
if (!key) return;
|
||||
const existing = index.get(key);
|
||||
if (existing === undefined) {
|
||||
index.set(key, file);
|
||||
return;
|
||||
}
|
||||
if (existing?.path !== file.path) index.set(key, null);
|
||||
};
|
||||
|
||||
for (const file of projectFiles) {
|
||||
addUnique(byArtifactId, file.artifactId?.trim(), file);
|
||||
addUnique(
|
||||
byRelativePath,
|
||||
normalizeWorkspaceRelativePath(file.relativePath),
|
||||
file
|
||||
);
|
||||
}
|
||||
|
||||
return items.map((item) => {
|
||||
const artifactId = item.file.artifactId?.trim();
|
||||
const relativePath = normalizeWorkspaceRelativePath(item.file.relativePath);
|
||||
const idMatch = artifactId ? byArtifactId.get(artifactId) : undefined;
|
||||
const pathMatch = relativePath
|
||||
? byRelativePath.get(relativePath)
|
||||
: undefined;
|
||||
|
||||
// Conflicting or ambiguous resolver identities must fail closed.
|
||||
if (idMatch === null || pathMatch === null) return item;
|
||||
if (idMatch && pathMatch && idMatch.path !== pathMatch.path) return item;
|
||||
|
||||
const match = idMatch || pathMatch;
|
||||
if (!match) return item;
|
||||
if (
|
||||
artifactId &&
|
||||
match.artifactId &&
|
||||
artifactId !== match.artifactId.trim()
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
previewable: true,
|
||||
file: {
|
||||
...item.file,
|
||||
...match,
|
||||
name: item.file.name || match.name,
|
||||
type: item.file.type || match.type,
|
||||
path: match.path || item.file.path,
|
||||
relativePath:
|
||||
relativePath ||
|
||||
normalizeWorkspaceRelativePath(match.relativePath) ||
|
||||
undefined,
|
||||
artifactId: artifactId || match.artifactId,
|
||||
artifactChange: item.file.artifactChange,
|
||||
mimeType: item.file.mimeType || match.mimeType,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function isProgressDone(task: TaskInfo): boolean {
|
||||
return (
|
||||
task.status === TaskStatus.COMPLETED || task.status === TaskStatus.FAILED
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
362
src/components/Session/SidePanel/sections/primitives.tsx
Normal file
362
src/components/Session/SidePanel/sections/primitives.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { Check, ChevronDown, History } from 'lucide-react';
|
||||
import {
|
||||
forwardRef,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const SESSION_ROW_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
||||
|
||||
/** Shared SidePanel row variants. */
|
||||
export const sessionPanelRowVariants = cva(
|
||||
[
|
||||
'group flex h-10 min-h-10 w-full min-w-0 items-center gap-2 rounded-lg px-2 py-0 text-left',
|
||||
'transition-colors duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus/40',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
section:
|
||||
'!text-body-sm font-semibold text-ds-text-neutral-default-default',
|
||||
subcategory:
|
||||
'!text-body-sm font-medium text-ds-text-neutral-default-default',
|
||||
item: '!text-body-sm font-medium text-ds-text-neutral-default-default',
|
||||
history: '!text-body-sm font-medium text-ds-text-neutral-muted-default',
|
||||
earlier: '!text-body-sm font-medium text-ds-text-neutral-muted-default',
|
||||
},
|
||||
interactive: {
|
||||
true: 'cursor-pointer',
|
||||
false: 'cursor-default',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: 'item',
|
||||
interactive: true,
|
||||
className:
|
||||
'hover:bg-ds-bg-neutral-subtle-hover active:bg-ds-bg-neutral-subtle-default',
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'item',
|
||||
interactive: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type SessionPanelRowVariant = NonNullable<
|
||||
VariantProps<typeof sessionPanelRowVariants>['variant']
|
||||
>;
|
||||
|
||||
type SessionPanelButtonProps = {
|
||||
leading?: ReactNode;
|
||||
children: ReactNode;
|
||||
badge?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
chevron?: boolean;
|
||||
open?: boolean;
|
||||
completed?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
interactiveHover?: boolean;
|
||||
className?: string;
|
||||
variant?: SessionPanelRowVariant;
|
||||
ariaLabel?: string;
|
||||
ariaExpanded?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared 40px interaction row for section triggers, nested categories, items,
|
||||
* and the historical-items disclosure.
|
||||
*/
|
||||
export const SessionPanelButton = forwardRef<
|
||||
HTMLElement,
|
||||
SessionPanelButtonProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
leading,
|
||||
children,
|
||||
badge,
|
||||
trailing,
|
||||
chevron,
|
||||
open,
|
||||
completed,
|
||||
disabled,
|
||||
onClick,
|
||||
interactiveHover,
|
||||
className,
|
||||
variant = 'item',
|
||||
ariaLabel,
|
||||
ariaExpanded,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const interactive = Boolean(onClick || interactiveHover);
|
||||
const earlierRow = variant === 'earlier';
|
||||
const disclosureRow = variant !== 'item';
|
||||
const base = cn(
|
||||
sessionPanelRowVariants({ variant, interactive }),
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
className
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
{leading ? (
|
||||
<span className="flex size-4 shrink-0 items-center justify-center">
|
||||
{leading}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate',
|
||||
disclosureRow && !earlierRow ? 'shrink' : 'flex-1',
|
||||
completed && 'line-through opacity-70'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{badge ? (
|
||||
<span className="flex shrink-0 items-center">{badge}</span>
|
||||
) : null}
|
||||
{chevron ? (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-ds-text-neutral-muted-default transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none',
|
||||
earlierRow
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100',
|
||||
open ? 'rotate-0' : '-rotate-90'
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{trailing ? (
|
||||
<span className="flex shrink-0 items-center">{trailing}</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={base}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={ariaExpanded}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref as React.Ref<HTMLDivElement>} className={base}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
SessionPanelButton.displayName = 'SessionPanelButton';
|
||||
|
||||
export function SessionPanelCollapse({
|
||||
open,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const collapseRef = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
collapseRef.current?.toggleAttribute('inert', !open);
|
||||
}, [open]);
|
||||
return (
|
||||
<motion.div
|
||||
ref={collapseRef}
|
||||
initial={false}
|
||||
animate={{
|
||||
height: open ? 'auto' : 0,
|
||||
opacity: open ? 1 : 0,
|
||||
}}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
height: { duration: 0.26, ease: SESSION_ROW_EASE },
|
||||
opacity: { duration: open ? 0.18 : 0.12, ease: 'easeOut' },
|
||||
}
|
||||
}
|
||||
aria-hidden={!open}
|
||||
style={{ pointerEvents: open ? 'auto' : 'none' }}
|
||||
className={cn('min-h-0 w-full overflow-hidden', className)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Small round count/label pill used next to accordion titles.
|
||||
*/
|
||||
export function CountPill({ count }: { count: number }) {
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-ds-bg-neutral-subtle-default px-1.5 text-label-xs font-bold text-ds-text-neutral-subtle-default">
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps previous-run content available without adding per-item run metadata.
|
||||
* The owning task id remains on each row's action, so items can still navigate
|
||||
* back to the correct place in chat.
|
||||
*/
|
||||
export function EarlierItems({
|
||||
children,
|
||||
count,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
count: number;
|
||||
label?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
if (count === 0) return null;
|
||||
const resolvedLabel =
|
||||
label ??
|
||||
t('layout.session-panel-earlier', {
|
||||
defaultValue: 'Earlier',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<SessionPanelButton
|
||||
variant="earlier"
|
||||
leading={<History size={16} aria-hidden />}
|
||||
chevron
|
||||
open={open}
|
||||
ariaExpanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{resolvedLabel}
|
||||
</SessionPanelButton>
|
||||
<SessionPanelCollapse open={open}>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</SessionPanelCollapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SidePanelListRowProps = {
|
||||
leading?: ReactNode;
|
||||
children: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
/**
|
||||
* Pointer + subtle hover/active backgrounds without an action (e.g. read-only list rows).
|
||||
* When `onClick` is set, focus ring is included; for hover-only rows it is omitted.
|
||||
*/
|
||||
interactiveHover?: boolean;
|
||||
completed?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Row primitive used across Agent Pool / Execution Context / Agent Folder sections.
|
||||
* Rendered as a button when `onClick` is provided, otherwise a div.
|
||||
*/
|
||||
export const SidePanelListRow = forwardRef<HTMLElement, SidePanelListRowProps>(
|
||||
(
|
||||
{
|
||||
leading,
|
||||
children,
|
||||
trailing,
|
||||
disabled,
|
||||
onClick,
|
||||
interactiveHover,
|
||||
completed,
|
||||
className,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<SessionPanelButton
|
||||
ref={ref}
|
||||
variant="item"
|
||||
leading={leading}
|
||||
trailing={trailing}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
interactiveHover={interactiveHover}
|
||||
completed={completed}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</SessionPanelButton>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidePanelListRow.displayName = 'SidePanelListRow';
|
||||
|
||||
/**
|
||||
* Progress circle. Incomplete: neutral subtle fill so the ring reads on any
|
||||
* panel background. Complete: filled success (matches primary success button)
|
||||
* with inverse check mark.
|
||||
*/
|
||||
export function ProgressCircle({
|
||||
done,
|
||||
size = 14,
|
||||
}: {
|
||||
done: boolean;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-full border-[0.5px] border-solid',
|
||||
done
|
||||
? 'border-ds-bg-success-default-default bg-ds-bg-success-default-default text-ds-text-success-inverse-default'
|
||||
: 'border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default'
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
aria-hidden
|
||||
>
|
||||
{done ? (
|
||||
<Check
|
||||
className="!text-ds-text-success-inverse-default"
|
||||
size={Math.max(8, size - 6)}
|
||||
strokeWidth={4}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
/** Controls whether SidePanel shows only the current run or every run. */
|
||||
export type SessionPanelScope = 'latest' | 'all';
|
||||
|
||||
export interface SessionPanelScopedItem {
|
||||
historical: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export function selectSessionPanelRuns<
|
||||
T extends { isCurrent: boolean; createdAt: number; updatedAt: number },
|
||||
>(runs: T[], scope: SessionPanelScope): T[] {
|
||||
const sorted = [...runs].sort(
|
||||
(a, b) => b.createdAt - a.createdAt || b.updatedAt - a.updatedAt
|
||||
);
|
||||
return scope === 'latest'
|
||||
? sorted.filter((run) => run.isCurrent).slice(0, 1)
|
||||
: sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split items into the current run's rows and a collapsed "earlier runs" group.
|
||||
* In `latest` scope the panel is already fed only the current run, so anything
|
||||
* still flagged historical is dropped rather than shown.
|
||||
*/
|
||||
export function arrangeSessionPanelItems<T extends SessionPanelScopedItem>(
|
||||
items: T[],
|
||||
scope: SessionPanelScope,
|
||||
order: 'newest' | 'source' = 'newest'
|
||||
): { primary: T[]; earlier: T[] } {
|
||||
const sorted =
|
||||
order === 'source'
|
||||
? [...items]
|
||||
: [...items].sort(
|
||||
(a, b) => b.createdAt - a.createdAt || b.updatedAt - a.updatedAt
|
||||
);
|
||||
const primary = sorted.filter((item) => !item.historical);
|
||||
|
||||
return scope === 'latest'
|
||||
? { primary, earlier: [] }
|
||||
: { primary, earlier: sorted.filter((item) => item.historical) };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
const CONTENT_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
||||
const REVEAL_EASE: [number, number, number, number] = [0.23, 1, 0.32, 1];
|
||||
const LAYOUT_TRANSITION = {
|
||||
layout: { duration: 0.28, ease: CONTENT_EASE },
|
||||
} as const;
|
||||
|
||||
export type SidePanelAccordionRenderArgs = { open: boolean };
|
||||
|
||||
export type SidePanelAccordionChildren =
|
||||
| ReactNode
|
||||
| ((state: SidePanelAccordionRenderArgs) => ReactNode);
|
||||
|
||||
export function SidePanelAccordionBox({
|
||||
title,
|
||||
titleSuffix,
|
||||
collapsedPreview,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
title: string;
|
||||
/** Small adornment rendered right after the title (e.g. count pill). */
|
||||
titleSuffix?: ReactNode;
|
||||
/**
|
||||
* Compact content below the header when collapsed (static `children` only;
|
||||
* render-prop children control their own open/closed layout).
|
||||
*/
|
||||
collapsedPreview?: ReactNode;
|
||||
/**
|
||||
* Static: classic accordion — body hidden when closed.
|
||||
* Render prop: body stays in one region; switch layout by `open` (e.g. summary vs full list).
|
||||
*/
|
||||
children: SidePanelAccordionChildren;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const isRenderProp = typeof children === 'function';
|
||||
const dynamicBody = isRenderProp
|
||||
? (children as (s: SidePanelAccordionRenderArgs) => ReactNode)({ open })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="z-10 flex min-w-0 shrink-0 flex-col overflow-hidden rounded-xl border border-solid border-ds-border-neutral-subtle-disabled bg-ds-bg-neutral-default-default">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full shrink-0 items-center justify-between gap-2 px-3 py-2.5 text-left transition-colors hover:bg-ds-bg-neutral-default-hover"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="text-body-sm font-semibold text-ds-text-neutral-default-default">
|
||||
{title}
|
||||
</span>
|
||||
{titleSuffix ? (
|
||||
<span className="flex shrink-0 items-center">{titleSuffix}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-ds-text-neutral-muted-default transition-transform duration-200 ease-out motion-reduce:transition-none',
|
||||
open ? 'rotate-0' : '-rotate-90'
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
{!open && collapsedPreview && !isRenderProp ? (
|
||||
<div className="w-full px-2 pb-3">{collapsedPreview}</div>
|
||||
) : null}
|
||||
|
||||
{isRenderProp ? (
|
||||
<motion.div
|
||||
layout={!shouldReduceMotion}
|
||||
transition={
|
||||
shouldReduceMotion ? { layout: { duration: 0 } } : LAYOUT_TRANSITION
|
||||
}
|
||||
className="min-h-0 w-full overflow-hidden"
|
||||
>
|
||||
{dynamicBody != null ? (
|
||||
<div className="w-full px-2 pb-3">{dynamicBody}</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
) : (
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<motion.div
|
||||
key="static-content"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
transform: shouldReduceMotion
|
||||
? 'translateY(0px)'
|
||||
: 'translateY(-8px)',
|
||||
}}
|
||||
animate={{ opacity: 1, transform: 'translateY(0px)' }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transform: shouldReduceMotion
|
||||
? 'translateY(0px)'
|
||||
: 'translateY(-4px)',
|
||||
transition: {
|
||||
duration: shouldReduceMotion ? 0.14 : 0.125,
|
||||
ease: REVEAL_EASE,
|
||||
},
|
||||
}}
|
||||
transition={{ duration: 0.16, ease: REVEAL_EASE }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="w-full px-2 pb-3">{children as ReactNode}</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { SidePanelAccordionBox } from '@/components/Session/SidePanelAccordionBox';
|
||||
import { SidePanelListRow } from '@/components/Session/SidePanelSections/primitives';
|
||||
import { isVisibleAgentFile } from '@/lib/agentFileFilters';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
buildWorkspaceFileTree,
|
||||
type WorkspaceFileTreeNode,
|
||||
} from '@/lib/workspaceFileTree';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
File,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileVideo,
|
||||
Folder as FolderIcon,
|
||||
FolderOpen,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
const EXT_MAP: Record<string, LucideIcon> = {
|
||||
// images
|
||||
png: FileImage,
|
||||
jpg: FileImage,
|
||||
jpeg: FileImage,
|
||||
gif: FileImage,
|
||||
webp: FileImage,
|
||||
svg: FileImage,
|
||||
// video
|
||||
mp4: FileVideo,
|
||||
mov: FileVideo,
|
||||
webm: FileVideo,
|
||||
// audio
|
||||
mp3: FileAudio,
|
||||
wav: FileAudio,
|
||||
m4a: FileAudio,
|
||||
// archive
|
||||
zip: FileArchive,
|
||||
tar: FileArchive,
|
||||
gz: FileArchive,
|
||||
// spreadsheet
|
||||
csv: FileSpreadsheet,
|
||||
xlsx: FileSpreadsheet,
|
||||
xls: FileSpreadsheet,
|
||||
// code
|
||||
ts: FileCode,
|
||||
tsx: FileCode,
|
||||
js: FileCode,
|
||||
jsx: FileCode,
|
||||
py: FileCode,
|
||||
json: FileCode,
|
||||
html: FileCode,
|
||||
// docs
|
||||
md: FileText,
|
||||
txt: FileText,
|
||||
pdf: FileText,
|
||||
doc: FileText,
|
||||
docx: FileText,
|
||||
};
|
||||
|
||||
function iconFor(file: FileInfo): LucideIcon {
|
||||
const name = (file.name || file.path || '').toLowerCase();
|
||||
const idx = name.lastIndexOf('.');
|
||||
const ext = idx >= 0 ? name.slice(idx + 1) : '';
|
||||
return EXT_MAP[ext] ?? File;
|
||||
}
|
||||
|
||||
interface AgentFolderSectionProps {
|
||||
title: string;
|
||||
files: FileInfo[];
|
||||
/** Opens the Folder workspace tab and selects this file (parent supplies navigation). */
|
||||
onOpenFile: (file: FileInfo) => void;
|
||||
}
|
||||
|
||||
interface AgentFolderTreeProps {
|
||||
nodes: WorkspaceFileTreeNode[];
|
||||
expandedFolders: Set<string>;
|
||||
onToggleFolder: (path: string) => void;
|
||||
onOpenFile: (file: FileInfo) => void;
|
||||
}
|
||||
|
||||
function AgentFolderTree({
|
||||
nodes,
|
||||
expandedFolders,
|
||||
onToggleFolder,
|
||||
onOpenFile,
|
||||
}: AgentFolderTreeProps) {
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{nodes.map((node) => {
|
||||
const isExpanded =
|
||||
node.isFolder && expandedFolders.has(node.relativePath);
|
||||
const Icon = node.file ? iconFor(node.file) : File;
|
||||
|
||||
return (
|
||||
<motion.li
|
||||
key={`${node.isFolder ? 'folder' : 'file'}:${node.relativePath}`}
|
||||
layout
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="m-0 list-none"
|
||||
>
|
||||
<SidePanelListRow
|
||||
leading={
|
||||
node.isFolder ? (
|
||||
<span className="flex items-center gap-1">
|
||||
{isExpanded ? (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="text-ds-icon-neutral-muted-default"
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="text-ds-icon-neutral-muted-default"
|
||||
/>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<FolderOpen
|
||||
size={16}
|
||||
className="text-ds-icon-neutral-default-default"
|
||||
/>
|
||||
) : (
|
||||
<FolderIcon
|
||||
size={16}
|
||||
className="text-ds-icon-neutral-default-default"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<Icon
|
||||
size={16}
|
||||
className={cn('text-ds-icon-neutral-default-default')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (node.isFolder) {
|
||||
onToggleFolder(node.relativePath);
|
||||
} else if (node.file) {
|
||||
onOpenFile(node.file);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span title={node.relativePath}>{node.name}</span>
|
||||
</SidePanelListRow>
|
||||
|
||||
{isExpanded && node.children.length > 0 ? (
|
||||
<ul className="m-0 ml-4 list-none border-y-0 border-l border-r-0 border-solid border-ds-border-neutral-subtle-default p-0 pl-1">
|
||||
<AgentFolderTree
|
||||
nodes={node.children}
|
||||
expandedFolders={expandedFolders}
|
||||
onToggleFolder={onToggleFolder}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</ul>
|
||||
) : null}
|
||||
</motion.li>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentFolderSection({
|
||||
title,
|
||||
files,
|
||||
onOpenFile,
|
||||
}: AgentFolderSectionProps) {
|
||||
const unique = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: FileInfo[] = [];
|
||||
for (const f of files) {
|
||||
if (!isVisibleAgentFile(f)) continue;
|
||||
const key = f.path || f.name;
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(f);
|
||||
}
|
||||
return out;
|
||||
}, [files]);
|
||||
const fileTree = useMemo(() => buildWorkspaceFileTree(unique), [unique]);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
|
||||
const toggleFolder = (path: string) => {
|
||||
setExpandedFolders((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SidePanelAccordionBox title={title}>
|
||||
{unique.length === 0 ? (
|
||||
<div className="px-1 py-1 text-body-sm text-ds-text-neutral-subtle-default opacity-60">
|
||||
Files the agent writes or updates during this task appear here so you
|
||||
can open them.
|
||||
</div>
|
||||
) : (
|
||||
<motion.ul
|
||||
layout
|
||||
className="m-0 max-h-96 list-none overflow-y-auto overscroll-contain p-0 pr-1"
|
||||
>
|
||||
<AgentFolderTree
|
||||
nodes={fileTree}
|
||||
expandedFolders={expandedFolders}
|
||||
onToggleFolder={toggleFolder}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</motion.ul>
|
||||
)}
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,380 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { SidePanelAccordionBox } from '@/components/Session/SidePanelAccordionBox';
|
||||
import ShinyText from '@/components/ui/ShinyText/ShinyText';
|
||||
import { agentMap, type WorkflowAgentType } from '@/components/WorkFlow/agents';
|
||||
import { getToolkitIcon } from '@/lib/toolkitIcons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AgentStatusValue } from '@/types/constants';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Bird, Bot, CodeXml, FileText, Globe, Image } from 'lucide-react';
|
||||
import {
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
function hasWork(agent: Agent) {
|
||||
return Array.isArray(agent.tasks) && agent.tasks.length > 0;
|
||||
}
|
||||
|
||||
function agentHasAnyToolkitsSeen(agent: Agent): boolean {
|
||||
for (const task of agent.tasks ?? []) {
|
||||
for (const tk of task.toolkits ?? []) {
|
||||
if (tk.toolkitName && tk.toolkitName !== 'notice') return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors `Workspace/index.tsx` ordering: agents with assigned tasks come
|
||||
* first, preserving their insertion order within each bucket.
|
||||
*/
|
||||
function sortByAssigned(agents: Agent[]): Agent[] {
|
||||
return [...agents].sort((a, b) => {
|
||||
const aHas = hasWork(a);
|
||||
const bHas = hasWork(b);
|
||||
if (aHas && !bHas) return -1;
|
||||
if (!aHas && bHas) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function getAgentSubIcon(agentType: string): ReactNode {
|
||||
const key = agentType as WorkflowAgentType;
|
||||
const preset = agentMap[key];
|
||||
if (!preset) return null;
|
||||
const iconClass = cn('!h-[10px] !w-[10px] shrink-0', preset.textColor);
|
||||
switch (key) {
|
||||
case 'developer_agent':
|
||||
return <CodeXml className={iconClass} />;
|
||||
case 'browser_agent':
|
||||
return <Globe className={iconClass} />;
|
||||
case 'document_agent':
|
||||
return <FileText className={iconClass} />;
|
||||
case 'multi_modal_agent':
|
||||
return <Image className={iconClass} />;
|
||||
case 'social_media_agent':
|
||||
return <Bird className={iconClass} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function AgentLeadingIcon({ agentType }: { agentType: string }) {
|
||||
const subIcon = getAgentSubIcon(agentType);
|
||||
return (
|
||||
<div className="h-6 w-6 text-ds-text-neutral-muted-default bg-ds-bg-neutral-subtle-default rounded-md relative inline-flex shrink-0 items-center justify-center self-center">
|
||||
<Bot className="h-5 w-5" strokeWidth={2} aria-hidden />
|
||||
{subIcon != null && (
|
||||
<span className="-right-0.5 -top-0.5 absolute inline-flex items-center justify-center [&_svg]:shrink-0">
|
||||
{subIcon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum on-screen time for any toolkit that went RUNNING, so fast tools
|
||||
* (e.g. Screenshot, Search) remain observable even when they flip to
|
||||
* COMPLETED within a few milliseconds.
|
||||
*/
|
||||
export const TOOLKIT_MIN_DISPLAY_MS = 1500;
|
||||
|
||||
/** How long each toolkit stays focused before rotating to the next. */
|
||||
export const TOOLKIT_ROTATION_MS = 2000;
|
||||
|
||||
type ToolkitEntry = {
|
||||
/** Unique per activation — from `toolkitId` in the store. */
|
||||
id: string;
|
||||
name: string;
|
||||
firstSeenAt: number;
|
||||
/** Epoch ms when the entry should be dropped; `null` while RUNNING. */
|
||||
expireAt: number | null;
|
||||
};
|
||||
|
||||
type ToolkitState = {
|
||||
entries: Map<string, ToolkitEntry>;
|
||||
timers: Map<string, ReturnType<typeof setTimeout>>;
|
||||
/** Ids that have already been shown and evicted — never re-add. */
|
||||
retired: Set<string>;
|
||||
};
|
||||
|
||||
function readToolkitEvents(
|
||||
agent: Pick<Agent, 'tasks'> | undefined
|
||||
): Array<{ id: string; name: string; status: string | undefined }> {
|
||||
const out: Array<{ id: string; name: string; status: string | undefined }> =
|
||||
[];
|
||||
for (const task of agent?.tasks ?? []) {
|
||||
for (const tk of task.toolkits ?? []) {
|
||||
if (!tk.toolkitName || tk.toolkitName === 'notice') continue;
|
||||
const id = String(
|
||||
(tk as { toolkitId?: string }).toolkitId ??
|
||||
`${tk.toolkitName}:${tk.toolkitMethods}`
|
||||
);
|
||||
out.push({ id, name: tk.toolkitName, status: tk.toolkitStatus });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exported for unit tests. Reconciles a single pass of toolkit events against
|
||||
* the component's local `ToolkitState`, arming timers via the injected
|
||||
* scheduler when a toolkit flips from RUNNING → anything else.
|
||||
*
|
||||
* Returns the ordered, deduped toolkit names currently eligible for display.
|
||||
*/
|
||||
export function reconcileToolkitState(
|
||||
state: ToolkitState,
|
||||
events: Array<{ id: string; name: string; status: string | undefined }>,
|
||||
opts: {
|
||||
now: number;
|
||||
minDisplayMs: number;
|
||||
schedule: (id: string, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
cancel: (handle: ReturnType<typeof setTimeout>) => void;
|
||||
}
|
||||
): string[] {
|
||||
for (const event of events) {
|
||||
if (state.retired.has(event.id)) continue;
|
||||
let entry = state.entries.get(event.id);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
firstSeenAt: opts.now,
|
||||
expireAt: null,
|
||||
};
|
||||
state.entries.set(event.id, entry);
|
||||
}
|
||||
if (event.status === AgentStatusValue.RUNNING) {
|
||||
if (entry.expireAt !== null) {
|
||||
entry.expireAt = null;
|
||||
const t = state.timers.get(event.id);
|
||||
if (t) {
|
||||
opts.cancel(t);
|
||||
state.timers.delete(event.id);
|
||||
}
|
||||
}
|
||||
} else if (entry.expireAt === null) {
|
||||
const expireAt = Math.max(
|
||||
opts.now,
|
||||
entry.firstSeenAt + opts.minDisplayMs
|
||||
);
|
||||
entry.expireAt = expireAt;
|
||||
const delay = Math.max(0, expireAt - opts.now);
|
||||
state.timers.set(event.id, opts.schedule(event.id, delay));
|
||||
}
|
||||
}
|
||||
|
||||
// Collect names — dedupe preserving first-seen order, drop ones whose
|
||||
// timers have already fired and removed them.
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const entry of state.entries.values()) {
|
||||
if (entry.expireAt !== null && entry.expireAt <= opts.now) continue;
|
||||
if (!seen.has(entry.name)) {
|
||||
seen.add(entry.name);
|
||||
out.push(entry.name);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of toolkit names to display for an agent, honoring a
|
||||
* minimum display time so short-lived toolkits (< a few hundred ms) remain
|
||||
* observable. Recomputes on every parent render (cheap) because the store
|
||||
* mutates `agent.tasks[*].toolkits` in place.
|
||||
*/
|
||||
export function useLiveToolkits(
|
||||
agent: Agent,
|
||||
minDisplayMs: number = TOOLKIT_MIN_DISPLAY_MS
|
||||
): string[] {
|
||||
const [, bump] = useReducer((n: number) => n + 1, 0);
|
||||
const stateRef = useRef<ToolkitState>({
|
||||
entries: new Map(),
|
||||
timers: new Map(),
|
||||
retired: new Set(),
|
||||
});
|
||||
|
||||
const events = readToolkitEvents(agent);
|
||||
const names = reconcileToolkitState(stateRef.current, events, {
|
||||
// Wall-clock read during render is intentional: the reconcile needs
|
||||
// `now` to filter entries whose min-display window has elapsed, and the
|
||||
// setTimeout scheduled below forces a re-render exactly when that
|
||||
// boundary passes — so the result stays consistent across renders.
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
now: Date.now(),
|
||||
minDisplayMs,
|
||||
schedule: (id, delay) =>
|
||||
setTimeout(() => {
|
||||
stateRef.current.entries.delete(id);
|
||||
stateRef.current.timers.delete(id);
|
||||
stateRef.current.retired.add(id);
|
||||
bump();
|
||||
}, delay),
|
||||
cancel: clearTimeout,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const state = stateRef.current;
|
||||
return () => {
|
||||
state.timers.forEach(clearTimeout);
|
||||
state.timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Single-tag strip that rotates through live toolkits with a roll animation. */
|
||||
function AgentToolkitTag({ names }: { names: string[] }) {
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (names.length <= 1) {
|
||||
setFocusIndex(0);
|
||||
return;
|
||||
}
|
||||
const id = window.setInterval(() => {
|
||||
setFocusIndex((i) => (i + 1) % names.length);
|
||||
}, TOOLKIT_ROTATION_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [names.length]);
|
||||
|
||||
const focused =
|
||||
names.length > 0 ? names[Math.min(focusIndex, names.length - 1)] : null;
|
||||
|
||||
return (
|
||||
<div className="h-6 min-w-0 inline-flex shrink-0 items-center overflow-hidden">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{focused && (
|
||||
<motion.div
|
||||
key={focused}
|
||||
initial={{ y: -18, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 18, opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.2, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
'gap-1 px-1.5 py-0.5 rounded-md inline-flex max-w-full items-center opacity-80',
|
||||
'bg-ds-bg-neutral-muted-default'
|
||||
)}
|
||||
data-testid="agent-toolkit-tag"
|
||||
>
|
||||
<span className="text-ds-text-neutral-default-default [&_svg]:h-4 [&_svg]:w-4 inline-flex shrink-0 items-center">
|
||||
{getToolkitIcon(focused, 16, '')}
|
||||
</span>
|
||||
<ShinyText
|
||||
text={focused}
|
||||
speed={2.5}
|
||||
className="text-label-xs font-medium max-w-[140px] truncate"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentRow({ agent }: { agent: Agent }) {
|
||||
const display = agentMap[agent.type as WorkflowAgentType];
|
||||
const active = hasWork(agent);
|
||||
const name = display?.name ?? agent.name;
|
||||
const liveToolkits = useLiveToolkits(agent);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg bg-ds-bg-neutral-subtle-default px-1.5 py-1.5',
|
||||
'gap-2 min-w-0 flex items-center',
|
||||
!active && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<AgentLeadingIcon agentType={agent.type} />
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 !text-body-sm font-medium text-ds-text-neutral-default-default flex-1 truncate',
|
||||
display?.textColor
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<AgentToolkitTag names={liveToolkits} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentList({ agents }: { agents: Agent[] }) {
|
||||
return (
|
||||
<motion.ul layout className="gap-2 p-0 m-0 flex list-none flex-col">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{agents.map((agent) => (
|
||||
<motion.li
|
||||
key={agent.agent_id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<AgentRow agent={agent} />
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentPoolSectionProps {
|
||||
title: string;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
export function AgentPoolSection({ title, agents }: AgentPoolSectionProps) {
|
||||
const ordered = useMemo(() => sortByAssigned(agents), [agents]);
|
||||
const activeAgents = useMemo(() => ordered.filter(hasWork), [ordered]);
|
||||
const toolingAgents = useMemo(
|
||||
() => ordered.filter(agentHasAnyToolkitsSeen),
|
||||
[ordered]
|
||||
);
|
||||
|
||||
const emptyState = (
|
||||
<div className="text-ds-text-neutral-subtle-default text-body-sm px-1 py-1">
|
||||
No agents yet
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidePanelAccordionBox title={title} defaultOpen={false}>
|
||||
{({ open }) => {
|
||||
if (ordered.length === 0) {
|
||||
return open ? emptyState : null;
|
||||
}
|
||||
if (!open) {
|
||||
const collapsed =
|
||||
toolingAgents.length > 0 ? toolingAgents : activeAgents;
|
||||
return collapsed.length > 0 ? <AgentList agents={collapsed} /> : null;
|
||||
}
|
||||
return <AgentList agents={ordered} />;
|
||||
}}
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { SidePanelAccordionBox } from '@/components/Session/SidePanelAccordionBox';
|
||||
import {
|
||||
CategoryLabel,
|
||||
SidePanelListRow,
|
||||
} from '@/components/Session/SidePanelSections/primitives';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export type ContextCategory = 'skill' | 'connector' | 'file';
|
||||
|
||||
export interface ContextItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
category: ContextCategory;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: ContextCategory[] = ['skill', 'connector', 'file'];
|
||||
const CATEGORY_LABEL: Record<ContextCategory, string> = {
|
||||
skill: 'Skills',
|
||||
connector: 'MCP Tools',
|
||||
file: 'Referenced Files',
|
||||
};
|
||||
|
||||
interface ExecutionContextSectionProps {
|
||||
title: string;
|
||||
items: ContextItem[];
|
||||
}
|
||||
|
||||
export function ExecutionContextSection({
|
||||
title,
|
||||
items,
|
||||
}: ExecutionContextSectionProps) {
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<ContextCategory, ContextItem[]>();
|
||||
for (const item of items) {
|
||||
if (!map.has(item.category)) map.set(item.category, []);
|
||||
map.get(item.category)!.push(item);
|
||||
}
|
||||
return CATEGORY_ORDER.filter((c) => map.has(c)).map((c) => ({
|
||||
category: c,
|
||||
items: map.get(c)!,
|
||||
}));
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<SidePanelAccordionBox title={title}>
|
||||
{items.length === 0 ? (
|
||||
<div className="text-ds-text-neutral-subtle-default text-body-sm px-1 py-1 opacity-60">
|
||||
Track skills, MCPs and referenced files used in this task.
|
||||
</div>
|
||||
) : (
|
||||
<div className="gap-2 flex flex-col">
|
||||
{grouped.map(({ category, items: groupItems }) => (
|
||||
<div key={category} className="flex flex-col">
|
||||
<CategoryLabel>{CATEGORY_LABEL[category]}</CategoryLabel>
|
||||
<motion.ul layout className="p-0 m-0 space-y-0.5 list-none">
|
||||
<AnimatePresence initial={false}>
|
||||
{groupItems.map((item) => (
|
||||
<motion.li
|
||||
key={item.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<SidePanelListRow
|
||||
leading={item.icon}
|
||||
onClick={item.onClick}
|
||||
interactiveHover
|
||||
>
|
||||
{item.label}
|
||||
</SidePanelListRow>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { SidePanelAccordionBox } from '@/components/Session/SidePanelAccordionBox';
|
||||
import {
|
||||
CountPill,
|
||||
ProgressCircle,
|
||||
ProgressConnector,
|
||||
SidePanelListRow,
|
||||
} from '@/components/Session/SidePanelSections/primitives';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import { TaskStatus } from '@/types/constants';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
function isDone(task: TaskInfo) {
|
||||
return task.status === TaskStatus.COMPLETED;
|
||||
}
|
||||
|
||||
interface ProgressSectionProps {
|
||||
title: string;
|
||||
subtasks: TaskInfo[];
|
||||
projectId?: string | null;
|
||||
taskId?: string | null;
|
||||
}
|
||||
|
||||
export function ProgressSection({
|
||||
title,
|
||||
subtasks,
|
||||
projectId,
|
||||
taskId,
|
||||
}: ProgressSectionProps) {
|
||||
const visibleSubtasks = useMemo(
|
||||
() => subtasks.filter((task) => task.content.trim() !== ''),
|
||||
[subtasks]
|
||||
);
|
||||
const count = visibleSubtasks.length;
|
||||
const requestTaskBoxFocus = usePageTabStore((s) => s.requestTaskBoxFocus);
|
||||
|
||||
const collapsedStrip =
|
||||
count > 0 ? (
|
||||
<div className="gap-1 min-w-0 mx-1 flex items-center overflow-hidden">
|
||||
<AnimatePresence initial={false}>
|
||||
{visibleSubtasks.map((task, idx) => (
|
||||
<motion.span
|
||||
key={task.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.6 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.6 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="gap-1 min-w-0 flex items-center"
|
||||
>
|
||||
<ProgressCircle done={isDone(task)} />
|
||||
{idx < visibleSubtasks.length - 1 ? <ProgressConnector /> : null}
|
||||
</motion.span>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<SidePanelAccordionBox
|
||||
title={title}
|
||||
titleSuffix={count > 0 ? <CountPill count={count} /> : null}
|
||||
>
|
||||
{({ open }) => {
|
||||
if (!open) {
|
||||
return collapsedStrip;
|
||||
}
|
||||
if (count === 0) {
|
||||
return (
|
||||
<div className="text-ds-text-neutral-subtle-default text-body-sm px-1 py-1 opacity-60">
|
||||
Follow each plan step and its status as this task runs.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.ul layout className="p-0 m-0 space-y-0.5 list-none">
|
||||
<AnimatePresence initial={false}>
|
||||
{visibleSubtasks.map((task) => (
|
||||
<motion.li
|
||||
key={task.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<SidePanelListRow
|
||||
className="hover:bg-ds-bg-neutral-subtle-default"
|
||||
leading={<ProgressCircle done={isDone(task)} />}
|
||||
onClick={() => requestTaskBoxFocus(projectId, taskId)}
|
||||
>
|
||||
<span className={cn(isDone(task) && 'line-through')}>
|
||||
{task.content}
|
||||
</span>
|
||||
</SidePanelListRow>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.ul>
|
||||
);
|
||||
}}
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,346 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { FileText, Hammer, WandSparkles } from 'lucide-react';
|
||||
import { createElement } from 'react';
|
||||
import type { ContextItem } from './ExecutionContextSection';
|
||||
|
||||
/**
|
||||
* Minimal shape this builder needs from a skill record. Mirrors the
|
||||
* skillsStore's `Skill` interface but kept local so this module doesn't
|
||||
* depend on the Zustand store.
|
||||
*/
|
||||
export interface ContextSkill {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
scope?: { isGlobal?: boolean; selectedAgents?: string[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a toolkit/server/skill name for dedup and hint-matching.
|
||||
* Lowercases, strips whitespace/underscores/hyphens, and drops a trailing
|
||||
* "toolkit" so e.g. "Google Calendar Toolkit", "google_calendar", and
|
||||
* "google-calendar" all collapse to the same key.
|
||||
*/
|
||||
function normalizeKey(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+toolkit\s*$/i, '')
|
||||
.replace(/[\s_-]+/g, '');
|
||||
}
|
||||
|
||||
function categoryFromCategoryName(
|
||||
categoryName: string | undefined
|
||||
): 'skill' | 'connector' {
|
||||
return (categoryName ?? '').toLowerCase() === 'skill' ? 'skill' : 'connector';
|
||||
}
|
||||
|
||||
const SKILL_TOOLKIT_NAME = 'SkillToolkit';
|
||||
/**
|
||||
* Method names show up in two flavors depending on which path emitted the
|
||||
* event: `@listen_toolkit` rewrites `_` → ` ` ("load skill"), while the
|
||||
* agent's untracked-tool path passes the raw Python identifier
|
||||
* ("load_skill"). We normalize before comparing so both flavors match.
|
||||
*/
|
||||
const SKILL_LOAD_METHOD = 'load skill';
|
||||
const SKILL_LIST_METHOD = 'list skills';
|
||||
|
||||
function normalizeMethodName(method: string): string {
|
||||
return method.trim().toLowerCase().replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Substring (not word-boundary) checks: backend toolkit class names like
|
||||
* `SkillToolkit` and `XxxMCPToolkit` don't have a word boundary after
|
||||
* "skill"/"mcp", so a `\bskill\b` / `\bmcp\b` regex would silently miss them.
|
||||
*/
|
||||
function isSkillToolkitName(name: string): boolean {
|
||||
return /skill/i.test(name);
|
||||
}
|
||||
|
||||
function isMcpToolkitName(name: string): boolean {
|
||||
return /mcp/i.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull skill name(s) out of a `SkillToolkit.load_skill(...)` args string.
|
||||
*
|
||||
* Two emission paths produce two formats:
|
||||
* 1. **Agent path** (`listen_chat_agent._aexecute_tool` /
|
||||
* `_execute_tool`) emits `message = json.dumps(args)`, e.g.
|
||||
* `{"name":"pdf"}` or `{"name":["pdf","foo"]}`. This is what
|
||||
* SkillToolkit currently goes through because its `load_skill` /
|
||||
* `list_skills` methods aren't `@listen_toolkit`-decorated.
|
||||
* 2. **`@listen_toolkit` path** (other toolkits) emits Python `repr`
|
||||
* formatted args, e.g. `'pdf'` or `name='pdf'` or `['pdf','foo']`.
|
||||
* Kept as a fallback in case SkillToolkit ever gets decorated.
|
||||
*
|
||||
* Once the tool deactivates, chatStore concatenates the activate args and
|
||||
* the deactivate result with a `\n`, so the args are on the first line and
|
||||
* the rest of `message` is the skill body. Backend may also append a
|
||||
* "(truncated, …)" tail at 500 chars — we strip it.
|
||||
*/
|
||||
function extractLoadedSkillNames(message: string): string[] {
|
||||
if (!message) return [];
|
||||
|
||||
// Args (if present) sit on the first line — the deactivate result is
|
||||
// appended after a newline. Try the head first, then fall back to the
|
||||
// whole string if the head doesn't yield anything.
|
||||
const head = message.split(/\r?\n/)[0] ?? '';
|
||||
const candidates =
|
||||
head.trim() && head.trim() !== message.trim() ? [head, message] : [message];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const cleaned = candidate
|
||||
.replace(/\.\.\.\s*\(truncated[^)]*\)\s*$/i, '')
|
||||
.trim();
|
||||
if (!cleaned) continue;
|
||||
|
||||
// 1. JSON args from the agent path.
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (parsed && typeof parsed === 'object' && 'name' in parsed) {
|
||||
const name = (parsed as { name: unknown }).name;
|
||||
if (Array.isArray(name)) {
|
||||
const items = name
|
||||
.filter((n): n is string => typeof n === 'string')
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean);
|
||||
if (items.length) return items;
|
||||
} else if (typeof name === 'string' && name.trim()) {
|
||||
return [name.trim()];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — fall through to repr parsing.
|
||||
}
|
||||
|
||||
// 2. Python-repr fallback (`@listen_toolkit` formatting).
|
||||
const noKw = cleaned.replace(/^\s*name\s*=\s*/i, '').trim();
|
||||
if (noKw.startsWith('[')) {
|
||||
const items: string[] = [];
|
||||
const re = /['"]([^'"]+?)['"]/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(noKw)) !== null) {
|
||||
const v = m[1].trim();
|
||||
if (v) items.push(v);
|
||||
}
|
||||
if (items.length) return items;
|
||||
}
|
||||
const quoted = noKw.match(/^['"]([^'"]+?)['"]/);
|
||||
if (quoted) return [quoted[1].trim()];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
type RuntimeToolkit = {
|
||||
toolkitName: string;
|
||||
method: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function forEachRuntimeToolkit(
|
||||
agents: Agent[],
|
||||
taskRunning: TaskInfo[] | undefined,
|
||||
fn: (tk: RuntimeToolkit) => void
|
||||
) {
|
||||
for (const agent of agents) {
|
||||
for (const task of agent.tasks ?? []) {
|
||||
for (const tk of task.toolkits ?? []) {
|
||||
const name = tk.toolkitName;
|
||||
if (!name || name === 'notice') continue;
|
||||
fn({
|
||||
toolkitName: name,
|
||||
method: tk.toolkitMethods ?? '',
|
||||
message: tk.message ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const task of taskRunning ?? []) {
|
||||
for (const tk of task.toolkits ?? []) {
|
||||
const name = tk.toolkitName;
|
||||
if (!name || name === 'notice') continue;
|
||||
fn({
|
||||
toolkitName: name,
|
||||
method: tk.toolkitMethods ?? '',
|
||||
message: tk.message ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect classification hints from per-agent `workerInfo` and the skills
|
||||
* store. These are used **only** to bucket runtime toolkit names into
|
||||
* skill vs connector — configured items are not surfaced until they
|
||||
* actually fire at runtime, so the panel stays scoped to "what was used in
|
||||
* this task".
|
||||
*/
|
||||
function collectHints(agents: Agent[], skills: ContextSkill[]) {
|
||||
const skillHints = new Set<string>();
|
||||
const connectorHints = new Set<string>();
|
||||
|
||||
const add = (set: Set<string>, raw: string) => {
|
||||
const k = normalizeKey(raw);
|
||||
if (k) set.add(k);
|
||||
};
|
||||
|
||||
for (const agent of agents) {
|
||||
const info = agent.workerInfo;
|
||||
if (!info) continue;
|
||||
|
||||
const mcp: unknown = info.mcp_tools;
|
||||
if (mcp && typeof mcp === 'object') {
|
||||
const servers = (mcp as { mcpServers?: Record<string, unknown> })
|
||||
.mcpServers;
|
||||
if (servers && typeof servers === 'object') {
|
||||
for (const name of Object.keys(servers)) add(connectorHints, name);
|
||||
}
|
||||
}
|
||||
|
||||
const selected: unknown = info.selectedTools;
|
||||
if (Array.isArray(selected)) {
|
||||
for (const raw of selected) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const item = raw as {
|
||||
name?: string;
|
||||
key?: string;
|
||||
toolkit?: string;
|
||||
category?: { name?: string };
|
||||
};
|
||||
const label = item.name ?? item.key ?? item.toolkit;
|
||||
if (!label) continue;
|
||||
const cat = categoryFromCategoryName(item.category?.name);
|
||||
const set = cat === 'skill' ? skillHints : connectorHints;
|
||||
add(set, label);
|
||||
if (item.toolkit) add(set, item.toolkit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const skill of skills) {
|
||||
if (!skill.enabled) continue;
|
||||
add(skillHints, skill.name);
|
||||
}
|
||||
|
||||
return { skillHints, connectorHints };
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a flat, deduplicated list of context items (skills / MCP tools /
|
||||
* referenced files) for the **active task**. Only items the task has
|
||||
* actually used at runtime appear here — configured-but-unused skills and
|
||||
* MCP servers are intentionally hidden so the panel reflects work in
|
||||
* flight, not the user's library.
|
||||
*
|
||||
* Sources:
|
||||
* 1. Runtime toolkit usage from `task.toolkits` /
|
||||
* `taskRunning[].toolkits` (ACTIVATE_TOOLKIT). For `SkillToolkit` the
|
||||
* *method* is the skill name and is surfaced one row per skill;
|
||||
* everything else surfaces by toolkit name. Classification falls
|
||||
* back to substring tests against the toolkit name itself, so
|
||||
* `SkillToolkit` / `XxxMCPToolkit` always classify even with no
|
||||
* hints.
|
||||
* 2. Uploaded files referenced on user messages.
|
||||
*
|
||||
* The `skills` and `workerInfo` data are read **only** to seed
|
||||
* classification hints for ambiguous toolkit names — they never produce
|
||||
* standalone rows.
|
||||
*/
|
||||
export function buildContextItems(
|
||||
agents: Agent[],
|
||||
taskRunning?: TaskInfo[],
|
||||
uploadedFiles: File[] = [],
|
||||
skills: ContextSkill[] = []
|
||||
): ContextItem[] {
|
||||
const seen = new Set<string>();
|
||||
const out: ContextItem[] = [];
|
||||
|
||||
const push = (item: ContextItem) => {
|
||||
const key = `${item.category}:${normalizeKey(item.id)}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push(item);
|
||||
};
|
||||
|
||||
const { skillHints, connectorHints } = collectHints(agents, skills);
|
||||
|
||||
forEachRuntimeToolkit(
|
||||
agents,
|
||||
taskRunning,
|
||||
({ toolkitName, method, message }) => {
|
||||
// SkillToolkit only exposes two methods to the agent: `list_skills`
|
||||
// and `load_skill(name)`. The skill the user actually invoked is the
|
||||
// *argument* to `load_skill`, never the method name. So we ignore
|
||||
// `list skills` (it's just enumeration) and parse the args of
|
||||
// `load skill` to surface one row per loaded skill.
|
||||
if (toolkitName === SKILL_TOOLKIT_NAME) {
|
||||
const m = normalizeMethodName(method);
|
||||
if (m === SKILL_LIST_METHOD) return;
|
||||
if (m === SKILL_LOAD_METHOD) {
|
||||
for (const skillName of extractLoadedSkillNames(message)) {
|
||||
push({
|
||||
id: `skill:${skillName}`,
|
||||
label: skillName,
|
||||
category: 'skill',
|
||||
icon: createElement(WandSparkles, { size: 16 }),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Unknown method on SkillToolkit — never surface the umbrella row.
|
||||
// If a real skill was invoked we'd have hit the `load_skill` branch
|
||||
// above; falling through here would just re-display "SkillToolkit".
|
||||
return;
|
||||
}
|
||||
|
||||
const norm = normalizeKey(toolkitName);
|
||||
let category: ContextItem['category'] | null = null;
|
||||
if (skillHints.has(norm) || isSkillToolkitName(toolkitName)) {
|
||||
category = 'skill';
|
||||
} else if (connectorHints.has(norm) || isMcpToolkitName(toolkitName)) {
|
||||
category = 'connector';
|
||||
}
|
||||
if (!category) return;
|
||||
|
||||
push({
|
||||
id: toolkitName,
|
||||
label: toolkitName,
|
||||
category,
|
||||
icon:
|
||||
category === 'skill'
|
||||
? createElement(WandSparkles, { size: 16 })
|
||||
: createElement(Hammer, { size: 16 }),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
for (const file of uploadedFiles) {
|
||||
const filePath = file.filePath?.trim();
|
||||
if (!filePath) continue;
|
||||
const fallbackName = filePath.split('/').pop() || filePath;
|
||||
const label = file.fileName?.trim() || fallbackName;
|
||||
push({
|
||||
id: filePath,
|
||||
label,
|
||||
category: 'file',
|
||||
icon: createElement(FileText, { size: 16 }),
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check } from 'lucide-react';
|
||||
import { type ReactNode, forwardRef } from 'react';
|
||||
|
||||
/**
|
||||
* Small round count/label pill used next to accordion titles.
|
||||
*/
|
||||
export function CountPill({ count }: { count: number }) {
|
||||
return (
|
||||
<span className="bg-ds-bg-neutral-subtle-default text-ds-text-neutral-subtle-default text-label-xs font-bold px-1.5 inline-flex items-center justify-center rounded-full">
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Small muted category label for grouping list items.
|
||||
*/
|
||||
export function CategoryLabel({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-ds-text-neutral-muted-default text-body-xs px-1 pb-1 pt-2 first:pt-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SidePanelListRowProps = {
|
||||
leading?: ReactNode;
|
||||
children: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
/**
|
||||
* Pointer + subtle hover/active backgrounds without an action (e.g. read-only list rows).
|
||||
* When `onClick` is set, focus ring is included; for hover-only rows it is omitted.
|
||||
*/
|
||||
interactiveHover?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Row primitive used across Agent Pool / Execution Context / Agent Folder sections.
|
||||
* Rendered as a button when `onClick` is provided, otherwise a div.
|
||||
*/
|
||||
export const SidePanelListRow = forwardRef<HTMLElement, SidePanelListRowProps>(
|
||||
(
|
||||
{
|
||||
leading,
|
||||
children,
|
||||
trailing,
|
||||
disabled,
|
||||
onClick,
|
||||
interactiveHover,
|
||||
className,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const showAffordance = Boolean(onClick || interactiveHover);
|
||||
const base = cn(
|
||||
'group gap-2 px-1.5 py-1.5 rounded-md min-w-0 w-full flex items-center',
|
||||
'text-ds-text-neutral-default-default text-body-sm text-left',
|
||||
'transition-colors',
|
||||
disabled
|
||||
? 'opacity-50 pointer-events-none'
|
||||
: showAffordance
|
||||
? cn(
|
||||
'cursor-pointer hover:bg-ds-bg-neutral-subtle-default active:bg-ds-bg-neutral-subtle-hover',
|
||||
onClick &&
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus/40'
|
||||
)
|
||||
: '',
|
||||
className
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{leading ? (
|
||||
<span className="flex shrink-0 items-center">{leading}</span>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate">{children}</span>
|
||||
{trailing ? (
|
||||
<span className="flex shrink-0 items-center">{trailing}</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={base}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref as React.Ref<HTMLDivElement>} className={base}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidePanelListRow.displayName = 'SidePanelListRow';
|
||||
|
||||
/**
|
||||
* Progress circle. Incomplete: neutral subtle fill so the ring reads on any
|
||||
* panel background. Complete: filled success (matches primary success button)
|
||||
* with inverse check mark.
|
||||
*/
|
||||
export function ProgressCircle({
|
||||
done,
|
||||
size = 14,
|
||||
}: {
|
||||
done: boolean;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-full border-[0.5px] border-solid',
|
||||
done
|
||||
? 'border-ds-bg-success-default-default bg-ds-bg-success-default-default text-ds-text-success-inverse-default'
|
||||
: 'border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default'
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
aria-hidden
|
||||
>
|
||||
{done ? (
|
||||
<Check
|
||||
className="!text-ds-text-success-inverse-default"
|
||||
size={Math.max(8, size - 6)}
|
||||
strokeWidth={4}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin connector line between two progress circles in the folded strip view.
|
||||
*/
|
||||
export function ProgressConnector() {
|
||||
return (
|
||||
<span
|
||||
className="bg-ds-border-neutral-default-default h-px min-w-[6px] flex-1"
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { AgentFolderSection } from '@/components/Session/SidePanelSections/AgentFolderSection';
|
||||
import { ExecutionContextSection } from '@/components/Session/SidePanelSections/ExecutionContextSection';
|
||||
import { ProgressSection } from '@/components/Session/SidePanelSections/ProgressSection';
|
||||
import { buildContextItems } from '@/components/Session/SidePanelSections/buildContextItems';
|
||||
import {
|
||||
collectSidePanelOutputFiles,
|
||||
mergeSidePanelOutputFiles,
|
||||
} from '@/components/Session/SidePanelSections/collectSidePanelOutputFiles';
|
||||
import { useProjectOutputFiles } from '@/components/Session/SidePanelSections/useProjectOutputFiles';
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import { useSkillsStore } from '@/store/skillsStore';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function SingleAgentSidePanel() {
|
||||
const { t } = useTranslation();
|
||||
const { chatStore, projectStore } = useChatStoreAdapter();
|
||||
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
|
||||
|
||||
const selectedTurn = useSelectedProjectTurn(projectStore.activeProjectId);
|
||||
const selectedTask = selectedTurn.task;
|
||||
const selectedTaskId = selectedTurn.taskId;
|
||||
// The visible turn follows chat scrolling, but the filesystem scan is
|
||||
// project-level. Drive it from the active Run so scrolling across history
|
||||
// never turns into a series of identical /files requests.
|
||||
const activeProjectTaskId = chatStore?.activeTaskId ?? null;
|
||||
const activeProjectTask = activeProjectTaskId
|
||||
? chatStore?.tasks[activeProjectTaskId]
|
||||
: undefined;
|
||||
|
||||
const agents = useMemo(
|
||||
() => selectedTask?.taskAssigning ?? [],
|
||||
[selectedTask?.taskAssigning]
|
||||
);
|
||||
const projectFiles = useProjectOutputFiles(
|
||||
projectStore.activeProjectId,
|
||||
activeProjectTask,
|
||||
activeProjectTaskId
|
||||
);
|
||||
/** Prefer live `taskRunning` status (updated on TASK_STATE), keep plan order/text from agent tasks or taskInfo. */
|
||||
const subtasks = useMemo(() => {
|
||||
const base = agents[0]?.tasks ?? selectedTask?.taskInfo ?? [];
|
||||
const taskRunning = selectedTask?.taskRunning ?? [];
|
||||
if (taskRunning.length === 0) return base;
|
||||
return base.map((t) => {
|
||||
const live = taskRunning.find((r) => r.id === t.id);
|
||||
if (!live) return t;
|
||||
return { ...t, ...live, content: t.content || live.content };
|
||||
});
|
||||
}, [agents, selectedTask?.taskInfo, selectedTask?.taskRunning]);
|
||||
const files = useMemo(
|
||||
() =>
|
||||
mergeSidePanelOutputFiles(
|
||||
collectSidePanelOutputFiles(selectedTask),
|
||||
projectFiles
|
||||
),
|
||||
[selectedTask, projectFiles]
|
||||
);
|
||||
const uploadedFiles = useMemo(() => {
|
||||
if (!selectedTask) return [];
|
||||
const all = [
|
||||
...(selectedTask.messages ?? [])
|
||||
.filter((m) => m.role === 'user')
|
||||
.flatMap((m) => m.attaches ?? []),
|
||||
...(selectedTask.attaches ?? []),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return all.filter((file) => {
|
||||
const key = file.filePath;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}, [selectedTask]);
|
||||
const skills = useSkillsStore((s) => s.skills);
|
||||
const contextItems = useMemo(
|
||||
() =>
|
||||
buildContextItems(
|
||||
agents,
|
||||
selectedTask?.taskRunning,
|
||||
uploadedFiles,
|
||||
skills
|
||||
),
|
||||
[agents, selectedTask?.taskRunning, uploadedFiles, skills]
|
||||
);
|
||||
|
||||
const handleOpenAgentFile = useCallback(
|
||||
(file: FileInfo) => {
|
||||
openFilePreview(file);
|
||||
},
|
||||
[openFilePreview]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden',
|
||||
'relative'
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden px-2 pb-2">
|
||||
<ProgressSection
|
||||
title={t('layout.workforce-progress', { defaultValue: 'Progress' })}
|
||||
subtasks={subtasks}
|
||||
projectId={projectStore.activeProjectId}
|
||||
taskId={selectedTaskId}
|
||||
/>
|
||||
<ExecutionContextSection
|
||||
title={t('layout.execution-context', {
|
||||
defaultValue: 'Execution Context',
|
||||
})}
|
||||
items={contextItems}
|
||||
/>
|
||||
<AgentFolderSection
|
||||
title={t('layout.workforce-agent-folder', {
|
||||
defaultValue: 'Agent Folder',
|
||||
})}
|
||||
files={files}
|
||||
onOpenFile={handleOpenAgentFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
|
||||
import { ChatTaskStatus } from '@/types/constants';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { useEffect, useReducer } from 'react';
|
||||
|
||||
function LiveDot() {
|
||||
return (
|
||||
<span
|
||||
className="h-2 w-2 bg-ds-bg-brand-default-default animate-pulse inline-flex shrink-0 rounded-full"
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === ChatTaskStatus.FINISHED
|
||||
? 'bg-ds-bg-success-default-default'
|
||||
: status === 'failed'
|
||||
? 'bg-ds-bg-error-default-default'
|
||||
: 'bg-ds-border-neutral-default-default';
|
||||
return (
|
||||
<span
|
||||
className={cn('h-2 w-2 inline-flex shrink-0 rounded-full', cls)}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface TurnEntry {
|
||||
taskId: string;
|
||||
status: string;
|
||||
prompt: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown button showing "Run N ▼" next to the fold button.
|
||||
* Hidden when the project has ≤ 1 turn.
|
||||
*/
|
||||
export function TurnTabs() {
|
||||
const projectStore = useProjectRuntimeStore();
|
||||
const activeProjectId = projectStore.activeProjectId;
|
||||
|
||||
const [, forceUpdate] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => {
|
||||
if (!activeProjectId) return;
|
||||
const stores = projectStore.getAllChatStores(activeProjectId);
|
||||
if (!stores.length) return;
|
||||
const unsubs = stores.map(({ chatStore }) =>
|
||||
chatStore.subscribe(forceUpdate)
|
||||
);
|
||||
return () => unsubs.forEach((fn) => fn());
|
||||
}, [activeProjectId, projectStore]);
|
||||
|
||||
// Collect all visible turns, sorted oldest-first for chronological numbering
|
||||
const turns: TurnEntry[] = (() => {
|
||||
if (!activeProjectId) return [];
|
||||
const stores = projectStore.getAllChatStores(activeProjectId);
|
||||
const seen = new Set<string>();
|
||||
const result: TurnEntry[] = [];
|
||||
for (const { chatStore } of stores) {
|
||||
const state = chatStore.getState();
|
||||
for (const [taskId, task] of Object.entries(state.tasks)) {
|
||||
if (seen.has(taskId)) continue;
|
||||
const userMsg = (task.messages ?? []).find(
|
||||
(m: any) => m.role === 'user' && m.content
|
||||
);
|
||||
if (!userMsg) continue;
|
||||
seen.add(taskId);
|
||||
result.push({
|
||||
taskId,
|
||||
status: task.status ?? ChatTaskStatus.PENDING,
|
||||
prompt: String(userMsg.content ?? ''),
|
||||
createdAt: task.createdAt ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
result.sort((a, b) => a.createdAt - b.createdAt);
|
||||
return result;
|
||||
})();
|
||||
|
||||
const activeChatStore = projectStore.getActiveChatStore(
|
||||
activeProjectId ?? undefined
|
||||
);
|
||||
const activeTaskId = activeChatStore?.getState().activeTaskId ?? null;
|
||||
|
||||
const selectedByProject = usePageTabStore(
|
||||
(s) => s.sidePanelSelectedTurnByProject
|
||||
);
|
||||
const setSidePanelSelectedTurn = usePageTabStore(
|
||||
(s) => s.setSidePanelSelectedTurn
|
||||
);
|
||||
const setScrollToTurnRequest = usePageTabStore(
|
||||
(s) => s.setScrollToTurnRequest
|
||||
);
|
||||
|
||||
const projectId = activeProjectId ?? '';
|
||||
// Both tab clicks and (post-manual-window) viewport scrolls write here,
|
||||
// so there is no Date.now() needed at render time.
|
||||
const effectiveSelectedId = selectedByProject[projectId] ?? activeTaskId;
|
||||
|
||||
if (turns.length <= 1) return null;
|
||||
|
||||
// 1-based turn number of the currently selected turn
|
||||
const selectedIndex = turns.findIndex(
|
||||
(t) => t.taskId === effectiveSelectedId
|
||||
);
|
||||
const selectedTurnNumber =
|
||||
selectedIndex >= 0 ? selectedIndex + 1 : turns.length;
|
||||
|
||||
const handleSelect = (taskId: string) => {
|
||||
if (!projectId) return;
|
||||
setSidePanelSelectedTurn(projectId, taskId, 5000);
|
||||
setScrollToTurnRequest({ projectId, taskId });
|
||||
};
|
||||
|
||||
// Display newest-first in the dropdown
|
||||
const displayTurns = [...turns].reverse();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
buttonRadius="lg"
|
||||
className="opacity-60"
|
||||
aria-label={`Run ${selectedTurnNumber}, click to switch turn`}
|
||||
>
|
||||
Run {selectedTurnNumber}
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{displayTurns.map((turn, displayIdx) => {
|
||||
const turnNumber = turns.length - displayIdx;
|
||||
const isSelected = turn.taskId === effectiveSelectedId;
|
||||
const isLive =
|
||||
turn.status === ChatTaskStatus.RUNNING ||
|
||||
turn.status === ChatTaskStatus.PENDING;
|
||||
const preview =
|
||||
turn.prompt.length > 28
|
||||
? turn.prompt.slice(0, 28) + '…'
|
||||
: turn.prompt;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={turn.taskId}
|
||||
onSelect={() => handleSelect(turn.taskId)}
|
||||
className="gap-2"
|
||||
>
|
||||
{isLive ? <LiveDot /> : <StatusDot status={turn.status} />}
|
||||
<span className="text-ds-text-neutral-muted-default text-label-xs font-semibold shrink-0">
|
||||
Run {turnNumber}
|
||||
</span>
|
||||
<span className="text-ds-text-neutral-subtle-default text-label-xs min-w-0 flex-1 truncate">
|
||||
{preview}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<Check className="size-3 text-ds-icon-brand-default-default ml-auto shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { AgentFolderSection } from '@/components/Session/SidePanelSections/AgentFolderSection';
|
||||
import { AgentPoolSection } from '@/components/Session/SidePanelSections/AgentPoolSection';
|
||||
import { buildContextItems } from '@/components/Session/SidePanelSections/buildContextItems';
|
||||
import {
|
||||
collectSidePanelOutputFiles,
|
||||
mergeSidePanelOutputFiles,
|
||||
} from '@/components/Session/SidePanelSections/collectSidePanelOutputFiles';
|
||||
import { ExecutionContextSection } from '@/components/Session/SidePanelSections/ExecutionContextSection';
|
||||
import { ProgressSection } from '@/components/Session/SidePanelSections/ProgressSection';
|
||||
import { useProjectOutputFiles } from '@/components/Session/SidePanelSections/useProjectOutputFiles';
|
||||
import ExpandedOverlay from '@/components/Session/Workforce/ExpandedOverlay';
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import { useSkillsStore } from '@/store/skillsStore';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Main column under `SessionSidePanel` header: fills remaining height in flex parent */
|
||||
export const WORKFORCE_MAIN_SURFACE_CLASS =
|
||||
'min-w-0 flex w-full min-h-0 flex-1 flex-col overflow-hidden';
|
||||
|
||||
export interface WorkforceSidePanelProps {
|
||||
workforcePanelKey: string;
|
||||
hasAnyMessages: boolean;
|
||||
isSidePanelVisible: boolean;
|
||||
onToggleSidePanel: () => void;
|
||||
/** Controlled: whether the full-screen workforce overlay is open. */
|
||||
isExpandedOverlayOpen: boolean;
|
||||
onToggleExpandedOverlay: () => void;
|
||||
onCloseExpandedOverlay: () => void;
|
||||
}
|
||||
|
||||
export function WorkforceSidePanel({
|
||||
workforcePanelKey,
|
||||
hasAnyMessages: _hasAnyMessages,
|
||||
isSidePanelVisible,
|
||||
onToggleSidePanel,
|
||||
isExpandedOverlayOpen,
|
||||
onToggleExpandedOverlay: _onToggleExpandedOverlay,
|
||||
onCloseExpandedOverlay,
|
||||
}: WorkforceSidePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { chatStore, projectStore } = useChatStoreAdapter();
|
||||
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
|
||||
|
||||
const selectedTurn = useSelectedProjectTurn(projectStore.activeProjectId);
|
||||
const selectedTask = selectedTurn.task;
|
||||
const selectedTaskId = selectedTurn.taskId;
|
||||
// Viewport scrolling selects a historical turn for display only. Project
|
||||
// file discovery follows the active Run, otherwise every turn boundary
|
||||
// crossed while scrolling causes the same project directory to be scanned.
|
||||
const activeProjectTaskId = chatStore?.activeTaskId ?? null;
|
||||
const activeProjectTask = activeProjectTaskId
|
||||
? chatStore?.tasks[activeProjectTaskId]
|
||||
: undefined;
|
||||
|
||||
const agents = useMemo(
|
||||
() => selectedTask?.taskAssigning ?? [],
|
||||
[selectedTask?.taskAssigning]
|
||||
);
|
||||
const projectFiles = useProjectOutputFiles(
|
||||
projectStore.activeProjectId,
|
||||
activeProjectTask,
|
||||
activeProjectTaskId
|
||||
);
|
||||
/** Subtask status is updated in `taskRunning` (e.g. TASK_STATE); `taskInfo` keeps plan text/order. */
|
||||
const subtasks = useMemo(() => {
|
||||
const taskInfo = selectedTask?.taskInfo ?? [];
|
||||
const taskRunning = selectedTask?.taskRunning ?? [];
|
||||
if (taskRunning.length === 0) return taskInfo;
|
||||
const runById = new Map(
|
||||
taskRunning.map((r) => [r.id, r] as [string, TaskInfo])
|
||||
);
|
||||
return taskInfo.map((t) => {
|
||||
const live = runById.get(t.id);
|
||||
if (!live) return t;
|
||||
return { ...t, ...live, content: t.content || live.content };
|
||||
});
|
||||
}, [selectedTask?.taskInfo, selectedTask?.taskRunning]);
|
||||
const files = useMemo(
|
||||
() =>
|
||||
mergeSidePanelOutputFiles(
|
||||
collectSidePanelOutputFiles(selectedTask),
|
||||
projectFiles
|
||||
),
|
||||
[selectedTask, projectFiles]
|
||||
);
|
||||
const uploadedFiles = useMemo(() => {
|
||||
if (!selectedTask) return [];
|
||||
const all = [
|
||||
...(selectedTask.messages ?? [])
|
||||
.filter((m) => m.role === 'user')
|
||||
.flatMap((m) => m.attaches ?? []),
|
||||
...(selectedTask.attaches ?? []),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return all.filter((file) => {
|
||||
const key = file.filePath;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}, [selectedTask]);
|
||||
const skills = useSkillsStore((s) => s.skills);
|
||||
const contextItems = useMemo(
|
||||
() =>
|
||||
buildContextItems(
|
||||
agents,
|
||||
selectedTask?.taskRunning,
|
||||
uploadedFiles,
|
||||
skills
|
||||
),
|
||||
[agents, selectedTask?.taskRunning, uploadedFiles, skills]
|
||||
);
|
||||
|
||||
const handleOpenAgentFile = useCallback(
|
||||
(file: FileInfo) => {
|
||||
openFilePreview(file);
|
||||
},
|
||||
[openFilePreview]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(WORKFORCE_MAIN_SURFACE_CLASS, 'relative')}>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden px-2 pb-2">
|
||||
<AgentPoolSection
|
||||
title={t('layout.workforce-agent-pool', {
|
||||
defaultValue: 'Agent Pool',
|
||||
})}
|
||||
agents={agents}
|
||||
/>
|
||||
<ProgressSection
|
||||
title={t('layout.workforce-progress', {
|
||||
defaultValue: 'Progress',
|
||||
})}
|
||||
subtasks={subtasks}
|
||||
projectId={projectStore.activeProjectId}
|
||||
taskId={selectedTaskId}
|
||||
/>
|
||||
<ExecutionContextSection
|
||||
title={t('layout.execution-context', {
|
||||
defaultValue: 'Execution Context',
|
||||
})}
|
||||
items={contextItems}
|
||||
/>
|
||||
<AgentFolderSection
|
||||
title={t('layout.workforce-agent-folder', {
|
||||
defaultValue: 'Agent Folder',
|
||||
})}
|
||||
files={files}
|
||||
onOpenFile={handleOpenAgentFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExpandedOverlay
|
||||
open={isExpandedOverlayOpen}
|
||||
onClose={onCloseExpandedOverlay}
|
||||
workforcePanelKey={workforcePanelKey}
|
||||
onToggleSidePanel={onToggleSidePanel}
|
||||
isSidePanelVisible={isSidePanelVisible}
|
||||
selectedTurn={selectedTurn}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ import { HeaderBox } from '@/components/Session/HeaderBox';
|
|||
import { PreviewPanel } from '@/components/Session/PreviewPanel';
|
||||
import Workspace from '@/components/Workspace';
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
|
||||
import { ProjectEventRuntimeProvider } from '@/hooks/useProjectEventRuntime';
|
||||
import { inferSessionModeFromTask } from '@/lib/sessionMode';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore';
|
||||
|
|
@ -30,11 +30,11 @@ import {
|
|||
} from '@/types/constants';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { SessionSidePanel } from './SessionSidePanel';
|
||||
import { SessionSidePanel } from './SidePanel';
|
||||
import {
|
||||
SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS,
|
||||
SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS,
|
||||
} from './sessionSidePanelLayout';
|
||||
} from './SidePanel/layout';
|
||||
|
||||
/** Maximum width the resizable chat column can reclaim while display is open. */
|
||||
const CHAT_PRIORITY_WIDTH = 680;
|
||||
|
|
@ -109,21 +109,6 @@ export default function Session({ isNewProject = false }: SessionProps) {
|
|||
return projectStore.getAllChatStores(projectStore.activeProjectId);
|
||||
}, [projectStore]);
|
||||
|
||||
const hasAnyMessages = useMemo(() => {
|
||||
const hasMessages = (store: typeof chatStore) =>
|
||||
!!store &&
|
||||
Object.values(store.tasks).some(
|
||||
(task) => (task.messages?.length || 0) > 0 || task.hasMessages
|
||||
);
|
||||
if (hasMessages(chatStore)) return true;
|
||||
return getAllChatStoresMemoized.some(({ chatStore: store }) => {
|
||||
const state = store.getState();
|
||||
return Object.values(state.tasks).some(
|
||||
(task) => (task.messages?.length || 0) > 0 || task.hasMessages
|
||||
);
|
||||
});
|
||||
}, [chatStore, getAllChatStoresMemoized]);
|
||||
|
||||
const workforcePanelKey = chatStore?.activeTaskId ?? '';
|
||||
|
||||
const hasSessionStarted = useMemo(() => {
|
||||
|
|
@ -137,7 +122,7 @@ export default function Session({ isNewProject = false }: SessionProps) {
|
|||
// assume the project never started, and bounce the user back to the
|
||||
// workforce shell — even though the project chatStore already has
|
||||
// task content. Cross-check live state via `getAllChatStores` (same
|
||||
// pattern as `hasAnyMessages` above) to avoid that race.
|
||||
// pattern as the project-wide store lookup above) to avoid that race.
|
||||
const checkTasks = (tasksRecord: Record<string, unknown> | undefined) => {
|
||||
if (!tasksRecord) return false;
|
||||
return Object.values(tasksRecord).some((task) => {
|
||||
|
|
@ -323,20 +308,17 @@ export default function Session({ isNewProject = false }: SessionProps) {
|
|||
);
|
||||
|
||||
// "Context" breadcrumb / empty-state action: open the Inbox tab for this file.
|
||||
const selectedTurn = useSelectedProjectTurn(activeProjectId);
|
||||
const handleJumpToContext = useCallback(
|
||||
(file: FileInfo | null) => {
|
||||
if (file && selectedTurn.taskId && selectedTurn.chatStore) {
|
||||
selectedTurn.chatStore
|
||||
.getState()
|
||||
.setSelectedFile(selectedTurn.taskId, file);
|
||||
if (file && chatStore?.activeTaskId) {
|
||||
chatStore.setSelectedFile(chatStore.activeTaskId, file);
|
||||
}
|
||||
setActiveWorkspaceTab('inbox', {
|
||||
clearInboxForProjectId: activeProjectId ?? null,
|
||||
});
|
||||
closeSessionPreview();
|
||||
},
|
||||
[selectedTurn, setActiveWorkspaceTab, activeProjectId, closeSessionPreview]
|
||||
[chatStore, setActiveWorkspaceTab, activeProjectId, closeSessionPreview]
|
||||
);
|
||||
|
||||
const toggleExpandedOverlay = useCallback(() => {
|
||||
|
|
@ -356,7 +338,6 @@ export default function Session({ isNewProject = false }: SessionProps) {
|
|||
key={displaySessionMode}
|
||||
mode={displaySessionMode}
|
||||
workforcePanelKey={workforcePanelKey}
|
||||
hasAnyMessages={hasAnyMessages}
|
||||
isSidePanelVisible={isSidePanelVisible}
|
||||
onToggleSidePanel={toggleSidePanel}
|
||||
isExpandedOverlayOpen={isExpandedOverlayOpen}
|
||||
|
|
@ -364,21 +345,120 @@ export default function Session({ isNewProject = false }: SessionProps) {
|
|||
onCloseExpandedOverlay={closeExpandedOverlay}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (isNewProject) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<HeaderBox empty />
|
||||
// The new-project tab deliberately preserves the last active Project in
|
||||
// ProjectStore until the first message creates its replacement. Do not
|
||||
// let that navigation convenience scope the SidePanel to the old Run.
|
||||
<ProjectEventRuntimeProvider projectId={null}>
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<Workspace
|
||||
variant="new-project"
|
||||
embedded
|
||||
sessionMode={displaySessionMode ?? SessionMode.SINGLE_AGENT}
|
||||
onSessionModeChange={handleNewProjectSessionModeChange}
|
||||
/>
|
||||
<HeaderBox empty />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<Workspace
|
||||
variant="new-project"
|
||||
embedded
|
||||
sessionMode={displaySessionMode ?? SessionMode.SINGLE_AGENT}
|
||||
onSessionModeChange={handleNewProjectSessionModeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="session-side-panel"
|
||||
className={cn(
|
||||
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-out',
|
||||
isSidePanelVisible
|
||||
? SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS
|
||||
: cn(SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS, 'rounded-l-xl')
|
||||
)}
|
||||
>
|
||||
{sessionSidePanel}
|
||||
</div>
|
||||
</div>
|
||||
</ProjectEventRuntimeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectEventRuntimeProvider projectId={activeProjectId}>
|
||||
<div
|
||||
ref={chatRowRef}
|
||||
className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden"
|
||||
>
|
||||
{/* Chat content: owns the project header and folds when display opens. */}
|
||||
<div
|
||||
style={previewOpen ? { width: chatWidth } : undefined}
|
||||
className={cn(
|
||||
'flex min-h-0 min-w-0 flex-col overflow-hidden',
|
||||
previewOpen ? 'shrink-0' : 'flex-1',
|
||||
!isResizingPreview && 'transition-[width] duration-200 ease-out'
|
||||
)}
|
||||
>
|
||||
<HeaderBox
|
||||
projectName={activeProjectMeta?.name}
|
||||
totalTokens={
|
||||
chatStore.activeTaskId
|
||||
? chatStore.tasks[chatStore.activeTaskId]?.tokens || 0
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<ChatBox />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{previewOpen && (
|
||||
<motion.div
|
||||
key="session-display-content"
|
||||
initial={{
|
||||
clipPath: 'inset(0 0 0 100%)',
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
clipPath: 'inset(0 0 0 0%)',
|
||||
opacity: 1,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
exit={{
|
||||
clipPath: 'inset(0 0 0 100%)',
|
||||
opacity: 0,
|
||||
flexGrow: 0,
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: DISPLAY_PANEL_EASE }}
|
||||
style={{ transformOrigin: 'right center' }}
|
||||
className="flex min-h-0 min-w-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
onPointerDown={handlePreviewResizeStart}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
data-resize-handle-state={
|
||||
isResizingPreview ? 'drag' : 'inactive'
|
||||
}
|
||||
className={cn(
|
||||
// Transparent 2px rail with a centered line and wider hit area.
|
||||
'relative z-10 flex w-[2px] shrink-0 cursor-col-resize items-center justify-center bg-transparent transition-colors hover:bg-ds-bg-brand-subtle-default',
|
||||
"before:absolute before:inset-y-0 before:-left-1 before:-right-1 before:content-['']",
|
||||
'after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 after:bg-ds-bg-neutral-default-default after:transition-colors',
|
||||
isResizingPreview &&
|
||||
'bg-ds-bg-brand-subtle-default after:bg-ds-bg-brand-default-focus'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Display content: middle column between chat and session. */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{activeProjectId ? (
|
||||
<PreviewPanel
|
||||
displaySettled={displaySettled}
|
||||
onJumpToContext={handleJumpToContext}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div
|
||||
id="session-side-panel"
|
||||
|
|
@ -392,97 +472,6 @@ export default function Session({ isNewProject = false }: SessionProps) {
|
|||
{sessionSidePanel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={chatRowRef}
|
||||
className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden"
|
||||
>
|
||||
{/* Chat content: owns the project header and folds when display opens. */}
|
||||
<div
|
||||
style={previewOpen ? { width: chatWidth } : undefined}
|
||||
className={cn(
|
||||
'flex min-h-0 min-w-0 flex-col overflow-hidden',
|
||||
previewOpen ? 'shrink-0' : 'flex-1',
|
||||
!isResizingPreview && 'transition-[width] duration-200 ease-out'
|
||||
)}
|
||||
>
|
||||
<HeaderBox
|
||||
projectName={activeProjectMeta?.name}
|
||||
totalTokens={
|
||||
chatStore.activeTaskId
|
||||
? chatStore.tasks[chatStore.activeTaskId]?.tokens || 0
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<ChatBox />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{previewOpen && (
|
||||
<motion.div
|
||||
key="session-display-content"
|
||||
initial={{
|
||||
clipPath: 'inset(0 0 0 100%)',
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
clipPath: 'inset(0 0 0 0%)',
|
||||
opacity: 1,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
exit={{
|
||||
clipPath: 'inset(0 0 0 100%)',
|
||||
opacity: 0,
|
||||
flexGrow: 0,
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: DISPLAY_PANEL_EASE }}
|
||||
style={{ transformOrigin: 'right center' }}
|
||||
className="flex min-h-0 min-w-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
onPointerDown={handlePreviewResizeStart}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
data-resize-handle-state={isResizingPreview ? 'drag' : 'inactive'}
|
||||
className={cn(
|
||||
// Transparent 2px rail with a centered line and wider hit area.
|
||||
'relative z-10 flex w-[2px] shrink-0 cursor-col-resize items-center justify-center bg-transparent transition-colors hover:bg-ds-bg-brand-subtle-default',
|
||||
"before:absolute before:inset-y-0 before:-left-1 before:-right-1 before:content-['']",
|
||||
'after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 after:bg-ds-bg-neutral-default-default after:transition-colors',
|
||||
isResizingPreview &&
|
||||
'bg-ds-bg-brand-subtle-default after:bg-ds-bg-brand-default-focus'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Display content: middle column between chat and session. */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{activeProjectId ? (
|
||||
<PreviewPanel
|
||||
displaySettled={displaySettled}
|
||||
onJumpToContext={handleJumpToContext}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div
|
||||
id="session-side-panel"
|
||||
className={cn(
|
||||
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-out',
|
||||
isSidePanelVisible
|
||||
? SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS
|
||||
: cn(SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS, 'rounded-l-xl')
|
||||
)}
|
||||
>
|
||||
{sessionSidePanel}
|
||||
</div>
|
||||
</div>
|
||||
</ProjectEventRuntimeProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@
|
|||
import { fetchPut } from '@/api/http';
|
||||
import Terminal from '@/components/Terminal';
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import type { SelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
|
||||
import { useHost } from '@/host';
|
||||
import {
|
||||
ArrowDown,
|
||||
|
|
@ -34,11 +33,7 @@ import { useMemo, useRef, useState } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export default function TerminalAgentWorkspace({
|
||||
selectedTurn,
|
||||
}: {
|
||||
selectedTurn?: SelectedProjectTurn;
|
||||
}) {
|
||||
export default function TerminalAgentWorkspace() {
|
||||
//Get Chatstore for the active project's task
|
||||
const host = useHost();
|
||||
const electronAPI = host?.electronAPI;
|
||||
|
|
@ -48,9 +43,8 @@ export default function TerminalAgentWorkspace({
|
|||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isTakeControl, setIsTakeControl] = useState(false);
|
||||
|
||||
const selectedChatState = selectedTurn?.chatStore?.getState();
|
||||
const targetChatStore = selectedChatState ?? chatStore;
|
||||
const activeTaskId = selectedTurn?.taskId ?? targetChatStore?.activeTaskId;
|
||||
const targetChatStore = chatStore;
|
||||
const activeTaskId = targetChatStore?.activeTaskId;
|
||||
const taskAssigning =
|
||||
targetChatStore?.tasks[activeTaskId as string]?.taskAssigning;
|
||||
const activeWorkspace =
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
112
src/hooks/useProjectEventRuntime.tsx
Normal file
112
src/hooks/useProjectEventRuntime.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import {
|
||||
useProjectEventStoreHydration,
|
||||
type ProjectEventStoreHydrationState,
|
||||
} from '@/hooks/useProjectEventStoreHydration';
|
||||
import { useProjectRunEventStreams } from '@/hooks/useProjectRunEventStreams';
|
||||
import {
|
||||
getProjectEventStore,
|
||||
type ProjectEventStoreSnapshot,
|
||||
} from '@/store/projectEventStore';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
const subscribeToNothing = () => () => undefined;
|
||||
const getNullSnapshot = () => null;
|
||||
const retryNothing = () => undefined;
|
||||
|
||||
const IDLE_HYDRATION: ProjectEventStoreHydrationState = {
|
||||
status: 'idle',
|
||||
errorCode: null,
|
||||
eventsTruncated: false,
|
||||
retry: retryNothing,
|
||||
};
|
||||
|
||||
export interface ProjectEventRuntimeValue {
|
||||
hydration: ProjectEventStoreHydrationState;
|
||||
projectId: string | null;
|
||||
snapshot: ProjectEventStoreSnapshot | null;
|
||||
}
|
||||
|
||||
const ProjectEventRuntimeContext = createContext<ProjectEventRuntimeValue>({
|
||||
hydration: IDLE_HYDRATION,
|
||||
projectId: null,
|
||||
snapshot: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Own the durable Project event runtime once for the whole Session shell.
|
||||
* ChatBox and SessionSidePanel are read-only consumers of this shared owner.
|
||||
*/
|
||||
export function ProjectEventRuntimeProvider({
|
||||
children,
|
||||
projectId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
projectId: string | null | undefined;
|
||||
}) {
|
||||
const normalizedProjectId = projectId || null;
|
||||
const runtimeEnabled = Boolean(normalizedProjectId);
|
||||
const store = useMemo(
|
||||
() =>
|
||||
runtimeEnabled && normalizedProjectId
|
||||
? getProjectEventStore(normalizedProjectId)
|
||||
: null,
|
||||
[normalizedProjectId, runtimeEnabled]
|
||||
);
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) =>
|
||||
store?.subscribe(listener) ?? subscribeToNothing(),
|
||||
[store]
|
||||
);
|
||||
const getSnapshot = useCallback(() => store?.getSnapshot() ?? null, [store]);
|
||||
const snapshot = useSyncExternalStore(
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
getNullSnapshot
|
||||
);
|
||||
const hydration = useProjectEventStoreHydration({
|
||||
projectId: normalizedProjectId,
|
||||
enabled: runtimeEnabled,
|
||||
});
|
||||
|
||||
useProjectRunEventStreams({
|
||||
projectId: normalizedProjectId,
|
||||
snapshot,
|
||||
enabled: runtimeEnabled,
|
||||
});
|
||||
|
||||
const value = useMemo<ProjectEventRuntimeValue>(
|
||||
() => ({ hydration, projectId: normalizedProjectId, snapshot }),
|
||||
[hydration, normalizedProjectId, snapshot]
|
||||
);
|
||||
|
||||
return (
|
||||
<ProjectEventRuntimeContext.Provider value={value}>
|
||||
{children}
|
||||
</ProjectEventRuntimeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useProjectEventRuntime(): ProjectEventRuntimeValue {
|
||||
return useContext(ProjectEventRuntimeContext);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
143
src/hooks/useProjectSessionOverview.ts
Normal file
143
src/hooks/useProjectSessionOverview.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { useProjectEventRuntime } from '@/hooks/useProjectEventRuntime';
|
||||
import type { ProjectedRun } from '@/lib/projector';
|
||||
import type { ChatProjectionNode } from '@/lib/projector/chat';
|
||||
import type { ProjectEventStoreSnapshot } from '@/store/projectEventStore';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const LIVE_RUN_STATUSES = new Set<ProjectedRun['status']>([
|
||||
'pending',
|
||||
'running',
|
||||
'waiting_for_user',
|
||||
'cancelling',
|
||||
]);
|
||||
|
||||
export interface ProjectSessionRun {
|
||||
/** Durable Run identity. `taskId` remains as a UI compatibility alias. */
|
||||
runId: string;
|
||||
taskId: string;
|
||||
status: ProjectedRun['status'];
|
||||
nodes: ChatProjectionNode[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectSessionOverview {
|
||||
currentRun: ProjectSessionRun | null;
|
||||
historicalRuns: ProjectSessionRun[];
|
||||
runs: ProjectSessionRun[];
|
||||
}
|
||||
|
||||
function timestamp(value: string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function compareNodes(left: ChatProjectionNode, right: ChatProjectionNode) {
|
||||
// Callers sort one Run at a time. Its durable sequence is the canonical
|
||||
// fact order; wall-clock timestamps are display metadata and may tie or
|
||||
// arrive skewed after restore/import.
|
||||
return (
|
||||
left.runSequence - right.runSequence ||
|
||||
timestamp(left.createdAt) - timestamp(right.createdAt) ||
|
||||
left.eventId.localeCompare(right.eventId)
|
||||
);
|
||||
}
|
||||
|
||||
function compareRuns(left: ProjectSessionRun, right: ProjectSessionRun) {
|
||||
return (
|
||||
right.updatedAt - left.updatedAt ||
|
||||
right.createdAt - left.createdAt ||
|
||||
right.runId.localeCompare(left.runId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the SidePanel Run view exclusively from the bounded durable bus. */
|
||||
export function buildProjectSessionOverview(
|
||||
snapshot: ProjectEventStoreSnapshot | null
|
||||
): ProjectSessionOverview {
|
||||
if (!snapshot) {
|
||||
return { currentRun: null, historicalRuns: [], runs: [] };
|
||||
}
|
||||
|
||||
const nodesByRun = new Map<string, ChatProjectionNode[]>();
|
||||
for (const node of snapshot.chat.nodes) {
|
||||
const nodes = nodesByRun.get(node.runId) ?? [];
|
||||
nodes.push(node);
|
||||
nodesByRun.set(node.runId, nodes);
|
||||
}
|
||||
|
||||
const runIds = new Set([
|
||||
...Object.keys(snapshot.view.runs),
|
||||
...nodesByRun.keys(),
|
||||
]);
|
||||
const runs = [...runIds].map<ProjectSessionRun>((runId) => {
|
||||
const projectedRun = snapshot.view.runs[runId];
|
||||
const nodes = [...(nodesByRun.get(runId) ?? [])].sort(compareNodes);
|
||||
const nodeTimes = nodes
|
||||
.map((node) => timestamp(node.createdAt))
|
||||
.filter((value) => value > 0);
|
||||
const aggregateTime = timestamp(projectedRun?.updatedAt);
|
||||
const createdAt =
|
||||
nodeTimes.length > 0 ? Math.min(...nodeTimes) : aggregateTime;
|
||||
const updatedAt = Math.max(aggregateTime, ...nodeTimes, createdAt);
|
||||
|
||||
return {
|
||||
runId,
|
||||
taskId: runId,
|
||||
status: projectedRun?.status ?? 'unknown',
|
||||
nodes,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
isCurrent: false,
|
||||
};
|
||||
});
|
||||
|
||||
runs.sort(compareRuns);
|
||||
const current =
|
||||
runs
|
||||
.filter((run) => LIVE_RUN_STATUSES.has(run.status))
|
||||
.sort(compareRuns)[0] ??
|
||||
runs[0] ??
|
||||
null;
|
||||
const normalizedRuns = runs.map((run) => ({
|
||||
...run,
|
||||
isCurrent: run.runId === current?.runId,
|
||||
}));
|
||||
const currentRun =
|
||||
normalizedRuns.find((run) => run.isCurrent) ?? current ?? null;
|
||||
|
||||
return {
|
||||
currentRun,
|
||||
historicalRuns: normalizedRuns.filter((run) => !run.isCurrent),
|
||||
runs: normalizedRuns,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Project-level SidePanel view backed by the SQLite journal projection.
|
||||
* ChatStore is intentionally not consulted here.
|
||||
*/
|
||||
export function useProjectSessionOverview(
|
||||
projectId: string | null | undefined
|
||||
): ProjectSessionOverview {
|
||||
const runtime = useProjectEventRuntime();
|
||||
const snapshot =
|
||||
projectId && runtime.projectId === projectId ? runtime.snapshot : null;
|
||||
return useMemo(() => buildProjectSessionOverview(snapshot), [snapshot]);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
PROJECT_SIDEBAR_FOLD_SPRING,
|
||||
PROJECT_SIDEBAR_RAIL_WIDTH_PX,
|
||||
} from '@/components/ProjectPageSidebar/constants';
|
||||
import SessionGroup from '@/components/Session/SessionGroup';
|
||||
import SessionGroup from '@/components/Session/SidePanel/components/SessionGroup';
|
||||
import TriggerPanel from '@/components/Trigger';
|
||||
import Workspace from '@/components/Workspace';
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
|
|
@ -763,7 +763,7 @@ export default function WorkspacePage() {
|
|||
<div className="flex h-full min-h-0 flex-row overflow-hidden px-1 pb-1 pt-10">
|
||||
<div
|
||||
ref={shellPanelGroupRef}
|
||||
className="h-full min-h-0 w-full min-w-0 flex-1 rounded-2xl bg-ds-bg-neutral-subtle-default"
|
||||
className="h-full min-h-0 w-full min-w-0 flex-1"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
ref={shellPanelGroupImperativeRef}
|
||||
|
|
@ -777,7 +777,7 @@ export default function WorkspacePage() {
|
|||
defaultSize={defaultPanelLayout[0]}
|
||||
minSize={sidebarPct.rail}
|
||||
maxSize={sidebarPct.max}
|
||||
className="min-h-0 min-w-0 py-1 pl-1"
|
||||
className="min-h-0 min-w-0"
|
||||
>
|
||||
<ProjectPageSidebar chatStore={chatStore} />
|
||||
</ResizablePanel>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -771,7 +771,7 @@ interface Task {
|
|||
// Trigger execution ID for tracking trigger task completion
|
||||
executionId?: string;
|
||||
nextExecutionId?: string;
|
||||
/** Unix ms timestamp when this task was created — used for TurnTabs ordering. */
|
||||
/** Unix ms timestamp when this task was created — used for Run ordering. */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
|
@ -2027,18 +2027,18 @@ const chatStore = (initial?: Partial<ChatStore>) =>
|
|||
return taskId;
|
||||
},
|
||||
computedProgressValue(taskId: string) {
|
||||
const { tasks, setProgressValue, activeTaskId } = get();
|
||||
const { tasks, setProgressValue } = get();
|
||||
const taskRunning = [...tasks[taskId].taskRunning];
|
||||
const finishedTask = taskRunning?.filter(
|
||||
(task) =>
|
||||
task.status === TaskStatus.COMPLETED ||
|
||||
task.status === TaskStatus.FAILED
|
||||
).length;
|
||||
const taskProgress = (
|
||||
((finishedTask || 0) / (taskRunning?.length || 0)) *
|
||||
100
|
||||
).toFixed(2);
|
||||
setProgressValue(activeTaskId as string, Number(taskProgress));
|
||||
const taskProgress =
|
||||
taskRunning.length > 0
|
||||
? Number(((finishedTask / taskRunning.length) * 100).toFixed(2))
|
||||
: 0;
|
||||
setProgressValue(taskId, taskProgress);
|
||||
},
|
||||
removeTask(taskId: string) {
|
||||
// Clean up any pending auto-confirm timers when removing a task
|
||||
|
|
|
|||
|
|
@ -364,29 +364,7 @@ interface PageTabState {
|
|||
triggerSelectRequestId: number;
|
||||
requestSelectTrigger: (triggerId: number) => void;
|
||||
|
||||
// ── TurnTabs: per-project turn selection ─────────────────────────────────
|
||||
/**
|
||||
* Which task (turn) is currently highlighted in the side-panel TurnTabs,
|
||||
* per project. `null` / absent → default to the chatStore's activeTaskId.
|
||||
*/
|
||||
sidePanelSelectedTurnByProject: Record<string, string>;
|
||||
/**
|
||||
* Unix-ms timestamp until which a user tab-click overrides the
|
||||
* scroll-driven viewport selection, per project.
|
||||
*/
|
||||
sidePanelManualUntilByProject: Record<string, number>;
|
||||
/**
|
||||
* Task ID currently visible in the chatbox scroll viewport, per project.
|
||||
* Written by the IntersectionObserver in ProjectChatContainer.
|
||||
*/
|
||||
sidePanelViewedTurnByProject: Record<string, string>;
|
||||
setSidePanelSelectedTurn: (
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
manualDurationMs?: number
|
||||
) => void;
|
||||
setSidePanelViewedTurn: (projectId: string, taskId: string) => void;
|
||||
/** Set by TurnTabs to tell the matching ProjectChatContainer to scroll. */
|
||||
/** One-shot command used by historical rows in the Session side panel. */
|
||||
scrollToTurnRequest: { projectId: string; taskId: string } | null;
|
||||
setScrollToTurnRequest: (
|
||||
request: { projectId: string; taskId: string } | null
|
||||
|
|
@ -638,56 +616,6 @@ export const usePageTabStore = create<PageTabState>()(
|
|||
triggerSelectRequestId: state.triggerSelectRequestId + 1,
|
||||
})),
|
||||
|
||||
sidePanelSelectedTurnByProject: {},
|
||||
sidePanelManualUntilByProject: {},
|
||||
sidePanelViewedTurnByProject: {},
|
||||
setSidePanelSelectedTurn: (projectId, taskId, manualDurationMs = 1500) =>
|
||||
set((state) => ({
|
||||
sidePanelSelectedTurnByProject: {
|
||||
...state.sidePanelSelectedTurnByProject,
|
||||
[projectId]: taskId,
|
||||
},
|
||||
sidePanelManualUntilByProject: {
|
||||
...state.sidePanelManualUntilByProject,
|
||||
[projectId]: Date.now() + manualDurationMs,
|
||||
},
|
||||
})),
|
||||
setSidePanelViewedTurn: (projectId, taskId) =>
|
||||
set((state) => {
|
||||
const manualUntil =
|
||||
state.sidePanelManualUntilByProject[projectId] ?? 0;
|
||||
// Suppress viewport updates during the manual-selection window so a
|
||||
// tab click isn't immediately overwritten by an in-flight observer
|
||||
// firing while the chatbox is mid-scroll.
|
||||
const selectedTaskId =
|
||||
state.sidePanelSelectedTurnByProject[projectId];
|
||||
if (Date.now() < manualUntil && selectedTaskId !== taskId) {
|
||||
return state;
|
||||
}
|
||||
if (
|
||||
state.sidePanelViewedTurnByProject[projectId] === taskId &&
|
||||
selectedTaskId === taskId &&
|
||||
manualUntil === 0
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
// Once the window expires, drive both fields so components only need
|
||||
// to read `sidePanelSelectedTurnByProject` — no Date.now() in render.
|
||||
return {
|
||||
sidePanelViewedTurnByProject: {
|
||||
...state.sidePanelViewedTurnByProject,
|
||||
[projectId]: taskId,
|
||||
},
|
||||
sidePanelSelectedTurnByProject: {
|
||||
...state.sidePanelSelectedTurnByProject,
|
||||
[projectId]: taskId,
|
||||
},
|
||||
sidePanelManualUntilByProject: {
|
||||
...state.sidePanelManualUntilByProject,
|
||||
[projectId]: 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
scrollToTurnRequest: null,
|
||||
setScrollToTurnRequest: (request) =>
|
||||
set({ scrollToTurnRequest: request }),
|
||||
|
|
|
|||
|
|
@ -1,306 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import {
|
||||
reconcileToolkitState,
|
||||
TOOLKIT_MIN_DISPLAY_MS,
|
||||
} from '@/components/Session/SidePanelSections/AgentPoolSection';
|
||||
import { AgentStatusValue } from '@/types/constants';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
type State = Parameters<typeof reconcileToolkitState>[0];
|
||||
|
||||
function makeState(): State {
|
||||
return { entries: new Map(), timers: new Map(), retired: new Set() };
|
||||
}
|
||||
|
||||
function makeScheduler() {
|
||||
const scheduled: Array<{
|
||||
id: string;
|
||||
delay: number;
|
||||
cancelled: boolean;
|
||||
handle: number;
|
||||
}> = [];
|
||||
let nextHandle = 1;
|
||||
const schedule = (id: string, delay: number) => {
|
||||
const handle = nextHandle++;
|
||||
const record = { id, delay, cancelled: false, handle };
|
||||
scheduled.push(record);
|
||||
return handle as unknown as ReturnType<typeof setTimeout>;
|
||||
};
|
||||
const cancel = (h: ReturnType<typeof setTimeout>) => {
|
||||
const record = scheduled.find(
|
||||
(r) => r.handle === (h as unknown as number) && !r.cancelled
|
||||
);
|
||||
if (record) record.cancelled = true;
|
||||
};
|
||||
return { scheduled, schedule, cancel };
|
||||
}
|
||||
|
||||
function runExpired(
|
||||
state: State,
|
||||
_scheduled: ReturnType<typeof makeScheduler>['scheduled'],
|
||||
now: number
|
||||
) {
|
||||
for (const entry of [...state.entries.values()]) {
|
||||
if (entry.expireAt !== null && entry.expireAt <= now) {
|
||||
state.entries.delete(entry.id);
|
||||
state.timers.delete(entry.id);
|
||||
state.retired.add(entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('reconcileToolkitState', () => {
|
||||
it('shows a RUNNING toolkit immediately', () => {
|
||||
const state = makeState();
|
||||
const { schedule, cancel } = makeScheduler();
|
||||
const names = reconcileToolkitState(
|
||||
state,
|
||||
[{ id: 'a1', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING }],
|
||||
{ now: 0, minDisplayMs: TOOLKIT_MIN_DISPLAY_MS, schedule, cancel }
|
||||
);
|
||||
expect(names).toEqual(['Browser Toolkit']);
|
||||
});
|
||||
|
||||
it('hides a RUNNING toolkit when it completes — but only after minDisplayMs', () => {
|
||||
const state = makeState();
|
||||
const { scheduled, schedule, cancel } = makeScheduler();
|
||||
const minDisplayMs = 1500;
|
||||
|
||||
// t=0 → ACTIVATE
|
||||
let names = reconcileToolkitState(
|
||||
state,
|
||||
[{ id: 'a1', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING }],
|
||||
{ now: 0, minDisplayMs, schedule, cancel }
|
||||
);
|
||||
expect(names).toEqual(['Browser Toolkit']);
|
||||
|
||||
// t=50 → DEACTIVATE — still in min-display window
|
||||
names = reconcileToolkitState(
|
||||
state,
|
||||
[
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'Browser Toolkit',
|
||||
status: AgentStatusValue.COMPLETED,
|
||||
},
|
||||
],
|
||||
{ now: 50, minDisplayMs, schedule, cancel }
|
||||
);
|
||||
expect(names).toEqual(['Browser Toolkit']);
|
||||
expect(scheduled).toHaveLength(1);
|
||||
expect(scheduled[0]?.delay).toBeGreaterThanOrEqual(1400);
|
||||
|
||||
// t=1500 → timer fires, entry evicted
|
||||
runExpired(state, scheduled, 1500);
|
||||
names = reconcileToolkitState(state, [], {
|
||||
now: 1500,
|
||||
minDisplayMs,
|
||||
schedule,
|
||||
cancel,
|
||||
});
|
||||
expect(names).toEqual([]);
|
||||
});
|
||||
|
||||
it('surfaces a fast toolkit that ACTIVATEs and DEACTIVATEs within a single render pass', () => {
|
||||
// This mirrors the user report: a browser_agent fires Browser Toolkit,
|
||||
// Search Toolkit, and Screenshot Toolkit. Search/Screenshot can flip
|
||||
// RUNNING → COMPLETED very quickly. All three must still appear.
|
||||
const state = makeState();
|
||||
const { schedule, cancel } = makeScheduler();
|
||||
const minDisplayMs = 1500;
|
||||
|
||||
const names = reconcileToolkitState(
|
||||
state,
|
||||
[
|
||||
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
|
||||
{
|
||||
id: 's',
|
||||
name: 'Search Toolkit',
|
||||
status: AgentStatusValue.COMPLETED,
|
||||
},
|
||||
{
|
||||
id: 'p',
|
||||
name: 'Screenshot Toolkit',
|
||||
status: AgentStatusValue.COMPLETED,
|
||||
},
|
||||
],
|
||||
{ now: 100, minDisplayMs, schedule, cancel }
|
||||
);
|
||||
|
||||
expect(names).toEqual([
|
||||
'Browser Toolkit',
|
||||
'Search Toolkit',
|
||||
'Screenshot Toolkit',
|
||||
]);
|
||||
});
|
||||
|
||||
it('re-arming: a toolkit that flips back to RUNNING cancels its pending removal', () => {
|
||||
const state = makeState();
|
||||
const { scheduled, schedule, cancel } = makeScheduler();
|
||||
const minDisplayMs = 1500;
|
||||
|
||||
reconcileToolkitState(
|
||||
state,
|
||||
[{ id: 'a', name: 'Search Toolkit', status: AgentStatusValue.RUNNING }],
|
||||
{ now: 0, minDisplayMs, schedule, cancel }
|
||||
);
|
||||
reconcileToolkitState(
|
||||
state,
|
||||
[{ id: 'a', name: 'Search Toolkit', status: AgentStatusValue.COMPLETED }],
|
||||
{ now: 100, minDisplayMs, schedule, cancel }
|
||||
);
|
||||
expect(scheduled.filter((s) => !s.cancelled)).toHaveLength(1);
|
||||
|
||||
reconcileToolkitState(
|
||||
state,
|
||||
[{ id: 'a', name: 'Search Toolkit', status: AgentStatusValue.RUNNING }],
|
||||
{ now: 120, minDisplayMs, schedule, cancel }
|
||||
);
|
||||
expect(scheduled.filter((s) => !s.cancelled)).toHaveLength(0);
|
||||
const entry = state.entries.get('a');
|
||||
expect(entry?.expireAt).toBeNull();
|
||||
});
|
||||
|
||||
it('dedupes by name while preserving first-seen order', () => {
|
||||
// Each ACTIVATE of the same toolkit name gets a unique id from the store,
|
||||
// but users should only see one tag per name.
|
||||
const state = makeState();
|
||||
const { schedule, cancel } = makeScheduler();
|
||||
const minDisplayMs = 1500;
|
||||
|
||||
const names = reconcileToolkitState(
|
||||
state,
|
||||
[
|
||||
{
|
||||
id: '1',
|
||||
name: 'Browser Toolkit',
|
||||
status: AgentStatusValue.RUNNING,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Search Toolkit',
|
||||
status: AgentStatusValue.RUNNING,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Browser Toolkit',
|
||||
status: AgentStatusValue.RUNNING,
|
||||
},
|
||||
],
|
||||
{ now: 0, minDisplayMs, schedule, cancel }
|
||||
);
|
||||
expect(names).toEqual(['Browser Toolkit', 'Search Toolkit']);
|
||||
});
|
||||
|
||||
it('ignores the "notice" placeholder', () => {
|
||||
// Consumers filter `toolkitName === "notice"` before passing events in.
|
||||
// This test asserts reconcile behaves correctly when callers hand it only
|
||||
// real toolkit events (the helper itself is caller-filtered).
|
||||
const state = makeState();
|
||||
const { schedule, cancel } = makeScheduler();
|
||||
const names = reconcileToolkitState(
|
||||
state,
|
||||
[{ id: '1', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING }],
|
||||
{ now: 0, minDisplayMs: 1500, schedule, cancel }
|
||||
);
|
||||
expect(names).toEqual(['Browser Toolkit']);
|
||||
});
|
||||
|
||||
it('full sequence: browser stays, search and screenshot come and go', () => {
|
||||
const state = makeState();
|
||||
const { scheduled, schedule, cancel } = makeScheduler();
|
||||
const minDisplayMs = 1500;
|
||||
|
||||
// t=0: browser starts
|
||||
let names = reconcileToolkitState(
|
||||
state,
|
||||
[{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING }],
|
||||
{ now: 0, minDisplayMs, schedule, cancel }
|
||||
);
|
||||
expect(names).toEqual(['Browser Toolkit']);
|
||||
|
||||
// t=200: search starts (while browser still running)
|
||||
names = reconcileToolkitState(
|
||||
state,
|
||||
[
|
||||
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
|
||||
{ id: 's1', name: 'Search Toolkit', status: AgentStatusValue.RUNNING },
|
||||
],
|
||||
{ now: 200, minDisplayMs, schedule, cancel }
|
||||
);
|
||||
expect(names).toEqual(['Browser Toolkit', 'Search Toolkit']);
|
||||
|
||||
// t=250: search finishes fast
|
||||
names = reconcileToolkitState(
|
||||
state,
|
||||
[
|
||||
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
|
||||
{
|
||||
id: 's1',
|
||||
name: 'Search Toolkit',
|
||||
status: AgentStatusValue.COMPLETED,
|
||||
},
|
||||
],
|
||||
{ now: 250, minDisplayMs, schedule, cancel }
|
||||
);
|
||||
expect(names).toEqual(['Browser Toolkit', 'Search Toolkit']);
|
||||
|
||||
// t=400: screenshot starts
|
||||
names = reconcileToolkitState(
|
||||
state,
|
||||
[
|
||||
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
|
||||
{
|
||||
id: 's1',
|
||||
name: 'Search Toolkit',
|
||||
status: AgentStatusValue.COMPLETED,
|
||||
},
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Screenshot Toolkit',
|
||||
status: AgentStatusValue.RUNNING,
|
||||
},
|
||||
],
|
||||
{ now: 400, minDisplayMs, schedule, cancel }
|
||||
);
|
||||
expect(names).toEqual([
|
||||
'Browser Toolkit',
|
||||
'Search Toolkit',
|
||||
'Screenshot Toolkit',
|
||||
]);
|
||||
|
||||
// t=1700: search's min-display elapsed (firstSeen=200 + 1500) → evicted
|
||||
runExpired(state, scheduled, 1700);
|
||||
names = reconcileToolkitState(
|
||||
state,
|
||||
[
|
||||
{ id: 'b', name: 'Browser Toolkit', status: AgentStatusValue.RUNNING },
|
||||
{
|
||||
id: 's1',
|
||||
name: 'Search Toolkit',
|
||||
status: AgentStatusValue.COMPLETED,
|
||||
},
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Screenshot Toolkit',
|
||||
status: AgentStatusValue.RUNNING,
|
||||
},
|
||||
],
|
||||
{ now: 1700, minDisplayMs, schedule, cancel }
|
||||
);
|
||||
expect(names).toEqual(['Browser Toolkit', 'Screenshot Toolkit']);
|
||||
});
|
||||
});
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
isChatTimelineNearBottom,
|
||||
prepareEventNativeTimelineWindow,
|
||||
} from '@/components/ChatBox/EventNativeProjectTimeline';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
projection: null as ChatProjectionState | null,
|
||||
|
|
@ -39,11 +40,12 @@ const mocks = vi.hoisted(() => ({
|
|||
} as ProjectEventStoreHydrationState,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useProjectEventView', () => ({
|
||||
useProjectChatProjection: () => mocks.projection,
|
||||
}));
|
||||
vi.mock('@/hooks/useProjectEventStoreHydration', () => ({
|
||||
useProjectEventStoreHydration: () => mocks.hydration,
|
||||
vi.mock('@/hooks/useProjectEventRuntime', () => ({
|
||||
useProjectEventRuntime: () => ({
|
||||
projectId: 'project-1',
|
||||
hydration: mocks.hydration,
|
||||
snapshot: mocks.projection ? { chat: mocks.projection } : null,
|
||||
}),
|
||||
}));
|
||||
vi.mock('framer-motion', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('framer-motion')>();
|
||||
|
|
@ -167,6 +169,7 @@ describe('EventNativeProjectTimeline', () => {
|
|||
eventsTruncated: false,
|
||||
retry: mocks.retry,
|
||||
};
|
||||
usePageTabStore.getState().setScrollToTurnRequest(null);
|
||||
if (!globalThis.ResizeObserver) {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
|
|
@ -471,4 +474,41 @@ describe('EventNativeProjectTimeline', () => {
|
|||
|
||||
expect(scrollContainer.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('consumes a missed scroll-to-run request only once', () => {
|
||||
const scrollContainer = createScrollContainer();
|
||||
const scrollContainerRef = { current: scrollContainer };
|
||||
mocks.projection = projection([messageNode(0)]);
|
||||
usePageTabStore.getState().setScrollToTurnRequest({
|
||||
projectId: 'project-1',
|
||||
taskId: 'run-not-mounted',
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<EventNativeProjectTimeline
|
||||
projectId="project-1"
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
scrollBottomInsetPx={128}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(usePageTabStore.getState().scrollToTurnRequest).toBeNull();
|
||||
vi.mocked(scrollContainer.scrollTo).mockClear();
|
||||
|
||||
mocks.projection = projection([
|
||||
messageNode(0),
|
||||
{ ...messageNode(1), runId: 'run-not-mounted' },
|
||||
]);
|
||||
rerender(
|
||||
<EventNativeProjectTimeline
|
||||
projectId="project-1"
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
scrollBottomInsetPx={128}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(scrollContainer.scrollTo).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ behavior: 'smooth' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
348
test/unit/components/Session/ActivityPanel.test.tsx
Normal file
348
test/unit/components/Session/ActivityPanel.test.tsx
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { SessionActivityPanel } from '@/components/Session/SidePanel/components/ActivityPanel';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
activeProjectId: 'project-1' as string,
|
||||
chatStore: {} as any,
|
||||
hydration: {} as any,
|
||||
projectId: 'project-1' as string | null,
|
||||
projectFiles: [] as any[],
|
||||
overviews: {} as Record<string, any>,
|
||||
selectFile: vi.fn(),
|
||||
setScrollToTurnRequest: vi.fn(),
|
||||
requestTaskBoxFocus: vi.fn(),
|
||||
openFilePreview: vi.fn(),
|
||||
openBrowserPreview: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/connectors', () => ({
|
||||
fetchConnectedProviders: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('@/client/platform', () => ({ isWeb: () => false }));
|
||||
|
||||
vi.mock('@/hooks/useChatStoreAdapter', () => ({
|
||||
default: () => ({ chatStore: mocks.chatStore }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useProjectEventRuntime', () => ({
|
||||
useProjectEventRuntime: () => ({
|
||||
projectId: mocks.projectId,
|
||||
snapshot: null,
|
||||
hydration: mocks.hydration,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useProjectSessionOverview', () => ({
|
||||
useProjectSessionOverview: (projectId: string | null) =>
|
||||
(projectId && mocks.overviews[projectId]) || {
|
||||
currentRun: null,
|
||||
historicalRuns: [],
|
||||
runs: [],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/host', () => ({
|
||||
useHost: () => ({ electronAPI: { selectFile: mocks.selectFile } }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/projectRuntimeStore', () => ({
|
||||
useProjectRuntimeStore: () => ({
|
||||
activeProjectId: mocks.activeProjectId,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/skillsStore', () => ({
|
||||
useSkillsStore: (selector: (state: { skills: never[] }) => unknown) =>
|
||||
selector({ skills: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/pageTabStore', () => ({
|
||||
usePageTabStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
requestTaskBoxFocus: mocks.requestTaskBoxFocus,
|
||||
setScrollToTurnRequest: mocks.setScrollToTurnRequest,
|
||||
openFilePreview: mocks.openFilePreview,
|
||||
openBrowserPreview: mocks.openBrowserPreview,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
'@/components/Session/SidePanel/sections/useProjectOutputFiles',
|
||||
() => ({ useProjectOutputFiles: () => mocks.projectFiles })
|
||||
);
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
TooltipSimple: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Session/SidePanel/components/AccordionBox', () => ({
|
||||
SidePanelAccordionBox: ({
|
||||
title,
|
||||
headerAction,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
headerAction?: ReactNode;
|
||||
children: ReactNode | ((state: { open: boolean }) => ReactNode);
|
||||
}) => (
|
||||
<section>
|
||||
<h2>{title}</h2>
|
||||
{headerAction}
|
||||
{typeof children === 'function' ? children({ open: true }) : children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Session/SidePanel/sections/primitives', () => ({
|
||||
CountPill: ({ count }: { count: number }) => <span>{count}</span>,
|
||||
EarlierItems: ({ children }: { children: ReactNode }) => children,
|
||||
ProgressCircle: () => null,
|
||||
SidePanelListRow: ({
|
||||
children,
|
||||
onClick,
|
||||
trailing,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
trailing?: ReactNode;
|
||||
}) =>
|
||||
onClick ? (
|
||||
<button onClick={onClick}>
|
||||
{children}
|
||||
{trailing}
|
||||
</button>
|
||||
) : (
|
||||
<div>
|
||||
{children}
|
||||
{trailing}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
'@/components/Session/SidePanel/sections/SessionSidePanelDialogs',
|
||||
() => ({
|
||||
AgentInformationDialog: ({ agent }: { agent: any }) =>
|
||||
agent ? <div data-testid="agent-dialog">{agent.name}</div> : null,
|
||||
ToolCallsDialog: ({ item }: { item: any }) =>
|
||||
item ? <div data-testid="tool-dialog">{item.label}</div> : null,
|
||||
})
|
||||
);
|
||||
|
||||
function chatStore(taskId: string | null) {
|
||||
return {
|
||||
activeTaskId: taskId,
|
||||
tasks: taskId ? { [taskId]: { attaches: [] } } : {},
|
||||
setAttaches: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function overview(runId: string, withAgent = false) {
|
||||
const run = {
|
||||
runId,
|
||||
taskId: runId,
|
||||
status: 'running',
|
||||
createdAt: 1_000,
|
||||
updatedAt: 1_000,
|
||||
isCurrent: true,
|
||||
nodes: withAgent
|
||||
? [
|
||||
{
|
||||
id: 'agent-1',
|
||||
eventId: 'agent-1',
|
||||
eventType: 'agent.started',
|
||||
projectId: 'project-1',
|
||||
runId,
|
||||
runSequence: 1,
|
||||
cloudCursor: 1,
|
||||
createdAt: new Date(1_000).toISOString(),
|
||||
legacyStep: null,
|
||||
kind: 'activity',
|
||||
activityType: 'agent',
|
||||
status: 'running',
|
||||
title: 'Agent One',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'Agent One',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
return { currentRun: run, historicalRuns: [], runs: [run] };
|
||||
}
|
||||
|
||||
describe('SessionActivityPanel project scope', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.projectId = 'project-1';
|
||||
mocks.activeProjectId = 'project-1';
|
||||
mocks.chatStore = chatStore(null);
|
||||
mocks.projectFiles = [];
|
||||
mocks.hydration = {
|
||||
status: 'ready',
|
||||
errorCode: null,
|
||||
eventsTruncated: false,
|
||||
retry: vi.fn(),
|
||||
};
|
||||
mocks.overviews = {
|
||||
'project-1': { currentRun: null, historicalRuns: [], runs: [] },
|
||||
'project-2': { currentRun: null, historicalRuns: [], runs: [] },
|
||||
};
|
||||
});
|
||||
|
||||
it('keeps an empty Files section visible and fails closed without a composer target', async () => {
|
||||
render(<SessionActivityPanel scope="latest" />);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
});
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Files' })).toBeInTheDocument();
|
||||
expect(screen.getByText('No output files yet.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Attach files/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows unsupported history as degraded and exposes manual retry', async () => {
|
||||
mocks.hydration = {
|
||||
status: 'error',
|
||||
errorCode: 'unsupported',
|
||||
eventsTruncated: false,
|
||||
retry: vi.fn(),
|
||||
};
|
||||
|
||||
render(<SessionActivityPanel scope="latest" />);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
});
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'This backend does not support session history yet.'
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||
expect(mocks.hydration.retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps an artifact noninteractive until the workspace resolver matches it', async () => {
|
||||
mocks.chatStore = chatStore('run-1');
|
||||
const scopedOverview = overview('run-1');
|
||||
scopedOverview.currentRun.nodes = [
|
||||
{
|
||||
id: 'artifact-1',
|
||||
eventId: 'artifact-1',
|
||||
eventType: 'artifact.created',
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
runSequence: 1,
|
||||
cloudCursor: 1,
|
||||
createdAt: new Date(1_000).toISOString(),
|
||||
legacyStep: null,
|
||||
kind: 'artifact',
|
||||
operation: 'created',
|
||||
path: 'outputs/report.md',
|
||||
relativePath: 'outputs/report.md',
|
||||
name: 'report.md',
|
||||
},
|
||||
];
|
||||
mocks.overviews['project-1'] = scopedOverview;
|
||||
|
||||
const { rerender } = render(<SessionActivityPanel scope="latest" />);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
});
|
||||
|
||||
expect(screen.getByText('Preview unavailable')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /report\.md/ })
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
mocks.projectFiles = [
|
||||
{
|
||||
name: 'report.md',
|
||||
type: 'md',
|
||||
path: '/workspace/outputs/report.md',
|
||||
relativePath: 'outputs/report.md',
|
||||
},
|
||||
];
|
||||
rerender(<SessionActivityPanel scope="latest" />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /report\.md/ }));
|
||||
expect(mocks.openFilePreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: '/workspace/outputs/report.md' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not attach a delayed picker result after switching Projects', async () => {
|
||||
let resolvePicker!: (value: unknown) => void;
|
||||
mocks.chatStore = chatStore('run-1');
|
||||
const oldStore = mocks.chatStore;
|
||||
mocks.overviews['project-1'] = overview('run-1');
|
||||
mocks.selectFile.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolvePicker = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const { rerender } = render(<SessionActivityPanel scope="latest" />);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Attach files/ }));
|
||||
expect(mocks.selectFile).toHaveBeenCalledTimes(1);
|
||||
|
||||
mocks.projectId = 'project-2';
|
||||
mocks.activeProjectId = 'project-2';
|
||||
mocks.chatStore = chatStore('run-2');
|
||||
const newStore = mocks.chatStore;
|
||||
mocks.overviews['project-2'] = overview('run-2');
|
||||
rerender(<SessionActivityPanel scope="latest" />);
|
||||
|
||||
await act(async () => {
|
||||
resolvePicker({
|
||||
success: true,
|
||||
files: [{ fileName: 'late.txt', filePath: '/late.txt' }],
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(oldStore.setAttaches).not.toHaveBeenCalled();
|
||||
expect(newStore.setAttaches).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes an Agent dialog when the Project incarnation changes', async () => {
|
||||
mocks.chatStore = chatStore('run-1');
|
||||
mocks.overviews['project-1'] = overview('run-1', true);
|
||||
const { rerender } = render(<SessionActivityPanel scope="latest" />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Agent One' }));
|
||||
expect(screen.getByTestId('agent-dialog')).toHaveTextContent('Agent One');
|
||||
|
||||
mocks.projectId = 'project-2';
|
||||
mocks.activeProjectId = 'project-2';
|
||||
mocks.chatStore = chatStore(null);
|
||||
rerender(<SessionActivityPanel scope="latest" />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('agent-dialog')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
895
test/unit/components/buildProjectSessionPanelData.test.ts
Normal file
895
test/unit/components/buildProjectSessionPanelData.test.ts
Normal file
|
|
@ -0,0 +1,895 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import {
|
||||
buildProjectSessionPanelData,
|
||||
collectSessionToolCalls,
|
||||
extractHttpUrls,
|
||||
mergeProjectFiles,
|
||||
} from '@/components/Session/SidePanel/sections/buildProjectSessionPanelData';
|
||||
import type { ProjectSessionRun } from '@/hooks/useProjectSessionOverview';
|
||||
import { normalizeLegacyChatStep } from '@/lib/projector';
|
||||
import {
|
||||
adaptChatProjectionEvent,
|
||||
type ChatActivityNode,
|
||||
type ChatArtifactNode,
|
||||
type ChatPlanNode,
|
||||
type ChatProjectionNode,
|
||||
} from '@/lib/projector/chat';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function baseNode(
|
||||
runId: string,
|
||||
eventId: string,
|
||||
runSequence: number
|
||||
): Omit<ChatProjectionNode, 'kind'> {
|
||||
return {
|
||||
id: eventId,
|
||||
eventId,
|
||||
projectId: 'project-1',
|
||||
runId,
|
||||
createdAt: new Date(runSequence * 1_000).toISOString(),
|
||||
runSequence,
|
||||
cloudCursor: null,
|
||||
eventType: 'test.event',
|
||||
legacyStep: null,
|
||||
} as Omit<ChatProjectionNode, 'kind'>;
|
||||
}
|
||||
|
||||
function toolNode(
|
||||
runId: string,
|
||||
eventId: string,
|
||||
sequence: number,
|
||||
status: ChatActivityNode['status'],
|
||||
detail: string,
|
||||
toolCallId?: string
|
||||
): ChatActivityNode {
|
||||
return {
|
||||
...baseNode(runId, eventId, sequence),
|
||||
kind: 'activity',
|
||||
activityType: 'tool',
|
||||
status,
|
||||
title: 'Notion search',
|
||||
detail,
|
||||
agentId: 'agent-1',
|
||||
agentName: 'Research Agent',
|
||||
toolkitName: 'MCPToolkit',
|
||||
methodName: 'notion_search',
|
||||
toolCallId,
|
||||
};
|
||||
}
|
||||
|
||||
function agentNode(
|
||||
runId: string,
|
||||
eventId: string,
|
||||
sequence: number,
|
||||
agentId: string,
|
||||
agentName: string,
|
||||
eventType = 'legacy.create_agent',
|
||||
title = agentName
|
||||
): ChatActivityNode {
|
||||
return {
|
||||
...baseNode(runId, eventId, sequence),
|
||||
eventType,
|
||||
legacyStep: eventType.startsWith('legacy.')
|
||||
? eventType.replace('legacy.', '')
|
||||
: null,
|
||||
kind: 'activity',
|
||||
activityType: 'agent',
|
||||
status: 'running',
|
||||
title,
|
||||
agentId,
|
||||
agentName,
|
||||
};
|
||||
}
|
||||
|
||||
function todoActivity(
|
||||
eventId: string,
|
||||
sequence: number,
|
||||
status: ChatActivityNode['status'],
|
||||
eventType: string,
|
||||
legacyStep: string | null = null
|
||||
): ChatActivityNode {
|
||||
return {
|
||||
...baseNode('run-current', eventId, sequence),
|
||||
eventType,
|
||||
legacyStep,
|
||||
kind: 'activity',
|
||||
activityType: 'tool',
|
||||
status,
|
||||
title: 'todo_write',
|
||||
toolName: 'todo_write',
|
||||
toolkitName: 'TodoToolkit',
|
||||
methodName: 'todo_write',
|
||||
};
|
||||
}
|
||||
|
||||
function todoPlan(
|
||||
eventId: string,
|
||||
sequence: number,
|
||||
title: string
|
||||
): ChatPlanNode {
|
||||
return {
|
||||
...baseNode('run-current', eventId, sequence),
|
||||
eventType: 'legacy.todo_state',
|
||||
legacyStep: 'todo_state',
|
||||
kind: 'plan',
|
||||
tasks: [{ id: 'todo_1', title, status: 'running' }],
|
||||
};
|
||||
}
|
||||
|
||||
function skillToolkitNode(
|
||||
eventId: string,
|
||||
sequence: number,
|
||||
status: ChatActivityNode['status'],
|
||||
methodName: 'list_skills' | 'load_skill',
|
||||
detail: string
|
||||
): ChatActivityNode {
|
||||
const active = status === 'running';
|
||||
return {
|
||||
...toolNode('run-current', eventId, sequence, status, detail),
|
||||
eventType: active ? 'legacy.activate_toolkit' : 'legacy.deactivate_toolkit',
|
||||
legacyStep: active ? 'activate_toolkit' : 'deactivate_toolkit',
|
||||
title: detail,
|
||||
toolkitName: 'SkillToolkit',
|
||||
methodName,
|
||||
toolName: undefined,
|
||||
toolCallId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRun(
|
||||
runId: string,
|
||||
isCurrent: boolean,
|
||||
nodes: ChatProjectionNode[]
|
||||
): ProjectSessionRun {
|
||||
return {
|
||||
runId,
|
||||
taskId: runId,
|
||||
status: isCurrent ? 'running' : 'completed',
|
||||
nodes,
|
||||
createdAt: isCurrent ? 2_000 : 1_000,
|
||||
updatedAt: isCurrent ? 20_000 : 10_000,
|
||||
isCurrent,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildProjectSessionPanelData', () => {
|
||||
it('deduplicates logical agents across Runs and skips anonymous tool frames', () => {
|
||||
const oldRun = makeRun('run-old', false, [
|
||||
agentNode(
|
||||
'run-old',
|
||||
'question-confirm',
|
||||
1,
|
||||
'confirm-agent-id',
|
||||
'question_confirm_agent'
|
||||
),
|
||||
agentNode(
|
||||
'run-old',
|
||||
'single-agent-old',
|
||||
2,
|
||||
'single-agent-instance-a',
|
||||
'single_agent'
|
||||
),
|
||||
]);
|
||||
const currentRun = makeRun('run-current', true, [
|
||||
agentNode(
|
||||
'run-current',
|
||||
'single-agent-current',
|
||||
1,
|
||||
'single-agent-instance-b',
|
||||
'single_agent'
|
||||
),
|
||||
{
|
||||
...toolNode(
|
||||
'run-current',
|
||||
'named-tool-frame',
|
||||
2,
|
||||
'running',
|
||||
'Registering agent'
|
||||
),
|
||||
agentId: undefined,
|
||||
agentName: 'single_agent',
|
||||
},
|
||||
{
|
||||
...toolNode(
|
||||
'run-current',
|
||||
'anonymous-canonical-tool',
|
||||
3,
|
||||
'running',
|
||||
'Tool without agent identity'
|
||||
),
|
||||
agentId: undefined,
|
||||
agentName: undefined,
|
||||
},
|
||||
]);
|
||||
|
||||
const data = buildProjectSessionPanelData([oldRun, currentRun], []);
|
||||
|
||||
expect(data.agents).toMatchObject([
|
||||
{
|
||||
id: 'agent:singleagent',
|
||||
name: 'single_agent',
|
||||
historical: false,
|
||||
subagent: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('classifies remote delegated agents separately from primary agents', () => {
|
||||
const run = makeRun('run-current', true, [
|
||||
agentNode(
|
||||
'run-current',
|
||||
'primary-agent',
|
||||
1,
|
||||
'primary-instance',
|
||||
'single_agent'
|
||||
),
|
||||
agentNode(
|
||||
'run-current',
|
||||
'remote-agent',
|
||||
2,
|
||||
'remote-instance',
|
||||
'research_helper',
|
||||
'agent.remote_started',
|
||||
'Remote subagent research_helper'
|
||||
),
|
||||
]);
|
||||
|
||||
expect(buildProjectSessionPanelData([run], []).agents).toMatchObject([
|
||||
{ name: 'single_agent', type: 'agent', subagent: false },
|
||||
{ name: 'research_helper', type: 'subagent', subagent: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('collects terminal, browser, and remote execution environments', () => {
|
||||
const terminal: ChatActivityNode = {
|
||||
...baseNode('run-current', 'terminal', 1),
|
||||
kind: 'activity',
|
||||
activityType: 'terminal',
|
||||
status: 'running',
|
||||
title: 'Run shell command',
|
||||
};
|
||||
const browser = {
|
||||
...toolNode('run-current', 'browser', 2, 'running', 'Open page'),
|
||||
toolkitName: 'BrowserToolkit',
|
||||
methodName: 'browser_navigate',
|
||||
};
|
||||
const remote = agentNode(
|
||||
'run-current',
|
||||
'remote',
|
||||
3,
|
||||
'remote-agent',
|
||||
'research_helper',
|
||||
'agent.remote_started',
|
||||
'Remote subagent research_helper'
|
||||
);
|
||||
|
||||
expect(
|
||||
buildProjectSessionPanelData(
|
||||
[makeRun('run-current', true, [terminal, browser, remote])],
|
||||
[]
|
||||
).environments.map((item) => item.label)
|
||||
).toEqual(['Browser', 'Remote environment', 'Terminal']);
|
||||
});
|
||||
|
||||
it('pairs semantic tool lifecycle events by durable call id', () => {
|
||||
const run = makeRun('run-1', true, [
|
||||
toolNode('run-1', 'tool-start', 1, 'running', 'Searching', 'call-1'),
|
||||
toolNode('run-1', 'tool-end', 2, 'completed', '3 results', 'call-1'),
|
||||
]);
|
||||
|
||||
expect(collectSessionToolCalls([run])).toMatchObject([
|
||||
{
|
||||
id: 'call-1',
|
||||
toolkitName: 'MCPToolkit',
|
||||
method: 'notion_search',
|
||||
input: 'Searching',
|
||||
output: '3 results',
|
||||
status: 'done',
|
||||
taskId: 'run-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('scopes durable call ids to their owning Run', () => {
|
||||
const current = makeRun('run-current', true, [
|
||||
toolNode(
|
||||
'run-current',
|
||||
'current-start',
|
||||
1,
|
||||
'running',
|
||||
'current input',
|
||||
'call-1'
|
||||
),
|
||||
toolNode(
|
||||
'run-current',
|
||||
'current-end',
|
||||
2,
|
||||
'completed',
|
||||
'current output',
|
||||
'call-1'
|
||||
),
|
||||
]);
|
||||
const historical = makeRun('run-old', false, [
|
||||
toolNode('run-old', 'old-start', 1, 'running', 'old input', 'call-1'),
|
||||
toolNode('run-old', 'old-end', 2, 'completed', 'old output', 'call-1'),
|
||||
]);
|
||||
|
||||
expect(collectSessionToolCalls([current, historical])).toMatchObject([
|
||||
{ taskId: 'run-old', input: 'old input', output: 'old output' },
|
||||
{
|
||||
taskId: 'run-current',
|
||||
input: 'current input',
|
||||
output: 'current output',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses FIFO fallback for older tool frames without correlation ids', () => {
|
||||
const run = makeRun('run-1', true, [
|
||||
toolNode('run-1', 'start-1', 1, 'running', 'first'),
|
||||
toolNode('run-1', 'start-2', 2, 'running', 'second'),
|
||||
toolNode('run-1', 'end-1', 3, 'completed', 'first result'),
|
||||
toolNode('run-1', 'end-2', 4, 'completed', 'second result'),
|
||||
]);
|
||||
|
||||
expect(collectSessionToolCalls([run])).toMatchObject([
|
||||
{ input: 'first', output: 'first result', status: 'done' },
|
||||
{ input: 'second', output: 'second result', status: 'done' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('projects plans, artifacts and safe URL resources across Runs', () => {
|
||||
const plan: ChatPlanNode = {
|
||||
...baseNode('run-current', 'plan', 3),
|
||||
kind: 'plan',
|
||||
tasks: [{ id: 'task-1', title: 'Build report', status: 'running' }],
|
||||
};
|
||||
const artifact: ChatArtifactNode = {
|
||||
...baseNode('run-current', 'artifact', 4),
|
||||
kind: 'artifact',
|
||||
operation: 'created',
|
||||
path: 'outputs/report.md',
|
||||
relativePath: 'outputs/report.md',
|
||||
name: 'report.md',
|
||||
};
|
||||
const current = makeRun('run-current', true, [plan, artifact]);
|
||||
const historical = makeRun('run-old', false, [
|
||||
{
|
||||
...baseNode('run-old', 'message', 1),
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
content: 'Read https://old.example.com/research.',
|
||||
status: 'complete',
|
||||
},
|
||||
]);
|
||||
|
||||
const data = buildProjectSessionPanelData([current, historical], []);
|
||||
|
||||
expect(data.progress).toMatchObject([
|
||||
{
|
||||
taskId: 'run-current',
|
||||
historical: false,
|
||||
task: { id: 'task-1', content: 'Build report', status: 'running' },
|
||||
},
|
||||
]);
|
||||
expect(data.files).toMatchObject([
|
||||
{
|
||||
id: 'outputs/report.md',
|
||||
previewable: false,
|
||||
taskId: 'run-current',
|
||||
historical: false,
|
||||
file: { name: 'report.md', artifactChange: 'generated' },
|
||||
},
|
||||
]);
|
||||
expect(data.resources).toMatchObject([
|
||||
{ taskId: 'run-old', historical: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps terminal task activity statuses into side-panel task statuses', () => {
|
||||
const nodes = (
|
||||
[
|
||||
['timed-out', 'timed_out'],
|
||||
['unknown-outcome', 'outcome_unknown'],
|
||||
['cancelled', 'cancelled'],
|
||||
] as const
|
||||
).map(([taskId, status], index): ChatActivityNode => ({
|
||||
...baseNode('run-current', `task-${taskId}`, index + 1),
|
||||
kind: 'activity',
|
||||
activityType: 'task',
|
||||
status,
|
||||
title: taskId,
|
||||
taskId,
|
||||
}));
|
||||
|
||||
expect(
|
||||
buildProjectSessionPanelData(
|
||||
[makeRun('run-current', true, nodes)],
|
||||
[]
|
||||
).progress.map((item) => item.task.status)
|
||||
).toEqual(['failed', 'blocked', 'skipped']);
|
||||
});
|
||||
|
||||
it('uses durable artifact identity and only enriches matching project files', () => {
|
||||
const created: ChatArtifactNode = {
|
||||
...baseNode('run-current', 'artifact-created', 1),
|
||||
kind: 'artifact',
|
||||
operation: 'created',
|
||||
artifactId: 'artifact-1',
|
||||
relativePath: 'outputs/report.md',
|
||||
path: '/private/workspace/outputs/report.md',
|
||||
name: 'report.md',
|
||||
};
|
||||
const updated: ChatArtifactNode = {
|
||||
...baseNode('run-current', 'artifact-updated', 2),
|
||||
kind: 'artifact',
|
||||
operation: 'updated',
|
||||
artifactId: 'artifact-1',
|
||||
relativePath: './outputs\\report.md',
|
||||
path: '/different-machine/workspace/outputs/report.md',
|
||||
name: 'report.md',
|
||||
};
|
||||
const data = buildProjectSessionPanelData(
|
||||
[makeRun('run-current', true, [created, updated])],
|
||||
[]
|
||||
);
|
||||
|
||||
const merged = mergeProjectFiles(data.files, [
|
||||
{
|
||||
name: 'report.md',
|
||||
type: 'md',
|
||||
path: 'https://files.example.test/outputs/report.md',
|
||||
relativePath: 'outputs/report.md',
|
||||
artifactId: 'artifact-1',
|
||||
isRemote: true,
|
||||
},
|
||||
{
|
||||
name: 'unrelated.txt',
|
||||
type: 'txt',
|
||||
path: '/workspace/unrelated.txt',
|
||||
relativePath: 'unrelated.txt',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]).toMatchObject({
|
||||
id: 'artifact-1',
|
||||
previewable: true,
|
||||
taskId: 'run-current',
|
||||
file: {
|
||||
artifactId: 'artifact-1',
|
||||
relativePath: 'outputs/report.md',
|
||||
path: 'https://files.example.test/outputs/report.md',
|
||||
artifactChange: 'changed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not enrich by basename or an unsafe relative path', () => {
|
||||
const item = buildProjectSessionPanelData(
|
||||
[
|
||||
makeRun('run-current', true, [
|
||||
{
|
||||
...baseNode('run-current', 'artifact', 1),
|
||||
kind: 'artifact',
|
||||
operation: 'created',
|
||||
path: 'reports/quarterly/report.md',
|
||||
relativePath: 'reports/quarterly/report.md',
|
||||
name: 'report.md',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[]
|
||||
).files[0];
|
||||
|
||||
const merged = mergeProjectFiles(
|
||||
[item],
|
||||
[
|
||||
{
|
||||
name: 'report.md',
|
||||
type: 'md',
|
||||
path: '/workspace/archive/report.md',
|
||||
relativePath: 'archive/report.md',
|
||||
},
|
||||
{
|
||||
name: 'report.md',
|
||||
type: 'md',
|
||||
path: '/outside/report.md',
|
||||
relativePath: '../report.md',
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(merged).toEqual([item]);
|
||||
});
|
||||
|
||||
it('does not turn a display-only basename into durable file identity', () => {
|
||||
const decision = adaptChatProjectionEvent(
|
||||
normalizeLegacyChatStep(
|
||||
{
|
||||
step: 'write_file',
|
||||
data: {
|
||||
file_path: '/Users/me/eigent/proj/reports/summary.md',
|
||||
},
|
||||
},
|
||||
{
|
||||
projectId: 'project-1',
|
||||
runId: 'run-current',
|
||||
sequence: 1,
|
||||
sourceId: 'legacy-stream',
|
||||
createdAt: 1_000,
|
||||
}
|
||||
)
|
||||
);
|
||||
expect(decision).toMatchObject({
|
||||
kind: 'display',
|
||||
node: {
|
||||
kind: 'artifact',
|
||||
path: 'summary.md',
|
||||
relativePath: undefined,
|
||||
},
|
||||
});
|
||||
if (decision.kind !== 'display') throw new Error('Expected artifact node');
|
||||
|
||||
const data = buildProjectSessionPanelData(
|
||||
[makeRun('run-current', true, [decision.node])],
|
||||
[]
|
||||
);
|
||||
|
||||
expect(data.files).toEqual([]);
|
||||
expect(
|
||||
mergeProjectFiles(data.files, [
|
||||
{
|
||||
name: 'summary.md',
|
||||
type: 'md',
|
||||
path: '/workspace/summary.md',
|
||||
relativePath: 'summary.md',
|
||||
},
|
||||
])
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows a trusted realtime write and converges on its terminal artifact', () => {
|
||||
const liveDecision = adaptChatProjectionEvent(
|
||||
normalizeLegacyChatStep(
|
||||
{
|
||||
step: 'write_file',
|
||||
data: {
|
||||
file_path: '/private/run/reports/summary.md',
|
||||
relative_path: 'reports/summary.md',
|
||||
},
|
||||
},
|
||||
{
|
||||
projectId: 'project-1',
|
||||
runId: 'run-current',
|
||||
sequence: 1,
|
||||
sourceId: 'legacy-stream',
|
||||
createdAt: 1_000,
|
||||
}
|
||||
)
|
||||
);
|
||||
expect(liveDecision).toMatchObject({
|
||||
kind: 'display',
|
||||
node: {
|
||||
kind: 'artifact',
|
||||
path: 'reports/summary.md',
|
||||
relativePath: 'reports/summary.md',
|
||||
},
|
||||
});
|
||||
if (liveDecision.kind !== 'display') {
|
||||
throw new Error('Expected artifact node');
|
||||
}
|
||||
|
||||
const liveData = buildProjectSessionPanelData(
|
||||
[makeRun('run-current', true, [liveDecision.node])],
|
||||
[]
|
||||
);
|
||||
expect(liveData.files).toMatchObject([
|
||||
{
|
||||
id: 'reports/summary.md',
|
||||
previewable: false,
|
||||
taskId: 'run-current',
|
||||
file: { relativePath: 'reports/summary.md' },
|
||||
},
|
||||
]);
|
||||
|
||||
const terminalArtifact: ChatArtifactNode = {
|
||||
...baseNode('run-current', 'artifact-terminal', 2),
|
||||
eventType: 'artifact.created',
|
||||
kind: 'artifact',
|
||||
operation: 'created',
|
||||
artifactId: 'artifact-summary',
|
||||
path: 'reports/summary.md',
|
||||
relativePath: 'reports/summary.md',
|
||||
name: 'summary.md',
|
||||
};
|
||||
const finalized = buildProjectSessionPanelData(
|
||||
[makeRun('run-current', true, [liveDecision.node, terminalArtifact])],
|
||||
[]
|
||||
);
|
||||
|
||||
expect(finalized.files).toHaveLength(1);
|
||||
expect(finalized.files[0]).toMatchObject({
|
||||
id: 'artifact-summary',
|
||||
file: {
|
||||
artifactId: 'artifact-summary',
|
||||
relativePath: 'reports/summary.md',
|
||||
},
|
||||
});
|
||||
expect(
|
||||
mergeProjectFiles(finalized.files, [
|
||||
{
|
||||
name: 'summary.md',
|
||||
type: 'md',
|
||||
path: '/workspace/reports/summary.md',
|
||||
relativePath: 'reports/summary.md',
|
||||
},
|
||||
])
|
||||
).toMatchObject([
|
||||
{
|
||||
previewable: true,
|
||||
file: { path: '/workspace/reports/summary.md' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('quarantines workspace-loaded todo state without a todo_write call', () => {
|
||||
const staleStartupPlan: ChatPlanNode = {
|
||||
...baseNode('run-current', 'stale-todos', 2),
|
||||
eventType: 'legacy.todo_state',
|
||||
legacyStep: 'todo_state',
|
||||
kind: 'plan',
|
||||
tasks: [
|
||||
{
|
||||
id: 'todo_1',
|
||||
title: 'Task from a different Project',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
buildProjectSessionPanelData(
|
||||
[makeRun('run-current', true, [staleStartupPlan])],
|
||||
[]
|
||||
).progress
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts a real todo lifecycle without authorizing a later stale state', () => {
|
||||
const typedPlan: ChatPlanNode = {
|
||||
...baseNode('run-current', 'typed-plan', 1),
|
||||
eventType: 'plan.created',
|
||||
kind: 'plan',
|
||||
tasks: [
|
||||
{
|
||||
id: 'plan-task',
|
||||
title: 'Typed plan task',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
const run = makeRun('run-current', true, [
|
||||
typedPlan,
|
||||
todoActivity('todo-prepared-1', 2, 'running', 'tool.prepared'),
|
||||
todoActivity('todo-completed-1', 3, 'completed', 'tool.completed'),
|
||||
todoActivity(
|
||||
'todo-activate-1',
|
||||
4,
|
||||
'running',
|
||||
'legacy.activate_toolkit',
|
||||
'activate_toolkit'
|
||||
),
|
||||
todoPlan('todo-state-1', 5, 'First current task'),
|
||||
todoActivity(
|
||||
'todo-deactivate-1',
|
||||
6,
|
||||
'completed',
|
||||
'legacy.deactivate_toolkit',
|
||||
'deactivate_toolkit'
|
||||
),
|
||||
todoPlan('unpaired-state', 7, 'Must stay quarantined'),
|
||||
todoActivity('todo-prepared-2', 8, 'running', 'tool.prepared'),
|
||||
todoPlan('todo-state-2', 9, 'Replacement current task'),
|
||||
]);
|
||||
|
||||
expect(buildProjectSessionPanelData([run], []).progress).toMatchObject([
|
||||
{ task: { content: 'Typed plan task' } },
|
||||
{ task: { content: 'Replacement current task' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts legacy-only activate_toolkit followed by todo_state', () => {
|
||||
const run = makeRun('run-current', true, [
|
||||
todoActivity(
|
||||
'todo-activate',
|
||||
1,
|
||||
'running',
|
||||
'legacy.activate_toolkit',
|
||||
'activate_toolkit'
|
||||
),
|
||||
todoPlan('todo-state', 2, 'Legacy current task'),
|
||||
]);
|
||||
|
||||
expect(buildProjectSessionPanelData([run], []).progress).toMatchObject([
|
||||
{ task: { content: 'Legacy current task' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves legacy tool lifecycle status through the shipping bridge', () => {
|
||||
const rawSteps = [
|
||||
{
|
||||
step: 'activate_toolkit',
|
||||
data: {
|
||||
toolkit_name: 'TodoToolkit',
|
||||
method_name: 'todo_write',
|
||||
tool_name: 'todo_write',
|
||||
message: '{"todos":["first","second"]}',
|
||||
},
|
||||
},
|
||||
{
|
||||
step: 'todo_state',
|
||||
data: {
|
||||
todos: [
|
||||
{ id: 'todo-1', content: 'First task', status: 'in_progress' },
|
||||
{ id: 'todo-2', content: 'Second task', status: 'pending' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
step: 'deactivate_toolkit',
|
||||
data: {
|
||||
toolkit_name: 'TodoToolkit',
|
||||
method_name: 'todo_write',
|
||||
tool_name: 'todo_write',
|
||||
message: 'Todos updated',
|
||||
},
|
||||
},
|
||||
];
|
||||
const nodes = rawSteps.flatMap((raw, index) => {
|
||||
const decision = adaptChatProjectionEvent(
|
||||
normalizeLegacyChatStep(raw, {
|
||||
projectId: 'project-1',
|
||||
runId: 'run-current',
|
||||
sequence: index + 1,
|
||||
sourceId: 'test-stream',
|
||||
createdAt: (index + 1) * 1_000,
|
||||
})
|
||||
);
|
||||
return decision.kind === 'display' ? [decision.node] : [];
|
||||
});
|
||||
|
||||
expect(
|
||||
nodes.map((node) => ('status' in node ? node.status : undefined))
|
||||
).toEqual(['running', undefined, 'completed']);
|
||||
|
||||
const data = buildProjectSessionPanelData(
|
||||
[makeRun('run-current', true, nodes)],
|
||||
[]
|
||||
);
|
||||
expect(data.progress.map((item) => item.task.content)).toEqual([
|
||||
'First task',
|
||||
'Second task',
|
||||
]);
|
||||
expect(data.toolCalls).toMatchObject([
|
||||
{
|
||||
toolkitName: 'TodoToolkit',
|
||||
method: 'todo_write',
|
||||
input: '{"todos":["first","second"]}',
|
||||
output: 'Todos updated',
|
||||
status: 'done',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses connector identity without reading a raw event payload', () => {
|
||||
const current = makeRun('run-current', true, [
|
||||
toolNode(
|
||||
'run-current',
|
||||
'tool-start',
|
||||
1,
|
||||
'running',
|
||||
'roadmap',
|
||||
'notion-call'
|
||||
),
|
||||
toolNode(
|
||||
'run-current',
|
||||
'tool-end',
|
||||
2,
|
||||
'completed',
|
||||
'done',
|
||||
'notion-call'
|
||||
),
|
||||
]);
|
||||
|
||||
const data = buildProjectSessionPanelData(
|
||||
[current],
|
||||
[],
|
||||
[
|
||||
{
|
||||
service: 'notion',
|
||||
displayName: 'Notion',
|
||||
iconUrl: 'https://cdn.example.com/notion.svg',
|
||||
actions: [{ id: 'notion_search', name: 'Search Notion' }],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(data.contextItems).toMatchObject([
|
||||
{
|
||||
id: 'connector:notion',
|
||||
label: 'Notion',
|
||||
iconUrl: 'https://cdn.example.com/notion.svg',
|
||||
historical: false,
|
||||
calls: [{ id: 'notion-call' }],
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(data)).not.toContain('__legacy_data');
|
||||
});
|
||||
|
||||
it('shows only explicitly loaded skills and ignores skill discovery', () => {
|
||||
const availableSkills =
|
||||
"[{'name': 'skill-security-auditor', 'description': " +
|
||||
"'Security auditing for code, configs, and infrastructure.'}]";
|
||||
const run = makeRun('run-current', true, [
|
||||
skillToolkitNode(
|
||||
'list-start',
|
||||
1,
|
||||
'running',
|
||||
'list_skills',
|
||||
JSON.stringify({ message_title: 'List Skills' })
|
||||
),
|
||||
skillToolkitNode(
|
||||
'list-end',
|
||||
2,
|
||||
'completed',
|
||||
'list_skills',
|
||||
availableSkills
|
||||
),
|
||||
skillToolkitNode(
|
||||
'load-start',
|
||||
3,
|
||||
'running',
|
||||
'load_skill',
|
||||
JSON.stringify({ name: 'pdf', message_title: 'Load Skill' })
|
||||
),
|
||||
skillToolkitNode(
|
||||
'load-end',
|
||||
4,
|
||||
'completed',
|
||||
'load_skill',
|
||||
'## Skill: pdf\n\n# PDF Processing Guide'
|
||||
),
|
||||
]);
|
||||
|
||||
expect(buildProjectSessionPanelData([run], []).contextItems).toMatchObject([
|
||||
{
|
||||
id: 'skill:pdf',
|
||||
label: 'pdf',
|
||||
category: 'skill',
|
||||
historical: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts unique searched URLs without trailing punctuation', () => {
|
||||
expect(
|
||||
extractHttpUrls(
|
||||
'Read https://example.com/a, then https://example.com/a and https://docs.example.com/page).'
|
||||
)
|
||||
).toEqual(['https://example.com/a', 'https://docs.example.com/page']);
|
||||
});
|
||||
});
|
||||
66
test/unit/components/resolveContextConnector.test.ts
Normal file
66
test/unit/components/resolveContextConnector.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import {
|
||||
resolveContextConnector,
|
||||
type ContextConnector,
|
||||
} from '@/components/Session/SidePanel/sections/buildContextItems';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const notion: ContextConnector = {
|
||||
service: 'notion',
|
||||
displayName: 'Notion',
|
||||
};
|
||||
const slack: ContextConnector = {
|
||||
service: 'slack',
|
||||
displayName: 'Slack',
|
||||
};
|
||||
|
||||
describe('resolveContextConnector', () => {
|
||||
it('matches a provider-prefixed toolkit by name', () => {
|
||||
expect(
|
||||
resolveContextConnector('NotionMCPToolkit', 'search', '', [notion, slack])
|
||||
).toBe(notion);
|
||||
});
|
||||
|
||||
it('matches a provider by method when the toolkit is generic', () => {
|
||||
expect(
|
||||
resolveContextConnector('MCPToolkit', 'slack_send_message', '', [
|
||||
notion,
|
||||
slack,
|
||||
])
|
||||
).toBe(slack);
|
||||
});
|
||||
|
||||
it('assumes the only connector for a connector gateway call', () => {
|
||||
expect(
|
||||
resolveContextConnector('ConnectorGateway', 'call', '', [slack])
|
||||
).toBe(slack);
|
||||
expect(
|
||||
resolveContextConnector('connector_gateway', 'call', '', [slack])
|
||||
).toBe(slack);
|
||||
});
|
||||
|
||||
it('stays generic for an unidentified MCP call even with one connector', () => {
|
||||
expect(
|
||||
resolveContextConnector('MCPToolkit', 'call', '', [slack])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('stays generic when a gateway call cannot pick between connectors', () => {
|
||||
expect(
|
||||
resolveContextConnector('ConnectorGateway', 'call', '', [notion, slack])
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
67
test/unit/components/sessionPanelPrimitives.test.tsx
Normal file
67
test/unit/components/sessionPanelPrimitives.test.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { SidePanelAccordionBox } from '@/components/Session/SidePanel/components/AccordionBox';
|
||||
import { SessionPanelCollapse } from '@/components/Session/SidePanel/sections/primitives';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('SessionPanelCollapse', () => {
|
||||
it('removes collapsed content from keyboard navigation', () => {
|
||||
const { container, rerender } = render(
|
||||
<SessionPanelCollapse open={false}>
|
||||
<button type="button">Hidden action</button>
|
||||
</SessionPanelCollapse>
|
||||
);
|
||||
|
||||
expect(container.firstElementChild).toHaveAttribute('inert');
|
||||
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
|
||||
|
||||
rerender(
|
||||
<SessionPanelCollapse open>
|
||||
<button type="button">Visible action</button>
|
||||
</SessionPanelCollapse>
|
||||
);
|
||||
|
||||
expect(container.firstElementChild).not.toHaveAttribute('inert');
|
||||
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SidePanelAccordionBox', () => {
|
||||
it('keeps top-level section headers sticky within their section', () => {
|
||||
const { container } = render(
|
||||
<SidePanelAccordionBox title="Resources">
|
||||
<div>Resource rows</div>
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Resources' });
|
||||
const header = trigger.parentElement?.parentElement;
|
||||
expect(header).toHaveClass('sticky', 'top-0');
|
||||
expect(container.firstElementChild).toHaveClass('overflow-visible');
|
||||
});
|
||||
|
||||
it('does not pin nested subcategory headers', () => {
|
||||
render(
|
||||
<SidePanelAccordionBox title="Skills" rowVariant="subcategory">
|
||||
<div>Skill rows</div>
|
||||
</SidePanelAccordionBox>
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Skills' });
|
||||
const header = trigger.parentElement?.parentElement;
|
||||
expect(header).not.toHaveClass('sticky');
|
||||
});
|
||||
});
|
||||
113
test/unit/components/sessionPanelScope.test.ts
Normal file
113
test/unit/components/sessionPanelScope.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import {
|
||||
arrangeSessionPanelItems,
|
||||
selectSessionPanelRuns,
|
||||
} from '@/components/Session/SidePanel/sections/sessionPanelScope';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: 'current-recently-updated',
|
||||
historical: false,
|
||||
createdAt: 400,
|
||||
updatedAt: 500,
|
||||
},
|
||||
{
|
||||
id: 'newest-history',
|
||||
historical: true,
|
||||
createdAt: 300,
|
||||
updatedAt: 100,
|
||||
},
|
||||
{
|
||||
id: 'current-less-recently-updated',
|
||||
historical: false,
|
||||
createdAt: 400,
|
||||
updatedAt: 200,
|
||||
},
|
||||
{
|
||||
id: 'oldest-history',
|
||||
historical: true,
|
||||
createdAt: 100,
|
||||
updatedAt: 400,
|
||||
},
|
||||
];
|
||||
|
||||
describe('arrangeSessionPanelItems', () => {
|
||||
it('shows only the current run in latest mode', () => {
|
||||
const result = arrangeSessionPanelItems(items, 'latest');
|
||||
|
||||
expect(result.primary.map((item) => item.id)).toEqual([
|
||||
'current-recently-updated',
|
||||
'current-less-recently-updated',
|
||||
]);
|
||||
expect(result.earlier).toEqual([]);
|
||||
});
|
||||
|
||||
it('groups previous-run items under earlier in all mode', () => {
|
||||
const result = arrangeSessionPanelItems(items, 'all');
|
||||
|
||||
expect(result.primary.map((item) => item.id)).toEqual([
|
||||
'current-recently-updated',
|
||||
'current-less-recently-updated',
|
||||
]);
|
||||
expect(result.earlier.map((item) => item.id)).toEqual([
|
||||
'newest-history',
|
||||
'oldest-history',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps every item reachable in all mode', () => {
|
||||
const result = arrangeSessionPanelItems(items, 'all');
|
||||
|
||||
expect([...result.primary, ...result.earlier]).toHaveLength(items.length);
|
||||
});
|
||||
|
||||
it('can preserve a deliberately chronological source order', () => {
|
||||
const chronological = [...items].reverse();
|
||||
const result = arrangeSessionPanelItems(chronological, 'all', 'source');
|
||||
|
||||
expect(result.primary.map((item) => item.id)).toEqual([
|
||||
'current-less-recently-updated',
|
||||
'current-recently-updated',
|
||||
]);
|
||||
expect(result.earlier.map((item) => item.id)).toEqual([
|
||||
'oldest-history',
|
||||
'newest-history',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectSessionPanelRuns', () => {
|
||||
const runs = [
|
||||
{ id: 'old', isCurrent: false, createdAt: 100, updatedAt: 500 },
|
||||
{ id: 'current', isCurrent: true, createdAt: 400, updatedAt: 200 },
|
||||
{ id: 'previous', isCurrent: false, createdAt: 300, updatedAt: 100 },
|
||||
];
|
||||
|
||||
it('returns only the current run in latest mode', () => {
|
||||
expect(selectSessionPanelRuns(runs, 'latest').map((run) => run.id)).toEqual(
|
||||
['current']
|
||||
);
|
||||
});
|
||||
|
||||
it('returns every run by creation time in all mode', () => {
|
||||
expect(selectSessionPanelRuns(runs, 'all').map((run) => run.id)).toEqual([
|
||||
'current',
|
||||
'previous',
|
||||
'old',
|
||||
]);
|
||||
});
|
||||
});
|
||||
134
test/unit/hooks/useProjectEventRuntime.test.tsx
Normal file
134
test/unit/hooks/useProjectEventRuntime.test.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import {
|
||||
ProjectEventRuntimeProvider,
|
||||
useProjectEventRuntime,
|
||||
} from '@/hooks/useProjectEventRuntime';
|
||||
import { resetProjectEventStoresForTests } from '@/store/projectEventStore';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
hydration: vi.fn(() => ({
|
||||
status: 'ready' as const,
|
||||
errorCode: null,
|
||||
eventsTruncated: false,
|
||||
})),
|
||||
streams: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useProjectEventStoreHydration', () => ({
|
||||
useProjectEventStoreHydration: mocks.hydration,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useProjectRunEventStreams', () => ({
|
||||
useProjectRunEventStreams: mocks.streams,
|
||||
}));
|
||||
|
||||
function Consumer() {
|
||||
const runtime = useProjectEventRuntime();
|
||||
return (
|
||||
<div>
|
||||
{runtime.projectId}:{runtime.snapshot?.revision ?? 'none'}:
|
||||
{runtime.hydration.status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ProjectEventRuntimeProvider', () => {
|
||||
afterEach(() => {
|
||||
resetProjectEventStoresForTests();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('owns one hydration and live-stream subscription for its Session', () => {
|
||||
render(
|
||||
<ProjectEventRuntimeProvider projectId="project-1">
|
||||
<Consumer />
|
||||
</ProjectEventRuntimeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('project-1:0:ready')).toBeInTheDocument();
|
||||
expect(mocks.hydration).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.hydration).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
enabled: true,
|
||||
});
|
||||
expect(mocks.streams).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.streams).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: 'project-1',
|
||||
enabled: true,
|
||||
snapshot: expect.objectContaining({ revision: 0 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('detaches the previous Project snapshot when the Session has no scope', () => {
|
||||
const { rerender } = render(
|
||||
<ProjectEventRuntimeProvider projectId="project-previous">
|
||||
<Consumer />
|
||||
</ProjectEventRuntimeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('project-previous:0:ready')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ProjectEventRuntimeProvider projectId={null}>
|
||||
<Consumer />
|
||||
</ProjectEventRuntimeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText(':none:ready')).toBeInTheDocument();
|
||||
expect(mocks.hydration).toHaveBeenLastCalledWith({
|
||||
projectId: null,
|
||||
enabled: false,
|
||||
});
|
||||
expect(mocks.streams).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: null,
|
||||
enabled: false,
|
||||
snapshot: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rebinds hydration and streams when the active Project changes', () => {
|
||||
const { rerender } = render(
|
||||
<ProjectEventRuntimeProvider projectId="project-1">
|
||||
<Consumer />
|
||||
</ProjectEventRuntimeProvider>
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ProjectEventRuntimeProvider projectId="project-2">
|
||||
<Consumer />
|
||||
</ProjectEventRuntimeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('project-2:0:ready')).toBeInTheDocument();
|
||||
expect(mocks.hydration).toHaveBeenLastCalledWith({
|
||||
projectId: 'project-2',
|
||||
enabled: true,
|
||||
});
|
||||
expect(mocks.streams).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: 'project-2',
|
||||
enabled: true,
|
||||
snapshot: expect.objectContaining({ revision: 0 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
124
test/unit/hooks/useProjectEventStoreHydration.test.tsx
Normal file
124
test/unit/hooks/useProjectEventStoreHydration.test.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { useProjectEventStoreHydration } from '@/hooks/useProjectEventStoreHydration';
|
||||
import { resetProjectEventStoresForTests } from '@/store/projectEventStore';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
hydrate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/service/projectEventStoreHydration', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<
|
||||
typeof import('@/service/projectEventStoreHydration')
|
||||
>();
|
||||
return {
|
||||
...original,
|
||||
hydrateProjectEventStore: mocks.hydrate,
|
||||
};
|
||||
});
|
||||
|
||||
const hydrated = {
|
||||
projectId: 'project-1',
|
||||
runCount: 0,
|
||||
eventCount: 0,
|
||||
pageCount: 1,
|
||||
byteCount: 0,
|
||||
eventsTruncated: false,
|
||||
};
|
||||
|
||||
async function flushHydration(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('useProjectEventStoreHydration', () => {
|
||||
let warn: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warn.mockRestore();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
resetProjectEventStoresForTests();
|
||||
});
|
||||
|
||||
it('blocks automatic retries when the replay API is unsupported', async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.hydrate.mockRejectedValue(
|
||||
Object.assign(new Error('Not found'), {
|
||||
status: 404,
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectEventStoreHydration({
|
||||
projectId: 'project-1',
|
||||
enabled: true,
|
||||
})
|
||||
);
|
||||
await flushHydration();
|
||||
|
||||
expect(result.current).toMatchObject({
|
||||
status: 'error',
|
||||
errorCode: 'unsupported',
|
||||
});
|
||||
expect(mocks.hydrate).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
});
|
||||
expect(mocks.hydrate).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => result.current.retry());
|
||||
await flushHydration();
|
||||
expect(mocks.hydrate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps bounded backoff for transient request failures', async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.hydrate
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Unavailable'), {
|
||||
status: 503,
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(hydrated);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectEventStoreHydration({
|
||||
projectId: 'project-1',
|
||||
enabled: true,
|
||||
})
|
||||
);
|
||||
await flushHydration();
|
||||
|
||||
expect(result.current.status).toBe('retrying');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
});
|
||||
await flushHydration();
|
||||
|
||||
expect(mocks.hydrate).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.status).toBe('ready');
|
||||
});
|
||||
});
|
||||
150
test/unit/hooks/useProjectSessionOverview.test.tsx
Normal file
150
test/unit/hooks/useProjectSessionOverview.test.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { buildProjectSessionOverview } from '@/hooks/useProjectSessionOverview';
|
||||
import {
|
||||
createChatProjectionState,
|
||||
type ChatMessageNode,
|
||||
} from '@/lib/projector/chat';
|
||||
import {
|
||||
createHumanControlProjectionState,
|
||||
type HumanControlProjectionState,
|
||||
} from '@/lib/projector/control';
|
||||
import type { ProjectEventStoreSnapshot } from '@/store/projectEventStore';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function message(
|
||||
runId: string,
|
||||
sequence: number,
|
||||
createdAt = new Date(sequence * 1_000).toISOString()
|
||||
): ChatMessageNode {
|
||||
return {
|
||||
id: `${runId}:${sequence}`,
|
||||
eventId: `${runId}:${sequence}`,
|
||||
projectId: 'project-1',
|
||||
runId,
|
||||
createdAt,
|
||||
runSequence: sequence,
|
||||
cloudCursor: null,
|
||||
eventType: 'message.completed',
|
||||
legacyStep: null,
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
content: runId,
|
||||
status: 'complete',
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(): ProjectEventStoreSnapshot {
|
||||
const nodes = [message('run-complete', 4), message('run-live', 1)];
|
||||
return {
|
||||
view: {
|
||||
projectId: 'project-1',
|
||||
mode: 'live',
|
||||
seenEventIds: {},
|
||||
currentCursor: 0,
|
||||
eventsTruncated: false,
|
||||
lastSyncedAt: null,
|
||||
needsResync: false,
|
||||
resyncReason: null,
|
||||
resyncTargetCursor: null,
|
||||
runs: {
|
||||
'run-complete': {
|
||||
runId: 'run-complete',
|
||||
status: 'completed',
|
||||
lastSequence: 4,
|
||||
runVersion: 4,
|
||||
updatedAt: new Date(4_000).toISOString(),
|
||||
origin: 'local',
|
||||
resumeBlockedReason: null,
|
||||
},
|
||||
'run-live': {
|
||||
runId: 'run-live',
|
||||
status: 'running',
|
||||
lastSequence: 1,
|
||||
runVersion: 1,
|
||||
updatedAt: new Date(1_000).toISOString(),
|
||||
origin: 'local',
|
||||
resumeBlockedReason: null,
|
||||
},
|
||||
},
|
||||
legacySteps: [],
|
||||
unknownEvents: [],
|
||||
},
|
||||
chat: {
|
||||
...createChatProjectionState('project-1'),
|
||||
nodes,
|
||||
nodeById: Object.fromEntries(nodes.map((node) => [node.id, node])),
|
||||
seenEventIds: Object.fromEntries(
|
||||
nodes.map((node) => [node.eventId, true as const])
|
||||
),
|
||||
},
|
||||
control: createHumanControlProjectionState(
|
||||
'project-1'
|
||||
) as HumanControlProjectionState,
|
||||
revision: 1,
|
||||
hasHydratedSnapshot: true,
|
||||
overflowed: false,
|
||||
lastEffects: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildProjectSessionOverview', () => {
|
||||
it('keeps an active durable Run current even when history is newer', () => {
|
||||
const overview = buildProjectSessionOverview(snapshot());
|
||||
|
||||
expect(overview.currentRun?.runId).toBe('run-live');
|
||||
expect(overview.historicalRuns.map((run) => run.runId)).toContain(
|
||||
'run-complete'
|
||||
);
|
||||
});
|
||||
|
||||
it('groups semantic nodes by Run without consulting ChatStore', () => {
|
||||
const overview = buildProjectSessionOverview(snapshot());
|
||||
|
||||
expect(overview.runs).toHaveLength(2);
|
||||
expect(
|
||||
overview.runs.find((run) => run.runId === 'run-complete')?.nodes
|
||||
).toMatchObject([{ content: 'run-complete' }]);
|
||||
});
|
||||
|
||||
it('keeps canonical Run sequence order when timestamps are reversed', () => {
|
||||
const input = snapshot();
|
||||
const nodes = [
|
||||
message('run-live', 2, new Date(1_000).toISOString()),
|
||||
message('run-live', 1, new Date(2_000).toISOString()),
|
||||
];
|
||||
input.chat.nodes = nodes;
|
||||
input.chat.nodeById = Object.fromEntries(
|
||||
nodes.map((node) => [node.id, node])
|
||||
);
|
||||
input.chat.seenEventIds = Object.fromEntries(
|
||||
nodes.map((node) => [node.eventId, true as const])
|
||||
);
|
||||
|
||||
const overview = buildProjectSessionOverview(input);
|
||||
|
||||
expect(overview.currentRun?.nodes.map((node) => node.runSequence)).toEqual([
|
||||
1, 2,
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty view before durable hydration has a snapshot', () => {
|
||||
expect(buildProjectSessionOverview(null)).toEqual({
|
||||
currentRun: null,
|
||||
historicalRuns: [],
|
||||
runs: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -12,11 +12,15 @@
|
|||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { normalizeLegacyChatStep } from '@/lib/projector';
|
||||
import { adaptChatProjectionEvent } from '@/lib/projector/chat';
|
||||
import type { CanonicalProjectEvent } from '@/lib/projector/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function event(payload: Record<string, unknown>): CanonicalProjectEvent {
|
||||
function event(
|
||||
payload: Record<string, unknown>,
|
||||
eventType = 'tool.started'
|
||||
): CanonicalProjectEvent {
|
||||
return {
|
||||
eventId: 'tool-event-1',
|
||||
projectId: 'project-1',
|
||||
|
|
@ -24,7 +28,7 @@ function event(payload: Record<string, unknown>): CanonicalProjectEvent {
|
|||
runSequence: 1,
|
||||
runVersion: 1,
|
||||
cloudCursor: 1,
|
||||
eventType: 'tool.started',
|
||||
eventType,
|
||||
payload,
|
||||
legacyStep: null,
|
||||
createdAt: '2026-08-13T00:00:00Z',
|
||||
|
|
@ -81,4 +85,55 @@ describe('chat activity projection', () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps artifact identity separate from a machine-local path', () => {
|
||||
const node = adaptChatProjectionEvent(
|
||||
event(
|
||||
{
|
||||
artifact_id: 'artifact-1',
|
||||
file_path: '/private/workspace/outputs/report.md',
|
||||
relative_path: 'outputs/report.md',
|
||||
name: 'report.md',
|
||||
},
|
||||
'artifact.created'
|
||||
)
|
||||
);
|
||||
|
||||
expect(node).toMatchObject({
|
||||
kind: 'display',
|
||||
node: {
|
||||
kind: 'artifact',
|
||||
artifactId: 'artifact-1',
|
||||
path: 'outputs/report.md',
|
||||
relativePath: 'outputs/report.md',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a portable legacy file path as artifact identity', () => {
|
||||
const node = adaptChatProjectionEvent(
|
||||
normalizeLegacyChatStep(
|
||||
{
|
||||
step: 'write_file',
|
||||
data: { file_path: 'reports/quarterly/summary.md' },
|
||||
},
|
||||
{
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
sequence: 1,
|
||||
sourceId: 'legacy-stream',
|
||||
createdAt: 1_000,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(node).toMatchObject({
|
||||
kind: 'display',
|
||||
node: {
|
||||
kind: 'artifact',
|
||||
path: 'reports/quarterly/summary.md',
|
||||
relativePath: 'reports/quarterly/summary.md',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue