@brainpilot/web 0.0.5 → 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 (52) 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/package.json +5 -2
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  8. package/src/__tests__/demoConversation.test.ts +73 -0
  9. package/src/__tests__/demoReset.test.ts +24 -0
  10. package/src/__tests__/runningToast.test.ts +29 -0
  11. package/src/__tests__/tokenUsage.test.ts +48 -0
  12. package/src/__tests__/toolDisplay.test.ts +55 -0
  13. package/src/__tests__/traceReducer.test.ts +62 -0
  14. package/src/components/chat/MessageStream.tsx +97 -56
  15. package/src/components/chat/PromptComposer.tsx +120 -29
  16. package/src/components/chat/chatScrollMemory.ts +49 -0
  17. package/src/components/demo/DemoView.tsx +91 -29
  18. package/src/components/demo/TraceNodeModal.tsx +6 -2
  19. package/src/components/demo/demoBundle.ts +7 -2
  20. package/src/components/demo/demoReset.ts +16 -0
  21. package/src/components/session/AgentNetwork.tsx +68 -75
  22. package/src/components/session/AgentTraceViews.tsx +35 -70
  23. package/src/components/session/AnalyticsTab.tsx +58 -224
  24. package/src/components/session/TraceGraphView.tsx +36 -30
  25. package/src/components/session/TraceNodeDetail.tsx +61 -24
  26. package/src/components/session/agentNetworkShared.ts +10 -0
  27. package/src/components/session/traceLayout.ts +32 -0
  28. package/src/components/settings/SettingsDialog.tsx +19 -1
  29. package/src/components/shell/DesktopShell.tsx +39 -14
  30. package/src/components/sidebar/Sidebar.tsx +6 -2
  31. package/src/contexts/SSEContext.tsx +90 -1
  32. package/src/contexts/SessionContext.tsx +354 -43
  33. package/src/contexts/agentsReducer.ts +49 -0
  34. package/src/contexts/runningToast.ts +33 -0
  35. package/src/contexts/traceReducer.ts +62 -0
  36. package/src/contexts/turnTimer.test.ts +97 -0
  37. package/src/contexts/turnTimer.ts +108 -0
  38. package/src/contexts/useTurnTimer.ts +104 -0
  39. package/src/contracts/backend.ts +53 -2
  40. package/src/i18n/messages/analytics.ts +16 -6
  41. package/src/i18n/messages/chat.ts +26 -4
  42. package/src/i18n/messages/contexts.ts +2 -0
  43. package/src/i18n/messages/network.ts +13 -9
  44. package/src/i18n/messages/profile.ts +4 -0
  45. package/src/i18n/messages/settings.ts +4 -0
  46. package/src/i18n/messages/trace.ts +69 -17
  47. package/src/mocks/backend.ts +7 -0
  48. package/src/styles/global.css +204 -55
  49. package/src/utils/api.ts +105 -8
  50. package/src/utils/toolDisplay.ts +74 -0
  51. package/dist/assets/index-C-8G4D4j.js +0 -448
  52. package/dist/assets/index-C501m5OS.css +0 -1
@@ -1,5 +1,5 @@
1
1
  import { Check, ChevronDown, Copy } from "lucide-react";
2
- import { memo, useEffect, useMemo, useRef, useState } from "react";
2
+ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import type { ChatMessage } from "../../contracts/backend";
4
4
  import { buildRenderItems } from "../../contexts/messageGroups";
5
5
  import { useT } from "../../i18n/useT";
@@ -7,16 +7,31 @@ import { MarkdownMessage } from "./MarkdownMessage";
7
7
  import { SystemMessageBubble } from "./SystemMessageBubble";
8
8
  import { AskUserCard } from "./AskUserCard";
9
9
  import { AutoRetryIndicator } from "./AutoRetryIndicator";
10
+ import { formatToolName, formatPayload } from "../../utils/toolDisplay";
11
+ import { getChatScroll, setChatScroll, resolveScrollTop } from "./chatScrollMemory";
10
12
 
