@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,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatHeader 任务角标契约:后台任务在面板关着的时候也要能被看见,
|
|
3
|
+
* 且只有一件事等着处理时点角标要能跳过任务列表这一层。
|
|
4
|
+
* - 无任务 → 按钮保持素净,不显示计数;
|
|
5
|
+
* - 有任务 → 按钮上色 + 计数,配色按「该不该现在看一眼」取最高优先级
|
|
6
|
+
* (运行中 > 待合并 > 失败 > 全部跑完);
|
|
7
|
+
* - 唯一失败任务 → 点角标直接开推理过程,不经弹层;
|
|
8
|
+
* - 唯一待合并任务 → 点角标开弹层并展开该行(合并/丢弃按钮在那儿);
|
|
9
|
+
* - 有任务在跑或多件待处理 → 仍然先给列表,用户得先挑。
|
|
10
|
+
* 列表本身的订阅与刷新归 useChatTasks,见 chat/useChatTasks.test.ts。
|
|
11
|
+
*/
|
|
12
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import type { LocalChat, LocalTask } from '@/lib/local-api';
|
|
15
|
+
import { ChatHeader } from './ChatHeader';
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
cleanup();
|
|
19
|
+
delete (window as { electron?: unknown }).electron;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const CHAT: LocalChat = {
|
|
23
|
+
id: 'chat_1',
|
|
24
|
+
projectId: null,
|
|
25
|
+
userId: 'u1',
|
|
26
|
+
title: '循环任务',
|
|
27
|
+
agentId: null,
|
|
28
|
+
createdAt: '2026-09-13T01:00:00.000Z',
|
|
29
|
+
updatedAt: '2026-09-13T01:00:00.000Z',
|
|
30
|
+
isPinned: false,
|
|
31
|
+
systemPrompt: null,
|
|
32
|
+
pinnedRefs: null,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
|
|
36
|
+
return {
|
|
37
|
+
id: 'task-1',
|
|
38
|
+
chatId: 'chat_1',
|
|
39
|
+
task: '每 5 分钟问好',
|
|
40
|
+
status: 'completed',
|
|
41
|
+
answer: '你好',
|
|
42
|
+
error: null,
|
|
43
|
+
worktreePath: null,
|
|
44
|
+
worktreeBranch: null,
|
|
45
|
+
worktreeState: null,
|
|
46
|
+
createdAt: '2026-09-13T01:00:00.000Z',
|
|
47
|
+
updatedAt: '2026-09-13T01:01:00.000Z',
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 弹层自带订阅:展开它的用例需要一个能应答 GET /tasks 的 bridge。 */
|
|
53
|
+
function installBridge(tasks: LocalTask[]) {
|
|
54
|
+
(window as { electron?: unknown }).electron = {
|
|
55
|
+
localBackend: {
|
|
56
|
+
request: vi.fn((input: { method: string; path: string }) => {
|
|
57
|
+
if (input.method === 'GET' && input.path.endsWith('/tasks')) {
|
|
58
|
+
return Promise.resolve({ tasks });
|
|
59
|
+
}
|
|
60
|
+
return Promise.reject(new Error(`unexpected request: ${input.path}`));
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
onTaskUpdated: () => () => {},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function taskButton() {
|
|
68
|
+
return screen.getByRole('button', { name: /后台任务/ });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('ChatHeader 后台任务角标', () => {
|
|
72
|
+
it('没有任务时不显示计数', () => {
|
|
73
|
+
render(<ChatHeader chat={CHAT} agent={null} tasks={[]} />);
|
|
74
|
+
|
|
75
|
+
expect(taskButton().getAttribute('data-task-state')).toBeNull();
|
|
76
|
+
expect(taskButton().textContent).toBe('');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('有运行中的任务时显示运行中计数并标成 running', () => {
|
|
80
|
+
render(
|
|
81
|
+
<ChatHeader
|
|
82
|
+
chat={CHAT}
|
|
83
|
+
agent={null}
|
|
84
|
+
tasks={[
|
|
85
|
+
makeTask({ id: 'a', status: 'running' }),
|
|
86
|
+
makeTask({ id: 'b', status: 'running' }),
|
|
87
|
+
makeTask({ id: 'c', status: 'completed' }),
|
|
88
|
+
]}
|
|
89
|
+
/>,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(taskButton().getAttribute('data-task-state')).toBe('running');
|
|
93
|
+
expect(taskButton().textContent).toBe('2');
|
|
94
|
+
expect(taskButton().getAttribute('title')).toBe('共 3 个后台任务(2 个运行中)');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('等依赖的任务也算在推进中', () => {
|
|
98
|
+
render(
|
|
99
|
+
<ChatHeader chat={CHAT} agent={null} tasks={[makeTask({ id: 'a', status: 'blocked' })]} />,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(taskButton().getAttribute('data-task-state')).toBe('running');
|
|
103
|
+
expect(taskButton().getAttribute('title')).toBe('共 1 个后台任务(1 个等依赖)');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('待合并的 worktree 任务优先于失败任务上色', () => {
|
|
107
|
+
render(
|
|
108
|
+
<ChatHeader
|
|
109
|
+
chat={CHAT}
|
|
110
|
+
agent={null}
|
|
111
|
+
tasks={[
|
|
112
|
+
makeTask({ id: 'a', status: 'completed', worktreeState: 'pending' }),
|
|
113
|
+
makeTask({ id: 'b', status: 'failed', error: '炸了' }),
|
|
114
|
+
]}
|
|
115
|
+
/>,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(taskButton().getAttribute('data-task-state')).toBe('review');
|
|
119
|
+
expect(taskButton().textContent).toBe('1');
|
|
120
|
+
expect(taskButton().getAttribute('title')).toBe('共 2 个后台任务(1 个待合并,1 个失败)');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('只有失败任务时标成 failed', () => {
|
|
124
|
+
render(
|
|
125
|
+
<ChatHeader
|
|
126
|
+
chat={CHAT}
|
|
127
|
+
agent={null}
|
|
128
|
+
tasks={[makeTask({ id: 'a', status: 'failed', error: '炸了' })]}
|
|
129
|
+
/>,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(taskButton().getAttribute('data-task-state')).toBe('failed');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('全部跑完时显示总数,不再抢注意力', () => {
|
|
136
|
+
render(
|
|
137
|
+
<ChatHeader chat={CHAT} agent={null} tasks={[makeTask({ id: 'a' }), makeTask({ id: 'b' })]} />,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(taskButton().getAttribute('data-task-state')).toBe('idle');
|
|
141
|
+
expect(taskButton().textContent).toBe('2');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('ChatHeader 角标直达', () => {
|
|
146
|
+
it('唯一失败任务:点角标直接开推理过程,不开弹层', () => {
|
|
147
|
+
const onInspectTask = vi.fn();
|
|
148
|
+
render(
|
|
149
|
+
<ChatHeader
|
|
150
|
+
chat={CHAT}
|
|
151
|
+
agent={null}
|
|
152
|
+
onInspectTask={onInspectTask}
|
|
153
|
+
tasks={[makeTask({ id: 'a', status: 'failed', task: '跑测试', error: '炸了' })]}
|
|
154
|
+
/>,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expect(taskButton().getAttribute('data-task-shortcut')).toBe('process');
|
|
158
|
+
expect(taskButton().getAttribute('title')).toContain('点击查看推理过程');
|
|
159
|
+
|
|
160
|
+
fireEvent.click(taskButton());
|
|
161
|
+
|
|
162
|
+
expect(onInspectTask).toHaveBeenCalledWith({
|
|
163
|
+
id: 'a',
|
|
164
|
+
chatId: 'chat_1',
|
|
165
|
+
title: '跑测试',
|
|
166
|
+
});
|
|
167
|
+
expect(document.querySelector('[data-task-panel-modal]')).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('唯一待合并任务:点角标开弹层并展开该行', async () => {
|
|
171
|
+
const task = makeTask({
|
|
172
|
+
id: 'a',
|
|
173
|
+
status: 'completed',
|
|
174
|
+
worktreeState: 'pending',
|
|
175
|
+
worktreePath: '/tmp/wt',
|
|
176
|
+
worktreeBranch: 'steerable/a',
|
|
177
|
+
});
|
|
178
|
+
installBridge([task]);
|
|
179
|
+
render(<ChatHeader chat={CHAT} agent={null} onInspectTask={vi.fn()} tasks={[task]} />);
|
|
180
|
+
|
|
181
|
+
expect(taskButton().getAttribute('data-task-shortcut')).toBe('expand');
|
|
182
|
+
expect(taskButton().getAttribute('title')).toContain('点击处理 worktree');
|
|
183
|
+
|
|
184
|
+
fireEvent.click(taskButton());
|
|
185
|
+
|
|
186
|
+
// 展开态的标志:worktree 的「合并到主仓」按钮直接可见,不用再点一次。
|
|
187
|
+
await waitFor(() => expect(document.querySelector('[data-task-merge]')).not.toBeNull());
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('有任务在跑时不直达——先给列表', () => {
|
|
191
|
+
const onInspectTask = vi.fn();
|
|
192
|
+
render(
|
|
193
|
+
<ChatHeader
|
|
194
|
+
chat={CHAT}
|
|
195
|
+
agent={null}
|
|
196
|
+
onInspectTask={onInspectTask}
|
|
197
|
+
tasks={[
|
|
198
|
+
makeTask({ id: 'a', status: 'failed', error: '炸了' }),
|
|
199
|
+
makeTask({ id: 'b', status: 'running' }),
|
|
200
|
+
]}
|
|
201
|
+
/>,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(taskButton().getAttribute('data-task-shortcut')).toBeNull();
|
|
205
|
+
expect(taskButton().getAttribute('title')).not.toContain('点击');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('多件待处理时不直达——用户得先挑', () => {
|
|
209
|
+
const onInspectTask = vi.fn();
|
|
210
|
+
installBridge([]);
|
|
211
|
+
render(
|
|
212
|
+
<ChatHeader
|
|
213
|
+
chat={CHAT}
|
|
214
|
+
agent={null}
|
|
215
|
+
onInspectTask={onInspectTask}
|
|
216
|
+
tasks={[
|
|
217
|
+
makeTask({ id: 'a', status: 'failed', error: '炸了' }),
|
|
218
|
+
makeTask({ id: 'b', status: 'failed', error: '也炸了' }),
|
|
219
|
+
]}
|
|
220
|
+
/>,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(taskButton().getAttribute('data-task-shortcut')).toBeNull();
|
|
224
|
+
|
|
225
|
+
fireEvent.click(taskButton());
|
|
226
|
+
|
|
227
|
+
expect(onInspectTask).not.toHaveBeenCalled();
|
|
228
|
+
expect(document.querySelector('[data-task-panel-modal]')).not.toBeNull();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { LuCheck, LuGitBranch, LuListTodo, LuListTree, LuLoaderCircle } from 'react-icons/lu';
|
|
4
|
+
import type { LocalChat, LocalChatAgent, LocalTask } from '@/lib/local-api';
|
|
5
|
+
import {
|
|
6
|
+
activateChatBranch,
|
|
7
|
+
getChatBranches,
|
|
8
|
+
type ChatBranchesResponse,
|
|
9
|
+
} from '@/lib/local-api';
|
|
10
|
+
import { SessionTreeModal } from './chat/SessionTreeModal';
|
|
11
|
+
import { TaskPanelModal } from './chat/TaskPanelModal';
|
|
12
|
+
import { actionableTasks, summarizeTasks, type ChatTaskSummary } from './chat/useChatTasks';
|
|
13
|
+
|
|
14
|
+
interface ChatHeaderProps {
|
|
15
|
+
chat: LocalChat | null;
|
|
16
|
+
agent: LocalChatAgent | null;
|
|
17
|
+
/**
|
|
18
|
+
* W1.2.1: called after the active branch switches — the page re-hydrates
|
|
19
|
+
* the message list from the re-projected store. When provided (and the
|
|
20
|
+
* chat has branches), a branch picker appears in the header.
|
|
21
|
+
*/
|
|
22
|
+
onBranchSwitched?: () => void;
|
|
23
|
+
/** 点击后台任务行:在右侧栏打开该任务的推理过程。 */
|
|
24
|
+
onInspectTask?: (task: { id: string; chatId: string; title: string }) => void;
|
|
25
|
+
/** 本对话的后台任务(AgentPage 的 `useChatTasks` 单一订阅源)。 */
|
|
26
|
+
tasks?: LocalTask[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* ChatHeader — slim title bar above ChatPanel. Shows:
|
|
31
|
+
* - chat title (完整显示,长标题换行不截断)
|
|
32
|
+
* - bound agent name + icon (read-only here; switching is done from the
|
|
33
|
+
* sidebar's expert team list)
|
|
34
|
+
* - shortened chat id (for support / debugging)
|
|
35
|
+
*
|
|
36
|
+
* Terminal / 场景包调试窗 / 本地模型设置 USED to live here, but per the
|
|
37
|
+
* P1 parity contract (`match_old`) they've been moved to the AgentSidebar
|
|
38
|
+
* bottom strip. 刷新按钮已下线(消息流有 SSE 自动同步)。分享在最后一条
|
|
39
|
+
* 助手消息的时间戳行,不占标题栏。
|
|
40
|
+
*/
|
|
41
|
+
export function ChatHeader({
|
|
42
|
+
chat,
|
|
43
|
+
agent,
|
|
44
|
+
onBranchSwitched,
|
|
45
|
+
onInspectTask,
|
|
46
|
+
tasks = [],
|
|
47
|
+
}: ChatHeaderProps) {
|
|
48
|
+
const [branchMenuOpen, setBranchMenuOpen] = useState(false);
|
|
49
|
+
const [treeModalOpen, setTreeModalOpen] = useState(false);
|
|
50
|
+
const [taskPanelOpen, setTaskPanelOpen] = useState(false);
|
|
51
|
+
const [branches, setBranches] = useState<ChatBranchesResponse | null>(null);
|
|
52
|
+
const [switching, setSwitching] = useState(false);
|
|
53
|
+
const [menuBox, setMenuBox] = useState<{ top: number; left: number; width: number } | null>(
|
|
54
|
+
null,
|
|
55
|
+
);
|
|
56
|
+
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
|
57
|
+
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
58
|
+
|
|
59
|
+
const branchCount = branches ? branches.lineage.length + branches.children.length : 0;
|
|
60
|
+
|
|
61
|
+
const taskSummary = useMemo(() => summarizeTasks(tasks), [tasks]);
|
|
62
|
+
const taskBadge = describeTaskBadge(taskSummary);
|
|
63
|
+
const taskShortcut = useMemo(
|
|
64
|
+
() => describeTaskShortcut(tasks, taskSummary),
|
|
65
|
+
[tasks, taskSummary],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const openBranchMenu = async () => {
|
|
69
|
+
if (!chat || branchMenuOpen) {
|
|
70
|
+
setBranchMenuOpen(false);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const data = await getChatBranches(chat.id);
|
|
75
|
+
setBranches(data);
|
|
76
|
+
setBranchMenuOpen(true);
|
|
77
|
+
} catch {
|
|
78
|
+
setBranches(null);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const switchBranch = async (recordId: string) => {
|
|
83
|
+
if (!chat || !branches || switching || recordId === branches.activeRecordId) return;
|
|
84
|
+
setSwitching(true);
|
|
85
|
+
try {
|
|
86
|
+
await activateChatBranch(chat.id, recordId);
|
|
87
|
+
setBranchMenuOpen(false);
|
|
88
|
+
onBranchSwitched?.();
|
|
89
|
+
} catch {
|
|
90
|
+
// 切换失败(分支族外记录 / sidecar 离线)——菜单保持打开,用户可重试。
|
|
91
|
+
} finally {
|
|
92
|
+
setSwitching(false);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
if (!branchMenuOpen) {
|
|
98
|
+
setMenuBox(null);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const button = buttonRef.current;
|
|
102
|
+
if (!button) return;
|
|
103
|
+
const update = () => setMenuBox(placeBranchMenu(button));
|
|
104
|
+
update();
|
|
105
|
+
window.addEventListener('resize', update);
|
|
106
|
+
window.addEventListener('scroll', update, true);
|
|
107
|
+
return () => {
|
|
108
|
+
window.removeEventListener('resize', update);
|
|
109
|
+
window.removeEventListener('scroll', update, true);
|
|
110
|
+
};
|
|
111
|
+
}, [branchMenuOpen]);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!branchMenuOpen) return;
|
|
115
|
+
const onPointerDown = (e: MouseEvent) => {
|
|
116
|
+
const t = e.target as Node;
|
|
117
|
+
if (buttonRef.current?.contains(t) || menuRef.current?.contains(t)) return;
|
|
118
|
+
setBranchMenuOpen(false);
|
|
119
|
+
};
|
|
120
|
+
document.addEventListener('mousedown', onPointerDown);
|
|
121
|
+
return () => document.removeEventListener('mousedown', onPointerDown);
|
|
122
|
+
}, [branchMenuOpen]);
|
|
123
|
+
|
|
124
|
+
const branchMenu =
|
|
125
|
+
branchMenuOpen && menuBox
|
|
126
|
+
? createPortal(
|
|
127
|
+
<div
|
|
128
|
+
ref={menuRef}
|
|
129
|
+
className="fixed z-[80] rounded-lg border border-agent-border bg-agent-canvas p-1 shadow-lg"
|
|
130
|
+
style={{ top: menuBox.top, left: menuBox.left, width: menuBox.width }}
|
|
131
|
+
data-branch-menu
|
|
132
|
+
>
|
|
133
|
+
{!branches || branchCount === 0 ? (
|
|
134
|
+
<div className="px-3 py-2 text-xs text-agent-muted-foreground">
|
|
135
|
+
暂无分支 — 重新生成回复后,旧版本会保留在这里。
|
|
136
|
+
</div>
|
|
137
|
+
) : (
|
|
138
|
+
<>
|
|
139
|
+
{branches.lineage.map((point) => (
|
|
140
|
+
<BranchRow
|
|
141
|
+
key={point.recordId}
|
|
142
|
+
label={point.label}
|
|
143
|
+
active={point.recordId === branches.activeRecordId}
|
|
144
|
+
disabled={switching}
|
|
145
|
+
onSelect={() => void switchBranch(point.recordId)}
|
|
146
|
+
/>
|
|
147
|
+
))}
|
|
148
|
+
{branches.children.map((point) => (
|
|
149
|
+
<BranchRow
|
|
150
|
+
key={point.recordId}
|
|
151
|
+
label={point.label}
|
|
152
|
+
active={point.recordId === branches.activeRecordId}
|
|
153
|
+
disabled={switching}
|
|
154
|
+
onSelect={() => void switchBranch(point.recordId)}
|
|
155
|
+
/>
|
|
156
|
+
))}
|
|
157
|
+
{/* 全树入口:下拉只有 lineage + 直接子节点,堂兄弟等更远的
|
|
158
|
+
家族成员在树视图里切换。 */}
|
|
159
|
+
<div className="mt-1 border-t border-agent-border pt-1">
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => {
|
|
163
|
+
setBranchMenuOpen(false);
|
|
164
|
+
setTreeModalOpen(true);
|
|
165
|
+
}}
|
|
166
|
+
className="flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
167
|
+
data-action="branch-tree"
|
|
168
|
+
>
|
|
169
|
+
<LuListTree className="h-3 w-3 shrink-0" />
|
|
170
|
+
查看完整分支树
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
173
|
+
</>
|
|
174
|
+
)}
|
|
175
|
+
</div>,
|
|
176
|
+
document.body,
|
|
177
|
+
)
|
|
178
|
+
: null;
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<header className="flex w-full min-w-0 items-center gap-3 bg-agent-canvas px-3 py-2">
|
|
182
|
+
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-0.5">
|
|
183
|
+
<span
|
|
184
|
+
className="break-words text-sm font-medium text-agent-foreground"
|
|
185
|
+
title={chat?.title}
|
|
186
|
+
>
|
|
187
|
+
{chat?.title ?? '未选择对话'}
|
|
188
|
+
</span>
|
|
189
|
+
<span className="shrink-0 text-xs text-agent-muted-foreground">
|
|
190
|
+
{agent ? (
|
|
191
|
+
<>
|
|
192
|
+
<span className="mr-0.5">{agent.icon || '🤖'}</span>
|
|
193
|
+
{agent.name}
|
|
194
|
+
</>
|
|
195
|
+
) : chat?.agentId ? (
|
|
196
|
+
<span className="font-mono">{chat.agentId}</span>
|
|
197
|
+
) : (
|
|
198
|
+
<span className="italic">no agent</span>
|
|
199
|
+
)}
|
|
200
|
+
</span>
|
|
201
|
+
{chat && (
|
|
202
|
+
<span className="hidden shrink-0 font-mono text-[10px] text-agent-muted-foreground/70 md:inline">
|
|
203
|
+
{shortenId(chat.id)}
|
|
204
|
+
</span>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
{chat && onBranchSwitched && (
|
|
208
|
+
<div className="shrink-0">
|
|
209
|
+
<button
|
|
210
|
+
ref={buttonRef}
|
|
211
|
+
type="button"
|
|
212
|
+
onClick={() => void openBranchMenu()}
|
|
213
|
+
className="flex h-7 items-center gap-1 rounded-full px-2 text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
214
|
+
title="会话分支(重新生成产生的分叉)"
|
|
215
|
+
aria-label="会话分支"
|
|
216
|
+
data-action="branches"
|
|
217
|
+
>
|
|
218
|
+
<LuGitBranch className="h-3.5 w-3.5" />
|
|
219
|
+
{branchCount > 0 && <span>{branchCount}</span>}
|
|
220
|
+
</button>
|
|
221
|
+
{branchMenu}
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
{chat && (
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
onClick={() => {
|
|
228
|
+
if (taskShortcut?.kind === 'process' && onInspectTask) {
|
|
229
|
+
onInspectTask({
|
|
230
|
+
id: taskShortcut.task.id,
|
|
231
|
+
chatId: taskShortcut.task.chatId,
|
|
232
|
+
title: taskShortcut.task.task,
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
setTaskPanelOpen(true);
|
|
237
|
+
}}
|
|
238
|
+
className={`flex h-7 shrink-0 items-center gap-1 rounded-full transition-colors ${
|
|
239
|
+
taskBadge
|
|
240
|
+
? `px-2 ${taskBadge.className}`
|
|
241
|
+
: 'w-7 justify-center text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground'
|
|
242
|
+
}`}
|
|
243
|
+
title={taskButtonTitle(taskBadge, taskShortcut)}
|
|
244
|
+
aria-label={taskBadge ? `后台任务:${taskBadge.title}` : '后台任务'}
|
|
245
|
+
data-action="tasks"
|
|
246
|
+
data-task-state={taskBadge?.state}
|
|
247
|
+
data-task-shortcut={taskShortcut?.kind}
|
|
248
|
+
>
|
|
249
|
+
{taskSummary.running > 0 ? (
|
|
250
|
+
<LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
|
251
|
+
) : (
|
|
252
|
+
<LuListTodo className="h-3.5 w-3.5" />
|
|
253
|
+
)}
|
|
254
|
+
{taskBadge && <span className="text-xs">{taskBadge.count}</span>}
|
|
255
|
+
</button>
|
|
256
|
+
)}
|
|
257
|
+
{chat && taskPanelOpen && (
|
|
258
|
+
<TaskPanelModal
|
|
259
|
+
chatId={chat.id}
|
|
260
|
+
onClose={() => setTaskPanelOpen(false)}
|
|
261
|
+
initialExpandedId={
|
|
262
|
+
taskShortcut?.kind === 'expand' ? taskShortcut.taskId : undefined
|
|
263
|
+
}
|
|
264
|
+
onInspect={
|
|
265
|
+
onInspectTask
|
|
266
|
+
? (task) => {
|
|
267
|
+
onInspectTask({
|
|
268
|
+
id: task.id,
|
|
269
|
+
chatId: task.chatId,
|
|
270
|
+
title: task.task,
|
|
271
|
+
});
|
|
272
|
+
setTaskPanelOpen(false);
|
|
273
|
+
}
|
|
274
|
+
: undefined
|
|
275
|
+
}
|
|
276
|
+
/>
|
|
277
|
+
)}
|
|
278
|
+
{chat && treeModalOpen && (
|
|
279
|
+
<SessionTreeModal
|
|
280
|
+
chatId={chat.id}
|
|
281
|
+
onClose={() => setTreeModalOpen(false)}
|
|
282
|
+
onBranchSwitched={onBranchSwitched}
|
|
283
|
+
/>
|
|
284
|
+
)}
|
|
285
|
+
</header>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
interface TaskBadge {
|
|
290
|
+
/** 驱动角标配色的那一类任务,也写进 `data-task-state` 供测试断言。 */
|
|
291
|
+
state: 'running' | 'review' | 'failed' | 'idle';
|
|
292
|
+
count: number;
|
|
293
|
+
className: string;
|
|
294
|
+
title: string;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 任务按钮的角标。只有一个状态能上色,按「用户该不该现在看一眼」排序:
|
|
299
|
+
* 运行中(正在发生)> 待合并(等用户动手)> 失败(需要知道)> 全部跑完。
|
|
300
|
+
* 计数跟着状态走——显示 3 个任务里那 1 个待合并的,比显示总数 3 更有用。
|
|
301
|
+
*/
|
|
302
|
+
function describeTaskBadge(summary: ChatTaskSummary): TaskBadge | null {
|
|
303
|
+
if (summary.total === 0) return null;
|
|
304
|
+
|
|
305
|
+
const detail = [
|
|
306
|
+
summary.running > 0 ? `${summary.running} 个运行中` : null,
|
|
307
|
+
summary.blocked > 0 ? `${summary.blocked} 个等依赖` : null,
|
|
308
|
+
summary.needsReview > 0 ? `${summary.needsReview} 个待合并` : null,
|
|
309
|
+
summary.failed > 0 ? `${summary.failed} 个失败` : null,
|
|
310
|
+
]
|
|
311
|
+
.filter((part) => part !== null)
|
|
312
|
+
.join(',');
|
|
313
|
+
const title = `共 ${summary.total} 个后台任务${detail ? `(${detail})` : ''}`;
|
|
314
|
+
|
|
315
|
+
// 等依赖的任务也算在推进中:依赖跑完它会自动点火,用户不用做任何事。
|
|
316
|
+
const active = summary.running + summary.blocked;
|
|
317
|
+
if (active > 0) {
|
|
318
|
+
return {
|
|
319
|
+
state: 'running',
|
|
320
|
+
count: active,
|
|
321
|
+
className:
|
|
322
|
+
'bg-sky-500/10 text-sky-600 hover:bg-sky-500/15 dark:bg-sky-500/20 dark:text-sky-400',
|
|
323
|
+
title,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (summary.needsReview > 0) {
|
|
327
|
+
return {
|
|
328
|
+
state: 'review',
|
|
329
|
+
count: summary.needsReview,
|
|
330
|
+
className:
|
|
331
|
+
'bg-amber-500/10 text-amber-600 hover:bg-amber-500/15 dark:bg-amber-500/20 dark:text-amber-400',
|
|
332
|
+
title,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (summary.failed > 0) {
|
|
336
|
+
return {
|
|
337
|
+
state: 'failed',
|
|
338
|
+
count: summary.failed,
|
|
339
|
+
className:
|
|
340
|
+
'bg-red-500/10 text-red-600 hover:bg-red-500/15 dark:bg-red-500/20 dark:text-red-400',
|
|
341
|
+
title,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
state: 'idle',
|
|
346
|
+
count: summary.total,
|
|
347
|
+
className: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground',
|
|
348
|
+
title,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 角标的直达目标——只有一件事等着处理时跳过任务列表这一层。失败任务直接
|
|
354
|
+
* 开推理过程(要看的是为什么挂的),待合并的 worktree 任务直接展开它在弹层
|
|
355
|
+
* 里的行(合并/丢弃按钮在那儿)。有多件事等着、或还有任务在跑时仍然先给
|
|
356
|
+
* 列表:那时候用户得先挑一个。
|
|
357
|
+
*/
|
|
358
|
+
type TaskShortcut =
|
|
359
|
+
| { kind: 'process'; task: LocalTask }
|
|
360
|
+
| { kind: 'expand'; taskId: string };
|
|
361
|
+
|
|
362
|
+
function describeTaskShortcut(
|
|
363
|
+
tasks: LocalTask[],
|
|
364
|
+
summary: ChatTaskSummary,
|
|
365
|
+
): TaskShortcut | null {
|
|
366
|
+
if (summary.running + summary.blocked > 0) return null;
|
|
367
|
+
const pending = actionableTasks(tasks);
|
|
368
|
+
if (pending.length !== 1) return null;
|
|
369
|
+
const [task] = pending;
|
|
370
|
+
return task.worktreeState === 'pending'
|
|
371
|
+
? { kind: 'expand', taskId: task.id }
|
|
372
|
+
: { kind: 'process', task };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function taskButtonTitle(
|
|
376
|
+
badge: TaskBadge | null,
|
|
377
|
+
shortcut: TaskShortcut | null,
|
|
378
|
+
): string {
|
|
379
|
+
const base = badge?.title ?? '后台任务(跨轮运行,可查看结果与合并 worktree)';
|
|
380
|
+
if (!shortcut) return base;
|
|
381
|
+
return shortcut.kind === 'process'
|
|
382
|
+
? `${base} · 点击查看推理过程`
|
|
383
|
+
: `${base} · 点击处理 worktree`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function shortenId(id: string): string {
|
|
387
|
+
if (id.length <= 12) return id;
|
|
388
|
+
return `${id.slice(0, 6)}…${id.slice(-4)}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Pin the branch menu to the trigger, clamped inside the chat panel so
|
|
392
|
+
* `overflow-hidden` ancestors cannot clip the left side of a `w-72` sheet. */
|
|
393
|
+
function placeBranchMenu(button: HTMLElement): { top: number; left: number; width: number } {
|
|
394
|
+
const pad = 8;
|
|
395
|
+
const panel = button.closest('.chat-panel-container');
|
|
396
|
+
const bounds = (panel ?? document.documentElement).getBoundingClientRect();
|
|
397
|
+
const btn = button.getBoundingClientRect();
|
|
398
|
+
const width = Math.min(288, Math.max(160, bounds.width - pad * 2));
|
|
399
|
+
const minLeft = bounds.left + pad;
|
|
400
|
+
const maxLeft = bounds.right - width - pad;
|
|
401
|
+
const left = Math.min(Math.max(btn.right - width, minLeft), Math.max(minLeft, maxLeft));
|
|
402
|
+
return { top: btn.bottom + 4, left, width };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function BranchRow({
|
|
406
|
+
label,
|
|
407
|
+
active,
|
|
408
|
+
disabled,
|
|
409
|
+
onSelect,
|
|
410
|
+
}: {
|
|
411
|
+
label: string;
|
|
412
|
+
active: boolean;
|
|
413
|
+
disabled: boolean;
|
|
414
|
+
onSelect: () => void;
|
|
415
|
+
}) {
|
|
416
|
+
return (
|
|
417
|
+
<button
|
|
418
|
+
type="button"
|
|
419
|
+
onClick={onSelect}
|
|
420
|
+
aria-current={active ? 'true' : undefined}
|
|
421
|
+
aria-disabled={disabled || active || undefined}
|
|
422
|
+
className={`flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-xs transition-colors ${
|
|
423
|
+
active
|
|
424
|
+
? 'cursor-default bg-agent-foreground/5 font-medium text-agent-foreground'
|
|
425
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground'
|
|
426
|
+
} ${disabled ? 'pointer-events-none opacity-60' : ''}`}
|
|
427
|
+
data-branch-row
|
|
428
|
+
data-active={active || undefined}
|
|
429
|
+
>
|
|
430
|
+
<span className="min-w-0 flex-1 truncate">{label || '(空分支)'}</span>
|
|
431
|
+
{active && (
|
|
432
|
+
<LuCheck className="h-3 w-3 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
|
433
|
+
)}
|
|
434
|
+
</button>
|
|
435
|
+
);
|
|
436
|
+
}
|