@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,1226 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useNavigate, useOutletContext, useParams, useSearchParams } from "react-router-dom";
3
+ import { useChatStream, type SteerOutcome } from "@steerable/agent-ui";
4
+ import type { ChatMessage, SSEEvent } from "@steerable/agent-protocol";
5
+ import { getElectronBridge, isElectron } from "@/lib/electron-bridge";
6
+ import { trackBehavior } from "@/lib/insights";
7
+ import {
8
+ createElectronChatTransport,
9
+ regenerateChatMessage,
10
+ } from "@/lib/chat-transport";
11
+ import { ChatHeader } from "@/components/ChatHeader";
12
+ import { LocalChatPanel } from "@/components/chat/LocalChatPanel";
13
+ import { ModelPicker } from "@/components/chat/ModelPicker";
14
+ import {
15
+ ChatInput,
16
+ type ChatInputHandle,
17
+ type ChatMode,
18
+ type MentionReference,
19
+ } from "@/components/chat/ChatInput";
20
+ import {
21
+ persistExecPolicy,
22
+ readStoredExecPolicy,
23
+ type ExecPolicy,
24
+ } from "@/lib/exec-policy";
25
+ import type { ExecutedAction } from "@/components/chat/ExecutedActionsCard";
26
+ import type { ChildInfo } from "@/components/chat/OrchestrationChildrenCard";
27
+ import { foldOrchestrationChildEvents } from "@/components/chat/orchestration-children-model";
28
+ import { parseTurnBlocks, type TurnBlock } from "@/components/chat/turn-timeline";
29
+ import { readPersistedDurationMs } from "@/components/chat/elapsed";
30
+ import { useChatTasks } from "@/components/chat/useChatTasks";
31
+ import { LuListChecks, LuArrowRight } from "react-icons/lu";
32
+ import { LocalLlmSettingsModal } from "@/components/LocalLlmSettingsModal";
33
+ import {
34
+ ChatProjectBadge,
35
+ ProjectPickerButton,
36
+ } from "@/components/chat/ChatProjectBadge";
37
+ import { ProjectTrustBanner } from "@/components/chat/ProjectTrustBanner";
38
+ import {
39
+ getChatLiveStream,
40
+ listProjects,
41
+ type ChatLiveStream,
42
+ type LocalChatMessage,
43
+ type LocalProject,
44
+ type LocalTask,
45
+ } from "@/lib/local-api";
46
+ import type { AgentOutletContext } from "@/layouts/AgentLayout";
47
+ import {
48
+ setPendingFirstMessage,
49
+ takePendingFirstMessage,
50
+ } from "@/lib/pending-first-message";
51
+ import { BRAND_NAME, pickDefaultAgentId } from "@/brand";
52
+
53
+ /**
54
+ * `GET /api/v2/chats/:id/messages` returns rows in `createdAt DESC` (latest
55
+ * first) — the endpoint's own docstring spells it out: tie-breakers are
56
+ * arranged so that when the frontend reverses to ASC, user appears before
57
+ * assistant within the same timestamp. The cloud frontend reverses; this
58
+ * SPA used to forward DESC straight to the chat view, producing a visibly
59
+ * upside-down conversation (newest at the top, oldest near the input).
60
+ *
61
+ * Always go through this helper before handing messages to `useChatStream`
62
+ * — both initial hydration and the header's refresh action. New streaming
63
+ * messages are appended by `useChatStream` itself, so they land at the end
64
+ * (= bottom) without further work.
65
+ */
66
+ function chronological(messages: ChatMessage[] | undefined): ChatMessage[] {
67
+ if (!messages || messages.length === 0) return [];
68
+ return [...messages].reverse();
69
+ }
70
+
71
+ type ChatMessageWithMetadata = ChatMessage & LocalChatMessage;
72
+
73
+ const CHAT_MODE_STORAGE_KEY = "agent-chat-mode";
74
+
75
+ function readStoredMode(): ChatMode {
76
+ if (typeof localStorage === "undefined") return "agent";
77
+ return localStorage.getItem(CHAT_MODE_STORAGE_KEY) === "plan"
78
+ ? "plan"
79
+ : "agent";
80
+ }
81
+
82
+ function extractPersistedActions(
83
+ messages: ChatMessageWithMetadata[] | undefined,
84
+ ): Record<string, ExecutedAction[]> {
85
+ const seeded: Record<string, ExecutedAction[]> = {};
86
+ if (!messages || messages.length === 0) return seeded;
87
+ for (const message of messages) {
88
+ if (message.role !== "assistant" || !message.messageMetadata) continue;
89
+ try {
90
+ const metadata = JSON.parse(message.messageMetadata) as {
91
+ executedActions?: ExecutedAction[];
92
+ };
93
+ if (
94
+ Array.isArray(metadata.executedActions) &&
95
+ metadata.executedActions.length > 0
96
+ ) {
97
+ seeded[message.id] = metadata.executedActions;
98
+ }
99
+ } catch {
100
+ // Ignore malformed legacy metadata.
101
+ }
102
+ }
103
+ return seeded;
104
+ }
105
+
106
+ function extractPersistedTimelines(
107
+ messages: ChatMessageWithMetadata[] | undefined,
108
+ ): Record<string, TurnBlock[]> {
109
+ const seeded: Record<string, TurnBlock[]> = {};
110
+ if (!messages || messages.length === 0) return seeded;
111
+ for (const message of messages) {
112
+ if (message.role !== "assistant" || !message.messageMetadata) continue;
113
+ try {
114
+ const metadata = JSON.parse(message.messageMetadata) as {
115
+ timeline?: unknown;
116
+ };
117
+ const blocks = parseTurnBlocks(metadata.timeline);
118
+ if (blocks) seeded[message.id] = blocks;
119
+ } catch {
120
+ // Ignore malformed legacy metadata.
121
+ }
122
+ }
123
+ return seeded;
124
+ }
125
+
126
+ function extractPersistedDurations(
127
+ messages: ChatMessageWithMetadata[] | undefined,
128
+ ): Record<string, number> {
129
+ const seeded: Record<string, number> = {};
130
+ if (!messages || messages.length === 0) return seeded;
131
+ for (const message of messages) {
132
+ if (message.role !== "assistant" || !message.messageMetadata) continue;
133
+ const durationMs = readPersistedDurationMs(message.messageMetadata);
134
+ if (durationMs != null) seeded[message.id] = durationMs;
135
+ }
136
+ return seeded;
137
+ }
138
+
139
+ /**
140
+ * Routing layer for `/agent/:chatId`.
141
+ *
142
+ * Component split (three layers, intentional):
143
+ * • AgentPage — picks empty-state vs loader based on URL params.
144
+ * • AgentChatLoader — fetches initial history via IPC. Renders a loading
145
+ * splash until messages arrive, THEN mounts AgentChatView. We do this
146
+ * because `useChatStream`'s reducer only consumes `initialMessages` on
147
+ * its first call (the framework hook uses `useReducer((s, a) => …, {
148
+ * messages: initialMessages ?? [] })` — the lazy initializer runs once);
149
+ * mounting the view eagerly with `null → []` then trying to backfill via
150
+ * `setMessages` is racy and loses the user's first turn if it lands
151
+ * during hydration.
152
+ * • AgentChatView — owns the streaming reducer + all per-turn state
153
+ * (round counter, executed-actions queue). Remounts on `chatId` change
154
+ * via `key={chatId}` so the reducer is guaranteed fresh per chat.
155
+ *
156
+ * Sidebar / agent picker / new-chat continue to live in `AgentSidebar`
157
+ * (mounted by `AgentLayout`); this file only worries about the chat detail.
158
+ */
159
+ export function AgentPage() {
160
+ const { chatId } = useParams<{ chatId?: string }>();
161
+ // W1.2.1: bumped on branch switch / regenerate completion — remounting
162
+ // the loader re-hydrates the message list from the re-projected store.
163
+ const [branchTick, setBranchTick] = useState(0);
164
+ const bumpBranchTick = useCallback(() => setBranchTick((t) => t + 1), []);
165
+
166
+ if (!chatId) {
167
+ return <EmptyChatGate />;
168
+ }
169
+
170
+ return (
171
+ <AgentChatLoader
172
+ key={`${chatId}:${branchTick}`}
173
+ chatId={chatId}
174
+ onBranchTick={bumpBranchTick}
175
+ />
176
+ );
177
+ }
178
+
179
+ /**
180
+ * AgentChatLoader hydrates initial chat history before mounting the streaming
181
+ * view. Two states it can be in:
182
+ * • `initialMessages === null` → spinner; effect still in flight.
183
+ * • `initialMessages !== null` → forward into AgentChatView with the array
184
+ * (possibly empty if the chat has no prior messages or hydration failed).
185
+ *
186
+ * Browser-preview mode (no electron bridge) short-circuits to an empty array
187
+ * so the view at least renders the input.
188
+ */
189
+ function AgentChatLoader({
190
+ chatId,
191
+ onBranchTick,
192
+ }: {
193
+ chatId: string;
194
+ onBranchTick: () => void;
195
+ }) {
196
+ const [initialMessages, setInitialMessages] = useState<ChatMessage[] | null>(
197
+ null,
198
+ );
199
+ const [initialExecutedActions, setInitialExecutedActions] = useState<
200
+ Record<string, ExecutedAction[]>
201
+ >({});
202
+ const [initialTimelines, setInitialTimelines] = useState<
203
+ Record<string, TurnBlock[]>
204
+ >({});
205
+ const [initialDurations, setInitialDurations] = useState<
206
+ Record<string, number>
207
+ >({});
208
+ // W7-1: 后端在 messages 响应里下发 interrupted(上一轮崩溃/强杀中断)。
209
+ const [initialInterrupted, setInitialInterrupted] = useState(false);
210
+ // 运行中回合的实时快照:切走再切回时用它在历史消息之上叠出「正在运行」
211
+ // 的助手气泡 + 部分工具卡片 / 时间线。
212
+ const [initialLiveStream, setInitialLiveStream] = useState<ChatLiveStream>({
213
+ active: false,
214
+ });
215
+ const [hydrationError, setHydrationError] = useState<string | null>(null);
216
+
217
+ useEffect(() => {
218
+ let cancelled = false;
219
+ void (async () => {
220
+ if (!isElectron()) {
221
+ if (!cancelled) setInitialMessages([]);
222
+ return;
223
+ }
224
+ try {
225
+ const bridge = getElectronBridge()!;
226
+ const [response, live] = await Promise.all([
227
+ bridge.localBackend.request<{
228
+ messages?: ChatMessageWithMetadata[];
229
+ interrupted?: boolean;
230
+ }>({
231
+ method: "GET",
232
+ path: `/api/v2/chats/${encodeURIComponent(chatId)}/messages?limit=200`,
233
+ }),
234
+ getChatLiveStream(chatId),
235
+ ]);
236
+ if (cancelled) return;
237
+ setInitialMessages(chronological(response.messages));
238
+ setInitialExecutedActions(extractPersistedActions(response.messages));
239
+ setInitialTimelines(extractPersistedTimelines(response.messages));
240
+ setInitialDurations(extractPersistedDurations(response.messages));
241
+ setInitialInterrupted(response.interrupted === true);
242
+ setInitialLiveStream(live ?? { active: false });
243
+ } catch (err) {
244
+ if (cancelled) return;
245
+ setHydrationError(err instanceof Error ? err.message : String(err));
246
+ setInitialMessages([]);
247
+ setInitialExecutedActions({});
248
+ setInitialTimelines({});
249
+ setInitialDurations({});
250
+ setInitialInterrupted(false);
251
+ setInitialLiveStream({ active: false });
252
+ }
253
+ })();
254
+ return () => {
255
+ cancelled = true;
256
+ };
257
+ }, [chatId]);
258
+
259
+ if (initialMessages === null) {
260
+ return (
261
+ <div className="flex h-full w-full items-center justify-center text-sm text-agent-muted-foreground">
262
+ 加载对话历史…
263
+ </div>
264
+ );
265
+ }
266
+
267
+ return (
268
+ <AgentChatView
269
+ chatId={chatId}
270
+ initialMessages={initialMessages}
271
+ initialExecutedActions={initialExecutedActions}
272
+ initialTimelines={initialTimelines}
273
+ initialDurations={initialDurations}
274
+ initialInterrupted={initialInterrupted}
275
+ initialLiveStream={initialLiveStream}
276
+ hydrationError={hydrationError}
277
+ onBranchTick={onBranchTick}
278
+ />
279
+ );
280
+ }
281
+
282
+ interface AgentChatViewProps {
283
+ chatId: string;
284
+ initialMessages: ChatMessage[];
285
+ initialExecutedActions: Record<string, ExecutedAction[]>;
286
+ initialTimelines: Record<string, TurnBlock[]>;
287
+ initialDurations: Record<string, number>;
288
+ /** W7-1: 打开会话时上一轮处于中断态(崩溃/强杀,无完成记录)。 */
289
+ initialInterrupted: boolean;
290
+ /** 打开会话时的运行中回合快照(切回恢复运行状态用)。 */
291
+ initialLiveStream: ChatLiveStream;
292
+ hydrationError: string | null;
293
+ onBranchTick: () => void;
294
+ }
295
+
296
+ function AgentChatView({
297
+ chatId,
298
+ initialMessages,
299
+ initialExecutedActions,
300
+ initialTimelines,
301
+ initialDurations,
302
+ initialInterrupted,
303
+ initialLiveStream,
304
+ hydrationError,
305
+ onBranchTick,
306
+ }: AgentChatViewProps) {
307
+ const ctx = useOutletContext<AgentOutletContext>();
308
+ const chat = ctx.chats.find((c) => c.id === chatId) ?? null;
309
+ const agent =
310
+ (chat?.agentId ? ctx.agents.find((a) => a.id === chat.agentId) : null) ??
311
+ null;
312
+
313
+ // 当前会话绑定的项目 — Codex 式 cwd 指示:输入框上方显示项目名徽章,
314
+ // 点击可关联到其他项目 / 修改项目目录 / 移出项目。无项目会话不显示。
315
+ const [projects, setProjects] = useState<LocalProject[]>([]);
316
+ const fetchProjects = useCallback(async () => {
317
+ if (!isElectron()) return;
318
+ try {
319
+ const res = await listProjects();
320
+ setProjects(res.projects ?? []);
321
+ } catch {
322
+ /* 列表失败保持旧数据 */
323
+ }
324
+ }, []);
325
+ useEffect(() => {
326
+ void fetchProjects();
327
+ }, [fetchProjects]);
328
+ const chatProject = chat?.projectId
329
+ ? (projects.find((p) => p.id === chat.projectId) ?? null)
330
+ : null;
331
+ const transport = useMemo(
332
+ () => createElectronChatTransport(chatId),
333
+ [chatId],
334
+ );
335
+
336
+ // 4.6a 后台任务:header 角标与消息列尾部的终态卡共用这一份订阅,两处
337
+ // 因此不会各拉一次任务表、也不会显示互相错位的状态。
338
+ const {
339
+ tasks,
340
+ finished: finishedTasks,
341
+ dismissFinished,
342
+ } = useChatTasks(chatId);
343
+ const handleInspectTask = useCallback(
344
+ (task: LocalTask) => {
345
+ ctx.inspectTask({ id: task.id, chatId: task.chatId, title: task.task });
346
+ },
347
+ [ctx],
348
+ );
349
+ // ── 运行中回合的实时快照(切走再切回时恢复运行状态)─────────────────
350
+ // 回合不因切页而取消(见下方:卸载时不再 cancelActive),但流式 SSE 只
351
+ // 流向发起 fetch 的那个 mount;切回后的新 mount 靠轮询 GET /live-stream
352
+ // 把「正在运行 + 部分产出」恢复出来。initialLiveStream 是挂载时同步拿到的
353
+ // 首帧,之后由轮询持续刷新。
354
+ const [liveStream, setLiveStream] = useState<ChatLiveStream>(initialLiveStream);
355
+
356
+ // ── executed_actions plumbing ────────────────────────────────────────
357
+ // Local-backend emits `{type: 'executed_actions', actions: [...]}` AFTER each
358
+ // tool-call round; chat-transport.ts maps it to {type: 'agent',
359
+ // event: 'executed_actions', payload}. The framework's `useChatStream`
360
+ // doesn't know what to do with it, so it surfaces it via `onUnknownEvent`.
361
+ //
362
+ // Display invariant:
363
+ // • While streaming, render under the *latest* assistant message via
364
+ // `currentTurnActions` (its framework-assigned id isn't reconciled with
365
+ // the DB id yet — keying by index/recency is the only option).
366
+ // • When the backend emits `message_id` at stream end, flush the queue
367
+ // into `executedActionsByMessageId[msgId]` so the card survives
368
+ // scrolling away / refreshing.
369
+ //
370
+ // History is lost on page reload because local-backend doesn't (yet) persist
371
+ // executed_actions per message — when we hydrate from
372
+ // `/api/v2/chats/:id/messages`, only the inline text summary survives. The
373
+ // card surface degrades to the text summary in that case, which is still
374
+ // readable. Phase 2c can address with a `GET /messages?include=actions`
375
+ // expansion.
376
+ const [executedActionsByMessageId, setExecutedActionsByMessageId] = useState<
377
+ Record<string, ExecutedAction[]>
378
+ >(initialExecutedActions);
379
+ const [currentTurnActions, setCurrentTurnActions] = useState<
380
+ ExecutedAction[]
381
+ >([]);
382
+ const [timelineByMessageId, setTimelineByMessageId] = useState<
383
+ Record<string, TurnBlock[]>
384
+ >(initialTimelines);
385
+ const [currentTurnTimeline, setCurrentTurnTimeline] = useState<
386
+ TurnBlock[] | undefined
387
+ >(undefined);
388
+ const pendingTimelineRef = useRef<TurnBlock[]>([]);
389
+ const [durationByMessageId, setDurationByMessageId] = useState<
390
+ Record<string, number>
391
+ >(initialDurations);
392
+ const turnStartedAtRef = useRef<number | null>(null);
393
+ const [currentTurnStartedAtMs, setCurrentTurnStartedAtMs] = useState<
394
+ number | undefined
395
+ >(undefined);
396
+ const pendingDurationMessageIdRef = useRef<string | null>(null);
397
+ // P3.1 编排:本轮子代理生命周期(orchestration_child SSE 事件累积),
398
+ // 与 currentTurnActions 同款 reconcile 模式(message_id 落库后按键归档)。
399
+ const [currentTurnChildren, setCurrentTurnChildren] = useState<ChildInfo[]>([]);
400
+ const [orchestrationChildrenByMessageId, setOrchestrationChildrenByMessageId] =
401
+ useState<Record<string, ChildInfo[]>>({});
402
+ const pendingChildrenRef = useRef<ChildInfo[]>([]);
403
+ // Round counter for the in-flight turn. Bumped on every `round_end` event
404
+ // emitted by chat-transport (was previously suppressed). Driving this state
405
+ // lets `StreamingStatus` render "Round 2 · 继续推理..." between LLM bursts —
406
+ // before this signal, the UI looked frozen whenever the model paused to
407
+ // wait for tool results.
408
+ const [currentRound, setCurrentRound] = useState(1);
409
+ // Holds the latest in-flight actions for the `message_id` reconciliation
410
+ // — `useState`'s setter sees the latest value via the functional update
411
+ // form, but we keep this ref so the reconciliation event handler can read
412
+ // synchronously without race conditions across React 18's batching.
413
+ //
414
+ // Note: `useChatStream` keeps the current assistant message's placeholder id;
415
+ // it does not patch it to the backend DB id when `message_id` arrives. So we
416
+ // keep `currentTurnActions` visible until the next submit, while also storing
417
+ // the DB-id keyed copy for history after refresh.
418
+ const pendingActionsRef = useRef<ExecutedAction[]>([]);
419
+
420
+ // ── Plan 模式(类 Cursor "先出计划")────────────────────────────────────
421
+ // mode 持久化到 localStorage,跨对话/刷新保留用户偏好。planReady 控制"开始
422
+ // 执行计划"操作条:当一轮 plan 模式回复结束后置为 true。
423
+ const [mode, setMode] = useState<ChatMode>(readStoredMode);
424
+ const [execPolicy, setExecPolicy] = useState<ExecPolicy>(readStoredExecPolicy);
425
+ const [planReady, setPlanReady] = useState(false);
426
+ const planTurnRef = useRef(false);
427
+ const wasStreamingRef = useRef(false);
428
+
429
+ const handleModeChange = useCallback((next: ChatMode) => {
430
+ setMode(next);
431
+ if (typeof localStorage !== "undefined") {
432
+ localStorage.setItem(CHAT_MODE_STORAGE_KEY, next);
433
+ }
434
+ }, []);
435
+
436
+ const handleExecPolicyChange = useCallback((next: ExecPolicy) => {
437
+ setExecPolicy(next);
438
+ persistExecPolicy(next);
439
+ }, []);
440
+
441
+ const handleUnknownEvent = useCallback((event: SSEEvent) => {
442
+ const ev = event as any;
443
+ if (ev.type !== "agent") return;
444
+ if (ev.event === "executed_actions") {
445
+ const actions = ev.payload?.actions as ExecutedAction[] | undefined;
446
+ if (Array.isArray(actions)) {
447
+ // Local-backend always emits the full accumulated list; replace, don't
448
+ // append, to avoid double-rendering when the round re-fires.
449
+ pendingActionsRef.current = actions;
450
+ setCurrentTurnActions(actions);
451
+ }
452
+ return;
453
+ }
454
+ if (ev.event === "turn_timeline") {
455
+ const blocks = parseTurnBlocks(ev.payload?.blocks);
456
+ if (blocks) {
457
+ pendingTimelineRef.current = blocks;
458
+ setCurrentTurnTimeline(blocks);
459
+ }
460
+ return;
461
+ }
462
+ if (ev.event === "round_end") {
463
+ // chat-transport surfaces `completion executing` as round_end. Bumping
464
+ // the counter here is safe even if the round emits no tools — the
465
+ // status component only switches to "Round N" labelling once N > 1.
466
+ // `completion cancelled`(用户 Stop / 窗口关闭)也会落到 round_end,
467
+ // 这不是新一轮,跳过计数。
468
+ if (ev.payload?.status === "cancelled") return;
469
+ setCurrentRound((r) => r + 1);
470
+ return;
471
+ }
472
+ if (ev.event === "orchestration_child") {
473
+ const childId = ev.payload?.childId as string | undefined;
474
+ const kind = ev.payload?.kind as string | undefined;
475
+ if (!childId || !kind) return;
476
+ const update = (list: ChildInfo[]): ChildInfo[] => {
477
+ const idx = list.findIndex((c) => c.childId === childId);
478
+ if (kind === "child_spawned") {
479
+ if (idx >= 0) return list;
480
+ return [
481
+ ...list,
482
+ {
483
+ childId,
484
+ task: ev.payload?.task as string | undefined,
485
+ depth: ev.payload?.depth as number | undefined,
486
+ profile: ev.payload?.profile as string | undefined,
487
+ status: "running",
488
+ },
489
+ ];
490
+ }
491
+ if (idx < 0) return list;
492
+ const status =
493
+ kind === "child_completed"
494
+ ? "completed"
495
+ : kind === "child_failed"
496
+ ? "failed"
497
+ : kind === "child_cancelled"
498
+ ? "cancelled"
499
+ : kind === "child_interrupted"
500
+ ? "interrupted"
501
+ : kind === "child_resumed"
502
+ ? "running"
503
+ : list[idx].status;
504
+ const next = [...list];
505
+ next[idx] = { ...list[idx], status };
506
+ return next;
507
+ };
508
+ pendingChildrenRef.current = update(pendingChildrenRef.current);
509
+ setCurrentTurnChildren(pendingChildrenRef.current);
510
+ return;
511
+ }
512
+ if (ev.event === "message_id") {
513
+ const messageId = ev.payload?.messageId as string | undefined;
514
+ if (!messageId) return;
515
+ pendingDurationMessageIdRef.current = messageId;
516
+ const queued = pendingActionsRef.current;
517
+ if (queued.length > 0) {
518
+ setExecutedActionsByMessageId((prev) => ({
519
+ ...prev,
520
+ [messageId]: queued,
521
+ }));
522
+ }
523
+ pendingActionsRef.current = [];
524
+ const queuedTimeline = pendingTimelineRef.current;
525
+ if (queuedTimeline.length > 0) {
526
+ setTimelineByMessageId((prev) => ({
527
+ ...prev,
528
+ [messageId]: queuedTimeline,
529
+ }));
530
+ }
531
+ pendingTimelineRef.current = [];
532
+ const queuedChildren = pendingChildrenRef.current;
533
+ if (queuedChildren.length > 0) {
534
+ setOrchestrationChildrenByMessageId((prev) => ({
535
+ ...prev,
536
+ [messageId]: queuedChildren,
537
+ }));
538
+ }
539
+ pendingChildrenRef.current = [];
540
+ }
541
+ // AI 标题更新走的是后台 IPC 广播(chat-title-updated)而不是 SSE——
542
+ // 见 AgentLayout 里的订阅。SSE 通道在 [DONE] 之后就不再监听了,title-gen
543
+ // 是 fire-and-forget 的,所以这边不需要也不应该处理 chat_title_updated。
544
+ }, []);
545
+
546
+ const {
547
+ messages,
548
+ isStreaming,
549
+ sendUserMessage,
550
+ resumeTurn,
551
+ steerOrFollowUpUserMessage,
552
+ followUpUserMessage,
553
+ pendingFollowUps,
554
+ removeFollowUp,
555
+ cancel,
556
+ appendMessage,
557
+ } = useChatStream({
558
+ transport,
559
+ initialMessages,
560
+ onUnknownEvent: handleUnknownEvent,
561
+ });
562
+
563
+ // ── 切回恢复:远端回合仍在跑,但本 mount 不是发起者 ──────────────────
564
+ // useChatStream.isStreaming 只在本 mount 自己发起的流时为 true;切回后的
565
+ // 新 mount 没发起流,所以 isStreaming=false,但后端快照 liveStream.active
566
+ // 为 true —— 此时用快照叠出「正在运行」的助手气泡 + 部分工具卡片/时间线。
567
+ const remoteStreaming = liveStream.active === true && !isStreaming;
568
+
569
+ // 远端轮中转向:transport.steer 按 chatId 找活跃流,天然支持「切回后注入」
570
+ // ——不像 useChatStream.steerOrFollowUpUserMessage 那样被本地 isStreamingRef
571
+ // 卡住。注入成功后把用户消息追加到本地 transcript(与正常转向一致)。
572
+ const steerRemote = useCallback(
573
+ async (text: string): Promise<SteerOutcome> => {
574
+ const steer = transport.steer;
575
+ const ok = steer ? await steer(text) : false;
576
+ if (ok) {
577
+ appendMessage({
578
+ id: `user_steer_${Date.now()}_${Math.floor(Math.random() * 1e6)}`,
579
+ role: "user",
580
+ content: text,
581
+ createdAt: new Date().toISOString(),
582
+ });
583
+ return "steered";
584
+ }
585
+ // 回合可能恰好已结束——回落 queued 让 ChatInput 给出提示,紧跟着的
586
+ // re-hydrate 会把最终消息拉出来。
587
+ return "queued";
588
+ },
589
+ [transport, appendMessage],
590
+ );
591
+
592
+ // 轮询运行中回合的快照,直到它结束。
593
+ useEffect(() => {
594
+ if (!remoteStreaming) return;
595
+ let cancelled = false;
596
+ const poll = async () => {
597
+ try {
598
+ const next = await getChatLiveStream(chatId);
599
+ if (!cancelled) setLiveStream(next ?? { active: false });
600
+ } catch {
601
+ /* 保持上一帧快照 */
602
+ }
603
+ };
604
+ void poll();
605
+ const id = window.setInterval(poll, 750);
606
+ return () => {
607
+ cancelled = true;
608
+ window.clearInterval(id);
609
+ };
610
+ }, [chatId, remoteStreaming]);
611
+
612
+ // 快照从 active=true → false 的边沿:远端回合结束了,re-hydrate 出最终
613
+ // 落库消息(含真实 message id / executedActions / timeline)。
614
+ const wasLiveActiveRef = useRef(liveStream.active === true);
615
+ useEffect(() => {
616
+ const nowActive = liveStream.active === true;
617
+ if (wasLiveActiveRef.current && !nowActive) {
618
+ onBranchTick();
619
+ }
620
+ wasLiveActiveRef.current = nowActive;
621
+ }, [liveStream.active, onBranchTick]);
622
+
623
+ // W7-1: 中断提示卡的可见性。初值来自 messages 响应的 interrupted 标记;
624
+ // 点击「继续」/「忽略」或用户手动发起新一轮时本地清除(继续成功后后端的
625
+ // turn_active 标记已随回复落库清除,重新加载不会再报)。
626
+ const [interrupted, setInterrupted] = useState(initialInterrupted);
627
+
628
+ // 监听 streaming 结束沿:若刚结束的这一轮是 plan 模式,展示"开始执行计划"操作条。
629
+ // 同时冻结本轮墙钟,写进 durationByMessageId(占位 id + 落库 id),折叠条
630
+ // 才能在刷新前显示「工作了 …」。
631
+ useEffect(() => {
632
+ if (wasStreamingRef.current && !isStreaming) {
633
+ if (planTurnRef.current) {
634
+ setPlanReady(true);
635
+ planTurnRef.current = false;
636
+ }
637
+ const started = turnStartedAtRef.current;
638
+ if (started != null) {
639
+ const elapsed = Math.max(0, Date.now() - started);
640
+ const lastAssistant = [...messages]
641
+ .reverse()
642
+ .find((item) => item.role === "assistant");
643
+ setDurationByMessageId((prev) => {
644
+ const next = { ...prev };
645
+ if (lastAssistant) next[lastAssistant.id] = elapsed;
646
+ if (pendingDurationMessageIdRef.current) {
647
+ next[pendingDurationMessageIdRef.current] = elapsed;
648
+ }
649
+ return next;
650
+ });
651
+ }
652
+ }
653
+ wasStreamingRef.current = isStreaming;
654
+ }, [isStreaming, messages]);
655
+
656
+ // 真正能中断后端 agent 循环的取消:框架 hook 的 cancel 只重置前端状态
657
+ // (cancel 句柄在流结束后才被保存,是已知的上游缺陷),这里补上
658
+ // transport.cancelActive() 让主进程 abort LLM / 工具循环。
659
+ const handleCancel = useCallback(() => {
660
+ transport.cancelActive();
661
+ cancel();
662
+ }, [transport, cancel]);
663
+
664
+ // Reset the in-flight queue on every new user submit. We do this in a
665
+ // wrapper rather than directly in `handleUnknownEvent`, because there's no
666
+ // distinct SSE event for "turn started" — `user_message` is suppressed by
667
+ // chat-transport, and tokens just start streaming.
668
+ // 模型选择器的每轮覆盖:null = 跟随全局设置 / 厂商预制。网关目录由
669
+ // ModelPicker 自取(GET /api/v2/llm/models → sidecar models.list)。
670
+ const [modelOverride, setModelOverride] = useState<string | null>(null);
671
+ const [effortOverride, setEffortOverride] = useState<string | null>(null);
672
+
673
+ const handleSubmit = useCallback(
674
+ (input: { content: string; metadata?: Record<string, unknown> }) => {
675
+ trackBehavior('composer_send', {
676
+ empty: !input.content.trim(),
677
+ mode: input.metadata?.mode === 'plan' ? 'plan' : 'agent',
678
+ execPolicy,
679
+ length: input.content.trim().length,
680
+ });
681
+ pendingActionsRef.current = [];
682
+ setCurrentTurnActions([]);
683
+ pendingTimelineRef.current = [];
684
+ setCurrentTurnTimeline([]);
685
+ pendingChildrenRef.current = [];
686
+ setCurrentTurnChildren([]);
687
+ setCurrentRound(1);
688
+ const started = Date.now();
689
+ turnStartedAtRef.current = started;
690
+ setCurrentTurnStartedAtMs(started);
691
+ pendingDurationMessageIdRef.current = null;
692
+ // 新一轮开始:记录本轮是否为 plan 模式,并隐藏上一份计划的操作条。
693
+ planTurnRef.current = input.metadata?.mode === "plan";
694
+ setPlanReady(false);
695
+ // 手动发起新一轮即取代中断态——用户已经继续往前走了。
696
+ setInterrupted(false);
697
+ // 模型选择器的每轮覆盖随 metadata 下发:model 覆盖设置里的全局
698
+ // 模型;reasoningEffort 由 sidecar 按目录严格校验(不支持即报错,
699
+ // 不静默丢弃)。都为 null 时保持设置/预制默认。
700
+ const metadata = {
701
+ ...input.metadata,
702
+ ...(modelOverride ? { model: modelOverride } : {}),
703
+ ...(effortOverride ? { reasoningEffort: effortOverride } : {}),
704
+ ...(execPolicy === "full" ? { execPolicy: "full" } : {}),
705
+ };
706
+ return sendUserMessage({
707
+ ...input,
708
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
709
+ });
710
+ },
711
+ [sendUserMessage, modelOverride, effortOverride, execPolicy],
712
+ );
713
+
714
+ // 包槽位 fallback 发送通道(如文档包的单页修改在 sidecar 未就绪时把
715
+ // 后端拼好的指令退回主聊天):AgentLayout 持有发送器 ref,这里挂载时
716
+ // 注册、卸载时注销,包面板经 sendChatMessage 调到当前注册的 handleSubmit。
717
+ const { registerChatMessageSender } = ctx;
718
+ useEffect(() => {
719
+ registerChatMessageSender(handleSubmit);
720
+ return () => registerChatMessageSender(null);
721
+ }, [registerChatMessageSender, handleSubmit]);
722
+
723
+ // W7-1「继续上次回复」:走 resume 通道续跑被中断的 turn——不追加用户
724
+ // 消息,sidecar 回放 durable record 的投影作为循环种子。每轮重置项与
725
+ // handleSubmit 相同;plan 模式标记沿用当前模式(与正常发送一致)。
726
+ const handleContinueInterrupted = useCallback(() => {
727
+ if (isStreaming) return;
728
+ setInterrupted(false);
729
+ pendingActionsRef.current = [];
730
+ setCurrentTurnActions([]);
731
+ pendingTimelineRef.current = [];
732
+ setCurrentTurnTimeline([]);
733
+ pendingChildrenRef.current = [];
734
+ setCurrentTurnChildren([]);
735
+ setCurrentRound(1);
736
+ const started = Date.now();
737
+ turnStartedAtRef.current = started;
738
+ setCurrentTurnStartedAtMs(started);
739
+ pendingDurationMessageIdRef.current = null;
740
+ planTurnRef.current = mode === "plan";
741
+ setPlanReady(false);
742
+ const metadata = {
743
+ ...(mode === "plan" ? { mode: "plan" as const } : {}),
744
+ ...(execPolicy === "full" ? { execPolicy: "full" as const } : {}),
745
+ };
746
+ return resumeTurn({
747
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
748
+ });
749
+ }, [isStreaming, mode, execPolicy, resumeTurn]);
750
+
751
+ // W7-1「忽略」:仅本次挂载隐藏卡片,不写库——标记仍在,下次打开会再提示。
752
+ const handleDismissInterrupted = useCallback(() => {
753
+ setInterrupted(false);
754
+ }, []);
755
+
756
+ // 轮中转向 + 失败兜底(W6-2):streaming 期间 Enter 优先注入运行中的
757
+ // CoreLoop 回合;注入不了时 hook 按回合实况降级——仍在跑则排入待发队列
758
+ // ('queued',ChatInput 提示"已改为排队"),恰好已结束则作为新回合直发
759
+ // ('sent')。消息不会丢,ChatInput 对任何结果都清草稿。
760
+ const handleSteer = useCallback(
761
+ (text: string) =>
762
+ remoteStreaming ? steerRemote(text) : steerOrFollowUpUserMessage(text),
763
+ [remoteStreaming, steerRemote, steerOrFollowUpUserMessage],
764
+ );
765
+
766
+ // follow-up 排队(W6-2):streaming 期间 ⌘/Ctrl+Enter 把文本排入待发
767
+ // 队列,本轮结束后自动作为下一轮发出。与转向(注入当前回合)相对。
768
+ // 切回后的远端回合没有本地 follow-up 队列可挂,降级为注入当前回合。
769
+ const handleFollowUp = useCallback(
770
+ (text: string) => {
771
+ if (remoteStreaming) {
772
+ void steerRemote(text);
773
+ return;
774
+ }
775
+ followUpUserMessage({ content: text });
776
+ },
777
+ [remoteStreaming, steerRemote, followUpUserMessage],
778
+ );
779
+
780
+ // 首页输入框接力:EmptyChatGate 建好 chat 并跳转后,首条消息暂存在
781
+ // pending-first-message 里,这里挂载后自动发出——用户感知就是"首页输入
782
+ // 即开聊"。take 取出即清 + ref 守卫,StrictMode 双跑 effect 不会重发。
783
+ const pendingFirstSentRef = useRef(false);
784
+ useEffect(() => {
785
+ if (pendingFirstSentRef.current) return;
786
+ const pending = takePendingFirstMessage(chatId);
787
+ if (!pending) return;
788
+ pendingFirstSentRef.current = true;
789
+ const pendingModel =
790
+ typeof pending.metadata?.model === "string" ? pending.metadata.model : null;
791
+ const pendingEffort =
792
+ typeof pending.metadata?.reasoningEffort === "string"
793
+ ? pending.metadata.reasoningEffort
794
+ : null;
795
+ if (pendingModel) setModelOverride(pendingModel);
796
+ if (pendingEffort) setEffortOverride(pendingEffort);
797
+ void handleSubmit({
798
+ content: pending.content,
799
+ metadata: pending.metadata,
800
+ });
801
+ }, [chatId, handleSubmit]);
802
+
803
+ // "开始执行计划":切回 agent 模式并自动发送执行指令,恢复全量工具。
804
+ const handleExecutePlan = useCallback(() => {
805
+ handleModeChange("agent");
806
+ setPlanReady(false);
807
+ void handleSubmit({ content: "请按照上面的计划开始执行。" });
808
+ }, [handleModeChange, handleSubmit]);
809
+
810
+ // 分享当前对话:截取整个聊天面板区域(含 header + 消息 + 输入框),
811
+ // 由主进程 capturePage 后写入系统剪贴板,用户可直接粘贴到任何地方。
812
+ const handleShare = useCallback(async (): Promise<boolean> => {
813
+ if (!isElectron()) return false;
814
+ const bridge = getElectronBridge()!;
815
+ const panel = document.querySelector('.chat-panel-container');
816
+ const rect = panel?.getBoundingClientRect();
817
+ const result = await bridge.local?.captureScreenshot(
818
+ rect && rect.width > 0 && rect.height > 0
819
+ ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
820
+ : undefined,
821
+ );
822
+ return result?.success === true;
823
+ }, []);
824
+
825
+ // ChatInput toolbar's gear button — opens the same LLM settings modal the
826
+ // sidebar's bottom button opens. Two independent mounts is fine: they read
827
+ // the same backend config endpoint and the user can only see one at a time.
828
+ // Lifting modal state to AgentLayout would let us mount once but adds
829
+ // outlet-context plumbing that isn't worth it for a binary on/off flag.
830
+ const [llmSettingsOpen, setLlmSettingsOpen] = useState(false);
831
+
832
+ const handleSelectAgent = useCallback(
833
+ (agentId: string) => {
834
+ ctx.setSelectedAgentId(agentId);
835
+ },
836
+ [ctx],
837
+ );
838
+
839
+ // ── 把远端运行中的快照叠进渲染层 ────────────────────────────────────
840
+ const remoteContent = remoteStreaming ? (liveStream.content ?? "") : null;
841
+ const effectiveMessages = useMemo<ChatMessage[]>(() => {
842
+ if (remoteContent === null) return messages;
843
+ return [
844
+ ...messages,
845
+ {
846
+ id: `live_${chatId}`,
847
+ role: "assistant",
848
+ content: remoteContent,
849
+ createdAt: new Date().toISOString(),
850
+ },
851
+ ];
852
+ }, [messages, remoteContent, chatId]);
853
+ const effectiveIsStreaming = isStreaming || remoteStreaming;
854
+ const effectiveCurrentTurnActions: ExecutedAction[] = remoteStreaming
855
+ ? ((liveStream.executedActions as ExecutedAction[] | undefined) ?? [])
856
+ : currentTurnActions;
857
+ const effectiveCurrentTurnTimeline: TurnBlock[] | undefined = remoteStreaming
858
+ ? (parseTurnBlocks(liveStream.timeline) ?? undefined)
859
+ : currentTurnTimeline;
860
+ const effectiveCurrentTurnChildren: ChildInfo[] = remoteStreaming
861
+ ? foldOrchestrationChildEvents(
862
+ (liveStream.children ?? []) as ReadonlyArray<Record<string, unknown>>,
863
+ )
864
+ : currentTurnChildren;
865
+
866
+ return (
867
+ <div className="flex h-full w-full flex-col">
868
+ {hydrationError && (
869
+ <div className="border-b border-agent-border bg-agent-muted/60 px-3 py-1 text-xs text-agent-destructive">
870
+ 加载对话历史失败:{hydrationError}
871
+ </div>
872
+ )}
873
+ <LocalChatPanel
874
+ messages={effectiveMessages}
875
+ isStreaming={effectiveIsStreaming}
876
+ onSubmit={handleSubmit}
877
+ onCancel={handleCancel}
878
+ onSteer={handleSteer}
879
+ onFollowUp={handleFollowUp}
880
+ pendingFollowUps={pendingFollowUps.map((m) => m.content)}
881
+ onRemoveFollowUp={removeFollowUp}
882
+ className="flex-1"
883
+ emptyHero={{
884
+ title: BRAND_NAME,
885
+ subtitle: '输入消息,直接开始一段新对话。',
886
+ }}
887
+ header={
888
+ <ChatHeader
889
+ chat={chat}
890
+ agent={agent}
891
+ onBranchSwitched={isElectron() ? onBranchTick : undefined}
892
+ onInspectTask={ctx.inspectTask}
893
+ tasks={tasks}
894
+ />
895
+ }
896
+ onRegenerate={
897
+ isElectron()
898
+ ? async (messageId) => {
899
+ // 后端 regenerate 流跑完后桌面 store 已是新分支投影;
900
+ // bump tick 重挂消息列表。
901
+ await regenerateChatMessage(chatId, messageId);
902
+ onBranchTick();
903
+ }
904
+ : undefined
905
+ }
906
+ inputPlaceholder="向本地 Agent 发送消息…"
907
+ agents={ctx.agents}
908
+ chats={ctx.chats}
909
+ currentAgent={agent}
910
+ selectedAgentId={ctx.selectedAgentId}
911
+ onSelectAgent={handleSelectAgent}
912
+ executedActionsByMessageId={executedActionsByMessageId}
913
+ currentTurnActions={effectiveCurrentTurnActions}
914
+ timelineByMessageId={timelineByMessageId}
915
+ currentTurnTimeline={effectiveCurrentTurnTimeline}
916
+ currentTurnStartedAtMs={currentTurnStartedAtMs}
917
+ durationByMessageId={durationByMessageId}
918
+ currentTurnChildren={effectiveCurrentTurnChildren}
919
+ orchestrationChildrenByMessageId={orchestrationChildrenByMessageId}
920
+ currentRound={currentRound}
921
+ interrupted={interrupted}
922
+ onContinueInterrupted={handleContinueInterrupted}
923
+ onDismissInterrupted={handleDismissInterrupted}
924
+ finishedTasks={finishedTasks}
925
+ onInspectTask={handleInspectTask}
926
+ onDismissFinishedTask={dismissFinished}
927
+ onShare={isElectron() ? handleShare : undefined}
928
+ onOpenSettings={
929
+ isElectron() ? () => setLlmSettingsOpen(true) : undefined
930
+ }
931
+ inputToolbarExtras={
932
+ <ModelPicker
933
+ model={modelOverride}
934
+ reasoningEffort={effortOverride}
935
+ onSelectModel={setModelOverride}
936
+ onSelectEffort={setEffortOverride}
937
+ disabled={isStreaming}
938
+ />
939
+ }
940
+ mode={mode}
941
+ onModeChange={handleModeChange}
942
+ execPolicy={execPolicy}
943
+ onExecPolicyChange={handleExecPolicyChange}
944
+ inputLeadingChrome={
945
+ <ChatProjectBadge
946
+ chatId={chatId}
947
+ project={chatProject}
948
+ projects={projects}
949
+ onProjectsChanged={fetchProjects}
950
+ onChatProjectChanged={ctx.refreshChats}
951
+ />
952
+ }
953
+ inputBanner={
954
+ <>
955
+ {/* W6-5 项目信任门控:项目含规则文件但未信任时提示授权。 */}
956
+ <ProjectTrustBanner
957
+ chatId={chatId}
958
+ project={chatProject}
959
+ onTrustChanged={fetchProjects}
960
+ />
961
+ {planReady && !isStreaming ? (
962
+ <div className="mx-3 mb-1 flex items-center justify-between gap-3 rounded-agent-md border border-amber-400/50 bg-amber-400/10 px-3 py-2 text-xs">
963
+ <div className="flex items-center gap-2 text-amber-700 dark:text-amber-300">
964
+ <LuListChecks className="h-4 w-4 shrink-0" />
965
+ <span>计划已生成。确认无误后可切换到 Agent 模式开始执行。</span>
966
+ </div>
967
+ <div className="flex items-center gap-2">
968
+ <button
969
+ type="button"
970
+ onClick={() => setPlanReady(false)}
971
+ className="rounded-full px-2 py-1 text-agent-muted-foreground transition-colors hover:text-agent-foreground"
972
+ data-testid="plan-dismiss"
973
+ >
974
+ 忽略
975
+ </button>
976
+ <button
977
+ type="button"
978
+ onClick={handleExecutePlan}
979
+ className="inline-flex items-center gap-1 rounded-full bg-agent-foreground px-3 py-1 font-medium text-agent-canvas transition hover:opacity-90"
980
+ data-testid="plan-execute"
981
+ >
982
+ 开始执行计划
983
+ <LuArrowRight className="h-3.5 w-3.5" />
984
+ </button>
985
+ </div>
986
+ </div>
987
+ ) : null}
988
+ </>
989
+ }
990
+ />
991
+ {isElectron() && (
992
+ <LocalLlmSettingsModal
993
+ open={llmSettingsOpen}
994
+ onClose={() => setLlmSettingsOpen(false)}
995
+ />
996
+ )}
997
+ {/* W4-1 审批弹窗挂在 AgentLayout(回合不因页面切换而暂停,
998
+ 模态必须跨路由可渲染)。 */}
999
+ </div>
1000
+ );
1001
+ }
1002
+
1003
+ /**
1004
+ * 无 chatId 的落地页 —— workbuddy 式首页:居中一个完整的 ChatInput,
1005
+ * 输入即开聊。提交时 createChat → 暂存首条消息 → navigate 到新对话,
1006
+ * AgentChatView 挂载后自动把这条消息发出去(见 pending-first-message)。
1007
+ * 侧栏「新对话」只打开本页,不预先落库;没有发出去的内容不会出现在会话列表。
1008
+ *
1009
+ * ChatInput 是纯 props 驱动组件(无 ChatPanel context 依赖),这里独立
1010
+ * 渲染一份,agent 选择 / 模型选择 / plan 模式 / @ 引用 / 文件附加全部
1011
+ * 可用——与对话内输入框能力对齐。agent 选择只改 selectedAgentId(还没有
1012
+ * chat 可绑);模型覆盖随 pending-first-message metadata 交给新对话。
1013
+ */
1014
+ function EmptyChatGate() {
1015
+ const ctx = useOutletContext<AgentOutletContext>();
1016
+ const navigate = useNavigate();
1017
+ const [searchParams] = useSearchParams();
1018
+ const projectIdFromUrl = searchParams.get("projectId");
1019
+ const [inputValue, setInputValue] = useState("");
1020
+ const [files, setFiles] = useState<{ name: string; path: string }[]>([]);
1021
+ const [mentionReferences, setMentionReferences] = useState<
1022
+ MentionReference[]
1023
+ >([]);
1024
+ const [mode, setMode] = useState<ChatMode>(readStoredMode);
1025
+ const [execPolicy, setExecPolicy] = useState<ExecPolicy>(readStoredExecPolicy);
1026
+ const [modelOverride, setModelOverride] = useState<string | null>(null);
1027
+ const [effortOverride, setEffortOverride] = useState<string | null>(null);
1028
+ const [isCreating, setIsCreating] = useState(false);
1029
+ const [createError, setCreateError] = useState<string | null>(null);
1030
+ const [llmSettingsOpen, setLlmSettingsOpen] = useState(false);
1031
+ const inputRef = useRef<ChatInputHandle>(null);
1032
+
1033
+ // 落地页项目选择:还没有 chat 可绑,选中的 projectId 在首次提交
1034
+ // createChat 时一并传入。侧栏项目组「+」会带 ?projectId= 预选。
1035
+ const [projects, setProjects] = useState<LocalProject[]>([]);
1036
+ const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
1037
+ projectIdFromUrl,
1038
+ );
1039
+ useEffect(() => {
1040
+ setSelectedProjectId(projectIdFromUrl);
1041
+ }, [projectIdFromUrl]);
1042
+ useEffect(() => {
1043
+ if (!isElectron()) return;
1044
+ let cancelled = false;
1045
+ listProjects()
1046
+ .then((res) => {
1047
+ if (!cancelled) setProjects(res.projects ?? []);
1048
+ })
1049
+ .catch(() => {});
1050
+ return () => {
1051
+ cancelled = true;
1052
+ };
1053
+ }, []);
1054
+
1055
+ useEffect(() => {
1056
+ inputRef.current?.focusAtEnd();
1057
+ }, []);
1058
+
1059
+ const selectedAgent =
1060
+ ctx.agents.find((a) => a.id === ctx.selectedAgentId) ??
1061
+ ctx.agents.find((a) => a.id === pickDefaultAgentId(ctx.agents)) ??
1062
+ ctx.agents[0] ??
1063
+ null;
1064
+
1065
+ const handleModeChange = useCallback((next: ChatMode) => {
1066
+ setMode(next);
1067
+ if (typeof localStorage !== "undefined") {
1068
+ localStorage.setItem(CHAT_MODE_STORAGE_KEY, next);
1069
+ }
1070
+ }, []);
1071
+
1072
+ const handleExecPolicyChange = useCallback((next: ExecPolicy) => {
1073
+ setExecPolicy(next);
1074
+ persistExecPolicy(next);
1075
+ }, []);
1076
+
1077
+ const handleSelectAgent = useCallback(
1078
+ (agentId: string) => {
1079
+ ctx.setSelectedAgentId(agentId);
1080
+ },
1081
+ [ctx],
1082
+ );
1083
+
1084
+ const handleSubmit = useCallback(async () => {
1085
+ if (isCreating) return;
1086
+ let trimmed = inputValue.trim();
1087
+ if (!trimmed && files.length === 0) return;
1088
+ trackBehavior('composer_send', {
1089
+ empty: false,
1090
+ home: true,
1091
+ mode: mode === 'plan' ? 'plan' : 'agent',
1092
+ execPolicy,
1093
+ length: trimmed.length,
1094
+ });
1095
+
1096
+ // 与 LocalChatPanel.handleSubmit 相同的装配规则:文件路径附到正文,
1097
+ // @ 引用进 metadata,plan 模式打 metadata.mode。
1098
+ if (files.length > 0) {
1099
+ const fileRefs = files.map((f) => `- \`${f.path}\``).join("\n");
1100
+ trimmed = trimmed
1101
+ ? `${trimmed}\n\n---\n关联文件:\n${fileRefs}`
1102
+ : `关联文件:\n${fileRefs}`;
1103
+ }
1104
+ const mentionedAgentIds = mentionReferences
1105
+ .filter((ref) => ref.type === "agent")
1106
+ .map((ref) => ref.id);
1107
+ const referencedChatIds = mentionReferences
1108
+ .filter((ref) => ref.type === "chat")
1109
+ .map((ref) => ref.id);
1110
+ const metadata = {
1111
+ ...(mode === "plan" ? { mode: "plan" as const } : {}),
1112
+ ...(execPolicy === "full" ? { execPolicy: "full" as const } : {}),
1113
+ ...(ctx.selectedAgentId ? { agentId: ctx.selectedAgentId } : {}),
1114
+ ...(mentionedAgentIds.length > 0 ? { mentionedAgentIds } : {}),
1115
+ ...(referencedChatIds.length > 0 ? { referencedChatIds } : {}),
1116
+ ...(modelOverride ? { model: modelOverride } : {}),
1117
+ ...(effortOverride ? { reasoningEffort: effortOverride } : {}),
1118
+ };
1119
+
1120
+ setIsCreating(true);
1121
+ setCreateError(null);
1122
+ try {
1123
+ const id = await ctx.createChat({
1124
+ ...(selectedProjectId ? { projectId: selectedProjectId } : {}),
1125
+ ...(ctx.selectedAgentId ? { agentId: ctx.selectedAgentId } : {}),
1126
+ });
1127
+ if (!id) throw new Error("创建对话失败,请重试");
1128
+ setPendingFirstMessage({
1129
+ chatId: id,
1130
+ content: trimmed,
1131
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
1132
+ });
1133
+ navigate(`/agent/${id}`);
1134
+ // 跳转成功后组件即卸载,不用清 isCreating;失败才恢复可交互。
1135
+ } catch (err) {
1136
+ setCreateError(err instanceof Error ? err.message : String(err));
1137
+ setIsCreating(false);
1138
+ }
1139
+ }, [inputValue, files, mentionReferences, mode, execPolicy, modelOverride, effortOverride, isCreating, selectedProjectId, ctx, navigate]);
1140
+
1141
+ return (
1142
+ <div
1143
+ className="flex h-full w-full items-center justify-center p-4 sm:p-8"
1144
+ data-testid="empty-chat-home"
1145
+ >
1146
+ <div className="flex w-full max-w-2xl flex-col items-center gap-6">
1147
+ <div className="text-center">
1148
+ <h1 className="text-xl font-semibold tracking-tight text-agent-foreground">
1149
+ {BRAND_NAME}
1150
+ </h1>
1151
+ <p className="mt-1.5 text-sm text-agent-muted-foreground">
1152
+ 输入消息,直接开始一段新对话。
1153
+ </p>
1154
+ </div>
1155
+ <div className="w-full">
1156
+ <ChatInput
1157
+ ref={inputRef}
1158
+ value={inputValue}
1159
+ onChange={setInputValue}
1160
+ onSubmit={handleSubmit}
1161
+ disabled={!isElectron() || isCreating}
1162
+ placeholder={
1163
+ mode === "plan"
1164
+ ? "描述你的目标,Agent 将先制定计划…"
1165
+ : "向本地 Agent 发送消息…"
1166
+ }
1167
+ currentAgent={selectedAgent}
1168
+ agents={ctx.agents}
1169
+ chats={ctx.chats}
1170
+ selectedAgentId={ctx.selectedAgentId}
1171
+ onSelectAgent={handleSelectAgent}
1172
+ onOpenSettings={
1173
+ isElectron() ? () => setLlmSettingsOpen(true) : undefined
1174
+ }
1175
+ toolbarExtras={
1176
+ <ModelPicker
1177
+ model={modelOverride}
1178
+ reasoningEffort={effortOverride}
1179
+ onSelectModel={setModelOverride}
1180
+ onSelectEffort={setEffortOverride}
1181
+ disabled={!isElectron() || isCreating}
1182
+ />
1183
+ }
1184
+ leadingChrome={
1185
+ isElectron() ? (
1186
+ <ProjectPickerButton
1187
+ projects={projects}
1188
+ value={selectedProjectId}
1189
+ onChange={setSelectedProjectId}
1190
+ />
1191
+ ) : undefined
1192
+ }
1193
+ mode={mode}
1194
+ onModeChange={handleModeChange}
1195
+ execPolicy={execPolicy}
1196
+ onExecPolicyChange={handleExecPolicyChange}
1197
+ files={files}
1198
+ onFilesChange={setFiles}
1199
+ onMentionReferencesChange={setMentionReferences}
1200
+ />
1201
+ </div>
1202
+ {isCreating && (
1203
+ <p className="text-xs text-agent-muted-foreground">
1204
+ 正在创建对话…
1205
+ </p>
1206
+ )}
1207
+ {createError && (
1208
+ <p className="text-xs text-agent-destructive" role="alert">
1209
+ {createError}
1210
+ </p>
1211
+ )}
1212
+ {!isElectron() && (
1213
+ <p className="text-xs text-agent-destructive">
1214
+ 浏览器预览模式 — 没有 Electron IPC 桥接,聊天列表与流式响应不可用。
1215
+ </p>
1216
+ )}
1217
+ </div>
1218
+ {isElectron() && (
1219
+ <LocalLlmSettingsModal
1220
+ open={llmSettingsOpen}
1221
+ onClose={() => setLlmSettingsOpen(false)}
1222
+ />
1223
+ )}
1224
+ </div>
1225
+ );
1226
+ }