Refactor session side panel with event bus, unified shell and dynamic display

This commit is contained in:
Douglas 2026-07-24 17:26:20 +01:00
parent 75968fd6bb
commit 7ef924475d
52 changed files with 3436 additions and 876 deletions

View file

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

View file

@ -1,135 +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 { 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 { TooltipSimple } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { SessionMode, type SessionModeType } from '@/types/constants';
import { ChevronLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export interface SessionSidePanelProps {
mode: SessionModeType;
workforcePanelKey: string;
hasAnyMessages: boolean;
isSidePanelVisible: boolean;
onToggleSidePanel: () => void;
isExpandedOverlayOpen: boolean;
onToggleExpandedOverlay: () => void;
onCloseExpandedOverlay: () => void;
}
export function SessionSidePanel({
mode,
workforcePanelKey,
hasAnyMessages,
isSidePanelVisible,
onToggleSidePanel,
isExpandedOverlayOpen,
onToggleExpandedOverlay,
onCloseExpandedOverlay,
}: SessionSidePanelProps) {
const { t } = useTranslation();
const isFolded = !isSidePanelVisible;
const headerTitle =
mode === SessionMode.WORKFORCE
? t('layout.aiWorkforce')
: t('layout.workspace-session-single-agent');
const expandFoldedTooltip =
mode === SessionMode.WORKFORCE
? t('layout.show-workforce-panel', {
defaultValue: 'Show workforce panel',
})
: t('layout.show-side-panel', {
defaultValue: 'Show side panel',
});
return (
<div className="group relative h-full min-h-0 w-full overflow-hidden">
{/* Full logical width; outer #session-side-panel clips to 40px when folded */}
<div
className={cn(
'flex h-full min-h-0 flex-shrink-0 flex-col overflow-hidden',
SESSION_SIDE_PANEL_CONTENT_WIDTH_CLASS,
isFolded &&
'pointer-events-none opacity-40 transition-opacity duration-200 group-hover:opacity-80'
)}
>
<SessionSidePanelHeader
title={headerTitle}
mode={mode}
isSidePanelVisible={isSidePanelVisible}
onToggle={onToggleSidePanel}
start={<TurnTabs />}
end={
mode === SessionMode.WORKFORCE ? (
<WorkforceSidePanelHeaderEnd
isExpandedOverlayOpen={isExpandedOverlayOpen}
onToggleExpandedOverlay={onToggleExpandedOverlay}
/>
) : null
}
/>
{mode === SessionMode.WORKFORCE ? (
<WorkforceSidePanel
workforcePanelKey={workforcePanelKey}
hasAnyMessages={hasAnyMessages}
isSidePanelVisible={isSidePanelVisible}
onToggleSidePanel={onToggleSidePanel}
isExpandedOverlayOpen={isExpandedOverlayOpen}
onToggleExpandedOverlay={onToggleExpandedOverlay}
onCloseExpandedOverlay={onCloseExpandedOverlay}
/>
) : (
<SingleAgentSidePanel />
)}
</div>
{isFolded && (
<TooltipSimple
content={expandFoldedTooltip}
side="left"
variant="instant"
>
<button
type="button"
onClick={onToggleSidePanel}
aria-label={expandFoldedTooltip}
aria-expanded={isSidePanelVisible}
aria-controls="session-side-panel"
className={cn(
'absolute inset-0 z-20 flex items-center justify-center focus-visible:ring-ds-border-neutral-strong-default',
'cursor-pointer border-0 bg-transparent p-0 outline-none',
'focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-ds-bg-neutral-default-default',
'text-ds-text-neutral-default-default'
)}
>
<ChevronLeft
className="pointer-events-none h-4 w-4 shrink-0"
aria-hidden
/>
</button>
</TooltipSimple>
)}
</div>
);
}

View file

@ -0,0 +1,136 @@
// ========= 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;
return (
<motion.div
layout={!shouldReduceMotion}
transition={
shouldReduceMotion ? { layout: { duration: 0 } } : LAYOUT_TRANSITION
}
className="z-10 flex min-w-0 shrink-0 flex-col overflow-hidden"
>
<div className="flex h-10 min-h-10 w-full shrink-0 items-center">
<div className="min-w-0 flex-1">
<SessionPanelButton
variant={rowVariant}
leading={leading}
badge={titleSuffix}
chevron
open={open}
ariaExpanded={open}
onClick={() => setOpen((value) => !value)}
>
{title}
</SessionPanelButton>
</div>
{headerAction ? (
<div className="flex h-10 shrink-0 items-center pr-0.5">
{headerAction}
</div>
) : null}
</div>
{isRenderProp ? (
<SessionPanelCollapse open={dynamicBody != null}>
<motion.div
layout={!shouldReduceMotion}
transition={
shouldReduceMotion
? { layout: { duration: 0 } }
: LAYOUT_TRANSITION
}
className={cn('w-full', contentClassName)}
>
{dynamicBody}
</motion.div>
</SessionPanelCollapse>
) : (
<>
<SessionPanelCollapse open={open}>
<div className={cn('w-full', contentClassName)}>
{children as ReactNode}
</div>
</SessionPanelCollapse>
{collapsedPreview ? (
<SessionPanelCollapse open={!open}>
<div className={cn('w-full', contentClassName)}>
{collapsedPreview}
</div>
</SessionPanelCollapse>
) : null}
</>
)}
</motion.div>
);
}

View file

@ -0,0 +1,657 @@
// ========= 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 SessionAgentItem,
type SessionContextItem,
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 { Button } from '@/components/ui/button';
import { TooltipSimple } from '@/components/ui/tooltip';
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 {
Bot,
Boxes,
ExternalLink,
FileText,
Globe,
Hammer,
Plus,
WandSparkles,
} from 'lucide-react';
import {
Children,
Fragment,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
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 AgentsSection({
items,
scope,
headerAction,
onSelect,
}: {
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 ||
t('layout.session-panel-remote-subagent', {
defaultValue: 'Remote subagent',
})}
</SidePanelListRow>
)}
/>
);
return (
<SidePanelAccordionBox
title={t('layout.agents')}
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);
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,
icon,
items,
onSelect,
}: {
title: string;
icon: ReactNode;
items: SessionContextItem[];
onSelect: (item: SessionContextItem) => void;
}) {
if (items.length === 0) return null;
return (
<SidePanelAccordionBox
title={title}
titleSuffix={<CountPill count={items.length} />}
leading={icon}
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',
})}
icon={<WandSparkles size={16} aria-hidden />}
items={primary.filter((item) => item.category === 'skill')}
onSelect={onSelect}
/>
<ContextSubcategory
title={t('layout.mcp-tools')}
icon={<Hammer size={16} aria-hidden />}
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 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 />}
onClick={() => onSelect(item)}
>
{item.file.name || item.file.path}
</SidePanelListRow>
)}
/>
);
return (
<SidePanelAccordionBox
title={t('layout.session-panel-files', {
defaultValue: 'Files',
})}
titleSuffix={<CountPill count={primary.length} />}
headerAction={headerAction}
>
{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 projectStore = useProjectRuntimeStore();
const projectId = projectStore.activeProjectId;
const overview = useProjectSessionOverview(projectId);
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);
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 = useMemo(
() => buildProjectSessionPanelData(scopedRuns, skills, connectors),
[connectors, scopedRuns, skills]
);
const projectFiles = useProjectOutputFiles(
projectId,
overview.currentRun?.task,
overview.currentRun?.taskId
);
const files = useMemo(
() =>
mergeProjectFiles(
panelData.files,
projectFiles,
overview.currentRun?.taskId ?? '',
overview.currentRun?.createdAt ?? 0,
overview.currentRun?.updatedAt ?? 0
),
[
overview.currentRun?.taskId,
overview.currentRun?.createdAt,
overview.currentRun?.updatedAt,
panelData.files,
projectFiles,
]
);
const attachToRun = (
run: NonNullable<typeof overview.currentRun>,
selectedFiles: File[]
) => {
if (selectedFiles.length === 0) return;
// Read attaches at merge time so files added while the picker was open
// are not clobbered.
const state = run.chatStore.getState();
const existingFiles = state.tasks[run.taskId]?.attaches ?? [];
state.setAttaches(run.taskId, [
...existingFiles,
...selectedFiles.filter(
(selected) =>
!existingFiles.some(
(existing) => existing.filePath === selected.filePath
)
),
]);
};
const addFiles = async () => {
const run = overview.currentRun;
if (!run || addingFiles) return;
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) return;
setAddingFiles(true);
try {
const uploads: File[] = [];
for (const file of picked) {
try {
const result = await uploadFileToBrain(file);
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(run, uploads);
} finally {
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(run, result.files);
}
} catch (error) {
console.error('Select session files failed:', error);
} finally {
setAddingFiles(false);
}
};
const addFilesLabel = t('chat.input-attach-add-files-or-photos', {
defaultValue: 'Add files',
});
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">
<SectionList>
{panelData.agents.length > 0 ? (
<AgentsSection
items={panelData.agents}
scope={scope}
headerAction={agentHeaderAction}
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.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}
{files.length > 0 ? (
<FilesSection
items={files}
scope={scope}
onSelect={(item) => openFilePreview(item.file)}
headerAction={
<TooltipSimple
content={addFilesLabel}
variant="instant"
side="bottom"
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
buttonRadius="lg"
disabled={addingFiles || !overview.currentRun}
aria-label={addFilesLabel}
onClick={() => void addFiles()}
>
<Plus className="size-4" aria-hidden />
</Button>
</TooltipSimple>
}
/>
) : null}
</SectionList>
{panelData.agents.length === 0 &&
panelData.progress.length === 0 &&
panelData.contextItems.length === 0 &&
panelData.resources.length === 0 &&
files.length === 0 ? (
<div className="px-3 py-6 text-center text-body-sm text-ds-text-neutral-muted-default">
{t('layout.session-activity-empty', {
defaultValue:
'Session activity will appear here as work begins.',
})}
</div>
) : null}
</div>
</div>
<AgentInformationDialog
agent={selectedAgent}
onOpenChange={(open) => {
if (!open) setSelectedAgent(null);
}}
/>
<ToolCallsDialog
item={selectedContext}
onOpenChange={(open) => {
if (!open) setSelectedContext(null);
}}
/>
</>
);
}

View file

