@brainpilot/web 0.0.4 → 0.0.6

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 (114) hide show
  1. package/dist/assets/index-Br55rkHb.css +1 -0
  2. package/dist/assets/index-CeUzk-ej.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +12 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/agentsReducer.test.ts +67 -0
  8. package/src/__tests__/api.test.ts +221 -0
  9. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  10. package/src/__tests__/demoConversation.test.ts +73 -0
  11. package/src/__tests__/demoReset.test.ts +24 -0
  12. package/src/__tests__/messageGroups.test.ts +80 -0
  13. package/src/__tests__/newUiComponents.test.tsx +101 -0
  14. package/src/__tests__/newUiEvents.test.ts +236 -0
  15. package/src/__tests__/runningToast.test.ts +29 -0
  16. package/src/__tests__/tokenUsage.test.ts +48 -0
  17. package/src/__tests__/toolDisplay.test.ts +55 -0
  18. package/src/__tests__/traceReducer.test.ts +62 -0
  19. package/src/components/chat/AskUserCard.tsx +123 -0
  20. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  21. package/src/components/chat/ComposerInput.tsx +73 -0
  22. package/src/components/chat/ComposerSendButton.tsx +26 -0
  23. package/src/components/chat/MarkdownMessage.tsx +24 -0
  24. package/src/components/chat/MessageStream.tsx +505 -0
  25. package/src/components/chat/PromptComposer.tsx +489 -0
  26. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  27. package/src/components/chat/chatScrollMemory.ts +49 -0
  28. package/src/components/demo/DemoFileTree.tsx +146 -0
  29. package/src/components/demo/DemoView.tsx +730 -0
  30. package/src/components/demo/TraceNodeModal.tsx +80 -0
  31. package/src/components/demo/demoBundle.ts +223 -0
  32. package/src/components/demo/demoCache.ts +42 -0
  33. package/src/components/demo/demoReset.ts +16 -0
  34. package/src/components/files/FilePreviewView.tsx +153 -0
  35. package/src/components/files/FileSidebar.tsx +664 -0
  36. package/src/components/files/filePreview.ts +113 -0
  37. package/src/components/primitives/CustomSelect.tsx +200 -0
  38. package/src/components/primitives/IconButton.tsx +27 -0
  39. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  40. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  41. package/src/components/quota/QuotaFileManager.tsx +197 -0
  42. package/src/components/search/SearchDialog.tsx +101 -0
  43. package/src/components/session/AgentNetwork.tsx +1233 -0
  44. package/src/components/session/AgentTraceViews.tsx +346 -0
  45. package/src/components/session/AnalyticsTab.tsx +220 -0
  46. package/src/components/session/GlobalOverview.tsx +108 -0
  47. package/src/components/session/NodeTooltip.tsx +127 -0
  48. package/src/components/session/TimelineTab.tsx +320 -0
  49. package/src/components/session/TraceGraphView.tsx +307 -0
  50. package/src/components/session/TraceNodeDetail.tsx +179 -0
  51. package/src/components/session/agentAnalytics.ts +397 -0
  52. package/src/components/session/agentNetworkShared.ts +339 -0
  53. package/src/components/session/traceLayout.ts +182 -0
  54. package/src/components/settings/SettingsDialog.tsx +737 -0
  55. package/src/components/shell/DesktopShell.tsx +261 -0
  56. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  57. package/src/components/shell/SandboxStatus.tsx +287 -0
  58. package/src/components/shell/TerminalDrawer.tsx +387 -0
  59. package/src/components/sidebar/Sidebar.tsx +191 -0
  60. package/src/config.ts +10 -0
  61. package/src/contexts/AppProviders.tsx +20 -0
  62. package/src/contexts/AuthContext.tsx +61 -0
  63. package/src/contexts/PreferencesContext.tsx +125 -0
  64. package/src/contexts/SSEContext.tsx +264 -0
  65. package/src/contexts/SandboxContext.tsx +310 -0
  66. package/src/contexts/SessionContext.tsx +919 -0
  67. package/src/contexts/agentsReducer.ts +49 -0
  68. package/src/contexts/draftStore.ts +103 -0
  69. package/src/contexts/messageFilters.ts +29 -0
  70. package/src/contexts/messageGroups.ts +77 -0
  71. package/src/contexts/messageReducer.ts +401 -0
  72. package/src/contexts/newUiEvents.ts +190 -0
  73. package/src/contexts/runningToast.ts +33 -0
  74. package/src/contexts/traceReducer.ts +62 -0
  75. package/src/contexts/turnTimer.test.ts +97 -0
  76. package/src/contexts/turnTimer.ts +108 -0
  77. package/src/contexts/useTurnTimer.ts +104 -0
  78. package/src/contracts/backend.ts +897 -0
  79. package/src/contracts/demoBundle.ts +83 -0
  80. package/src/i18n/messages/analytics.ts +106 -0
  81. package/src/i18n/messages/chat.ts +130 -0
  82. package/src/i18n/messages/contexts.ts +42 -0
  83. package/src/i18n/messages/demo.ts +80 -0
  84. package/src/i18n/messages/files.ts +82 -0
  85. package/src/i18n/messages/network.ts +190 -0
  86. package/src/i18n/messages/profile.ts +44 -0
  87. package/src/i18n/messages/quota.ts +36 -0
  88. package/src/i18n/messages/sandbox.ts +116 -0
  89. package/src/i18n/messages/search.ts +16 -0
  90. package/src/i18n/messages/settings.ts +188 -0
  91. package/src/i18n/messages/shell.ts +38 -0
  92. package/src/i18n/messages/sidebar.ts +52 -0
  93. package/src/i18n/messages/terminal.ts +22 -0
  94. package/src/i18n/messages/trace.ts +136 -0
  95. package/src/i18n/messages.ts +32 -0
  96. package/src/i18n/translate.ts +46 -0
  97. package/src/i18n/types.ts +15 -0
  98. package/src/i18n/useT.ts +15 -0
  99. package/src/main.tsx +13 -0
  100. package/src/mocks/backend.ts +729 -0
  101. package/src/styles/global.css +7578 -0
  102. package/src/styles/tokens.css +161 -0
  103. package/src/utils/api.ts +724 -0
  104. package/src/utils/download.ts +18 -0
  105. package/src/utils/format.ts +7 -0
  106. package/src/utils/toolDisplay.ts +74 -0
  107. package/src/utils/zip.ts +119 -0
  108. package/src/vite-env.d.ts +1 -0
  109. package/tsconfig.app.json +22 -0
  110. package/tsconfig.json +7 -0
  111. package/tsconfig.node.json +13 -0
  112. package/vite.config.ts +13 -0
  113. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  114. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,489 @@