11
13
  interface MessageStreamProps {
12
14
  /** Already filtered / time-sliced by the host. */
13
15
  messages: ChatMessage[];
14
16
  /** Pin to bottom as new messages arrive (live chat). Default false. */
15
17
  autoScroll?: boolean;
18
+ /**
19
+ * #89 — session id used to remember scroll position/pinned intent across
20
+ * tab switches (Chat is unmounted when Agents/Trace is active). When set, the
21
+ * stream restores its prior position on mount instead of replaying a visible
22
+ * top-to-bottom scroll. Omit in read-only contexts (demo replay).
23
+ */
24
+ scrollKey?: string;
16
25
  /** Show the "N messages" toolbar row. Default true. */
17
26
  showToolbarCount?: boolean;
18
27
  /** Show per-agent elapsed timers + total conversation time. Live chat only. */
19
28
  showTiming?: boolean;
29
+ /**
30
+ * #99: whole-turn timing (user input → all agents finished). When provided,
31
+ * the footer shows this authoritative turn duration instead of a per-message
32
+ * span estimate. `running` drives a live ticking display.
33
+ */
34
+ turnTiming?: { running: boolean; elapsedMs: number | null; lastDurationMs: number | null };
20
35
  className?: string;
21
36
  ariaLabel?: string;
22
37
  /** 修正6 — submit an ask_user answer. Omitted in read-only contexts (demo). */
@@ -66,8 +81,10 @@ function formatElapsed(ms: number): string {
66
81
  function MessageStreamImpl({
67
82
  messages,
68
83
  autoScroll = false,
84
+ scrollKey,
69
85
  showToolbarCount = true,
70
86
  showTiming = false,
87
+ turnTiming,
71
88
  className,
72
89
  ariaLabel,
73
90
  onAskUserSubmit,
@@ -115,60 +132,56 @@ function MessageStreamImpl({
115
132
  return null;
116
133
  }, [messages]);
117
134
 
118
- // Per-message timing. start = createdAt; end is stamped the first time a
119
- // message is observed no longer streaming. A 1s tick drives live re-render
120
- // while anything is streaming so running timers advance.
121
- const timingRef = useRef<Map<string, { start: number; end: number | null }>>(new Map());
122
- const [, setNow] = useState(0);
135
+ // #99: per-message timer is shown ONLY on the live streaming message it is a
136
+ // live "this run has been going for Ns" indicator, never attached to a
137
+ // completed message or a user bubble (which previously drifted with wall-clock
138
+ // age). The authoritative whole-turn duration lives in the footer (turnTiming).
123
139
  const anyStreaming = liveStreamingId !== null;
124
-
125
- useEffect(() => {
126
- if (!showTiming) return;
127
- const map = timingRef.current;
128
- for (const m of messages) {
129
- if (m.role === "user" || m.kind === "hook") continue;
130
- const startMs = m.createdAt ? Date.parse(m.createdAt) : NaN;
131
- const existing = map.get(m.id);
132
- if (!existing) {
133
- map.set(m.id, { start: Number.isNaN(startMs) ? Date.now() : startMs, end: m.streaming ? null : Date.now() });
134
- } else if (existing.end === null && !m.streaming) {
135
- existing.end = Date.now();
136
- }
137
- }
138
- }, [messages, showTiming]);
139
-
140
+ const [, setNow] = useState(0);
140
141
  useEffect(() => {
141
142
  if (!showTiming || !anyStreaming) return;
142
143
  const id = window.setInterval(() => setNow((n) => n + 1), 1000);
143
144
  return () => window.clearInterval(id);
144
145
  }, [showTiming, anyStreaming]);
145
146
 
146
- // Total conversation time: span from the earliest tracked start to the
147
- // latest finish, shown only once the turn is idle and at least one message
148
- // has completed.
149
- const totalElapsed = useMemo(() => {
150
- if (!showTiming || anyStreaming) return null;
151
- let min = Infinity;
152
- let max = -Infinity;
153
- for (const m of messages) {
154
- const entry = timingRef.current.get(m.id);
155
- if (!entry || entry.end === null) continue;
156
- if (entry.start < min) min = entry.start;
157
- if (entry.end > max) max = entry.end;
158
- }
159
- if (min === Infinity || max <= min) return null;
160
- return max - min;
161
- // eslint-disable-next-line react-hooks/exhaustive-deps
162
- }, [messages, showTiming, anyStreaming]);
163
-
164
147
  const elapsedLabel = (message: ChatMessage): string | null => {
165
148
  if (!showTiming) return null;
166
- const entry = timingRef.current.get(message.id);
167
- if (!entry) return null;
168
- const end = entry.end ?? Date.now();
169
- return formatElapsed(end - entry.start);
149
+ // Only the currently-streaming message carries a live timer.
150
+ if (message.id !== liveStreamingId) return null;
151
+ const startMs = message.createdAt ? Date.parse(message.createdAt) : NaN;
152
+ if (Number.isNaN(startMs)) return null;
153
+ return formatElapsed(Date.now() - startMs);
170
154
  };
171
155
 
156
+ // #89 — restore scroll position on (re)mount BEFORE the browser paints, so
157
+ // returning to Chat from another tab lands at the right place with no visible
158
+ // top-to-bottom replay. Reads the per-session memory: pinned/fresh → bottom,
159
+ // otherwise the saved history position. A double rAF re-applies after async
160
+ // layout (Markdown, images) settles, in case scrollHeight grew post-mount.
161
+ useLayoutEffect(() => {
162
+ const node = stackRef.current;
163
+ if (!node) return;
164
+ const mem = getChatScroll(scrollKey);
165
+ isPinnedRef.current = mem ? mem.pinned : true;
166
+ const apply = () => {
167
+ const n = stackRef.current;
168
+ if (!n) return;
169
+ n.scrollTop = resolveScrollTop(mem, n.scrollHeight);
170
+ };
171
+ apply();
172
+ let raf2 = 0;
173
+ const raf1 = window.requestAnimationFrame(() => {
174
+ apply();
175
+ raf2 = window.requestAnimationFrame(apply);
176
+ });
177
+ return () => {
178
+ window.cancelAnimationFrame(raf1);
179
+ if (raf2) window.cancelAnimationFrame(raf2);
180
+ };
181
+ // Mount-only restore; live append is handled by the autoScroll effect below.
182
+ // eslint-disable-next-line react-hooks/exhaustive-deps
183
+ }, [scrollKey]);
184
+
172
185
  useEffect(() => {
173
186
  if (!autoScroll) {
174
187
  return;
@@ -187,6 +200,8 @@ function MessageStreamImpl({
187
200
  }
188
201
  const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
189
202
  isPinnedRef.current = distanceFromBottom < 24;
203
+ // #89 — persist intent so a tab switch (which unmounts Chat) can restore it.
204
+ setChatScroll(scrollKey, { scrollTop: node.scrollTop, pinned: isPinnedRef.current });
190
205
  };
191
206
 
192
207
  const handleCopy = async (id: string, text: string) => {
@@ -344,10 +359,19 @@ function MessageStreamImpl({
344
359
  const displayName = message.agent || (message.role === "system" ? "system" : "principal");
345
360
  const isLive = message.id === liveStreamingId;
346
361
  const timing = elapsedLabel(message);
347
- const content = message.kind === "error" ? (
348
- <p className="message-card__content--plain message-row__error">{message.content || (message.streaming ? t("chat.generating") : "")}</p>
349
- ) : (
350
- <MarkdownMessage content={message.content || (message.streaming ? t("chat.generating") : "")} />
362
+ const hasContent = !!message.content.trim();
363
+ const displayContent = hasContent ? message.content : (message.streaming ? t("chat.streamingPending") : "");
364
+ const content = (
365
+ <div className={`message-row__content ${message.streaming && !hasContent ? "message-row__content--pending" : ""}`}>
366
+ {message.kind === "error" ? (
367
+ <p className="message-card__content--plain message-row__error">{displayContent}</p>
368
+ ) : (
369
+ <MarkdownMessage content={displayContent} />
370
+ )}
371
+ {message.streaming && message.kind !== "error" ? (
372
+ <span className="message-row__streaming-cursor" aria-hidden="true" />
373
+ ) : null}
374
+ </div>
351
375
  );
352
376
  return (
353
377
  <div
@@ -377,15 +401,30 @@ function MessageStreamImpl({
377
401
  const renderActivityStep = (step: ChatMessage) => {
378
402
  const isExpert = !!step.agent && step.agent !== "principal";
379
403
  if (step.kind === "tool") {
404
+ // #84: render a friendly tool name (mcp__server__tool → server · tool) and
405
+ // un-escaped payloads. The raw name stays in `title` for debugging/copy.
406
+ const friendly = t("chat.toolPrefix", { name: formatToolName(step.toolName) });
407
+ const input = formatPayload(step.toolInput);
408
+ const result = formatPayload(step.toolResult);
380
409
  return (
381
410
  <div className="activity-step" key={step.id}>
382
411
  <details>
383
- <summary>
412
+ <summary title={step.toolName || undefined}>
384
413
  {isExpert ? <span className="message-card__agent-badge">{step.agent}</span> : null}
385
- {step.content || t("chat.toolPrefix", { name: step.toolName || "tool" })}
414
+ {friendly}
386
415
  </summary>
387
- {step.toolInput !== undefined && step.toolInput !== "" ? <pre>{JSON.stringify(step.toolInput, null, 2)}</pre> : null}
388
- {step.toolResult !== undefined ? <pre>{JSON.stringify(step.toolResult, null, 2)}</pre> : null}
416
+ {input ? (
417
+ <div className="activity-step__io">
418
+ <span className="activity-step__io-label">{t("chat.toolArgs")}</span>
419
+ <pre>{input}</pre>
420
+ </div>
421
+ ) : null}
422
+ {result ? (
423
+ <div className="activity-step__io">
424
+ <span className="activity-step__io-label">{t("chat.toolResult")}</span>
425
+ <pre>{result}</pre>
426
+ </div>
427
+ ) : null}
389
428
  </details>
390
429
  </div>
391
430
  );
@@ -400,7 +439,7 @@ function MessageStreamImpl({
400
439
  return (
401
440
  <div className="activity-step" key={step.id}>
402
441
  {isExpert ? <span className="message-card__agent-badge">{step.agent}</span> : null}
403
- <MarkdownMessage content={step.content || (step.streaming ? t("chat.generating") : "")} />
442
+ <MarkdownMessage content={step.content || (step.streaming ? t("chat.streamingPending") : "")} />
404
443
  </div>
405
444
  );
406
445
  };
@@ -410,7 +449,7 @@ function MessageStreamImpl({
410
449
  const activitySubtitle = (steps: ChatMessage[], streaming: boolean) => {
411
450
  if (!streaming) return t("chat.thinkingSteps", { count: steps.length });
412
451
  const last = steps[steps.length - 1];
413
- if (last?.kind === "tool") return t("chat.toolCall", { name: last.toolName || "tool" });
452
+ if (last?.kind === "tool") return t("chat.toolCall", { name: formatToolName(last.toolName) });
414
453
  const text = (last?.reasoning || last?.content || "").trim();
415
454
  if (text) return text.length > 80 ? `${text.slice(0, 80)}…` : text;
416
455
  return t("chat.thinking");
@@ -448,9 +487,11 @@ function MessageStreamImpl({
448
487
  </div>
449
488
  ),
450
489
  )}
451
- {totalElapsed !== null ? (
490
+ {showTiming && turnTiming && turnTiming.elapsedMs !== null ? (
452
491
  <div className="message-stack__total" role="status">
453
- {t("chat.totalTime", { time: formatElapsed(totalElapsed) })}
492
+ {t(turnTiming.running ? "chat.turnTimeRunning" : "chat.totalTime", {
493
+ time: formatElapsed(turnTiming.elapsedMs),
494
+ })}
454
495
  </div>
455
496
  ) : null}
456
497
  </div>
@@ -1,10 +1,12 @@
1
- import { Bot, Mic, Paperclip, Plus, Square } from "lucide-react";
1
+ import { Bot, Paperclip, Square, X } from "lucide-react";
2
2
  import { FormEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import type { ProviderProfile } from "../../contracts/backend";
4
4
  import { useSandbox } from "../../contexts/SandboxContext";
5
5
  import { DRAFT_SESSION_ID, useSessions } from "../../contexts/SessionContext";
6
+ import { useTurnTimer } from "../../contexts/useTurnTimer";
6
7
  import { draftStore } from "../../contexts/draftStore";
7
8
  import { applyMessageFilters } from "../../contexts/messageFilters";
9
+ import { runningToastLabel } from "../../contexts/runningToast";
8
10
  import { useT } from "../../i18n/useT";
9
11
  import { api } from "../../utils/api";
10
12
  import { CustomSelect } from "../primitives/CustomSelect";
@@ -21,15 +23,24 @@ export function PromptComposer() {
21
23
  // 可用命令(已通过真实 API 测试 /context ✅ /cost ✅;/compact 由 SDK 内置 ✅)
22
24
  // 不可用命令(已移除):/usage ❌ /clear ❌ /init ❌
23
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;
24
30
  const [slashCommands, setSlashCommands] = useState<string[]>(DEFAULT_SLASH_COMMANDS);
25
31
 
26
32
  const [showCommands, setShowCommands] = useState(false);
27
33
  const commandsRef = useRef<HTMLDivElement | null>(null);
28
34
  const menuRef = useRef<HTMLDivElement | null>(null);
29
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);
30
41
  const { status: sandboxStatus, currentSandbox, reloadConfig } = useSandbox();
31
42
  const [composerError, setComposerError] = useState<string | null>(null);
32
- const { currentSession, messages, isSending, error, sendPrompt, isConnected, isDraft, agents, agentFilters, interruptCurrent, respondToInput, messageFilters } = useSessions();
43
+ const { currentSession, messages, isSending, error, sendPrompt, isConnected, isDraft, agents, runActive, agentFilters, interruptCurrent, respondToInput, messageFilters } = useSessions();
33
44
  // In draft mode there's no session/connection yet — allow composing so the
34
45
  // first send can create + connect the session.
35
46
  const canSend = sandboxStatus === "running" && !isSending && (isConnected || isDraft);
@@ -65,6 +76,14 @@ export function PromptComposer() {
65
76
  [agents],
66
77
  );
67
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
+
68
87
  useEffect(() => {
69
88
  let cancelled = false;
70
89
  void api.ui.promptSuggestions().then((suggestions) => {
@@ -77,29 +96,10 @@ export function PromptComposer() {
77
96
  };
78
97
  }, []);
79
98
 
80
- useEffect(() => {
81
- let cancelled = false;
82
- if (!currentSession) {
83
- setSlashCommands(DEFAULT_SLASH_COMMANDS);
84
- return;
85
- }
86
- void api.sessions.commands(currentSession.id).then((res) => {
87
- if (!cancelled) {
88
- // Only override defaults when the backend actually returned commands
89
- if (res.commands.length > 0) {
90
- setSlashCommands(res.commands);
91
- }
92
- }
93
- }).catch(() => {
94
- if (!cancelled) {
95
- // Keep defaults on API failure so the button stays visible
96
- setSlashCommands(DEFAULT_SLASH_COMMANDS);
97
- }
98
- });
99
- return () => {
100
- cancelled = true;
101
- };
102
- }, [currentSession?.id]);
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
103
 
104
104
  useEffect(() => {
105
105
  const handleClickOutside = (event: MouseEvent) => {
@@ -212,6 +212,10 @@ export function PromptComposer() {
212
212
 
213
213
  const sessionId = currentSession?.id ?? (isDraft ? DRAFT_SESSION_ID : null);
214
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
+
215
219
  const handleSubmit = async (event: FormEvent) => {
216
220
  event.preventDefault();
217
221
  if (!sessionId) return;
@@ -220,12 +224,51 @@ export function PromptComposer() {
220
224
  return;
221
225
  }
222
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([]);
223
233
  // Carry the chosen provider/model so a freshly-created session records its
224
234
  // per-session selection (no-op for an already-running session).
225
- await sendPrompt(content, {
235
+ const ok = await sendPrompt(`${notice}${content}`, {
226
236
  providerId: activeProvider?.id,
227
237
  modelId: selectedModel || undefined,
228
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
+ }
229
272
  };
230
273
 
231
274
  // Writes to the draft store from non-text controls (slash command picks,
@@ -244,7 +287,9 @@ export function PromptComposer() {
244
287
  <MessageStream
245
288
  messages={visibleMessages}
246
289
  autoScroll
290
+ scrollKey={sessionId ?? undefined}
247
291
  showTiming
292
+ turnTiming={turnTiming}
248
293
  runningAgents={runningAgents}
249
294
  onAskUserSubmit={(requestId, answer) => void respondToInput(requestId, answer)}
250
295
  onRetryCancel={() => void interruptCurrent()}
@@ -254,7 +299,12 @@ export function PromptComposer() {
254
299
  {isAgentRunning || lastAssistantStreaming ? (
255
300
  <div className="agent-running-toast" role="status" aria-live="polite">
256
301
  <span className="agent-running-toast__dot" />
257
- <span className="agent-running-toast__label">{t("chat.agentThinking")}</span>
302
+ <span className="agent-running-toast__label">
303
+ {(() => {
304
+ const label = runningToastLabel(workingAgentNames);
305
+ return t(label.key, label.vars);
306
+ })()}
307
+ </span>
258
308
  <button
259
309
  className="agent-running-toast__stop"
260
310
  type="button"
@@ -275,12 +325,37 @@ export function PromptComposer() {
275
325
  ariaLabel={t("chat.srAsk")}
276
326
  />
277
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
+
278
348
  <div className="composer__toolbar">
279
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.
280
354
  <IconButton label={t("chat.aria.attachContext")}>
281
355
  <Plus size={18} />
282
356
  </IconButton>
283
- {slashCommands.length > 0 && (
357
+ */}
358
+ {SHOW_SLASH_COMMANDS && slashCommands.length > 0 && (
284
359
  <div className="command-picker" ref={commandsRef}>
285
360
  <IconButton
286
361
  label={t("chat.command")}
@@ -356,10 +431,26 @@ export function PromptComposer() {
356
431
  title={activeProvider ? t("chat.providerTitle", { name: activeProvider.name }) : t("chat.noActiveProvider")}
357
432
  value={selectedModel}
358
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.
359
438
  <IconButton label={t("chat.aria.voice")}>
360
439
  <Mic size={17} />
361
440
  </IconButton>
362
- <IconButton label={t("chat.aria.attachFile")}>
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
+ >
363
454
  <Paperclip size={17} />
364
455
  </IconButton>
365
456
  <ComposerSendButton
@@ -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
+ }