@ -29,7 +29,7 @@ import { useTranslation } from 'react-i18next';
const EDGE_PADDING_PX = 32;
export interface ExpandedOverlayProps {
export interface SidePanelExpandedOverlayProps {
open: boolean;
onClose: () => void;
workforcePanelKey: string;
@ -132,7 +132,7 @@ export default function ExpandedOverlay({
onToggleSidePanel,
isSidePanelVisible,
selectedTurn,
}: ExpandedOverlayProps) {
}: SidePanelExpandedOverlayProps) {
const shouldReduceMotion = useReducedMotion();
const { t } = useTranslation();
const host = useHost();

View file

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

View file

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

View file

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

View file

@ -12,11 +12,11 @@
// 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;
@ -27,31 +27,31 @@ export interface SessionSidePanelHeaderProps {
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">
<div className="flex min-w-0 flex-1 items-center justify-end gap-1">
{start}
{end != null ? (
<div className="gap-1 flex items-center">{end}</div>
<div className="flex items-center gap-1">{end}</div>
) : null}
</div>
</div>

View file

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

View file

@ -29,7 +29,7 @@ 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"
className="inline-flex h-2 w-2 shrink-0 animate-pulse rounded-full bg-ds-bg-brand-default-default"
aria-hidden
/>
);
@ -44,7 +44,7 @@ function StatusDot({ status }: { status: string }) {
: 'bg-ds-border-neutral-default-default';
return (
<span
className={cn('h-2 w-2 inline-flex shrink-0 rounded-full', cls)}
className={cn('inline-flex h-2 w-2 shrink-0 rounded-full', cls)}
aria-hidden
/>
);
@ -58,7 +58,7 @@ interface TurnEntry {
}
/**
* Dropdown button showing "Run N ▼" next to the fold button.
* Legacy run selector retained with the SidePanel components.
* Hidden when the project has 1 turn.
*/
export function TurnTabs() {
@ -175,14 +175,14 @@ export function TurnTabs() {
className="gap-2"
>
{isLive ? <LiveDot /> : <StatusDot status={turn.status} />}
<span className="text-ds-text-neutral-muted-default text-label-xs font-semibold shrink-0">
<span className="shrink-0 text-label-xs font-semibold text-ds-text-neutral-muted-default">
Run {turnNumber}
</span>
<span className="text-ds-text-neutral-subtle-default text-label-xs min-w-0 flex-1 truncate">
<span className="min-w-0 flex-1 truncate text-label-xs text-ds-text-neutral-subtle-default">
{preview}
</span>
{isSelected && (
<Check className="size-3 text-ds-icon-brand-default-default ml-auto shrink-0" />
<Check className="ml-auto size-3 shrink-0 text-ds-icon-brand-default-default" />
)}
</DropdownMenuItem>
);

View file

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

View file

@ -0,0 +1,175 @@
// ========= 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 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 { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
import { cn } from '@/lib/utils';
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
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;
isSidePanelVisible: boolean;
onToggleSidePanel: () => void;
isExpandedOverlayOpen: boolean;
onToggleExpandedOverlay: () => void;
onCloseExpandedOverlay: () => void;
}
export function SessionSidePanel({
mode,
workforcePanelKey,
isSidePanelVisible,
onToggleSidePanel,
isExpandedOverlayOpen,
onToggleExpandedOverlay,
onCloseExpandedOverlay,
}: SessionSidePanelProps) {
const { t } = useTranslation();
const isFolded = !isSidePanelVisible;
const projectStore = useProjectRuntimeStore();
const selectedTurn = useSelectedProjectTurn(projectStore.activeProjectId);
const [scope, setScope] = useState<SessionPanelScope>('latest');
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 = t('layout.show-side-panel', {
defaultValue: 'Show side panel',
});
return (
<div className="group relative h-full min-h-0 w-full overflow-hidden">
{/* Full logical width; outer #session-side-panel clips to 40px when folded */}
<div
className={cn(
'flex h-full min-h-0 flex-shrink-0 flex-col overflow-hidden',
SESSION_SIDE_PANEL_CONTENT_WIDTH_CLASS,
isFolded &&
'pointer-events-none opacity-40 transition-opacity duration-200 group-hover:opacity-80'
)}
>
<SidePanelHeader
title={headerTitle}
mode={mode}
isSidePanelVisible={isSidePanelVisible}
onToggle={onToggleSidePanel}
end={
<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>
}
/>
{/* 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
}
/>
</div>
</div>
{mode === SessionMode.WORKFORCE ? (
<ExpandedOverlay
open={isExpandedOverlayOpen}
onClose={onCloseExpandedOverlay}
workforcePanelKey={workforcePanelKey}
onToggleSidePanel={onToggleSidePanel}
isSidePanelVisible={isSidePanelVisible}
selectedTurn={selectedTurn}
/>
) : null}
{isFolded && (
<TooltipSimple
content={expandFoldedTooltip}
side="left"
variant="instant"
>
<button
type="button"
onClick={onToggleSidePanel}
aria-label={expandFoldedTooltip}
aria-expanded={isSidePanelVisible}
aria-controls="session-side-panel"
className={cn(
'absolute inset-0 z-20 flex items-center justify-center focus-visible:ring-ds-border-neutral-strong-default',
'cursor-pointer border-0 bg-transparent p-0 outline-none',
'focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-ds-bg-neutral-default-default',
'text-ds-text-neutral-default-default'
)}
>
<ChevronLeft
className="pointer-events-none h-4 w-4 shrink-0"
aria-hidden
/>
</button>
</TooltipSimple>
)}
</div>
);
}

View file

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

View file

@ -12,8 +12,8 @@
// 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 { SidePanelAccordionBox } from '@/components/Session/SidePanel/components/AccordionBox';
import { SidePanelListRow } from '@/components/Session/SidePanel/sections/primitives';
import { isVisibleAgentFile } from '@/lib/agentFileFilters';
import { cn } from '@/lib/utils';
import {

View file

@ -12,7 +12,7 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { SidePanelAccordionBox } from '@/components/Session/SidePanelAccordionBox';
import { SidePanelAccordionBox } from '@/components/Session/SidePanel/components/AccordionBox';
import ShinyText from '@/components/ui/ShinyText/ShinyText';
import { agentMap, type WorkflowAgentType } from '@/components/WorkFlow/agents';
import { getToolkitIcon } from '@/lib/toolkitIcons';
@ -80,10 +80,10 @@ function getAgentSubIcon(agentType: string): ReactNode {
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">
<div className="relative inline-flex h-6 w-6 shrink-0 items-center justify-center self-center rounded-md bg-ds-bg-neutral-subtle-default text-ds-text-neutral-muted-default">
<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">
<span className="absolute -right-0.5 -top-0.5 inline-flex items-center justify-center [&_svg]:shrink-0">
{subIcon}
</span>
)}
@ -264,7 +264,7 @@ function AgentToolkitTag({ names }: { names: string[] }) {
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">
<div className="inline-flex h-6 min-w-0 shrink-0 items-center overflow-hidden">
<AnimatePresence initial={false} mode="popLayout">
{focused && (
<motion.div
@ -274,18 +274,18 @@ function AgentToolkitTag({ names }: { names: string[] }) {
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',
'inline-flex max-w-full items-center gap-1 rounded-md px-1.5 py-0.5 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">
<span className="inline-flex shrink-0 items-center text-ds-text-neutral-default-default [&_svg]:h-4 [&_svg]:w-4">
{getToolkitIcon(focused, 16, '')}
</span>
<ShinyText
text={focused}
speed={2.5}
className="text-label-xs font-medium max-w-[140px] truncate"
className="max-w-[140px] truncate text-label-xs font-medium"
/>
</motion.div>
)}
@ -304,14 +304,14 @@ function AgentRow({ agent }: { agent: Agent }) {
<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',
'flex min-w-0 items-center gap-2',
!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',
'min-w-0 flex-1 truncate !text-body-sm font-medium text-ds-text-neutral-default-default',
display?.textColor
)}
>
@ -324,7 +324,7 @@ function AgentRow({ agent }: { agent: Agent }) {
function AgentList({ agents }: { agents: Agent[] }) {
return (
<motion.ul layout className="gap-2 p-0 m-0 flex list-none flex-col">
<motion.ul layout className="m-0 flex list-none flex-col gap-2 p-0">
<AnimatePresence initial={false} mode="popLayout">
{agents.map((agent) => (
<motion.li
@ -357,7 +357,7 @@ export function AgentPoolSection({ title, agents }: AgentPoolSectionProps) {
);
const emptyState = (
<div className="text-ds-text-neutral-subtle-default text-body-sm px-1 py-1">
<div className="px-1 py-1 text-body-sm text-ds-text-neutral-subtle-default">
No agents yet
</div>
);

View file

@ -12,11 +12,11 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { SidePanelAccordionBox } from '@/components/Session/SidePanelAccordionBox';
import { SidePanelAccordionBox } from '@/components/Session/SidePanel/components/AccordionBox';
import {
CategoryLabel,
SidePanelListRow,
} from '@/components/Session/SidePanelSections/primitives';
} from '@/components/Session/SidePanel/sections/primitives';
import { AnimatePresence, motion } from 'framer-motion';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
@ -27,6 +27,7 @@ export interface ContextItem {
id: string;
label: string;
icon?: ReactNode;
iconUrl?: string;
category: ContextCategory;
onClick?: () => void;
}
@ -62,15 +63,15 @@ export function ExecutionContextSection({
return (
<SidePanelAccordionBox title={title}>
{items.length === 0 ? (
<div className="text-ds-text-neutral-subtle-default text-body-sm px-1 py-1 opacity-60">
<div className="px-1 py-1 text-body-sm text-ds-text-neutral-subtle-default opacity-60">
Track skills, MCPs and referenced files used in this task.
</div>
) : (
<div className="gap-2 flex flex-col">
<div className="flex flex-col gap-2">
{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">
<motion.ul layout className="m-0 list-none space-y-0.5 p-0">
<AnimatePresence initial={false}>
{groupItems.map((item) => (
<motion.li

View file

@ -12,13 +12,13 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { SidePanelAccordionBox } from '@/components/Session/SidePanelAccordionBox';
import { SidePanelAccordionBox } from '@/components/Session/SidePanel/components/AccordionBox';
import {
CountPill,
ProgressCircle,
ProgressConnector,
SidePanelListRow,
} from '@/components/Session/SidePanelSections/primitives';
} from '@/components/Session/SidePanel/sections/primitives';
import { cn } from '@/lib/utils';
import { usePageTabStore } from '@/store/pageTabStore';
import { TaskStatus } from '@/types/constants';
@ -51,7 +51,7 @@ export function ProgressSection({
const collapsedStrip =
count > 0 ? (
<div className="gap-1 min-w-0 mx-1 flex items-center overflow-hidden">
<div className="mx-1 flex min-w-0 items-center gap-1 overflow-hidden">
<AnimatePresence initial={false}>
{visibleSubtasks.map((task, idx) => (
<motion.span
@ -61,7 +61,7 @@ export function ProgressSection({
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"
className="flex min-w-0 items-center gap-1"
>
<ProgressCircle done={isDone(task)} />
{idx < visibleSubtasks.length - 1 ? <ProgressConnector /> : null}
@ -82,13 +82,13 @@ export function ProgressSection({
}
if (count === 0) {
return (
<div className="text-ds-text-neutral-subtle-default text-body-sm px-1 py-1 opacity-60">
<div className="px-1 py-1 text-body-sm text-ds-text-neutral-subtle-default 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">
<motion.ul layout className="m-0 list-none space-y-0.5 p-0">
<AnimatePresence initial={false}>
{visibleSubtasks.map((task) => (
<motion.li

View file

@ -0,0 +1,197 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { MarkDown } from '@/components/WorkFlow/MarkDown';
import {
Dialog,
DialogContent,
DialogContentSection,
DialogHeader,
} from '@/components/ui/dialog';
import { getToolkitIcon } from '@/lib/toolkitIcons';
import { Bot } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type {
SessionAgentItem,
SessionContextItem,
} from './buildProjectSessionPanelData';
/** Dialogs opened from SidePanel list items. */
export function AgentInformationDialog({
agent,
onOpenChange,
}: {
agent: SessionAgentItem | null;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
const agentName =
agent?.name ||
(agent?.subagent
? t('layout.session-panel-remote-subagent', {
defaultValue: 'Remote subagent',
})
: t('layout.session-panel-agent', { defaultValue: 'Agent' }));
const agentDescription =
agent?.description ||
(agent?.subagent
? t('layout.session-panel-subagent-description', {
defaultValue:
'A delegated agent used for a bounded part of this project.',
})
: t('layout.session-panel-agent-description', {
defaultValue:
'A general-purpose agent that plans and completes the project using the available tools.',
}));
return (
<Dialog open={agent != null} onOpenChange={onOpenChange}>
<DialogContent size="sm" overlayVariant="dimmed">
<DialogHeader
title={agentName}
subtitle={
agent?.subagent
? t('layout.session-panel-subagent', {
defaultValue: 'Subagent',
})
: t('layout.session-panel-agent', { defaultValue: 'Agent' })
}
/>
<DialogContentSection className="scrollbar-always-visible overflow-y-auto">
{agent ? (
<div className="flex min-w-0 flex-col gap-4">
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-ds-bg-neutral-muted-default">
<Bot
size={20}
className="text-ds-icon-neutral-default-default"
aria-hidden
/>
</span>
<p className="m-0 text-body-sm text-ds-text-neutral-default-default">
{agentDescription}
</p>
</div>
{agent.tools.length > 0 ? (
<div className="flex flex-col gap-2">
<span className="text-label-xs font-semibold uppercase tracking-wide text-ds-text-neutral-muted-default">
{t('layout.capabilities')}
</span>
<div className="flex flex-wrap gap-1.5">
{agent.tools.map((tool) => (
<span
key={tool}
className="rounded-md bg-ds-bg-neutral-muted-default px-2 py-1 text-label-xs text-ds-text-neutral-default-default"
>
{tool}
</span>
))}
</div>
</div>
) : null}
</div>
) : null}
</DialogContentSection>
</DialogContent>
</Dialog>
);
}
export function ToolCallsDialog({
item,
onOpenChange,
}: {
item: SessionContextItem | null;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
return (
<Dialog open={item != null} onOpenChange={onOpenChange}>
<DialogContent size="lg" overlayVariant="dimmed">
<DialogHeader
title={
item?.label ??
t('layout.session-panel-tool-calls', {
defaultValue: 'Tool calls',
})
}
subtitle={t('layout.session-panel-tool-history', {
defaultValue: 'Request and response history',
})}
/>
<DialogContentSection className="scrollbar min-h-0 overflow-y-auto">
{item?.calls.length ? (
<div className="flex min-w-0 flex-col gap-3">
{item.calls.map((call, index) => (
<div
key={call.id}
className="flex min-w-0 flex-col gap-2 rounded-xl border border-solid border-ds-border-neutral-subtle-disabled bg-ds-bg-neutral-default-default p-3"
>
<div className="flex min-w-0 items-center gap-2">
<span className="flex shrink-0 items-center">
{getToolkitIcon(call.toolkitName)}
</span>
<span className="min-w-0 truncate text-body-sm font-semibold text-ds-text-neutral-default-default">
{call.method ||
t('layout.session-panel-call', {
defaultValue: '{{name}} call {{number}}',
name: item.label,
number: index + 1,
})}
</span>
</div>
{call.input ? (
<div className="min-w-0 rounded-lg bg-ds-bg-neutral-muted-default p-3">
<div className="mb-1 text-label-xs font-medium uppercase tracking-wide text-ds-text-neutral-muted-default">
{t('layout.session-panel-request', {
defaultValue: 'Request',
})}
</div>
<MarkDown
content={call.input}
enableTypewriter={false}
pTextSize="text-label-xs text-ds-text-neutral-default-default"
/>
</div>
) : null}
{call.output ? (
<div className="min-w-0 rounded-lg bg-ds-bg-neutral-muted-default p-3">
<div className="mb-1 text-label-xs font-medium uppercase tracking-wide text-ds-text-neutral-muted-default">
{t('layout.session-panel-response', {
defaultValue: 'Response',
})}
</div>
<MarkDown
content={call.output}
enableTypewriter={false}
pTextSize="text-label-xs text-ds-text-neutral-default-default"
/>
</div>
) : null}
</div>
))}
</div>
) : (
<p className="m-0 text-body-sm text-ds-text-neutral-muted-default">
{t('layout.session-panel-no-call-details', {
defaultValue:
'No stored request or response details are available.',
})}
</p>
)}
</DialogContentSection>
</DialogContent>
</Dialog>
);
}

View file

@ -27,13 +27,26 @@ export interface ContextSkill {
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.
*/
function normalizeKey(name: string): string {
/** Normalize provider and toolkit names for SidePanel aggregation. */
export function normalizeContextKey(name: string): string {
return name
.trim()
.toLowerCase()
@ -41,6 +54,115 @@ function normalizeKey(name: string): string {
.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,
toolkitName: string,
method: string,
message: string
): number {
const toolkitKey = normalizeConnectorIdentity(toolkitName);
const methodKey = normalizeContextKey(method);
const messageKey = normalizeContextKey(message.slice(0, 2_000));
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 {
const ranked = connectors
.map((connector) => ({
connector,
score: connectorMatchScore(connector, toolkitName, method, message),
}))
.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;
}
function categoryFromCategoryName(
categoryName: string | undefined
): 'skill' | 'connector' {
@ -92,7 +214,7 @@ function isMcpToolkitName(name: string): boolean {
* 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[] {
export function extractLoadedSkillNames(message: string): string[] {
if (!message) return [];
// Args (if present) sit on the first line — the deactivate result is
@ -195,7 +317,7 @@ function collectHints(agents: Agent[], skills: ContextSkill[]) {
const connectorHints = new Set<string>();
const add = (set: Set<string>, raw: string) => {
const k = normalizeKey(raw);
const k = normalizeContextKey(raw);
if (k) set.add(k);
};
@ -265,13 +387,14 @@ export function buildContextItems(
agents: Agent[],
taskRunning?: TaskInfo[],
uploadedFiles: File[] = [],
skills: ContextSkill[] = []
skills: ContextSkill[] = [],
connectors: ContextConnector[] = []
): ContextItem[] {
const seen = new Set<string>();
const out: ContextItem[] = [];
const push = (item: ContextItem) => {
const key = `${item.category}:${normalizeKey(item.id)}`;
const key = `${item.category}:${normalizeContextKey(item.id)}`;
if (seen.has(key)) return;
seen.add(key);
out.push(item);
@ -308,7 +431,7 @@ export function buildContextItems(
return;
}
const norm = normalizeKey(toolkitName);
const norm = normalizeContextKey(toolkitName);
let category: ContextItem['category'] | null = null;
if (skillHints.has(norm) || isSkillToolkitName(toolkitName)) {
category = 'skill';
@ -317,10 +440,16 @@ export function buildContextItems(
}
if (!category) return;
const connector =
category === 'connector'
? resolveContextConnector(toolkitName, method, message, connectors)
: null;
push({
id: toolkitName,
label: toolkitName,
id: connector?.service || toolkitName,
label: connector?.displayName || connector?.service || toolkitName,
category,
iconUrl: connector?.iconUrl || undefined,
icon:
category === 'skill'
? createElement(WandSparkles, { size: 16 })

View file

@ -0,0 +1,733 @@
// ========= 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 { httpUrlOrNull } from '@/lib/richText';
import { AgentStep, TaskStatus } from '@/types/constants';
import {
buildContextItems,
extractLoadedSkillNames,
normalizeContextKey,
resolveContextConnector,
type ContextConnector,
type ContextSkill,
} from './buildContextItems';
import {
collectSidePanelOutputFiles,
mergeSidePanelOutputFiles,
} from './collectSidePanelOutputFiles';
import type { ContextCategory, ContextItem } from './ExecutionContextSection';
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;
sourceAgent?: Agent;
}
export interface SessionToolCall {
id: string;
toolkitName: string;
method: string;
input: string;
output: string;
status: 'running' | 'done';
taskId: string;
agentName: string;
createdAt: 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;
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 unified SidePanel. */
export interface ProjectSessionPanelData {
agents: SessionAgentItem[];
contextItems: SessionContextItem[];
environments: SessionEnvironmentItem[];
files: SessionFileItem[];
progress: SessionProgressItem[];
resources: SessionResourceItem[];
toolCalls: SessionToolCall[];
}
function normalizeMessage(value: unknown): string {
if (typeof value === 'string') return value;
if (value == null) return '';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function eventTime(event: AgentMessage, fallback: number, sequence: number) {
if (typeof event.timestamp === 'number' && Number.isFinite(event.timestamp)) {
return event.timestamp < 1_000_000_000_000
? event.timestamp * 1000
: event.timestamp;
}
if (event.created_at) {
const parsed = Date.parse(event.created_at);
if (Number.isFinite(parsed)) return parsed;
}
return fallback + sequence;
}
export function collectSessionToolCalls(
runs: ProjectSessionRun[]
): SessionToolCall[] {
const calls: SessionToolCall[] = [];
let sequence = 0;
for (const run of [...runs].reverse()) {
for (const agent of run.task.taskAssigning ?? []) {
const openByPair = new Map<string, SessionToolCall[]>();
for (const event of agent.log ?? []) {
if (
event.step !== AgentStep.ACTIVATE_TOOLKIT &&
event.step !== AgentStep.DEACTIVATE_TOOLKIT
) {
continue;
}
const toolkitName = String(event.data?.toolkit_name ?? '').trim();
if (!toolkitName || toolkitName.toLowerCase() === 'notice') continue;
const method = String(event.data?.method_name ?? '').trim();
const message = normalizeMessage(event.data?.message).trim();
const pair = `${normalizeContextKey(toolkitName)}:${method
.toLowerCase()
.replace(/_/g, ' ')}`;
if (event.step === AgentStep.ACTIVATE_TOOLKIT) {
const call: SessionToolCall = {
id: `${run.taskId}:${agent.agent_id}:${sequence}`,
toolkitName,
method,
input: message,
output: '',
status: 'running',
taskId: run.taskId,
agentName: agent.name,
createdAt: eventTime(event, run.createdAt, sequence),
skillNames:
normalizeContextKey(toolkitName) === 'skill'
? extractLoadedSkillNames(message)
: [],
};
sequence += 1;
calls.push(call);
const pending = openByPair.get(pair) ?? [];
pending.push(call);
openByPair.set(pair, pending);
continue;
}
const pending = openByPair.get(pair) ?? [];
const call = [...pending]
.reverse()
.find((item) => item.status === 'running');
if (call) {
call.output = [call.output, message].filter(Boolean).join('\n\n');
call.status = 'done';
continue;
}
calls.push({
id: `${run.taskId}:${agent.agent_id}:${sequence}`,
toolkitName,
method,
input: '',
output: message,
status: 'done',
taskId: run.taskId,
agentName: agent.name,
createdAt: eventTime(event, run.createdAt, sequence),
skillNames: [],
});
sequence += 1;
}
}
}
return calls.sort((a, b) => a.createdAt - b.createdAt);
}
function mergeSubtasks(run: ProjectSessionRun): TaskInfo[] {
const { task } = run;
const singleAgent = task.taskAssigning.find(
(agent) => agent.type === 'single_agent'
);
const base =
singleAgent?.tasks?.length && singleAgent.tasks.length > 0
? singleAgent.tasks
: task.taskInfo;
const liveById = new Map(
task.taskRunning.map((item) => [item.id, item] as const)
);
const merged = base.map((item) => {
const live = liveById.get(item.id);
return live
? { ...item, ...live, content: item.content || live.content }
: item;
});
for (const live of task.taskRunning) {
if (!merged.some((item) => item.id === live.id)) merged.push(live);
}
return merged.filter((item) => item.content.trim() !== '');
}
function selectedToolNames(agent: Agent): string[] {
const names = new Set(agent.tools ?? []);
const selected = agent.workerInfo?.selectedTools;
if (Array.isArray(selected)) {
for (const value of selected) {
if (typeof value === 'string') {
names.add(value);
} else if (value && typeof value === 'object') {
const item = value as { name?: string; key?: string; toolkit?: string };
const name = item.name ?? item.key ?? item.toolkit;
if (name) names.add(name);
}
}
}
return [...names];
}
function extractJsonString(input: string, key: string): string | null {
try {
const parsed = JSON.parse(input);
const value = parsed?.[key];
return typeof value === 'string' && value.trim() ? value.trim() : null;
} catch {
const match = input.match(
new RegExp(`${key}\\s*=\\s*["']([^"']+)["']`, 'i')
);
return match?.[1]?.trim() || null;
}
}
function collectAgents(
runs: ProjectSessionRun[],
calls: SessionToolCall[]
): SessionAgentItem[] {
const items = new Map<string, SessionAgentItem>();
for (const run of runs) {
for (const agent of run.task.taskAssigning ?? []) {
const displayName = agent.workerInfo?.name || agent.name;
const key = `${agent.type}:${displayName.trim().toLowerCase()}`;
const existing = items.get(key);
if (existing) {
existing.createdAt = Math.max(existing.createdAt, run.createdAt);
existing.updatedAt = Math.max(existing.updatedAt, run.updatedAt);
if (!run.isCurrent || !existing.historical) continue;
}
items.set(key, {
id: key,
name: displayName,
type: agent.type,
description: agent.workerInfo?.description || '',
tools: selectedToolNames(agent),
historical: !run.isCurrent,
createdAt: Math.max(existing?.createdAt ?? 0, run.createdAt),
updatedAt: Math.max(existing?.updatedAt ?? 0, run.updatedAt),
subagent: false,
sourceAgent: agent,
});
}
}
for (const call of calls) {
const normalized = `${call.toolkitName} ${call.method}`
.toLowerCase()
.replace(/[_-]/g, ' ');
if (!normalized.includes('sub agent') && !normalized.includes('subagent')) {
continue;
}
const owningRun = runs.find((run) => run.taskId === call.taskId);
const remoteName = extractJsonString(call.input, 'remote_agent_name') || '';
const key = `subagent:${remoteName.toLowerCase() || 'remote-subagent'}`;
const existing = items.get(key);
const createdAt = Math.max(
existing?.createdAt ?? 0,
owningRun?.createdAt ?? call.createdAt
);
const updatedAt = Math.max(
existing?.updatedAt ?? 0,
call.createdAt,
owningRun?.updatedAt ?? 0
);
if (existing && !existing.historical) {
existing.createdAt = createdAt;
existing.updatedAt = updatedAt;
continue;
}
items.set(key, {
id: key,
name: remoteName,
type: 'subagent',
description: extractJsonString(call.input, 'instruction') || '',
tools: [],
historical: !owningRun?.isCurrent,
createdAt,
updatedAt,
subagent: true,
});
}
return [...items.values()].sort(
(a, b) =>
Number(a.historical) - Number(b.historical) ||
Number(a.subagent) - Number(b.subagent)
);
}
function uploadedFiles(run: ProjectSessionRun): File[] {
const all = [
...run.task.messages
.filter((message) => message.role === 'user')
.flatMap((message) => message.attaches ?? []),
...(run.task.attaches ?? []),
];
const seen = new Set<string>();
return all.filter((file) => {
if (!file.filePath || seen.has(file.filePath)) return false;
seen.add(file.filePath);
return true;
});
}
function callMatchesContext(
call: SessionToolCall,
item: ContextItem,
connectors: ContextConnector[]
): boolean {
if (item.category === 'skill') {
const itemKey = normalizeContextKey(item.label);
return call.skillNames.some(
(name) => normalizeContextKey(name) === itemKey
);
}
if (item.category === 'connector') {
const connector = resolveContextConnector(
call.toolkitName,
call.method,
call.input,
connectors
);
if (connector) {
return (
normalizeContextKey(connector.service) ===
normalizeContextKey(item.id) ||
normalizeContextKey(connector.displayName || connector.service) ===
normalizeContextKey(item.label)
);
}
return (
normalizeContextKey(call.toolkitName) ===
normalizeContextKey(item.label || item.id)
);
}
return false;
}
function collectContext(
runs: ProjectSessionRun[],
calls: SessionToolCall[],
skills: ContextSkill[],
connectors: ContextConnector[]
): SessionContextItem[] {
const items = new Map<string, SessionContextItem>();
for (const run of runs) {
const runItems = buildContextItems(
run.task.taskAssigning,
run.task.taskRunning,
uploadedFiles(run),
skills,
connectors
).filter(
(
item
): item is ContextItem & { category: Exclude<ContextCategory, 'file'> } =>
item.category !== 'file'
);
for (const item of runItems) {
const key = `${item.category}:${normalizeContextKey(item.label)}`;
const itemCalls = calls.filter((call) =>
callMatchesContext(call, item, connectors)
);
const existing = items.get(key);
if (existing) {
existing.calls = Array.from(
new Map(
[...existing.calls, ...itemCalls].map((call) => [call.id, call])
).values()
);
if (run.isCurrent) existing.historical = false;
existing.createdAt = Math.max(existing.createdAt, run.createdAt);
existing.updatedAt = Math.max(existing.updatedAt, run.updatedAt);
continue;
}
items.set(key, {
...item,
historical: !run.isCurrent,
createdAt: run.createdAt,
updatedAt: Math.max(
run.updatedAt,
...itemCalls.map((call) => call.createdAt)
),
calls: itemCalls,
});
}
}
return [...items.values()].sort(
(a, b) => Number(a.historical) - Number(b.historical)
);
}
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 collectResources(
runs: ProjectSessionRun[],
calls: SessionToolCall[]
): SessionResourceItem[] {
const resources = new Map<string, SessionResourceItem>();
const outputPaths = new Set(
runs.flatMap((run) =>
collectSidePanelOutputFiles(run.task).flatMap((file) => [
file.path,
file.relativePath ?? '',
])
)
);
const put = (item: SessionResourceItem) => {
const existing = resources.get(item.id);
if (existing) {
item.createdAt = Math.max(item.createdAt, existing.createdAt);
item.updatedAt = Math.max(item.updatedAt, existing.updatedAt);
}
if (!existing || (existing.historical && !item.historical)) {
resources.set(item.id, item);
} else if (existing) {
existing.updatedAt = item.updatedAt;
}
};
for (const run of runs) {
for (const entry of run.task.webViewUrls ?? []) {
const urls = extractHttpUrls(entry.url);
for (const url of urls) {
put({
id: `url:${url}`,
label: resourceLabel(url),
kind: 'url',
url,
taskId: run.taskId,
historical: !run.isCurrent,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
});
}
}
for (const file of uploadedFiles(run)) {
if (outputPaths.has(file.filePath)) continue;
const asUrl = httpUrlOrNull(file.filePath);
if (asUrl) {
put({
id: `url:${asUrl}`,
label: file.fileName || resourceLabel(asUrl),
kind: 'url',
url: asUrl,
taskId: run.taskId,
historical: !run.isCurrent,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
});
} else {
put({
id: `file:${file.filePath}`,
label:
file.fileName || file.filePath.split('/').pop() || file.filePath,
kind: 'file',
file: {
name: file.fileName,
path: file.filePath,
type: file.fileName.split('.').pop() || '',
},
taskId: run.taskId,
historical: !run.isCurrent,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
});
}
}
}
for (const call of calls) {
const run = runs.find((candidate) => candidate.taskId === call.taskId);
for (const url of extractHttpUrls(`${call.input}\n${call.output}`)) {
put({
id: `url:${url}`,
label: resourceLabel(url),
kind: 'url',
url,
taskId: call.taskId,
historical: !run?.isCurrent,
createdAt: run?.createdAt ?? call.createdAt,
updatedAt: Math.max(call.createdAt, run?.updatedAt ?? 0),
});
}
}
return [...resources.values()].sort(
(a, b) => Number(a.historical) - Number(b.historical)
);
}
function collectFiles(runs: ProjectSessionRun[]): SessionFileItem[] {
const files = new Map<string, SessionFileItem>();
for (const run of runs) {
for (const file of collectSidePanelOutputFiles(run.task)) {
const key = file.relativePath || file.path || file.name;
if (!key) continue;
const existing = files.get(key);
if (existing) {
existing.createdAt = Math.max(existing.createdAt, run.createdAt);
existing.updatedAt = Math.max(existing.updatedAt, run.updatedAt);
}
if (!existing || (existing.historical && run.isCurrent)) {
files.set(key, {
id: key,
file,
taskId: run.taskId,
historical: !run.isCurrent,
createdAt: Math.max(existing?.createdAt ?? 0, run.createdAt),
updatedAt: Math.max(existing?.updatedAt ?? 0, run.updatedAt),
});
}
}
}
return [...files.values()].sort(
(a, b) => Number(a.historical) - Number(b.historical)
);
}
function collectEnvironments(
runs: ProjectSessionRun[],
calls: SessionToolCall[]
): SessionEnvironmentItem[] {
const environments = new Map<string, SessionEnvironmentItem>();
const put = (
label: string,
taskId: string,
historical: boolean,
createdAt: number,
updatedAt: number
) => {
const id = label.toLowerCase();
const existing = environments.get(id);
const latestCreation = Math.max(existing?.createdAt ?? 0, createdAt);
const latestUpdate = Math.max(existing?.updatedAt ?? 0, updatedAt);
if (!existing || (existing.historical && !historical)) {
environments.set(id, {
id,
label,
taskId,
historical,
createdAt: latestCreation,
updatedAt: latestUpdate,
});
} else {
existing.createdAt = latestCreation;
existing.updatedAt = latestUpdate;
}
};
for (const run of runs) {
if (run.task.webViewUrls.length > 0) {
put('Browser', run.taskId, !run.isCurrent, run.createdAt, run.updatedAt);
}
const hasTerminal = run.task.taskAssigning.some((agent) =>
agent.tasks.some((task) => (task.terminal?.length ?? 0) > 0)
);
if (hasTerminal) {
put('Terminal', run.taskId, !run.isCurrent, run.createdAt, run.updatedAt);
}
}
for (const call of calls) {
const run = runs.find((candidate) => candidate.taskId === call.taskId);
const normalized = `${call.toolkitName} ${call.method}`.toLowerCase();
if (/browser|search|scrape/.test(normalized)) {
put(
'Browser',
call.taskId,
!run?.isCurrent,
run?.createdAt ?? call.createdAt,
Math.max(call.createdAt, run?.updatedAt ?? 0)
);
}
if (/terminal|shell|code execution/.test(normalized)) {
put(
'Terminal',
call.taskId,
!run?.isCurrent,
run?.createdAt ?? call.createdAt,
Math.max(call.createdAt, run?.updatedAt ?? 0)
);
}
if (/sub.?agent|remote/.test(normalized)) {
put(
'Remote environment',
call.taskId,
!run?.isCurrent,
run?.createdAt ?? call.createdAt,
Math.max(call.createdAt, run?.updatedAt ?? 0)
);
}
}
return [...environments.values()].sort(
(a, b) => Number(a.historical) - Number(b.historical)
);
}
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, toolCalls),
files: collectFiles(runs),
progress: runs.flatMap((run) =>
mergeSubtasks(run).map((task) => ({
key: `${run.taskId}:${task.id}`,
task,
taskId: run.taskId,
historical: !run.isCurrent,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
}))
),
resources: collectResources(runs, toolCalls),
toolCalls,
};
}
export function mergeProjectFiles(
items: SessionFileItem[],
projectFiles: FileInfo[],
fallbackTaskId: string,
fallbackCreatedAt = 0,
fallbackUpdatedAt = 0
): SessionFileItem[] {
const knownFiles = items.map((item) => item.file);
const merged = mergeSidePanelOutputFiles(knownFiles, projectFiles);
return merged.map((file) => {
const id = file.relativePath || file.path || file.name;
return (
items.find((item) => item.id === id) ?? {
id,
file,
taskId: fallbackTaskId,
historical: false,
createdAt: fallbackCreatedAt,
updatedAt: fallbackUpdatedAt,
}
);
});
}
export function isProgressDone(task: TaskInfo): boolean {
return (
task.status === TaskStatus.COMPLETED || task.status === TaskStatus.FAILED
);
}

View file

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

View file

@ -0,0 +1,385 @@
// ========= 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, 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();
return (
<motion.div
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>
);
}
/**
* Small muted category label for grouping list items.
*/
export function CategoryLabel({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<span
className={cn(
'block px-2 pb-1 pt-2 !text-body-sm text-ds-text-neutral-muted-default first:pt-0',
className
)}
>
{children}
</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>
);
}
/**
* Thin connector line between two progress circles in the folded strip view.
*/
export function ProgressConnector() {
return (
<span
className="h-px min-w-[6px] flex-1 bg-ds-border-neutral-default-default"
aria-hidden
/>
);
}

View file

@ -0,0 +1,52 @@
// ========= 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
): { primary: T[]; earlier: T[] } {
const sorted = [...items].sort(
(a, b) => b.createdAt - a.createdAt || b.updatedAt - a.updatedAt
);
const primary = sorted.filter((item) => !item.historical);
return scope === 'latest'
? { primary, earlier: [] }
: { primary, earlier: sorted.filter((item) => item.historical) };
}

View file

@ -55,6 +55,7 @@ function sameFileList(left: FileInfo[], right: FileInfo[]): boolean {
});
}
/** Loads generated output files for the SidePanel Files section. */
export function useProjectOutputFiles(
projectId: string | null | undefined,
activeTask: SidePanelTask | undefined,

View file

@ -1,134 +0,0 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { cn } from '@/lib/utils';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { ChevronDown } from 'lucide-react';
import { type ReactNode, useState } from 'react';
const CONTENT_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
const REVEAL_EASE: [number, number, number, number] = [0.23, 1, 0.32, 1];
const LAYOUT_TRANSITION = {
layout: { duration: 0.28, ease: CONTENT_EASE },
} as const;
export type SidePanelAccordionRenderArgs = { open: boolean };
export type SidePanelAccordionChildren =
| ReactNode
| ((state: SidePanelAccordionRenderArgs) => ReactNode);
export function SidePanelAccordionBox({
title,
titleSuffix,
collapsedPreview,
children,
defaultOpen = true,
}: {
title: string;
/** Small adornment rendered right after the title (e.g. count pill). */
titleSuffix?: ReactNode;
/**
* Compact content below the header when collapsed (static `children` only;
* render-prop children control their own open/closed layout).
*/
collapsedPreview?: ReactNode;
/**
* Static: classic accordion body hidden when closed.
* Render prop: body stays in one region; switch layout by `open` (e.g. summary vs full list).
*/
children: SidePanelAccordionChildren;
defaultOpen?: boolean;
}) {
const shouldReduceMotion = useReducedMotion();
const [open, setOpen] = useState(defaultOpen);
const isRenderProp = typeof children === 'function';
const dynamicBody = isRenderProp
? (children as (s: SidePanelAccordionRenderArgs) => ReactNode)({ open })
: null;
return (
<div className="z-10 flex min-w-0 shrink-0 flex-col overflow-hidden rounded-xl border border-solid border-ds-border-neutral-subtle-disabled bg-ds-bg-neutral-default-default">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full shrink-0 items-center justify-between gap-2 px-3 py-2.5 text-left transition-colors hover:bg-ds-bg-neutral-default-hover"
aria-expanded={open}
>
<div className="flex min-w-0 items-center gap-2">
<span className="text-body-sm font-semibold text-ds-text-neutral-default-default">
{title}
</span>
{titleSuffix ? (
<span className="flex shrink-0 items-center">{titleSuffix}</span>
) : null}
</div>
<ChevronDown
className={cn(
'h-4 w-4 shrink-0 text-ds-text-neutral-muted-default transition-transform duration-200 ease-out motion-reduce:transition-none',
open ? 'rotate-0' : '-rotate-90'
)}
aria-hidden
/>
</button>
{!open && collapsedPreview && !isRenderProp ? (
<div className="w-full px-2 pb-3">{collapsedPreview}</div>
) : null}
{isRenderProp ? (
<motion.div
layout={!shouldReduceMotion}
transition={
shouldReduceMotion ? { layout: { duration: 0 } } : LAYOUT_TRANSITION
}
className="min-h-0 w-full overflow-hidden"
>
{dynamicBody != null ? (
<div className="w-full px-2 pb-3">{dynamicBody}</div>
) : null}
</motion.div>
) : (
<AnimatePresence initial={false}>
{open ? (
<motion.div
key="static-content"
initial={{
opacity: 0,
transform: shouldReduceMotion
? 'translateY(0px)'
: 'translateY(-8px)',
}}
animate={{ opacity: 1, transform: 'translateY(0px)' }}
exit={{
opacity: 0,
transform: shouldReduceMotion
? 'translateY(0px)'
: 'translateY(-4px)',
transition: {
duration: shouldReduceMotion ? 0.14 : 0.125,
ease: REVEAL_EASE,
},
}}
transition={{ duration: 0.16, ease: REVEAL_EASE }}
className="overflow-hidden"
>
<div className="w-full px-2 pb-3">{children as ReactNode}</div>
</motion.div>
) : null}
</AnimatePresence>
)}
</div>
);
}

View file

@ -1,179 +0,0 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { cn } from '@/lib/utils';
import { Check } from 'lucide-react';
import { type ReactNode, forwardRef } from 'react';
/**
* Small round count/label pill used next to accordion titles.
*/
export function CountPill({ count }: { count: number }) {
return (
<span className="bg-ds-bg-neutral-subtle-default text-ds-text-neutral-subtle-default text-label-xs font-bold px-1.5 inline-flex items-center justify-center rounded-full">
{count}
</span>
);
}
/**
* Small muted category label for grouping list items.
*/
export function CategoryLabel({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'text-ds-text-neutral-muted-default text-body-xs px-1 pb-1 pt-2 first:pt-0',
className
)}
>
{children}
</div>
);
}
type SidePanelListRowProps = {
leading?: ReactNode;
children: ReactNode;
trailing?: ReactNode;
disabled?: boolean;
onClick?: () => void;
/**
* Pointer + subtle hover/active backgrounds without an action (e.g. read-only list rows).
* When `onClick` is set, focus ring is included; for hover-only rows it is omitted.
*/
interactiveHover?: boolean;
className?: string;
};
/**
* Row primitive used across Agent Pool / Execution Context / Agent Folder sections.
* Rendered as a button when `onClick` is provided, otherwise a div.
*/
export const SidePanelListRow = forwardRef<HTMLElement, SidePanelListRowProps>(
(
{
leading,
children,
trailing,
disabled,
onClick,
interactiveHover,
className,
},
ref
) => {
const showAffordance = Boolean(onClick || interactiveHover);
const base = cn(
'group gap-2 px-1.5 py-1.5 rounded-md min-w-0 w-full flex items-center',
'text-ds-text-neutral-default-default text-body-sm text-left',
'transition-colors',
disabled
? 'opacity-50 pointer-events-none'
: showAffordance
? cn(
'cursor-pointer hover:bg-ds-bg-neutral-subtle-default active:bg-ds-bg-neutral-subtle-hover',
onClick &&
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus/40'
)
: '',
className
);
const content = (
<>
{leading ? (
<span className="flex shrink-0 items-center">{leading}</span>
) : null}
<span className="min-w-0 flex-1 truncate">{children}</span>
{trailing ? (
<span className="flex shrink-0 items-center">{trailing}</span>
) : null}
</>
);
if (onClick) {
return (
<button
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
onClick={onClick}
disabled={disabled}
className={base}
>
{content}
</button>
);
}
return (
<div ref={ref as React.Ref<HTMLDivElement>} className={base}>
{content}
</div>
);
}
);
SidePanelListRow.displayName = 'SidePanelListRow';
/**
* Progress circle. Incomplete: neutral subtle fill so the ring reads on any
* panel background. Complete: filled success (matches primary success button)
* with inverse check mark.
*/
export function ProgressCircle({
done,
size = 14,
}: {
done: boolean;
size?: number;
}) {
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-full border-[0.5px] border-solid',
done
? 'border-ds-bg-success-default-default bg-ds-bg-success-default-default text-ds-text-success-inverse-default'
: 'border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default'
)}
style={{ width: size, height: size }}
aria-hidden
>
{done ? (
<Check
className="!text-ds-text-success-inverse-default"
size={Math.max(8, size - 6)}
strokeWidth={4}
/>
) : null}
</span>
);
}
/**
* Thin connector line between two progress circles in the folded strip view.
*/
export function ProgressConnector() {
return (
<span
className="bg-ds-border-neutral-default-default h-px min-w-[6px] flex-1"
aria-hidden
/>
);
}

View file

@ -1,141 +0,0 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { AgentFolderSection } from '@/components/Session/SidePanelSections/AgentFolderSection';
import { ExecutionContextSection } from '@/components/Session/SidePanelSections/ExecutionContextSection';
import { ProgressSection } from '@/components/Session/SidePanelSections/ProgressSection';
import { buildContextItems } from '@/components/Session/SidePanelSections/buildContextItems';
import {
collectSidePanelOutputFiles,
mergeSidePanelOutputFiles,
} from '@/components/Session/SidePanelSections/collectSidePanelOutputFiles';
import { useProjectOutputFiles } from '@/components/Session/SidePanelSections/useProjectOutputFiles';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
import { cn } from '@/lib/utils';
import { usePageTabStore } from '@/store/pageTabStore';
import { useSkillsStore } from '@/store/skillsStore';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export function SingleAgentSidePanel() {
const { t } = useTranslation();
const { chatStore, projectStore } = useChatStoreAdapter();
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
const selectedTurn = useSelectedProjectTurn(projectStore.activeProjectId);
const selectedTask = selectedTurn.task;
const selectedTaskId = selectedTurn.taskId;
// The visible turn follows chat scrolling, but the filesystem scan is
// project-level. Drive it from the active Run so scrolling across history
// never turns into a series of identical /files requests.
const activeProjectTaskId = chatStore?.activeTaskId ?? null;
const activeProjectTask = activeProjectTaskId
? chatStore?.tasks[activeProjectTaskId]
: undefined;
const agents = useMemo(
() => selectedTask?.taskAssigning ?? [],
[selectedTask?.taskAssigning]
);
const projectFiles = useProjectOutputFiles(
projectStore.activeProjectId,
activeProjectTask,
activeProjectTaskId
);
/** Prefer live `taskRunning` status (updated on TASK_STATE), keep plan order/text from agent tasks or taskInfo. */
const subtasks = useMemo(() => {
const base = agents[0]?.tasks ?? selectedTask?.taskInfo ?? [];
const taskRunning = selectedTask?.taskRunning ?? [];
if (taskRunning.length === 0) return base;
return base.map((t) => {
const live = taskRunning.find((r) => r.id === t.id);
if (!live) return t;
return { ...t, ...live, content: t.content || live.content };
});
}, [agents, selectedTask?.taskInfo, selectedTask?.taskRunning]);
const files = useMemo(
() =>
mergeSidePanelOutputFiles(
collectSidePanelOutputFiles(selectedTask),
projectFiles
),
[selectedTask, projectFiles]
);
const uploadedFiles = useMemo(() => {
if (!selectedTask) return [];
const all = [
...(selectedTask.messages ?? [])
.filter((m) => m.role === 'user')
.flatMap((m) => m.attaches ?? []),
...(selectedTask.attaches ?? []),
];
const seen = new Set<string>();
return all.filter((file) => {
const key = file.filePath;
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}, [selectedTask]);
const skills = useSkillsStore((s) => s.skills);
const contextItems = useMemo(
() =>
buildContextItems(
agents,
selectedTask?.taskRunning,
uploadedFiles,
skills
),
[agents, selectedTask?.taskRunning, uploadedFiles, skills]
);
const handleOpenAgentFile = useCallback(
(file: FileInfo) => {
openFilePreview(file);
},
[openFilePreview]
);
return (
<div
className={cn(
'flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden',
'relative'
)}
>
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden px-2 pb-2">
<ProgressSection
title={t('layout.workforce-progress', { defaultValue: 'Progress' })}
subtasks={subtasks}
projectId={projectStore.activeProjectId}
taskId={selectedTaskId}
/>
<ExecutionContextSection
title={t('layout.execution-context', {
defaultValue: 'Execution Context',
})}
items={contextItems}
/>
<AgentFolderSection
title={t('layout.workforce-agent-folder', {
defaultValue: 'Agent Folder',
})}
files={files}
onOpenFile={handleOpenAgentFile}
/>
</div>
</div>
);
}

View file

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

View file

@ -1,183 +0,0 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { AgentFolderSection } from '@/components/Session/SidePanelSections/AgentFolderSection';
import { AgentPoolSection } from '@/components/Session/SidePanelSections/AgentPoolSection';
import { buildContextItems } from '@/components/Session/SidePanelSections/buildContextItems';
import {
collectSidePanelOutputFiles,
mergeSidePanelOutputFiles,
} from '@/components/Session/SidePanelSections/collectSidePanelOutputFiles';
import { ExecutionContextSection } from '@/components/Session/SidePanelSections/ExecutionContextSection';
import { ProgressSection } from '@/components/Session/SidePanelSections/ProgressSection';
import { useProjectOutputFiles } from '@/components/Session/SidePanelSections/useProjectOutputFiles';
import ExpandedOverlay from '@/components/Session/Workforce/ExpandedOverlay';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
import { cn } from '@/lib/utils';
import { usePageTabStore } from '@/store/pageTabStore';
import { useSkillsStore } from '@/store/skillsStore';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
/** Main column under `SessionSidePanel` header: fills remaining height in flex parent */
export const WORKFORCE_MAIN_SURFACE_CLASS =
'min-w-0 flex w-full min-h-0 flex-1 flex-col overflow-hidden';
export interface WorkforceSidePanelProps {
workforcePanelKey: string;
hasAnyMessages: boolean;
isSidePanelVisible: boolean;
onToggleSidePanel: () => void;
/** Controlled: whether the full-screen workforce overlay is open. */
isExpandedOverlayOpen: boolean;
onToggleExpandedOverlay: () => void;
onCloseExpandedOverlay: () => void;
}
export function WorkforceSidePanel({
workforcePanelKey,
hasAnyMessages: _hasAnyMessages,
isSidePanelVisible,
onToggleSidePanel,
isExpandedOverlayOpen,
onToggleExpandedOverlay: _onToggleExpandedOverlay,
onCloseExpandedOverlay,
}: WorkforceSidePanelProps) {
const { t } = useTranslation();
const { chatStore, projectStore } = useChatStoreAdapter();
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
const selectedTurn = useSelectedProjectTurn(projectStore.activeProjectId);
const selectedTask = selectedTurn.task;
const selectedTaskId = selectedTurn.taskId;
// Viewport scrolling selects a historical turn for display only. Project
// file discovery follows the active Run, otherwise every turn boundary
// crossed while scrolling causes the same project directory to be scanned.
const activeProjectTaskId = chatStore?.activeTaskId ?? null;
const activeProjectTask = activeProjectTaskId
? chatStore?.tasks[activeProjectTaskId]
: undefined;
const agents = useMemo(
() => selectedTask?.taskAssigning ?? [],
[selectedTask?.taskAssigning]
);
const projectFiles = useProjectOutputFiles(
projectStore.activeProjectId,
activeProjectTask,
activeProjectTaskId
);
/** Subtask status is updated in `taskRunning` (e.g. TASK_STATE); `taskInfo` keeps plan text/order. */
const subtasks = useMemo(() => {
const taskInfo = selectedTask?.taskInfo ?? [];
const taskRunning = selectedTask?.taskRunning ?? [];
if (taskRunning.length === 0) return taskInfo;
const runById = new Map(
taskRunning.map((r) => [r.id, r] as [string, TaskInfo])
);
return taskInfo.map((t) => {
const live = runById.get(t.id);
if (!live) return t;
return { ...t, ...live, content: t.content || live.content };
});
}, [selectedTask?.taskInfo, selectedTask?.taskRunning]);
const files = useMemo(
() =>
mergeSidePanelOutputFiles(
collectSidePanelOutputFiles(selectedTask),
projectFiles
),
[selectedTask, projectFiles]
);
const uploadedFiles = useMemo(() => {
if (!selectedTask) return [];
const all = [
...(selectedTask.messages ?? [])
.filter((m) => m.role === 'user')
.flatMap((m) => m.attaches ?? []),
...(selectedTask.attaches ?? []),
];
const seen = new Set<string>();
return all.filter((file) => {
const key = file.filePath;
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}, [selectedTask]);
const skills = useSkillsStore((s) => s.skills);
const contextItems = useMemo(
() =>
buildContextItems(
agents,
selectedTask?.taskRunning,
uploadedFiles,
skills
),
[agents, selectedTask?.taskRunning, uploadedFiles, skills]
);
const handleOpenAgentFile = useCallback(
(file: FileInfo) => {
openFilePreview(file);
},
[openFilePreview]
);
return (
<>
<div className={cn(WORKFORCE_MAIN_SURFACE_CLASS, 'relative')}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden px-2 pb-2">
<AgentPoolSection
title={t('layout.workforce-agent-pool', {
defaultValue: 'Agent Pool',
})}
agents={agents}
/>
<ProgressSection
title={t('layout.workforce-progress', {
defaultValue: 'Progress',
})}
subtasks={subtasks}
projectId={projectStore.activeProjectId}
taskId={selectedTaskId}
/>
<ExecutionContextSection
title={t('layout.execution-context', {
defaultValue: 'Execution Context',
})}
items={contextItems}
/>
<AgentFolderSection
title={t('layout.workforce-agent-folder', {
defaultValue: 'Agent Folder',
})}
files={files}
onOpenFile={handleOpenAgentFile}
/>
</div>
</div>
<ExpandedOverlay
open={isExpandedOverlayOpen}
onClose={onCloseExpandedOverlay}
workforcePanelKey={workforcePanelKey}
onToggleSidePanel={onToggleSidePanel}
isSidePanelVisible={isSidePanelVisible}
selectedTurn={selectedTurn}
/>
</>
);
}

View file

@ -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;
@ -356,7 +356,6 @@ export default function Session({ isNewProject = false }: SessionProps) {
key={displaySessionMode}
mode={displaySessionMode}
workforcePanelKey={workforcePanelKey}
hasAnyMessages={hasAnyMessages}
isSidePanelVisible={isSidePanelVisible}
onToggleSidePanel={toggleSidePanel}
isExpandedOverlayOpen={isExpandedOverlayOpen}

View file

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

View file

@ -0,0 +1,133 @@
// ========= 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 { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
import { useEffect, useMemo, useReducer } from 'react';
export interface ProjectSessionRun {
chatId: string;
chatStore: VanillaChatStore;
task: ChatStore['tasks'][string];
taskId: string;
createdAt: number;
updatedAt: number;
isCurrent: boolean;
}
export interface ProjectSessionOverview {
currentRun: ProjectSessionRun | null;
historicalRuns: ProjectSessionRun[];
runs: ProjectSessionRun[];
}
function persistedEventTime(event: AgentMessage): number | null {
if (typeof event.timestamp === 'number' && Number.isFinite(event.timestamp)) {
return event.timestamp < 1_000_000_000_000
? event.timestamp * 1000
: event.timestamp;
}
if (event.created_at) {
const parsed = Date.parse(event.created_at);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function taskUpdatedAt(task: ChatStore['tasks'][string]): number {
const createdAt = task.createdAt || 0;
let updatedAt = createdAt;
if (task.taskTime > 0) updatedAt = Math.max(updatedAt, task.taskTime);
if (createdAt > 0 && task.elapsed > 0) {
updatedAt = Math.max(updatedAt, createdAt + task.elapsed);
}
for (const agent of task.taskAssigning ?? []) {
for (const event of agent.log ?? []) {
updatedAt = Math.max(updatedAt, persistedEventTime(event) ?? 0);
}
}
return updatedAt;
}
/**
* Project-level view of every run. Unlike `useSelectedProjectTurn`, this hook
* is deliberately independent from chat scroll position and TurnTabs.
*/
export function useProjectSessionOverview(
projectId: string | null | undefined
): ProjectSessionOverview {
const projectStore = useProjectRuntimeStore();
const [, refresh] = useReducer((value: number) => value + 1, 0);
const stores = useMemo(
() => (projectId ? projectStore.getAllChatStores(projectId) : []),
[projectId, projectStore]
);
useEffect(() => {
const unsubscribers = stores.map(({ chatStore }) =>
chatStore.subscribe(refresh)
);
return () => unsubscribers.forEach((unsubscribe) => unsubscribe());
}, [stores]);
const activeStore = projectId
? projectStore.getActiveChatStore(projectId)
: null;
const activeTaskId = activeStore?.getState().activeTaskId ?? null;
const seen = new Set<string>();
const runs: ProjectSessionRun[] = [];
for (const { chatId, chatStore } of stores) {
const state = chatStore.getState();
for (const [taskId, task] of Object.entries(state.tasks)) {
if (seen.has(taskId)) continue;
const hasProjectContent =
task.messages.some((message) => message.role === 'user') ||
task.taskInfo.length > 0 ||
task.taskRunning.length > 0 ||
task.taskAssigning.length > 0;
const isActiveTask = chatStore === activeStore && taskId === activeTaskId;
if (!hasProjectContent && !isActiveTask) continue;
seen.add(taskId);
const createdAt = task.createdAt || 0;
runs.push({
chatId,
chatStore,
task,
taskId,
createdAt,
updatedAt: taskUpdatedAt(task),
isCurrent: isActiveTask,
});
}
}
runs.sort((a, b) => b.createdAt - a.createdAt || b.updatedAt - a.updatedAt);
const currentRun =
runs.find((run) => run.isCurrent) ?? (runs.length > 0 ? runs[0]! : null);
const normalizedRuns = runs.map((run) => ({
...run,
isCurrent: run.taskId === currentRun?.taskId,
}));
return {
currentRun:
normalizedRuns.find((run) => run.isCurrent) ?? currentRun ?? null,
historicalRuns: normalizedRuns.filter((run) => !run.isCurrent),
runs: normalizedRuns,
};
}

View file

@ -335,6 +335,27 @@
"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-earlier": "عمليات التشغيل السابقة",
"session-panel-agent": "وكيل",
"session-panel-subagent": "وكيل فرعي",
"session-panel-remote-subagent": "وكيل فرعي بعيد",
"session-panel-agent-description": "وكيل متعدد الأغراض يخطط للمشروع وينجزه باستخدام الأدوات المتاحة.",
"session-panel-subagent-description": "وكيل مفوَّض يُستخدم لجزء محدد من هذا المشروع.",
"session-panel-tool-calls": "استدعاءات الأدوات",
"session-panel-tool-history": "سجل الطلبات والاستجابات",
"session-panel-call": "استدعاء {{name}} رقم {{number}}",
"session-panel-request": "الطلب",
"session-panel-response": "الاستجابة",
"session-panel-no-call-details": "لا تتوفر تفاصيل محفوظة للطلب أو الاستجابة.",
"session-panel-upload-failed": "تعذر تحميل {{name}}",
"workspace-lets-do-this": "لنبدأ",
"workspace-work-in-project": "العمل في مشروع",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,27 @@
"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-earlier": "Frühere Läufe",
"session-panel-agent": "Agent",
"session-panel-subagent": "Subagent",
"session-panel-remote-subagent": "Remote-Subagent",
"session-panel-agent-description": "Ein universeller Agent, der das Projekt mit den verfügbaren Tools plant und abschließt.",
"session-panel-subagent-description": "Ein delegierter Agent für einen abgegrenzten Teil dieses Projekts.",
"session-panel-tool-calls": "Tool-Aufrufe",
"session-panel-tool-history": "Anfrage- und Antwortverlauf",
"session-panel-call": "{{name}}-Aufruf {{number}}",
"session-panel-request": "Anfrage",
"session-panel-response": "Antwort",
"session-panel-no-call-details": "Es sind keine gespeicherten Anfrage- oder Antwortdetails verfügbar.",
"session-panel-upload-failed": "{{name}} konnte nicht hochgeladen werden",
"workspace-lets-do-this": "Los geht's",
"workspace-work-in-project": "In einem Projekt arbeiten",
"workspace-select-project": "Select a project",

View file

@ -344,6 +344,27 @@
"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-earlier": "Earlier runs",
"session-panel-agent": "Agent",
"session-panel-subagent": "Subagent",
"session-panel-remote-subagent": "Remote subagent",
"session-panel-agent-description": "A general-purpose agent that plans and completes the project using the available tools.",
"session-panel-subagent-description": "A delegated agent used for a bounded part of this project.",
"session-panel-tool-calls": "Tool calls",
"session-panel-tool-history": "Request and response history",
"session-panel-call": "{{name}} call {{number}}",
"session-panel-request": "Request",
"session-panel-response": "Response",
"session-panel-no-call-details": "No stored request or response details are available.",
"session-panel-upload-failed": "Failed to upload {{name}}",
"workspace-lets-do-this": "Let's do this",
"workspace-work-in-project": "Work in a project",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,27 @@
"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-earlier": "Ejecuciones anteriores",
"session-panel-agent": "Agente",
"session-panel-subagent": "Subagente",
"session-panel-remote-subagent": "Subagente remoto",
"session-panel-agent-description": "Un agente de propósito general que planifica y completa el proyecto con las herramientas disponibles.",
"session-panel-subagent-description": "Un agente delegado para una parte delimitada de este proyecto.",
"session-panel-tool-calls": "Llamadas a herramientas",
"session-panel-tool-history": "Historial de solicitudes y respuestas",
"session-panel-call": "Llamada {{number}} de {{name}}",
"session-panel-request": "Solicitud",
"session-panel-response": "Respuesta",
"session-panel-no-call-details": "No hay detalles guardados de solicitudes o respuestas.",
"session-panel-upload-failed": "No se pudo subir {{name}}",
"workspace-lets-do-this": "Vamos a ello",
"workspace-work-in-project": "Trabajar en un proyecto",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,27 @@
"sessions-collapse-list": "Réduire la liste des sessions",
"sessions-session-menu": "Options de session",
"sessions-untitled": "Session sans titre",
"session-summary": "Résumé",
"session-summary-scope": "Contenu du résumé",
"session-summary-latest-only": "Dernière exécution uniquement",
"session-summary-all": "Tout",
"session-activity-empty": "Lactivité de la session apparaîtra ici au début du travail.",
"session-panel-skills": "Compétences",
"session-panel-resources": "Ressources",
"session-panel-files": "Fichiers",
"session-panel-earlier": "Exécutions précédentes",
"session-panel-agent": "Agent",
"session-panel-subagent": "Sous-agent",
"session-panel-remote-subagent": "Sous-agent distant",
"session-panel-agent-description": "Un agent polyvalent qui planifie et réalise le projet à laide des outils disponibles.",
"session-panel-subagent-description": "Un agent délégué pour une partie délimitée de ce projet.",
"session-panel-tool-calls": "Appels doutils",
"session-panel-tool-history": "Historique des requêtes et réponses",
"session-panel-call": "Appel {{number}} de {{name}}",
"session-panel-request": "Requête",
"session-panel-response": "Réponse",
"session-panel-no-call-details": "Aucun détail de requête ou de réponse enregistré nest disponible.",
"session-panel-upload-failed": "Échec de limportation de {{name}}",
"workspace-lets-do-this": "C'est parti",
"workspace-work-in-project": "Travailler dans un projet",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,27 @@
"sessions-collapse-list": "Comprimi elenco sessioni",
"sessions-session-menu": "Opzioni sessione",
"sessions-untitled": "Sessione senza titolo",
"session-summary": "Riepilogo",
"session-summary-scope": "Contenuto del riepilogo",
"session-summary-latest-only": "Solo lesecuzione più recente",
"session-summary-all": "Tutte",
"session-activity-empty": "Lattività della sessione verrà visualizzata qui allinizio del lavoro.",
"session-panel-skills": "Competenze",
"session-panel-resources": "Risorse",
"session-panel-files": "File",
"session-panel-earlier": "Esecuzioni precedenti",
"session-panel-agent": "Agente",
"session-panel-subagent": "Sottoagente",
"session-panel-remote-subagent": "Sottoagente remoto",
"session-panel-agent-description": "Un agente generico che pianifica e completa il progetto utilizzando gli strumenti disponibili.",
"session-panel-subagent-description": "Un agente delegato utilizzato per una parte circoscritta di questo progetto.",
"session-panel-tool-calls": "Chiamate agli strumenti",
"session-panel-tool-history": "Cronologia di richieste e risposte",
"session-panel-call": "Chiamata {{number}} di {{name}}",
"session-panel-request": "Richiesta",
"session-panel-response": "Risposta",
"session-panel-no-call-details": "Non sono disponibili dettagli salvati per richieste o risposte.",
"session-panel-upload-failed": "Impossibile caricare {{name}}",
"workspace-lets-do-this": "Iniziamo",
"workspace-work-in-project": "Lavora in un progetto",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,27 @@
"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-earlier": "以前の実行",
"session-panel-agent": "エージェント",
"session-panel-subagent": "サブエージェント",
"session-panel-remote-subagent": "リモートサブエージェント",
"session-panel-agent-description": "利用可能なツールを使用してプロジェクトを計画し、完了する汎用エージェントです。",
"session-panel-subagent-description": "このプロジェクトの限定された部分を担当する委任エージェントです。",
"session-panel-tool-calls": "ツール呼び出し",
"session-panel-tool-history": "リクエストとレスポンスの履歴",
"session-panel-call": "{{name}} 呼び出し {{number}}",
"session-panel-request": "リクエスト",
"session-panel-response": "レスポンス",
"session-panel-no-call-details": "保存されたリクエストまたはレスポンスの詳細はありません。",
"session-panel-upload-failed": "{{name}} のアップロードに失敗しました",
"workspace-lets-do-this": "さあ、始めましょう",
"workspace-work-in-project": "プロジェクトで作業",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,27 @@
"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-earlier": "이전 실행",
"session-panel-agent": "에이전트",
"session-panel-subagent": "하위 에이전트",
"session-panel-remote-subagent": "원격 하위 에이전트",
"session-panel-agent-description": "사용 가능한 도구로 프로젝트를 계획하고 완료하는 범용 에이전트입니다.",
"session-panel-subagent-description": "이 프로젝트의 제한된 부분을 담당하도록 위임된 에이전트입니다.",
"session-panel-tool-calls": "도구 호출",
"session-panel-tool-history": "요청 및 응답 기록",
"session-panel-call": "{{name}} 호출 {{number}}",
"session-panel-request": "요청",
"session-panel-response": "응답",
"session-panel-no-call-details": "저장된 요청 또는 응답 세부 정보가 없습니다.",
"session-panel-upload-failed": "{{name}} 업로드에 실패했습니다",
"workspace-lets-do-this": "시작해요",
"workspace-work-in-project": "프로젝트에서 작업",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,27 @@
"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-earlier": "Предыдущие запуски",
"session-panel-agent": "Агент",
"session-panel-subagent": "Субагент",
"session-panel-remote-subagent": "Удалённый субагент",
"session-panel-agent-description": "Универсальный агент, который планирует и выполняет проект с помощью доступных инструментов.",
"session-panel-subagent-description": "Делегированный агент для выполнения ограниченной части этого проекта.",
"session-panel-tool-calls": "Вызовы инструментов",
"session-panel-tool-history": "История запросов и ответов",
"session-panel-call": "Вызов {{name}} № {{number}}",
"session-panel-request": "Запрос",
"session-panel-response": "Ответ",
"session-panel-no-call-details": "Сохранённые сведения о запросе или ответе отсутствуют.",
"session-panel-upload-failed": "Не удалось загрузить {{name}}",
"workspace-lets-do-this": "Давайте начнём",
"workspace-work-in-project": "Работа в проекте",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,27 @@
"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-earlier": "之前的运行",
"session-panel-agent": "智能体",
"session-panel-subagent": "子智能体",
"session-panel-remote-subagent": "远程子智能体",
"session-panel-agent-description": "使用可用工具规划并完成项目的通用智能体。",
"session-panel-subagent-description": "用于处理此项目中限定部分的委派智能体。",
"session-panel-tool-calls": "工具调用",
"session-panel-tool-history": "请求和响应历史",
"session-panel-call": "{{name}} 调用 {{number}}",
"session-panel-request": "请求",
"session-panel-response": "响应",
"session-panel-no-call-details": "没有已保存的请求或响应详情。",
"session-panel-upload-failed": "无法上传 {{name}}",
"workspace-lets-do-this": "开始吧",
"workspace-work-in-project": "在项目中工作",
"workspace-select-project": "Select a project",

View file

@ -335,6 +335,27 @@
"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-earlier": "先前的執行",
"session-panel-agent": "智能體",
"session-panel-subagent": "子智能體",
"session-panel-remote-subagent": "遠端子智能體",
"session-panel-agent-description": "使用可用工具規劃並完成專案的通用智能體。",
"session-panel-subagent-description": "用於處理此專案中限定部分的委派智能體。",
"session-panel-tool-calls": "工具呼叫",
"session-panel-tool-history": "請求和回應歷史",
"session-panel-call": "{{name}} 呼叫 {{number}}",
"session-panel-request": "請求",
"session-panel-response": "回應",
"session-panel-no-call-details": "沒有已儲存的請求或回應詳細資料。",
"session-panel-upload-failed": "無法上傳 {{name}}",
"workspace-lets-do-this": "開始吧",
"workspace-work-in-project": "在專案中工作",
"workspace-select-project": "Select a project",

View file

@ -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';
@ -757,7 +757,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}
@ -771,7 +771,7 @@ export default function WorkspacePage() {
defaultSize={24}
minSize={sidebarPct.rail}
maxSize={sidebarPct.max}
className="min-h-0 min-w-0 py-1 pl-1"
className="min-h-0 min-w-0"
>
<ProjectPageSidebar chatStore={chatStore} />
</ResizablePanel>

View file

@ -1781,18 +1781,18 @@ const chatStore = (initial?: Partial<ChatStore>) =>
return taskId;
},
computedProgressValue(taskId: string) {
const { tasks, setProgressValue, activeTaskId } = get();
const { tasks, setProgressValue } = get();
const taskRunning = [...tasks[taskId].taskRunning];
const finishedTask = taskRunning?.filter(
(task) =>
task.status === TaskStatus.COMPLETED ||
task.status === TaskStatus.FAILED
).length;
const taskProgress = (
((finishedTask || 0) / (taskRunning?.length || 0)) *
100
).toFixed(2);
setProgressValue(activeTaskId as string, Number(taskProgress));
const taskProgress =
taskRunning.length > 0
? Number(((finishedTask / taskRunning.length) * 100).toFixed(2))
: 0;
setProgressValue(taskId, taskProgress);
},
removeTask(taskId: string) {
// Clean up any pending auto-confirm timers when removing a task

View file

@ -15,7 +15,7 @@
import {
reconcileToolkitState,
TOOLKIT_MIN_DISPLAY_MS,
} from '@/components/Session/SidePanelSections/AgentPoolSection';
} from '@/components/Session/SidePanel/sections/AgentPoolSection';
import { AgentStatusValue } from '@/types/constants';
import { describe, expect, it } from 'vitest';

View file

@ -0,0 +1,215 @@
// ========= 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,
} from '@/components/Session/SidePanel/sections/buildProjectSessionPanelData';
import type { ProjectSessionRun } from '@/hooks/useProjectSessionOverview';
import { AgentStep } from '@/types/constants';
import { describe, expect, it } from 'vitest';
function makeRun(
taskId: string,
isCurrent: boolean,
task: Record<string, unknown>
): ProjectSessionRun {
return {
chatId: `chat-${taskId}`,
chatStore: {} as ProjectSessionRun['chatStore'],
taskId,
createdAt: isCurrent ? 200 : 100,
updatedAt: isCurrent ? 200 : 100,
isCurrent,
task: {
messages: [],
taskInfo: [],
taskRunning: [],
taskAssigning: [],
fileList: [],
webViewUrls: [],
attaches: [],
...task,
} as ProjectSessionRun['task'],
};
}
describe('buildProjectSessionPanelData', () => {
it('pairs toolkit activation and deactivation as request and response', () => {
const run = makeRun('run-1', true, {
taskAssigning: [
{
agent_id: 'agent-1',
name: 'Browser Agent',
type: 'browser_agent',
tasks: [],
log: [
{
step: AgentStep.ACTIVATE_TOOLKIT,
timestamp: 1,
data: {
toolkit_name: 'Browser Toolkit',
method_name: 'search',
message: '{"query":"Eigent"}',
},
},
{
step: AgentStep.DEACTIVATE_TOOLKIT,
timestamp: 2,
data: {
toolkit_name: 'Browser Toolkit',
method_name: 'search',
message: 'https://eigent.ai/docs',
},
},
],
},
],
});
expect(collectSessionToolCalls([run])).toMatchObject([
{
toolkitName: 'Browser Toolkit',
method: 'search',
input: '{"query":"Eigent"}',
output: 'https://eigent.ai/docs',
status: 'done',
taskId: 'run-1',
},
]);
});
it('keeps run ownership internally while folding historical content', () => {
const current = makeRun('run-current', true, {
taskInfo: [
{ id: 'p-current', content: 'Current step', status: 'running' },
],
webViewUrls: [
{
url: 'https://current.example.com/reference',
processTaskId: 'p-current',
},
],
});
const historical = makeRun('run-old', false, {
taskInfo: [{ id: 'p-old', content: 'Older step', status: 'completed' }],
webViewUrls: [
{
url: 'https://old.example.com/research',
processTaskId: 'p-old',
},
],
});
const data = buildProjectSessionPanelData([current, historical], []);
expect(data.progress).toMatchObject([
{ taskId: 'run-current', historical: false, updatedAt: 200 },
{ taskId: 'run-old', historical: true, updatedAt: 100 },
]);
expect(data.resources).toMatchObject([
{ taskId: 'run-current', historical: false, updatedAt: 200 },
{ taskId: 'run-old', historical: true, updatedAt: 100 },
]);
});
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']);
});
it('uses Open Connector identity and combines its calls across runs', () => {
const connectorAgent = (timestamp: number) => ({
agent_id: `agent-${timestamp}`,
name: 'Agent',
type: 'single_agent',
workerInfo: {
name: 'Agent',
description: '',
tools: [],
mcp_tools: { mcpServers: { connector_gateway: {} } },
selectedTools: [],
},
tasks: [
{
id: `task-${timestamp}`,
content: 'Search Notion',
status: 'completed',
toolkits: [
{
toolkitName: 'MCPToolkit',
toolkitMethods: 'notion_search',
message: '{"query":"roadmap"}',
},
],
},
],
log: [
{
step: AgentStep.ACTIVATE_TOOLKIT,
timestamp,
data: {
toolkit_name: 'MCPToolkit',
method_name: 'notion_search',
message: '{"query":"roadmap"}',
},
},
{
step: AgentStep.DEACTIVATE_TOOLKIT,
timestamp: timestamp + 1,
data: {
toolkit_name: 'MCPToolkit',
method_name: 'notion_search',
message: '{"results":[]}',
},
},
],
});
const current = makeRun('run-current', true, {
taskAssigning: [connectorAgent(3)],
});
const historical = makeRun('run-old', false, {
taskAssigning: [connectorAgent(1)],
});
const data = buildProjectSessionPanelData(
[current, historical],
[],
[
{
service: 'notion',
displayName: 'Notion',
iconUrl: 'https://cdn.example.com/notion.svg',
actions: [{ id: 'notion_search', name: 'Search Notion' }],
},
]
);
expect(data.contextItems).toHaveLength(1);
expect(data.contextItems[0]).toMatchObject({
id: 'notion',
label: 'Notion',
iconUrl: 'https://cdn.example.com/notion.svg',
historical: false,
});
expect(data.contextItems[0]?.calls.map((call) => call.taskId)).toEqual([
'run-old',
'run-current',
]);
});
});

View file

@ -0,0 +1,66 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import {
resolveContextConnector,
type ContextConnector,
} from '@/components/Session/SidePanel/sections/buildContextItems';
import { describe, expect, it } from 'vitest';
const notion: ContextConnector = {
service: 'notion',
displayName: 'Notion',
};
const slack: ContextConnector = {
service: 'slack',
displayName: 'Slack',
};
describe('resolveContextConnector', () => {
it('matches a provider-prefixed toolkit by name', () => {
expect(
resolveContextConnector('NotionMCPToolkit', 'search', '', [notion, slack])
).toBe(notion);
});
it('matches a provider by method when the toolkit is generic', () => {
expect(
resolveContextConnector('MCPToolkit', 'slack_send_message', '', [
notion,
slack,
])
).toBe(slack);
});
it('assumes the only connector for a connector gateway call', () => {
expect(
resolveContextConnector('ConnectorGateway', 'call', '', [slack])
).toBe(slack);
expect(
resolveContextConnector('connector_gateway', 'call', '', [slack])
).toBe(slack);
});
it('stays generic for an unidentified MCP call even with one connector', () => {
expect(
resolveContextConnector('MCPToolkit', 'call', '', [slack])
).toBeNull();
});
it('stays generic when a gateway call cannot pick between connectors', () => {
expect(
resolveContextConnector('ConnectorGateway', 'call', '', [notion, slack])
).toBeNull();
});
});

View file

@ -0,0 +1,99 @@
// ========= 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);
});
});
describe('selectSessionPanelRuns', () => {
const runs = [
{ id: 'old', isCurrent: false, createdAt: 100, updatedAt: 500 },
{ id: 'current', isCurrent: true, createdAt: 400, updatedAt: 200 },
{ id: 'previous', isCurrent: false, createdAt: 300, updatedAt: 100 },
];
it('returns only the current run in latest mode', () => {
expect(selectSessionPanelRuns(runs, 'latest').map((run) => run.id)).toEqual(
['current']
);
});
it('returns every run by creation time in all mode', () => {
expect(selectSessionPanelRuns(runs, 'all').map((run) => run.id)).toEqual([
'current',
'previous',
'old',
]);
});
});

View file

@ -0,0 +1,115 @@
// ========= 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 { useProjectSessionOverview } from '@/hooks/useProjectSessionOverview';
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('useProjectSessionOverview', () => {
beforeEach(() => {
useProjectStore.setState({
activeProjectId: null,
projects: {},
navLeadByProjectId: {},
historyLoadingProjectIds: {},
});
usePageTabStore.setState({
sidePanelSelectedTurnByProject: {},
sidePanelManualUntilByProject: {},
sidePanelViewedTurnByProject: {},
});
});
it('keeps the active run current while exposing every historical run', () => {
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');
oldStore!.getState().addMessages(oldTaskId, {
id: 'old-user',
role: 'user',
content: 'Old prompt',
});
latestStore!.getState().addMessages(latestTaskId, {
id: 'latest-user',
role: 'user',
content: 'Latest prompt',
});
projectStore.setActiveChatStore(projectId, latestChatId!);
act(() => {
usePageTabStore.getState().setSidePanelSelectedTurn(projectId, oldTaskId);
});
const { result } = renderHook(() => useProjectSessionOverview(projectId));
expect(result.current.currentRun?.taskId).toBe(latestTaskId);
expect(result.current.runs.map((run) => run.taskId)).toEqual(
expect.arrayContaining([oldTaskId, latestTaskId])
);
expect(
result.current.historicalRuns.some((run) => run.taskId === oldTaskId)
).toBe(true);
});
it('keeps a newly created empty active run current', () => {
const projectStore = useProjectStore.getState();
const projectId = projectStore.createProject(
'New run',
undefined,
'project-new-run',
ProjectType.REPLAY
);
const chatStore = projectStore.getActiveChatStore(projectId)!;
const historicalTaskId = chatStore.getState().create('task-historical');
chatStore.getState().addMessages(historicalTaskId, {
id: 'historical-user',
role: 'user',
content: 'Previous prompt',
});
const emptyActiveTaskId = chatStore.getState().create('task-empty-active');
chatStore.setState((state) => ({
tasks: {
...state.tasks,
[historicalTaskId]: {
...state.tasks[historicalTaskId],
createdAt: 100,
},
[emptyActiveTaskId]: {
...state.tasks[emptyActiveTaskId],
createdAt: 200,
},
},
}));
const { result } = renderHook(() => useProjectSessionOverview(projectId));
expect(result.current.currentRun?.taskId).toBe(emptyActiveTaskId);
expect(result.current.runs[0]?.taskId).toBe(emptyActiveTaskId);
expect(result.current.historicalRuns.map((run) => run.taskId)).toContain(
historicalTaskId
);
});
});

View file

@ -137,9 +137,9 @@ import {
getCloudModelPlatform,
mergeFileInfoLists,
normalizeTaskArtifactFileList,
resolveRunOutputFileList,
resolveConfirmedUserMessageContent,
resolveEndMessageText,
resolveRunOutputFileList,
useChatStore,
} from '../../../src/store/chatStore';
import { useProjectStore } from '../../../src/store/projectStore';
@ -1061,6 +1061,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', () => {