From e91cf31ff681f22b53460cdfdb67e7c8eef15dcc Mon Sep 17 00:00:00 2001 From: Douglas Date: Tue, 21 Apr 2026 20:09:02 +0100 Subject: [PATCH] update chatput process content --- package.json | 2 +- src/components/ChatBox/BottomBox/index.tsx | 2 +- .../ChatBox/MessageItem/UserMessageCard.tsx | 2 +- .../ChatBox/SplittingProgressRow.tsx | 144 +++++++ src/components/ChatBox/TaskBox/TaskCard.tsx | 85 +++- .../ChatBox/TaskWorkLogAccordion.tsx | 391 ++++++++++++++++++ src/components/ChatBox/TokenUtils.tsx | 13 + src/components/ChatBox/UserQueryGroup.tsx | 38 +- .../GroupedHistoryView/ProjectGroup.tsx | 74 ++-- src/components/HistorySidebar/index.tsx | 122 ++++-- .../ProjectPageSidebar/BottomAction.tsx | 8 +- src/components/ProjectPageSidebar/index.tsx | 61 +-- .../Session/SidePanelAccordionBox.tsx | 75 ++-- .../SidePanelSections/AgentFolderSection.tsx | 5 +- .../SidePanelSections/AgentPoolSection.tsx | 77 ++-- .../SidePanelSections/ContextSection.tsx | 8 +- .../SidePanelSections/ProgressSection.tsx | 64 +-- .../SidePanelSections/buildContextItems.ts | 117 +++++- .../collectSidePanelOutputFiles.ts | 36 ++ .../SidePanelSections/primitives.tsx | 55 ++- .../SingleAgent/SingleAgentSidePanel.tsx | 41 +- src/components/TaskState/index.tsx | 23 +- src/components/TopBar/index.tsx | 92 +++-- src/components/Trigger/TriggerDialog.tsx | 9 +- src/components/WorkFlow/agents.tsx | 15 + src/components/WorkFlow/node.tsx | 25 +- .../Workforce/FoldedPanel/AgentDetailPane.tsx | 16 +- .../Workforce/WorkforceSidePanel.tsx | 41 +- src/components/Workspace/FoldedAgentCard.tsx | 11 +- src/components/Workspace/index.tsx | 5 +- .../ui/animate-ui/icons/clipboard-list.tsx | 174 ++++++++ src/components/ui/button.tsx | 2 +- src/components/ui/switch.tsx | 4 +- src/components/ui/tabs.tsx | 109 +++-- src/components/ui/tokenAliases.ts | 2 +- src/hooks/use-is-in-view.tsx | 2 +- src/i18n/locales/ar/chat.json | 2 + src/i18n/locales/de/chat.json | 2 + src/i18n/locales/en-us/chat.json | 2 + src/i18n/locales/es/chat.json | 2 + src/i18n/locales/fr/chat.json | 2 + src/i18n/locales/it/chat.json | 2 + src/i18n/locales/ja/chat.json | 2 + src/i18n/locales/ko/chat.json | 2 + src/i18n/locales/ru/chat.json | 2 + src/i18n/locales/zh-Hans/chat.json | 2 + src/i18n/locales/zh-Hant/chat.json | 2 + src/lib/themeTokens/engine.ts | 62 ++- src/pages/Agents/Skills.tsx | 16 +- src/pages/Connectors/MCP.tsx | 16 +- src/pages/Home.tsx | 123 ++++-- src/style/tokens/base.color.json | 26 +- test/unit/lib/themeTokens/engine.v2.test.ts | 54 ++- 53 files changed, 1835 insertions(+), 434 deletions(-) create mode 100644 src/components/ChatBox/SplittingProgressRow.tsx create mode 100644 src/components/ChatBox/TaskWorkLogAccordion.tsx create mode 100644 src/components/SidePanelSections/collectSidePanelOutputFiles.ts create mode 100644 src/components/ui/animate-ui/icons/clipboard-list.tsx diff --git a/package.json b/package.json index a112aa90..3fffb951 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/ChatBox/BottomBox/index.tsx b/src/components/ChatBox/BottomBox/index.tsx index 9d49510f..1a656ff5 100644 --- a/src/components/ChatBox/BottomBox/index.tsx +++ b/src/components/ChatBox/BottomBox/index.tsx @@ -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 (
diff --git a/src/components/ChatBox/MessageItem/UserMessageCard.tsx b/src/components/ChatBox/MessageItem/UserMessageCard.tsx index 79f01f20..3a705def 100644 --- a/src/components/ChatBox/MessageItem/UserMessageCard.tsx +++ b/src/components/ChatBox/MessageItem/UserMessageCard.tsx @@ -128,7 +128,7 @@ export function UserMessageCard({ return (
-
+
{attaches && attaches.length > 0 && (
{(() => { diff --git a/src/components/ChatBox/SplittingProgressRow.tsx b/src/components/ChatBox/SplittingProgressRow.tsx new file mode 100644 index 00000000..e7730f52 --- /dev/null +++ b/src/components/ChatBox/SplittingProgressRow.tsx @@ -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(); + +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 ( +
+
+ +
+ + {t('chat.splitting-tasks')} + + + {formatSplittingElapsed(elapsedMs)} + + + {' '} + •{' '} + + + + Token + +
+ ); +} diff --git a/src/components/ChatBox/TaskBox/TaskCard.tsx b/src/components/ChatBox/TaskBox/TaskCard.tsx index 095b93af..e467a1ff 100644 --- a/src/components/ChatBox/TaskBox/TaskCard.tsx +++ b/src/components/ChatBox/TaskBox/TaskCard.tsx @@ -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(null); const [contentHeight, setContentHeight] = useState('auto'); const [selectedState, setSelectedState] = useState('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 (
-
+
@@ -289,7 +325,7 @@ export function TaskCard({ )} {taskType === 2 && (
- {(isExpanded || isAllTaskFinished) && ( + {isExpanded && (
{taskRunning?.filter( (task) => @@ -302,7 +338,13 @@ export function TaskCard({ +
+ {detail ? ( +
+ +
+ ) : null} +
+
+ ); +} + +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 ( +
+ + +
+
+ {segments.map((seg, index) => { + if (seg.type === 'agent') { + return ( +
+ {useTypewriterForAgent ? ( + + ) : ( +

+ {seg.text} +

+ )} +
+ ); + } + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/components/ChatBox/TokenUtils.tsx b/src/components/ChatBox/TokenUtils.tsx index 3464e299..042eb49d 100644 --- a/src/components/ChatBox/TokenUtils.tsx +++ b/src/components/ChatBox/TokenUtils.tsx @@ -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'; diff --git a/src/components/ChatBox/UserQueryGroup.tsx b/src/components/ChatBox/UserQueryGroup.tsx index fd91a7a0..0a7370b0 100644 --- a/src/components/ChatBox/UserQueryGroup.tsx +++ b/src/components/ChatBox/UserQueryGroup.tsx @@ -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 / non–single-agent turns). */ const AgentResultCard: React.FC<{ @@ -368,6 +369,17 @@ export const UserQueryGroup: React.FC = ({ )} + {taskCardVisible && activeTaskId && ( + + + + )} + {/* Other Messages */} {queryGroup.otherMessages.map((message) => { if (message.content.length > 0) { @@ -378,7 +390,7 @@ export const UserQueryGroup: React.FC = ({ 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" > = ({ onMarkdownRenderComplete={onTaskCompletionMarkdownReady} deferredFooter={ message.fileList?.length ? ( -
+
{message.fileList.map( (file: any, fileIndex: number) => ( = ({ '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" >
-
+
{file.name.split('.')[0]}
-
+
{file.type}
@@ -435,7 +447,7 @@ export const UserQueryGroup: React.FC = ({ 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" > = ({ 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" > = ({ 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 && (
@@ -575,14 +587,14 @@ export const UserQueryGroup: React.FC = ({ )} - {/* Skeleton for loading state */} - {isSkeletonPhase && ( + {isSkeletonPhase && activeTaskId && ( - + )} diff --git a/src/components/Dashboard/GroupedHistoryView/ProjectGroup.tsx b/src/components/Dashboard/GroupedHistoryView/ProjectGroup.tsx index af76101d..fd923fc1 100644 --- a/src/components/Dashboard/GroupedHistoryView/ProjectGroup.tsx +++ b/src/components/Dashboard/GroupedHistoryView/ProjectGroup.tsx @@ -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" > - - - {project.total_tokens - ? project.total_tokens.toLocaleString() - : '0'} + + + {formatCompactCount(project.total_tokens)} @@ -359,12 +366,15 @@ export default function ProjectGroup({ - - {project.task_count} + + + {formatCompactCount(project.task_count)} + @@ -372,11 +382,14 @@ export default function ProjectGroup({ - - {project.total_triggers || 0} + + + {formatCompactCount(project.total_triggers)} +
@@ -420,30 +433,32 @@ export default function ProjectGroup({
{/* Middle: Project, Trigger, Agent tags - Aligned to right */} -
+
- - - {project.total_tokens - ? project.total_tokens.toLocaleString() - : '0'} + + + {formatCompactCount(project.total_tokens)} - - {project.task_count} + + + {formatCompactCount(project.task_count)} + @@ -451,11 +466,14 @@ export default function ProjectGroup({ - - {project.total_triggers || 0} + + + {formatCompactCount(project.total_triggers)} +
diff --git a/src/components/HistorySidebar/index.tsx b/src/components/HistorySidebar/index.tsx index 4fc0ee5d..6c3493ec 100644 --- a/src/components/HistorySidebar/index.tsx +++ b/src/components/HistorySidebar/index.tsx @@ -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) => { 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" > - +
- - - - {(project.total_tokens || 0).toLocaleString()} + + + + {formatCompactCount(project.total_tokens)} - - - - - {project.task_count} + + + + + {formatCompactCount(project.task_count)} + + + + + + + + + {formatCompactCount(project.total_triggers)} @@ -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" > - +
- - - - {(project.total_tokens || 0).toLocaleString()} + + + + {formatCompactCount(project.total_tokens)} - - - - - {project.task_count} + + + + + {formatCompactCount(project.task_count)} + + + + + + + + + {formatCompactCount(project.total_triggers)} diff --git a/src/components/ProjectPageSidebar/BottomAction.tsx b/src/components/ProjectPageSidebar/BottomAction.tsx index e432de72..66e1994a 100644 --- a/src/components/ProjectPageSidebar/BottomAction.tsx +++ b/src/components/ProjectPageSidebar/BottomAction.tsx @@ -60,11 +60,15 @@ export function BottomAction({
- - {!projectSidebarFolded ? ( - - { - chatStore.setActiveTaskId(id); - setActiveWorkspaceTab('session'); - }} - onDeleteSession={handleDeleteSession} - onShowAll={handleShowAllSessions} - showAllActive={activeWorkspaceTab === 'sessions'} - /> - - ) : null} - + + { + chatStore.setActiveTaskId(id); + setActiveWorkspaceTab('session'); + }} + onDeleteSession={handleDeleteSession} + onShowAll={handleShowAllSessions} + showAllActive={activeWorkspaceTab === 'sessions'} + /> +
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 ( -
+
- {!open && collapsedPreview ? ( -
{collapsedPreview}
- ) : null} - - - {open ? ( - -
{children}
-
- ) : null} -
+ {isRenderProp ? ( + + {dynamicBody != null ? ( +
{dynamicBody}
+ ) : null} +
+ ) : ( + + {open ? ( + +
{children as ReactNode}
+
+ ) : null} +
+ )}
); } diff --git a/src/components/SidePanelSections/AgentFolderSection.tsx b/src/components/SidePanelSections/AgentFolderSection.tsx index 8b335049..7528c2e8 100644 --- a/src/components/SidePanelSections/AgentFolderSection.tsx +++ b/src/components/SidePanelSections/AgentFolderSection.tsx @@ -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} diff --git a/src/components/SidePanelSections/AgentPoolSection.tsx b/src/components/SidePanelSections/AgentPoolSection.tsx index 854abc13..620e2c54 100644 --- a/src/components/SidePanelSections/AgentPoolSection.tsx +++ b/src/components/SidePanelSections/AgentPoolSection.tsx @@ -67,8 +67,8 @@ function getAgentSubIcon(agentType: string): ReactNode { function AgentLeadingIcon({ agentType }: { agentType: string }) { const subIcon = getAgentSubIcon(agentType); return ( -
- +
+ {subIcon != null && ( {subIcon} @@ -85,6 +85,7 @@ function AgentRow({ agent }: { agent: Agent }) { return ( } disabled={!active} > @@ -93,6 +94,27 @@ function AgentRow({ agent }: { agent: Agent }) { ); } +function AgentList({ agents }: { agents: Agent[] }) { + return ( + + + {agents.map((agent) => ( + + + + ))} + + + ); +} + interface AgentPoolSectionProps { title: string; agents: Agent[]; @@ -108,46 +130,19 @@ export function AgentPoolSection({ title, agents }: AgentPoolSectionProps) {
); - const collapsedPreview = - activeAgents.length > 0 ? ( -
    - - {activeAgents.map((agent) => ( - - - - ))} - -
- ) : null; - return ( - - {ordered.length === 0 ? ( - emptyState - ) : ( -
    - - {ordered.map((agent) => ( - - - - ))} - -
- )} + + {({ open }) => { + if (ordered.length === 0) { + return open ? emptyState : null; + } + if (!open) { + return activeAgents.length > 0 ? ( + + ) : null; + } + return ; + }} ); } diff --git a/src/components/SidePanelSections/ContextSection.tsx b/src/components/SidePanelSections/ContextSection.tsx index e253ceda..cf398e01 100644 --- a/src/components/SidePanelSections/ContextSection.tsx +++ b/src/components/SidePanelSections/ContextSection.tsx @@ -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) {
{grouped.map(({ category, items: groupItems }) => (
- {CATEGORY_LABEL[category]} + + {CATEGORY_LABEL[category]} + {groupItems.map((item) => ( @@ -81,6 +86,7 @@ export function ContextSection({ title, items }: ContextSectionProps) { {item.label} diff --git a/src/components/SidePanelSections/ProgressSection.tsx b/src/components/SidePanelSections/ProgressSection.tsx index 0b7d9df1..43a3595d 100644 --- a/src/components/SidePanelSections/ProgressSection.tsx +++ b/src/components/SidePanelSections/ProgressSection.tsx @@ -34,9 +34,9 @@ interface ProgressSectionProps { export function ProgressSection({ title, subtasks }: ProgressSectionProps) { const count = subtasks.length; - const collapsedPreview = + const collapsedStrip = count > 0 ? ( -
+
{subtasks.map((task, idx) => ( 0 ? : null} - collapsedPreview={collapsedPreview} > - {count === 0 ? ( -
- No subtasks yet -
- ) : ( - - - {subtasks.map((task) => ( - - } + {({ open }) => { + if (!open) { + return collapsedStrip; + } + if (count === 0) { + return ( +
+ No subtasks yet +
+ ); + } + return ( + + + {subtasks.map((task) => ( + - {task.content} -
-
- ))} -
-
- )} + } + > + {task.content} + + + ))} +
+ + ); + }} ); } diff --git a/src/components/SidePanelSections/buildContextItems.ts b/src/components/SidePanelSections/buildContextItems.ts index a6cc066e..a534a4c9 100644 --- a/src/components/SidePanelSections/buildContextItems.ts +++ b/src/components/SidePanelSections/buildContextItems.ts @@ -15,16 +15,105 @@ import { getToolkitIcon } from '@/lib/toolkitIcons'; import type { ContextItem } from './ContextSection'; +function addHint(set: Set, 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(); + const connectorHints = new Set(); + + for (const agent of agents) { + const info = agent.workerInfo; + if (!info) continue; + + const mcp: unknown = info.mcp_tools; + if (mcp && typeof mcp === 'object') { + const servers = (mcp as { mcpServers?: Record }) + .mcpServers; + if (servers && typeof servers === 'object') { + for (const name of Object.keys(servers)) { + 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, + connectorHints: Set +): 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(); 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; } diff --git a/src/components/SidePanelSections/collectSidePanelOutputFiles.ts b/src/components/SidePanelSections/collectSidePanelOutputFiles.ts new file mode 100644 index 00000000..2e5bbd34 --- /dev/null +++ b/src/components/SidePanelSections/collectSidePanelOutputFiles.ts @@ -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]; +} diff --git a/src/components/SidePanelSections/primitives.tsx b/src/components/SidePanelSections/primitives.tsx index f891ac43..d5e1625c 100644 --- a/src/components/SidePanelSections/primitives.tsx +++ b/src/components/SidePanelSections/primitives.tsx @@ -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 ( -
+
{children}
); @@ -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( - ({ 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( 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 ( - {done ? : null} + {done ? : null} ); } diff --git a/src/components/SingleAgent/SingleAgentSidePanel.tsx b/src/components/SingleAgent/SingleAgentSidePanel.tsx index 58e15817..71e4ad6e 100644 --- a/src/components/SingleAgent/SingleAgentSidePanel.tsx +++ b/src/components/SingleAgent/SingleAgentSidePanel.tsx @@ -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} />
diff --git a/src/components/TaskState/index.tsx b/src/components/TaskState/index.tsx index 209c1be9..7385a11c 100644 --- a/src/components/TaskState/index.tsx +++ b/src/components/TaskState/index.tsx @@ -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) ? (
0) ? (
0) ? (
0) ? (
0) ? (
0) ? (
handleStateClick('pending')} > - 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() {
{location.pathname === '/' && ( -
+
+
+ + + +
- - - - + +
)} diff --git a/src/components/Trigger/TriggerDialog.tsx b/src/components/Trigger/TriggerDialog.tsx index 0cea8a1a..d81db4c5 100644 --- a/src/components/Trigger/TriggerDialog.tsx +++ b/src/components/Trigger/TriggerDialog.tsx @@ -480,13 +480,10 @@ export const TriggerDialog: React.FC = ({ }} className="rounded-2xl bg-ds-bg-neutral-muted-disabled w-full" > - + @@ -494,7 +491,7 @@ export const TriggerDialog: React.FC = ({ diff --git a/src/components/WorkFlow/agents.tsx b/src/components/WorkFlow/agents.tsx index eba6686d..54c99fc9 100644 --- a/src/components/WorkFlow/agents.tsx +++ b/src/components/WorkFlow/agents.tsx @@ -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 = + { + 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 = { developer_agent: { name: 'Developer Agent', diff --git a/src/components/WorkFlow/node.tsx b/src/components/WorkFlow/node.tsx index 79d9a29f..c468b0ce 100644 --- a/src/components/WorkFlow/node.tsx +++ b/src/components/WorkFlow/node.tsx @@ -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 (
{ @@ -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 && ( )} {task.status === TaskStatus.COMPLETED && ( diff --git a/src/components/Workforce/FoldedPanel/AgentDetailPane.tsx b/src/components/Workforce/FoldedPanel/AgentDetailPane.tsx index 9ca0dbd3..f1785176 100644 --- a/src/components/Workforce/FoldedPanel/AgentDetailPane.tsx +++ b/src/components/Workforce/FoldedPanel/AgentDetailPane.tsx @@ -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 && ( )} {task.status === TaskStatus.COMPLETED && ( @@ -435,7 +443,7 @@ export function AgentDetailPane({ task.status === TaskStatus.WAITING) && ( )} diff --git a/src/components/Workforce/WorkforceSidePanel.tsx b/src/components/Workforce/WorkforceSidePanel.tsx index 92c88867..030450e8 100644 --- a/src/components/Workforce/WorkforceSidePanel.tsx +++ b/src/components/Workforce/WorkforceSidePanel.tsx @@ -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} />
diff --git a/src/components/Workspace/FoldedAgentCard.tsx b/src/components/Workspace/FoldedAgentCard.tsx index 0ff4e3df..0976ffad 100644 --- a/src/components/Workspace/FoldedAgentCard.tsx +++ b/src/components/Workspace/FoldedAgentCard.tsx @@ -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 ; diff --git a/src/components/Workspace/index.tsx b/src/components/Workspace/index.tsx index 7eac00e9..09e4b707 100644 --- a/src/components/Workspace/index.tsx +++ b/src/components/Workspace/index.tsx @@ -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 diff --git a/src/components/ui/animate-ui/icons/clipboard-list.tsx b/src/components/ui/animate-ui/icons/clipboard-list.tsx new file mode 100644 index 00000000..0c3a65dc --- /dev/null +++ b/src/components/ui/animate-ui/icons/clipboard-list.tsx @@ -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; + +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, +} as const; + +function IconComponent({ size, ...props }: ClipboardListProps) { + const { controls } = useAnimateIconContext(); + const variants = getVariants(animations); + + return ( + + + + + + + + + ); +} + +function ClipboardList(props: ClipboardListProps) { + return ; +} + +export { + animations, + ClipboardList, + ClipboardList as ClipboardListIcon, + type ClipboardListProps as ClipboardListIconProps, + type ClipboardListProps, +}; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 70f52f7c..a9325b7f 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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: { diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx index 532adf6d..f9a7f028 100644 --- a/src/components/ui/switch.tsx +++ b/src/components/ui/switch.tsx @@ -43,7 +43,7 @@ const Switch = React.forwardRef< >(({ className, size = 'default', style, ...props }, ref) => ( diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 4fef1a62..e3410ff8 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -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, TabsListProps >(({ className, variant = 'default', ...props }, ref) => { + const wrapperRef = React.useRef(null); const tabsListRef = React.useRef | null>(null) as React.MutableRefObject | 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 ( -
+
)} + {variant === 'border' && borderBarStyle.width > 0 && ( + + )}
); @@ -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 ( ( ref: React.Ref, options: UseIsInViewOptions = {} -): { ref: React.MutableRefObject; isInView: boolean } { +) { const { inView, inViewOnce = false, inViewMargin = '0px' } = options; const localRef = React.useRef(null); React.useImperativeHandle(ref, () => localRef.current as T); diff --git a/src/i18n/locales/ar/chat.json b/src/i18n/locales/ar/chat.json index fe979038..1483fffe 100644 --- a/src/i18n/locales/ar/chat.json +++ b/src/i18n/locales/ar/chat.json @@ -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": "إزالة الملف", diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json index 65e6179e..0c4e9d38 100644 --- a/src/i18n/locales/de/chat.json +++ b/src/i18n/locales/de/chat.json @@ -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", diff --git a/src/i18n/locales/en-us/chat.json b/src/i18n/locales/en-us/chat.json index 1d4b9352..5174ac77 100644 --- a/src/i18n/locales/en-us/chat.json +++ b/src/i18n/locales/en-us/chat.json @@ -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", diff --git a/src/i18n/locales/es/chat.json b/src/i18n/locales/es/chat.json index a386bead..3a3de323 100644 --- a/src/i18n/locales/es/chat.json +++ b/src/i18n/locales/es/chat.json @@ -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", diff --git a/src/i18n/locales/fr/chat.json b/src/i18n/locales/fr/chat.json index ffb259f6..023d9dfc 100644 --- a/src/i18n/locales/fr/chat.json +++ b/src/i18n/locales/fr/chat.json @@ -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", diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json index 199711de..9c29f1f5 100644 --- a/src/i18n/locales/it/chat.json +++ b/src/i18n/locales/it/chat.json @@ -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", diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index 8163eb87..34c73a68 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -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": "ファイルを削除", diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index 3fa11e33..98a45dff 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -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": "파일 제거", diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index c356e4a1..ca8bceea 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -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": "Удалить файл", diff --git a/src/i18n/locales/zh-Hans/chat.json b/src/i18n/locales/zh-Hans/chat.json index f3dfae06..7ee64682 100644 --- a/src/i18n/locales/zh-Hans/chat.json +++ b/src/i18n/locales/zh-Hans/chat.json @@ -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": "移除文件", diff --git a/src/i18n/locales/zh-Hant/chat.json b/src/i18n/locales/zh-Hant/chat.json index 0a8af8d8..eb65c925 100644 --- a/src/i18n/locales/zh-Hant/chat.json +++ b/src/i18n/locales/zh-Hant/chat.json @@ -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": "移除文件", diff --git a/src/lib/themeTokens/engine.ts b/src/lib/themeTokens/engine.ts index 34a6339b..ca1b4db3 100644 --- a/src/lib/themeTokens/engine.ts +++ b/src/lib/themeTokens/engine.ts @@ -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, Record > = { @@ -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 — subtle→950, muted→600, default→300, strong→50 (mirrored + * hover/active steps vs. light). + */ +const SYSTEM_STATUS_SHADE_BY_EMPHASIS_DARK: Record< + Extract, + Record +> = { + 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, - 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] = diff --git a/src/pages/Agents/Skills.tsx b/src/pages/Agents/Skills.tsx index 07d09580..eeaba93e 100644 --- a/src/pages/Agents/Skills.tsx +++ b/src/pages/Agents/Skills.tsx @@ -102,23 +102,17 @@ export default function Skills() {
- + {t('agents.your-skills')} - + {t('agents.example-skills')} -
+
- + {t('setting.mcp-and-tools')} - + {t('setting.your-own-mcps')} -
+
(null); const projectSidebarPanelRef = useRef(null); const applyingSidebarLayoutRef = useRef(false); + const sidebarLayoutAnimationFrameRef = useRef(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 { + 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); } diff --git a/src/style/tokens/base.color.json b/src/style/tokens/base.color.json index 0cb33161..b9fea6f0 100644 --- a/src/style/tokens/base.color.json +++ b/src/style/tokens/base.color.json @@ -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", diff --git a/test/unit/lib/themeTokens/engine.v2.test.ts b/test/unit/lib/themeTokens/engine.v2.test.ts index fa53112e..a4a2e2f4 100644 --- a/test/unit/lib/themeTokens/engine.v2.test.ts +++ b/test/unit/lib/themeTokens/engine.v2.test.ts @@ -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']); } });