update chatput process content

This commit is contained in:
Douglas 2026-04-21 20:09:02 +01:00
parent a1a63a60c5
commit e91cf31ff6
53 changed files with 1835 additions and 434 deletions

View file

@ -102,7 +102,7 @@
"marked": "^17.0.1",
"mime": "^4.1.0",
"monaco-editor": "^0.52.2",
"motion": "^12.23.24",
"motion": "^12.38.0",
"next-themes": "^0.4.6",
"papaparse": "^5.5.3",
"postprocessing": "^6.37.8",

View file

@ -89,7 +89,7 @@ export default function BottomBox({
if (state === 'splitting')
backgroundClass = 'bg-ds-bg-splitting-subtle-default';
else if (state === 'confirm')
backgroundClass = 'bg-ds-bg-pending-subtle-default';
backgroundClass = 'bg-ds-bg-running-subtle-default';
return (
<div className="backdrop-blur-xl rounded-t-2xl bg-ds-bg-neutral-subtle-default relative z-50 flex w-full flex-col">

View file

@ -128,7 +128,7 @@ export function UserMessageCard({
return (
<div key={id} className={cn('group/msg relative w-full', className)}>
<div className="rounded-xl p-sm bg-ds-bg-neutral-default-default w-full overflow-visible">
<div className="rounded-xl py-2 px-4 bg-ds-bg-neutral-default-default w-full overflow-visible">
{attaches && attaches.length > 0 && (
<div className="mb-2 gap-1 relative box-border flex w-full flex-wrap items-start">
{(() => {

View file

@ -0,0 +1,144 @@
// ========= 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 tokenDarkIcon from '@/assets/token-dark.svg';
import tokenLightIcon from '@/assets/token-light.svg';
import { ClipboardList } from '@/components/ui/animate-ui/icons/clipboard-list';
import { cn } from '@/lib/utils';
import { useAuthStore } from '@/store/authStore';
import type { VanillaChatStore } from '@/store/chatStore';
import { AgentStep, ChatTaskStatus } from '@/types/constants';
import { useEffect, useState, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { AnimatedTokenNumber, formatSplittingElapsed } from './TokenUtils';
/** One shared start time per task so inline + bottom splitting rows stay in sync. */
const splittingTimerStartMsByTaskId = new Map<string, number>();
function getOrCreateSplittingTimerStart(taskId: string): number {
let started = splittingTimerStartMsByTaskId.get(taskId);
if (started === undefined) {
started = Date.now();
splittingTimerStartMsByTaskId.set(taskId, started);
}
return started;
}
function clearSplittingTimerStart(taskId: string) {
splittingTimerStartMsByTaskId.delete(taskId);
}
function isSplittingSkeletonPhase(task: any): boolean {
if (!task) return false;
const anyToSubTasksMessage = task.messages?.find(
(m: any) => m.step === AgentStep.TO_SUB_TASKS
);
return (
(task.status !== ChatTaskStatus.FINISHED &&
task.status !== ChatTaskStatus.RUNNING &&
!anyToSubTasksMessage &&
!task.hasWaitComfirm &&
(task.messages?.length ?? 0) > 0) ||
(task.isTakeControl && !anyToSubTasksMessage)
);
}
function useSplittingPhaseElapsedMs(
chatStore: VanillaChatStore,
taskId: string | null
): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!taskId) return;
const tick = () => setNow(Date.now());
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [taskId]);
useEffect(() => {
if (!taskId) return;
const sync = () => {
const task = chatStore.getState().tasks[taskId];
if (!isSplittingSkeletonPhase(task)) {
clearSplittingTimerStart(taskId);
}
};
sync();
return chatStore.subscribe(sync);
}, [chatStore, taskId]);
if (!taskId) return 0;
const startMs = getOrCreateSplittingTimerStart(taskId);
return Math.max(0, now - startMs);
}
export interface SplittingProgressRowProps {
chatStore: VanillaChatStore;
taskId: string | null;
className?: string;
}
export function SplittingProgressRow({
chatStore,
taskId,
className,
}: SplittingProgressRowProps) {
const { t } = useTranslation();
const { appearance } = useAuthStore();
const tokenIcon = appearance === 'dark' ? tokenDarkIcon : tokenLightIcon;
const elapsedMs = useSplittingPhaseElapsedMs(chatStore, taskId);
const tokens = useSyncExternalStore(
(cb) => chatStore.subscribe(cb),
() => (taskId ? (chatStore.getState().tasks[taskId]?.tokens ?? 0) : 0),
() => (taskId ? (chatStore.getState().tasks[taskId]?.tokens ?? 0) : 0)
);
return (
<div
className={cn(
'gap-x-2 gap-y-1 min-w-0 m-2 flex w-full flex-wrap items-center justify-start',
className
)}
>
<div className="flex shrink-0 items-center justify-center">
<ClipboardList
animate
loop
size={16}
className="text-[color:var(--ds-icon-information-default-default)]"
/>
</div>
<span className="text-sm font-bold shrink-0 text-[color:var(--ds-text-information-default-default)]">
{t('chat.splitting-tasks')}
</span>
<span className="text-xs font-medium shrink-0 text-[color:var(--ds-text-neutral-muted-default)] tabular-nums">
{formatSplittingElapsed(elapsedMs)}
</span>
<span className="text-xs font-medium shrink-0 text-[color:var(--ds-text-neutral-muted-default)] tabular-nums">
{' '}
{' '}
</span>
<span
className="gap-1 text-xs font-medium flex shrink-0 items-center text-[color:var(--ds-text-neutral-muted-default)]"
aria-label={`${t('chat.token')}: ${tokens}`}
>
<AnimatedTokenNumber value={tokens} />
Token
</span>
</div>
);
}

View file

@ -28,7 +28,37 @@ import {
Plus,
TriangleAlert,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
const TASK_CARD_EXPAND_STORAGE_PREFIX = 'eigent:task-card-expanded';
function getTaskCardExpandStorageKey(
chatId: string | undefined,
activeTaskId: string | undefined
): string | null {
if (!activeTaskId) return null;
if (chatId)
return `${TASK_CARD_EXPAND_STORAGE_PREFIX}:${chatId}:${activeTaskId}`;
return `${TASK_CARD_EXPAND_STORAGE_PREFIX}:${activeTaskId}`;
}
function readStoredTaskCardExpanded(key: string | null): boolean {
if (!key || typeof window === 'undefined') return false;
try {
return sessionStorage.getItem(key) === '1';
} catch {
return false;
}
}
function writeStoredTaskCardExpanded(key: string | null, expanded: boolean) {
if (!key || typeof window === 'undefined') return;
try {
sessionStorage.setItem(key, expanded ? '1' : '0');
} catch {
/* ignore quota / private mode */
}
}
interface TaskCardProps {
taskInfo: any[];
@ -58,7 +88,6 @@ export function TaskCard({
clickable = true,
chatId,
}: TaskCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number | 'auto'>('auto');
const [selectedState, setSelectedState] = useState<TaskStateType>('all');
@ -71,6 +100,17 @@ export function TaskCard({
const activeTaskId = chatStore?.activeTaskId as string;
const activeTask = chatStore?.tasks?.[activeTaskId];
const activeTaskStatus = activeTask?.status;
const expandStorageKey = getTaskCardExpandStorageKey(chatId, activeTaskId);
const [isExpanded, setIsExpanded] = useState(() =>
readStoredTaskCardExpanded(
getTaskCardExpandStorageKey(chatId, activeTaskId)
)
);
useEffect(() => {
setIsExpanded(readStoredTaskCardExpanded(expandStorageKey));
}, [expandStorageKey]);
useEffect(() => {
const tasks = taskRunning || [];
@ -110,10 +150,6 @@ export function TaskCard({
}
}, [selectedState, taskInfo, taskRunning]);
const isAllTaskFinished = useMemo(() => {
return activeTaskStatus === ChatTaskStatus.FINISHED;
}, [activeTaskStatus]);
// Improved height calculation logic
useEffect(() => {
if (!contentRef.current) return;
@ -176,7 +212,7 @@ export function TaskCard({
return (
<div>
<div className="gap-2 px-sm flex h-auto w-full flex-col transition-all duration-300">
<div className="gap-2 px-sm py-2 flex h-auto w-full flex-col transition-all duration-300">
<div className="rounded-xl py-sm bg-ds-bg-neutral-default-default relative h-auto w-full overflow-hidden">
<div className="left-0 top-0 absolute w-full bg-transparent">
<Progress value={progressValue} className="h-[2px] w-full" />
@ -289,7 +325,7 @@ export function TaskCard({
)}
{taskType === 2 && (
<div className="gap-2 animate-in fade-in-0 slide-in-from-right-2 flex items-center duration-300">
{(isExpanded || isAllTaskFinished) && (
{isExpanded && (
<div className="text-xs font-medium leading-17 text-ds-text-neutral-subtle-default">
{taskRunning?.filter(
(task) =>
@ -302,7 +338,13 @@ export function TaskCard({
<Button
variant="ghost"
size="icon"
onClick={() => setIsExpanded(!isExpanded)}
onClick={() => {
setIsExpanded((prev) => {
const next = !prev;
writeStoredTaskCardExpanded(expandStorageKey, next);
return next;
});
}}
>
<ChevronDown
size={16}
@ -399,7 +441,11 @@ export function TaskCard({
? 'bg-[var(--ds-bg-status-running-subtle-default)]'
: task.status === TaskStatus.BLOCKED
? 'bg-[var(--ds-bg-status-blocked-subtle-default)]'
: 'bg-[var(--ds-bg-status-running-subtle-default)]'
: task.status === TaskStatus.SKIPPED ||
task.status === TaskStatus.WAITING ||
task.status === TaskStatus.EMPTY
? 'bg-[var(--ds-bg-status-pending-subtle-default)]'
: 'bg-[var(--ds-bg-status-running-subtle-default)]'
} cursor-pointer border border-solid border-transparent ${
task.status === TaskStatus.COMPLETED
? 'hover:border-[color:var(--ds-border-status-completed-default-focus)]'
@ -409,7 +455,11 @@ export function TaskCard({
? 'hover:border-[color:var(--ds-border-status-running-default-focus)]'
: task.status === TaskStatus.BLOCKED
? 'hover:border-[color:var(--ds-border-status-blocked-default-focus)]'
: 'border-transparent'
: task.status === TaskStatus.SKIPPED ||
task.status === TaskStatus.WAITING ||
task.status === TaskStatus.EMPTY
? 'hover:border-[color:var(--ds-border-status-pending-default-hover)]'
: 'border-transparent'
} `}
>
<div className="pt-0.5">
@ -425,7 +475,7 @@ export function TaskCard({
{task.status === TaskStatus.SKIPPED && (
<LoaderCircle
size={16}
className={`text-[color:var(--ds-icon-neutral-muted-default)]`}
className="text-[color:var(--ds-icon-status-pending-default-default)]"
/>
)}
{task.status === TaskStatus.COMPLETED && (
@ -446,10 +496,11 @@ export function TaskCard({
className="text-[color:var(--ds-icon-warning-default-default)]"
/>
)}
{task.status === TaskStatus.EMPTY && (
{(task.status === TaskStatus.EMPTY ||
task.status === TaskStatus.WAITING) && (
<Circle
size={16}
className="text-[color:var(--ds-icon-neutral-muted-default)]"
className="text-[color:var(--ds-icon-status-pending-default-default)]"
/>
)}
</div>
@ -460,7 +511,11 @@ export function TaskCard({
? 'text-[color:var(--ds-text-caution-default-default)]'
: task.status === TaskStatus.BLOCKED
? 'text-[color:var(--ds-text-warning-default-default)]'
: 'text-[color:var(--ds-text-neutral-default-default)]'
: task.status === TaskStatus.SKIPPED ||
task.status === TaskStatus.WAITING ||
task.status === TaskStatus.EMPTY
? 'text-[color:var(--ds-text-status-pending-default-default)]'
: 'text-[color:var(--ds-text-neutral-default-default)]'
} text-sm font-medium leading-13`}
>
{task.content}

View file

@ -0,0 +1,391 @@
// ========= 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 { cn } from '@/lib/utils';
import type { VanillaChatStore } from '@/store/chatStore';
import { AgentStep, ChatTaskStatus } from '@/types/constants';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { formatSplittingElapsed } from './TokenUtils';
function normalizeToolkitMessage(value: unknown): string {
if (typeof value === 'string') return value;
if (value == null) return '';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
/** Matches `getFormattedTaskTime` / task timer fields on the chat task. */
function getTaskElapsedMs(task: { taskTime: number; elapsed: number }): number {
if (task.taskTime !== 0) {
return Math.max(0, Date.now() - task.taskTime + task.elapsed);
}
return Math.max(0, task.elapsed);
}
function mergeAgentLogs(taskAssigning: Agent[] | undefined): AgentMessage[] {
if (!taskAssigning?.length) return [];
return taskAssigning.flatMap((a) => a.log ?? []);
}
function titleCaseMethod(method: string): string {
if (!method) return '';
return method.charAt(0).toUpperCase() + method.slice(1);
}
function truncateText(text: string, max: number): string {
const t = text.trim();
if (t.length <= max) return t;
return `${t.slice(0, max - 1)}`;
}
function formatToolSummary(
toolkitName: string,
method: string,
preview: string
): string {
const p = preview.trim();
const head = `${toolkitName} · ${titleCaseMethod(method)}`;
return p ? `${head}${truncateText(p, 100)}` : head;
}
/** Collapsed row: only the toolkit · method prefix; preview after — or - stays in expanded detail. */
function summaryRowLabel(fullSummary: string): string {
const em = fullSummary.split(' — ');
if (em.length > 1) return em[0]!.trim();
const spacedHyphen = fullSummary.split(' - ');
if (spacedHyphen.length > 1) return spacedHyphen[0]!.trim();
return fullSummary.trim();
}
type ToolSegment = {
type: 'tool';
toolkitName: string;
method: string;
summary: string;
detail: string;
status: 'running' | 'done';
};
type AgentSegment = { type: 'agent'; text: string };
type LogSegment = AgentSegment | ToolSegment;
function buildLogSegments(merged: AgentMessage[]): LogSegment[] {
const segments: LogSegment[] = [];
for (const entry of merged) {
if (entry.step === AgentStep.ACTIVATE_AGENT) {
const text = normalizeToolkitMessage(entry.data?.message).trim();
if (text) segments.push({ type: 'agent', text });
continue;
}
if (entry.step === AgentStep.ACTIVATE_TOOLKIT) {
const name = (entry.data?.toolkit_name ?? '').trim() || 'Tool';
const method = (entry.data?.method_name ?? '').trim();
const rawMsg = normalizeToolkitMessage(entry.data?.message).trim();
if (name.toLowerCase() === 'notice') {
if (rawMsg) segments.push({ type: 'agent', text: rawMsg });
continue;
}
if (!method && !rawMsg) continue;
segments.push({
type: 'tool',
toolkitName: name,
method,
summary: formatToolSummary(name, method, rawMsg),
detail: rawMsg,
status: 'running',
});
continue;
}
if (entry.step === AgentStep.DEACTIVATE_TOOLKIT) {
const name = (entry.data?.toolkit_name ?? '').trim();
const method = (entry.data?.method_name ?? '').trim();
const msg = normalizeToolkitMessage(entry.data?.message).trim();
for (let i = segments.length - 1; i >= 0; i--) {
const s = segments[i];
if (s.type !== 'tool') continue;
if (s.status !== 'running') continue;
if (s.toolkitName !== name || s.method !== method) continue;
s.status = 'done';
s.detail = [s.detail, msg].filter(Boolean).join('\n\n').trim();
s.summary = formatToolSummary(name, method, s.detail);
break;
}
}
}
return segments;
}
/**
* Cheap digest of the task slice that affects this accordion, so any chat store
* mutation re-renders without relying on `updateCount` (rarely bumped).
*/
function useTaskWorkStoreSnapshot(
chatStore: VanillaChatStore,
taskId: string | null
) {
return useSyncExternalStore(
(cb) => chatStore.subscribe(cb),
() => {
if (!taskId) return '';
const t = chatStore.getState().tasks[taskId];
if (!t) return '';
const logDigest = (t.taskAssigning ?? [])
.map((a) => {
const log = a.log ?? [];
const last = log[log.length - 1];
const msg = last?.data?.message;
const msgLen =
typeof msg === 'string'
? msg.length
: msg != null
? JSON.stringify(msg).length
: 0;
return `${log.length}:${last?.step ?? ''}:${msgLen}:${last?.data?.toolkit_name ?? ''}:${last?.data?.method_name ?? ''}`;
})
.join('>');
return `${t.status}|${t.taskTime}|${t.elapsed}|${logDigest}`;
},
() => ''
);
}
function useTaskWorkLogData(
chatStore: VanillaChatStore,
taskId: string | null,
_snapshot: string
) {
return useMemo(() => {
if (!taskId) {
return { task: undefined, segments: [] as LogSegment[] };
}
const t = chatStore.getState().tasks[taskId];
const merged = mergeAgentLogs(t?.taskAssigning);
const segments = buildLogSegments(merged);
return { task: t, segments };
}, [chatStore, taskId, _snapshot]);
}
function useWorkLogElapsedMs(
chatStore: VanillaChatStore,
taskId: string | null,
snapshot: string
): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const t = taskId ? chatStore.getState().tasks[taskId] : null;
if (t?.status !== ChatTaskStatus.RUNNING) return;
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [chatStore, taskId, snapshot]);
return useMemo(() => {
if (!taskId) return 0;
const t = chatStore.getState().tasks[taskId];
if (!t) return 0;
return getTaskElapsedMs(t);
}, [chatStore, taskId, snapshot, now]);
}
function ToolDetailRow({
summary,
detail,
}: {
summary: string;
detail: string;
}) {
const [open, setOpen] = useState(false);
return (
<div className="min-w-0 flex w-full flex-col items-start">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="min-w-0 gap-1 py-2 px-2 inline-flex max-w-full items-center self-start text-left transition-opacity hover:opacity-80"
>
<span className="text-body-sm font-medium min-w-0 text-ds-text-neutral-muted-default shrink overflow-hidden text-ellipsis whitespace-nowrap">
{summaryRowLabel(summary)}
</span>
<ChevronRight
size={16}
aria-hidden
className={cn(
'text-ds-icon-neutral-muted-default shrink-0 transition-transform duration-200',
open && 'rotate-90'
)}
/>
</button>
<div
className={cn(
'min-w-0 ease-in-out mx-2 w-full overflow-hidden transition-all duration-200',
open ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'
)}
>
{detail ? (
<div className="pb-2 pl-0 pr-0 pt-0">
<MarkDown
content={detail}
enableTypewriter={false}
pTextSize="text-xs"
/>
</div>
) : null}
</div>
</div>
);
}
export interface TaskWorkLogAccordionProps {
chatStore: VanillaChatStore;
taskId: string | null;
className?: string;
}
export function TaskWorkLogAccordion({
chatStore,
taskId,
className,
}: TaskWorkLogAccordionProps) {
const { t } = useTranslation();
const snapshot = useTaskWorkStoreSnapshot(chatStore, taskId);
const { task, segments } = useTaskWorkLogData(chatStore, taskId, snapshot);
const status = task?.status;
const elapsedMs = useWorkLogElapsedMs(chatStore, taskId, snapshot);
const [outerOpen, setOuterOpen] = useState(
() => status === ChatTaskStatus.RUNNING
);
useEffect(() => {
if (status === ChatTaskStatus.FINISHED) {
setOuterOpen(false);
} else if (status === ChatTaskStatus.RUNNING) {
setOuterOpen(true);
}
}, [status]);
if (!taskId || !task) return null;
const allowed =
status === ChatTaskStatus.RUNNING ||
status === ChatTaskStatus.FINISHED ||
status === ChatTaskStatus.PAUSE;
if (!allowed) return null;
if (status !== ChatTaskStatus.RUNNING && segments.length === 0) {
return null;
}
const timeLabel = formatSplittingElapsed(elapsedMs);
const headerRunning = t('chat.working-on-tasks-for', { time: timeLabel });
const headerDone = t('chat.worked-for', { time: timeLabel });
const headerText =
status === ChatTaskStatus.RUNNING || status === ChatTaskStatus.PAUSE
? headerRunning
: headerDone;
const useTypewriterForAgent =
outerOpen &&
(status === ChatTaskStatus.FINISHED || status === ChatTaskStatus.PAUSE);
return (
<div className={cn('min-w-0 my-2 flex w-full flex-col', className)}>
<button
type="button"
aria-expanded={outerOpen}
onClick={() => setOuterOpen((v) => !v)}
className="gap-1 py-2 px-2 min-w-0 flex w-full items-center justify-start text-left"
>
<span className="text-body-sm font-medium text-ds-text-neutral-muted-default tabular-nums">
{headerText}
</span>
{outerOpen ? (
<ChevronDown
size={16}
strokeWidth={2}
aria-hidden
className="text-ds-icon-neutral-muted-default shrink-0"
/>
) : (
<ChevronRight
size={16}
strokeWidth={2}
aria-hidden
className="text-ds-icon-neutral-muted-default shrink-0"
/>
)}
</button>
<div
className={cn(
'ease-in-out overflow-hidden transition-all duration-200',
outerOpen ? 'max-h-[8000px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<div className="gap-3 pb-1 min-w-0 flex flex-col">
{segments.map((seg, index) => {
if (seg.type === 'agent') {
return (
<div
key={`agent-${index}-${seg.text.slice(0, 24)}`}
className="min-w-0"
>
{useTypewriterForAgent ? (
<MarkDown
key={`tw-${index}-${outerOpen}`}
content={seg.text}
enableTypewriter
speed={12}
pTextSize="text-sm"
/>
) : (
<p className="text-body-sm font-medium m-0 leading-snug text-ds-text-neutral-default-default break-words whitespace-pre-wrap">
{seg.text}
</p>
)}
</div>
);
}
return (
<ToolDetailRow
key={`tool-${index}-${seg.toolkitName}-${seg.method}`}
summary={seg.summary}
detail={seg.detail}
/>
);
})}
</div>
</div>
</div>
);
}

View file

@ -28,6 +28,19 @@ const TOKEN_UNITS = [
{ threshold: 1_000, suffix: 'K' },
] as const;
/**
* Elapsed time during splitting / planning: seconds, then minutes + seconds.
* Examples: "0s", "45s", "1m 05s", "12m 00s"
*/
export function formatSplittingElapsed(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return '0s';
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}m ${s.toString().padStart(2, '0')}s`;
}
export function formatTokenCount(n: number): string {
if (!Number.isFinite(n)) return '0';

View file

@ -27,9 +27,10 @@ import { AgentMessageCard } from './MessageItem/AgentMessageCard';
import { NoticeCard } from './MessageItem/NoticeCard';
import { TaskCompletionCard } from './MessageItem/TaskCompletionCard';
import { UserMessageCard } from './MessageItem/UserMessageCard';
import { SplittingProgressRow } from './SplittingProgressRow';
import { StreamingTaskList } from './TaskBox/StreamingTaskList';
import { TaskCard } from './TaskBox/TaskCard';
import { TypeCardSkeleton } from './TaskBox/TypeCardSkeleton';
import { TaskWorkLogAccordion } from './TaskWorkLogAccordion';
/** Collapsible card that shows a single agent's result (workforce / nonsingle-agent turns). */
const AgentResultCard: React.FC<{
@ -368,6 +369,17 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
</motion.div>
)}
{taskCardVisible && activeTaskId && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.05 }}
className="px-sm"
>
<TaskWorkLogAccordion chatStore={chatStore} taskId={activeTaskId} />
</motion.div>
)}
{/* Other Messages */}
{queryGroup.otherMessages.map((message) => {
if (message.content.length > 0) {
@ -378,7 +390,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="gap-4 px-sm flex flex-col"
className="gap-4 flex flex-col"
>
<AgentMessageCard
typewriter={
@ -391,7 +403,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
onMarkdownRenderComplete={onTaskCompletionMarkdownReady}
deferredFooter={
message.fileList?.length ? (
<div className="gap-2 flex flex-wrap">
<div className="gap-2 my-2 flex flex-wrap">
{message.fileList.map(
(file: any, fileIndex: number) => (
<motion.div
@ -409,13 +421,13 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
'documentWorkSpace'
);
}}
className="gap-2 rounded-sm px-2 py-1 flex w-[140px] cursor-pointer items-center bg-[var(--ds-bg-neutral-default-default)] transition-colors hover:bg-[var(--ds-bg-neutral-default-hover)]"
className="gap-2 rounded-lg py-2 px-3 bg-ds-bg-neutral-default-default hover:bg-ds-bg-neutral-default-hover flex w-[140px] cursor-pointer items-center transition-colors"
>
<div className="flex flex-col">
<div className="text-body text-sm font-bold max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap text-[color:var(--ds-text-neutral-default-default)]">
<div className="text-body-sm font-bold text-ds-text-neutral-default-default max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap">
{file.name.split('.')[0]}
</div>
<div className="text-xs font-medium leading-29 text-[color:var(--ds-text-neutral-default-default)]">
<div className="text-label-xs font-medium text-ds-text-neutral-muted-default">
{file.type}
</div>
</div>
@ -435,7 +447,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="gap-4 px-sm flex flex-col"
className="gap-4 flex flex-col"
>
<AgentMessageCard
key={message.id}
@ -470,7 +482,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="gap-4 px-sm flex flex-col"
className="gap-4 flex flex-col"
>
<AgentMessageCard
key={message.id}
@ -493,7 +505,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="gap-4 px-sm flex flex-col"
className="gap-4 flex flex-col"
>
{message.fileList && (
<div className="gap-2 flex flex-wrap">
@ -575,14 +587,14 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
<StreamingTaskList streamingText={streamingDecomposeText} />
)}
{/* Skeleton for loading state */}
{isSkeletonPhase && (
{isSkeletonPhase && activeTaskId && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
transition={{ delay: 0.15 }}
className="px-sm"
>
<TypeCardSkeleton isTakeControl={task?.isTakeControl || false} />
<SplittingProgressRow chatStore={chatStore} taskId={activeTaskId} />
</motion.div>
)}
</motion.div>

View file

@ -43,6 +43,14 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ProjectDialog from './ProjectDialog';
const compactCountFormatter = new Intl.NumberFormat('en', {
notation: 'compact',
maximumFractionDigits: 1,
});
const formatCompactCount = (value?: number) =>
compactCountFormatter.format(value || 0).replace('.0', '');
interface ProjectGroupProps {
project: ProjectGroupType;
onTaskSelect: (
@ -345,12 +353,11 @@ export default function ProjectGroup({
tone="information"
emphasis="default"
size="xs"
className="gap-1.5"
>
<Hash />
<span>
{project.total_tokens
? project.total_tokens.toLocaleString()
: '0'}
<Hash className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.total_tokens)}
</span>
</Tag>
</TooltipSimple>
@ -359,12 +366,15 @@ export default function ProjectGroup({
<TooltipSimple content={t('layout.tasks')}>
<Tag
variant="primary"
tone="neutral"
tone="default"
emphasis="default"
size="xs"
className="min-w-10"
className="gap-1.5 min-w-10"
>
<ListChecks />
<span>{project.task_count}</span>
<ListChecks className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.task_count)}
</span>
</Tag>
</TooltipSimple>
@ -372,11 +382,14 @@ export default function ProjectGroup({
<Tag
variant="primary"
tone="warning"
emphasis="default"
size="xs"
className="min-w-10"
className="gap-1.5 min-w-10"
>
<Zap />
<span>{project.total_triggers || 0}</span>
<Zap className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.total_triggers)}
</span>
</Tag>
</TooltipSimple>
</div>
@ -420,30 +433,32 @@ export default function ProjectGroup({
</div>
{/* Middle: Project, Trigger, Agent tags - Aligned to right */}
<div className="gap-4 flex w-fit flex-1 items-center justify-end">
<div className="gap-2 flex w-fit flex-1 items-center justify-end">
<Tag
variant="primary"
tone="information"
emphasis="default"
size="sm"
size="xs"
className="gap-1.5"
>
<Hash />
<span>
{project.total_tokens
? project.total_tokens.toLocaleString()
: '0'}
<Hash className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.total_tokens)}
</span>
</Tag>
<TooltipSimple content={t('layout.tasks')}>
<Tag
variant="primary"
tone="neutral"
size="sm"
className="min-w-10"
tone="default"
emphasis="default"
size="xs"
className="gap-1.5 min-w-10"
>
<ListChecks />
<span>{project.task_count}</span>
<ListChecks className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.task_count)}
</span>
</Tag>
</TooltipSimple>
@ -451,11 +466,14 @@ export default function ProjectGroup({
<Tag
variant="primary"
tone="warning"
size="sm"
className="min-w-10"
emphasis="default"
size="xs"
className="gap-1.5 min-w-10"
>
<Zap />
<span>{project.total_triggers || 0}</span>
<Zap className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.total_triggers)}
</span>
</Tag>
</TooltipSimple>
</div>

View file

@ -13,7 +13,6 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { proxyFetchDelete } from '@/api/http';
import { Sparkle } from '@/components/ui/animate-ui/icons/sparkle';
import { Button } from '@/components/ui/button';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { loadProjectFromHistory } from '@/lib';
@ -26,12 +25,14 @@ import { HistoryTask, ProjectGroup } from '@/types/history';
import { AnimatePresence, motion } from 'framer-motion';
import {
Ellipsis,
FolderCheck,
FolderClock,
Hash,
Pin,
ListChecks,
Plus,
Share,
Sparkles,
Trash2,
Zap,
} from 'lucide-react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -47,6 +48,14 @@ import { Tag } from '../ui/tag';
import { TooltipSimple } from '../ui/tooltip';
import SearchInput from './SearchInput';
const compactCountFormatter = new Intl.NumberFormat('en', {
notation: 'compact',
maximumFractionDigits: 1,
});
const formatCompactCount = (value?: number) =>
compactCountFormatter.format(value || 0).replace('.0', '');
export default function HistorySidebar() {
const { t } = useTranslation();
const { isOpen, close } = useSidebarStore();
@ -67,7 +76,7 @@ export default function HistorySidebar() {
useEffect(() => {
if (!chatStore) return;
fetchGroupedHistoryTasks(setHistoryTasks);
}, [chatStore?.updateCount]);
}, [chatStore, chatStore?.updateCount]);
// Group ongoing tasks by project
const ongoingProjects = useMemo(() => {
@ -112,6 +121,9 @@ export default function HistorySidebar() {
tasks: [],
task_count: taskCount,
total_tokens: totalTokens,
total_triggers:
historyTasks.find((item) => item.project_id === project.id)
?.total_triggers || 0,
last_prompt: lastPrompt,
isOngoing: true,
});
@ -119,7 +131,7 @@ export default function HistorySidebar() {
});
return Array.from(projectMap.values());
}, [projectStore, chatStore]);
}, [projectStore, chatStore, historyTasks]);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value) {
@ -420,10 +432,7 @@ export default function HistorySidebar() {
}}
className="gap-sm rounded-xl px-4 py-3 shadow-history-item border-ds-border-neutral-subtle-default bg-ds-bg-neutral-default-default hover:bg-ds-bg-neutral-default-hover relative flex w-full max-w-full cursor-pointer items-center justify-between border border-solid transition-all duration-300"
>
<Sparkles
size={20}
className="text-ds-icon-status-splitting-default-default flex-shrink-0"
/>
<FolderClock className="h-5 w-5 text-ds-icon-status-running-default-default flex-shrink-0" />
<div className="min-w-0 gap-1 flex flex-1 flex-col">
<TooltipSimple
@ -443,19 +452,46 @@ export default function HistorySidebar() {
<div className="gap-2 flex flex-shrink-0 items-center">
<TooltipSimple content={t('chat.token')}>
<Tag variant="primary" tone="information" size="sm">
<Hash className="h-3.5 w-3.5" />
<span className="text-xs">
{(project.total_tokens || 0).toLocaleString()}
<Tag
variant="primary"
tone="information"
emphasis="default"
size="xs"
className="gap-1.5"
>
<Hash className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.total_tokens)}
</span>
</Tag>
</TooltipSimple>
<TooltipSimple content="Tasks">
<Tag variant="primary" tone="neutral" size="sm">
<Pin className="h-3.5 w-3.5" />
<span className="text-xs">
{project.task_count}
<TooltipSimple content={t('layout.tasks')}>
<Tag
variant="primary"
tone="default"
emphasis="default"
size="xs"
className="gap-1.5"
>
<ListChecks className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.task_count)}
</span>
</Tag>
</TooltipSimple>
<TooltipSimple content="Triggers">
<Tag
variant="primary"
tone="warning"
emphasis="default"
size="xs"
className="gap-1.5"
>
<Zap className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.total_triggers)}
</span>
</Tag>
</TooltipSimple>
@ -538,10 +574,7 @@ export default function HistorySidebar() {
key={project.project_id}
className="gap-sm rounded-xl px-4 py-3 shadow-history-item border-ds-border-neutral-subtle-default bg-ds-bg-neutral-default-default hover:bg-ds-bg-neutral-default-hover relative flex w-full max-w-full cursor-pointer items-center justify-between border border-solid transition-all duration-300"
>
<Sparkle
size={20}
className="text-ds-icon-neutral-muted-default flex-shrink-0"
/>
<FolderCheck className="h-5 w-5 text-ds-icon-neutral-subtle-default flex-shrink-0" />
<div className="min-w-0 flex-1">
<TooltipSimple
@ -565,19 +598,46 @@ export default function HistorySidebar() {
<div className="gap-2 flex flex-shrink-0 items-center">
<TooltipSimple content={t('chat.token')}>
<Tag variant="primary" tone="information" size="sm">
<Hash className="h-3.5 w-3.5" />
<span className="text-xs">
{(project.total_tokens || 0).toLocaleString()}
<Tag
variant="primary"
tone="information"
emphasis="default"
size="xs"
className="gap-1.5"
>
<Hash className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.total_tokens)}
</span>
</Tag>
</TooltipSimple>
<TooltipSimple content="Tasks">
<Tag variant="primary" tone="neutral" size="sm">
<Pin className="h-3.5 w-3.5" />
<span className="text-xs">
{project.task_count}
<TooltipSimple content={t('layout.tasks')}>
<Tag
variant="primary"
tone="default"
emphasis="default"
size="xs"
className="gap-1.5"
>
<ListChecks className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.task_count)}
</span>
</Tag>
</TooltipSimple>
<TooltipSimple content="Triggers">
<Tag
variant="primary"
tone="warning"
emphasis="default"
size="xs"
className="gap-1.5"
>
<Zap className="h-3 w-3" />
<span className="text-label-xs">
{formatCompactCount(project.total_triggers)}
</span>
</Tag>
</TooltipSimple>

View file

@ -60,11 +60,15 @@ export function BottomAction({
<button
type="button"
onClick={onEndProjectClick}
className={cn(workspaceTabButtonClass(false), folded && 'gap-0')}
className={cn(
workspaceTabButtonClass(false),
'bg-ds-bg-error-subtle-default hover:bg-ds-bg-status-error-subtle-hover active:bg-ds-bg-status-error-subtle-active',
folded && 'gap-0'
)}
aria-label={endProjectAriaLabel}
>
<Power
className="h-4 w-4 text-ds-icon-error-default-default shrink-0"
className="h-4 w-4 !text-ds-icon-error-default-default shrink-0"
aria-hidden
/>
<motion.span

View file

@ -23,7 +23,7 @@ import { usePageTabStore } from '@/store/pageTabStore';
import { useProjectStore } from '@/store/projectStore';
import { useTriggerStore } from '@/store/triggerStore';
import { ChatTaskStatus } from '@/types/constants';
import { AnimatePresence, motion } from 'framer-motion';
import { motion } from 'framer-motion';
import { Inbox, LayoutGrid, Plus, Zap, ZapOff } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -411,35 +411,36 @@ export default function ProjectPageSidebar({
</div>
</div>
<AnimatePresence initial={false}>
{!projectSidebarFolded ? (
<motion.div
key="nav-list"
className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden"
initial={false}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={PROJECT_SIDEBAR_FOLD_SPRING}
>
<NavList
className="min-h-0 mt-6 flex flex-1 flex-col"
sessions={navSessions}
activeSessionId={
activeWorkspaceTab === 'session'
? chatStore.activeTaskId
: null
}
onSessionClick={(id) => {
chatStore.setActiveTaskId(id);
setActiveWorkspaceTab('session');
}}
onDeleteSession={handleDeleteSession}
onShowAll={handleShowAllSessions}
showAllActive={activeWorkspaceTab === 'sessions'}
/>
</motion.div>
) : null}
</AnimatePresence>
<motion.div
className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden"
initial={false}
animate={{
opacity: projectSidebarFolded ? 0 : 1,
y: projectSidebarFolded ? -6 : 0,
}}
transition={PROJECT_SIDEBAR_FOLD_SPRING}
aria-hidden={projectSidebarFolded}
style={{
pointerEvents: projectSidebarFolded ? 'none' : undefined,
}}
>
<NavList
className="min-h-0 mt-6 flex flex-1 flex-col"
sessions={navSessions}
activeSessionId={
activeWorkspaceTab === 'session'
? chatStore.activeTaskId
: null
}
onSessionClick={(id) => {
chatStore.setActiveTaskId(id);
setActiveWorkspaceTab('session');
}}
onDeleteSession={handleDeleteSession}
onShowAll={handleShowAllSessions}
showAllActive={activeWorkspaceTab === 'sessions'}
/>
</motion.div>
</div>
<BottomAction

View file

@ -17,25 +17,41 @@ import { AnimatePresence, motion } 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 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 rendered below the header when the accordion is collapsed. */
collapsedPreview?: ReactNode;
children: 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 [open, setOpen] = useState(defaultOpen);
const isRenderProp = typeof children === 'function';
const dynamicBody = isRenderProp
? (children as (s: SidePanelAccordionRenderArgs) => ReactNode)({ open })
: null;
return (
<div className="rounded-xl bg-ds-bg-neutral-default-default border-ds-border-neutral-subtle-disabled ease-in-out min-w-0 z-10 flex shrink-0 flex-col overflow-hidden border border-solid transition-all duration-200">
<div className="rounded-xl bg-ds-bg-neutral-default-default border-ds-border-neutral-subtle-disabled min-w-0 z-10 flex shrink-0 flex-col overflow-hidden border border-solid">
<button
type="button"
onClick={() => setOpen((v) => !v)}
@ -52,31 +68,42 @@ export function SidePanelAccordionBox({
</div>
<ChevronDown
className={cn(
'text-ds-text-neutral-muted-default h-4 w-4 shrink-0 transition-transform duration-200',
'text-ds-text-neutral-muted-default h-4 w-4 ease-out shrink-0 transition-transform duration-200',
open ? 'rotate-0' : '-rotate-90'
)}
aria-hidden
/>
</button>
{!open && collapsedPreview ? (
<div className="px-3 pb-3 w-full">{collapsedPreview}</div>
) : null}
<AnimatePresence initial={false}>
{open ? (
<motion.div
key="content"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="px-3 pb-3 w-full">{children}</div>
</motion.div>
) : null}
</AnimatePresence>
{isRenderProp ? (
<motion.div
layout
transition={LAYOUT_TRANSITION}
className="min-h-0 w-full overflow-hidden"
>
{dynamicBody != null ? (
<div className="px-2 pb-3 w-full">{dynamicBody}</div>
) : null}
</motion.div>
) : (
<AnimatePresence initial={false}>
{open ? (
<motion.div
key="static-content"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: { duration: 0.22, ease: CONTENT_EASE },
opacity: { duration: 0.16, ease: CONTENT_EASE },
}}
className="overflow-hidden"
>
<div className="px-2 pb-3 w-full">{children as ReactNode}</div>
</motion.div>
) : null}
</AnimatePresence>
)}
</div>
);
}

View file

@ -79,7 +79,8 @@ function iconFor(file: FileInfo): LucideIcon {
interface AgentFolderSectionProps {
title: string;
files: FileInfo[];
onOpenFile?: (file: FileInfo) => void;
/** Opens the Folder workspace tab and selects this file (parent supplies navigation). */
onOpenFile: (file: FileInfo) => void;
}
export function AgentFolderSection({
@ -126,7 +127,7 @@ export function AgentFolderSection({
className={cn('text-ds-icon-neutral-default-default')}
/>
}
onClick={onOpenFile ? () => onOpenFile(file) : undefined}
onClick={() => onOpenFile(file)}
>
{file.name || file.path}
</SidePanelListRow>

View file

@ -67,8 +67,8 @@ 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 relative inline-flex shrink-0 items-center justify-center self-center">
<Bot className="h-6 w-6" strokeWidth={2} aria-hidden />
<div className="h-6 w-6 text-ds-text-neutral-muted-default bg-ds-bg-neutral-subtle-default relative inline-flex shrink-0 items-center justify-center self-center">
<Bot className="h-4 w-4" strokeWidth={2} aria-hidden />
{subIcon != null && (
<span className="-right-1 -top-1 absolute inline-flex items-center justify-center [&_svg]:shrink-0">
{subIcon}
@ -85,6 +85,7 @@ function AgentRow({ agent }: { agent: Agent }) {
return (
<SidePanelListRow
className="rounded-lg bg-ds-bg-neutral-subtle-default"
leading={<AgentLeadingIcon agentType={agent.type} />}
disabled={!active}
>
@ -93,6 +94,27 @@ 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">
<AnimatePresence initial={false} mode="popLayout">
{agents.map((agent) => (
<motion.li
key={agent.agent_id}
layout
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<AgentRow agent={agent} />
</motion.li>
))}
</AnimatePresence>
</motion.ul>
);
}
interface AgentPoolSectionProps {
title: string;
agents: Agent[];
@ -108,46 +130,19 @@ export function AgentPoolSection({ title, agents }: AgentPoolSectionProps) {
</div>
);
const collapsedPreview =
activeAgents.length > 0 ? (
<ul className="p-0 m-0 space-y-0.5 list-none">
<AnimatePresence initial={false}>
{activeAgents.map((agent) => (
<motion.li
key={agent.agent_id}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<AgentRow agent={agent} />
</motion.li>
))}
</AnimatePresence>
</ul>
) : null;
return (
<SidePanelAccordionBox title={title} collapsedPreview={collapsedPreview}>
{ordered.length === 0 ? (
emptyState
) : (
<ul className="p-0 m-0 space-y-0.5 list-none">
<AnimatePresence initial={false}>
{ordered.map((agent) => (
<motion.li
key={agent.agent_id}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<AgentRow agent={agent} />
</motion.li>
))}
</AnimatePresence>
</ul>
)}
<SidePanelAccordionBox title={title} defaultOpen={false}>
{({ open }) => {
if (ordered.length === 0) {
return open ? emptyState : null;
}
if (!open) {
return activeAgents.length > 0 ? (
<AgentList agents={activeAgents} />
) : null;
}
return <AgentList agents={ordered} />;
}}
</SidePanelAccordionBox>
);
}

View file

@ -17,6 +17,7 @@ import {
CategoryLabel,
SidePanelListRow,
} from '@/components/SidePanelSections/primitives';
import { cn } from '@/lib/utils';
import { AnimatePresence, motion } from 'framer-motion';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
@ -66,7 +67,11 @@ export function ContextSection({ title, items }: ContextSectionProps) {
<div className="flex flex-col">
{grouped.map(({ category, items: groupItems }) => (
<div key={category} className="flex flex-col">
<CategoryLabel>{CATEGORY_LABEL[category]}</CategoryLabel>
<CategoryLabel
className={cn(category === 'tool' && 'text-label-xs')}
>
{CATEGORY_LABEL[category]}
</CategoryLabel>
<motion.ul layout className="p-0 m-0 space-y-0.5 list-none">
<AnimatePresence initial={false}>
{groupItems.map((item) => (
@ -81,6 +86,7 @@ export function ContextSection({ title, items }: ContextSectionProps) {
<SidePanelListRow
leading={item.icon}
onClick={item.onClick}
interactiveHover
>
{item.label}
</SidePanelListRow>

View file

@ -34,9 +34,9 @@ interface ProgressSectionProps {
export function ProgressSection({ title, subtasks }: ProgressSectionProps) {
const count = subtasks.length;
const collapsedPreview =
const collapsedStrip =
count > 0 ? (
<div className="gap-1 min-w-0 flex items-center overflow-hidden">
<div className="gap-1 min-w-0 mx-1 flex items-center overflow-hidden">
<AnimatePresence initial={false}>
{subtasks.map((task, idx) => (
<motion.span
@ -60,34 +60,42 @@ export function ProgressSection({ title, subtasks }: ProgressSectionProps) {
<SidePanelAccordionBox
title={title}
titleSuffix={count > 0 ? <CountPill count={count} /> : null}
collapsedPreview={collapsedPreview}
>
{count === 0 ? (
<div className="text-ds-text-neutral-muted-default text-body-sm px-1 py-1">
No subtasks yet
</div>
) : (
<motion.ul layout className="p-0 m-0 space-y-0.5 list-none">
<AnimatePresence initial={false}>
{subtasks.map((task) => (
<motion.li
key={task.id}
layout
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<SidePanelListRow
leading={<ProgressCircle done={isDone(task)} />}
{({ open }) => {
if (!open) {
return collapsedStrip;
}
if (count === 0) {
return (
<div className="text-ds-text-neutral-muted-default text-body-sm px-1 py-1">
No subtasks yet
</div>
);
}
return (
<motion.ul layout className="p-0 m-0 space-y-0.5 list-none">
<AnimatePresence initial={false}>
{subtasks.map((task) => (
<motion.li
key={task.id}
layout
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
{task.content}
</SidePanelListRow>
</motion.li>
))}
</AnimatePresence>
</motion.ul>
)}
<SidePanelListRow
className="hover:bg-ds-bg-neutral-subtle-default cursor-pointer"
leading={<ProgressCircle done={isDone(task)} />}
>
{task.content}
</SidePanelListRow>
</motion.li>
))}
</AnimatePresence>
</motion.ul>
);
}}
</SidePanelAccordionBox>
);
}

View file

@ -15,16 +15,105 @@
import { getToolkitIcon } from '@/lib/toolkitIcons';
import type { ContextItem } from './ContextSection';
function addHint(set: Set<string>, raw: string) {
const t = raw.trim().toLowerCase();
if (!t) return;
set.add(t);
const noToolkit = t.replace(/\s+toolkit\s*$/i, '').trim();
if (noToolkit && noToolkit !== t) set.add(noToolkit);
}
function collectWorkerHintSets(agents: Agent[]) {
const skillHints = new Set<string>();
const connectorHints = new Set<string>();
for (const agent of agents) {
const info = agent.workerInfo;
if (!info) continue;
const mcp: unknown = info.mcp_tools;
if (mcp && typeof mcp === 'object') {
const servers = (mcp as { mcpServers?: Record<string, unknown> })
.mcpServers;
if (servers && typeof servers === 'object') {
for (const name of Object.keys(servers)) {
addHint(connectorHints, name);
}
}
}
const selected: unknown = info.selectedTools;
if (!Array.isArray(selected)) continue;
for (const raw of selected) {
if (!raw || typeof raw !== 'object') continue;
const item = raw as {
name?: string;
key?: string;
toolkit?: string;
category?: { name?: string };
};
const label = item.name ?? item.key ?? item.toolkit;
if (!label) continue;
const categoryName = item.category?.name?.toLowerCase() ?? '';
const hints = categoryName === 'skill' ? skillHints : connectorHints;
addHint(hints, label);
if (item.toolkit) addHint(hints, item.toolkit);
}
}
return { skillHints, connectorHints };
}
function runtimeCategoryForToolkit(
toolkitName: string,
skillHints: Set<string>,
connectorHints: Set<string>
): ContextItem['category'] {
const tn = toolkitName.trim().toLowerCase();
if (tn.includes('mcp')) return 'connector';
if (skillHints.has(tn)) return 'skill';
const noTk = tn.replace(/\s+toolkit\s*$/i, '').trim();
if (skillHints.has(noTk)) return 'skill';
if (connectorHints.has(tn)) return 'connector';
if (connectorHints.has(noTk)) return 'connector';
return 'tool';
}
function forEachRuntimeToolkit(
agents: Agent[],
taskRunning: TaskInfo[] | undefined,
fn: (toolkitName: string) => void
) {
for (const agent of agents) {
for (const task of agent.tasks ?? []) {
for (const tk of task.toolkits ?? []) {
const name = tk.toolkitName;
if (!name || name === 'notice') continue;
fn(name);
}
}
}
for (const task of taskRunning ?? []) {
for (const tk of task.toolkits ?? []) {
const name = tk.toolkitName;
if (!name || name === 'notice') continue;
fn(name);
}
}
}
/**
* Derive a flat, deduplicated list of context items (skills / connectors /
* tools) from a set of agents' workerInfo.
* tools) from agents' workerInfo **and** runtime toolkit usage on subtasks.
*
* - `workerInfo.tools: string[]` category "tool"
* - `workerInfo.mcp_tools.mcpServers: { [name]: config }` category "connector"
* - `workerInfo.selectedTools: McpItem[]` with `category.name` category "skill"
* (fallback to connector if category is not "skill")
* - `workerInfo` configured tools, connectors, skills (as before)
* - `task.toolkits` / `taskRunning[].toolkits` from ACTIVATE_TOOLKIT records
* actual tool/skill/connector usage during the run (merged in, deduped)
*/
export function buildContextItems(agents: Agent[]): ContextItem[] {
export function buildContextItems(
agents: Agent[],
taskRunning?: TaskInfo[]
): ContextItem[] {
const seen = new Set<string>();
const out: ContextItem[] = [];
@ -35,6 +124,8 @@ export function buildContextItems(agents: Agent[]): ContextItem[] {
out.push(item);
};
const { skillHints, connectorHints } = collectWorkerHintSets(agents);
for (const agent of agents) {
const info = agent.workerInfo;
if (!info) continue;
@ -93,5 +184,19 @@ export function buildContextItems(agents: Agent[]): ContextItem[] {
}
}
forEachRuntimeToolkit(agents, taskRunning, (toolkitName) => {
const category = runtimeCategoryForToolkit(
toolkitName,
skillHints,
connectorHints
);
push({
id: toolkitName,
label: toolkitName,
category,
icon: getToolkitIcon(toolkitName, 16),
});
});
return out;
}

View file

@ -0,0 +1,36 @@
// ========= 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. =========
/**
* Output files from agent runs are stored on each plan subtask under
* `taskAssigning[].tasks[].fileList` (see `addFileList` on WRITE_FILE).
* The chat task's top-level `fileList` is not kept in sync, so the side
* panel must aggregate from assigning agents.
*/
export function collectSidePanelOutputFiles(
task:
| {
taskAssigning?: Agent[];
fileList?: FileInfo[];
}
| null
| undefined
): FileInfo[] {
if (!task) return [];
const nested = (task.taskAssigning ?? []).flatMap((agent) =>
agent.tasks.flatMap((t) => t.fileList ?? [])
);
const top = task.fileList ?? [];
return [...top, ...nested];
}

View file

@ -30,9 +30,20 @@ export function CountPill({ count }: { count: number }) {
/**
* Small muted category label for grouping list items.
*/
export function CategoryLabel({ children }: { children: ReactNode }) {
export function CategoryLabel({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className="text-ds-text-neutral-muted-default text-body-sm px-1 pb-1 pt-2 first:pt-0">
<div
className={cn(
'text-ds-text-neutral-muted-default text-body-sm px-1 pb-1 pt-2 first:pt-0',
className
)}
>
{children}
</div>
);
@ -44,6 +55,11 @@ type SidePanelListRowProps = {
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;
};
@ -52,15 +68,31 @@ type SidePanelListRowProps = {
* Rendered as a button when `onClick` is provided, otherwise a div.
*/
export const SidePanelListRow = forwardRef<HTMLElement, SidePanelListRowProps>(
({ leading, children, trailing, disabled, onClick, className }, ref) => {
(
{
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'
: onClick
? 'hover:bg-ds-bg-neutral-default-hover cursor-pointer'
: 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
);
@ -101,9 +133,8 @@ export const SidePanelListRow = forwardRef<HTMLElement, SidePanelListRowProps>(
SidePanelListRow.displayName = 'SidePanelListRow';
/**
* Progress circle. `done` shows a filled circle with a check icon; otherwise
* renders an empty outlined circle (all non-done states share the empty look,
* per design spec).
* Progress circle. Incomplete: neutral subtle fill so the ring reads on any
* panel background. Complete: success subtle fill, strong border and check.
*/
export function ProgressCircle({
done,
@ -115,15 +146,15 @@ export function ProgressCircle({
return (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-full border',
'inline-flex shrink-0 items-center justify-center rounded-full border border-solid',
done
? 'bg-ds-bg-status-completed-default-default border-ds-border-status-completed-default-default text-ds-text-brand-inverse-default'
: 'border-ds-border-neutral-default-default bg-transparent text-transparent'
? 'bg-ds-bg-success-subtle-default border-ds-border-success-strong-default text-ds-text-success-strong-default'
: 'border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default'
)}
style={{ width: size, height: size }}
aria-hidden
>
{done ? <Check size={Math.max(8, size - 6)} strokeWidth={3} /> : null}
{done ? <Check size={Math.max(8, size - 6)} strokeWidth={4} /> : null}
</span>
);
}

View file

@ -16,9 +16,11 @@ import { AgentFolderSection } from '@/components/SidePanelSections/AgentFolderSe
import { ContextSection } from '@/components/SidePanelSections/ContextSection';
import { ProgressSection } from '@/components/SidePanelSections/ProgressSection';
import { buildContextItems } from '@/components/SidePanelSections/buildContextItems';
import { collectSidePanelOutputFiles } from '@/components/SidePanelSections/collectSidePanelOutputFiles';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { cn } from '@/lib/utils';
import { useMemo } from 'react';
import { usePageTabStore } from '@/store/pageTabStore';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export interface SingleAgentSidePanelProps {
@ -31,16 +33,44 @@ export function SingleAgentSidePanel({
onToggleSidePanel: _onToggleSidePanel,
}: SingleAgentSidePanelProps) {
const { t } = useTranslation();
const { chatStore } = useChatStoreAdapter();
const { chatStore, projectStore } = useChatStoreAdapter();
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
const activeTask = chatStore?.activeTaskId
? chatStore.tasks[chatStore.activeTaskId]
: undefined;
const agents = activeTask?.taskAssigning ?? [];
const subtasks = agents[0]?.tasks ?? activeTask?.taskInfo ?? [];
const files = activeTask?.fileList ?? [];
const contextItems = useMemo(() => buildContextItems(agents), [agents]);
/** 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 ?? activeTask?.taskInfo ?? [];
const taskRunning = activeTask?.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, activeTask?.taskInfo, activeTask?.taskRunning]);
const files = useMemo(
() => collectSidePanelOutputFiles(activeTask),
[activeTask]
);
const contextItems = useMemo(
() => buildContextItems(agents, activeTask?.taskRunning),
[agents, activeTask?.taskRunning]
);
const handleOpenAgentFile = useCallback(
(file: FileInfo) => {
if (!chatStore?.activeTaskId) return;
chatStore.setSelectedFile(chatStore.activeTaskId, file);
setActiveWorkspaceTab('inbox', {
clearInboxForProjectId: projectStore.activeProjectId ?? null,
});
},
[chatStore, projectStore.activeProjectId, setActiveWorkspaceTab]
);
if (!isSidePanelVisible) {
return null;
@ -73,6 +103,7 @@ export function SingleAgentSidePanel({
defaultValue: 'Agent Folder',
})}
files={files}
onOpenFile={handleOpenAgentFile}
/>
</div>
</div>

View file

@ -13,7 +13,12 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { CircleCheckBig, CircleSlash2, LoaderCircle } from 'lucide-react';
import {
Circle,
CircleCheckBig,
CircleSlash2,
LoaderCircle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
export type TaskStateType =
@ -74,7 +79,7 @@ export const TaskState = ({
{/* All */}
{all && (forceVisible || all > 0) ? (
<div
className={`group gap-xs px-2 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
className={`group gap-xs rounded-md px-2 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
isSelected('all')
? 'bg-ds-bg-neutral-subtle-default'
: 'bg-transparent'
@ -90,7 +95,7 @@ export const TaskState = ({
{/* Done */}
{done && (forceVisible || done > 0) ? (
<div
className={`group gap-xs px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
className={`group gap-xs rounded-md px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
isSelected('done') && 'bg-ds-bg-neutral-subtle-default'
} ${
clickable && 'cursor-pointer transition-opacity hover:opacity-80'
@ -117,7 +122,7 @@ export const TaskState = ({
{/* Reassigned */}
{reAssignTo && (forceVisible || reAssignTo > 0) ? (
<div
className={`group gap-xs px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
className={`group gap-xs rounded-md px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
isSelected('reassigned') && 'bg-ds-bg-neutral-subtle-default'
} ${
clickable && 'cursor-pointer transition-opacity hover:opacity-80'
@ -145,7 +150,7 @@ export const TaskState = ({
{/* Ongoing */}
{progress && (forceVisible || progress > 0) ? (
<div
className={`group gap-xs px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center ${
className={`group gap-xs rounded-md px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center ${
isSelected('ongoing') && 'bg-ds-bg-neutral-subtle-default'
} ${
clickable && 'cursor-pointer transition-opacity hover:opacity-80'
@ -176,7 +181,7 @@ export const TaskState = ({
{/* Failed */}
{failed && (forceVisible || failed > 0) ? (
<div
className={`group gap-xs px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
className={`group gap-xs rounded-md px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center transition-all duration-200 ${
isSelected('failed') && 'bg-ds-bg-neutral-subtle-default'
} ${
clickable && 'cursor-pointer transition-opacity hover:opacity-80'
@ -202,16 +207,16 @@ export const TaskState = ({
{/* Pending */}
{skipped && (forceVisible || skipped > 0) ? (
<div
className={`group gap-xs px-0.5 py-0.5 hover:bg-ds-bg-neutral-subtle-default flex items-center ${
className={`group gap-xs rounded-md px-0.5 py-0.5 hover:bg-ds-bg-status-pending-subtle-hover flex items-center ${
isSelected('pending')
? 'bg-ds-bg-neutral-subtle-default'
? 'bg-ds-bg-status-pending-subtle-default'
: 'bg-transparent'
} ${
clickable && 'cursor-pointer transition-opacity hover:opacity-80'
}`}
onClick={() => handleStateClick('pending')}
>
<LoaderCircle
<Circle
className={`text-ds-icon-neutral-muted-default group-hover:text-ds-icon-status-pending-default-default h-[10px] w-[10px] ${
(isSelected('pending') || forceVisible) &&
'text-ds-icon-status-pending-default-default'

View file

@ -41,11 +41,11 @@ import {
Download,
House,
Minus,
Plus,
Settings,
Share,
Sparkles,
Square,
SquarePen,
TagIcon,
X,
} from 'lucide-react';
@ -126,8 +126,12 @@ function HeaderWin() {
const location = useLocation();
const { canGoBack, canGoForward } = useStackNavigationBounds();
//Get Chatstore for the active project's task
const { chatStore, projectStore } = useChatStoreAdapter();
const { chatStore } = useChatStoreAdapter();
const { chatPanelPosition, setChatPanelPosition } = usePageTabStore();
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
const requestWorkspaceChatFocus = usePageTabStore(
(s) => s.requestWorkspaceChatFocus
);
const historySidebarOpen = useSidebarStore((s) => s.isOpen);
const toggleHistorySidebar = useSidebarStore((s) => s.toggle);
const appearance = useAuthStore((state) => state.appearance);
@ -182,10 +186,18 @@ function HeaderWin() {
return path === '/history' || path.endsWith('/history');
}, [location.pathname]);
const createNewProject = () => {
projectStore.createProject('new project');
navigate('/');
};
const openWorkspaceNewTask = useCallback(() => {
if (location.pathname !== '/') {
navigate('/');
}
setActiveWorkspaceTab('workforce');
requestWorkspaceChatFocus();
}, [
location.pathname,
navigate,
requestWorkspaceChatFocus,
setActiveWorkspaceTab,
]);
const summaryTask =
chatStore?.tasks[chatStore?.activeTaskId as string]?.summaryTask;
@ -354,42 +366,48 @@ function HeaderWin() {
</TooltipSimple>
</div>
{location.pathname === '/' && (
<div className="no-drag ease-out animate-in fade-in-0 inline-flex items-stretch overflow-hidden rounded-full duration-200">
<div className="no-drag gap-1 inline-flex items-center">
<div className="ease-out animate-in fade-in-0 inline-flex items-stretch overflow-hidden rounded-full duration-200">
<TooltipSimple
content={
activeTaskTitle === t('layout.new-project')
? t('layout.new-project')
: activeTaskTitle
}
side="bottom"
align="center"
>
<button
id="active-task-title-btn"
type="button"
className="no-drag min-w-0 px-2 text-label-sm font-bold focus-visible:ring-ds-ring-brand-default-focus/50 !text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-default-hover active:bg-ds-bg-neutral-default-active flex min-h-[28px] max-w-[300px] flex-1 items-center text-left outline-none focus-visible:ring-[3px]"
onClick={toggleHistorySidebar}
aria-expanded={historySidebarOpen}
aria-haspopup="dialog"
>
<span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
{activeTaskTitle}
</span>
</button>
</TooltipSimple>
</div>
<TooltipSimple
content={
activeTaskTitle === t('layout.new-project')
? t('layout.new-project')
: activeTaskTitle
}
content={t('layout.add-new-task')}
side="bottom"
align="center"
>
<button
id="active-task-title-btn"
type="button"
className="no-drag min-w-0 px-2 text-label-sm font-bold focus-visible:ring-ds-ring-brand-default-focus/50 !text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-default-hover active:bg-ds-bg-neutral-default-active flex min-h-[28px] max-w-[300px] flex-1 items-center text-left outline-none focus-visible:ring-[3px]"
onClick={toggleHistorySidebar}
aria-expanded={historySidebarOpen}
aria-haspopup="dialog"
<Button
variant="ghost"
size="icon"
className="no-drag rounded-full"
onClick={openWorkspaceNewTask}
aria-label={t('layout.add-new-task')}
>
<span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
{activeTaskTitle}
</span>
</button>
</TooltipSimple>
<TooltipSimple
content={t('layout.new-project')}
side="bottom"
align="center"
>
<button
type="button"
className="no-drag w-8 focus-visible:ring-ds-ring-brand-default-focus/50 !text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-default-hover active:bg-ds-bg-neutral-default-active box-border flex min-h-[28px] shrink-0 items-center justify-center outline-none focus-visible:ring-[3px]"
onClick={createNewProject}
aria-label={t('layout.new-project')}
>
<Plus className="h-4 w-4 shrink-0" aria-hidden />
</button>
<SquarePen
className="h-4 w-4 text-[color:var(--ds-icon-neutral-default-default)]"
aria-hidden
/>
</Button>
</TooltipSimple>
</div>
)}

View file

@ -480,13 +480,10 @@ export const TriggerDialog: React.FC<TriggerDialogProps> = ({
}}
className="rounded-2xl bg-ds-bg-neutral-muted-disabled w-full"
>
<TabsList
variant="outline"
className="rounded-t-2xl border-ds-border-neutral-default-default px-4 w-full border-x-0 border-t-0 border-b-[0.5px] border-solid"
>
<TabsList variant="default" className="w-full">
<TabsTrigger
value="schedule"
className="flex-1"
className="text-body-sm gap-2 flex-1"
disabled={!!selectedTrigger}
>
<AlarmClockIcon className="h-4 w-4" />
@ -494,7 +491,7 @@ export const TriggerDialog: React.FC<TriggerDialogProps> = ({
</TabsTrigger>
<TabsTrigger
value="app"
className="flex-1"
className="text-body-sm gap-2 flex-1"
disabled={!!selectedTrigger}
>
<CableIcon className="h-4 w-4" />

View file

@ -32,6 +32,21 @@ export interface AgentDisplayInfo {
bgColorLight: string;
}
/**
* Classes for the small top-right role badge on agent tiles. Must be full literal
* strings (including `!`) so Tailwind emits them, and `!text-*` beats
* `button .lucide` in `src/style/index.css`.
*/
export const WORKFLOW_AGENT_SUB_ICON_CLASS: Record<WorkflowAgentType, string> =
{
developer_agent:
'!h-[10px] !w-[10px] shrink-0 !text-ds-text-terminal-default-default',
browser_agent: '!h-[10px] !w-[10px] shrink-0 !text-blue-700',
document_agent: '!h-[10px] !w-[10px] shrink-0 !text-yellow-700',
multi_modal_agent: '!h-[10px] !w-[10px] shrink-0 !text-fuchsia-700',
social_media_agent: '!h-[10px] !w-[10px] shrink-0 !text-purple-700',
};
export const agentMap: Record<WorkflowAgentType, AgentDisplayInfo> = {
developer_agent: {
name: 'Developer Agent',

View file

@ -635,7 +635,11 @@ export function Node({ id, data }: NodeProps) {
? 'bg-ds-bg-status-error-subtle-default hover:bg-ds-bg-status-error-subtle-hover'
: task.status === TaskStatus.RUNNING
? 'bg-ds-bg-status-running-subtle-default hover:bg-ds-bg-status-running-subtle-hover'
: 'bg-ds-bg-status-running-subtle-default hover:bg-ds-bg-status-running-subtle-hover';
: task.status === TaskStatus.SKIPPED ||
task.status === TaskStatus.WAITING ||
task.status === TaskStatus.EMPTY
? 'bg-ds-bg-status-pending-subtle-default hover:bg-ds-bg-status-pending-subtle-hover'
: 'bg-ds-bg-status-running-subtle-default hover:bg-ds-bg-status-running-subtle-hover';
const taskTextClass = task.reAssignTo
? 'text-ds-text-status-blocked-default-default'
: task.status === TaskStatus.COMPLETED
@ -646,12 +650,11 @@ export function Node({ id, data }: NodeProps) {
? 'text-ds-text-status-running-default-default'
: task.status === TaskStatus.BLOCKED
? 'text-ds-text-status-blocked-default-default'
: task.status === TaskStatus.SKIPPED
? 'text-ds-text-status-skipped-default-default'
: task.status === TaskStatus.WAITING ||
task.status === TaskStatus.EMPTY
? 'text-ds-text-status-pending-default-default'
: 'text-ds-text-status-running-default-default';
: task.status === TaskStatus.SKIPPED ||
task.status === TaskStatus.WAITING ||
task.status === TaskStatus.EMPTY
? 'text-ds-text-status-pending-default-default'
: 'text-ds-text-status-running-default-default';
return (
<div
onClick={() => {
@ -691,7 +694,11 @@ export function Node({ id, data }: NodeProps) {
? '!border-ds-border-neutral-subtle-focus'
: task.status === TaskStatus.BLOCKED
? '!border-ds-border-status-blocked-subtle-focus'
: '!border-ds-border-neutral-subtle-focus'
: task.status === TaskStatus.SKIPPED ||
task.status === TaskStatus.WAITING ||
task.status === TaskStatus.EMPTY
? '!border-ds-border-status-pending-default-hover'
: '!border-ds-border-neutral-subtle-focus'
: 'border-transparent'
}`}
>
@ -719,7 +726,7 @@ export function Node({ id, data }: NodeProps) {
{task.status === TaskStatus.SKIPPED && (
<LoaderCircle
size={16}
className="text-ds-icon-status-skipped-default-default"
className="text-ds-icon-status-pending-default-default"
/>
)}
{task.status === TaskStatus.COMPLETED && (

View file

@ -372,7 +372,11 @@ export function AgentDetailPane({
? 'bg-ds-bg-status-running-subtle-default'
: task.status === TaskStatus.BLOCKED
? 'bg-ds-bg-status-blocked-subtle-default'
: 'bg-ds-bg-status-running-subtle-default',
: task.status === TaskStatus.SKIPPED ||
task.status === TaskStatus.WAITING ||
task.status === TaskStatus.EMPTY
? 'bg-ds-bg-status-pending-subtle-default'
: 'bg-ds-bg-status-running-subtle-default',
task.status === TaskStatus.COMPLETED
? 'hover:border-ds-border-status-completed-default-focus'
: task.status === TaskStatus.FAILED
@ -381,7 +385,11 @@ export function AgentDetailPane({
? 'hover:border-ds-border-neutral-strong-default'
: task.status === TaskStatus.BLOCKED
? 'hover:border-ds-border-status-blocked-default-focus'
: 'hover:border-ds-border-neutral-default-focus',
: task.status === TaskStatus.SKIPPED ||
task.status === TaskStatus.WAITING ||
task.status === TaskStatus.EMPTY
? 'hover:border-ds-border-status-pending-default-hover'
: 'hover:border-ds-border-neutral-default-focus',
'border-transparent'
)}
>
@ -410,7 +418,7 @@ export function AgentDetailPane({
{task.status === TaskStatus.SKIPPED && (
<LoaderCircle
size={16}
className="text-ds-icon-neutral-muted-default"
className="text-ds-icon-status-pending-default-default"
/>
)}
{task.status === TaskStatus.COMPLETED && (
@ -435,7 +443,7 @@ export function AgentDetailPane({
task.status === TaskStatus.WAITING) && (
<Circle
size={16}
className="text-ds-icon-neutral-muted-default"
className="text-ds-icon-status-pending-default-default"
/>
)}
</>

View file

@ -15,6 +15,7 @@
import { AgentFolderSection } from '@/components/SidePanelSections/AgentFolderSection';
import { AgentPoolSection } from '@/components/SidePanelSections/AgentPoolSection';
import { buildContextItems } from '@/components/SidePanelSections/buildContextItems';
import { collectSidePanelOutputFiles } from '@/components/SidePanelSections/collectSidePanelOutputFiles';
import { ContextSection } from '@/components/SidePanelSections/ContextSection';
import { ProgressSection } from '@/components/SidePanelSections/ProgressSection';
import { Button } from '@/components/ui/button';
@ -22,8 +23,9 @@ import { TooltipSimple } from '@/components/ui/tooltip';
import ExpandedOverlay from '@/components/Workforce/ExpandedOverlay';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { cn } from '@/lib/utils';
import { usePageTabStore } from '@/store/pageTabStore';
import { Maximize2, X } from 'lucide-react';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export const WORKFORCE_MAIN_SURFACE_CLASS =
@ -50,15 +52,43 @@ export function WorkforceSidePanel({
onCloseExpandedOverlay,
}: WorkforceSidePanelProps) {
const { t } = useTranslation();
const { chatStore } = useChatStoreAdapter();
const { chatStore, projectStore } = useChatStoreAdapter();
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
const activeTask = chatStore?.activeTaskId
? chatStore.tasks[chatStore.activeTaskId]
: undefined;
const agents = activeTask?.taskAssigning ?? [];
const subtasks = activeTask?.taskInfo ?? [];
const files = activeTask?.fileList ?? [];
const contextItems = useMemo(() => buildContextItems(agents), [agents]);
/** Subtask status is updated in `taskRunning` (e.g. TASK_STATE); `taskInfo` keeps plan text/order. */
const subtasks = useMemo(() => {
const taskInfo = activeTask?.taskInfo ?? [];
const taskRunning = activeTask?.taskRunning ?? [];
if (taskRunning.length === 0) return taskInfo;
return taskInfo.map((t) => {
const live = taskRunning.find((r) => r.id === t.id);
if (!live) return t;
return { ...t, ...live, content: t.content || live.content };
});
}, [activeTask?.taskInfo, activeTask?.taskRunning]);
const files = useMemo(
() => collectSidePanelOutputFiles(activeTask),
[activeTask]
);
const contextItems = useMemo(
() => buildContextItems(agents, activeTask?.taskRunning),
[agents, activeTask?.taskRunning]
);
const handleOpenAgentFile = useCallback(
(file: FileInfo) => {
if (!chatStore?.activeTaskId) return;
chatStore.setSelectedFile(chatStore.activeTaskId, file);
setActiveWorkspaceTab('inbox', {
clearInboxForProjectId: projectStore.activeProjectId ?? null,
});
},
[chatStore, projectStore.activeProjectId, setActiveWorkspaceTab]
);
return (
<>
@ -130,6 +160,7 @@ export function WorkforceSidePanel({
defaultValue: 'Agent Folder',
})}
files={files}
onOpenFile={handleOpenAgentFile}
/>
</div>
</div>

View file

@ -28,7 +28,11 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { TooltipSimple } from '@/components/ui/tooltip';
import { agentMap, type WorkflowAgentType } from '@/components/WorkFlow/agents';
import {
agentMap,
WORKFLOW_AGENT_SUB_ICON_CLASS,
type WorkflowAgentType,
} from '@/components/WorkFlow/agents';
import { getAgentToolkitLabels } from '@/components/WorkFlow/agentToolkitLabels';
import { BASE_WORKFLOW_AGENTS } from '@/components/WorkFlow/baseWorkers';
import { cn } from '@/lib/utils';
@ -50,9 +54,8 @@ import { useTranslation } from 'react-i18next';
/** Sub icons aligned with `WorkforceMenu` / `ui/menu-button` → `MenuToggleItem` (top-right badge, 10px). */
function getWorkforceMenuStyleSubIcon(agentType: string): ReactNode {
const key = agentType as WorkflowAgentType;
if (!agentMap[key]) return null;
const textColor = agentMap[key].textColor;
const iconClass = cn('!h-[10px] !w-[10px] shrink-0', textColor);
const iconClass = WORKFLOW_AGENT_SUB_ICON_CLASS[key];
if (!iconClass) return null;
switch (key) {
case 'developer_agent':
return <CodeXml className={iconClass} />;

View file

@ -149,6 +149,9 @@ export default function Workspace() {
const attachesToSend =
JSON.parse(JSON.stringify(chatStore.tasks[taskId]?.attaches)) || [];
// Enter the live session immediately; task startup continues in the background.
setActiveWorkspaceTab('session');
try {
await chatStore.startTask(
taskId,
@ -162,8 +165,8 @@ export default function Workspace() {
chatStore.setHasWaitComfirm(taskId, true);
chatStore.setAttaches(taskId, []);
setMessage('');
setActiveWorkspaceTab('session');
} catch (err: unknown) {
setActiveWorkspaceTab('workforce');
console.error('Failed to start task:', err);
toast.error(
err instanceof Error

View file

@ -0,0 +1,174 @@
// ========= 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. =========
'use client';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
IconWrapper,
useAnimateIconContext,
type IconProps,
} from '@/components/ui/animate-ui/icons/icon';
type ClipboardListProps = IconProps<keyof typeof animations>;
const animations = {
default: {
rect: {},
path1: {},
path2: {
initial: {
pathLength: 1,
opacity: 1,
scale: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
scale: [1.1, 1],
transition: {
duration: 0.4,
ease: 'easeInOut',
},
},
},
path3: {
initial: {
pathLength: 1,
opacity: 1,
scale: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
scale: [1.1, 1],
transition: {
duration: 0.4,
ease: 'easeInOut',
delay: 0.2,
},
},
},
path4: {
initial: {
pathLength: 1,
opacity: 1,
scale: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
scale: [1.1, 1],
transition: {
duration: 0.4,
ease: 'easeInOut',
delay: 0.5,
},
},
},
path5: {
initial: {
pathLength: 1,
opacity: 1,
scale: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
scale: [1.1, 1],
transition: {
duration: 0.4,
ease: 'easeInOut',
delay: 0.7,
},
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: ClipboardListProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.rect
width="8"
height="4"
x="8"
y="2"
rx="1"
ry="1"
variants={variants.rect}
initial="initial"
animate={controls}
/>
<motion.path
d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"
variants={variants.path1}
initial="initial"
animate={controls}
/>
<motion.path
d="M8 11h.01"
variants={variants.path2}
initial="initial"
animate={controls}
/>
<motion.path
d="M12 11h4"
variants={variants.path3}
initial="initial"
animate={controls}
/>
<motion.path
d="M8 16h.01"
variants={variants.path4}
initial="initial"
animate={controls}
/>
<motion.path
d="M12 16h4"
variants={variants.path5}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function ClipboardList(props: ClipboardListProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
ClipboardList,
ClipboardList as ClipboardListIcon,
type ClipboardListProps as ClipboardListIconProps,
type ClipboardListProps,
};

View file

@ -343,7 +343,7 @@ const INVERSE = [
].join(' ');
const buttonVariants = cva(
'inline-flex items-center whitespace-nowrap border border-solid transition-all duration-200 ease-in-out disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 outline-none focus-visible:border-ds-border-brand-default-focus focus-visible:ring-ds-ring-brand-default-focus/50 focus-visible:ring-[3px] aria-invalid:ring-ds-ring-error-default-default/20 aria-invalid:border-ds-border-status-error-default-default shrink-0 cursor-pointer',
'inline-flex items-center whitespace-nowrap border border-solid transition-all duration-200 ease-in-out disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:!text-inherit outline-none focus-visible:border-ds-border-brand-default-focus focus-visible:ring-ds-ring-brand-default-focus/50 focus-visible:ring-[3px] aria-invalid:ring-ds-ring-error-default-default/20 aria-invalid:border-ds-border-status-error-default-default shrink-0 cursor-pointer',
{
variants: {
variant: {

View file

@ -43,7 +43,7 @@ const Switch = React.forwardRef<
>(({ className, size = 'default', style, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer focus-visible:ring-ds-ring-brand-default-focus focus-visible:ring-offset-ds-bg-neutral-subtle-default data-[state=checked]:bg-ds-bg-status-completed-default-default data-[state=unchecked]:bg-ds-bg-neutral-default-default inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
'peer focus-visible:ring-ds-ring-brand-default-focus focus-visible:ring-offset-ds-bg-neutral-subtle-default data-[state=checked]:bg-ds-bg-status-completed-default-default data-[state=unchecked]:bg-ds-bg-neutral-subtle-default shadow-sm inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
sizeClasses[size].root,
className
)}
@ -53,7 +53,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
'bg-ds-text-brand-inverse-default data-[state=unchecked]:translate-x-0 pointer-events-none block rounded-full shadow-none ring-0 transition-transform',
'bg-ds-text-brand-inverse-default data-[state=unchecked]:translate-x-0 shadow-lg pointer-events-none block rounded-full ring-0 transition-transform',
sizeClasses[size].thumb
)}
/>

View file

@ -18,8 +18,10 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
export type TabsVariant = 'default' | 'outline' | 'border';
// Context for variant
const TabsContext = React.createContext<{ variant?: 'default' | 'outline' }>({
const TabsContext = React.createContext<{ variant?: TabsVariant }>({
variant: 'default',
});
@ -27,52 +29,88 @@ const TabsContext = React.createContext<{ variant?: 'default' | 'outline' }>({
const tabsTriggerClassName =
'ring-offset-ds-bg-neutral-subtle-default focus-visible:ring-ds-ring-brand-default-focus gap-1 rounded-xl bg-ds-bg-neutral-strong-default px-2 py-1 text-body-sm font-semibold text-ds-text-neutral-default-default data-[state=active]:bg-ds-bg-neutral-subtle-default data-[state=active]:text-ds-text-neutral-default-default data-[state=active]:shadow-sm inline-flex items-center justify-center whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:text-ds-icon-neutral-default-default';
/**
* Transparent triggers + hover chip (HistoryTabsNav); active selection is shown
* by the animated bar under the tab row (TabsList), not a border on the trigger.
*/
const tabsTriggerBorderClassName =
'ring-offset-ds-bg-neutral-default-default focus-visible:ring-ds-ring-brand-default-focus inline-flex h-8 min-h-8 shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-lg border border-solid border-transparent bg-transparent px-2 text-label-sm font-bold text-ds-text-neutral-muted-default transition-colors hover:bg-ds-bg-neutral-subtle-default hover:text-ds-text-neutral-default-default hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] hover:ring-1 hover:ring-ds-border-neutral-default-default focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none data-[state=active]:bg-transparent data-[state=active]:text-ds-text-neutral-default-default data-[state=active]:shadow-none data-[state=active]:ring-0 data-[state=active]:hover:bg-ds-bg-neutral-subtle-default disabled:pointer-events-none disabled:opacity-50 [&_svg]:text-ds-icon-neutral-default-default';
/** Gap (px) between tab row and underline — matches HistoryTabsNav. */
const BORDER_TAB_UNDERLINE_GAP_PX = 8;
const Tabs = TabsPrimitive.Root;
type TabsListProps = React.ComponentPropsWithoutRef<
typeof TabsPrimitive.List
> & {
variant?: 'default' | 'outline';
variant?: TabsVariant;
};
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
TabsListProps
>(({ className, variant = 'default', ...props }, ref) => {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const tabsListRef = React.useRef<React.ElementRef<
typeof TabsPrimitive.List
> | null>(null) as React.MutableRefObject<React.ElementRef<
typeof TabsPrimitive.List
> | null>;
const [sliderStyle, setSliderStyle] = React.useState({ left: 0, width: 0 });
const [borderBarStyle, setBorderBarStyle] = React.useState({
left: 0,
top: 0,
width: 0,
});
// Update slider position when active tab changes
// Update underline position when active tab changes (outline: inside list; border: below row, HistoryTabsNav-style)
React.useLayoutEffect(() => {
if (variant !== 'outline' || !tabsListRef.current) return;
if (
!tabsListRef.current ||
(variant !== 'outline' && variant !== 'border')
) {
return;
}
const updateSlider = () => {
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
const activeTab = tabsListRef.current?.querySelector(
'[data-state="active"][data-variant="outline"]'
) as HTMLElement;
const list = tabsListRef.current;
const wrap = wrapperRef.current;
if (!list) return;
if (activeTab && tabsListRef.current) {
const containerRect = tabsListRef.current.getBoundingClientRect();
if (variant === 'outline') {
const activeTab = list.querySelector(
'[data-state="active"][data-variant="outline"]'
) as HTMLElement | null;
if (activeTab) {
const containerRect = list.getBoundingClientRect();
const tabRect = activeTab.getBoundingClientRect();
setSliderStyle({
left: tabRect.left - containerRect.left,
width: tabRect.width,
});
}
return;
}
const activeTab = list.querySelector(
'[data-state="active"][data-variant="border"]'
) as HTMLElement | null;
if (activeTab && wrap) {
const wr = wrap.getBoundingClientRect();
const tabRect = activeTab.getBoundingClientRect();
setSliderStyle({
left: tabRect.left - containerRect.left,
setBorderBarStyle({
left: tabRect.left - wr.left,
top: tabRect.bottom - wr.top + BORDER_TAB_UNDERLINE_GAP_PX,
width: tabRect.width,
});
}
});
};
// Initial update
updateSlider();
// Watch for changes
const observer = new MutationObserver(updateSlider);
if (tabsListRef.current) {
observer.observe(tabsListRef.current, {
@ -82,7 +120,6 @@ const TabsList = React.forwardRef<
});
}
// Also listen for resize
window.addEventListener('resize', updateSlider);
return () => {
@ -109,14 +146,20 @@ const TabsList = React.forwardRef<
return (
<TabsContext.Provider value={{ variant }}>
<div className="relative">
<div
ref={wrapperRef}
className={cn('relative', variant === 'border' && 'pb-2')}
>
<TabsPrimitive.List
ref={combinedRef}
className={cn(
'rounded-xl bg-ds-bg-neutral-strong-default p-0.5 inline-flex items-center justify-center border border-solid',
variant === 'outline'
? 'border-ds-border-neutral-default-default relative'
: 'border-[color:var(--ds-bg-neutral-strong-default)]',
'inline-flex items-center justify-center',
variant === 'border' &&
'gap-2 p-0 rounded-none border-0 border-solid bg-transparent shadow-none',
variant === 'outline' &&
'rounded-xl border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default p-0.5 relative border border-solid',
variant === 'default' &&
'rounded-xl bg-ds-bg-neutral-strong-default p-0.5 border border-solid border-[color:var(--ds-bg-neutral-strong-default)]',
'data-[orientation=vertical]:flex data-[orientation=vertical]:h-full data-[orientation=vertical]:w-full data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch data-[orientation=vertical]:justify-start',
className
)}
@ -138,6 +181,24 @@ const TabsList = React.forwardRef<
}}
/>
)}
{variant === 'border' && borderBarStyle.width > 0 && (
<motion.div
aria-hidden
className="bg-ds-bg-brand-default-default h-0.5 pointer-events-none absolute z-10 rounded-full"
initial={false}
animate={{
left: borderBarStyle.left,
top: borderBarStyle.top,
width: borderBarStyle.width,
}}
transition={{
type: 'spring',
stiffness: 420,
damping: 34,
mass: 0.55,
}}
/>
)}
</div>
</TabsContext.Provider>
);
@ -147,7 +208,7 @@ TabsList.displayName = TabsPrimitive.List.displayName;
type TabsTriggerProps = React.ComponentPropsWithoutRef<
typeof TabsPrimitive.Trigger
> & {
variant?: 'default' | 'outline';
variant?: TabsVariant;
};
const TabsTrigger = React.forwardRef<
@ -156,11 +217,13 @@ const TabsTrigger = React.forwardRef<
>(({ className, variant: propVariant, ...props }, ref) => {
const { variant: contextVariant } = React.useContext(TabsContext);
const variant = propVariant || contextVariant || 'default';
const triggerBase =
variant === 'border' ? tabsTriggerBorderClassName : tabsTriggerClassName;
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(tabsTriggerClassName, className)}
className={cn(triggerBase, className)}
data-variant={variant}
data-value={props.value}
{...props}

View file

@ -131,7 +131,7 @@ export const checkboxTokenAliases = asCssVarMap({
export const switchTokenAliases = asCssVarMap({
'--switch-on-fill-track-fill':
'var(--ds-bg-status-completed-default-default)',
'--switch-off-fill-track-fill': 'var(--ds-bg-neutral-default-default)',
'--switch-off-fill-track-fill': 'var(--ds-bg-neutral-subtle-default)',
'--switch-on-fill-thumb-fill': 'var(--ds-text-brand-inverse-default)',
});

View file

@ -24,7 +24,7 @@ interface UseIsInViewOptions {
function useIsInView<T extends HTMLElement = HTMLElement>(
ref: React.Ref<T>,
options: UseIsInViewOptions = {}
): { ref: React.MutableRefObject<T | null>; isInView: boolean } {
) {
const { inView, inViewOnce = false, inViewMargin = '0px' } = options;
const localRef = React.useRef<T>(null);
React.useImperativeHandle(ref, () => localRef.current as T);

View file

@ -61,6 +61,8 @@
"new-project": "مشروع جديد",
"no-reply-received-task-continue": "لم يتم استلام رد، تستمر المهمة",
"splitting-tasks": "تقسيم المهام",
"working-on-tasks-for": "العمل على المهام لمدة {{time}}",
"worked-for": "عُمل لمدة {{time}}",
"start-task": "بدء المهمة",
"message-cannot-be-empty": "لا يمكن أن تكون الرسالة فارغة",
"remove-file": "إزالة الملف",

View file

@ -61,6 +61,8 @@
"new-project": "Neues Projekt",
"no-reply-received-task-continue": "Keine Antwort erhalten, Aufgabe wird fortgesetzt",
"splitting-tasks": "Aufgaben teilen",
"working-on-tasks-for": "Arbeitet an Aufgaben seit {{time}}",
"worked-for": "Gearbeitet für {{time}}",
"start-task": "Aufgabe starten",
"message-cannot-be-empty": "Nachricht darf nicht leer sein",
"remove-file": "Datei entfernen",

View file

@ -61,6 +61,8 @@
"new-project": "Untitled Project",
"no-reply-received-task-continue": "No reply received, task continue",
"splitting-tasks": "Splitting Tasks",
"working-on-tasks-for": "Working on tasks for {{time}}",
"worked-for": "Worked for {{time}}",
"start-task": "Start Task",
"message-cannot-be-empty": "Message cannot be empty",
"remove-file": "Remove file",

View file

@ -61,6 +61,8 @@
"new-project": "Nuevo proyecto",
"no-reply-received-task-continue": "No se recibió respuesta, la tarea continúa",
"splitting-tasks": "Dividiendo tareas",
"working-on-tasks-for": "Trabajando en tareas durante {{time}}",
"worked-for": "Trabajó durante {{time}}",
"start-task": "Iniciar tarea",
"message-cannot-be-empty": "El mensaje no puede estar vacío",
"remove-file": "Eliminar archivo",

View file

@ -61,6 +61,8 @@
"new-project": "Nouveau projet",
"no-reply-received-task-continue": "Aucune réponse reçue, la tâche continue",
"splitting-tasks": "Division des tâches",
"working-on-tasks-for": "Travaille sur les tâches depuis {{time}}",
"worked-for": "A travaillé pendant {{time}}",
"start-task": "Démarrer la tâche",
"message-cannot-be-empty": "Le message ne peut pas être vide",
"remove-file": "Supprimer le fichier",

View file

@ -61,6 +61,8 @@
"new-project": "Nuovo Progetto",
"no-reply-received-task-continue": "Nessuna risposta ricevuta, il compito continua",
"splitting-tasks": "Suddivisione dei compiti",
"working-on-tasks-for": "Lavoro alle attività da {{time}}",
"worked-for": "Ha lavorato per {{time}}",
"start-task": "Avvia compito",
"message-cannot-be-empty": "Il messaggio non può essere vuoto",
"remove-file": "Rimuovi file",

View file

@ -61,6 +61,8 @@
"new-project": "新規プロジェクト",
"no-reply-received-task-continue": "応答がないため、タスクを続行します",
"splitting-tasks": "タスク分割",
"working-on-tasks-for": "タスクを実行中 · {{time}}",
"worked-for": "作業時間 {{time}}",
"start-task": "タスク開始",
"message-cannot-be-empty": "メッセージは空にできません",
"remove-file": "ファイルを削除",

View file

@ -61,6 +61,8 @@
"new-project": "새 프로젝트",
"no-reply-received-task-continue": "응답이 없으므로 작업을 계속합니다.",
"splitting-tasks": "작업 분할",
"working-on-tasks-for": "작업 진행 중 · {{time}}",
"worked-for": "작업 시간 {{time}}",
"start-task": "작업 시작",
"message-cannot-be-empty": "메시지는 비워둘 수 없습니다",
"remove-file": "파일 제거",

View file

@ -61,6 +61,8 @@
"new-project": "Новый проект",
"no-reply-received-task-continue": "Ответ не получен, задача продолжается",
"splitting-tasks": "Разделение задач",
"working-on-tasks-for": "Работа над задачами · {{time}}",
"worked-for": "Работал {{time}}",
"start-task": "Начать задачу",
"message-cannot-be-empty": "Сообщение не может быть пустым",
"remove-file": "Удалить файл",

View file

@ -61,6 +61,8 @@
"new-project": "新项目",
"no-reply-received-task-continue": "没有收到回复,任务继续",
"splitting-tasks": "拆分任务",
"working-on-tasks-for": "正在处理任务 · {{time}}",
"worked-for": "已工作 {{time}}",
"start-task": "开始任务",
"message-cannot-be-empty": "消息不能为空",
"remove-file": "移除文件",

View file

@ -61,6 +61,8 @@
"new-project": "新項目",
"no-reply-received-task-continue": "沒有收到回复,任務繼續",
"splitting-tasks": "拆分任務",
"working-on-tasks-for": "正在處理任務 · {{time}}",
"worked-for": "已工作 {{time}}",
"start-task": "開始任務",
"message-cannot-be-empty": "訊息不能為空",
"remove-file": "移除文件",

View file

@ -160,7 +160,8 @@ type FixedShade =
| '900'
| '950';
const SYSTEM_STATUS_SHADE_BY_EMPHASIS: Record<
/** Light: pale surfaces → saturated accents (50 / 300 / 600 / 900). */
const SYSTEM_STATUS_SHADE_BY_EMPHASIS_LIGHT: Record<
Extract<Emphasis, 'subtle' | 'muted' | 'default' | 'strong'>,
Record<State, FixedShade>
> = {
@ -198,6 +199,49 @@ const SYSTEM_STATUS_SHADE_BY_EMPHASIS: Record<
},
};
/**
* Dark: reverse the ramp so subtle reads as a deep tint and strong as a
* light accent subtle950, muted600, default300, strong50 (mirrored
* hover/active steps vs. light).
*/
const SYSTEM_STATUS_SHADE_BY_EMPHASIS_DARK: Record<
Extract<Emphasis, 'subtle' | 'muted' | 'default' | 'strong'>,
Record<State, FixedShade>
> = {
subtle: {
default: '950',
hover: '900',
active: '800',
selected: '800',
focus: '900',
disabled: '950',
},
muted: {
default: '600',
hover: '500',
active: '400',
selected: '400',
focus: '500',
disabled: '600',
},
default: {
default: '300',
hover: '400',
active: '500',
selected: '500',
focus: '400',
disabled: '300',
},
strong: {
default: '50',
hover: '100',
active: '100',
selected: '100',
focus: '100',
disabled: '50',
},
};
// Per-state opacity used for the `transparent` emphasis. The surface color is
// the tone's base hue shown at these alphas so status chips read as "main
// color, faded" rather than a washed-out rendered hue.
@ -256,9 +300,14 @@ function getFixedShade(tone: Tone, shade: FixedShade): `#${string}` | null {
function getSystemStatusShade(
tone: Tone,
emphasis: Extract<Emphasis, 'subtle' | 'muted' | 'default' | 'strong'>,
state: State
state: State,
mode: Mode
): `#${string}` | null {
const shade = SYSTEM_STATUS_SHADE_BY_EMPHASIS[emphasis][state];
const table =
mode === 'dark'
? SYSTEM_STATUS_SHADE_BY_EMPHASIS_DARK
: SYSTEM_STATUS_SHADE_BY_EMPHASIS_LIGHT;
const shade = table[emphasis][state];
return getFixedShade(tone, shade);
}
@ -741,8 +790,10 @@ function buildSemanticTokens(
if (SYSTEM_STATUS_TONES.has(tone) && emph !== 'inverse') {
if (emph === 'transparent') {
const transparentShade: FixedShade =
contract.mode === 'dark' ? '300' : '600';
const baseHex =
getFixedShade(tone, '600') ??
getFixedShade(tone, transparentShade) ??
oklchToHex(toneBaseColor(tone, contract.mode, seed, element));
const stateAlpha =
SYSTEM_STATUS_TRANSPARENT_OPACITY_BY_STATE[state as State] ??
@ -761,7 +812,8 @@ function buildSemanticTokens(
Emphasis,
'subtle' | 'muted' | 'default' | 'strong'
>,
state
state,
contract.mode
);
if (statusShade) {
tokens[tokenKey] =

View file

@ -102,23 +102,17 @@ export default function Skills() {
<Tabs defaultValue="your-skills" className="w-full">
<div className="gap-4 border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default sticky top-[84px] z-10 flex w-full items-center justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
<TabsList
variant="outline"
className="h-auto flex-1 justify-start border-0 bg-transparent"
variant="border"
className="h-auto flex-1 justify-start"
>
<TabsTrigger
value="your-skills"
className="data-[state=active]:bg-transparent"
>
<TabsTrigger value="your-skills">
{t('agents.your-skills')}
</TabsTrigger>
<TabsTrigger
value="example-skills"
className="data-[state=active]:bg-transparent"
>
<TabsTrigger value="example-skills">
{t('agents.example-skills')}
</TabsTrigger>
</TabsList>
<div className="gap-2 flex items-center">
<div className="gap-2 mb-2 flex items-center">
<SearchInput
variant="icon"
value={searchQuery}

View file

@ -573,23 +573,17 @@ export default function SettingMCP() {
>
<div className="gap-4 border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default sticky top-[84px] z-10 flex w-full items-center justify-between border-x-0 border-t-0 border-b-[0.5px] border-solid">
<TabsList
variant="outline"
className="h-auto flex-1 justify-start border-0 bg-transparent"
variant="border"
className="h-auto flex-1 justify-start"
>
<TabsTrigger
value="mcp-tools"
className="data-[state=active]:bg-transparent"
>
<TabsTrigger value="mcp-tools">
{t('setting.mcp-and-tools')}
</TabsTrigger>
<TabsTrigger
value="your-mcp"
className="data-[state=active]:bg-transparent"
>
<TabsTrigger value="your-mcp">
{t('setting.your-own-mcps')}
</TabsTrigger>
</TabsList>
<div className="gap-2 flex items-center">
<div className="gap-2 mb-2 flex items-center">
<SearchInput
variant="icon"
value={searchQuery}

View file

@ -128,6 +128,8 @@ export default function Home() {
const shellPanelGroupImperativeRef = useRef<ImperativePanelGroupHandle>(null);
const projectSidebarPanelRef = useRef<ImperativePanelHandle>(null);
const applyingSidebarLayoutRef = useRef(false);
const sidebarLayoutAnimationFrameRef = useRef<number | null>(null);
const hasInitializedSidebarLayoutRef = useRef(false);
/** Expanded sidebar width in px; only user drag (or stored value) changes this — window resize adjusts % to keep this width. */
const sidebarWidthPxRef = useRef(readStoredSidebarWidthPx());
const persistSidebarWidthTimeoutRef = useRef<ReturnType<
@ -171,28 +173,83 @@ export default function Home() {
}, 250);
}, []);
const setShellPanelLayout = useCallback(
(layout: number[], animate: boolean) => {
const group = shellPanelGroupImperativeRef.current;
if (!group) return;
const target = layout.map(clampPct);
if (sidebarLayoutAnimationFrameRef.current != null) {
cancelAnimationFrame(sidebarLayoutAnimationFrameRef.current);
sidebarLayoutAnimationFrameRef.current = null;
}
const applyFinalLayout = () => {
group.setLayout(target);
requestAnimationFrame(() => {
applyingSidebarLayoutRef.current = false;
});
};
const current = group.getLayout();
const shouldAnimate =
animate &&
current.length === target.length &&
current.some((value, index) => Math.abs(value - target[index]) > 0.1);
applyingSidebarLayoutRef.current = true;
if (!shouldAnimate) {
applyFinalLayout();
return;
}
const from = [...current];
const durationMs = 260;
const start = performance.now();
const tick = (now: number) => {
const progress = Math.min(1, (now - start) / durationMs);
const eased = 1 - Math.pow(1 - progress, 3);
group.setLayout(
from.map((value, index) => value + (target[index] - value) * eased)
);
if (progress < 1) {
sidebarLayoutAnimationFrameRef.current = requestAnimationFrame(tick);
return;
}
sidebarLayoutAnimationFrameRef.current = null;
applyFinalLayout();
};
sidebarLayoutAnimationFrameRef.current = requestAnimationFrame(tick);
},
[]
);
/** Recompute sidebar % from fixed px so the rail does not grow/shrink when the window resizes. */
const applyExpandedSidebarLayout = useCallback(() => {
const shell = shellPanelGroupRef.current;
const group = shellPanelGroupImperativeRef.current;
if (!shell || !group) return;
if (usePageTabStore.getState().projectSidebarFolded) return;
const w = shell.getBoundingClientRect().width;
if (w <= 0) return;
const minPct = clampPct((SIDEBAR_MIN_PX / w) * 100);
const maxPct = clampPct((SIDEBAR_MAX_PX / w) * 100);
const px = Math.min(
SIDEBAR_MAX_PX,
Math.max(SIDEBAR_MIN_PX, sidebarWidthPxRef.current)
);
let pct = (px / w) * 100;
pct = Math.min(maxPct, Math.max(minPct, pct));
applyingSidebarLayoutRef.current = true;
group.setLayout([pct, 100 - pct]);
requestAnimationFrame(() => {
applyingSidebarLayoutRef.current = false;
});
}, []);
const applyExpandedSidebarLayout = useCallback(
(animate: boolean = false) => {
const shell = shellPanelGroupRef.current;
if (!shell) return;
if (usePageTabStore.getState().projectSidebarFolded) return;
const w = shell.getBoundingClientRect().width;
if (w <= 0) return;
const minPct = clampPct((SIDEBAR_MIN_PX / w) * 100);
const maxPct = clampPct((SIDEBAR_MAX_PX / w) * 100);
const px = Math.min(
SIDEBAR_MAX_PX,
Math.max(SIDEBAR_MIN_PX, sidebarWidthPxRef.current)
);
let pct = (px / w) * 100;
pct = Math.min(maxPct, Math.max(minPct, pct));
setShellPanelLayout([pct, 100 - pct], animate);
},
[setShellPanelLayout]
);
const handleShellPanelLayout = useCallback(
(sizes: number[]) => {
@ -240,26 +297,21 @@ export default function Home() {
/** Expanded: apply stored px width when leaving folded or on first paint. */
useLayoutEffect(() => {
if (projectSidebarFolded) return;
applyExpandedSidebarLayout();
applyExpandedSidebarLayout(hasInitializedSidebarLayoutRef.current);
hasInitializedSidebarLayoutRef.current = true;
}, [projectSidebarFolded, applyExpandedSidebarLayout]);
/** Folded: exact rail + main split (`setLayout`); update when shell width changes rail %. */
useLayoutEffect(() => {
if (!projectSidebarFolded) return;
const shell = shellPanelGroupRef.current;
const group = shellPanelGroupImperativeRef.current;
if (!shell || !group) return;
const w = shell.getBoundingClientRect().width;
if (w <= 0) return;
applyingSidebarLayoutRef.current = true;
const rail = sidebarPct.rail;
const main = Math.min(99, Math.max(0, 100 - rail));
group.setLayout([rail, main]);
requestAnimationFrame(() => {
applyingSidebarLayoutRef.current = false;
});
}, [projectSidebarFolded, sidebarPct.rail]);
setShellPanelLayout(
[rail, main],
hasInitializedSidebarLayoutRef.current && sidebarWidthPxRef.current > 0
);
hasInitializedSidebarLayoutRef.current = true;
}, [projectSidebarFolded, sidebarPct.rail, setShellPanelLayout]);
useEffect(() => {
const el = shellPanelGroupRef.current;
@ -296,6 +348,9 @@ export default function Home() {
useEffect(() => {
return () => {
if (sidebarLayoutAnimationFrameRef.current != null) {
cancelAnimationFrame(sidebarLayoutAnimationFrameRef.current);
}
if (persistSidebarWidthTimeoutRef.current) {
clearTimeout(persistSidebarWidthTimeoutRef.current);
}

View file

@ -60,7 +60,7 @@
"information": "#2563eb",
"status-running": "#2563eb",
"status-splitting": "#0284c7",
"status-pending": "#d97706",
"status-pending": "#4a69af",
"status-error": "#dc2626",
"status-reassigning": "#ea580c",
"status-completed": "#16a34a",
@ -82,7 +82,7 @@
"information": "#2563eb",
"status-running": "#2563eb",
"status-splitting": "#0284c7",
"status-pending": "#d97706",
"status-pending": "#4a69af",
"status-error": "#dc2626",
"status-reassigning": "#ea580c",
"status-completed": "#16a34a",
@ -172,17 +172,17 @@
"950": "#082f49"
},
"status-pending": {
"50": "#fffbeb",
"100": "#fef3c7",
"200": "#fde68a",
"300": "#fcd34d",
"400": "#fbbf24",
"500": "#f59e0b",
"600": "#d97706",
"700": "#b45309",
"800": "#92400e",
"900": "#78350f",
"950": "#451a03"
"50": "#e0e8f6",
"100": "#d0dcf1",
"200": "#b3c5e8",
"300": "#92abdc",
"400": "#7291cf",
"500": "#5778c0",
"600": "#4a69af",
"700": "#3f598f",
"800": "#374d79",
"900": "#314163",
"950": "#222d45"
},
"status-error": {
"50": "#fef2f2",

View file

@ -314,24 +314,46 @@ describe('themeTokens v2 engine', () => {
expect(successInverse).toBe('#ffffff');
});
it('maps system status background emphases to 50/300/600/900 shades', () => {
for (const mode of ['light', 'dark'] as const) {
const theme = buildThemeV2(
createDefaultThemeContractV2(mode, {
themeId: 'eigent',
contrast: 50,
}),
DEFAULT_THEME_CATALOG
it('maps system status background emphases to fixed shade steps (light vs dark)', () => {
const lightTheme = buildThemeV2(
createDefaultThemeContractV2('light', {
themeId: 'eigent',
contrast: 50,
}),
DEFAULT_THEME_CATALOG
);
const darkTheme = buildThemeV2(
createDefaultThemeContractV2('dark', {
themeId: 'eigent',
contrast: 50,
}),
DEFAULT_THEME_CATALOG
);
for (const tone of SYSTEM_STATUS_TONES) {
const scale = FIXED_SHADE_SCALES[tone];
expect(scale).toBeDefined();
expect(lightTheme.tokens[`bg.${tone}.subtle.default`]).toBe(
scale?.['50']
);
expect(lightTheme.tokens[`bg.${tone}.muted.default`]).toBe(
scale?.['300']
);
expect(lightTheme.tokens[`bg.${tone}.default.default`]).toBe(
scale?.['600']
);
expect(lightTheme.tokens[`bg.${tone}.strong.default`]).toBe(
scale?.['900']
);
for (const tone of SYSTEM_STATUS_TONES) {
const scale = FIXED_SHADE_SCALES[tone];
expect(scale).toBeDefined();
expect(theme.tokens[`bg.${tone}.subtle.default`]).toBe(scale?.['50']);
expect(theme.tokens[`bg.${tone}.muted.default`]).toBe(scale?.['300']);
expect(theme.tokens[`bg.${tone}.default.default`]).toBe(scale?.['600']);
expect(theme.tokens[`bg.${tone}.strong.default`]).toBe(scale?.['900']);
}
expect(darkTheme.tokens[`bg.${tone}.subtle.default`]).toBe(
scale?.['950']
);
expect(darkTheme.tokens[`bg.${tone}.muted.default`]).toBe(scale?.['600']);
expect(darkTheme.tokens[`bg.${tone}.default.default`]).toBe(
scale?.['300']
);
expect(darkTheme.tokens[`bg.${tone}.strong.default`]).toBe(scale?.['50']);
}
});