1
+ import { Bot, Paperclip, Square, X } from "lucide-react";
2
+ import { FormEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import type { ProviderProfile } from "../../contracts/backend";
4
+ import { useSandbox } from "../../contexts/SandboxContext";
5
+ import { DRAFT_SESSION_ID, useSessions } from "../../contexts/SessionContext";
6
+ import { useTurnTimer } from "../../contexts/useTurnTimer";
7
+ import { draftStore } from "../../contexts/draftStore";
8
+ import { applyMessageFilters } from "../../contexts/messageFilters";
9
+ import { runningToastLabel } from "../../contexts/runningToast";
10
+ import { useT } from "../../i18n/useT";
11
+ import { api } from "../../utils/api";
12
+ import { CustomSelect } from "../primitives/CustomSelect";
13
+ import { IconButton } from "../primitives/IconButton";
14
+ import { ComposerInput } from "./ComposerInput";
15
+ import { ComposerSendButton } from "./ComposerSendButton";
16
+ import { MessageStream } from "./MessageStream";
17
+
18
+ export function PromptComposer() {
19
+ const t = useT();
20
+ const [suggestedTasks, setSuggestedTasks] = useState<string[]>([]);
21
+ const [activeProvider, setActiveProvider] = useState<ProviderProfile | null>(null);
22
+ const [selectedModel, setSelectedModel] = useState("");
23
+ // 可用命令(已通过真实 API 测试 /context ✅ /cost ✅;/compact 由 SDK 内置 ✅)
24
+ // 不可用命令(已移除):/usage ❌ /clear ❌ /init ❌
25
+ const DEFAULT_SLASH_COMMANDS = ["/compact", "/context", "/cost"];
26
+ // issue #43: temporarily hide the whole slash-command button until the
27
+ // dynamic command list (GET /sessions/:id/commands) is implemented backend
28
+ // side. Flip to true to restore. Code below is kept intact for that.
29
+ const SHOW_SLASH_COMMANDS = false;
30
+ const [slashCommands, setSlashCommands] = useState<string[]>(DEFAULT_SLASH_COMMANDS);
31
+
32
+ const [showCommands, setShowCommands] = useState(false);
33
+ const commandsRef = useRef<HTMLDivElement | null>(null);
34
+ const menuRef = useRef<HTMLDivElement | null>(null);
35
+ const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
36
+ // #47: file upload — names of files uploaded into the workspace this turn,
37
+ // shown as removable chips and announced to the agent on send.
38
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
39
+ const [attachments, setAttachments] = useState<string[]>([]);
40
+ const [uploading, setUploading] = useState(false);
41
+ const { status: sandboxStatus, currentSandbox, reloadConfig } = useSandbox();
42
+ const [composerError, setComposerError] = useState<string | null>(null);
43
+ const { currentSession, messages, isSending, error, sendPrompt, isConnected, isDraft, agents, runActive, agentFilters, interruptCurrent, respondToInput, messageFilters } = useSessions();
44
+ // In draft mode there's no session/connection yet — allow composing so the
45
+ // first send can create + connect the session.
46
+ const canSend = sandboxStatus === "running" && !isSending && (isConnected || isDraft);
47
+
48
+ const visibleMessages = useMemo(() => {
49
+ const agentFiltered = messages.filter((msg) => {
50
+ if (msg.role === "user") return true;
51
+ const agent = msg.agent || "principal";
52
+ const filters = agentFilters[agent];
53
+ if (!filters) return true;
54
+ // "隐藏消息" 只隐藏普通消息,不碰 tool / hook
55
+ if (filters.hideMessages && msg.kind !== "tool" && msg.kind !== "hook") return false;
56
+ // "隐藏工具调用" 只隐藏 tool
57
+ if (filters.hideTools && msg.kind === "tool") return false;
58
+ // "隐藏 Hooks" 只隐藏 hook
59
+ if (filters.hideHooks && msg.kind === "hook") return false;
60
+ return true;
61
+ });
62
+ return applyMessageFilters(agentFiltered, messageFilters);
63
+ }, [messages, agentFilters, messageFilters]);
64
+
65
+ const hasMessages = visibleMessages.length > 0;
66
+ const isAgentRunning = agents.some((a) => a.status === "running");
67
+ const lastAssistantStreaming = visibleMessages[visibleMessages.length - 1]?.role === "assistant" && visibleMessages[visibleMessages.length - 1]?.streaming;
68
+
69
+ // Agents whose run is still active. Threaded to MessageStream so a folded
70
+ // activity block stays "in progress" across ReAct rounds — without this, the
71
+ // per-message streaming flags all clear between rounds and the block flashes
72
+ // "完成思考" in the gap. Memoized so its identity is stable for MessageStream's
73
+ // memo() (a fresh Set each render would defeat the memoization).
74
+ const runningAgents = useMemo(
75
+ () => new Set(agents.filter((a) => a.status === "running").map((a) => a.name)),
76
+ [agents],
77
+ );
78
+
79
+ // Names of agents actively working, for the "X 正在工作" toast. Excludes the
80
+ // trace agent (it self-records continuously and isn't "the user's task"),
81
+ // matching the runtime's run-active aggregation (#76).
82
+ const workingAgentNames = useMemo(
83
+ () => agents.filter((a) => a.status === "running" && a.name !== "trace").map((a) => a.name),
84
+ [agents],
85
+ );
86
+
87
+ useEffect(() => {
88
+ let cancelled = false;
89
+ void api.ui.promptSuggestions().then((suggestions) => {
90
+ if (!cancelled) {
91
+ setSuggestedTasks(suggestions);
92
+ }
93
+ });
94
+ return () => {
95
+ cancelled = true;
96
+ };
97
+ }, []);
98
+
99
+ // issue #43: the dynamic slash-command list (GET /sessions/:id/commands) is
100
+ // not implemented on the backend yet — fetching it 404'd on every selected
101
+ // session. The whole slash-command button is hidden below until that lands,
102
+ // so we no longer fetch and just keep the local DEFAULT_SLASH_COMMANDS.
103
+
104
+ useEffect(() => {
105
+ const handleClickOutside = (event: MouseEvent) => {
106
+ if (commandsRef.current && !commandsRef.current.contains(event.target as Node)) {
107
+ setShowCommands(false);
108
+ }
109
+ };
110
+ document.addEventListener("mousedown", handleClickOutside);
111
+ return () => document.removeEventListener("mousedown", handleClickOutside);
112
+ }, []);
113
+
114
+ useLayoutEffect(() => {
115
+ if (!showCommands || !commandsRef.current || !menuRef.current) return;
116
+ const buttonRect = commandsRef.current.getBoundingClientRect();
117
+ const menuRect = menuRef.current.getBoundingClientRect();
118
+ setMenuPos({
119
+ top: buttonRect.top - menuRect.height - 6,
120
+ left: buttonRect.left,
121
+ });
122
+ }, [showCommands]);
123
+
124
+ useEffect(() => {
125
+ if (!showCommands) return;
126
+ const handleClose = () => setShowCommands(false);
127
+ window.addEventListener("resize", handleClose);
128
+ return () => {
129
+ window.removeEventListener("resize", handleClose);
130
+ };
131
+ }, [showCommands]);
132
+
133
+ // Textarea autoresize, key handling, and draft state moved to ComposerInput,
134
+ // which owns the textarea ref and subscribes to draftStore directly.
135
+ // PromptComposer no longer re-renders on keystrokes — that's the whole point
136
+ // of the split.
137
+
138
+ useEffect(() => {
139
+ let cancelled = false;
140
+ const loadProviderAndSettings = async () => {
141
+ try {
142
+ const [providerRes, settings, healthProfiles] = await Promise.all([
143
+ api.providers.getActive(),
144
+ api.settings.get(),
145
+ api.providers.health().catch(() => [] as ProviderProfile[]),
146
+ ]);
147
+ if (cancelled) {
148
+ return;
149
+ }
150
+ let provider = providerRes;
151
+ if (provider && healthProfiles.length > 0) {
152
+ const activeId = provider.id;
153
+ const hp = healthProfiles.find((p) => p.id === activeId);
154
+ if (hp) {
155
+ provider = { ...provider, healthStatus: hp.healthStatus, healthCheckedAt: hp.healthCheckedAt, modelHealth: hp.modelHealth };
156
+ }
157
+ }
158
+ setActiveProvider(provider);
159
+ setSelectedModel((current) => {
160
+ if (current && provider?.models.includes(current)) {
161
+ return current;
162
+ }
163
+ if (settings.model && provider?.models.includes(settings.model)) {
164
+ return settings.model;
165
+ }
166
+ return provider?.models[0] ?? "";
167
+ });
168
+ } catch {
169
+ if (!cancelled) {
170
+ setActiveProvider(null);
171
+ setSelectedModel("");
172
+ }
173
+ }
174
+ };
175
+ void loadProviderAndSettings();
176
+ window.addEventListener("provider-profiles-updated", loadProviderAndSettings);
177
+ return () => {
178
+ cancelled = true;
179
+ window.removeEventListener("provider-profiles-updated", loadProviderAndSettings);
180
+ };
181
+ }, []);
182
+
183
+ useEffect(() => {
184
+ const refreshProvider = async () => {
185
+ try {
186
+ const [providerRes, healthProfiles] = await Promise.all([
187
+ api.providers.getActive(),
188
+ api.providers.health().catch(() => [] as ProviderProfile[]),
189
+ ]);
190
+ let provider = providerRes;
191
+ if (provider && healthProfiles.length > 0) {
192
+ const providerId = provider.id;
193
+ const hp = healthProfiles.find((p) => p.id === providerId);
194
+ if (hp) {
195
+ provider = { ...provider, healthStatus: hp.healthStatus, healthCheckedAt: hp.healthCheckedAt, modelHealth: hp.modelHealth };
196
+ }
197
+ }
198
+ setActiveProvider(provider);
199
+ setSelectedModel((current) => {
200
+ if (current && provider?.models.includes(current)) {
201
+ return current;
202
+ }
203
+ return provider?.models[0] ?? "";
204
+ });
205
+ } catch {
206
+ // ignore silent refresh errors
207
+ }
208
+ };
209
+ const id = window.setInterval(() => void refreshProvider(), 30000);
210
+ return () => window.clearInterval(id);
211
+ }, []);
212
+
213
+ const sessionId = currentSession?.id ?? (isDraft ? DRAFT_SESSION_ID : null);
214
+
215
+ // #99: whole-turn timer — spans user input → every agent finished (runState
216
+ // settles false), debounced against hook/system re-wakes.
217
+ const turnTiming = useTurnTimer({ runActive, resetKey: currentSession?.id ?? null });
218
+
219
+ const handleSubmit = async (event: FormEvent) => {
220
+ event.preventDefault();
221
+ if (!sessionId) return;
222
+ const content = draftStore.get(sessionId).trim();
223
+ if (!content || !canSend) {
224
+ return;
225
+ }
226
+ draftStore.set(sessionId, "");
227
+ // #47: if files were uploaded this turn, prepend a notice so the agent knows
228
+ // they exist in its workspace and can `read` them. Cleared after send.
229
+ const notice =
230
+ attachments.length > 0 ? `${t("chat.upload.notice", { names: attachments.join(", ") })}\n\n` : "";
231
+ const sentAttachments = attachments;
232
+ if (attachments.length > 0) setAttachments([]);
233
+ // Carry the chosen provider/model so a freshly-created session records its
234
+ // per-session selection (no-op for an already-running session).
235
+ const ok = await sendPrompt(`${notice}${content}`, {
236
+ providerId: activeProvider?.id,
237
+ modelId: selectedModel || undefined,
238
+ });
239
+ // #106: a failed/timed-out send must not silently eat the user's input.
240
+ // Restore the draft (and attachment chips) so they can retry without
241
+ // retyping. Only restore if they haven't already started typing again.
242
+ if (!ok) {
243
+ if (draftStore.get(sessionId).trim().length === 0) {
244
+ draftStore.set(sessionId, content);
245
+ }
246
+ if (sentAttachments.length > 0) {
247
+ setAttachments((prev) => (prev.length === 0 ? sentAttachments : prev));
248
+ }
249
+ }
250
+ };
251
+
252
+ // #47: upload the chosen files into the session workspace, then track their
253
+ // names as chips. Uses the current sandbox/session id (single-user: same id).
254
+ const handleFilesChosen = async (files: FileList | null) => {
255
+ if (!files || files.length === 0) return;
256
+ const sandboxId = currentSandbox?.id;
257
+ if (!sandboxId) return;
258
+ setUploading(true);
259
+ setComposerError(null);
260
+ try {
261
+ for (const file of Array.from(files)) {
262
+ await api.sandbox.uploadFile(sandboxId, file.name, file);
263
+ setAttachments((prev) => (prev.includes(file.name) ? prev : [...prev, file.name]));
264
+ }
265
+ } catch (e) {
266
+ const msg = e instanceof Error ? e.message : String(e);
267
+ setComposerError(t("chat.upload.failed", { msg }));
268
+ } finally {
269
+ setUploading(false);
270
+ if (fileInputRef.current) fileInputRef.current.value = ""; // allow re-selecting the same file
271
+ }
272
+ };
273
+
274
+ // Writes to the draft store from non-text controls (slash command picks,
275
+ // suggestion cards). PromptComposer never reads the draft, so these don't
276
+ // pull it onto the keystroke render path.
277
+ const setDraftFor = (value: string) => {
278
+ if (sessionId) draftStore.set(sessionId, value);
279
+ };
280
+
281
+ return (
282
+ <section className={`prompt-home ${hasMessages ? "prompt-home--active" : ""}`} aria-labelledby="prompt-heading">
283
+ <div className="prompt-home__inner">
284
+ {hasMessages ? null : <h1 id="prompt-heading">{currentSession?.title ?? t("chat.heading")}</h1>}
285
+
286
+ {hasMessages ? (
287
+ <MessageStream
288
+ messages={visibleMessages}
289
+ autoScroll
290
+ scrollKey={sessionId ?? undefined}
291
+ showTiming
292
+ turnTiming={turnTiming}
293
+ runningAgents={runningAgents}
294
+ onAskUserSubmit={(requestId, answer) => void respondToInput(requestId, answer)}
295
+ onRetryCancel={() => void interruptCurrent()}
296
+ />
297
+ ) : null}
298
+
299
+ {isAgentRunning || lastAssistantStreaming ? (
300
+ <div className="agent-running-toast" role="status" aria-live="polite">
301
+ <span className="agent-running-toast__dot" />
302
+ <span className="agent-running-toast__label">
303
+ {(() => {
304
+ const label = runningToastLabel(workingAgentNames);
305
+ return t(label.key, label.vars);
306
+ })()}
307
+ </span>
308
+ <button
309
+ className="agent-running-toast__stop"
310
+ type="button"
311
+ onClick={() => void interruptCurrent()}
312
+ aria-label={t("chat.aria.stop")}
313
+ title={t("chat.aria.stop")}
314
+ >
315
+ <Square size={10} fill="currentColor" />
316
+ <span>{t("chat.stop")}</span>
317
+ </button>
318
+ </div>
319
+ ) : null}
320
+
321
+ <form className="composer" aria-label={t("chat.aria.newPrompt")} onSubmit={handleSubmit}>
322
+ <ComposerInput
323
+ sessionId={sessionId}
324
+ placeholder={t("chat.placeholder")}
325
+ ariaLabel={t("chat.srAsk")}
326
+ />
327
+
328
+ {attachments.length > 0 || uploading ? (
329
+ <div className="composer__attachments" aria-label={t("chat.aria.attachFile")}>
330
+ {attachments.map((name) => (
331
+ <span className="composer__chip" key={name}>
332
+ <Paperclip size={12} />
333
+ <span className="composer__chip-name">{name}</span>
334
+ <button
335
+ type="button"
336
+ className="composer__chip-remove"
337
+ aria-label={t("chat.aria.removeAttachment")}
338
+ onClick={() => setAttachments((prev) => prev.filter((n) => n !== name))}
339
+ >
340
+ <X size={12} />
341
+ </button>
342
+ </span>
343
+ ))}
344
+ {uploading ? <span className="composer__chip composer__chip--pending">{t("chat.upload.uploading")}</span> : null}
345
+ </div>
346
+ ) : null}
347
+
348
+ <div className="composer__toolbar">
349
+ <div className="composer__tools">
350
+ {/*
351
+ issue #47: 添加上下文 (Plus) has no picker yet — hidden until the
352
+ context-attachment flow exists. The chat.aria.attachContext i18n
353
+ key is kept. Re-add the Plus lucide import when restoring this.
354
+ <IconButton label={t("chat.aria.attachContext")}>
355
+ <Plus size={18} />
356
+ </IconButton>
357
+ */}
358
+ {SHOW_SLASH_COMMANDS && slashCommands.length > 0 && (
359
+ <div className="command-picker" ref={commandsRef}>
360
+ <IconButton
361
+ label={t("chat.command")}
362
+ onClick={() => setShowCommands((s) => !s)}
363
+ className={`command-trigger ${showCommands ? "is-active" : ""}`}
364
+ >
365
+ <span>{t("chat.command")}</span>
366
+ </IconButton>
367
+ {showCommands && (
368
+ <div
369
+ className="command-picker__menu"
370
+ ref={menuRef}
371
+ style={menuPos ? { top: menuPos.top, left: menuPos.left } : { top: -9999, left: -9999 }}
372
+ >
373
+ {slashCommands.map((cmd) => (
374
+ <button
375
+ key={cmd}
376
+ className="command-picker__option"
377
+ type="button"
378
+ onClick={() => {
379
+ setDraftFor(cmd);
380
+ setShowCommands(false);
381
+ }}
382
+ >
383
+ {cmd}
384
+ </button>
385
+ ))}
386
+ </div>
387
+ )}
388
+ </div>
389
+ )}
390
+ </div>
391
+
392
+ <div className="composer__send-tools">
393
+ <CustomSelect
394
+ ariaLabel={t("chat.modelPlaceholder")}
395
+ className="model-select"
396
+ disabled={!currentSandbox || !activeProvider || activeProvider.models.length === 0}
397
+ onChange={async (model) => {
398
+ setSelectedModel(model);
399
+ setComposerError(null);
400
+ try {
401
+ await api.settings.update({ model });
402
+ } catch (e) {
403
+ const msg = e instanceof Error ? e.message : String(e);
404
+ console.error("Failed to save model selection", e);
405
+ setComposerError(t("chat.error.saveModel", { msg }));
406
+ return;
407
+ }
408
+ try {
409
+ await reloadConfig();
410
+ } catch (e) {
411
+ const msg = e instanceof Error ? e.message : String(e);
412
+ console.error("Failed to reload config after model change", e);
413
+ setComposerError(t("chat.error.reloadConfig", { msg }));
414
+ }
415
+ }}
416
+ options={activeProvider?.models.map((model) => {
417
+ const mh = activeProvider.modelHealth?.find((m) => m.model === model);
418
+ const status = mh?.status ?? "unknown";
419
+ return {
420
+ value: model,
421
+ label: model,
422
+ indicator: (
423
+ <span
424
+ className={`model-status-dot model-status-dot--${status}`}
425
+ title={mh?.error ?? status}
426
+ />
427
+ ),
428
+ };
429
+ }) ?? []}
430
+ placeholder={t("chat.modelPlaceholder")}
431
+ title={activeProvider ? t("chat.providerTitle", { name: activeProvider.name }) : t("chat.noActiveProvider")}
432
+ value={selectedModel}
433
+ />
434
+ {/*
435
+ issue #47: 语音输入 (Mic) has no capture/permission flow yet —
436
+ hidden until implemented. The chat.aria.voice i18n key is kept.
437
+ Re-add the Mic lucide import when restoring this.
438
+ <IconButton label={t("chat.aria.voice")}>
439
+ <Mic size={17} />
440
+ </IconButton>
441
+ */}
442
+ <input
443
+ ref={fileInputRef}
444
+ type="file"
445
+ multiple
446
+ style={{ display: "none" }}
447
+ onChange={(e) => void handleFilesChosen(e.target.files)}
448
+ />
449
+ <IconButton
450
+ label={t("chat.aria.attachFile")}
451
+ onClick={() => fileInputRef.current?.click()}
452
+ disabled={uploading || !currentSandbox}
453
+ >
454
+ <Paperclip size={17} />
455
+ </IconButton>
456
+ <ComposerSendButton
457
+ sessionId={sessionId}
458
+ canSend={canSend}
459
+ label={t("chat.aria.send")}
460
+ />
461
+ </div>
462
+ </div>
463
+
464
+ </form>
465
+
466
+ {error ? <p className="composer-status composer-status--error">{error}</p> : null}
467
+ {composerError ? <p className="composer-status composer-status--error">{composerError}</p> : null}
468
+ {!canSend ? (
469
+ <p className="composer-status">
470
+ {sandboxStatus !== "running"
471
+ ? t("chat.status.startSandbox")
472
+ : isConnected
473
+ ? t("chat.status.preparing")
474
+ : t("chat.status.connecting")}
475
+ </p>
476
+ ) : null}
477
+
478
+ {!hasMessages && suggestedTasks.length > 0 ? <div className="suggestions" aria-label={t("chat.aria.suggested")}>
479
+ {suggestedTasks.map((task) => (
480
+ <button className="suggestion-row" key={task} onClick={() => setDraftFor(task)} type="button">
481
+ <Bot size={15} />
482
+ <span>{task}</span>
483
+ </button>
484
+ ))}
485
+ </div> : null}
486
+ </div>
487
+ </section>
488
+ );
489
+ }
@@ -0,0 +1,46 @@
1
+ import { AlertTriangle, Info, OctagonAlert, XCircle } from "lucide-react";
2
+ import type { SystemMessageView } from "../../contracts/backend";
3
+ import { useT } from "../../i18n/useT";
4
+
5
+ /**
6
+ * 修正6 — system_message bubble. Renders a `system_message` event (level
7
+ * info|warning|error|fatal) as a 4-level styled inline bubble in the
8
+ * conversation stream. `fatal` gets the emphasized red treatment; `details`
9
+ * (debug) is revealed on expand.
10
+ */
11
+ export function SystemMessageBubble({ view }: { view: SystemMessageView }) {
12
+ const t = useT();
13
+ const level = view.level;
14
+ const Icon =
15
+ level === "fatal" ? OctagonAlert :
16
+ level === "error" ? XCircle :
17
+ level === "warning" ? AlertTriangle :
18
+ Info;
19
+ const labelKey =
20
+ level === "fatal" ? "chat.system.fatal" :
21
+ level === "error" ? "chat.system.error" :
22
+ level === "warning" ? "chat.system.warning" :
23
+ "chat.system.info";
24
+
25
+ return (
26
+ <div
27
+ className={`system-message system-message--${level}${level === "fatal" ? " system-message--emphasis" : ""}`}
28
+ role={level === "error" || level === "fatal" ? "alert" : "status"}
29
+ data-testid="system-message"
30
+ data-level={level}
31
+ >
32
+ <div className="system-message__head">
33
+ <Icon size={15} className="system-message__icon" aria-hidden="true" />
34
+ <span className="system-message__label">{t(labelKey)}</span>
35
+ {view.agent ? <span className="system-message__agent">{view.agent}</span> : null}
36
+ </div>
37
+ <p className="system-message__text">{view.message}</p>
38
+ {view.details ? (
39
+ <details className="system-message__details">
40
+ <summary>{t("chat.system.details")}</summary>
41
+ <pre>{view.details}</pre>
42
+ </details>
43
+ ) : null}
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Per-session chat scroll memory (#89).
3
+ *
4
+ * Switching workspace tabs (Chat ↔ Agents ↔ Trace) unmounts/remounts the Chat
5
+ * subtree in DesktopShell, so MessageStream loses its scroll position and its
6
+ * "is the user pinned to the bottom" intent. This module-level store survives
7
+ * those remounts, keyed by session id, so returning to Chat can restore where
8
+ * the user was — at the bottom following live output, or up in the history they
9
+ * were reading — without a visible top-to-bottom replay.
10
+ *
11
+ * Module-level (not React state) on purpose: it must outlive the component that
12
+ * reads it, and it is deliberately ephemeral (lost on full page reload, which
13
+ * is the right default — a reload starts a fresh view).
14
+ */
15
+
16
+ export interface ChatScrollState {
17
+ /** Last observed scrollTop of the message stack. */
18
+ scrollTop: number;
19
+ /** Whether the user was pinned to (near) the bottom. */
20
+ pinned: boolean;
21
+ }
22
+
23
+ const store = new Map<string, ChatScrollState>();
24
+
25
+ export function getChatScroll(key: string | undefined): ChatScrollState | undefined {
26
+ if (!key) return undefined;
27
+ return store.get(key);
28
+ }
29
+
30
+ export function setChatScroll(key: string | undefined, state: ChatScrollState): void {
31
+ if (!key) return;
32
+ store.set(key, state);
33
+ }
34
+
35
+ /**
36
+ * Resolve the scrollTop to apply on (re)mount.
37
+ *
38
+ * - no memory yet, or the user was pinned → bottom (scrollHeight); this is the
39
+ * default for a freshly-opened conversation and for "following live output".
40
+ * - the user had scrolled up to read history → restore that exact position,
41
+ * clamped to the current scrollHeight in case content shrank.
42
+ */
43
+ export function resolveScrollTop(
44
+ mem: ChatScrollState | undefined,
45
+ scrollHeight: number,
46
+ ): number {
47
+ if (!mem || mem.pinned) return scrollHeight;
48
+ return Math.min(mem.scrollTop, scrollHeight);
49
+ }