feat(agent): show topic sidebar status indicators (#15739)

This commit is contained in:
Arvin Xu
2026-06-13 13:32:56 +08:00
committed by GitHub
parent 5d6eaf53f3
commit f6db1361ee
14 changed files with 567 additions and 102 deletions
+6
View File
@@ -135,6 +135,12 @@
"management.view.card": "Card", "management.view.card": "Card",
"management.view.list": "List", "management.view.list": "List",
"newTopic": "New Topic", "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.description": "Keep it short and easy to recognize.",
"renameModal.title": "Rename Topic", "renameModal.title": "Rename Topic",
"searchPlaceholder": "Search Topics...", "searchPlaceholder": "Search Topics...",
+6
View File
@@ -135,6 +135,12 @@
"management.view.card": "卡片", "management.view.card": "卡片",
"management.view.list": "列表", "management.view.list": "列表",
"newTopic": "新话题", "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.description": "保持简短且易于识别。",
"renameModal.title": "重命名话题", "renameModal.title": "重命名话题",
"searchPlaceholder": "搜索话题…", "searchPlaceholder": "搜索话题…",
+6
View File
@@ -66,6 +66,12 @@ export default {
'inPopup.focus': 'Focus Popup Window', 'inPopup.focus': 'Focus Popup Window',
'inPopup.title': 'Open in Popup Window', 'inPopup.title': 'Open in Popup Window',
'loadMore': 'Load More', '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.newChat': 'New chat',
'management.actions.select': 'Select', 'management.actions.select': 'Select',
'management.actionsMenu.archiveStale.confirm': 'management.actionsMenu.archiveStale.confirm':
+17
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import { import {
createTimingHelpers, createTimingHelpers,
formatElapsedClockTime,
markTimingSinkStageDone, markTimingSinkStageDone,
markTimingStageDone, markTimingStageDone,
type TimingLogger, type TimingLogger,
@@ -11,6 +12,22 @@ import {
describe('timing utilities', () => { describe('timing utilities', () => {
const context = { requestId: 'req-1', startedAt: Date.now() }; 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', () => { describe('markTimingStageDone', () => {
it('should emit a done marker with zero stage duration', () => { it('should emit a done marker with zero stage duration', () => {
const logger = vi.fn<TimingLogger>(); const logger = vi.fn<TimingLogger>();
+12
View File
@@ -26,6 +26,18 @@ export const createDebugTimingLogger = (namespace: string): TimingLogger => debu
export const getDurationMs = (startedAt: number) => Date.now() - startedAt; 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 = () => export const createTimingRequestId = () =>
globalThis.crypto?.randomUUID?.() ?? globalThis.crypto?.randomUUID?.() ??
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { formatElapsedClockTime } from '@lobechat/utils';
import { Flexbox, Icon, Popover, Tooltip } from '@lobehub/ui'; import { Flexbox, Icon, Popover, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style'; import { createStaticStyles, cx } from 'antd-style';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
@@ -26,9 +27,10 @@ import { parseStatusPhrases, pickStableStatusPhrase } from './OpStatusTray/logic
const styles = createStaticStyles(({ css, cssVar }) => ({ const styles = createStaticStyles(({ css, cssVar }) => ({
container: css` container: css`
container-type: inline-size;
padding-block: 8px; padding-block: 8px;
padding-inline: 14px; padding-inline: 14px;
container-type: inline-size;
border: 1px solid ${cssVar.colorFillSecondary}; border: 1px solid ${cssVar.colorFillSecondary};
border-block-end: none; border-block-end: none;
border-start-start-radius: 12px; border-start-start-radius: 12px;
@@ -52,6 +54,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
display: inline-flex; display: inline-flex;
gap: 4px; gap: 4px;
align-items: center; align-items: center;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
`, `,
@@ -81,8 +84,8 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
font-size: 12px; font-size: 12px;
`, `,
metricPopoverValue: css` metricPopoverValue: css`
color: ${cssVar.colorTextSecondary};
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: ${cssVar.colorTextSecondary};
`, `,
metricValue: css` metricValue: css`
overflow: hidden; overflow: hidden;
@@ -90,11 +93,10 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
text-overflow: ellipsis; text-overflow: ellipsis;
`, `,
compactMetric: css` compactMetric: css`
cursor: default;
display: none; display: none;
flex: none; flex: none;
cursor: default;
@container (max-width: 360px) { @container (max-width: 360px) {
display: inline-flex; display: inline-flex;
} }
@@ -106,7 +108,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
`, `,
statusText: css` statusText: css`
overflow: hidden; overflow: hidden;
font-weight: 500; font-weight: 500;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -176,17 +177,6 @@ const ActivityGlyph = memo(() => (
ActivityGlyph.displayName = 'ActivityGlyph'; 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) => { const formatTokens = (n: number) => {
if (n < 1000) return String(n); if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
@@ -413,7 +403,7 @@ const OpStatusTray = memo<OpStatusTrayProps>(({ topAttached }) => {
<span className={cx(styles.metric, styles.statusMetric)}> <span className={cx(styles.metric, styles.statusMetric)}>
<ActivityGlyph /> <ActivityGlyph />
<span className={cx(styles.statusText, shinyTextStyles.shinyText)}>{statusText}...</span> <span className={cx(styles.statusText, shinyTextStyles.shinyText)}>{statusText}...</span>
<span className={styles.timerValue}>{formatDuration(elapsed)}</span> <span className={styles.timerValue}>{formatElapsedClockTime(elapsed)}</span>
</span> </span>
{metrics.length > 0 && ( {metrics.length > 0 && (
@@ -3,11 +3,12 @@
*/ */
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import type { ReactNode } from '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'; import TopicItem from './index';
const useTopicNavigationMock = vi.hoisted(() => vi.fn()); const useTopicNavigationMock = vi.hoisted(() => vi.fn());
const runningStartTimeMock = vi.hoisted(() => ({ value: undefined as number | undefined }));
vi.mock('@lobehub/ui', () => ({ vi.mock('@lobehub/ui', () => ({
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( 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}`, SESSION_CHAT_TOPIC_URL: (agentId: string, topicId: string) => `/agent/${agentId}/${topicId}`,
})); }));
vi.mock('@/features/NavPanel/components/NavItem', () => ({ 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;
}) => (
<div data-active={String(active)} data-href={href} data-testid="nav-item"> <div data-active={String(active)} data-href={href} data-testid="nav-item">
{title} {title}
{extra}
</div> </div>
), ),
})); }));
@@ -83,6 +95,7 @@ vi.mock('@/store/chat', () => ({
})); }));
vi.mock('@/store/chat/selectors', () => ({ vi.mock('@/store/chat/selectors', () => ({
operationSelectors: { operationSelectors: {
getAgentRuntimeStartTimeByContext: () => () => runningStartTimeMock.value,
isTopicUnreadCompleted: () => () => false, isTopicUnreadCompleted: () => () => false,
}, },
})); }));
@@ -109,6 +122,11 @@ vi.mock('../../TopicListContent/ThreadList', () => ({
})); }));
describe('TopicItem active state', () => { describe('TopicItem active state', () => {
afterEach(() => {
runningStartTimeMock.value = undefined;
vi.useRealTimers();
});
it('keeps the current topic highlighted on topic page sub-routes', () => { it('keeps the current topic highlighted on topic page sub-routes', () => {
useTopicNavigationMock.mockReturnValue({ useTopicNavigationMock.mockReturnValue({
isInAgentSubRoute: true, isInAgentSubRoute: true,
@@ -153,4 +171,21 @@ describe('TopicItem active state', () => {
'/team/agent/agt_test/tpc_test', '/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(<TopicItem id="tpc_test" status="running" title="Topic" />);
expect(screen.getByText('00:33')).toBeInTheDocument();
});
}); });
@@ -1,8 +1,9 @@
import type { ChatTopicMetadata, ChatTopicStatus } from '@lobechat/types'; import type { ChatTopicMetadata, ChatTopicStatus } from '@lobechat/types';
import { formatElapsedClockTime } from '@lobechat/utils';
import { Flexbox, Icon, Skeleton, Tag, Text, Tooltip } from '@lobehub/ui'; import { Flexbox, Icon, Skeleton, Tag, Text, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cssVar, keyframes, useTheme } from 'antd-style'; import { createStaticStyles, cssVar, keyframes, useTheme } from 'antd-style';
import { CheckCircle2, Hand, HashIcon, MessageSquareDashed, TriangleAlert } from 'lucide-react'; 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 { useTranslation } from 'react-i18next';
import { useActiveWorkspaceSlug } from '@/business/client/hooks/useActiveWorkspaceSlug'; import { useActiveWorkspaceSlug } from '@/business/client/hooks/useActiveWorkspaceSlug';
@@ -72,6 +73,17 @@ const styles = createStaticStyles(({ css }) => ({
animation: ${rippleAnim} 1.8s ease-out infinite; 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. // 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"). // a web github URL (".../owner/repo" → "repo").
const getDirName = (path: string) => path.split('/').findLast(Boolean) || path; const getDirName = (path: string) => path.split('/').findLast(Boolean) || path;
interface RunningElapsedTimeProps {
agentId?: string;
topicId: string;
}
const RunningElapsedTime = memo<RunningElapsedTimeProps>(({ 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 (
<span className={styles.runningElapsedTime}>{formatElapsedClockTime(now - startTime)}</span>
);
});
RunningElapsedTime.displayName = 'RunningElapsedTime';
interface TopicItemProps { interface TopicItemProps {
active?: boolean; active?: boolean;
fav?: boolean; fav?: boolean;
@@ -124,7 +167,10 @@ const TopicItem = memo<TopicItemProps>(
// Construct href for cmd+click support // Construct href for cmd+click support
const href = useMemo(() => { const href = useMemo(() => {
if (!activeAgentId || !id) return undefined; 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]); }, [activeAgentId, activeWorkspaceSlug, id]);
const [editing, isLoading] = useChatStore((s) => [ const [editing, isLoading] = useChatStore((s) => [
@@ -261,6 +307,7 @@ const TopicItem = memo<TopicItemProps>(
contextMenuItems={dropdownMenu} contextMenuItems={dropdownMenu}
description={workingDirectoryNode} description={workingDirectoryNode}
disabled={editing} disabled={editing}
extra={<RunningElapsedTime agentId={activeAgentId} topicId={id} />}
href={href} href={href}
title={title === '...' ? <DotsLoading gap={3} size={4} /> : title} title={title === '...' ? <DotsLoading gap={3} size={4} /> : title}
titleColor={cssVar.colorText} titleColor={cssVar.colorText}
@@ -1,9 +1,17 @@
import { AccordionItem, ActionIcon, Center, Flexbox, Icon, Text } from '@lobehub/ui'; import { AccordionItem, ActionIcon, Center, Flexbox, Icon, Text, Tooltip } from '@lobehub/ui';
import { cssVar } from 'antd-style'; import { createStaticStyles, cssVar, cx } from 'antd-style';
import { FolderClosedIcon, PlusIcon } from 'lucide-react'; import {
FolderClosedIcon,
FolderOpenIcon,
HandIcon,
type LucideIcon,
PlusIcon,
TriangleAlertIcon,
} from 'lucide-react';
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import RingLoadingIcon from '@/components/RingLoading';
import { isDesktop } from '@/const/version'; import { isDesktop } from '@/const/version';
import { useCommitWorkingDirectory } from '@/features/ChatInput/ControlBar/useCommitWorkingDirectory'; import { useCommitWorkingDirectory } from '@/features/ChatInput/ControlBar/useCommitWorkingDirectory';
import { resolveExecutionTarget } from '@/helpers/executionTarget'; import { resolveExecutionTarget } from '@/helpers/executionTarget';
@@ -13,93 +21,226 @@ import { useChatStore } from '@/store/chat';
import TopicItem from '../../List/Item'; import TopicItem from '../../List/Item';
import { type GroupItemComponentProps } from '../GroupedAccordion'; import { type GroupItemComponentProps } from '../GroupedAccordion';
import {
getProjectTopicStatusCounts,
hasProjectTopicStatusCounts,
type ProjectTopicStatusCounts,
} from './statusCounts';
const PROJECT_GROUP_PREFIX = 'project:'; const PROJECT_GROUP_PREFIX = 'project:';
const GroupItem = memo<GroupItemComponentProps>(({ 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 { t } = useTranslation('topic');
const { id, title, children } = group;
const workingDirectory = useMemo( const items: StatusBadgeConfig[] = [
() => (id.startsWith(PROJECT_GROUP_PREFIX) ? id.slice(PROJECT_GROUP_PREFIX.length) : undefined), {
[id], 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); if (items.length === 0) return null;
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;
return ( return (
<AccordionItem <Flexbox horizontal align={'center'} gap={3}>
itemKey={id} {items.map(({ className, count, icon, label, loading }) => (
paddingBlock={4} <Tooltip key={label} title={label}>
paddingInline={4} <span aria-label={label} className={cx(styles.statusBadge, className)} role="status">
action={ {loading ? (
canAddTopic ? ( <RingLoadingIcon
<ActionIcon ringColor={`color-mix(in srgb, ${cssVar.colorWarning} 28%, transparent)`}
icon={PlusIcon} size={11}
size={'small'} style={{ color: cssVar.colorWarning }}
title={t('actions.addNewTopicInProject', { directory: title })} />
tooltipProps={{ placement: 'right' }} ) : (
onClick={(e) => { icon && <Icon icon={icon} size={{ size: 11, strokeWidth: 2 }} />
e.stopPropagation(); )}
void handleAddTopic(); {count}
}} </span>
/> </Tooltip>
) : undefined ))}
} </Flexbox>
title={
<Flexbox horizontal align="center" gap={8} height={24} style={{ overflow: 'hidden' }}>
<Center flex={'none'} height={24} width={28}>
<Icon
color={cssVar.colorTextTertiary}
icon={FolderClosedIcon}
size={{ size: 15, strokeWidth: 1.5 }}
/>
</Center>
<Text ellipsis fontSize={14} style={{ color: cssVar.colorTextSecondary, flex: 1 }}>
{title}
</Text>
</Flexbox>
}
>
<Flexbox gap={1} paddingBlock={1}>
{children.map((topic) => (
<TopicItem
active={activeTopicId === topic.id}
fav={topic.favorite}
id={topic.id}
key={topic.id}
metadata={topic.metadata}
status={topic.status}
threadId={activeThreadId}
title={topic.title}
/>
))}
</Flexbox>
</AccordionItem>
); );
}); });
CollapsedStatusBadges.displayName = 'CollapsedProjectStatusBadges';
const GroupItem = memo<GroupItemComponentProps>(
({ 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 ? (
<Flexbox horizontal align={'center'} gap={4}>
{hasCollapsedStatus && <CollapsedStatusBadges counts={statusCounts} />}
{canAddTopic && (
<span className={hasCollapsedStatus ? styles.addTopicAction : undefined}>
<ActionIcon
icon={PlusIcon}
size={'small'}
title={t('actions.addNewTopicInProject', { directory: title })}
tooltipProps={{ placement: 'right' }}
onClick={(e) => {
e.stopPropagation();
void handleAddTopic();
}}
/>
</span>
)}
</Flexbox>
) : undefined;
return (
<AccordionItem
action={action}
alwaysShowAction={hasCollapsedStatus}
itemKey={id}
paddingBlock={4}
paddingInline={4}
title={
<Flexbox horizontal align="center" gap={8} height={24} style={{ overflow: 'hidden' }}>
<Center flex={'none'} height={24} width={28}>
<Icon
color={cssVar.colorTextTertiary}
icon={ProjectFolderIcon}
size={{ size: 15, strokeWidth: 1.5 }}
/>
</Center>
<Text ellipsis fontSize={14} style={{ color: cssVar.colorTextSecondary, flex: 1 }}>
{title}
</Text>
</Flexbox>
}
>
<Flexbox gap={1} paddingBlock={1}>
{children.map((topic) => (
<TopicItem
active={activeTopicId === topic.id}
fav={topic.favorite}
id={topic.id}
key={topic.id}
metadata={topic.metadata}
status={topic.status}
threadId={activeThreadId}
title={topic.title}
/>
))}
</Flexbox>
</AccordionItem>
);
},
);
export default GroupItem; export default GroupItem;
@@ -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);
});
});
@@ -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<string>,
): ProjectTopicStatusCounts =>
topics.reduce<ProjectTopicStatusCounts>(
(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;
@@ -23,6 +23,7 @@ import { useAgentTopicGroupMode } from '../hooks/useAgentTopicGroupMode';
export interface GroupItemComponentProps { export interface GroupItemComponentProps {
activeThreadId?: string; activeThreadId?: string;
activeTopicId?: string; activeTopicId?: string;
expanded: boolean;
group: GroupedTopic; group: GroupedTopic;
} }
@@ -75,6 +76,7 @@ const GroupedAccordion = memo<GroupedAccordionProps>(({ GroupItem }) => {
<GroupItem <GroupItem
activeThreadId={activeThreadId} activeThreadId={activeThreadId}
activeTopicId={activeTopicId} activeTopicId={activeTopicId}
expanded={expandedKeys.includes(group.id)}
group={group} group={group}
key={group.id} key={group.id}
/> />
@@ -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', () => { describe('getRunningToolCallStartTime', () => {
it('should prefer the running executeToolCall start time', () => { it('should prefer the running executeToolCall start time', () => {
const { result } = renderHook(() => useChatStore()); const { result } = renderHook(() => useChatStore());
@@ -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 * Check if input should show loading state in a specific context
* Includes sendMessage in addition to AI runtime operations, * Includes sendMessage in addition to AI runtime operations,
@@ -603,6 +634,7 @@ export const operationSelectors = {
getDeepestRunningOperationByMessage, getDeepestRunningOperationByMessage,
getOperationById, getOperationById,
getOperationContextFromMessage, getOperationContextFromMessage,
getAgentRuntimeStartTimeByContext,
getOperationsByContext, getOperationsByContext,
getOperationsByMessage, getOperationsByMessage,
getOperationsByType, getOperationsByType,