@brainpilot/web 0.0.8 → 0.0.9
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-CJNvdeGz.js +445 -0
- package/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/__tests__/api.test.ts +41 -0
- package/src/__tests__/demoTruncatedExport.test.ts +104 -0
- package/src/__tests__/rehydrateMerge.test.ts +40 -0
- package/src/__tests__/timelineBounds.test.ts +51 -0
- package/src/components/chat/PromptComposer.tsx +91 -6
- 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/contexts/SessionContext.tsx +31 -7
- package/src/contexts/messageReducer.ts +19 -0
- package/src/contracts/backend.ts +8 -1
- package/src/i18n/messages/files.ts +2 -0
- package/src/utils/api.ts +14 -0
- package/dist/assets/index-162Pskp8.js +0 -438
package/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<meta name="color-scheme" content="light dark" />
|
|
7
7
|
<title>BrainPilot</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CJNvdeGz.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-DWOsU22G.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brainpilot/web",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=22"
|
|
6
6
|
},
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@brainpilot/protocol": "^0.0.
|
|
34
|
+
"@brainpilot/protocol": "^0.0.9",
|
|
35
35
|
"@fontsource-variable/geist": "^5.2.9",
|
|
36
36
|
"@fontsource-variable/geist-mono": "^5.2.8",
|
|
37
37
|
"@types/react": "^18.3.12",
|
|
@@ -219,3 +219,44 @@ describe("api.sessions.interrupt — hits the interrupt route, not /messages (#9
|
|
|
219
219
|
expect(url.endsWith("/messages")).toBe(false);
|
|
220
220
|
});
|
|
221
221
|
});
|
|
222
|
+
|
|
223
|
+
describe("api.sandbox.uploadFile — #47 base64 upload to the workspace", () => {
|
|
224
|
+
// blobToBase64 uses the browser FileReader, absent in the node test env; stub
|
|
225
|
+
// it with a minimal readAsDataURL that emits a data: URL so the base64 path
|
|
226
|
+
// (prefix stripping) is exercised end-to-end.
|
|
227
|
+
beforeEach(() => {
|
|
228
|
+
class FakeFileReader {
|
|
229
|
+
result: string | null = null;
|
|
230
|
+
error: unknown = null;
|
|
231
|
+
onload: (() => void) | null = null;
|
|
232
|
+
onerror: (() => void) | null = null;
|
|
233
|
+
readAsDataURL(_blob: Blob) {
|
|
234
|
+
// "hi" → aGk= ; the helper must strip the "data:...;base64," prefix.
|
|
235
|
+
this.result = "data:text/plain;base64,aGk=";
|
|
236
|
+
this.onload?.();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
vi.stubGlobal("FileReader", FakeFileReader);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("POSTs { path, contentBase64 } and returns the runtime's { path, size }", async () => {
|
|
243
|
+
fetchMock.mockResolvedValueOnce(
|
|
244
|
+
makeResponse({ status: 201, contentType: "application/json", json: { path: "notes.txt", size: 2 } }),
|
|
245
|
+
);
|
|
246
|
+
const out = await api.sandbox.uploadFile("s1", "notes.txt", new Blob(["hi"]));
|
|
247
|
+
|
|
248
|
+
const [url, init] = fetchMock.mock.calls[0]!;
|
|
249
|
+
expect(String(url)).toMatch(/\/sandbox\/s1\/files$/);
|
|
250
|
+
expect((init as RequestInit).method).toBe("POST");
|
|
251
|
+
const body = JSON.parse(String((init as RequestInit).body));
|
|
252
|
+
expect(body).toEqual({ path: "notes.txt", contentBase64: "aGk=" });
|
|
253
|
+
expect(out).toEqual({ path: "notes.txt", size: 2 });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("throws the backend error message on a non-ok response", async () => {
|
|
257
|
+
fetchMock.mockResolvedValueOnce(
|
|
258
|
+
makeResponse({ ok: false, status: 400, contentType: "application/json", json: { detail: "file too large" } }),
|
|
259
|
+
);
|
|
260
|
+
await expect(api.sandbox.uploadFile("s1", "big.bin", new Blob(["hi"]))).rejects.toThrow("file too large");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { reduceMessagesForEvent } from "../contexts/messageReducer";
|
|
3
|
+
import { normalizeAgUiEvent } from "../contracts/backend";
|
|
4
|
+
import type { ChatMessage, WebSocketEvent } from "../contracts/backend";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression coverage for demo bundles built from a *tail-sliced* history.
|
|
8
|
+
*
|
|
9
|
+
* When a long session was exported with a positive `limit`, the history
|
|
10
|
+
* endpoint returns only the tail of events.jsonl — the leading
|
|
11
|
+
* TEXT_MESSAGE_START of the earliest messages is gone, leaving orphaned
|
|
12
|
+
* CONTENT/END. The replay then dropped those opening replies silently
|
|
13
|
+
* ("开始的消息被截断"). The export now requests the full log (`limit: 0`), and
|
|
14
|
+
* the reducer additionally recovers gracefully if an orphan still slips
|
|
15
|
+
* through.
|
|
16
|
+
*/
|
|
17
|
+
describe("demo replay tolerates truncated/orphaned events", () => {
|
|
18
|
+
it("renders orphaned TEXT_MESSAGE_CONTENT whose START was sliced off", () => {
|
|
19
|
+
// Simulate a tail-sliced stream: CONTENT/END with no preceding START.
|
|
20
|
+
let msgs: ChatMessage[] = [];
|
|
21
|
+
msgs = reduceMessagesForEvent(msgs, {
|
|
22
|
+
type: "TEXT_MESSAGE_CONTENT",
|
|
23
|
+
messageId: "orphan-1",
|
|
24
|
+
delta: "Librarian → methodology grounding",
|
|
25
|
+
agentName: "principal",
|
|
26
|
+
} as WebSocketEvent);
|
|
27
|
+
msgs = reduceMessagesForEvent(msgs, {
|
|
28
|
+
type: "TEXT_MESSAGE_CONTENT",
|
|
29
|
+
messageId: "orphan-1",
|
|
30
|
+
delta: " against the paper",
|
|
31
|
+
agentName: "principal",
|
|
32
|
+
} as WebSocketEvent);
|
|
33
|
+
msgs = reduceMessagesForEvent(msgs, {
|
|
34
|
+
type: "TEXT_MESSAGE_END",
|
|
35
|
+
messageId: "orphan-1",
|
|
36
|
+
} as WebSocketEvent);
|
|
37
|
+
|
|
38
|
+
expect(msgs).toHaveLength(1);
|
|
39
|
+
expect(msgs[0].id).toBe("orphan-1");
|
|
40
|
+
expect(msgs[0].content).toBe("Librarian → methodology grounding against the paper");
|
|
41
|
+
expect(msgs[0].agent).toBe("principal");
|
|
42
|
+
expect(msgs[0].streaming).toBe(false); // END finalized it
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("still folds a normal START→CONTENT→END triad into one message", () => {
|
|
46
|
+
let msgs: ChatMessage[] = [];
|
|
47
|
+
msgs = reduceMessagesForEvent(msgs, {
|
|
48
|
+
type: "TEXT_MESSAGE_START",
|
|
49
|
+
messageId: "m1",
|
|
50
|
+
role: "assistant",
|
|
51
|
+
agentName: "principal",
|
|
52
|
+
} as WebSocketEvent);
|
|
53
|
+
msgs = reduceMessagesForEvent(msgs, {
|
|
54
|
+
type: "TEXT_MESSAGE_CONTENT",
|
|
55
|
+
messageId: "m1",
|
|
56
|
+
delta: "hello",
|
|
57
|
+
} as WebSocketEvent);
|
|
58
|
+
msgs = reduceMessagesForEvent(msgs, {
|
|
59
|
+
type: "TEXT_MESSAGE_CONTENT",
|
|
60
|
+
messageId: "m1",
|
|
61
|
+
delta: " world",
|
|
62
|
+
} as WebSocketEvent);
|
|
63
|
+
msgs = reduceMessagesForEvent(msgs, {
|
|
64
|
+
type: "TEXT_MESSAGE_END",
|
|
65
|
+
messageId: "m1",
|
|
66
|
+
} as WebSocketEvent);
|
|
67
|
+
|
|
68
|
+
expect(msgs).toHaveLength(1);
|
|
69
|
+
expect(msgs[0].content).toBe("hello world");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("strips NO-RENDER wrappers even on orphaned content", () => {
|
|
73
|
+
let msgs: ChatMessage[] = [];
|
|
74
|
+
msgs = reduceMessagesForEvent(msgs, {
|
|
75
|
+
type: "TEXT_MESSAGE_CONTENT",
|
|
76
|
+
messageId: "orphan-2",
|
|
77
|
+
delta: "<!--NO-RENDER-->internal<!--/NO-RENDER-->",
|
|
78
|
+
} as WebSocketEvent);
|
|
79
|
+
// Entire delta was a NO-RENDER wrapper → nothing to render, no message created.
|
|
80
|
+
expect(msgs).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("normalizeAgUiEvent preserves transport metadata keys", () => {
|
|
85
|
+
it("keeps _ts (and _seq) instead of mangling to Ts/Seq", () => {
|
|
86
|
+
const raw = {
|
|
87
|
+
type: "TEXT_MESSAGE_CONTENT",
|
|
88
|
+
_ts: "2026-06-22T08:43:24.619Z",
|
|
89
|
+
_seq: 42,
|
|
90
|
+
agent_name: "principal",
|
|
91
|
+
message_id: "m1",
|
|
92
|
+
delta: "hi",
|
|
93
|
+
};
|
|
94
|
+
const norm = normalizeAgUiEvent(raw) as Record<string, unknown>;
|
|
95
|
+
// The timestamp the demo timeline sorts on must survive normalization.
|
|
96
|
+
expect(norm._ts).toBe("2026-06-22T08:43:24.619Z");
|
|
97
|
+
expect(norm._seq).toBe(42);
|
|
98
|
+
expect(norm).not.toHaveProperty("Ts");
|
|
99
|
+
expect(norm).not.toHaveProperty("Seq");
|
|
100
|
+
// Internal snake_case still camelizes.
|
|
101
|
+
expect(norm.agentName).toBe("principal");
|
|
102
|
+
expect(norm.messageId).toBe("m1");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mergeRehydratedMessages } from "../contexts/SessionContext";
|
|
3
|
+
import type { ChatMessage } from "../contracts/backend";
|
|
4
|
+
|
|
5
|
+
function msg(id: string, content = id): ChatMessage {
|
|
6
|
+
return { id, role: "assistant", content, createdAt: "2026-01-01T00:00:00.000Z" };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("mergeRehydratedMessages (#194-B1)", () => {
|
|
10
|
+
it("seeds the full history when nothing is live yet", () => {
|
|
11
|
+
const history = [msg("a"), msg("b"), msg("c")];
|
|
12
|
+
expect(mergeRehydratedMessages([], history).map((m) => m.id)).toEqual(["a", "b", "c"]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("does NOT drop history when SSE has already seeded a recent tail", () => {
|
|
16
|
+
// The regression: refresh → SSE ring buffer delivers only the last 2 msgs
|
|
17
|
+
// before history (the full log) lands. Old guard kept just these two.
|
|
18
|
+
const sseTail = [msg("y"), msg("z")];
|
|
19
|
+
const fullHistory = [msg("v"), msg("w"), msg("x"), msg("y"), msg("z")];
|
|
20
|
+
const merged = mergeRehydratedMessages(sseTail, fullHistory);
|
|
21
|
+
// Full history is restored, in order, with no duplicates.
|
|
22
|
+
expect(merged.map((m) => m.id)).toEqual(["v", "w", "x", "y", "z"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("preserves live-only messages history does not contain (optimistic/newer)", () => {
|
|
26
|
+
const live = [msg("x"), msg("optimistic-1"), msg("newer-2")];
|
|
27
|
+
const history = [msg("w"), msg("x")];
|
|
28
|
+
const merged = mergeRehydratedMessages(live, history);
|
|
29
|
+
// History base first, then the live-only tail in its original order.
|
|
30
|
+
expect(merged.map((m) => m.id)).toEqual(["w", "x", "optimistic-1", "newer-2"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("dedupes by id (history wins the shared ids)", () => {
|
|
34
|
+
const live = [msg("a", "stale"), msg("b", "live-only")];
|
|
35
|
+
const history = [msg("a", "canonical")];
|
|
36
|
+
const merged = mergeRehydratedMessages(live, history);
|
|
37
|
+
expect(merged.map((m) => m.id)).toEqual(["a", "b"]);
|
|
38
|
+
expect(merged.find((m) => m.id === "a")!.content).toBe("canonical");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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";
|
|
@@ -34,6 +34,14 @@ export function PromptComposer() {
|
|
|
34
34
|
const commandsRef = useRef<HTMLDivElement | null>(null);
|
|
35
35
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
36
36
|
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
|
37
|
+
// #47: file upload — names of files uploaded into the workspace this turn,
|
|
38
|
+
// shown as removable chips and announced to the agent on send. (Restored: the
|
|
39
|
+
// backend upload chain — writeFile route + #60 staging/drain — was always
|
|
40
|
+
// present; only this composer UI was removed in #160. It now lives in the
|
|
41
|
+
// left tool cluster, not the send cluster guarded by composerSendTools.test.)
|
|
42
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
43
|
+
const [attachments, setAttachments] = useState<string[]>([]);
|
|
44
|
+
const [uploading, setUploading] = useState(false);
|
|
37
45
|
const { status: sandboxStatus, currentSandbox, reloadConfig } = useSandbox();
|
|
38
46
|
const [composerError, setComposerError] = useState<string | null>(null);
|
|
39
47
|
const { currentSession, messages, isSending, error, sendPrompt, isConnected, isDraft, agents, runActive, agentFilters, interruptCurrent, respondToInput, messageFilters } = useSessions();
|
|
@@ -220,17 +228,53 @@ export function PromptComposer() {
|
|
|
220
228
|
return;
|
|
221
229
|
}
|
|
222
230
|
draftStore.set(sessionId, "");
|
|
231
|
+
// #47: if files were uploaded this turn, prepend a notice so the agent knows
|
|
232
|
+
// they exist in its workspace and can `read` them. Cleared after send.
|
|
233
|
+
const notice =
|
|
234
|
+
attachments.length > 0 ? `${t("chat.upload.notice", { names: attachments.join(", ") })}\n\n` : "";
|
|
235
|
+
const sentAttachments = attachments;
|
|
236
|
+
if (attachments.length > 0) setAttachments([]);
|
|
223
237
|
// Carry the chosen provider/model so a freshly-created session records its
|
|
224
238
|
// per-session selection (no-op for an already-running session).
|
|
225
|
-
const ok = await sendPrompt(content
|
|
239
|
+
const ok = await sendPrompt(`${notice}${content}`, {
|
|
226
240
|
providerId: activeProvider?.id,
|
|
227
241
|
modelId: selectedModel || undefined,
|
|
228
242
|
});
|
|
229
243
|
// #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.
|
|
244
|
+
// Restore the draft (and attachment chips) so they can retry without
|
|
245
|
+
// retyping. Only restore if they haven't already started typing again.
|
|
246
|
+
if (!ok) {
|
|
247
|
+
if (draftStore.get(sessionId).trim().length === 0) {
|
|
248
|
+
draftStore.set(sessionId, content);
|
|
249
|
+
}
|
|
250
|
+
if (sentAttachments.length > 0) {
|
|
251
|
+
setAttachments((prev) => (prev.length === 0 ? sentAttachments : prev));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// #47: upload the chosen files into the session workspace, then track their
|
|
257
|
+
// names as chips. In single-user mode the sandbox id and session id are the
|
|
258
|
+
// same; a draft has no real session yet, so uploads land in the `"local"`
|
|
259
|
+
// staging area and the runtime drains them into the real workspace on send
|
|
260
|
+
// (#60 drainLocalUploads). Files are uploaded to the workspace root by name.
|
|
261
|
+
const handleFilesChosen = async (files: FileList | null) => {
|
|
262
|
+
if (!files || files.length === 0) return;
|
|
263
|
+
const uploadId = currentSession?.id ?? currentSandbox?.id;
|
|
264
|
+
if (!uploadId) return;
|
|
265
|
+
setUploading(true);
|
|
266
|
+
setComposerError(null);
|
|
267
|
+
try {
|
|
268
|
+
for (const file of Array.from(files)) {
|
|
269
|
+
await api.sandbox.uploadFile(uploadId, file.name, file);
|
|
270
|
+
setAttachments((prev) => (prev.includes(file.name) ? prev : [...prev, file.name]));
|
|
271
|
+
}
|
|
272
|
+
} catch (e) {
|
|
273
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
274
|
+
setComposerError(t("chat.upload.failed", { msg }));
|
|
275
|
+
} finally {
|
|
276
|
+
setUploading(false);
|
|
277
|
+
if (fileInputRef.current) fileInputRef.current.value = ""; // allow re-selecting the same file
|
|
234
278
|
}
|
|
235
279
|
};
|
|
236
280
|
|
|
@@ -288,6 +332,26 @@ export function PromptComposer() {
|
|
|
288
332
|
ariaLabel={t("chat.srAsk")}
|
|
289
333
|
/>
|
|
290
334
|
|
|
335
|
+
{attachments.length > 0 || uploading ? (
|
|
336
|
+
<div className="composer__attachments" aria-label={t("chat.aria.attachFile")}>
|
|
337
|
+
{attachments.map((name) => (
|
|
338
|
+
<span className="composer__chip" key={name}>
|
|
339
|
+
<Paperclip size={12} />
|
|
340
|
+
<span className="composer__chip-name">{name}</span>
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
className="composer__chip-remove"
|
|
344
|
+
aria-label={t("chat.aria.removeAttachment")}
|
|
345
|
+
onClick={() => setAttachments((prev) => prev.filter((n) => n !== name))}
|
|
346
|
+
>
|
|
347
|
+
<X size={12} />
|
|
348
|
+
</button>
|
|
349
|
+
</span>
|
|
350
|
+
))}
|
|
351
|
+
{uploading ? <span className="composer__chip composer__chip--pending">{t("chat.upload.uploading")}</span> : null}
|
|
352
|
+
</div>
|
|
353
|
+
) : null}
|
|
354
|
+
|
|
291
355
|
<div className="composer__toolbar">
|
|
292
356
|
<div className="composer__tools">
|
|
293
357
|
{/*
|
|
@@ -298,6 +362,27 @@ export function PromptComposer() {
|
|
|
298
362
|
<Plus size={18} />
|
|
299
363
|
</IconButton>
|
|
300
364
|
*/}
|
|
365
|
+
{/*
|
|
366
|
+
#47: file upload. The button lives here in the left tool cluster
|
|
367
|
+
(not the send cluster, which composerSendTools.test.tsx guards
|
|
368
|
+
against an upload control under #160). The hidden <input> is
|
|
369
|
+
clicked programmatically; chosen files upload to the workspace
|
|
370
|
+
root and are announced to the agent on send.
|
|
371
|
+
*/}
|
|
372
|
+
<input
|
|
373
|
+
ref={fileInputRef}
|
|
374
|
+
type="file"
|
|
375
|
+
multiple
|
|
376
|
+
style={{ display: "none" }}
|
|
377
|
+
onChange={(e) => void handleFilesChosen(e.target.files)}
|
|
378
|
+
/>
|
|
379
|
+
<IconButton
|
|
380
|
+
label={t("chat.aria.attachFile")}
|
|
381
|
+
onClick={() => fileInputRef.current?.click()}
|
|
382
|
+
disabled={uploading || !currentSandbox}
|
|
383
|
+
>
|
|
384
|
+
<Paperclip size={17} />
|
|
385
|
+
</IconButton>
|
|
301
386
|
{SHOW_SLASH_COMMANDS && slashCommands.length > 0 && (
|
|
302
387
|
<div className="command-picker" ref={commandsRef}>
|
|
303
388
|
<IconButton
|
|
@@ -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) {
|
|
@@ -13,9 +13,9 @@ import {
|
|
|
13
13
|
X,
|
|
14
14
|
} from "lucide-react";
|
|
15
15
|
import { FileContent, FileEntry } from "../../contracts/backend";
|
|
16
|
+
import { runtimeConfig } from "../../config";
|
|
16
17
|
import { useSandbox } from "../../contexts/SandboxContext";
|
|
17
18
|
import { useSessions } from "../../contexts/SessionContext";
|
|
18
|
-
import { runtimeConfig } from "../../config";
|
|
19
19
|
import { useT } from "../../i18n/useT";
|
|
20
20
|
import { api } from "../../utils/api";
|
|
21
21
|
import { downloadBlob } from "../../utils/download";
|
|
@@ -128,10 +128,14 @@ function findNode(root: FileNode, path: string | null): FileNode | null {
|
|
|
128
128
|
export function FileSidebar({ isOpen, onClose, onResize, onResizeEnd, onResizeStart, width }: FileSidebarProps) {
|
|
129
129
|
const { currentSandbox } = useSandbox();
|
|
130
130
|
const { currentSession } = useSessions();
|
|
131
|
-
//
|
|
132
|
-
// id
|
|
133
|
-
//
|
|
134
|
-
|
|
131
|
+
// The runtime always addresses a workspace by session id (workspaces/<sid>/),
|
|
132
|
+
// never by container id — in both local and remote mode. A container can host
|
|
133
|
+
// several sessions, and the file tree shows the *current session's* workspace.
|
|
134
|
+
// (#168) `currentSandbox.status` still gates whether files are live; the
|
|
135
|
+
// variable name stays `sandboxId` only because the call sites/sub-component
|
|
136
|
+
// prop are named that way — it has always carried the session id in local
|
|
137
|
+
// mode. A full rename rides with the planned session-management cleanup.
|
|
138
|
+
const sandboxId = currentSession?.id ?? null;
|
|
135
139
|
const t = useT();
|
|
136
140
|
const [tree, setTree] = useState<FileNode>(rootNode);
|
|
137
141
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["/workspace"]));
|
|
@@ -144,18 +148,82 @@ export function FileSidebar({ isOpen, onClose, onResize, onResizeEnd, onResizeSt
|
|
|
144
148
|
const [isPreviewMaximized, setIsPreviewMaximized] = useState(false);
|
|
145
149
|
const resizeStartRef = useRef<{ pointerX: number; width: number } | null>(null);
|
|
146
150
|
|
|
151
|
+
// #156: in local mode, surface the real on-disk workspace dir so users know
|
|
152
|
+
// which directory the agent writes into. `workspacesRoot` comes from the
|
|
153
|
+
// backend (gated to local mode there too); the per-session dir is
|
|
154
|
+
// `<workspacesRoot>/<sessionId>`. Null in hosted mode → keep showing the
|
|
155
|
+
// virtual `/workspace` and never disclose a host path.
|
|
156
|
+
const [workspacesRoot, setWorkspacesRoot] = useState<string | null>(null);
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!runtimeConfig.localMode) return;
|
|
159
|
+
let cancelled = false;
|
|
160
|
+
void api.getInfo().then((info) => {
|
|
161
|
+
if (!cancelled && info.localMode && info.workspacesRoot) {
|
|
162
|
+
setWorkspacesRoot(info.workspacesRoot);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
return () => {
|
|
166
|
+
cancelled = true;
|
|
167
|
+
};
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
// Join with the platform's separator: a Windows root contains "\", a POSIX
|
|
171
|
+
// root "/". Detect from the root itself rather than assuming the host.
|
|
172
|
+
const realWorkspacePath = useMemo(() => {
|
|
173
|
+
if (!workspacesRoot || !currentSession?.id) return null;
|
|
174
|
+
const sepChar = workspacesRoot.includes("\\") && !workspacesRoot.includes("/") ? "\\" : "/";
|
|
175
|
+
return `${workspacesRoot.replace(/[\\/]$/, "")}${sepChar}${currentSession.id}`;
|
|
176
|
+
}, [workspacesRoot, currentSession?.id]);
|
|
177
|
+
|
|
178
|
+
// Map a virtual `/workspace[/...]` path to its real on-disk equivalent for
|
|
179
|
+
// display. Returns the original virtual path when no real root is known.
|
|
180
|
+
const toDisplayPath = useCallback(
|
|
181
|
+
(virtualPath: string): string => {
|
|
182
|
+
if (!realWorkspacePath) return virtualPath;
|
|
183
|
+
const sepChar = realWorkspacePath.includes("\\") && !realWorkspacePath.includes("/") ? "\\" : "/";
|
|
184
|
+
if (virtualPath === "/workspace") return realWorkspacePath;
|
|
185
|
+
if (virtualPath.startsWith("/workspace/")) {
|
|
186
|
+
const rel = virtualPath.slice("/workspace/".length).split("/").join(sepChar);
|
|
187
|
+
return `${realWorkspacePath}${sepChar}${rel}`;
|
|
188
|
+
}
|
|
189
|
+
return virtualPath;
|
|
190
|
+
},
|
|
191
|
+
[realWorkspacePath],
|
|
192
|
+
);
|
|
193
|
+
|
|
147
194
|
const loadDirectory = useCallback(
|
|
148
195
|
async (path: string) => {
|
|
149
196
|
if (!currentSandbox || currentSandbox.status !== "running" || !sandboxId) {
|
|
197
|
+
// #193 diagnostics: distinguish "panel gated off" from "listed but empty".
|
|
198
|
+
// Logs the exact reason the gate blocked the load so a user (esp. on
|
|
199
|
+
// Windows, where the empty-panel report originates) can paste it back.
|
|
200
|
+
console.warn("[FileSidebar] load skipped — sandbox not ready", {
|
|
201
|
+
path,
|
|
202
|
+
sandboxId,
|
|
203
|
+
hasSandbox: !!currentSandbox,
|
|
204
|
+
sandboxStatus: currentSandbox?.status ?? null,
|
|
205
|
+
});
|
|
150
206
|
setError(t("files.error.notRunning"));
|
|
151
207
|
return;
|
|
152
208
|
}
|
|
153
209
|
setError(null);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
210
|
+
try {
|
|
211
|
+
// #193 diagnostics: log the exact request being addressed so an empty or
|
|
212
|
+
// failing listing can be traced to the real sandboxId + path on the wire.
|
|
213
|
+
console.debug("[FileSidebar] listFiles", { sandboxId, path });
|
|
214
|
+
const entries = await api.sandbox.listFiles(sandboxId, path);
|
|
215
|
+
console.debug("[FileSidebar] listFiles ok", { sandboxId, path, count: entries.length });
|
|
216
|
+
const children = entries.map((entry) => ({ ...entry, path: joinPath(path, entry.name) }));
|
|
217
|
+
setTree((current) => updateNode(current, path, (node) => ({ ...node, children, loaded: true })));
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// The runtime now returns a distinct error (instead of an empty array)
|
|
220
|
+
// when readdir fails for a reason other than ENOENT (#193). Surface it
|
|
221
|
+
// rather than leaving the panel stuck loading with no feedback.
|
|
222
|
+
console.error("[FileSidebar] listFiles failed", { sandboxId, path, error: err });
|
|
223
|
+
setError(err instanceof Error ? err.message : t("files.error.loadFailed"));
|
|
224
|
+
}
|
|
157
225
|
},
|
|
158
|
-
[currentSandbox, sandboxId],
|
|
226
|
+
[currentSandbox, sandboxId, t],
|
|
159
227
|
);
|
|
160
228
|
|
|
161
229
|
useEffect(() => {
|
|
@@ -443,7 +511,7 @@ export function FileSidebar({ isOpen, onClose, onResize, onResizeEnd, onResizeSt
|
|
|
443
511
|
</header>
|
|
444
512
|
|
|
445
513
|
<div className="file-sidebar__path">
|
|
446
|
-
<span
|
|
514
|
+
<span title={realWorkspacePath ?? "/workspace"}>{realWorkspacePath ?? "/workspace"}</span>
|
|
447
515
|
<small>{currentSandbox?.status === "running" ? t("files.live") : t("files.offline")}</small>
|
|
448
516
|
</div>
|
|
449
517
|
|
|
@@ -463,6 +531,7 @@ export function FileSidebar({ isOpen, onClose, onResize, onResizeEnd, onResizeSt
|
|
|
463
531
|
setIsPreviewMaximized(false);
|
|
464
532
|
}}
|
|
465
533
|
sandboxId={sandboxId}
|
|
534
|
+
toDisplayPath={toDisplayPath}
|
|
466
535
|
onToggleMaximize={() => setIsPreviewMaximized((current) => !current)}
|
|
467
536
|
/>
|
|
468
537
|
</>
|
|
@@ -475,6 +544,7 @@ function FilePreviewPanel({
|
|
|
475
544
|
isMaximized,
|
|
476
545
|
onClose,
|
|
477
546
|
sandboxId,
|
|
547
|
+
toDisplayPath,
|
|
478
548
|
onToggleMaximize,
|
|
479
549
|
}: {
|
|
480
550
|
file: FileNode | null;
|
|
@@ -482,6 +552,7 @@ function FilePreviewPanel({
|
|
|
482
552
|
isMaximized: boolean;
|
|
483
553
|
onClose: () => void;
|
|
484
554
|
sandboxId: string | null;
|
|
555
|
+
toDisplayPath: (virtualPath: string) => string;
|
|
485
556
|
onToggleMaximize: () => void;
|
|
486
557
|
}) {
|
|
487
558
|
const t = useT();
|
|
@@ -634,7 +705,7 @@ function FilePreviewPanel({
|
|
|
634
705
|
<dl className="file-preview__meta">
|
|
635
706
|
<div>
|
|
636
707
|
<dt>{t("files.preview.path")}</dt>
|
|
637
|
-
<dd>{file.path}</dd>
|
|
708
|
+
<dd>{toDisplayPath(file.path)}</dd>
|
|
638
709
|
</div>
|
|
639
710
|
<div>
|
|
640
711
|
<dt>{t("files.preview.size")}</dt>
|