@steerable/agent-shell-web 0.6.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +91 -0
- package/README.md +20 -0
- package/package.json +69 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/src/AppShell.tsx +24 -0
- package/src/assets/logo-generic.png +0 -0
- package/src/brand.test.ts +22 -0
- package/src/brand.ts +48 -0
- package/src/components/AgentSidebar.test.tsx +127 -0
- package/src/components/AgentSidebar.tsx +1010 -0
- package/src/components/ChatHeader.test.tsx +230 -0
- package/src/components/ChatHeader.tsx +436 -0
- package/src/components/DockPanelHeader.tsx +69 -0
- package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
- package/src/components/LocalLlmSettingsModal.tsx +72 -0
- package/src/components/TerminalPanel.tsx +68 -0
- package/src/components/TerminalView.tsx +228 -0
- package/src/components/chat/ApprovalModal.test.tsx +83 -0
- package/src/components/chat/ApprovalModal.tsx +229 -0
- package/src/components/chat/AskUserModalHost.test.tsx +176 -0
- package/src/components/chat/AskUserModalHost.tsx +61 -0
- package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
- package/src/components/chat/AssistantMessage.test.tsx +68 -0
- package/src/components/chat/AssistantMessage.tsx +469 -0
- package/src/components/chat/ChatInput.test.tsx +609 -0
- package/src/components/chat/ChatInput.tsx +2068 -0
- package/src/components/chat/ChatProjectBadge.tsx +362 -0
- package/src/components/chat/EmptyChat.tsx +18 -0
- package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
- package/src/components/chat/ExecPolicyPicker.tsx +120 -0
- package/src/components/chat/ExecutedActionsCard.tsx +216 -0
- package/src/components/chat/InterruptedTurnCard.tsx +53 -0
- package/src/components/chat/LocalChatPanel.tsx +408 -0
- package/src/components/chat/Markdown.test.tsx +170 -0
- package/src/components/chat/Markdown.tsx +432 -0
- package/src/components/chat/MessageList.tsx +332 -0
- package/src/components/chat/ModelPicker.test.tsx +149 -0
- package/src/components/chat/ModelPicker.tsx +289 -0
- package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
- package/src/components/chat/PlanTodoList.tsx +121 -0
- package/src/components/chat/ProjectTrustBanner.tsx +91 -0
- package/src/components/chat/SessionTreeModal.test.tsx +161 -0
- package/src/components/chat/SessionTreeModal.tsx +221 -0
- package/src/components/chat/StreamingStatus.tsx +95 -0
- package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
- package/src/components/chat/TaskOutcomeCards.tsx +128 -0
- package/src/components/chat/TaskPanelModal.test.tsx +198 -0
- package/src/components/chat/TaskPanelModal.tsx +310 -0
- package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
- package/src/components/chat/TaskProcessPanel.tsx +208 -0
- package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
- package/src/components/chat/TurnProcessGroup.tsx +193 -0
- package/src/components/chat/UnifiedDiff.tsx +31 -0
- package/src/components/chat/UserMessage.tsx +155 -0
- package/src/components/chat/elapsed.test.ts +42 -0
- package/src/components/chat/elapsed.ts +45 -0
- package/src/components/chat/executed-actions-model.test.ts +134 -0
- package/src/components/chat/executed-actions-model.ts +143 -0
- package/src/components/chat/orchestration-children-model.test.ts +115 -0
- package/src/components/chat/orchestration-children-model.ts +104 -0
- package/src/components/chat/timestamp.ts +24 -0
- package/src/components/chat/turn-timeline.test.ts +97 -0
- package/src/components/chat/turn-timeline.ts +132 -0
- package/src/components/chat/useChatTasks.test.ts +136 -0
- package/src/components/chat/useChatTasks.ts +134 -0
- package/src/components/chat/useCopy.ts +62 -0
- package/src/components/settings/AgentCapabilityFields.tsx +343 -0
- package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
- package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
- package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
- package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
- package/src/components/settings/LlmSettingsPanel.tsx +875 -0
- package/src/components/settings/McpSettingsPanel.tsx +466 -0
- package/src/components/settings/ModelCapabilityChips.tsx +46 -0
- package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
- package/src/components/settings/ModelIdCombobox.tsx +185 -0
- package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
- package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
- package/src/components/settings/SettingsSaveButton.tsx +35 -0
- package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
- package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
- package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
- package/src/components/settings/UsagePanel.tsx +196 -0
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
- package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
- package/src/components/settings/compat-flags-model.test.ts +71 -0
- package/src/components/settings/compat-flags-model.ts +57 -0
- package/src/components/settings/llm-vendors.test.ts +171 -0
- package/src/components/settings/llm-vendors.ts +275 -0
- package/src/components/settings/model-capabilities.test.ts +88 -0
- package/src/components/settings/model-capabilities.ts +123 -0
- package/src/components/settings/preset-choice-model.test.ts +96 -0
- package/src/components/settings/preset-choice-model.ts +75 -0
- package/src/fixtures/browser-dev-data.json +315 -0
- package/src/hooks/useChatsAndAgents.ts +182 -0
- package/src/layouts/AgentLayout.tsx +574 -0
- package/src/lib/attachments.ts +78 -0
- package/src/lib/browser-dev-electron-mock.ts +640 -0
- package/src/lib/chat-title.ts +33 -0
- package/src/lib/chat-transport.ts +346 -0
- package/src/lib/date-groups.ts +34 -0
- package/src/lib/demo-flag.ts +15 -0
- package/src/lib/electron-bridge.ts +270 -0
- package/src/lib/exec-policy.ts +25 -0
- package/src/lib/http-bridge.ts +293 -0
- package/src/lib/insights.ts +17 -0
- package/src/lib/local-api.ts +714 -0
- package/src/lib/pending-first-message.test.ts +17 -0
- package/src/lib/pending-first-message.ts +36 -0
- package/src/lib/slash-sources.ts +191 -0
- package/src/main.tsx +103 -0
- package/src/packs/registry.ts +64 -0
- package/src/pages/AgentPage.tsx +1226 -0
- package/src/pages/ChatPanelPreviewPage.tsx +264 -0
- package/src/pages/SettingsPage.test.tsx +102 -0
- package/src/pages/SettingsPage.tsx +197 -0
- package/src/styles/index.css +156 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.base.ts +94 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TaskProcessPanel:拉 GET /tasks/:id/process 渲染时间线;
|
|
3
|
+
* task-process 广播更新 live 块。
|
|
4
|
+
*/
|
|
5
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { TaskProcessPanel } from './TaskProcessPanel';
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
cleanup();
|
|
11
|
+
delete (window as { electron?: unknown }).electron;
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function installBridge(timeline: unknown[], live = false, stale = false) {
|
|
16
|
+
const request = vi.fn((input: { method: string; path: string }) => {
|
|
17
|
+
if (input.method === 'GET' && input.path.endsWith('/process')) {
|
|
18
|
+
return Promise.resolve({
|
|
19
|
+
task: {
|
|
20
|
+
id: 'task-1',
|
|
21
|
+
chatId: 'chat_1',
|
|
22
|
+
task: '问好循环',
|
|
23
|
+
status: live ? 'running' : 'completed',
|
|
24
|
+
},
|
|
25
|
+
timeline,
|
|
26
|
+
live,
|
|
27
|
+
stale,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return Promise.reject(new Error(`unexpected ${input.method} ${input.path}`));
|
|
31
|
+
});
|
|
32
|
+
const listeners: Array<
|
|
33
|
+
(payload: { chatId: string; taskId: string; timeline: unknown; live: boolean }) => void
|
|
34
|
+
> = [];
|
|
35
|
+
(window as { electron?: unknown }).electron = {
|
|
36
|
+
localBackend: { request },
|
|
37
|
+
onTaskProcess: (
|
|
38
|
+
cb: (payload: { chatId: string; taskId: string; timeline: unknown; live: boolean }) => void,
|
|
39
|
+
) => {
|
|
40
|
+
listeners.push(cb);
|
|
41
|
+
return () => {
|
|
42
|
+
const i = listeners.indexOf(cb);
|
|
43
|
+
if (i >= 0) listeners.splice(i, 1);
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
emit: (payload: { chatId: string; taskId: string; timeline: unknown; live: boolean }) => {
|
|
49
|
+
for (const cb of listeners) cb(payload);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('TaskProcessPanel', () => {
|
|
55
|
+
it('渲染历史推理与工具调用', async () => {
|
|
56
|
+
installBridge([
|
|
57
|
+
{ type: 'reasoning', content: '先跑命令。' },
|
|
58
|
+
{ type: 'tools', actions: [{ tool: 'local_exec_shell', success: true }] },
|
|
59
|
+
{ type: 'text', content: '问好完成。' },
|
|
60
|
+
]);
|
|
61
|
+
render(
|
|
62
|
+
<TaskProcessPanel
|
|
63
|
+
inspected={{ id: 'task-1', chatId: 'chat_1', title: '问好循环' }}
|
|
64
|
+
onClose={() => {}}
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
await waitFor(() => screen.getByText('问好完成。'));
|
|
68
|
+
expect(screen.getByText(/1 次工具调用/)).toBeTruthy();
|
|
69
|
+
expect(screen.getByText(/已结束/)).toBeTruthy();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('task-process 推送更新时间线', async () => {
|
|
73
|
+
const { emit } = installBridge([], true, false);
|
|
74
|
+
render(
|
|
75
|
+
<TaskProcessPanel
|
|
76
|
+
inspected={{ id: 'task-1', chatId: 'chat_1', title: '问好循环' }}
|
|
77
|
+
onClose={() => {}}
|
|
78
|
+
/>,
|
|
79
|
+
);
|
|
80
|
+
await waitFor(() => screen.getByText(/正在推理/));
|
|
81
|
+
emit({
|
|
82
|
+
chatId: 'chat_1',
|
|
83
|
+
taskId: 'task-1',
|
|
84
|
+
live: true,
|
|
85
|
+
timeline: [{ type: 'reasoning', content: '开始第一轮。' }],
|
|
86
|
+
});
|
|
87
|
+
await waitFor(() => screen.getByText('开始第一轮。'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('live 增量时把滚动条钉在底部', async () => {
|
|
91
|
+
const scrollTo = vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {});
|
|
92
|
+
const { emit } = installBridge([], true, false);
|
|
93
|
+
render(
|
|
94
|
+
<TaskProcessPanel
|
|
95
|
+
inspected={{ id: 'task-1', chatId: 'chat_1', title: '问好循环' }}
|
|
96
|
+
onClose={() => {}}
|
|
97
|
+
/>,
|
|
98
|
+
);
|
|
99
|
+
await waitFor(() => screen.getByText(/正在推理/));
|
|
100
|
+
scrollTo.mockClear();
|
|
101
|
+
|
|
102
|
+
emit({
|
|
103
|
+
chatId: 'chat_1',
|
|
104
|
+
taskId: 'task-1',
|
|
105
|
+
live: true,
|
|
106
|
+
timeline: [{ type: 'reasoning', content: '开始第一轮。' }],
|
|
107
|
+
});
|
|
108
|
+
await waitFor(() => screen.getByText('开始第一轮。'));
|
|
109
|
+
expect(scrollTo).toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { LuArrowDown, LuLoaderCircle, LuTerminal } from 'react-icons/lu';
|
|
3
|
+
import { DockHeaderButton, DockPanelHeader } from '@/components/DockPanelHeader';
|
|
4
|
+
import { getTaskProcess } from '@/lib/local-api';
|
|
5
|
+
import { getElectronBridge } from '@/lib/electron-bridge';
|
|
6
|
+
import { Markdown } from '@/components/chat/Markdown';
|
|
7
|
+
import { TurnProcessGroup } from '@/components/chat/TurnProcessGroup';
|
|
8
|
+
import { parseTurnBlocks, type TurnBlock } from '@/components/chat/turn-timeline';
|
|
9
|
+
|
|
10
|
+
export interface InspectedTask {
|
|
11
|
+
id: string;
|
|
12
|
+
chatId: string;
|
|
13
|
+
title: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const NEAR_BOTTOM_THRESHOLD_PX = 100;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 右侧栏:后台任务的模型推理过程(与主对话 TurnProcessGroup 同形)。
|
|
20
|
+
* 数据来自 GET /tasks/:id/process,live 增量走 task-process 广播。
|
|
21
|
+
*/
|
|
22
|
+
export function TaskProcessPanel({
|
|
23
|
+
inspected,
|
|
24
|
+
onClose,
|
|
25
|
+
onShowTerminal,
|
|
26
|
+
}: {
|
|
27
|
+
inspected: InspectedTask;
|
|
28
|
+
onClose: () => void;
|
|
29
|
+
onShowTerminal?: () => void;
|
|
30
|
+
}) {
|
|
31
|
+
const [blocks, setBlocks] = useState<TurnBlock[]>([]);
|
|
32
|
+
const [live, setLive] = useState(false);
|
|
33
|
+
const [stale, setStale] = useState(false);
|
|
34
|
+
const [loading, setLoading] = useState(true);
|
|
35
|
+
const [error, setError] = useState<string | null>(null);
|
|
36
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
38
|
+
|
|
39
|
+
const checkAtBottom = useCallback(() => {
|
|
40
|
+
const el = containerRef.current;
|
|
41
|
+
if (!el) return;
|
|
42
|
+
const atBottom =
|
|
43
|
+
el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD_PX;
|
|
44
|
+
setIsAtBottom(atBottom);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
|
48
|
+
const el = containerRef.current;
|
|
49
|
+
if (!el) return;
|
|
50
|
+
if (behavior === 'smooth') {
|
|
51
|
+
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
el.scrollTop = el.scrollHeight;
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const el = containerRef.current;
|
|
59
|
+
if (!el) return;
|
|
60
|
+
el.addEventListener('scroll', checkAtBottom, { passive: true });
|
|
61
|
+
return () => {
|
|
62
|
+
el.removeEventListener('scroll', checkAtBottom);
|
|
63
|
+
};
|
|
64
|
+
}, [checkAtBottom]);
|
|
65
|
+
|
|
66
|
+
// 换任务 / 首屏:没有「之前在不在底部」可言,直接钉到底。
|
|
67
|
+
useLayoutEffect(() => {
|
|
68
|
+
scrollToBottom('instant' as ScrollBehavior);
|
|
69
|
+
setIsAtBottom(true);
|
|
70
|
+
}, [inspected.id, scrollToBottom]);
|
|
71
|
+
|
|
72
|
+
// live 增量只在用户还贴着底部时跟滚——往上翻看前面的工具调用时不要拽走。
|
|
73
|
+
const timelineSig = blocks
|
|
74
|
+
.map((block) =>
|
|
75
|
+
block.type === 'tools' ? `t${block.actions.length}` : `c${block.content.length}`,
|
|
76
|
+
)
|
|
77
|
+
.join('|');
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!isAtBottom) return;
|
|
80
|
+
scrollToBottom('smooth');
|
|
81
|
+
}, [timelineSig, live, isAtBottom, scrollToBottom]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
let cancelled = false;
|
|
85
|
+
setLoading(true);
|
|
86
|
+
setError(null);
|
|
87
|
+
void (async () => {
|
|
88
|
+
try {
|
|
89
|
+
const snapshot = await getTaskProcess(inspected.id);
|
|
90
|
+
if (cancelled) return;
|
|
91
|
+
setBlocks(parseTurnBlocks(snapshot.timeline) ?? []);
|
|
92
|
+
setLive(snapshot.live);
|
|
93
|
+
setStale(snapshot.stale);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (cancelled) return;
|
|
96
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
97
|
+
setBlocks([]);
|
|
98
|
+
} finally {
|
|
99
|
+
if (!cancelled) setLoading(false);
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
return () => {
|
|
103
|
+
cancelled = true;
|
|
104
|
+
};
|
|
105
|
+
}, [inspected.id]);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const bridge = getElectronBridge();
|
|
109
|
+
if (!bridge?.onTaskProcess) return;
|
|
110
|
+
return bridge.onTaskProcess((payload) => {
|
|
111
|
+
if (payload.taskId !== inspected.id) return;
|
|
112
|
+
const next = parseTurnBlocks(payload.timeline);
|
|
113
|
+
if (next) setBlocks(next);
|
|
114
|
+
else if (Array.isArray(payload.timeline) && payload.timeline.length === 0) {
|
|
115
|
+
setBlocks([]);
|
|
116
|
+
}
|
|
117
|
+
setLive(payload.live);
|
|
118
|
+
if (payload.live) setStale(false);
|
|
119
|
+
});
|
|
120
|
+
}, [inspected.id]);
|
|
121
|
+
|
|
122
|
+
const statusLabel = live
|
|
123
|
+
? '正在推理'
|
|
124
|
+
: stale
|
|
125
|
+
? '进程已中断(表上仍是运行中)'
|
|
126
|
+
: '已结束';
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
className="flex h-full w-full flex-col bg-agent-canvas text-agent-foreground"
|
|
131
|
+
data-testid="task-process-panel"
|
|
132
|
+
>
|
|
133
|
+
<DockPanelHeader
|
|
134
|
+
title={`后台推理 · ${statusLabel}`}
|
|
135
|
+
onClose={onClose}
|
|
136
|
+
closeLabel="关闭过程面板"
|
|
137
|
+
actions={
|
|
138
|
+
onShowTerminal && (
|
|
139
|
+
<DockHeaderButton
|
|
140
|
+
icon={<LuTerminal className="h-3 w-3" />}
|
|
141
|
+
label="终端"
|
|
142
|
+
title="切换到终端"
|
|
143
|
+
onClick={onShowTerminal}
|
|
144
|
+
/>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
/>
|
|
148
|
+
<div className="border-b border-agent-border/60 px-3 py-2">
|
|
149
|
+
<div className="line-clamp-3 text-xs leading-relaxed text-agent-foreground">
|
|
150
|
+
{inspected.title}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
<div className="relative min-h-0 flex-1 overflow-hidden">
|
|
154
|
+
<div
|
|
155
|
+
ref={containerRef}
|
|
156
|
+
className="h-full overflow-y-auto p-3"
|
|
157
|
+
data-testid="task-process-scroll"
|
|
158
|
+
>
|
|
159
|
+
{loading ? (
|
|
160
|
+
<div className="flex items-center gap-2 text-xs text-agent-muted-foreground">
|
|
161
|
+
<LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
|
162
|
+
正在加载推理过程…
|
|
163
|
+
</div>
|
|
164
|
+
) : error ? (
|
|
165
|
+
<div className="text-xs text-red-600 dark:text-red-300">{error}</div>
|
|
166
|
+
) : (
|
|
167
|
+
<TurnProcessGroup
|
|
168
|
+
blocks={blocks}
|
|
169
|
+
isStreaming={live}
|
|
170
|
+
agents={[]}
|
|
171
|
+
chats={[]}
|
|
172
|
+
emptyFallback={
|
|
173
|
+
<div className="text-xs text-agent-muted-foreground">
|
|
174
|
+
{stale
|
|
175
|
+
? '任务流已随进程结束,没有留下可回放的推理记录。'
|
|
176
|
+
: live
|
|
177
|
+
? '任务已启动,推理过程会显示在这里。'
|
|
178
|
+
: '没有可显示的推理过程。'}
|
|
179
|
+
</div>
|
|
180
|
+
}
|
|
181
|
+
renderAnswer={(block) => (
|
|
182
|
+
<div className="text-sm leading-relaxed text-agent-foreground">
|
|
183
|
+
<Markdown agents={[]} chats={[]}>{block.content}</Markdown>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
{!isAtBottom && !loading && (
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={() => {
|
|
193
|
+
setIsAtBottom(true);
|
|
194
|
+
scrollToBottom('smooth');
|
|
195
|
+
}}
|
|
196
|
+
className="absolute bottom-3 right-3 flex h-8 w-8 items-center justify-center rounded-full border border-agent-border bg-agent-canvas text-agent-foreground shadow-md transition-colors hover:bg-agent-foreground/5"
|
|
197
|
+
title="回到底部"
|
|
198
|
+
aria-label="回到底部"
|
|
199
|
+
>
|
|
200
|
+
<LuArrowDown className="h-4 w-4" />
|
|
201
|
+
</button>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export default TaskProcessPanel;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { TurnBlock } from './turn-timeline';
|
|
4
|
+
|
|
5
|
+
vi.mock('./Markdown', () => ({
|
|
6
|
+
Markdown: ({ children }: { children: string }) => <span>{children}</span>,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock('./ExecutedActionsCard', () => ({
|
|
10
|
+
ToolsFlow: ({ actions }: { actions: Array<{ tool: string }> }) => (
|
|
11
|
+
<div data-testid="tools-flow">{actions.map((action) => action.tool).join(',')}</div>
|
|
12
|
+
),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const { TurnProcessGroup } = await import('./TurnProcessGroup');
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
cleanup();
|
|
19
|
+
vi.useRealTimers();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const tool = (name: string): TurnBlock => ({
|
|
23
|
+
type: 'tools',
|
|
24
|
+
actions: [{ tool: name, arguments: {} }],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const blocks: TurnBlock[] = [
|
|
28
|
+
{ type: 'reasoning', content: '先读配置' },
|
|
29
|
+
tool('csv_get_config'),
|
|
30
|
+
{ type: 'reasoning', content: '再查天气' },
|
|
31
|
+
tool('web_fetch'),
|
|
32
|
+
{ type: 'text', content: '本地 CSV 配置' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function renderGroup(overrides: {
|
|
36
|
+
blocks?: TurnBlock[];
|
|
37
|
+
isStreaming?: boolean;
|
|
38
|
+
startedAtMs?: number;
|
|
39
|
+
durationMs?: number;
|
|
40
|
+
} = {}) {
|
|
41
|
+
return render(
|
|
42
|
+
<TurnProcessGroup
|
|
43
|
+
blocks={overrides.blocks ?? blocks}
|
|
44
|
+
isStreaming={overrides.isStreaming ?? false}
|
|
45
|
+
startedAtMs={overrides.startedAtMs}
|
|
46
|
+
durationMs={overrides.durationMs}
|
|
47
|
+
agents={[]}
|
|
48
|
+
chats={[]}
|
|
49
|
+
emptyFallback={<div>empty</div>}
|
|
50
|
+
renderAnswer={(block) => <div data-testid="answer">{block.content}</div>}
|
|
51
|
+
/>,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('TurnProcessGroup', () => {
|
|
56
|
+
it('collapses think+tool process after a finished summary, leaving the answer visible', () => {
|
|
57
|
+
renderGroup({ isStreaming: false });
|
|
58
|
+
|
|
59
|
+
const toggle = screen.getByRole('button');
|
|
60
|
+
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
|
61
|
+
expect(toggle.textContent).toContain('2 次工具调用 · 已思考');
|
|
62
|
+
expect(screen.queryByText('先读配置')).toBeNull();
|
|
63
|
+
expect(screen.queryByTestId('tools-flow')).toBeNull();
|
|
64
|
+
expect(screen.getByTestId('answer').textContent).toBe('本地 CSV 配置');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('keeps the process expanded while streaming, then auto-collapses when the summary lands', () => {
|
|
68
|
+
const view = renderGroup({
|
|
69
|
+
isStreaming: true,
|
|
70
|
+
blocks: blocks.slice(0, 4),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(screen.getByRole('button').textContent).toContain('正在执行…');
|
|
74
|
+
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('true');
|
|
75
|
+
expect(screen.getByText('先读配置')).toBeTruthy();
|
|
76
|
+
expect(screen.queryByTestId('answer')).toBeNull();
|
|
77
|
+
|
|
78
|
+
view.rerender(
|
|
79
|
+
<TurnProcessGroup
|
|
80
|
+
blocks={blocks}
|
|
81
|
+
isStreaming={false}
|
|
82
|
+
agents={[]}
|
|
83
|
+
chats={[]}
|
|
84
|
+
emptyFallback={<div>empty</div>}
|
|
85
|
+
renderAnswer={(block) => <div data-testid="answer">{block.content}</div>}
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false');
|
|
90
|
+
expect(screen.getByRole('button').textContent).toContain('2 次工具调用 · 已思考');
|
|
91
|
+
expect(screen.queryByText('先读配置')).toBeNull();
|
|
92
|
+
expect(screen.getByTestId('answer').textContent).toBe('本地 CSV 配置');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('expands the process again when the disclosure is clicked', () => {
|
|
96
|
+
renderGroup({ isStreaming: false });
|
|
97
|
+
fireEvent.click(screen.getByRole('button'));
|
|
98
|
+
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('true');
|
|
99
|
+
expect(screen.getByText('再查天气')).toBeTruthy();
|
|
100
|
+
expect(screen.getAllByTestId('tools-flow')).toHaveLength(2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('does not fold a tools-only turn that never produced a summary', () => {
|
|
104
|
+
renderGroup({
|
|
105
|
+
isStreaming: false,
|
|
106
|
+
blocks: [tool('csv_get_config')],
|
|
107
|
+
});
|
|
108
|
+
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('true');
|
|
109
|
+
expect(screen.getByTestId('tools-flow').textContent).toBe('csv_get_config');
|
|
110
|
+
expect(screen.queryByTestId('answer')).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('shows Codex-style elapsed while streaming', () => {
|
|
114
|
+
vi.useFakeTimers();
|
|
115
|
+
vi.setSystemTime(new Date('2026-09-08T12:00:12.000Z'));
|
|
116
|
+
renderGroup({
|
|
117
|
+
isStreaming: true,
|
|
118
|
+
blocks: blocks.slice(0, 4),
|
|
119
|
+
startedAtMs: Date.parse('2026-09-08T12:00:00.000Z'),
|
|
120
|
+
});
|
|
121
|
+
expect(screen.getByRole('button').textContent).toContain('正在执行… 12s');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('appends worked duration after the turn finishes', () => {
|
|
125
|
+
renderGroup({ isStreaming: false, durationMs: 83_000 });
|
|
126
|
+
expect(screen.getByRole('button').textContent).toContain(
|
|
127
|
+
'2 次工具调用 · 已思考 · 工作了 1m 23s',
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('omits worked duration for sub-second finished turns', () => {
|
|
132
|
+
renderGroup({ isStreaming: false, durationMs: 400 });
|
|
133
|
+
const label = screen.getByRole('button').textContent ?? '';
|
|
134
|
+
expect(label).toContain('2 次工具调用 · 已思考');
|
|
135
|
+
expect(label).not.toContain('工作了');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('freezes live elapsed when the stream ends before durationMs lands', () => {
|
|
139
|
+
vi.useFakeTimers();
|
|
140
|
+
vi.setSystemTime(new Date('2026-09-08T12:00:12.000Z'));
|
|
141
|
+
const startedAtMs = Date.parse('2026-09-08T12:00:00.000Z');
|
|
142
|
+
const view = renderGroup({
|
|
143
|
+
isStreaming: true,
|
|
144
|
+
blocks: blocks.slice(0, 4),
|
|
145
|
+
startedAtMs,
|
|
146
|
+
});
|
|
147
|
+
view.rerender(
|
|
148
|
+
<TurnProcessGroup
|
|
149
|
+
blocks={blocks}
|
|
150
|
+
isStreaming={false}
|
|
151
|
+
agents={[]}
|
|
152
|
+
chats={[]}
|
|
153
|
+
emptyFallback={<div>empty</div>}
|
|
154
|
+
renderAnswer={(block) => <div data-testid="answer">{block.content}</div>}
|
|
155
|
+
/>,
|
|
156
|
+
);
|
|
157
|
+
expect(screen.getByRole('button').textContent).toContain(
|
|
158
|
+
'2 次工具调用 · 已思考 · 工作了 12s',
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { LuChevronDown, LuChevronRight } from 'react-icons/lu';
|
|
3
|
+
import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
|
|
4
|
+
import { Markdown } from './Markdown';
|
|
5
|
+
import { ToolsFlow } from './ExecutedActionsCard';
|
|
6
|
+
import { formatElapsedCompact } from './elapsed';
|
|
7
|
+
import {
|
|
8
|
+
countProcessTools,
|
|
9
|
+
processHasReasoning,
|
|
10
|
+
splitTurnProcess,
|
|
11
|
+
type TurnBlock,
|
|
12
|
+
} from './turn-timeline';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Codex / DeepSeek-style turn process: think + tool rows stay in one
|
|
16
|
+
* disclosure. Expanded while the turn streams; collapsed once the trailing
|
|
17
|
+
* summary is on screen. The summary itself stays outside the fold.
|
|
18
|
+
* Elapsed time ticks while streaming (Codex status line) and freezes as
|
|
19
|
+
* 「工作了 …」 after the turn ends.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function useLiveElapsedMs(
|
|
23
|
+
startedAtMs: number | undefined,
|
|
24
|
+
enabled: boolean,
|
|
25
|
+
): number | undefined {
|
|
26
|
+
const [now, setNow] = useState(() => Date.now());
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!enabled || startedAtMs == null) return;
|
|
29
|
+
setNow(Date.now());
|
|
30
|
+
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
|
31
|
+
return () => window.clearInterval(id);
|
|
32
|
+
}, [enabled, startedAtMs]);
|
|
33
|
+
if (startedAtMs == null) return undefined;
|
|
34
|
+
return Math.max(0, now - startedAtMs);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function processLabel(
|
|
38
|
+
process: TurnBlock[],
|
|
39
|
+
isStreaming: boolean,
|
|
40
|
+
elapsedMs: number | undefined,
|
|
41
|
+
): string {
|
|
42
|
+
const elapsed =
|
|
43
|
+
elapsedMs != null && (isStreaming || elapsedMs >= 1000)
|
|
44
|
+
? formatElapsedCompact(elapsedMs)
|
|
45
|
+
: null;
|
|
46
|
+
if (isStreaming) {
|
|
47
|
+
return elapsed ? `正在执行… ${elapsed}` : '正在执行…';
|
|
48
|
+
}
|
|
49
|
+
const parts: string[] = [];
|
|
50
|
+
const tools = countProcessTools(process);
|
|
51
|
+
if (tools > 0) parts.push(`${tools} 次工具调用`);
|
|
52
|
+
if (processHasReasoning(process)) parts.push('已思考');
|
|
53
|
+
if (elapsed) parts.push(`工作了 ${elapsed}`);
|
|
54
|
+
if (parts.length === 0) return '执行过程';
|
|
55
|
+
return parts.join(' · ');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ProcessBlocks({
|
|
59
|
+
blocks,
|
|
60
|
+
isStreaming,
|
|
61
|
+
agents,
|
|
62
|
+
chats,
|
|
63
|
+
}: {
|
|
64
|
+
blocks: TurnBlock[];
|
|
65
|
+
isStreaming: boolean;
|
|
66
|
+
agents: LocalChatAgent[];
|
|
67
|
+
chats: LocalChat[];
|
|
68
|
+
}) {
|
|
69
|
+
const lastIndex = blocks.length - 1;
|
|
70
|
+
return (
|
|
71
|
+
<div className="space-y-1.5 border-l border-agent-border/70 pl-3">
|
|
72
|
+
{blocks.map((block, index) => {
|
|
73
|
+
const isLast = index === lastIndex;
|
|
74
|
+
if (block.type === 'reasoning') {
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
key={`reasoning-${index}`}
|
|
78
|
+
className="text-xs leading-relaxed text-agent-muted-foreground"
|
|
79
|
+
>
|
|
80
|
+
<Markdown agents={agents} chats={chats}>{block.content}</Markdown>
|
|
81
|
+
{isStreaming && isLast && (
|
|
82
|
+
<span className="ml-0.5 inline-block h-3 w-[2px] animate-agent-cursor-blink bg-agent-muted-foreground/60 align-text-bottom" />
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (block.type === 'tools') {
|
|
88
|
+
return <ToolsFlow key={`tools-${index}`} actions={block.actions} compact />;
|
|
89
|
+
}
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
key={`text-${index}`}
|
|
93
|
+
className="markdown-content text-xs leading-relaxed text-agent-muted-foreground"
|
|
94
|
+
>
|
|
95
|
+
<Markdown agents={agents} chats={chats}>{block.content}</Markdown>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
})}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function TurnProcessGroup({
|
|
104
|
+
blocks,
|
|
105
|
+
isStreaming,
|
|
106
|
+
agents,
|
|
107
|
+
chats,
|
|
108
|
+
emptyFallback,
|
|
109
|
+
streamingHint,
|
|
110
|
+
startedAtMs,
|
|
111
|
+
durationMs,
|
|
112
|
+
renderAnswer,
|
|
113
|
+
}: {
|
|
114
|
+
blocks: TurnBlock[];
|
|
115
|
+
isStreaming: boolean;
|
|
116
|
+
agents: LocalChatAgent[];
|
|
117
|
+
chats: LocalChat[];
|
|
118
|
+
emptyFallback: ReactNode;
|
|
119
|
+
/** Shown while streaming after a tools row and before the summary lands. */
|
|
120
|
+
streamingHint?: ReactNode;
|
|
121
|
+
/** Epoch ms when this turn started — live ticker while `isStreaming`. */
|
|
122
|
+
startedAtMs?: number;
|
|
123
|
+
/** Frozen wall-clock of a finished turn (metadata or just-ended stream). */
|
|
124
|
+
durationMs?: number;
|
|
125
|
+
renderAnswer: (block: Extract<TurnBlock, { type: 'text' }>, isLast: boolean) => ReactNode;
|
|
126
|
+
}) {
|
|
127
|
+
const { process, answer } = splitTurnProcess(blocks);
|
|
128
|
+
const [open, setOpen] = useState(() => isStreaming || answer.length === 0);
|
|
129
|
+
const wasStreamingRef = useRef(isStreaming);
|
|
130
|
+
const liveElapsedMs = useLiveElapsedMs(startedAtMs, isStreaming);
|
|
131
|
+
const lastLiveElapsedRef = useRef<number | undefined>(undefined);
|
|
132
|
+
if (isStreaming && liveElapsedMs != null) {
|
|
133
|
+
lastLiveElapsedRef.current = liveElapsedMs;
|
|
134
|
+
}
|
|
135
|
+
const elapsedMs = isStreaming
|
|
136
|
+
? liveElapsedMs
|
|
137
|
+
: durationMs ?? lastLiveElapsedRef.current;
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const wasStreaming = wasStreamingRef.current;
|
|
141
|
+
if (wasStreaming && !isStreaming && answer.length > 0) {
|
|
142
|
+
setOpen(false);
|
|
143
|
+
}
|
|
144
|
+
if (!wasStreaming && isStreaming) {
|
|
145
|
+
setOpen(true);
|
|
146
|
+
}
|
|
147
|
+
wasStreamingRef.current = isStreaming;
|
|
148
|
+
}, [isStreaming, answer.length]);
|
|
149
|
+
|
|
150
|
+
if (blocks.length === 0) return <>{emptyFallback}</>;
|
|
151
|
+
|
|
152
|
+
const showToggle = process.length > 0;
|
|
153
|
+
const showProcess = !showToggle || open;
|
|
154
|
+
const lastProcess = process[process.length - 1];
|
|
155
|
+
const showStreamingHint =
|
|
156
|
+
Boolean(streamingHint) && isStreaming && answer.length === 0 && lastProcess?.type === 'tools';
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div className="space-y-1.5" data-turn-timeline>
|
|
160
|
+
{showToggle && (
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
onClick={() => setOpen((value) => !value)}
|
|
164
|
+
className="flex w-full items-center gap-1 py-0.5 text-left text-xs text-agent-muted-foreground transition-colors hover:text-agent-foreground"
|
|
165
|
+
aria-expanded={open}
|
|
166
|
+
data-turn-process=""
|
|
167
|
+
data-open={open || undefined}
|
|
168
|
+
>
|
|
169
|
+
{open ? (
|
|
170
|
+
<LuChevronDown className="h-3.5 w-3.5 shrink-0" />
|
|
171
|
+
) : (
|
|
172
|
+
<LuChevronRight className="h-3.5 w-3.5 shrink-0" />
|
|
173
|
+
)}
|
|
174
|
+
<span className="truncate">{processLabel(process, isStreaming, elapsedMs)}</span>
|
|
175
|
+
</button>
|
|
176
|
+
)}
|
|
177
|
+
{showProcess && (
|
|
178
|
+
<ProcessBlocks
|
|
179
|
+
blocks={process}
|
|
180
|
+
isStreaming={isStreaming && answer.length === 0}
|
|
181
|
+
agents={agents}
|
|
182
|
+
chats={chats}
|
|
183
|
+
/>
|
|
184
|
+
)}
|
|
185
|
+
{showStreamingHint ? streamingHint : null}
|
|
186
|
+
{answer.map((block, index) => (
|
|
187
|
+
<Fragment key={`answer-${index}`}>
|
|
188
|
+
{renderAnswer(block, index === answer.length - 1)}
|
|
189
|
+
</Fragment>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|