mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +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.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...",
|
||||||
|
|||||||
@@ -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": "搜索话题…",
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
+222
-81
@@ -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;
|
||||||
|
|||||||
+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 {
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user