@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.
Files changed (121) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +20 -0
  3. package/package.json +69 -0
  4. package/public/favicon.ico +0 -0
  5. package/public/favicon.png +0 -0
  6. package/src/AppShell.tsx +24 -0
  7. package/src/assets/logo-generic.png +0 -0
  8. package/src/brand.test.ts +22 -0
  9. package/src/brand.ts +48 -0
  10. package/src/components/AgentSidebar.test.tsx +127 -0
  11. package/src/components/AgentSidebar.tsx +1010 -0
  12. package/src/components/ChatHeader.test.tsx +230 -0
  13. package/src/components/ChatHeader.tsx +436 -0
  14. package/src/components/DockPanelHeader.tsx +69 -0
  15. package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
  16. package/src/components/LocalLlmSettingsModal.tsx +72 -0
  17. package/src/components/TerminalPanel.tsx +68 -0
  18. package/src/components/TerminalView.tsx +228 -0
  19. package/src/components/chat/ApprovalModal.test.tsx +83 -0
  20. package/src/components/chat/ApprovalModal.tsx +229 -0
  21. package/src/components/chat/AskUserModalHost.test.tsx +176 -0
  22. package/src/components/chat/AskUserModalHost.tsx +61 -0
  23. package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
  24. package/src/components/chat/AssistantMessage.test.tsx +68 -0
  25. package/src/components/chat/AssistantMessage.tsx +469 -0
  26. package/src/components/chat/ChatInput.test.tsx +609 -0
  27. package/src/components/chat/ChatInput.tsx +2068 -0
  28. package/src/components/chat/ChatProjectBadge.tsx +362 -0
  29. package/src/components/chat/EmptyChat.tsx +18 -0
  30. package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
  31. package/src/components/chat/ExecPolicyPicker.tsx +120 -0
  32. package/src/components/chat/ExecutedActionsCard.tsx +216 -0
  33. package/src/components/chat/InterruptedTurnCard.tsx +53 -0
  34. package/src/components/chat/LocalChatPanel.tsx +408 -0
  35. package/src/components/chat/Markdown.test.tsx +170 -0
  36. package/src/components/chat/Markdown.tsx +432 -0
  37. package/src/components/chat/MessageList.tsx +332 -0
  38. package/src/components/chat/ModelPicker.test.tsx +149 -0
  39. package/src/components/chat/ModelPicker.tsx +289 -0
  40. package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
  41. package/src/components/chat/PlanTodoList.tsx +121 -0
  42. package/src/components/chat/ProjectTrustBanner.tsx +91 -0
  43. package/src/components/chat/SessionTreeModal.test.tsx +161 -0
  44. package/src/components/chat/SessionTreeModal.tsx +221 -0
  45. package/src/components/chat/StreamingStatus.tsx +95 -0
  46. package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
  47. package/src/components/chat/TaskOutcomeCards.tsx +128 -0
  48. package/src/components/chat/TaskPanelModal.test.tsx +198 -0
  49. package/src/components/chat/TaskPanelModal.tsx +310 -0
  50. package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
  51. package/src/components/chat/TaskProcessPanel.tsx +208 -0
  52. package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
  53. package/src/components/chat/TurnProcessGroup.tsx +193 -0
  54. package/src/components/chat/UnifiedDiff.tsx +31 -0
  55. package/src/components/chat/UserMessage.tsx +155 -0
  56. package/src/components/chat/elapsed.test.ts +42 -0
  57. package/src/components/chat/elapsed.ts +45 -0
  58. package/src/components/chat/executed-actions-model.test.ts +134 -0
  59. package/src/components/chat/executed-actions-model.ts +143 -0
  60. package/src/components/chat/orchestration-children-model.test.ts +115 -0
  61. package/src/components/chat/orchestration-children-model.ts +104 -0
  62. package/src/components/chat/timestamp.ts +24 -0
  63. package/src/components/chat/turn-timeline.test.ts +97 -0
  64. package/src/components/chat/turn-timeline.ts +132 -0
  65. package/src/components/chat/useChatTasks.test.ts +136 -0
  66. package/src/components/chat/useChatTasks.ts +134 -0
  67. package/src/components/chat/useCopy.ts +62 -0
  68. package/src/components/settings/AgentCapabilityFields.tsx +343 -0
  69. package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
  70. package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
  71. package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
  72. package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
  73. package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
  74. package/src/components/settings/LlmSettingsPanel.tsx +875 -0
  75. package/src/components/settings/McpSettingsPanel.tsx +466 -0
  76. package/src/components/settings/ModelCapabilityChips.tsx +46 -0
  77. package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
  78. package/src/components/settings/ModelIdCombobox.tsx +185 -0
  79. package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
  80. package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
  81. package/src/components/settings/SettingsSaveButton.tsx +35 -0
  82. package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
  83. package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
  84. package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
  85. package/src/components/settings/UsagePanel.tsx +196 -0
  86. package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
  87. package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
  88. package/src/components/settings/compat-flags-model.test.ts +71 -0
  89. package/src/components/settings/compat-flags-model.ts +57 -0
  90. package/src/components/settings/llm-vendors.test.ts +171 -0
  91. package/src/components/settings/llm-vendors.ts +275 -0
  92. package/src/components/settings/model-capabilities.test.ts +88 -0
  93. package/src/components/settings/model-capabilities.ts +123 -0
  94. package/src/components/settings/preset-choice-model.test.ts +96 -0
  95. package/src/components/settings/preset-choice-model.ts +75 -0
  96. package/src/fixtures/browser-dev-data.json +315 -0
  97. package/src/hooks/useChatsAndAgents.ts +182 -0
  98. package/src/layouts/AgentLayout.tsx +574 -0
  99. package/src/lib/attachments.ts +78 -0
  100. package/src/lib/browser-dev-electron-mock.ts +640 -0
  101. package/src/lib/chat-title.ts +33 -0
  102. package/src/lib/chat-transport.ts +346 -0
  103. package/src/lib/date-groups.ts +34 -0
  104. package/src/lib/demo-flag.ts +15 -0
  105. package/src/lib/electron-bridge.ts +270 -0
  106. package/src/lib/exec-policy.ts +25 -0
  107. package/src/lib/http-bridge.ts +293 -0
  108. package/src/lib/insights.ts +17 -0
  109. package/src/lib/local-api.ts +714 -0
  110. package/src/lib/pending-first-message.test.ts +17 -0
  111. package/src/lib/pending-first-message.ts +36 -0
  112. package/src/lib/slash-sources.ts +191 -0
  113. package/src/main.tsx +103 -0
  114. package/src/packs/registry.ts +64 -0
  115. package/src/pages/AgentPage.tsx +1226 -0
  116. package/src/pages/ChatPanelPreviewPage.tsx +264 -0
  117. package/src/pages/SettingsPage.test.tsx +102 -0
  118. package/src/pages/SettingsPage.tsx +197 -0
  119. package/src/styles/index.css +156 -0
  120. package/src/vite-env.d.ts +1 -0
  121. package/vite.base.ts +94 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * 后台任务终态通知卡(消息列尾部)。
