@brainpilot/web 0.0.5 → 0.0.7
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-DWOsU22G.css +1 -0
- package/dist/assets/index-j3rGyO6m.js +445 -0
- package/dist/index.html +2 -2
- package/package.json +6 -3
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +118 -0
- package/src/__tests__/chatScrollBehavior.test.ts +48 -0
- package/src/__tests__/chatScrollMemory.test.ts +49 -0
- package/src/__tests__/demoConversation.test.ts +96 -0
- package/src/__tests__/demoReset.test.ts +24 -0
- package/src/__tests__/internalToolStrip.test.ts +108 -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 +104 -56
- package/src/components/chat/PromptComposer.tsx +120 -29
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoView.tsx +98 -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 +72 -17
- package/src/components/sidebar/SessionList.tsx +127 -0
- package/src/components/sidebar/Sidebar.tsx +94 -98
- package/src/contexts/SSEContext.tsx +90 -1
- package/src/contexts/SessionContext.tsx +397 -43
- package/src/contexts/agentsReducer.ts +49 -0
- package/src/contexts/messageGroups.ts +56 -0
- package/src/contexts/messageReducer.ts +4 -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/shell.ts +2 -0
- package/src/i18n/messages/trace.ts +69 -17
- package/src/mocks/backend.ts +7 -0
- package/src/styles/global.css +289 -70
- 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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatToolName, formatPayload } from "../utils/toolDisplay";
|
|
3
|
+
|
|
4
|
+
describe("formatToolName (#84)", () => {
|
|
5
|
+
it("maps mcp__server__tool to 'server · tool'", () => {
|
|
6
|
+
expect(formatToolName("mcp__bp_skills__skills_tool")).toBe("bp_skills · skills_tool");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("keeps server/tool segments that contain underscores", () => {
|
|
10
|
+
expect(formatToolName("mcp__my_server__do_a_thing")).toBe("my_server · do_a_thing");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns non-MCP names unchanged", () => {
|
|
14
|
+
expect(formatToolName("read")).toBe("read");
|
|
15
|
+
expect(formatToolName("send_message")).toBe("send_message");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("falls back gracefully for missing/empty names", () => {
|
|
19
|
+
expect(formatToolName(undefined)).toBe("tool");
|
|
20
|
+
expect(formatToolName(null)).toBe("tool");
|
|
21
|
+
expect(formatToolName("")).toBe("tool");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("does not crash on a malformed mcp__ prefix with no tool segment", () => {
|
|
25
|
+
// No second `__` — show the remainder rather than the raw identifier.
|
|
26
|
+
expect(formatToolName("mcp__justserver")).toBe("justserver");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("formatPayload (#84)", () => {
|
|
31
|
+
it("parses a JSON string so it is not double-escaped", () => {
|
|
32
|
+
const raw = JSON.stringify({ path: "a/b.txt", count: 2 });
|
|
33
|
+
const out = formatPayload(raw);
|
|
34
|
+
expect(out).toBe('{\n "path": "a/b.txt",\n "count": 2\n}');
|
|
35
|
+
expect(out).not.toContain('\\"');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("pretty-prints a plain object", () => {
|
|
39
|
+
expect(formatPayload({ a: 1 })).toBe('{\n "a": 1\n}');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns non-JSON strings verbatim", () => {
|
|
43
|
+
expect(formatPayload("just some text")).toBe("just some text");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns a partial/invalid JSON string verbatim", () => {
|
|
47
|
+
expect(formatPayload('{"path": "a/b')).toBe('{"path": "a/b');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns empty string for null/undefined/blank", () => {
|
|
51
|
+
expect(formatPayload(undefined)).toBe("");
|
|
52
|
+
expect(formatPayload(null)).toBe("");
|
|
53
|
+
expect(formatPayload(" ")).toBe("");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { reduceTraceForEvent } from "../contexts/traceReducer";
|
|
3
|
+
import type { TraceGraph, WebSocketEvent } from "../contracts/backend";
|
|
4
|
+
|
|
5
|
+
// #79: trace nodes arrive live as CUSTOM { name:"trace_node", value:{ op, node } }.
|
|
6
|
+
|
|
7
|
+
const node = (id: string, extra: Record<string, unknown> = {}) => ({
|
|
8
|
+
id,
|
|
9
|
+
title: `node ${id}`,
|
|
10
|
+
type: "task",
|
|
11
|
+
status: "completed",
|
|
12
|
+
parents: [],
|
|
13
|
+
parentIds: [],
|
|
14
|
+
childIds: [],
|
|
15
|
+
artifacts: [],
|
|
16
|
+
toolCalls: [],
|
|
17
|
+
...extra,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const traceEv = (op: string, n: Record<string, unknown>): WebSocketEvent =>
|
|
21
|
+
({ type: "CUSTOM", name: "trace_node", value: { op, node: n } } as unknown as WebSocketEvent);
|
|
22
|
+
|
|
23
|
+
describe("reduceTraceForEvent (#79)", () => {
|
|
24
|
+
it("seeds a graph from null on the first node", () => {
|
|
25
|
+
const out = reduceTraceForEvent(null, traceEv("created", node("a")), "s");
|
|
26
|
+
expect(out).not.toBeNull();
|
|
27
|
+
expect(out!.nodes.map((n) => n.id)).toEqual(["a"]);
|
|
28
|
+
expect(out!.meta.sessionId).toBe("s");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("appends a new node id", () => {
|
|
32
|
+
const start: TraceGraph = { meta: { sessionId: "s" }, nodes: [node("a")] };
|
|
33
|
+
const out = reduceTraceForEvent(start, traceEv("created", node("b")), "s");
|
|
34
|
+
expect(out!.nodes.map((n) => n.id)).toEqual(["a", "b"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("replaces an existing node in place on update", () => {
|
|
38
|
+
const start: TraceGraph = { meta: { sessionId: "s" }, nodes: [node("a", { status: "running" })] };
|
|
39
|
+
const out = reduceTraceForEvent(start, traceEv("updated", node("a", { status: "completed" })), "s");
|
|
40
|
+
expect(out!.nodes).toHaveLength(1);
|
|
41
|
+
expect(out!.nodes[0]!.status).toBe("completed");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("recomputes childIds from parent links", () => {
|
|
45
|
+
const start: TraceGraph = { meta: { sessionId: "s" }, nodes: [node("a")] };
|
|
46
|
+
const child = node("b", { parents: [{ id: "a", relation: "follows" }], parentIds: ["a"] });
|
|
47
|
+
const out = reduceTraceForEvent(start, traceEv("created", child), "s");
|
|
48
|
+
const parent = out!.nodes.find((n) => n.id === "a")!;
|
|
49
|
+
expect(parent.childIds).toEqual(["b"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("ignores non trace_node events (same reference)", () => {
|
|
53
|
+
const start: TraceGraph = { meta: { sessionId: "s" }, nodes: [node("a")] };
|
|
54
|
+
expect(reduceTraceForEvent(start, { type: "RUN_STARTED" } as WebSocketEvent, "s")).toBe(start);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("ignores a payload with no node id (same reference)", () => {
|
|
58
|
+
const start: TraceGraph = { meta: { sessionId: "s" }, nodes: [node("a")] };
|
|
59
|
+
const bad = { type: "CUSTOM", name: "trace_node", value: { op: "created", node: {} } } as unknown as WebSocketEvent;
|
|
60
|
+
expect(reduceTraceForEvent(start, bad, "s")).toBe(start);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -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,61 @@ 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
|
+
// #133 — force an instant jump for the restore. The container CSS no
|
|
170
|
+
// longer sets `scroll-behavior: smooth`, but pin it locally too so a
|
|
171
|
+
// future global rule (or an inherited one) can never turn this restore
|
|
172
|
+
// into a visible top-to-bottom replay through the history.
|
|
173
|
+
n.style.scrollBehavior = "auto";
|
|
174
|
+
n.scrollTop = resolveScrollTop(mem, n.scrollHeight);
|
|
175
|
+
};
|
|
176
|
+
apply();
|
|
177
|
+
let raf2 = 0;
|
|
178
|
+
const raf1 = window.requestAnimationFrame(() => {
|
|
179
|
+
apply();
|
|
180
|
+
raf2 = window.requestAnimationFrame(apply);
|
|
181
|
+
});
|
|
182
|
+
return () => {
|
|
183
|
+
window.cancelAnimationFrame(raf1);
|
|
184
|
+
if (raf2) window.cancelAnimationFrame(raf2);
|
|
185
|
+
};
|
|
186
|
+
// Mount-only restore; live append is handled by the autoScroll effect below.
|
|
187
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
188
|
+
}, [scrollKey]);
|
|
189
|
+
|
|
172
190
|
useEffect(() => {
|
|
173
191
|
if (!autoScroll) {
|
|
174
192
|
return;
|
|
@@ -177,6 +195,8 @@ function MessageStreamImpl({
|
|
|
177
195
|
if (!node || !isPinnedRef.current) {
|
|
178
196
|
return;
|
|
179
197
|
}
|
|
198
|
+
// #133 — pinned-bottom live append also jumps instantly (no smooth replay).
|
|
199
|
+
node.style.scrollBehavior = "auto";
|
|
180
200
|
node.scrollTop = node.scrollHeight;
|
|
181
201
|
}, [messages, autoScroll]);
|
|
182
202
|
|
|
@@ -187,6 +207,8 @@ function MessageStreamImpl({
|
|
|
187
207
|
}
|
|
188
208
|
const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
|
|
189
209
|
isPinnedRef.current = distanceFromBottom < 24;
|
|
210
|
+
// #89 — persist intent so a tab switch (which unmounts Chat) can restore it.
|
|
211
|
+
setChatScroll(scrollKey, { scrollTop: node.scrollTop, pinned: isPinnedRef.current });
|
|
190
212
|
};
|
|
191
213
|
|
|
192
214
|
const handleCopy = async (id: string, text: string) => {
|
|
@@ -344,10 +366,19 @@ function MessageStreamImpl({
|
|
|
344
366
|
const displayName = message.agent || (message.role === "system" ? "system" : "principal");
|
|
345
367
|
const isLive = message.id === liveStreamingId;
|
|
346
368
|
const timing = elapsedLabel(message);
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
<
|
|
369
|
+
const hasContent = !!message.content.trim();
|
|
370
|
+
const displayContent = hasContent ? message.content : (message.streaming ? t("chat.streamingPending") : "");
|
|
371
|
+
const content = (
|
|
372
|
+
<div className={`message-row__content ${message.streaming && !hasContent ? "message-row__content--pending" : ""}`}>
|
|
373
|
+
{message.kind === "error" ? (
|
|
374
|
+
<p className="message-card__content--plain message-row__error">{displayContent}</p>
|
|
375
|
+
) : (
|
|
376
|
+
<MarkdownMessage content={displayContent} />
|
|
377
|
+
)}
|
|
378
|
+
{message.streaming && message.kind !== "error" ? (
|
|
379
|
+
<span className="message-row__streaming-cursor" aria-hidden="true" />
|
|
380
|
+
) : null}
|
|
381
|
+
</div>
|
|
351
382
|
);
|
|
352
383
|
return (
|
|
353
384
|
<div
|
|
@@ -377,15 +408,30 @@ function MessageStreamImpl({
|
|
|
377
408
|
const renderActivityStep = (step: ChatMessage) => {
|
|
378
409
|
const isExpert = !!step.agent && step.agent !== "principal";
|
|
379
410
|
if (step.kind === "tool") {
|
|
411
|
+
// #84: render a friendly tool name (mcp__server__tool → server · tool) and
|
|
412
|
+
// un-escaped payloads. The raw name stays in `title` for debugging/copy.
|
|
413
|
+
const friendly = t("chat.toolPrefix", { name: formatToolName(step.toolName) });
|
|
414
|
+
const input = formatPayload(step.toolInput);
|
|
415
|
+
const result = formatPayload(step.toolResult);
|
|
380
416
|
return (
|
|
381
417
|
<div className="activity-step" key={step.id}>
|
|
382
418
|
<details>
|
|
383
|
-
<summary>
|
|
419
|
+
<summary title={step.toolName || undefined}>
|
|
384
420
|
{isExpert ? <span className="message-card__agent-badge">{step.agent}</span> : null}
|
|
385
|
-
{
|
|
421
|
+
{friendly}
|
|
386
422
|
</summary>
|
|
387
|
-
{
|
|
388
|
-
|
|
423
|
+
{input ? (
|
|
424
|
+
<div className="activity-step__io">
|
|
425
|
+
<span className="activity-step__io-label">{t("chat.toolArgs")}</span>
|
|
426
|
+
<pre>{input}</pre>
|
|
427
|
+
</div>
|
|
428
|
+
) : null}
|
|
429
|
+
{result ? (
|
|
430
|
+
<div className="activity-step__io">
|
|
431
|
+
<span className="activity-step__io-label">{t("chat.toolResult")}</span>
|
|
432
|
+
<pre>{result}</pre>
|
|
433
|
+
</div>
|
|
434
|
+
) : null}
|
|
389
435
|
</details>
|
|
390
436
|
</div>
|
|
391
437
|
);
|
|
@@ -400,7 +446,7 @@ function MessageStreamImpl({
|
|
|
400
446
|
return (
|
|
401
447
|
<div className="activity-step" key={step.id}>
|
|
402
448
|
{isExpert ? <span className="message-card__agent-badge">{step.agent}</span> : null}
|
|
403
|
-
<MarkdownMessage content={step.content || (step.streaming ? t("chat.
|
|
449
|
+
<MarkdownMessage content={step.content || (step.streaming ? t("chat.streamingPending") : "")} />
|
|
404
450
|
</div>
|
|
405
451
|
);
|
|
406
452
|
};
|
|
@@ -410,7 +456,7 @@ function MessageStreamImpl({
|
|
|
410
456
|
const activitySubtitle = (steps: ChatMessage[], streaming: boolean) => {
|
|
411
457
|
if (!streaming) return t("chat.thinkingSteps", { count: steps.length });
|
|
412
458
|
const last = steps[steps.length - 1];
|
|
413
|
-
if (last?.kind === "tool") return t("chat.toolCall", { name: last.toolName
|
|
459
|
+
if (last?.kind === "tool") return t("chat.toolCall", { name: formatToolName(last.toolName) });
|
|
414
460
|
const text = (last?.reasoning || last?.content || "").trim();
|
|
415
461
|
if (text) return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
|
416
462
|
return t("chat.thinking");
|
|
@@ -448,9 +494,11 @@ function MessageStreamImpl({
|
|
|
448
494
|
</div>
|
|
449
495
|
),
|
|
450
496
|
)}
|
|
451
|
-
{
|
|
497
|
+
{showTiming && turnTiming && turnTiming.elapsedMs !== null ? (
|
|
452
498
|
<div className="message-stack__total" role="status">
|
|
453
|
-
{t("chat.totalTime", {
|
|
499
|
+
{t(turnTiming.running ? "chat.turnTimeRunning" : "chat.totalTime", {
|
|
500
|
+
time: formatElapsed(turnTiming.elapsedMs),
|
|
501
|
+
})}
|
|
454
502
|
</div>
|
|
455
503
|
) : null}
|
|
456
504
|
</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
|
+
}
|