@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.
- package/dist/assets/index-Br55rkHb.css +1 -0
- package/dist/assets/index-CeUzk-ej.js +445 -0
- package/dist/index.html +2 -2
- package/package.json +5 -2
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +118 -0
- package/src/__tests__/chatScrollMemory.test.ts +49 -0
- package/src/__tests__/demoConversation.test.ts +73 -0
- package/src/__tests__/demoReset.test.ts +24 -0
- package/src/__tests__/runningToast.test.ts +29 -0
- package/src/__tests__/tokenUsage.test.ts +48 -0
- package/src/__tests__/toolDisplay.test.ts +55 -0
- package/src/__tests__/traceReducer.test.ts +62 -0
- package/src/components/chat/MessageStream.tsx +97 -56
- package/src/components/chat/PromptComposer.tsx +120 -29
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoView.tsx +91 -29
- package/src/components/demo/TraceNodeModal.tsx +6 -2
- package/src/components/demo/demoBundle.ts +7 -2
- package/src/components/demo/demoReset.ts +16 -0
- package/src/components/session/AgentNetwork.tsx +68 -75
- package/src/components/session/AgentTraceViews.tsx +35 -70
- package/src/components/session/AnalyticsTab.tsx +58 -224
- package/src/components/session/TraceGraphView.tsx +36 -30
- package/src/components/session/TraceNodeDetail.tsx +61 -24
- package/src/components/session/agentNetworkShared.ts +10 -0
- package/src/components/session/traceLayout.ts +32 -0
- package/src/components/settings/SettingsDialog.tsx +19 -1
- package/src/components/shell/DesktopShell.tsx +39 -14
- package/src/components/sidebar/Sidebar.tsx +6 -2
- package/src/contexts/SSEContext.tsx +90 -1
- package/src/contexts/SessionContext.tsx +354 -43
- package/src/contexts/agentsReducer.ts +49 -0
- package/src/contexts/runningToast.ts +33 -0
- package/src/contexts/traceReducer.ts +62 -0
- package/src/contexts/turnTimer.test.ts +97 -0
- package/src/contexts/turnTimer.ts +108 -0
- package/src/contexts/useTurnTimer.ts +104 -0
- package/src/contracts/backend.ts +53 -2
- package/src/i18n/messages/analytics.ts +16 -6
- package/src/i18n/messages/chat.ts +26 -4
- package/src/i18n/messages/contexts.ts +2 -0
- package/src/i18n/messages/network.ts +13 -9
- package/src/i18n/messages/profile.ts +4 -0
- package/src/i18n/messages/settings.ts +4 -0
- package/src/i18n/messages/trace.ts +69 -17
- package/src/mocks/backend.ts +7 -0
- package/src/styles/global.css +204 -55
- package/src/utils/api.ts +105 -8
- package/src/utils/toolDisplay.ts +74 -0
- package/dist/assets/index-C-8G4D4j.js +0 -448
- 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
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
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
|
-
|
|
167
|
-
if (
|
|
168
|
-
const
|
|
169
|
-
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
<
|
|
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
|
-
{
|
|
414
|
+
{friendly}
|
|
386
415
|
</summary>
|
|
387
|
-
{
|
|
388
|
-
|
|
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.
|
|
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
|
|
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
|
-
{
|
|
490
|
+
{showTiming && turnTiming && turnTiming.elapsedMs !== null ? (
|
|
452
491
|
<div className="message-stack__total" role="status">
|
|
453
|
-
{t("chat.totalTime", {
|
|
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,
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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">
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|