mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-15 19:53:29 +00:00
Refactor session side panel with event bus, unified shell and dynamic display
This commit is contained in:
parent
75968fd6bb
commit
7ef924475d
52 changed files with 3436 additions and 876 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
136
src/components/Session/SidePanel/components/AccordionBox.tsx
Normal file
136
src/components/Session/SidePanel/components/AccordionBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
657
src/components/Session/SidePanel/components/ActivityPanel.tsx
Normal file
657
src/components/Session/SidePanel/components/ActivityPanel.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -19,19 +19,19 @@ import type { SessionModeType } from '@/types/constants';
|
|||
import { PanelRight, PanelRightClose } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface SessionSidePanelFoldButtonProps {
|
||||
export interface SidePanelFoldButtonProps {
|
||||
sessionSidePanelMode: SessionModeType;
|
||||
isSidePanelVisible: boolean;
|
||||
onToggle: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SessionSidePanelFoldButton({
|
||||
export function SidePanelFoldButton({
|
||||
sessionSidePanelMode,
|
||||
isSidePanelVisible,
|
||||
onToggle,
|
||||
className,
|
||||
}: SessionSidePanelFoldButtonProps) {
|
||||
}: SidePanelFoldButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const sessionSidePanelTooltip =
|
||||
sessionSidePanelMode === 'single-agent'
|
||||
|
|
@ -81,6 +81,7 @@ const foldedTaskLogContentVariants = {
|
|||
},
|
||||
};
|
||||
|
||||
/** Legacy workforce detail view retained as a SidePanel implementation detail. */
|
||||
export function AgentDetailPane({
|
||||
agent,
|
||||
onTakeManualFollowControl,
|
||||
|
|
@ -90,7 +90,7 @@ function pickLatestWorkingAgentId(agents: Agent[]): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
export interface FoldedPanelProps {
|
||||
export interface SidePanelFoldedPanelProps {
|
||||
/** When true, do not push global activeWorkspace/activeAgent from the folded rail (expanded overlay shows workflow-only). */
|
||||
pauseAgentWorkspaceSync?: boolean;
|
||||
}
|
||||
|
|
@ -102,7 +102,7 @@ export interface FoldedPanelProps {
|
|||
*/
|
||||
export default function FoldedPanel({
|
||||
pauseAgentWorkspaceSync = false,
|
||||
}: FoldedPanelProps) {
|
||||
}: SidePanelFoldedPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHost();
|
||||
const { chatStore, projectStore } = useChatStoreAdapter();
|
||||
|
|
@ -393,15 +393,15 @@ export default function FoldedPanel({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="bg-ds-bg-neutral-default-default min-h-0 min-w-0 flex h-full w-full flex-col"
|
||||
className="flex h-full min-h-0 w-full min-w-0 flex-col bg-ds-bg-neutral-default-default"
|
||||
data-workforce-folded={isTaskLiveLayout ? 'task-live' : 'initial'}
|
||||
>
|
||||
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{isTaskLiveLayout ? (
|
||||
<motion.div
|
||||
key="task-live"
|
||||
className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden"
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
|
|
@ -409,8 +409,8 @@ export default function FoldedPanel({
|
|||
onPointerDownCapture={onFoldedPanelEngagement}
|
||||
onWheelCapture={onFoldedPanelEngagement}
|
||||
>
|
||||
<div className="scrollbar scrollbar-always-visible py-2 pl-2 pr-2 shrink-0 overflow-x-auto overflow-y-hidden">
|
||||
<div className="gap-2 flex w-max min-w-full flex-row flex-nowrap items-center">
|
||||
<div className="scrollbar scrollbar-always-visible shrink-0 overflow-x-auto overflow-y-hidden py-2 pl-2 pr-2">
|
||||
<div className="flex w-max min-w-full flex-row flex-nowrap items-center gap-2">
|
||||
{sortedAgents.map((agent) => (
|
||||
<div key={agent.agent_id} className="shrink-0">
|
||||
<FoldedAgentCard
|
||||
|
|
@ -435,7 +435,7 @@ export default function FoldedPanel({
|
|||
activeTaskId &&
|
||||
activeTask &&
|
||||
chatStore && (
|
||||
<div className="scrollbar scrollbar-always-visible min-h-0 min-w-0 pb-2 flex h-full shrink-0 flex-col overflow-x-hidden overflow-y-auto">
|
||||
<div className="scrollbar scrollbar-always-visible flex h-full min-h-0 min-w-0 shrink-0 flex-col overflow-y-auto overflow-x-hidden pb-2">
|
||||
<TaskCard
|
||||
key={`task-folded-${activeTaskId}`}
|
||||
chatId={taskPanelChatId}
|
||||
|
|
@ -462,7 +462,7 @@ export default function FoldedPanel({
|
|||
</div>
|
||||
)}
|
||||
{showPlanTaskBox && activeTaskId && activeChatStore ? (
|
||||
<div className="pb-2 shrink-0">
|
||||
<div className="shrink-0 pb-2">
|
||||
<PlanTaskBox
|
||||
chatStore={activeChatStore}
|
||||
taskId={activeTaskId}
|
||||
|
|
@ -470,7 +470,7 @@ export default function FoldedPanel({
|
|||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 min-w-0 px-2 pb-2 flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden px-2 pb-2">
|
||||
{detailAgent ? (
|
||||
<AgentDetailPane
|
||||
agent={detailAgent}
|
||||
|
|
@ -479,7 +479,7 @@ export default function FoldedPanel({
|
|||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-ds-text-neutral-muted-default p-3 text-body-sm">
|
||||
<div className="p-3 text-body-sm text-ds-text-neutral-muted-default">
|
||||
{t('chat.select-agent')}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -488,13 +488,13 @@ export default function FoldedPanel({
|
|||
) : (
|
||||
<motion.div
|
||||
key="initial"
|
||||
className="scrollbar scrollbar-always-visible min-h-0 min-w-0 pl-2 pb-2 pt-1 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
className="scrollbar scrollbar-always-visible min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pb-2 pl-2 pt-1"
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={FOLDED_LAYOUT_TRANSITION}
|
||||
>
|
||||
<div className="gap-2 min-w-0 flex w-full max-w-full flex-col opacity-80">
|
||||
<div className="flex w-full min-w-0 max-w-full flex-col gap-2 opacity-80">
|
||||
{showPlanTaskBox && activeTaskId && activeChatStore ? (
|
||||
<PlanTaskBox
|
||||
chatStore={activeChatStore}
|
||||
|
|
@ -12,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>
|
||||
|
|
@ -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')}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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
|
||||
175
src/components/Session/SidePanel/index.tsx
Normal file
175
src/components/Session/SidePanel/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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]';
|
||||
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
385
src/components/Session/SidePanel/sections/primitives.tsx
Normal file
385
src/components/Session/SidePanel/sections/primitives.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
const CONTENT_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
||||
const REVEAL_EASE: [number, number, number, number] = [0.23, 1, 0.32, 1];
|
||||
const LAYOUT_TRANSITION = {
|
||||
layout: { duration: 0.28, ease: CONTENT_EASE },
|
||||
} as const;
|
||||
|
||||
export type SidePanelAccordionRenderArgs = { open: boolean };
|
||||
|
||||
export type SidePanelAccordionChildren =
|
||||
| ReactNode
|
||||
| ((state: SidePanelAccordionRenderArgs) => ReactNode);
|
||||
|
||||
export function SidePanelAccordionBox({
|
||||
title,
|
||||
titleSuffix,
|
||||
collapsedPreview,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
title: string;
|
||||
/** Small adornment rendered right after the title (e.g. count pill). */
|
||||
titleSuffix?: ReactNode;
|
||||
/**
|
||||
* Compact content below the header when collapsed (static `children` only;
|
||||
* render-prop children control their own open/closed layout).
|
||||
*/
|
||||
collapsedPreview?: ReactNode;
|
||||
/**
|
||||
* Static: classic accordion — body hidden when closed.
|
||||
* Render prop: body stays in one region; switch layout by `open` (e.g. summary vs full list).
|
||||
*/
|
||||
children: SidePanelAccordionChildren;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const isRenderProp = typeof children === 'function';
|
||||
const dynamicBody = isRenderProp
|
||||
? (children as (s: SidePanelAccordionRenderArgs) => ReactNode)({ open })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="z-10 flex min-w-0 shrink-0 flex-col overflow-hidden rounded-xl border border-solid border-ds-border-neutral-subtle-disabled bg-ds-bg-neutral-default-default">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full shrink-0 items-center justify-between gap-2 px-3 py-2.5 text-left transition-colors hover:bg-ds-bg-neutral-default-hover"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="text-body-sm font-semibold text-ds-text-neutral-default-default">
|
||||
{title}
|
||||
</span>
|
||||
{titleSuffix ? (
|
||||
<span className="flex shrink-0 items-center">{titleSuffix}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-ds-text-neutral-muted-default transition-transform duration-200 ease-out motion-reduce:transition-none',
|
||||
open ? 'rotate-0' : '-rotate-90'
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
{!open && collapsedPreview && !isRenderProp ? (
|
||||
<div className="w-full px-2 pb-3">{collapsedPreview}</div>
|
||||
) : null}
|
||||
|
||||
{isRenderProp ? (
|
||||
<motion.div
|
||||
layout={!shouldReduceMotion}
|
||||
transition={
|
||||
shouldReduceMotion ? { layout: { duration: 0 } } : LAYOUT_TRANSITION
|
||||
}
|
||||
className="min-h-0 w-full overflow-hidden"
|
||||
>
|
||||
{dynamicBody != null ? (
|
||||
<div className="w-full px-2 pb-3">{dynamicBody}</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
) : (
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<motion.div
|
||||
key="static-content"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
transform: shouldReduceMotion
|
||||
? 'translateY(0px)'
|
||||
: 'translateY(-8px)',
|
||||
}}
|
||||
animate={{ opacity: 1, transform: 'translateY(0px)' }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transform: shouldReduceMotion
|
||||
? 'translateY(0px)'
|
||||
: 'translateY(-4px)',
|
||||
transition: {
|
||||
duration: shouldReduceMotion ? 0.14 : 0.125,
|
||||
ease: REVEAL_EASE,
|
||||
},
|
||||
}}
|
||||
transition={{ duration: 0.16, ease: REVEAL_EASE }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="w-full px-2 pb-3">{children as ReactNode}</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check } from 'lucide-react';
|
||||
import { type ReactNode, forwardRef } from 'react';
|
||||
|
||||
/**
|
||||
* Small round count/label pill used next to accordion titles.
|
||||
*/
|
||||
export function CountPill({ count }: { count: number }) {
|
||||
return (
|
||||
<span className="bg-ds-bg-neutral-subtle-default text-ds-text-neutral-subtle-default text-label-xs font-bold px-1.5 inline-flex items-center justify-center rounded-full">
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Small muted category label for grouping list items.
|
||||
*/
|
||||
export function CategoryLabel({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-ds-text-neutral-muted-default text-body-xs px-1 pb-1 pt-2 first:pt-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SidePanelListRowProps = {
|
||||
leading?: ReactNode;
|
||||
children: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
/**
|
||||
* Pointer + subtle hover/active backgrounds without an action (e.g. read-only list rows).
|
||||
* When `onClick` is set, focus ring is included; for hover-only rows it is omitted.
|
||||
*/
|
||||
interactiveHover?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Row primitive used across Agent Pool / Execution Context / Agent Folder sections.
|
||||
* Rendered as a button when `onClick` is provided, otherwise a div.
|
||||
*/
|
||||
export const SidePanelListRow = forwardRef<HTMLElement, SidePanelListRowProps>(
|
||||
(
|
||||
{
|
||||
leading,
|
||||
children,
|
||||
trailing,
|
||||
disabled,
|
||||
onClick,
|
||||
interactiveHover,
|
||||
className,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const showAffordance = Boolean(onClick || interactiveHover);
|
||||
const base = cn(
|
||||
'group gap-2 px-1.5 py-1.5 rounded-md min-w-0 w-full flex items-center',
|
||||
'text-ds-text-neutral-default-default text-body-sm text-left',
|
||||
'transition-colors',
|
||||
disabled
|
||||
? 'opacity-50 pointer-events-none'
|
||||
: showAffordance
|
||||
? cn(
|
||||
'cursor-pointer hover:bg-ds-bg-neutral-subtle-default active:bg-ds-bg-neutral-subtle-hover',
|
||||
onClick &&
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-ring-brand-default-focus/40'
|
||||
)
|
||||
: '',
|
||||
className
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{leading ? (
|
||||
<span className="flex shrink-0 items-center">{leading}</span>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate">{children}</span>
|
||||
{trailing ? (
|
||||
<span className="flex shrink-0 items-center">{trailing}</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={base}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref as React.Ref<HTMLDivElement>} className={base}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidePanelListRow.displayName = 'SidePanelListRow';
|
||||
|
||||
/**
|
||||
* Progress circle. Incomplete: neutral subtle fill so the ring reads on any
|
||||
* panel background. Complete: filled success (matches primary success button)
|
||||
* with inverse check mark.
|
||||
*/
|
||||
export function ProgressCircle({
|
||||
done,
|
||||
size = 14,
|
||||
}: {
|
||||
done: boolean;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-full border-[0.5px] border-solid',
|
||||
done
|
||||
? 'border-ds-bg-success-default-default bg-ds-bg-success-default-default text-ds-text-success-inverse-default'
|
||||
: 'border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default'
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
aria-hidden
|
||||
>
|
||||
{done ? (
|
||||
<Check
|
||||
className="!text-ds-text-success-inverse-default"
|
||||
size={Math.max(8, size - 6)}
|
||||
strokeWidth={4}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin connector line between two progress circles in the folded strip view.
|
||||
*/
|
||||
export function ProgressConnector() {
|
||||
return (
|
||||
<span
|
||||
className="bg-ds-border-neutral-default-default h-px min-w-[6px] flex-1"
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { AgentFolderSection } from '@/components/Session/SidePanelSections/AgentFolderSection';
|
||||
import { ExecutionContextSection } from '@/components/Session/SidePanelSections/ExecutionContextSection';
|
||||
import { ProgressSection } from '@/components/Session/SidePanelSections/ProgressSection';
|
||||
import { buildContextItems } from '@/components/Session/SidePanelSections/buildContextItems';
|
||||
import {
|
||||
collectSidePanelOutputFiles,
|
||||
mergeSidePanelOutputFiles,
|
||||
} from '@/components/Session/SidePanelSections/collectSidePanelOutputFiles';
|
||||
import { useProjectOutputFiles } from '@/components/Session/SidePanelSections/useProjectOutputFiles';
|
||||
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
|
||||
import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePageTabStore } from '@/store/pageTabStore';
|
||||
import { useSkillsStore } from '@/store/skillsStore';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function SingleAgentSidePanel() {
|
||||
const { t } = useTranslation();
|
||||
const { chatStore, projectStore } = useChatStoreAdapter();
|
||||
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
|
||||
|
||||
const selectedTurn = useSelectedProjectTurn(projectStore.activeProjectId);
|
||||
const selectedTask = selectedTurn.task;
|
||||
const selectedTaskId = selectedTurn.taskId;
|
||||
// The visible turn follows chat scrolling, but the filesystem scan is
|
||||
// project-level. Drive it from the active Run so scrolling across history
|
||||
// never turns into a series of identical /files requests.
|
||||
const activeProjectTaskId = chatStore?.activeTaskId ?? null;
|
||||
const activeProjectTask = activeProjectTaskId
|
||||
? chatStore?.tasks[activeProjectTaskId]
|
||||
: undefined;
|
||||
|
||||
const agents = useMemo(
|
||||
() => selectedTask?.taskAssigning ?? [],
|
||||
[selectedTask?.taskAssigning]
|
||||
);
|
||||
const projectFiles = useProjectOutputFiles(
|
||||
projectStore.activeProjectId,
|
||||
activeProjectTask,
|
||||
activeProjectTaskId
|
||||
);
|
||||
/** Prefer live `taskRunning` status (updated on TASK_STATE), keep plan order/text from agent tasks or taskInfo. */
|
||||
const subtasks = useMemo(() => {
|
||||
const base = agents[0]?.tasks ?? selectedTask?.taskInfo ?? [];
|
||||
const taskRunning = selectedTask?.taskRunning ?? [];
|
||||
if (taskRunning.length === 0) return base;
|
||||
return base.map((t) => {
|
||||
const live = taskRunning.find((r) => r.id === t.id);
|
||||
if (!live) return t;
|
||||
return { ...t, ...live, content: t.content || live.content };
|
||||
});
|
||||
}, [agents, selectedTask?.taskInfo, selectedTask?.taskRunning]);
|
||||
const files = useMemo(
|
||||
() =>
|
||||
mergeSidePanelOutputFiles(
|
||||
collectSidePanelOutputFiles(selectedTask),
|
||||
projectFiles
|
||||
),
|
||||
[selectedTask, projectFiles]
|
||||
);
|
||||
const uploadedFiles = useMemo(() => {
|
||||
if (!selectedTask) return [];
|
||||
const all = [
|
||||
...(selectedTask.messages ?? [])
|
||||
.filter((m) => m.role === 'user')
|
||||
.flatMap((m) => m.attaches ?? []),
|
||||
...(selectedTask.attaches ?? []),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return all.filter((file) => {
|
||||
const key = file.filePath;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}, [selectedTask]);
|
||||
const skills = useSkillsStore((s) => s.skills);
|
||||
const contextItems = useMemo(
|
||||
() =>
|
||||
buildContextItems(
|
||||
agents,
|
||||
selectedTask?.taskRunning,
|
||||
uploadedFiles,
|
||||
skills
|
||||
),
|
||||
[agents, selectedTask?.taskRunning, uploadedFiles, skills]
|
||||
);
|
||||
|
||||
const handleOpenAgentFile = useCallback(
|
||||
(file: FileInfo) => {
|
||||
openFilePreview(file);
|
||||
},
|
||||
[openFilePreview]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden',
|
||||
'relative'
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden px-2 pb-2">
|
||||
<ProgressSection
|
||||
title={t('layout.workforce-progress', { defaultValue: 'Progress' })}
|
||||
subtasks={subtasks}
|
||||
projectId={projectStore.activeProjectId}
|
||||
taskId={selectedTaskId}
|
||||
/>
|
||||
<ExecutionContextSection
|
||||
title={t('layout.execution-context', {
|
||||
defaultValue: 'Execution Context',
|
||||
})}
|
||||
items={contextItems}
|
||||
/>
|
||||
<AgentFolderSection
|
||||
title={t('layout.workforce-agent-folder', {
|
||||
defaultValue: 'Agent Folder',
|
||||
})}
|
||||
files={files}
|
||||
onOpenFile={handleOpenAgentFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
export { SingleAgentSidePanel } from './SingleAgentSidePanel';
|
||||
|
|
@ -1,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
133
src/hooks/useProjectSessionOverview.ts
Normal file
133
src/hooks/useProjectSessionOverview.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "L’activité de la session apparaîtra ici au début du travail.",
|
||||
"session-panel-skills": "Compétences",
|
||||
"session-panel-resources": "Ressources",
|
||||
"session-panel-files": "Fichiers",
|
||||
"session-panel-earlier": "Exécutions précédentes",
|
||||
"session-panel-agent": "Agent",
|
||||
"session-panel-subagent": "Sous-agent",
|
||||
"session-panel-remote-subagent": "Sous-agent distant",
|
||||
"session-panel-agent-description": "Un agent polyvalent qui planifie et réalise le projet à l’aide des outils disponibles.",
|
||||
"session-panel-subagent-description": "Un agent délégué pour une partie délimitée de ce projet.",
|
||||
"session-panel-tool-calls": "Appels d’outils",
|
||||
"session-panel-tool-history": "Historique des requêtes et réponses",
|
||||
"session-panel-call": "Appel {{number}} de {{name}}",
|
||||
"session-panel-request": "Requête",
|
||||
"session-panel-response": "Réponse",
|
||||
"session-panel-no-call-details": "Aucun détail de requête ou de réponse enregistré n’est disponible.",
|
||||
"session-panel-upload-failed": "Échec de l’importation de {{name}}",
|
||||
"workspace-lets-do-this": "C'est parti",
|
||||
"workspace-work-in-project": "Travailler dans un projet",
|
||||
"workspace-select-project": "Select a project",
|
||||
|
|
|
|||
|
|
@ -335,6 +335,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 l’esecuzione più recente",
|
||||
"session-summary-all": "Tutte",
|
||||
"session-activity-empty": "L’attività della sessione verrà visualizzata qui all’inizio del lavoro.",
|
||||
"session-panel-skills": "Competenze",
|
||||
"session-panel-resources": "Risorse",
|
||||
"session-panel-files": "File",
|
||||
"session-panel-earlier": "Esecuzioni precedenti",
|
||||
"session-panel-agent": "Agente",
|
||||
"session-panel-subagent": "Sottoagente",
|
||||
"session-panel-remote-subagent": "Sottoagente remoto",
|
||||
"session-panel-agent-description": "Un agente generico che pianifica e completa il progetto utilizzando gli strumenti disponibili.",
|
||||
"session-panel-subagent-description": "Un agente delegato utilizzato per una parte circoscritta di questo progetto.",
|
||||
"session-panel-tool-calls": "Chiamate agli strumenti",
|
||||
"session-panel-tool-history": "Cronologia di richieste e risposte",
|
||||
"session-panel-call": "Chiamata {{number}} di {{name}}",
|
||||
"session-panel-request": "Richiesta",
|
||||
"session-panel-response": "Risposta",
|
||||
"session-panel-no-call-details": "Non sono disponibili dettagli salvati per richieste o risposte.",
|
||||
"session-panel-upload-failed": "Impossibile caricare {{name}}",
|
||||
"workspace-lets-do-this": "Iniziamo",
|
||||
"workspace-work-in-project": "Lavora in un progetto",
|
||||
"workspace-select-project": "Select a project",
|
||||
|
|
|
|||
|
|
@ -335,6 +335,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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
215
test/unit/components/buildProjectSessionPanelData.test.ts
Normal file
215
test/unit/components/buildProjectSessionPanelData.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
66
test/unit/components/resolveContextConnector.test.ts
Normal file
66
test/unit/components/resolveContextConnector.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import {
|
||||
resolveContextConnector,
|
||||
type ContextConnector,
|
||||
} from '@/components/Session/SidePanel/sections/buildContextItems';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const notion: ContextConnector = {
|
||||
service: 'notion',
|
||||
displayName: 'Notion',
|
||||
};
|
||||
const slack: ContextConnector = {
|
||||
service: 'slack',
|
||||
displayName: 'Slack',
|
||||
};
|
||||
|
||||
describe('resolveContextConnector', () => {
|
||||
it('matches a provider-prefixed toolkit by name', () => {
|
||||
expect(
|
||||
resolveContextConnector('NotionMCPToolkit', 'search', '', [notion, slack])
|
||||
).toBe(notion);
|
||||
});
|
||||
|
||||
it('matches a provider by method when the toolkit is generic', () => {
|
||||
expect(
|
||||
resolveContextConnector('MCPToolkit', 'slack_send_message', '', [
|
||||
notion,
|
||||
slack,
|
||||
])
|
||||
).toBe(slack);
|
||||
});
|
||||
|
||||
it('assumes the only connector for a connector gateway call', () => {
|
||||
expect(
|
||||
resolveContextConnector('ConnectorGateway', 'call', '', [slack])
|
||||
).toBe(slack);
|
||||
expect(
|
||||
resolveContextConnector('connector_gateway', 'call', '', [slack])
|
||||
).toBe(slack);
|
||||
});
|
||||
|
||||
it('stays generic for an unidentified MCP call even with one connector', () => {
|
||||
expect(
|
||||
resolveContextConnector('MCPToolkit', 'call', '', [slack])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('stays generic when a gateway call cannot pick between connectors', () => {
|
||||
expect(
|
||||
resolveContextConnector('ConnectorGateway', 'call', '', [notion, slack])
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
99
test/unit/components/sessionPanelScope.test.ts
Normal file
99
test/unit/components/sessionPanelScope.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
115
test/unit/hooks/useProjectSessionOverview.test.tsx
Normal file
115
test/unit/hooks/useProjectSessionOverview.test.tsx
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue