mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ feat(agent): show topic sidebar status indicators (#15739)
This commit is contained in:
@@ -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...",
|
||||
|
||||
@@ -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": "搜索话题…",
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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<TimingLogger>();
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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<OpStatusTrayProps>(({ topAttached }) => {
|
||||
<span className={cx(styles.metric, styles.statusMetric)}>
|
||||
<ActivityGlyph />
|
||||
<span className={cx(styles.statusText, shinyTextStyles.shinyText)}>{statusText}...</span>
|
||||
<span className={styles.timerValue}>{formatDuration(elapsed)}</span>
|
||||
<span className={styles.timerValue}>{formatElapsedClockTime(elapsed)}</span>
|
||||
</span>
|
||||
|
||||
{metrics.length > 0 && (
|
||||
|
||||
@@ -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;
|
||||
}) => (
|
||||
<div data-active={String(active)} data-href={href} data-testid="nav-item">
|
||||
{title}
|
||||
{extra}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -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(<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 { 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<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 {
|
||||
active?: boolean;
|
||||
fav?: boolean;
|
||||
@@ -124,7 +167,10 @@ const TopicItem = memo<TopicItemProps>(
|
||||
// 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<TopicItemProps>(
|
||||
contextMenuItems={dropdownMenu}
|
||||
description={workingDirectoryNode}
|
||||
disabled={editing}
|
||||
extra={<RunningElapsedTime agentId={activeAgentId} topicId={id} />}
|
||||
href={href}
|
||||
title={title === '...' ? <DotsLoading gap={3} size={4} /> : title}
|
||||
titleColor={cssVar.colorText}
|
||||
|
||||
+222
-81
@@ -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<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 { 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 (
|
||||
<AccordionItem
|
||||
itemKey={id}
|
||||
paddingBlock={4}
|
||||
paddingInline={4}
|
||||
action={
|
||||
canAddTopic ? (
|
||||
<ActionIcon
|
||||
icon={PlusIcon}
|
||||
size={'small'}
|
||||
title={t('actions.addNewTopicInProject', { directory: title })}
|
||||
tooltipProps={{ placement: 'right' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleAddTopic();
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
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>
|
||||
<Flexbox horizontal align={'center'} gap={3}>
|
||||
{items.map(({ className, count, icon, label, loading }) => (
|
||||
<Tooltip key={label} title={label}>
|
||||
<span aria-label={label} className={cx(styles.statusBadge, className)} role="status">
|
||||
{loading ? (
|
||||
<RingLoadingIcon
|
||||
ringColor={`color-mix(in srgb, ${cssVar.colorWarning} 28%, transparent)`}
|
||||
size={11}
|
||||
style={{ color: cssVar.colorWarning }}
|
||||
/>
|
||||
) : (
|
||||
icon && <Icon icon={icon} size={{ size: 11, strokeWidth: 2 }} />
|
||||
)}
|
||||
{count}
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
+64
@@ -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);
|
||||
});
|
||||
});
|
||||
+41
@@ -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 {
|
||||
activeThreadId?: string;
|
||||
activeTopicId?: string;
|
||||
expanded: boolean;
|
||||
group: GroupedTopic;
|
||||
}
|
||||
|
||||
@@ -75,6 +76,7 @@ const GroupedAccordion = memo<GroupedAccordionProps>(({ GroupItem }) => {
|
||||
<GroupItem
|
||||
activeThreadId={activeThreadId}
|
||||
activeTopicId={activeTopicId}
|
||||
expanded={expandedKeys.includes(group.id)}
|
||||
group={group}
|
||||
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', () => {
|
||||
it('should prefer the running executeToolCall start time', () => {
|
||||
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
|
||||
* Includes sendMessage in addition to AI runtime operations,
|
||||
@@ -603,6 +634,7 @@ export const operationSelectors = {
|
||||
getDeepestRunningOperationByMessage,
|
||||
getOperationById,
|
||||
getOperationContextFromMessage,
|
||||
getAgentRuntimeStartTimeByContext,
|
||||
getOperationsByContext,
|
||||
getOperationsByMessage,
|
||||
getOperationsByType,
|
||||
|
||||
Reference in New Issue
Block a user