@brainpilot/web 0.0.8 → 0.0.10
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-D63mUJxx.js +450 -0
- package/dist/assets/index-D8J9Cnup.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/__tests__/api.test.ts +90 -1
- package/src/__tests__/demoTruncatedExport.test.ts +104 -0
- package/src/__tests__/rehydrateMerge.test.ts +40 -0
- package/src/__tests__/runningScripts.test.ts +139 -0
- package/src/__tests__/timelineBounds.test.ts +51 -0
- package/src/components/chat/MessageStream.tsx +1 -11
- package/src/components/chat/PromptComposer.tsx +118 -16
- package/src/components/chat/RunningScriptsPanel.tsx +118 -0
- package/src/components/chat/runningScripts.ts +88 -0
- package/src/components/demo/demoBundle.ts +8 -3
- package/src/components/files/FileSidebar.tsx +82 -11
- package/src/components/session/AgentNetwork.tsx +1 -0
- package/src/components/session/TimelineTab.tsx +39 -9
- package/src/components/settings/KnowledgeBasePanel.tsx +594 -0
- package/src/components/settings/SettingsDialog.tsx +12 -4
- package/src/contexts/SessionContext.tsx +31 -7
- package/src/contexts/messageReducer.ts +19 -0
- package/src/contracts/backend.ts +8 -1
- package/src/i18n/messages/chat.ts +4 -0
- package/src/i18n/messages/files.ts +2 -0
- package/src/i18n/messages/settings.ts +57 -0
- package/src/styles/global.css +139 -1
- package/src/utils/api.ts +139 -3
- package/src/utils/format.ts +9 -0
- package/dist/assets/index-162Pskp8.js +0 -438
- package/dist/assets/index-DWOsU22G.css +0 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
extractCommand,
|
|
4
|
+
isBashTool,
|
|
5
|
+
selectActiveScripts,
|
|
6
|
+
} from "../components/chat/runningScripts";
|
|
7
|
+
import type { ChatMessage } from "../contracts/backend";
|
|
8
|
+
|
|
9
|
+
function bashCall(over: Partial<ChatMessage> = {}): ChatMessage {
|
|
10
|
+
return {
|
|
11
|
+
id: over.id ?? "call-1",
|
|
12
|
+
role: "assistant",
|
|
13
|
+
content: "Tool: bash",
|
|
14
|
+
createdAt: "2026-07-01T00:00:00.000Z",
|
|
15
|
+
agent: over.agent ?? "principal",
|
|
16
|
+
kind: "tool",
|
|
17
|
+
toolName: "bash",
|
|
18
|
+
streaming: true,
|
|
19
|
+
toolInput: JSON.stringify({ command: "pytest -x tests/unit" }),
|
|
20
|
+
...over,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("isBashTool", () => {
|
|
25
|
+
it("matches the bare bash tool", () => {
|
|
26
|
+
expect(isBashTool("bash")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("matches the mcp-namespaced bash tool", () => {
|
|
30
|
+
expect(isBashTool("mcp__local__bash")).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("does not match other tool names", () => {
|
|
34
|
+
expect(isBashTool("read")).toBe(false);
|
|
35
|
+
expect(isBashTool("mcp__local__read")).toBe(false);
|
|
36
|
+
expect(isBashTool("bash_history")).toBe(false); // suffix match required
|
|
37
|
+
expect(isBashTool(undefined)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("extractCommand", () => {
|
|
42
|
+
it("pulls `command` out of a fully-formed JSON args string", () => {
|
|
43
|
+
expect(extractCommand('{"command":"ls -la"}')).toBe("ls -la");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("falls back to `cmd`/`script`/`shell` keys", () => {
|
|
47
|
+
expect(extractCommand('{"cmd":"echo hi"}')).toBe("echo hi");
|
|
48
|
+
expect(extractCommand('{"script":"./run.sh"}')).toBe("./run.sh");
|
|
49
|
+
expect(extractCommand('{"shell":"bash -c foo"}')).toBe("bash -c foo");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns the raw string when the JSON is still partial", () => {
|
|
53
|
+
// TOOL_CALL_ARGS deltas may arrive as `{"comm` before the full JSON has
|
|
54
|
+
// buffered. Better to show *something* than to render an empty row.
|
|
55
|
+
expect(extractCommand('{"command":"pyt')).toBe('{"command":"pyt');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns empty for non-strings and empty strings", () => {
|
|
59
|
+
expect(extractCommand(undefined)).toBe("");
|
|
60
|
+
expect(extractCommand(null)).toBe("");
|
|
61
|
+
expect(extractCommand("")).toBe("");
|
|
62
|
+
expect(extractCommand({ command: "not a string" })).toBe("");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("selectActiveScripts", () => {
|
|
67
|
+
it("returns empty when there are no tool messages", () => {
|
|
68
|
+
expect(selectActiveScripts([])).toEqual([]);
|
|
69
|
+
expect(
|
|
70
|
+
selectActiveScripts([
|
|
71
|
+
{
|
|
72
|
+
id: "t1",
|
|
73
|
+
role: "assistant",
|
|
74
|
+
content: "hello",
|
|
75
|
+
createdAt: "",
|
|
76
|
+
kind: "text",
|
|
77
|
+
},
|
|
78
|
+
]),
|
|
79
|
+
).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns only streaming bash calls, ignoring completed ones", () => {
|
|
83
|
+
const active = bashCall({ id: "a", streaming: true });
|
|
84
|
+
const done = bashCall({ id: "b", streaming: false });
|
|
85
|
+
const result = selectActiveScripts([active, done]);
|
|
86
|
+
expect(result.map((s) => s.id)).toEqual(["a"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("ignores non-bash tool calls even when they are streaming", () => {
|
|
90
|
+
const bash = bashCall({ id: "a", streaming: true });
|
|
91
|
+
const read = bashCall({
|
|
92
|
+
id: "b",
|
|
93
|
+
streaming: true,
|
|
94
|
+
toolName: "read",
|
|
95
|
+
toolInput: JSON.stringify({ file: "foo.txt" }),
|
|
96
|
+
});
|
|
97
|
+
const trace = bashCall({
|
|
98
|
+
id: "c",
|
|
99
|
+
streaming: true,
|
|
100
|
+
toolName: "mcp__brainpilot__record_trace",
|
|
101
|
+
toolInput: "{}",
|
|
102
|
+
});
|
|
103
|
+
const result = selectActiveScripts([bash, read, trace]);
|
|
104
|
+
expect(result.map((s) => s.id)).toEqual(["a"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("preserves arrival order across multiple concurrent bash calls", () => {
|
|
108
|
+
const first = bashCall({
|
|
109
|
+
id: "first",
|
|
110
|
+
agent: "principal",
|
|
111
|
+
toolInput: JSON.stringify({ command: "pytest" }),
|
|
112
|
+
});
|
|
113
|
+
const second = bashCall({
|
|
114
|
+
id: "second",
|
|
115
|
+
agent: "engineer",
|
|
116
|
+
toolInput: JSON.stringify({ command: "npm test" }),
|
|
117
|
+
});
|
|
118
|
+
const result = selectActiveScripts([first, second]);
|
|
119
|
+
expect(result).toEqual([
|
|
120
|
+
expect.objectContaining({ id: "first", agent: "principal", command: "pytest" }),
|
|
121
|
+
expect.objectContaining({ id: "second", agent: "engineer", command: "npm test" }),
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("defaults the agent name to 'principal' when unattributed", () => {
|
|
126
|
+
const noAgent = bashCall({ id: "a", agent: undefined });
|
|
127
|
+
const [row] = selectActiveScripts([noAgent]);
|
|
128
|
+
expect(row.agent).toBe("principal");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("shows the raw arg fragment while args are still streaming", () => {
|
|
132
|
+
const partial = bashCall({
|
|
133
|
+
id: "a",
|
|
134
|
+
toolInput: '{"command":"pyt', // TOOL_CALL_ARGS half-arrived
|
|
135
|
+
});
|
|
136
|
+
const [row] = selectActiveScripts([partial]);
|
|
137
|
+
expect(row.command).toBe('{"command":"pyt');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { computeTimeBounds } from "../components/session/TimelineTab";
|
|
3
|
+
|
|
4
|
+
describe("computeTimeBounds (#166)", () => {
|
|
5
|
+
const now = 1_000_000;
|
|
6
|
+
|
|
7
|
+
it("falls back to a 60s window ending at `now` when there are no dots", () => {
|
|
8
|
+
expect(computeTimeBounds([], now, false)).toEqual({ start: now - 60_000, end: now });
|
|
9
|
+
expect(computeTimeBounds([], now, true)).toEqual({ start: now - 60_000, end: now });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("ends at the last message for a finished (not running) session", () => {
|
|
13
|
+
const first = 100_000;
|
|
14
|
+
const last = 200_000;
|
|
15
|
+
// `now` is far in the future, but the axis must NOT stretch to it.
|
|
16
|
+
const bounds = computeTimeBounds([first, last], now, false);
|
|
17
|
+
expect(bounds).toEqual({ start: first, end: last });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("does not grow the axis as wall-clock `now` advances when idle", () => {
|
|
21
|
+
const ts = [100_000, 200_000];
|
|
22
|
+
const a = computeTimeBounds(ts, 500_000, false);
|
|
23
|
+
const b = computeTimeBounds(ts, 9_000_000, false);
|
|
24
|
+
// Same dots, later `now` → identical bounds (no creeping right edge).
|
|
25
|
+
expect(a).toEqual(b);
|
|
26
|
+
expect(b.end).toBe(200_000);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("extends to `now` while the session is actively running", () => {
|
|
30
|
+
const ts = [100_000, 200_000];
|
|
31
|
+
const bounds = computeTimeBounds(ts, now, true);
|
|
32
|
+
expect(bounds).toEqual({ start: 100_000, end: now });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("when running but `now` is before the last message, keeps the last message", () => {
|
|
36
|
+
const ts = [100_000, 200_000];
|
|
37
|
+
const bounds = computeTimeBounds(ts, 150_000, true);
|
|
38
|
+
expect(bounds.end).toBe(200_000);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("expands a degenerate (single-dot / identical-ts) span to 60s", () => {
|
|
42
|
+
const bounds = computeTimeBounds([100_000], 100_000, false);
|
|
43
|
+
expect(bounds).toEqual({ start: 100_000, end: 160_000 });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("expands a degenerate span even while running", () => {
|
|
47
|
+
// Single dot, now == that dot → end would equal start, must be padded.
|
|
48
|
+
const bounds = computeTimeBounds([100_000], 100_000, true);
|
|
49
|
+
expect(bounds).toEqual({ start: 100_000, end: 160_000 });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -8,6 +8,7 @@ import { SystemMessageBubble } from "./SystemMessageBubble";
|
|
|
8
8
|
import { AskUserCard } from "./AskUserCard";
|
|
9
9
|
import { AutoRetryIndicator } from "./AutoRetryIndicator";
|
|
10
10
|
import { formatToolName, formatPayload } from "../../utils/toolDisplay";
|
|
11
|
+
import { formatElapsed } from "../../utils/format";
|
|
11
12
|
import { getChatScroll, setChatScroll, resolveScrollTop } from "./chatScrollMemory";
|
|
12
13
|
|
|
13
14
|
interface MessageStreamProps {
|
|
@@ -60,17 +61,6 @@ function mergeName(message: ChatMessage): string {
|
|
|
60
61
|
return message.agent || (message.role === "system" ? "system" : "principal");
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
// Compact elapsed formatter: "3.2s" under a minute, "1m 05s" above.
|
|
64
|
-
function formatElapsed(ms: number): string {
|
|
65
|
-
if (ms < 0) ms = 0;
|
|
66
|
-
const totalSeconds = ms / 1000;
|
|
67
|
-
if (totalSeconds < 60) {
|
|
68
|
-
return `${totalSeconds.toFixed(1)}s`;
|
|
69
|
-
}
|
|
70
|
-
const minutes = Math.floor(totalSeconds / 60);
|
|
71
|
-
const seconds = Math.floor(totalSeconds % 60);
|
|
72
|
-
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
73
|
-
}
|
|
74
64
|
|
|
75
65
|
/**
|
|
76
66
|
* Presentational chat message stack — message bubbles, agent rows, hook notes,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Bot, 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";
|
|
@@ -15,6 +15,8 @@ import { ComposerInput } from "./ComposerInput";
|
|
|
15
15
|
import { ComposerSendButton } from "./ComposerSendButton";
|
|
16
16
|
import { ComposerSendTools } from "./ComposerSendTools";
|
|
17
17
|
import { MessageStream } from "./MessageStream";
|
|
18
|
+
import { RunningScriptsPanel } from "./RunningScriptsPanel";
|
|
19
|
+
import { selectActiveScripts } from "./runningScripts";
|
|
18
20
|
|
|
19
21
|
export function PromptComposer() {
|
|
20
22
|
const t = useT();
|
|
@@ -34,6 +36,14 @@ export function PromptComposer() {
|
|
|
34
36
|
const commandsRef = useRef<HTMLDivElement | null>(null);
|
|
35
37
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
36
38
|
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
|
39
|
+
// #47: file upload — names of files uploaded into the workspace this turn,
|
|
40
|
+
// shown as removable chips and announced to the agent on send. (Restored: the
|
|
41
|
+
// backend upload chain — writeFile route + #60 staging/drain — was always
|
|
42
|
+
// present; only this composer UI was removed in #160. It now lives in the
|
|
43
|
+
// left tool cluster, not the send cluster guarded by composerSendTools.test.)
|
|
44
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
45
|
+
const [attachments, setAttachments] = useState<string[]>([]);
|
|
46
|
+
const [uploading, setUploading] = useState(false);
|
|
37
47
|
const { status: sandboxStatus, currentSandbox, reloadConfig } = useSandbox();
|
|
38
48
|
const [composerError, setComposerError] = useState<string | null>(null);
|
|
39
49
|
const { currentSession, messages, isSending, error, sendPrompt, isConnected, isDraft, agents, runActive, agentFilters, interruptCurrent, respondToInput, messageFilters } = useSessions();
|
|
@@ -61,6 +71,14 @@ export function PromptComposer() {
|
|
|
61
71
|
const hasMessages = visibleMessages.length > 0;
|
|
62
72
|
const isAgentRunning = agents.some((a) => a.status === "running");
|
|
63
73
|
const lastAssistantStreaming = visibleMessages[visibleMessages.length - 1]?.role === "assistant" && visibleMessages[visibleMessages.length - 1]?.streaming;
|
|
74
|
+
// A bash tool is in flight iff selectActiveScripts finds anything; when it
|
|
75
|
+
// does, the RunningScriptsPanel below the toast owns the Stop button so
|
|
76
|
+
// we don't render a duplicate. When no scripts are running (e.g. the agent
|
|
77
|
+
// is thinking or streaming text), the toast keeps its own Stop.
|
|
78
|
+
const hasActiveScripts = useMemo(
|
|
79
|
+
() => selectActiveScripts(visibleMessages).length > 0,
|
|
80
|
+
[visibleMessages],
|
|
81
|
+
);
|
|
64
82
|
|
|
65
83
|
// Agents whose run is still active. Threaded to MessageStream so a folded
|
|
66
84
|
// activity block stays "in progress" across ReAct rounds — without this, the
|
|
@@ -220,17 +238,53 @@ export function PromptComposer() {
|
|
|
220
238
|
return;
|
|
221
239
|
}
|
|
222
240
|
draftStore.set(sessionId, "");
|
|
241
|
+
// #47: if files were uploaded this turn, prepend a notice so the agent knows
|
|
242
|
+
// they exist in its workspace and can `read` them. Cleared after send.
|
|
243
|
+
const notice =
|
|
244
|
+
attachments.length > 0 ? `${t("chat.upload.notice", { names: attachments.join(", ") })}\n\n` : "";
|
|
245
|
+
const sentAttachments = attachments;
|
|
246
|
+
if (attachments.length > 0) setAttachments([]);
|
|
223
247
|
// Carry the chosen provider/model so a freshly-created session records its
|
|
224
248
|
// per-session selection (no-op for an already-running session).
|
|
225
|
-
const ok = await sendPrompt(content
|
|
249
|
+
const ok = await sendPrompt(`${notice}${content}`, {
|
|
226
250
|
providerId: activeProvider?.id,
|
|
227
251
|
modelId: selectedModel || undefined,
|
|
228
252
|
});
|
|
229
253
|
// #106: a failed/timed-out send must not silently eat the user's input.
|
|
230
|
-
// Restore the draft so they can retry without
|
|
231
|
-
// haven't already started typing again.
|
|
232
|
-
if (!ok
|
|
233
|
-
draftStore.
|
|
254
|
+
// Restore the draft (and attachment chips) so they can retry without
|
|
255
|
+
// retyping. Only restore if they haven't already started typing again.
|
|
256
|
+
if (!ok) {
|
|
257
|
+
if (draftStore.get(sessionId).trim().length === 0) {
|
|
258
|
+
draftStore.set(sessionId, content);
|
|
259
|
+
}
|
|
260
|
+
if (sentAttachments.length > 0) {
|
|
261
|
+
setAttachments((prev) => (prev.length === 0 ? sentAttachments : prev));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// #47: upload the chosen files into the session workspace, then track their
|
|
267
|
+
// names as chips. In single-user mode the sandbox id and session id are the
|
|
268
|
+
// same; a draft has no real session yet, so uploads land in the `"local"`
|
|
269
|
+
// staging area and the runtime drains them into the real workspace on send
|
|
270
|
+
// (#60 drainLocalUploads). Files are uploaded to the workspace root by name.
|
|
271
|
+
const handleFilesChosen = async (files: FileList | null) => {
|
|
272
|
+
if (!files || files.length === 0) return;
|
|
273
|
+
const uploadId = currentSession?.id ?? currentSandbox?.id;
|
|
274
|
+
if (!uploadId) return;
|
|
275
|
+
setUploading(true);
|
|
276
|
+
setComposerError(null);
|
|
277
|
+
try {
|
|
278
|
+
for (const file of Array.from(files)) {
|
|
279
|
+
await api.sandbox.uploadFile(uploadId, file.name, file);
|
|
280
|
+
setAttachments((prev) => (prev.includes(file.name) ? prev : [...prev, file.name]));
|
|
281
|
+
}
|
|
282
|
+
} catch (e) {
|
|
283
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
284
|
+
setComposerError(t("chat.upload.failed", { msg }));
|
|
285
|
+
} finally {
|
|
286
|
+
setUploading(false);
|
|
287
|
+
if (fileInputRef.current) fileInputRef.current.value = ""; // allow re-selecting the same file
|
|
234
288
|
}
|
|
235
289
|
};
|
|
236
290
|
|
|
@@ -268,19 +322,26 @@ export function PromptComposer() {
|
|
|
268
322
|
return t(label.key, label.vars);
|
|
269
323
|
})()}
|
|
270
324
|
</span>
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
325
|
+
{hasActiveScripts ? null : (
|
|
326
|
+
<button
|
|
327
|
+
className="agent-running-toast__stop"
|
|
328
|
+
type="button"
|
|
329
|
+
onClick={() => void interruptCurrent()}
|
|
330
|
+
aria-label={t("chat.aria.stop")}
|
|
331
|
+
title={t("chat.aria.stop")}
|
|
332
|
+
>
|
|
333
|
+
<Square size={10} fill="currentColor" />
|
|
334
|
+
<span>{t("chat.stop")}</span>
|
|
335
|
+
</button>
|
|
336
|
+
)}
|
|
281
337
|
</div>
|
|
282
338
|
) : null}
|
|
283
339
|
|
|
340
|
+
<RunningScriptsPanel
|
|
341
|
+
messages={visibleMessages}
|
|
342
|
+
onStop={() => void interruptCurrent()}
|
|
343
|
+
/>
|
|
344
|
+
|
|
284
345
|
<form className="composer" aria-label={t("chat.aria.newPrompt")} onSubmit={handleSubmit}>
|
|
285
346
|
<ComposerInput
|
|
286
347
|
sessionId={sessionId}
|
|
@@ -288,6 +349,26 @@ export function PromptComposer() {
|
|
|
288
349
|
ariaLabel={t("chat.srAsk")}
|
|
289
350
|
/>
|
|
290
351
|
|
|
352
|
+
{attachments.length > 0 || uploading ? (
|
|
353
|
+
<div className="composer__attachments" aria-label={t("chat.aria.attachFile")}>
|
|
354
|
+
{attachments.map((name) => (
|
|
355
|
+
<span className="composer__chip" key={name}>
|
|
356
|
+
<Paperclip size={12} />
|
|
357
|
+
<span className="composer__chip-name">{name}</span>
|
|
358
|
+
<button
|
|
359
|
+
type="button"
|
|
360
|
+
className="composer__chip-remove"
|
|
361
|
+
aria-label={t("chat.aria.removeAttachment")}
|
|
362
|
+
onClick={() => setAttachments((prev) => prev.filter((n) => n !== name))}
|
|
363
|
+
>
|
|
364
|
+
<X size={12} />
|
|
365
|
+
</button>
|
|
366
|
+
</span>
|
|
367
|
+
))}
|
|
368
|
+
{uploading ? <span className="composer__chip composer__chip--pending">{t("chat.upload.uploading")}</span> : null}
|
|
369
|
+
</div>
|
|
370
|
+
) : null}
|
|
371
|
+
|
|
291
372
|
<div className="composer__toolbar">
|
|
292
373
|
<div className="composer__tools">
|
|
293
374
|
{/*
|
|
@@ -298,6 +379,27 @@ export function PromptComposer() {
|
|
|
298
379
|
<Plus size={18} />
|
|
299
380
|
</IconButton>
|
|
300
381
|
*/}
|
|
382
|
+
{/*
|
|
383
|
+
#47: file upload. The button lives here in the left tool cluster
|
|
384
|
+
(not the send cluster, which composerSendTools.test.tsx guards
|
|
385
|
+
against an upload control under #160). The hidden <input> is
|
|
386
|
+
clicked programmatically; chosen files upload to the workspace
|
|
387
|
+
root and are announced to the agent on send.
|
|
388
|
+
*/}
|
|
389
|
+
<input
|
|
390
|
+
ref={fileInputRef}
|
|
391
|
+
type="file"
|
|
392
|
+
multiple
|
|
393
|
+
style={{ display: "none" }}
|
|
394
|
+
onChange={(e) => void handleFilesChosen(e.target.files)}
|
|
395
|
+
/>
|
|
396
|
+
<IconButton
|
|
397
|
+
label={t("chat.aria.attachFile")}
|
|
398
|
+
onClick={() => fileInputRef.current?.click()}
|
|
399
|
+
disabled={uploading || !currentSandbox}
|
|
400
|
+
>
|
|
401
|
+
<Paperclip size={17} />
|
|
402
|
+
</IconButton>
|
|
301
403
|
{SHOW_SLASH_COMMANDS && slashCommands.length > 0 && (
|
|
302
404
|
<div className="command-picker" ref={commandsRef}>
|
|
303
405
|
<IconButton
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ChevronDown, Square } from "lucide-react";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import type { ChatMessage } from "../../contracts/backend";
|
|
4
|
+
import { useT } from "../../i18n/useT";
|
|
5
|
+
import { formatElapsed } from "../../utils/format";
|
|
6
|
+
import { selectActiveScripts } from "./runningScripts";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
messages: ChatMessage[];
|
|
10
|
+
onStop: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* "Running scripts" panel — sits directly above the composer while any bash
|
|
15
|
+
* tool call is in flight, and unmounts the moment the last one ends.
|
|
16
|
+
*
|
|
17
|
+
* The message-stream activity block folds a whole turn's reasoning + tool
|
|
18
|
+
* calls into one collapsed history entry; the composer toast only names the
|
|
19
|
+
* working agent. Neither answers what users most often ask when they see the
|
|
20
|
+
* agent pause: "what shell command is running right now, and can I kill it?"
|
|
21
|
+
*
|
|
22
|
+
* Per-script elapsed timing is derived locally from a ref-held Map keyed by
|
|
23
|
+
* `toolCallId` — the runtime doesn't emit a start timestamp on
|
|
24
|
+
* `tool_call_start`, and per-second precision is fine for a "how long has
|
|
25
|
+
* this been going" affordance. `role="status"` (no `aria-live`) so the
|
|
26
|
+
* once-a-second tick doesn't spam screen readers with elapsed digits.
|
|
27
|
+
*/
|
|
28
|
+
export function RunningScriptsPanel({ messages, onStop }: Props) {
|
|
29
|
+
const t = useT();
|
|
30
|
+
const scripts = useMemo(() => selectActiveScripts(messages), [messages]);
|
|
31
|
+
|
|
32
|
+
// New scripts get a stamp the first render they appear in; finished ones
|
|
33
|
+
// are pruned so a re-appearing tool-call-id (shouldn't happen, but be
|
|
34
|
+
// defensive) restarts its clock cleanly.
|
|
35
|
+
const startedAt = useRef<Map<string, number>>(new Map());
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const now = performance.now();
|
|
38
|
+
const live = new Set(scripts.map((s) => s.id));
|
|
39
|
+
for (const id of live) {
|
|
40
|
+
if (!startedAt.current.has(id)) startedAt.current.set(id, now);
|
|
41
|
+
}
|
|
42
|
+
for (const id of startedAt.current.keys()) {
|
|
43
|
+
if (!live.has(id)) startedAt.current.delete(id);
|
|
44
|
+
}
|
|
45
|
+
}, [scripts]);
|
|
46
|
+
|
|
47
|
+
// Tick once a second while at least one script is in flight so the
|
|
48
|
+
// per-script elapsed advances without needing external state.
|
|
49
|
+
const [, setTick] = useState(0);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (scripts.length === 0) return;
|
|
52
|
+
const id = window.setInterval(() => setTick((n) => n + 1), 1000);
|
|
53
|
+
return () => window.clearInterval(id);
|
|
54
|
+
}, [scripts.length]);
|
|
55
|
+
|
|
56
|
+
// Open by default — the whole point of the panel is that the user sees
|
|
57
|
+
// what's running. `<details>` preserves the user's toggle across re-renders
|
|
58
|
+
// as long as the DOM node is reused (which it is: the panel itself doesn't
|
|
59
|
+
// remount while scripts come and go).
|
|
60
|
+
const [open, setOpen] = useState(true);
|
|
61
|
+
|
|
62
|
+
if (scripts.length === 0) return null;
|
|
63
|
+
|
|
64
|
+
const now = performance.now();
|
|
65
|
+
const starts = Array.from(startedAt.current.values());
|
|
66
|
+
const oldest = starts.length > 0 ? Math.min(...starts) : now;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="running-scripts" role="status">
|
|
70
|
+
<details
|
|
71
|
+
open={open}
|
|
72
|
+
onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
|
|
73
|
+
>
|
|
74
|
+
<summary>
|
|
75
|
+
<span className="running-scripts__dot" aria-hidden="true" />
|
|
76
|
+
<ChevronDown size={14} className="running-scripts__chevron" aria-hidden="true" />
|
|
77
|
+
<span className="running-scripts__label">
|
|
78
|
+
{t("chat.runningScripts.count", { count: scripts.length })}
|
|
79
|
+
{` · ${formatElapsed(now - oldest)}`}
|
|
80
|
+
</span>
|
|
81
|
+
<button
|
|
82
|
+
className="running-scripts__stop"
|
|
83
|
+
type="button"
|
|
84
|
+
onClick={(e) => {
|
|
85
|
+
// Don't toggle the <details> when clicking Stop.
|
|
86
|
+
e.stopPropagation();
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
onStop();
|
|
89
|
+
}}
|
|
90
|
+
aria-label={t("chat.aria.stop")}
|
|
91
|
+
title={t("chat.aria.stop")}
|
|
92
|
+
>
|
|
93
|
+
<Square size={10} fill="currentColor" />
|
|
94
|
+
<span>{t("chat.stop")}</span>
|
|
95
|
+
</button>
|
|
96
|
+
</summary>
|
|
97
|
+
<ul className="running-scripts__list">
|
|
98
|
+
{scripts.map((s) => {
|
|
99
|
+
const start = startedAt.current.get(s.id);
|
|
100
|
+
const elapsed = start === undefined ? "" : formatElapsed(now - start);
|
|
101
|
+
return (
|
|
102
|
+
<li className="running-scripts__item" key={s.id}>
|
|
103
|
+
<div className="running-scripts__item-head">
|
|
104
|
+
<span className="running-scripts__item-agent">{s.agent}</span>
|
|
105
|
+
<span className="running-scripts__item-name">bash</span>
|
|
106
|
+
<span className="running-scripts__item-elapsed">{elapsed}</span>
|
|
107
|
+
</div>
|
|
108
|
+
<pre className="running-scripts__cmd">
|
|
109
|
+
{s.command || t("chat.runningScripts.pending")}
|
|
110
|
+
</pre>
|
|
111
|
+
</li>
|
|
112
|
+
);
|
|
113
|
+
})}
|
|
114
|
+
</ul>
|
|
115
|
+
</details>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ChatMessage } from "../../contracts/backend";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Running script" panel data model (pure, no React).
|
|
5
|
+
*
|
|
6
|
+
* The composer's toast tells the user *which agent* is thinking, and the
|
|
7
|
+
* message stream's activity block shows the *history* of every reasoning /
|
|
8
|
+
* tool step. Neither surfaces the piece users most often ask for: "what
|
|
9
|
+
* shell command is running right now, and can I stop it?"
|
|
10
|
+
*
|
|
11
|
+
* A "script" here is a tool call whose kind is `tool`, whose name resolves
|
|
12
|
+
* to `bash` (bare or mcp-namespaced), and whose `streaming` flag is still
|
|
13
|
+
* true — i.e. TOOL_CALL_START has arrived but TOOL_CALL_END has not. Once
|
|
14
|
+
* the end event lands, the reducer clears `streaming`, the row falls off
|
|
15
|
+
* this list, and the panel collapses when the last one leaves.
|
|
16
|
+
*
|
|
17
|
+
* Restricting to bash on purpose: filesystem reads/greps/edits are cheap
|
|
18
|
+
* and finish fast, so listing them would just add noise. Long-running
|
|
19
|
+
* work — pytest, wget, training loops — flows through bash.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Match a tool name against the bash tool, whether it arrived bare
|
|
24
|
+
* (Pi's built-in) or namespaced (`mcp__<server>__bash` from a bridged MCP
|
|
25
|
+
* server). Mirrors the bare-name extraction used by `isInternalToolName`
|
|
26
|
+
* in messageGroups.ts so the two visibility filters stay consistent.
|
|
27
|
+
*/
|
|
28
|
+
export function isBashTool(name: string | undefined): boolean {
|
|
29
|
+
if (!name) return false;
|
|
30
|
+
if (name === "bash") return true;
|
|
31
|
+
const bare = name.includes("__") ? name.slice(name.lastIndexOf("__") + 2) : name;
|
|
32
|
+
return bare === "bash";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Best-effort extraction of the shell command from the tool call args.
|
|
37
|
+
*
|
|
38
|
+
* The reducer feeds `TOOL_CALL_ARGS` deltas verbatim into `toolInput` as a
|
|
39
|
+
* string, so once enough of the JSON has arrived we get `{"command": "..."}`
|
|
40
|
+
* (or `cmd`, depending on the runtime). While the JSON is still partial we
|
|
41
|
+
* just show the raw fragment — better than nothing, and it stops flickering
|
|
42
|
+
* once the delta stream completes.
|
|
43
|
+
*/
|
|
44
|
+
export function extractCommand(toolInput: unknown): string {
|
|
45
|
+
if (typeof toolInput !== "string" || toolInput.length === 0) return "";
|
|
46
|
+
const trimmed = toolInput.trim();
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(trimmed);
|
|
49
|
+
if (parsed && typeof parsed === "object") {
|
|
50
|
+
const obj = parsed as Record<string, unknown>;
|
|
51
|
+
for (const key of ["command", "cmd", "script", "shell"]) {
|
|
52
|
+
const value = obj[key];
|
|
53
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// Partial JSON while args are still streaming — fall through.
|
|
58
|
+
}
|
|
59
|
+
return trimmed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A bash tool call that is still executing. */
|
|
63
|
+
export interface ActiveScript {
|
|
64
|
+
id: string;
|
|
65
|
+
agent: string;
|
|
66
|
+
command: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Distil the flat message log down to the bash calls still in flight, in
|
|
71
|
+
* arrival order. Everything else — text, thinking, non-bash tools, completed
|
|
72
|
+
* bash calls — is filtered out. Callers can trigger the panel purely off
|
|
73
|
+
* `result.length > 0`.
|
|
74
|
+
*/
|
|
75
|
+
export function selectActiveScripts(messages: ChatMessage[]): ActiveScript[] {
|
|
76
|
+
const out: ActiveScript[] = [];
|
|
77
|
+
for (const m of messages) {
|
|
78
|
+
if (m.kind !== "tool") continue;
|
|
79
|
+
if (!m.streaming) continue;
|
|
80
|
+
if (!isBashTool(m.toolName)) continue;
|
|
81
|
+
out.push({
|
|
82
|
+
id: m.id,
|
|
83
|
+
agent: m.agent ?? "principal",
|
|
84
|
+
command: extractCommand(m.toolInput),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
@@ -159,9 +159,14 @@ export async function buildDemoBundle(opts: BuildDemoOptions): Promise<DemoBundl
|
|
|
159
159
|
let timeline: DemoBundle["timeline"] = "timestamped";
|
|
160
160
|
// Pull the persisted event timeline from the new history endpoint (the
|
|
161
161
|
// legacy `/sessions/:id/events` path is an SSE alias and returns no JSON).
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
162
|
+
// Request the FULL log (`limit: 0`) — same as the live chat rehydrate path
|
|
163
|
+
// (HISTORY_REHYDRATE_LIMIT). A positive cap returns the *tail* of the log,
|
|
164
|
+
// which slices off the oldest events: the leading TEXT_MESSAGE_START of the
|
|
165
|
+
// earliest messages is dropped, leaving orphaned CONTENT/END that the
|
|
166
|
+
// reducer can't attach to anything, so the conversation's opening replies
|
|
167
|
+
// silently vanish from the replay. The 25 MB embed budget (MAX_TOTAL_BYTES)
|
|
168
|
+
// still bounds the bundle's real footprint via the files section.
|
|
169
|
+
const historyEnvelope = await api.sessions.getHistory(session.id, { limit: 0 });
|
|
165
170
|
let events: typeof historyEnvelope.events | undefined = historyEnvelope.events;
|
|
166
171
|
let messages: ChatMessage[] | undefined;
|
|
167
172
|
if (!events || events.length === 0) {
|