diff --git a/locales/en-US/topic.json b/locales/en-US/topic.json index 88ab2e3465..3271c42eee 100644 --- a/locales/en-US/topic.json +++ b/locales/en-US/topic.json @@ -135,6 +135,12 @@ "management.view.card": "Card", "management.view.list": "List", "newTopic": "New Topic", + "projectStatus.failed_one": "{{count}} failed topic", + "projectStatus.failed_other": "{{count}} failed topics", + "projectStatus.loading_one": "{{count}} loading topic", + "projectStatus.loading_other": "{{count}} loading topics", + "projectStatus.waitingForHuman_one": "{{count}} topic awaiting input", + "projectStatus.waitingForHuman_other": "{{count}} topics awaiting input", "renameModal.description": "Keep it short and easy to recognize.", "renameModal.title": "Rename Topic", "searchPlaceholder": "Search Topics...", diff --git a/locales/zh-CN/topic.json b/locales/zh-CN/topic.json index 2b8eb4a2b8..b1e30125f5 100644 --- a/locales/zh-CN/topic.json +++ b/locales/zh-CN/topic.json @@ -135,6 +135,12 @@ "management.view.card": "卡片", "management.view.list": "列表", "newTopic": "新话题", + "projectStatus.failed_one": "{{count}} 个错误话题", + "projectStatus.failed_other": "{{count}} 个错误话题", + "projectStatus.loading_one": "{{count}} 个加载中话题", + "projectStatus.loading_other": "{{count}} 个加载中话题", + "projectStatus.waitingForHuman_one": "{{count}} 个等待处理的话题", + "projectStatus.waitingForHuman_other": "{{count}} 个等待处理的话题", "renameModal.description": "保持简短且易于识别。", "renameModal.title": "重命名话题", "searchPlaceholder": "搜索话题…", diff --git a/packages/locales/src/default/topic.ts b/packages/locales/src/default/topic.ts index 46866c90fd..7a48d5fe3a 100644 --- a/packages/locales/src/default/topic.ts +++ b/packages/locales/src/default/topic.ts @@ -66,6 +66,12 @@ export default { 'inPopup.focus': 'Focus Popup Window', 'inPopup.title': 'Open in Popup Window', 'loadMore': 'Load More', + 'projectStatus.failed_one': '{{count}} failed topic', + 'projectStatus.failed_other': '{{count}} failed topics', + 'projectStatus.loading_one': '{{count}} loading topic', + 'projectStatus.loading_other': '{{count}} loading topics', + 'projectStatus.waitingForHuman_one': '{{count}} topic awaiting input', + 'projectStatus.waitingForHuman_other': '{{count}} topics awaiting input', 'management.actions.newChat': 'New chat', 'management.actions.select': 'Select', 'management.actionsMenu.archiveStale.confirm': diff --git a/packages/utils/src/timing.test.ts b/packages/utils/src/timing.test.ts index 7d09ccd9d6..55d8c9b894 100644 --- a/packages/utils/src/timing.test.ts +++ b/packages/utils/src/timing.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createTimingHelpers, + formatElapsedClockTime, markTimingSinkStageDone, markTimingStageDone, type TimingLogger, @@ -11,6 +12,22 @@ import { describe('timing utilities', () => { const context = { requestId: 'req-1', startedAt: Date.now() }; + describe('formatElapsedClockTime', () => { + it('formats elapsed milliseconds as mm:ss below one hour', () => { + expect(formatElapsedClockTime(0)).toBe('00:00'); + expect(formatElapsedClockTime(33_000)).toBe('00:33'); + expect(formatElapsedClockTime(65_000)).toBe('01:05'); + }); + + it('formats elapsed milliseconds as h:mm:ss at one hour or above', () => { + expect(formatElapsedClockTime(3_661_000)).toBe('1:01:01'); + }); + + it('clamps negative elapsed time to zero', () => { + expect(formatElapsedClockTime(-1_000)).toBe('00:00'); + }); + }); + describe('markTimingStageDone', () => { it('should emit a done marker with zero stage duration', () => { const logger = vi.fn(); diff --git a/packages/utils/src/timing.ts b/packages/utils/src/timing.ts index 3cadae2347..fe6f75ab8f 100644 --- a/packages/utils/src/timing.ts +++ b/packages/utils/src/timing.ts @@ -26,6 +26,18 @@ export const createDebugTimingLogger = (namespace: string): TimingLogger => debu export const getDurationMs = (startedAt: number) => Date.now() - startedAt; +export const formatElapsedClockTime = (ms: number) => { + const normalizedMs = Math.max(0, ms); + const totalSeconds = Math.floor(normalizedMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const mm = String(minutes).padStart(2, '0'); + const ss = String(seconds).padStart(2, '0'); + + return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`; +}; + export const createTimingRequestId = () => globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; diff --git a/src/features/Conversation/ChatInput/OpStatusTray.tsx b/src/features/Conversation/ChatInput/OpStatusTray.tsx index 7540b8f25d..fa40a1e499 100644 --- a/src/features/Conversation/ChatInput/OpStatusTray.tsx +++ b/src/features/Conversation/ChatInput/OpStatusTray.tsx @@ -1,5 +1,6 @@ 'use client'; +import { formatElapsedClockTime } from '@lobechat/utils'; import { Flexbox, Icon, Popover, Tooltip } from '@lobehub/ui'; import { createStaticStyles, cx } from 'antd-style'; import type { LucideIcon } from 'lucide-react'; @@ -26,9 +27,10 @@ import { parseStatusPhrases, pickStableStatusPhrase } from './OpStatusTray/logic const styles = createStaticStyles(({ css, cssVar }) => ({ container: css` + container-type: inline-size; + padding-block: 8px; padding-inline: 14px; - container-type: inline-size; border: 1px solid ${cssVar.colorFillSecondary}; border-block-end: none; border-start-start-radius: 12px; @@ -52,6 +54,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ display: inline-flex; gap: 4px; align-items: center; + font-variant-numeric: tabular-nums; white-space: nowrap; `, @@ -81,8 +84,8 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ font-size: 12px; `, metricPopoverValue: css` - color: ${cssVar.colorTextSecondary}; font-variant-numeric: tabular-nums; + color: ${cssVar.colorTextSecondary}; `, metricValue: css` overflow: hidden; @@ -90,11 +93,10 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ text-overflow: ellipsis; `, compactMetric: css` + cursor: default; display: none; flex: none; - cursor: default; - @container (max-width: 360px) { display: inline-flex; } @@ -106,7 +108,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ `, statusText: css` overflow: hidden; - font-weight: 500; text-overflow: ellipsis; white-space: nowrap; @@ -176,17 +177,6 @@ const ActivityGlyph = memo(() => ( ActivityGlyph.displayName = 'ActivityGlyph'; -const formatDuration = (ms: number) => { - if (ms < 0) ms = 0; - const totalSeconds = Math.floor(ms / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - const mm = String(minutes).padStart(2, '0'); - const ss = String(seconds).padStart(2, '0'); - return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`; -}; - const formatTokens = (n: number) => { if (n < 1000) return String(n); if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; @@ -413,7 +403,7 @@ const OpStatusTray = memo(({ topAttached }) => { {statusText}... - {formatDuration(elapsed)} + {formatElapsedClockTime(elapsed)} {metrics.length > 0 && ( diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.test.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.test.tsx index 612bdac53b..7d216f8de3 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.test.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.test.tsx @@ -3,11 +3,12 @@ */ import { render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import TopicItem from './index'; const useTopicNavigationMock = vi.hoisted(() => vi.fn()); +const runningStartTimeMock = vi.hoisted(() => ({ value: undefined as number | undefined })); vi.mock('@lobehub/ui', () => ({ Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( @@ -57,9 +58,20 @@ vi.mock('@/const/url', () => ({ SESSION_CHAT_TOPIC_URL: (agentId: string, topicId: string) => `/agent/${agentId}/${topicId}`, })); vi.mock('@/features/NavPanel/components/NavItem', () => ({ - default: ({ active, href, title }: { active?: boolean; href?: string; title?: ReactNode }) => ( + default: ({ + active, + extra, + href, + title, + }: { + active?: boolean; + extra?: ReactNode; + href?: string; + title?: ReactNode; + }) => (
{title} + {extra}
), })); @@ -83,6 +95,7 @@ vi.mock('@/store/chat', () => ({ })); vi.mock('@/store/chat/selectors', () => ({ operationSelectors: { + getAgentRuntimeStartTimeByContext: () => () => runningStartTimeMock.value, isTopicUnreadCompleted: () => () => false, }, })); @@ -109,6 +122,11 @@ vi.mock('../../TopicListContent/ThreadList', () => ({ })); describe('TopicItem active state', () => { + afterEach(() => { + runningStartTimeMock.value = undefined; + vi.useRealTimers(); + }); + it('keeps the current topic highlighted on topic page sub-routes', () => { useTopicNavigationMock.mockReturnValue({ isInAgentSubRoute: true, @@ -153,4 +171,21 @@ describe('TopicItem active state', () => { '/team/agent/agt_test/tpc_test', ); }); + + it('shows running elapsed time in the nav item extra slot', () => { + vi.useFakeTimers(); + const now = Date.UTC(2026, 0, 1, 0, 0, 33); + vi.setSystemTime(now); + runningStartTimeMock.value = now - 33_000; + useTopicNavigationMock.mockReturnValue({ + isInAgentSubRoute: false, + isInTopicContextRoute: false, + navigateToTopic: vi.fn(), + routeTopicId: undefined, + }); + + render(); + + expect(screen.getByText('00:33')).toBeInTheDocument(); + }); }); diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx index e635c4767d..8c0a9bbb70 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx @@ -1,8 +1,9 @@ import type { ChatTopicMetadata, ChatTopicStatus } from '@lobechat/types'; +import { formatElapsedClockTime } from '@lobechat/utils'; import { Flexbox, Icon, Skeleton, Tag, Text, Tooltip } from '@lobehub/ui'; import { createStaticStyles, cssVar, keyframes, useTheme } from 'antd-style'; import { CheckCircle2, Hand, HashIcon, MessageSquareDashed, TriangleAlert } from 'lucide-react'; -import { memo, Suspense, useCallback, useMemo } from 'react'; +import { memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useActiveWorkspaceSlug } from '@/business/client/hooks/useActiveWorkspaceSlug'; @@ -72,6 +73,17 @@ const styles = createStaticStyles(({ css }) => ({ animation: ${rippleAnim} 1.8s ease-out infinite; `, + runningElapsedTime: css` + flex: none; + + min-width: 42px; + + font-size: 12px; + font-variant-numeric: tabular-nums; + line-height: 1; + color: ${cssVar.colorTextTertiary}; + text-align: end; + `, })); // Module-scoped so a click on any topic cancels a pending click on another. @@ -90,6 +102,37 @@ const cancelPendingSingleClick = () => { // a web github URL (".../owner/repo" → "repo"). const getDirName = (path: string) => path.split('/').findLast(Boolean) || path; +interface RunningElapsedTimeProps { + agentId?: string; + topicId: string; +} + +const RunningElapsedTime = memo(({ agentId, topicId }) => { + const startTime = useChatStore( + agentId + ? operationSelectors.getAgentRuntimeStartTimeByContext({ agentId, topicId }) + : () => undefined, + ); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!startTime) return; + + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), 1000); + + return () => clearInterval(timer); + }, [startTime]); + + if (!startTime) return null; + + return ( + {formatElapsedClockTime(now - startTime)} + ); +}); + +RunningElapsedTime.displayName = 'RunningElapsedTime'; + interface TopicItemProps { active?: boolean; fav?: boolean; @@ -124,7 +167,10 @@ const TopicItem = memo( // Construct href for cmd+click support const href = useMemo(() => { if (!activeAgentId || !id) return undefined; - return buildWorkspaceAwarePath(SESSION_CHAT_TOPIC_URL(activeAgentId, id), activeWorkspaceSlug); + return buildWorkspaceAwarePath( + SESSION_CHAT_TOPIC_URL(activeAgentId, id), + activeWorkspaceSlug, + ); }, [activeAgentId, activeWorkspaceSlug, id]); const [editing, isLoading] = useChatStore((s) => [ @@ -261,6 +307,7 @@ const TopicItem = memo( contextMenuItems={dropdownMenu} description={workingDirectoryNode} disabled={editing} + extra={} href={href} title={title === '...' ? : title} titleColor={cssVar.colorText} diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx index dd79818f53..384ab41bd4 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx @@ -1,9 +1,17 @@ -import { AccordionItem, ActionIcon, Center, Flexbox, Icon, Text } from '@lobehub/ui'; -import { cssVar } from 'antd-style'; -import { FolderClosedIcon, PlusIcon } from 'lucide-react'; +import { AccordionItem, ActionIcon, Center, Flexbox, Icon, Text, Tooltip } from '@lobehub/ui'; +import { createStaticStyles, cssVar, cx } from 'antd-style'; +import { + FolderClosedIcon, + FolderOpenIcon, + HandIcon, + type LucideIcon, + PlusIcon, + TriangleAlertIcon, +} from 'lucide-react'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import RingLoadingIcon from '@/components/RingLoading'; import { isDesktop } from '@/const/version'; import { useCommitWorkingDirectory } from '@/features/ChatInput/ControlBar/useCommitWorkingDirectory'; import { resolveExecutionTarget } from '@/helpers/executionTarget'; @@ -13,93 +21,226 @@ import { useChatStore } from '@/store/chat'; import TopicItem from '../../List/Item'; import { type GroupItemComponentProps } from '../GroupedAccordion'; +import { + getProjectTopicStatusCounts, + hasProjectTopicStatusCounts, + type ProjectTopicStatusCounts, +} from './statusCounts'; const PROJECT_GROUP_PREFIX = 'project:'; -const GroupItem = memo(({ group, activeTopicId, activeThreadId }) => { +const styles = createStaticStyles(({ css }) => ({ + statusBadge: css` + display: inline-flex; + gap: 2px; + align-items: center; + justify-content: center; + + min-width: 20px; + height: 18px; + padding-inline: 4px; + border-radius: 9px; + + font-size: 11px; + font-weight: 500; + line-height: 1; + `, + statusBadgeError: css` + color: ${cssVar.colorError}; + background: color-mix(in srgb, ${cssVar.colorError} 14%, transparent); + `, + statusBadgeLoading: css` + color: ${cssVar.colorWarning}; + background: color-mix(in srgb, ${cssVar.colorWarning} 14%, transparent); + `, + statusBadgeWaiting: css` + color: ${cssVar.colorInfo}; + background: color-mix(in srgb, ${cssVar.colorInfo} 14%, transparent); + `, + addTopicAction: css` + pointer-events: none; + + overflow: hidden; + display: inline-flex; + + width: 0; + + opacity: 0; + + transition: + width 150ms ${cssVar.motionEaseOut}, + opacity 150ms ${cssVar.motionEaseOut}; + + &:focus-within, + .accordion-header:hover & { + pointer-events: auto; + width: 24px; + opacity: 1; + } + `, +})); + +interface StatusBadgeConfig { + className: string; + count: number; + icon?: LucideIcon; + label: string; + loading?: boolean; +} + +const CollapsedStatusBadges = memo<{ counts: ProjectTopicStatusCounts }>(({ counts }) => { const { t } = useTranslation('topic'); - const { id, title, children } = group; - const workingDirectory = useMemo( - () => (id.startsWith(PROJECT_GROUP_PREFIX) ? id.slice(PROJECT_GROUP_PREFIX.length) : undefined), - [id], - ); + const items: StatusBadgeConfig[] = [ + { + className: styles.statusBadgeLoading, + count: counts.loading, + label: t('projectStatus.loading', { count: counts.loading }), + loading: true, + }, + { + className: styles.statusBadgeWaiting, + count: counts.waitingForHuman, + icon: HandIcon, + label: t('projectStatus.waitingForHuman', { count: counts.waitingForHuman }), + }, + { + className: styles.statusBadgeError, + count: counts.failed, + icon: TriangleAlertIcon, + label: t('projectStatus.failed', { count: counts.failed }), + }, + ].filter((item) => item.count > 0); - const agentId = useAgentStore((s) => s.activeAgentId); - const agencyConfig = useAgentStore(agentByIdSelectors.getAgencyConfigById(agentId ?? '')); - const isHeterogeneous = useAgentStore((s) => - agentId ? agentByIdSelectors.isAgentHeterogeneousById(agentId)(s) : false, - ); - const { commitAgentDefault } = useCommitWorkingDirectory(agentId ?? ''); - - const handleAddTopic = useCallback(async () => { - if (!workingDirectory || !agentId) return; - // Write the agent's per-device default so the new topic inherits this - // directory at creation time — the same high-precedence slot the picker - // uses, not the legacy per-agent fallback that gets shadowed by it. - await commitAgentDefault(workingDirectory); - useChatStore.getState().switchTopic(null, { skipRefreshMessage: true }); - }, [workingDirectory, agentId, commitAgentDefault]); - - // Web can add a topic in a directory too when the agent targets a bound - // device — the write goes to `workingDirByDevice`, no Electron dependency. - const effectiveTarget = resolveExecutionTarget(agencyConfig, { - isDesktop, - isHetero: isHeterogeneous, - }); - const isDeviceMode = effectiveTarget === 'device' && !!agencyConfig?.boundDeviceId; - const canAddTopic = (isDesktop || isDeviceMode) && !!workingDirectory; + if (items.length === 0) return null; return ( - { - e.stopPropagation(); - void handleAddTopic(); - }} - /> - ) : undefined - } - title={ - -
- -
- - {title} - -
- } - > - - {children.map((topic) => ( - - ))} - -
+ + {items.map(({ className, count, icon, label, loading }) => ( + + + {loading ? ( + + ) : ( + icon && + )} + {count} + + + ))} + ); }); +CollapsedStatusBadges.displayName = 'CollapsedProjectStatusBadges'; + +const GroupItem = memo( + ({ group, activeTopicId, activeThreadId, expanded }) => { + const { t } = useTranslation('topic'); + const { id, title, children } = group; + + const workingDirectory = useMemo( + () => + id.startsWith(PROJECT_GROUP_PREFIX) ? id.slice(PROJECT_GROUP_PREFIX.length) : undefined, + [id], + ); + + const agentId = useAgentStore((s) => s.activeAgentId); + const agencyConfig = useAgentStore(agentByIdSelectors.getAgencyConfigById(agentId ?? '')); + const isHeterogeneous = useAgentStore((s) => + agentId ? agentByIdSelectors.isAgentHeterogeneousById(agentId)(s) : false, + ); + const { commitAgentDefault } = useCommitWorkingDirectory(agentId ?? ''); + + const handleAddTopic = useCallback(async () => { + if (!workingDirectory || !agentId) return; + // Write the agent's per-device default so the new topic inherits this + // directory at creation time — the same high-precedence slot the picker + // uses, not the legacy per-agent fallback that gets shadowed by it. + await commitAgentDefault(workingDirectory); + useChatStore.getState().switchTopic(null, { skipRefreshMessage: true }); + }, [workingDirectory, agentId, commitAgentDefault]); + + // Web can add a topic in a directory too when the agent targets a bound + // device — the write goes to `workingDirByDevice`, no Electron dependency. + const effectiveTarget = resolveExecutionTarget(agencyConfig, { + isDesktop, + isHetero: isHeterogeneous, + }); + const isDeviceMode = effectiveTarget === 'device' && !!agencyConfig?.boundDeviceId; + const canAddTopic = (isDesktop || isDeviceMode) && !!workingDirectory; + + const loadingTopicIds = useChatStore((s) => s.topicLoadingIds); + const statusCounts = useMemo( + () => getProjectTopicStatusCounts(children, new Set(loadingTopicIds)), + [children, loadingTopicIds], + ); + const hasCollapsedStatus = !expanded && hasProjectTopicStatusCounts(statusCounts); + const ProjectFolderIcon = expanded ? FolderOpenIcon : FolderClosedIcon; + const action = + canAddTopic || hasCollapsedStatus ? ( + + {hasCollapsedStatus && } + {canAddTopic && ( + + { + e.stopPropagation(); + void handleAddTopic(); + }} + /> + + )} + + ) : undefined; + + return ( + +
+ +
+ + {title} + + + } + > + + {children.map((topic) => ( + + ))} + +
+ ); + }, +); + export default GroupItem; diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/statusCounts.test.ts b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/statusCounts.test.ts new file mode 100644 index 0000000000..f423e68069 --- /dev/null +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/statusCounts.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { type ChatTopic } from '@/types/topic'; + +import { getProjectTopicStatusCounts, hasProjectTopicStatusCounts } from './statusCounts'; + +const createTopic = (id: string, status?: ChatTopic['status']): ChatTopic => + ({ + createdAt: 0, + favorite: false, + id, + status, + title: id, + updatedAt: 0, + }) as ChatTopic; + +describe('getProjectTopicStatusCounts', () => { + it('counts loading, waiting-for-human, and failed topics by type', () => { + const counts = getProjectTopicStatusCounts( + [ + createTopic('running', 'running'), + createTopic('client-loading'), + createTopic('waiting', 'waitingForHuman'), + createTopic('failed', 'failed'), + createTopic('active', 'active'), + ], + new Set(['client-loading']), + ); + + expect(counts).toEqual({ + failed: 1, + loading: 2, + waitingForHuman: 1, + }); + expect(hasProjectTopicStatusCounts(counts)).toBe(true); + }); + + it('uses the same precedence as topic row icons', () => { + const counts = getProjectTopicStatusCounts( + [createTopic('waiting', 'waitingForHuman'), createTopic('failed', 'failed')], + new Set(['waiting', 'failed']), + ); + + expect(counts).toEqual({ + failed: 0, + loading: 1, + waitingForHuman: 1, + }); + }); + + it('reports empty counts when no actionable status exists', () => { + const counts = getProjectTopicStatusCounts( + [createTopic('active', 'active'), createTopic('completed', 'completed')], + new Set(), + ); + + expect(counts).toEqual({ + failed: 0, + loading: 0, + waitingForHuman: 0, + }); + expect(hasProjectTopicStatusCounts(counts)).toBe(false); + }); +}); diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/statusCounts.ts b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/statusCounts.ts new file mode 100644 index 0000000000..86b474ad37 --- /dev/null +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/statusCounts.ts @@ -0,0 +1,41 @@ +import { type ChatTopic } from '@/types/topic'; + +export interface ProjectTopicStatusCounts { + failed: number; + loading: number; + waitingForHuman: number; +} + +export const EMPTY_PROJECT_TOPIC_STATUS_COUNTS: ProjectTopicStatusCounts = { + failed: 0, + loading: 0, + waitingForHuman: 0, +}; + +export const getProjectTopicStatusCounts = ( + topics: ChatTopic[], + loadingTopicIds: ReadonlySet, +): ProjectTopicStatusCounts => + topics.reduce( + (counts, topic) => { + if (topic.status === 'waitingForHuman') { + counts.waitingForHuman += 1; + return counts; + } + + if (loadingTopicIds.has(topic.id) || topic.status === 'running') { + counts.loading += 1; + return counts; + } + + if (topic.status === 'failed') { + counts.failed += 1; + } + + return counts; + }, + { ...EMPTY_PROJECT_TOPIC_STATUS_COUNTS }, + ); + +export const hasProjectTopicStatusCounts = (counts: ProjectTopicStatusCounts) => + counts.loading > 0 || counts.waitingForHuman > 0 || counts.failed > 0; diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/GroupedAccordion.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/GroupedAccordion.tsx index d9e0b70b4a..ce2f011ce4 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/GroupedAccordion.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/GroupedAccordion.tsx @@ -23,6 +23,7 @@ import { useAgentTopicGroupMode } from '../hooks/useAgentTopicGroupMode'; export interface GroupItemComponentProps { activeThreadId?: string; activeTopicId?: string; + expanded: boolean; group: GroupedTopic; } @@ -75,6 +76,7 @@ const GroupedAccordion = memo(({ GroupItem }) => { diff --git a/src/store/chat/slices/operation/__tests__/selectors.test.ts b/src/store/chat/slices/operation/__tests__/selectors.test.ts index c1fe1f314b..19ab9d8fb0 100644 --- a/src/store/chat/slices/operation/__tests__/selectors.test.ts +++ b/src/store/chat/slices/operation/__tests__/selectors.test.ts @@ -509,6 +509,72 @@ describe('Operation Selectors', () => { }); }); + describe('getAgentRuntimeStartTimeByContext', () => { + it('should return the earliest running runtime start time for the context', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + result.current.startOperation({ + type: 'execAgentRuntime', + context: { agentId: 'agent1', topicId: 'topic1' }, + metadata: { startTime: 2000 }, + }); + + result.current.startOperation({ + type: 'execHeterogeneousAgent', + context: { agentId: 'agent1', topicId: 'topic1' }, + metadata: { startTime: 1000 }, + }); + + result.current.startOperation({ + type: 'reasoning', + context: { agentId: 'agent1', topicId: 'topic1' }, + metadata: { startTime: 500 }, + }); + + result.current.startOperation({ + type: 'execAgentRuntime', + context: { agentId: 'agent1', topicId: 'topic2' }, + metadata: { startTime: 300 }, + }); + }); + + expect( + operationSelectors.getAgentRuntimeStartTimeByContext({ + agentId: 'agent1', + topicId: 'topic1', + })(result.current), + ).toBe(1000); + }); + + it('should ignore completed and aborting runtime operations', () => { + const { result } = renderHook(() => useChatStore()); + + act(() => { + const completedOpId = result.current.startOperation({ + type: 'execAgentRuntime', + context: { agentId: 'agent1', topicId: 'topic1' }, + metadata: { startTime: 1000 }, + }).operationId; + + result.current.completeOperation(completedOpId); + + result.current.startOperation({ + type: 'execHeterogeneousAgent', + context: { agentId: 'agent1', topicId: 'topic1' }, + metadata: { isAborting: true, startTime: 1500 }, + }); + }); + + expect( + operationSelectors.getAgentRuntimeStartTimeByContext({ + agentId: 'agent1', + topicId: 'topic1', + })(result.current), + ).toBeUndefined(); + }); + }); + describe('getRunningToolCallStartTime', () => { it('should prefer the running executeToolCall start time', () => { const { result } = renderHook(() => useChatStore()); diff --git a/src/store/chat/slices/operation/selectors.ts b/src/store/chat/slices/operation/selectors.ts index dfb1238ef0..24cad548f0 100644 --- a/src/store/chat/slices/operation/selectors.ts +++ b/src/store/chat/slices/operation/selectors.ts @@ -220,6 +220,37 @@ const isAgentRuntimeRunningByContext = ); }; +/** + * Get the earliest start time for a running agent runtime operation in a + * specific context. This anchors visible elapsed-time UI to the top-level + * runtime op instead of short-lived sub-operations. + */ +const getAgentRuntimeStartTimeByContext = + (context: MessageMapKeyInput) => + (s: ChatStoreState): number | undefined => { + if (!context.agentId) return undefined; + + const operations = getOperationsByContext(context)(s); + let startTime: number | undefined; + + for (const op of operations) { + if ( + op.status !== 'running' || + op.metadata.isAborting || + !AI_RUNTIME_OPERATION_TYPES.includes(op.type) + ) { + continue; + } + + startTime = + startTime === undefined + ? op.metadata.startTime + : Math.min(startTime, op.metadata.startTime); + } + + return startTime; + }; + /** * Check if input should show loading state in a specific context * Includes sendMessage in addition to AI runtime operations, @@ -603,6 +634,7 @@ export const operationSelectors = { getDeepestRunningOperationByMessage, getOperationById, getOperationContextFromMessage, + getAgentRuntimeStartTimeByContext, getOperationsByContext, getOperationsByMessage, getOperationsByType,