3
+ *
4
+ * 后台任务跑在主对话之外,结果只有在模型自己调 `task_status` / `task_result`
5
+ * 时才会被提起——模型不调就没人提,失败原因要经「角标 → 任务弹层 → 任务名」
6
+ * 三层才看得到。本卡片把终态带回用户正在看的地方:说清哪个任务结束了、成败
7
+ * 与一句摘要,再给一个直达推理过程的入口。
8
+ *
9
+ * 只渲染本次挂载期间到达终态的任务(`useChatTasks` 的 `finished`),「忽略」
10
+ * 不写库。worktree 的合并/丢弃仍归任务弹层——卡片只通知,不重复那套操作。
11
+ */
12
+ import { LuCheck, LuGitBranch, LuX } from 'react-icons/lu';
13
+ import type { LocalTask } from '@/lib/local-api';
14
+
15
+ /** 同时挤在消息列尾部的卡片上限;更多的只留一行汇总指回角标。 */
16
+ const MAX_CARDS = 3;
17
+
18
+ export interface TaskOutcomeCardsProps {
19
+ /** 刚跑完的任务,新→旧。 */
20
+ tasks: LocalTask[];
21
+ /** 在右侧栏打开该任务的推理过程。 */
22
+ onInspect: (task: LocalTask) => void;
23
+ /** 隐藏这条通知(本次挂载内)。 */
24
+ onDismiss: (taskId: string) => void;
25
+ }
26
+
27
+ export function TaskOutcomeCards({ tasks, onInspect, onDismiss }: TaskOutcomeCardsProps) {
28
+ if (tasks.length === 0) return null;
29
+ const shown = tasks.slice(0, MAX_CARDS);
30
+ const overflow = tasks.length - shown.length;
31
+
32
+ return (
33
+ <div className="mt-1.5 space-y-1.5" data-testid="task-outcome-cards">
34
+ {shown.map((task) => (
35
+ <TaskOutcomeCard
36
+ key={task.id}
37
+ task={task}
38
+ onInspect={() => onInspect(task)}
39
+ onDismiss={() => onDismiss(task.id)}
40
+ />
41
+ ))}
42
+ {overflow > 0 && (
43
+ <div className="px-1 text-[11px] text-agent-muted-foreground">
44
+ 另有 {overflow} 个后台任务已结束 — 见标题栏的任务按钮。
45
+ </div>
46
+ )}
47
+ </div>
48
+ );
49
+ }
50
+
51
+ function TaskOutcomeCard({
52
+ task,
53
+ onInspect,
54
+ onDismiss,
55
+ }: {
56
+ task: LocalTask;
57
+ onInspect: () => void;
58
+ onDismiss: () => void;
59
+ }) {
60
+ const failed = task.status === 'failed';
61
+ const detail = failed ? task.error : task.answer;
62
+ const needsReview = task.worktreeState === 'pending';
63
+
64
+ return (
65
+ <div
66
+ role="status"
67
+ data-task-outcome={task.id}
68
+ data-status={task.status}
69
+ className={`flex items-start gap-2 rounded-agent-md border px-3 py-2 text-xs ${
70
+ failed
71
+ ? 'border-red-400/40 bg-red-400/10'
72
+ : 'border-emerald-400/40 bg-emerald-400/10'
73
+ }`}
74
+ >
75
+ {failed ? (
76
+ <LuX className="mt-0.5 h-4 w-4 shrink-0 text-red-600 dark:text-red-400" />
77
+ ) : (
78
+ <LuCheck className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
79
+ )}
80
+
81
+ <div className="min-w-0 flex-1">
82
+ <div
83
+ className={
84
+ failed
85
+ ? 'text-red-700 dark:text-red-300'
86
+ : 'text-emerald-700 dark:text-emerald-300'
87
+ }
88
+ >
89
+ {failed ? '后台任务失败:' : '后台任务已完成:'}
90
+ <span className="font-medium">{task.task}</span>
91
+ </div>
92
+ {detail && (
93
+ <div className="mt-1 line-clamp-3 whitespace-pre-wrap text-agent-muted-foreground">
94
+ {detail}
95
+ </div>
96
+ )}
97
+ {needsReview && (
98
+ <div className="mt-1 flex items-center gap-1 text-agent-muted-foreground">
99
+ <LuGitBranch className="h-3 w-3 shrink-0" />
100
+ <span className="min-w-0 truncate font-mono text-[10px]">
101
+ {task.worktreeBranch}
102
+ </span>
103
+ <span>待合并 — 在标题栏的任务按钮里处理。</span>
104
+ </div>
105
+ )}
106
+ </div>
107
+
108
+ <div className="flex shrink-0 items-center gap-2">
109
+ <button
110
+ type="button"
111
+ onClick={onDismiss}
112
+ className="rounded px-1.5 py-0.5 text-agent-muted-foreground transition-colors hover:text-agent-foreground"
113
+ >
114
+ 忽略
115
+ </button>
116
+ <button
117
+ type="button"
118
+ onClick={onInspect}
119
+ className="rounded-full bg-agent-foreground px-3 py-1 font-medium text-agent-canvas transition hover:opacity-90"
120
+ >
121
+ 查看过程
122
+ </button>
123
+ </div>
124
+ </div>
125
+ );
126
+ }
127
+
128
+ export default TaskOutcomeCards;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * TaskPanelModal 的接线契约(4.6a/4.6c):
3
+ * - 打开时经 localBackend 拉 GET /chats/:id/tasks,按状态渲染
4
+ * 运行中/已完成/失败徽标;
5
+ * - 展开行显示 answer / error;
6
+ * - worktree 任务(worktreeState=pending 且已完成)显示「合并到主仓」
7
+ * /「丢弃」,点击走 POST /tasks/:id/merge|discard 并刷新;
8
+ * - 主进程 task-updated 推送触发重拉;
9
+ * - Escape 关闭。
10
+ */
11
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
12
+ import { afterEach, describe, expect, it, vi } from 'vitest';
13
+ import type { LocalTask } from '@/lib/local-api';
14
+ import { TaskPanelModal } from './TaskPanelModal';
15
+
16
+ afterEach(() => {
17
+ cleanup();
18
+ delete (window as { electron?: unknown }).electron;
19
+ });
20
+
21
+ function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
22
+ return {
23
+ id: 'task-1',
24
+ chatId: 'chat_1',
25
+ task: '统计仓库行数',
26
+ status: 'completed',
27
+ answer: '共 42 行。',
28
+ error: null,
29
+ worktreePath: null,
30
+ worktreeBranch: null,
31
+ worktreeState: null,
32
+ createdAt: '2026-09-08T01:00:00.000Z',
33
+ updatedAt: '2026-09-08T01:01:00.000Z',
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function installBridge(tasks: LocalTask[]) {
39
+ const request = vi.fn((input: { method: string; path: string; body?: unknown }) => {
40
+ if (input.method === 'GET' && input.path.endsWith('/tasks')) {
41
+ return Promise.resolve({ tasks });
42
+ }
43
+ if (input.method === 'POST' && /\/tasks\/[^/]+\/(merge|discard)$/.test(input.path)) {
44
+ return Promise.resolve({ success: true, task: tasks[0] });
45
+ }
46
+ return Promise.reject(new Error(`unexpected request: ${input.method} ${input.path}`));
47
+ });
48
+ const taskUpdatedListeners: Array<
49
+ (payload: { chatId: string; taskId: string; status: string }) => void
50
+ > = [];
51
+ (window as { electron?: unknown }).electron = {
52
+ localBackend: { request },
53
+ onTaskUpdated: (
54
+ cb: (payload: { chatId: string; taskId: string; status: string }) => void,
55
+ ) => {
56
+ taskUpdatedListeners.push(cb);
57
+ return () => {
58
+ const i = taskUpdatedListeners.indexOf(cb);
59
+ if (i >= 0) taskUpdatedListeners.splice(i, 1);
60
+ };
61
+ },
62
+ };
63
+ return {
64
+ request,
65
+ emitTaskUpdated: (payload: { chatId: string; taskId: string; status: string }) => {
66
+ for (const cb of taskUpdatedListeners) cb(payload);
67
+ },
68
+ };
69
+ }
70
+
71
+ describe('TaskPanelModal', () => {
72
+ it('渲染任务列表:状态徽标 + 任务文本', async () => {
73
+ installBridge([
74
+ makeTask({ id: 't1', task: '跑测试', status: 'running', answer: null }),
75
+ makeTask({ id: 't2', task: '写文档', status: 'completed' }),
76
+ makeTask({ id: 't3', task: '改代码', status: 'failed', error: 'sidecar 断开' }),
77
+ ]);
78
+ render(<TaskPanelModal chatId="chat_1" onClose={() => {}} />);
79
+
80
+ await waitFor(() => expect(screen.queryByText('正在加载任务…')).toBeNull());
81
+ const rows = screen
82
+ .getAllByRole('button', { hidden: true })
83
+ .map((b) => b.closest('[data-task-row]'))
84
+ .filter((el): el is HTMLElement => el !== null);
85
+ const uniqueRows = [...new Set(rows)];
86
+ expect(uniqueRows).toHaveLength(3);
87
+ expect(uniqueRows.map((r) => r.getAttribute('data-status'))).toEqual([
88
+ 'running',
89
+ 'completed',
90
+ 'failed',
91
+ ]);
92
+ expect(screen.getByText('跑测试')).toBeTruthy();
93
+ expect(screen.getByText(/3 个任务,1 个运行中/)).toBeTruthy();
94
+ });
95
+
96
+ it('空态文案', async () => {
97
+ installBridge([]);
98
+ render(<TaskPanelModal chatId="chat_1" onClose={() => {}} />);
99
+ await waitFor(() => screen.getByText(/暂无后台任务/));
100
+ });
101
+
102
+ it('展开行显示答案;worktree pending 任务显示合并/丢弃并走对应路由', async () => {
103
+ const { request } = installBridge([
104
+ makeTask({
105
+ id: 'wt-1',
106
+ worktreePath: '/repo/.steerable/worktrees/demo',
107
+ worktreeBranch: 'steerable/demo',
108
+ worktreeState: 'pending',
109
+ }),
110
+ ]);
111
+ render(<TaskPanelModal chatId="chat_1" onClose={() => {}} />);
112
+ await waitFor(() => screen.getByText('统计仓库行数'));
113
+
114
+ // 展开 → 答案 + worktree 操作。
115
+ fireEvent.click(screen.getByRole('button', { name: '展开' }));
116
+ await waitFor(() => screen.getByText('共 42 行。'));
117
+ expect(screen.getByText('steerable/demo')).toBeTruthy();
118
+
119
+ fireEvent.click(screen.getByText('合并到主仓'));
120
+ await waitFor(() =>
121
+ expect(request).toHaveBeenCalledWith({
122
+ method: 'POST',
123
+ path: '/api/v2/tasks/wt-1/merge',
124
+ }),
125
+ );
126
+ // 合并后重拉列表。
127
+ await waitFor(() =>
128
+ expect(
129
+ request.mock.calls.filter(
130
+ ([input]) =>
131
+ (input as { method: string }).method === 'GET' &&
132
+ (input as { path: string }).path.endsWith('/tasks'),
133
+ ).length,
134
+ ).toBeGreaterThanOrEqual(2),
135
+ );
136
+ });
137
+
138
+ it('丢弃走 discard 路由', async () => {
139
+ const { request } = installBridge([
140
+ makeTask({
141
+ id: 'wt-2',
142
+ worktreePath: '/repo/.steerable/worktrees/x',
143
+ worktreeBranch: 'steerable/x',
144
+ worktreeState: 'pending',
145
+ }),
146
+ ]);
147
+ render(<TaskPanelModal chatId="chat_1" onClose={() => {}} />);
148
+ await waitFor(() => screen.getByText('统计仓库行数'));
149
+
150
+ fireEvent.click(screen.getByRole('button', { name: '展开' }));
151
+ fireEvent.click(await screen.findByText('丢弃'));
152
+ await waitFor(() =>
153
+ expect(request).toHaveBeenCalledWith({
154
+ method: 'POST',
155
+ path: '/api/v2/tasks/wt-2/discard',
156
+ }),
157
+ );
158
+ });
159
+
160
+ it('task-updated 推送触发重拉;其他 chat 的推送忽略', async () => {
161
+ const { request, emitTaskUpdated } = installBridge([makeTask()]);
162
+ render(<TaskPanelModal chatId="chat_1" onClose={() => {}} />);
163
+ await waitFor(() => screen.getByText('统计仓库行数'));
164
+ const getsBefore = request.mock.calls.filter(
165
+ ([input]) => (input as { method: string }).method === 'GET',
166
+ ).length;
167
+
168
+ emitTaskUpdated({ chatId: 'chat_2', taskId: 'x', status: 'completed' });
169
+ emitTaskUpdated({ chatId: 'chat_1', taskId: 'task-1', status: 'completed' });
170
+
171
+ await waitFor(() => {
172
+ const gets = request.mock.calls.filter(
173
+ ([input]) => (input as { method: string }).method === 'GET',
174
+ ).length;
175
+ expect(gets).toBe(getsBefore + 1);
176
+ });
177
+ });
178
+
179
+ it('点击任务名调用 onInspect', async () => {
180
+ const onInspect = vi.fn();
181
+ installBridge([makeTask({ id: 't1', task: '跑测试', status: 'running', answer: null })]);
182
+ render(<TaskPanelModal chatId="chat_1" onClose={() => {}} onInspect={onInspect} />);
183
+ await waitFor(() => screen.getByText('跑测试'));
184
+ fireEvent.click(screen.getByRole('button', { name: '跑测试' }));
185
+ expect(onInspect).toHaveBeenCalledTimes(1);
186
+ expect(onInspect.mock.calls[0][0]).toMatchObject({ id: 't1', task: '跑测试' });
187
+ });
188
+
189
+ it('Escape 关闭模态', async () => {
190
+ const onClose = vi.fn();
191
+ installBridge([makeTask()]);
192
+ render(<TaskPanelModal chatId="chat_1" onClose={onClose} />);
193
+ await waitFor(() => screen.getByText('统计仓库行数'));
194
+
195
+ fireEvent.keyDown(document, { key: 'Escape' });
196
+ expect(onClose).toHaveBeenCalledTimes(1);
197
+ });
198
+ });
@@ -0,0 +1,310 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import {
4
+ LuCheck,
5
+ LuChevronDown,
6
+ LuChevronRight,
7
+ LuClock,
8
+ LuGitBranch,
9
+ LuGitMerge,
10
+ LuListTodo,
11
+ LuLoaderCircle,
12
+ LuTrash2,
13
+ LuX,
14
+ } from 'react-icons/lu';
15
+ import {
16
+ discardTaskWorktree,
17
+ listChatTasks,
18
+ mergeTaskWorktree,
19
+ type LocalTask,
20
+ } from '@/lib/local-api';
21
+ import { getElectronBridge } from '@/lib/electron-bridge';
22
+
23
+ /**
24
+ * TaskPanelModal — 跨 turn 后台任务面板(4.6a/4.6c)。
25
+ *
26
+ * 数据来自 listChatTasks(host SQLite 任务表)。任务在独立 sidecar 流里
27
+ * 跑,终态经主进程 `task-updated` 广播推到这里刷新——面板打开期间任务
28
+ * 跑完会实时翻状态,不需要重开。
29
+ *
30
+ * worktree 任务(worktreeState=pending)在完成后提供「合并到主仓」/
31
+ * 「丢弃」操作;两者都是幂等目标态,失败(如合并冲突)以错误条显示,
32
+ * 任务保持 pending 可重试。
33
+ */
34
+
35
+ interface TaskPanelModalProps {
36
+ chatId: string;
37
+ onClose: () => void;
38
+ /** 点击任务行:关闭面板并在右侧展示该任务的推理过程。 */
39
+ onInspect?: (task: LocalTask) => void;
40
+ /** 打开时就展开的任务行——header 角标直达唯一待处理的 worktree 任务。 */
41
+ initialExpandedId?: string;
42
+ }
43
+
44
+ function formatTime(iso: string): string {
45
+ const d = new Date(iso);
46
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
47
+ }
48
+
49
+ export function TaskPanelModal({
50
+ chatId,
51
+ onClose,
52
+ onInspect,
53
+ initialExpandedId,
54
+ }: TaskPanelModalProps) {
55
+ const [tasks, setTasks] = useState<LocalTask[] | null>(null);
56
+ const [loading, setLoading] = useState(true);
57
+ const [expandedId, setExpandedId] = useState<string | null>(
58
+ initialExpandedId ?? null,
59
+ );
60
+ /** 正在执行合并/丢弃的任务 id——按钮防重入。 */
61
+ const [actingId, setActingId] = useState<string | null>(null);
62
+ /** 合并/丢弃失败(如冲突)的提示,按任务 id 挂。 */
63
+ const [actionError, setActionError] = useState<Record<string, string>>({});
64
+
65
+ const refresh = useCallback(async () => {
66
+ try {
67
+ const res = await listChatTasks(chatId);
68
+ setTasks(res.tasks);
69
+ } catch {
70
+ setTasks(null);
71
+ } finally {
72
+ setLoading(false);
73
+ }
74
+ }, [chatId]);
75
+
76
+ useEffect(() => {
77
+ void refresh();
78
+ }, [refresh]);
79
+
80
+ // 任务终态推送 → 重拉列表。preload 只透传 chatId/taskId/status,
81
+ // 记录本体以 SQLite 为准。
82
+ useEffect(() => {
83
+ const bridge = getElectronBridge();
84
+ if (!bridge?.onTaskUpdated) return;
85
+ return bridge.onTaskUpdated((payload) => {
86
+ if (payload.chatId === chatId) void refresh();
87
+ });
88
+ }, [chatId, refresh]);
89
+
90
+ useEffect(() => {
91
+ const onKeyDown = (e: KeyboardEvent) => {
92
+ if (e.key === 'Escape') {
93
+ e.stopPropagation();
94
+ onClose();
95
+ }
96
+ };
97
+ document.addEventListener('keydown', onKeyDown, true);
98
+ return () => document.removeEventListener('keydown', onKeyDown, true);
99
+ }, [onClose]);
100
+
101
+ const runAction = async (task: LocalTask, action: 'merge' | 'discard') => {
102
+ if (actingId) return;
103
+ setActingId(task.id);
104
+ setActionError((prev) => {
105
+ const next = { ...prev };
106
+ delete next[task.id];
107
+ return next;
108
+ });
109
+ try {
110
+ if (action === 'merge') {
111
+ await mergeTaskWorktree(task.id);
112
+ } else {
113
+ await discardTaskWorktree(task.id);
114
+ }
115
+ await refresh();
116
+ } catch (err) {
117
+ const message = err instanceof Error ? err.message : String(err);
118
+ setActionError((prev) => ({ ...prev, [task.id]: message }));
119
+ } finally {
120
+ setActingId(null);
121
+ }
122
+ };
123
+
124
+ const runningCount = tasks?.filter((t) => t.status === 'running').length ?? 0;
125
+
126
+ return createPortal(
127
+ <div
128
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
129
+ role="dialog"
130
+ aria-modal="true"
131
+ aria-label="后台任务"
132
+ onMouseDown={(e) => {
133
+ if (e.target === e.currentTarget) onClose();
134
+ }}
135
+ data-task-panel-modal
136
+ >
137
+ <div className="flex max-h-[85vh] w-full max-w-xl flex-col overflow-hidden rounded-agent-lg border border-agent-border bg-agent-canvas shadow-xl">
138
+ <div className="flex items-center gap-2 border-b border-agent-border px-4 py-2.5">
139
+ <LuListTodo className="h-4 w-4 shrink-0 text-agent-muted-foreground" />
140
+ <span className="min-w-0 flex-1 truncate text-sm font-medium text-agent-foreground">
141
+ 后台任务
142
+ {tasks && tasks.length > 0 && (
143
+ <span className="ml-1.5 text-xs font-normal text-agent-muted-foreground">
144
+ {tasks.length} 个任务{runningCount > 0 ? `,${runningCount} 个运行中` : ''}
145
+ </span>
146
+ )}
147
+ </span>
148
+ <button
149
+ type="button"
150
+ onClick={onClose}
151
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
152
+ aria-label="关闭"
153
+ >
154
+ <LuX className="h-3.5 w-3.5" />
155
+ </button>
156
+ </div>
157
+
158
+ <div className="min-h-0 flex-1 overflow-auto p-2">
159
+ {loading ? (
160
+ <div className="flex items-center justify-center gap-2 px-3 py-6 text-xs text-agent-muted-foreground">
161
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
162
+ 正在加载任务…
163
+ </div>
164
+ ) : !tasks || tasks.length === 0 ? (
165
+ <div className="px-3 py-6 text-center text-xs text-agent-muted-foreground">
166
+ 暂无后台任务 — 让助手用 task_run 启动一个跨轮任务后会出现在这里。
167
+ </div>
168
+ ) : (
169
+ tasks.map((task) => {
170
+ const expanded = expandedId === task.id;
171
+ const acting = actingId === task.id;
172
+ return (
173
+ <div
174
+ key={task.id}
175
+ className="mb-1 rounded border border-agent-border/60 px-2.5 py-2"
176
+ data-task-row
177
+ data-status={task.status}
178
+ >
179
+ <div className="flex items-center gap-2">
180
+ {task.status === 'running' ? (
181
+ <LuLoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin text-sky-600 dark:text-sky-400" />
182
+ ) : task.status === 'blocked' ? (
183
+ <LuClock className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
184
+ ) : task.status === 'completed' ? (
185
+ <LuCheck className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
186
+ ) : (
187
+ <LuX className="h-3.5 w-3.5 shrink-0 text-red-600 dark:text-red-400" />
188
+ )}
189
+ <button
190
+ type="button"
191
+ onClick={() => onInspect?.(task)}
192
+ className="min-w-0 flex-1 truncate text-left text-xs font-medium text-agent-foreground hover:underline"
193
+ title="在右侧查看推理过程"
194
+ >
195
+ {task.task}
196
+ </button>
197
+ {task.worktreeBranch && (
198
+ <span
199
+ className="flex shrink-0 items-center gap-1 rounded bg-agent-foreground/5 px-1.5 py-0.5 font-mono text-[10px] text-agent-muted-foreground"
200
+ title={task.worktreePath ?? undefined}
201
+ >
202
+ <LuGitBranch className="h-3 w-3" />
203
+ {task.worktreeBranch}
204
+ {task.worktreeState === 'merged'
205
+ ? ' · 已合并'
206
+ : task.worktreeState === 'discarded'
207
+ ? ' · 已丢弃'
208
+ : ''}
209
+ </span>
210
+ )}
211
+ <button
212
+ type="button"
213
+ onClick={() => setExpandedId(expanded ? null : task.id)}
214
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
215
+ aria-label={expanded ? '收起' : '展开'}
216
+ aria-expanded={expanded}
217
+ >
218
+ {expanded ? (
219
+ <LuChevronDown className="h-3.5 w-3.5" />
220
+ ) : (
221
+ <LuChevronRight className="h-3.5 w-3.5" />
222
+ )}
223
+ </button>
224
+ </div>
225
+
226
+ <div className="mt-1 pl-5 text-[10px] text-agent-muted-foreground/80">
227
+ {task.status === 'running'
228
+ ? '运行中'
229
+ : task.status === 'blocked'
230
+ ? '等待依赖'
231
+ : task.status === 'completed'
232
+ ? '已完成'
233
+ : '失败'}
234
+ {' · '}
235
+ {formatTime(task.updatedAt)}
236
+ </div>
237
+
238
+ {expanded && (
239
+ <div className="mt-2 border-t border-agent-border/60 pt-2 pl-5">
240
+ {task.error && (
241
+ <div className="mb-2 rounded bg-red-500/10 px-2 py-1.5 text-xs whitespace-pre-wrap text-red-700 dark:text-red-300">
242
+ {task.error}
243
+ </div>
244
+ )}
245
+ {task.answer ? (
246
+ <div className="max-h-64 overflow-auto text-xs whitespace-pre-wrap text-agent-foreground">
247
+ {task.answer}
248
+ </div>
249
+ ) : (
250
+ !task.error && (
251
+ <div className="text-xs text-agent-muted-foreground">
252
+ {task.status === 'running'
253
+ ? '任务还在运行,完成后结果会出现在这里。'
254
+ : task.status === 'blocked'
255
+ ? '等待依赖任务完成,就绪后会自动开始。'
256
+ : '无结果。'}
257
+ </div>
258
+ )
259
+ )}
260
+ {task.worktreeState === 'pending' && task.status === 'completed' && (
261
+ <div className="mt-2 flex items-center gap-2">
262
+ <button
263
+ type="button"
264
+ disabled={actingId !== null}
265
+ onClick={() => void runAction(task, 'merge')}
266
+ className="flex items-center gap-1 rounded bg-agent-foreground/10 px-2 py-1 text-[11px] font-medium text-agent-foreground transition-colors hover:bg-agent-foreground/15 disabled:opacity-50"
267
+ data-task-merge
268
+ >
269
+ {acting ? (
270
+ <LuLoaderCircle className="h-3 w-3 animate-spin" />
271
+ ) : (
272
+ <LuGitMerge className="h-3 w-3" />
273
+ )}
274
+ 合并到主仓
275
+ </button>
276
+ <button
277
+ type="button"
278
+ disabled={actingId !== null}
279
+ onClick={() => void runAction(task, 'discard')}
280
+ className="flex items-center gap-1 rounded px-2 py-1 text-[11px] text-agent-muted-foreground transition-colors hover:bg-red-500/10 hover:text-red-600 disabled:opacity-50 dark:hover:text-red-400"
281
+ data-task-discard
282
+ >
283
+ <LuTrash2 className="h-3 w-3" />
284
+ 丢弃
285
+ </button>
286
+ </div>
287
+ )}
288
+ {actionError[task.id] && (
289
+ <div className="mt-2 rounded bg-red-500/10 px-2 py-1.5 text-xs whitespace-pre-wrap text-red-700 dark:text-red-300">
290
+ {actionError[task.id]}
291
+ </div>
292
+ )}
293
+ </div>
294
+ )}
295
+ </div>
296
+ );
297
+ })
298
+ )}
299
+ </div>
300
+
301
+ <div className="border-t border-agent-border px-4 py-2 text-[10px] text-agent-muted-foreground/80">
302
+ 任务由后台智能体独立执行。点任务名可在右侧看推理过程;Esc 关闭。
303
+ </div>
304
+ </div>
305
+ </div>,
306
+ document.body,
307
+ );
308
+ }
309
+
310
+ export default TaskPanelModal;