@brainpilot/web 0.0.7 → 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__/composerSendTools.test.tsx +37 -0
- package/src/__tests__/demoTruncatedExport.test.ts +104 -0
- package/src/__tests__/rehydrateMerge.test.ts +40 -0
- package/src/__tests__/sidebarResize.test.ts +46 -0
- package/src/__tests__/timelineBounds.test.ts +51 -0
- package/src/components/chat/ComposerSendTools.tsx +31 -0
- package/src/components/chat/PromptComposer.tsx +91 -75
- 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/shell/DesktopShell.tsx +15 -8
- package/src/components/shell/sidebarResize.ts +49 -0
- 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-j3rGyO6m.js +0 -445
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,37 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { ComposerSendTools } from "../components/chat/ComposerSendTools";
|
|
4
|
+
|
|
5
|
+
// No jsdom/@testing-library in the monorepo, so we render the presentational
|
|
6
|
+
// send cluster to static markup and assert on the output. This guards #160:
|
|
7
|
+
// the file-upload (Paperclip) button + hidden <input type="file"> were removed
|
|
8
|
+
// from the composer's send cluster because upload was never a supported
|
|
9
|
+
// feature. Anyone re-adding an upload control here makes this test fail.
|
|
10
|
+
describe("ComposerSendTools — #160 no file-upload control", () => {
|
|
11
|
+
const markup = () =>
|
|
12
|
+
renderToStaticMarkup(
|
|
13
|
+
<ComposerSendTools
|
|
14
|
+
modelSelect={<div className="model-select">model</div>}
|
|
15
|
+
sendButton={<button type="submit">send</button>}
|
|
16
|
+
/>,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
it("renders the passed-in model picker and send button", () => {
|
|
20
|
+
const html = markup();
|
|
21
|
+
expect(html).toContain("composer__send-tools");
|
|
22
|
+
expect(html).toContain("model-select");
|
|
23
|
+
expect(html).toContain("send");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("renders no file input (upload removed)", () => {
|
|
27
|
+
const html = markup();
|
|
28
|
+
expect(html).not.toContain('type="file"');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders only the two nodes it is given — no extra upload button", () => {
|
|
32
|
+
// The cluster owns no controls of its own; it only lays out what the parent
|
|
33
|
+
// passes. A stray <input>/upload button would mean upload crept back in.
|
|
34
|
+
const html = markup();
|
|
35
|
+
expect(html).not.toContain("<input");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -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,46 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
resolveResize,
|
|
4
|
+
MIN_SIDEBAR_WIDTH,
|
|
5
|
+
MAX_SIDEBAR_WIDTH,
|
|
6
|
+
COLLAPSE_THRESHOLD,
|
|
7
|
+
DEFAULT_SIDEBAR_WIDTH,
|
|
8
|
+
} from "../components/shell/sidebarResize";
|
|
9
|
+
|
|
10
|
+
// #159 — drag-to-collapse geometry. The monorepo has no jsdom, so the real
|
|
11
|
+
// pointer-drag is exercised by DesktopShell at runtime; here we pin the pure
|
|
12
|
+
// decision: when does a drag collapse the rail, and how is width clamped.
|
|
13
|
+
describe("resolveResize — #159 drag-to-collapse", () => {
|
|
14
|
+
it("collapses when dragged at/below the collapse threshold", () => {
|
|
15
|
+
expect(resolveResize(COLLAPSE_THRESHOLD).collapse).toBe(true);
|
|
16
|
+
expect(resolveResize(COLLAPSE_THRESHOLD - 1).collapse).toBe(true);
|
|
17
|
+
expect(resolveResize(0).collapse).toBe(true);
|
|
18
|
+
expect(resolveResize(-50).collapse).toBe(true); // dragged past the left edge
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("does NOT collapse between the threshold and the minimum (buffer zone)", () => {
|
|
22
|
+
// Sitting at the min width is a normal narrow drag, not a collapse intent.
|
|
23
|
+
expect(resolveResize(MIN_SIDEBAR_WIDTH).collapse).toBe(false);
|
|
24
|
+
expect(resolveResize(COLLAPSE_THRESHOLD + 1).collapse).toBe(false);
|
|
25
|
+
expect(resolveResize(200).collapse).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("clamps expanded width into [MIN, MAX]", () => {
|
|
29
|
+
// Above threshold but below min → clamp up to min (still expanded).
|
|
30
|
+
expect(resolveResize(190)).toEqual({ width: MIN_SIDEBAR_WIDTH, collapse: false });
|
|
31
|
+
// In range → passthrough.
|
|
32
|
+
expect(resolveResize(300)).toEqual({ width: 300, collapse: false });
|
|
33
|
+
// Above max → clamp down.
|
|
34
|
+
expect(resolveResize(999)).toEqual({ width: MAX_SIDEBAR_WIDTH, collapse: false });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("threshold sits below the minimum so a min-width drag never collapses", () => {
|
|
38
|
+
expect(COLLAPSE_THRESHOLD).toBeLessThan(MIN_SIDEBAR_WIDTH);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("default restore width is a valid expanded width", () => {
|
|
42
|
+
expect(DEFAULT_SIDEBAR_WIDTH).toBeGreaterThanOrEqual(MIN_SIDEBAR_WIDTH);
|
|
43
|
+
expect(DEFAULT_SIDEBAR_WIDTH).toBeLessThanOrEqual(MAX_SIDEBAR_WIDTH);
|
|
44
|
+
expect(resolveResize(DEFAULT_SIDEBAR_WIDTH).collapse).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ComposerSendTools — presentational layout for the composer's right-hand send
|
|
5
|
+
* cluster (model picker + send button). Extracted from PromptComposer so the
|
|
6
|
+
* cluster can be rendered in isolation under react-dom/server (the monorepo has
|
|
7
|
+
* no jsdom/@testing-library). The stateful pieces — the model `CustomSelect`
|
|
8
|
+
* with its async onChange, the `ComposerSendButton` — are built by the parent
|
|
9
|
+
* and passed in as nodes; this component owns only the wrapper markup.
|
|
10
|
+
*
|
|
11
|
+
* #160: the file-upload (Paperclip) button used to live here and was removed —
|
|
12
|
+
* file upload was never a supported feature (it depended on a sandbox that the
|
|
13
|
+
* local non-Docker mode never provides). ComposerSendTools.test.tsx asserts the
|
|
14
|
+
* rendered cluster contains no file input, guarding against it creeping back.
|
|
15
|
+
*/
|
|
16
|
+
export function ComposerSendTools({
|
|
17
|
+
modelSelect,
|
|
18
|
+
sendButton,
|
|
19
|
+
}: {
|
|
20
|
+
/** The model picker node (parent builds the stateful CustomSelect). */
|
|
21
|
+
modelSelect: ReactNode;
|
|
22
|
+
/** The send button node. */
|
|
23
|
+
sendButton: ReactNode;
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="composer__send-tools">
|
|
27
|
+
{modelSelect}
|
|
28
|
+
{sendButton}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -13,6 +13,7 @@ import { CustomSelect } from "../primitives/CustomSelect";
|
|
|
13
13
|
import { IconButton } from "../primitives/IconButton";
|
|
14
14
|
import { ComposerInput } from "./ComposerInput";
|
|
15
15
|
import { ComposerSendButton } from "./ComposerSendButton";
|
|
16
|
+
import { ComposerSendTools } from "./ComposerSendTools";
|
|
16
17
|
import { MessageStream } from "./MessageStream";
|
|
17
18
|
|
|
18
19
|
export function PromptComposer() {
|
|
@@ -34,7 +35,10 @@ export function PromptComposer() {
|
|
|
34
35
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
35
36
|
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
|
36
37
|
// #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
|
+
// 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.)
|
|
38
42
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
39
43
|
const [attachments, setAttachments] = useState<string[]>([]);
|
|
40
44
|
const [uploading, setUploading] = useState(false);
|
|
@@ -250,16 +254,19 @@ export function PromptComposer() {
|
|
|
250
254
|
};
|
|
251
255
|
|
|
252
256
|
// #47: upload the chosen files into the session workspace, then track their
|
|
253
|
-
// names as chips.
|
|
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.
|
|
254
261
|
const handleFilesChosen = async (files: FileList | null) => {
|
|
255
262
|
if (!files || files.length === 0) return;
|
|
256
|
-
const
|
|
257
|
-
if (!
|
|
263
|
+
const uploadId = currentSession?.id ?? currentSandbox?.id;
|
|
264
|
+
if (!uploadId) return;
|
|
258
265
|
setUploading(true);
|
|
259
266
|
setComposerError(null);
|
|
260
267
|
try {
|
|
261
268
|
for (const file of Array.from(files)) {
|
|
262
|
-
await api.sandbox.uploadFile(
|
|
269
|
+
await api.sandbox.uploadFile(uploadId, file.name, file);
|
|
263
270
|
setAttachments((prev) => (prev.includes(file.name) ? prev : [...prev, file.name]));
|
|
264
271
|
}
|
|
265
272
|
} catch (e) {
|
|
@@ -355,6 +362,27 @@ export function PromptComposer() {
|
|
|
355
362
|
<Plus size={18} />
|
|
356
363
|
</IconButton>
|
|
357
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>
|
|
358
386
|
{SHOW_SLASH_COMMANDS && slashCommands.length > 0 && (
|
|
359
387
|
<div className="command-picker" ref={commandsRef}>
|
|
360
388
|
<IconButton
|
|
@@ -389,76 +417,64 @@ export function PromptComposer() {
|
|
|
389
417
|
)}
|
|
390
418
|
</div>
|
|
391
419
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
label={t("chat.aria.attachFile")}
|
|
451
|
-
onClick={() => fileInputRef.current?.click()}
|
|
452
|
-
disabled={uploading || !currentSandbox}
|
|
453
|
-
>
|
|
454
|
-
<Paperclip size={17} />
|
|
455
|
-
</IconButton>
|
|
456
|
-
<ComposerSendButton
|
|
457
|
-
sessionId={sessionId}
|
|
458
|
-
canSend={canSend}
|
|
459
|
-
label={t("chat.aria.send")}
|
|
460
|
-
/>
|
|
461
|
-
</div>
|
|
420
|
+
{/*
|
|
421
|
+
issue #47: 语音输入 (Mic) had no capture/permission flow and was
|
|
422
|
+
never shipped; #160 removed the file-upload (Paperclip) button that
|
|
423
|
+
also lived in this cluster (upload was never a supported feature).
|
|
424
|
+
The send cluster is now just the model picker + send button.
|
|
425
|
+
*/}
|
|
426
|
+
<ComposerSendTools
|
|
427
|
+
modelSelect={
|
|
428
|
+
<CustomSelect
|
|
429
|
+
ariaLabel={t("chat.modelPlaceholder")}
|
|
430
|
+
className="model-select"
|
|
431
|
+
disabled={!currentSandbox || !activeProvider || activeProvider.models.length === 0}
|
|
432
|
+
onChange={async (model) => {
|
|
433
|
+
setSelectedModel(model);
|
|
434
|
+
setComposerError(null);
|
|
435
|
+
try {
|
|
436
|
+
await api.settings.update({ model });
|
|
437
|
+
} catch (e) {
|
|
438
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
439
|
+
console.error("Failed to save model selection", e);
|
|
440
|
+
setComposerError(t("chat.error.saveModel", { msg }));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
await reloadConfig();
|
|
445
|
+
} catch (e) {
|
|
446
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
447
|
+
console.error("Failed to reload config after model change", e);
|
|
448
|
+
setComposerError(t("chat.error.reloadConfig", { msg }));
|
|
449
|
+
}
|
|
450
|
+
}}
|
|
451
|
+
options={activeProvider?.models.map((model) => {
|
|
452
|
+
const mh = activeProvider.modelHealth?.find((m) => m.model === model);
|
|
453
|
+
const status = mh?.status ?? "unknown";
|
|
454
|
+
return {
|
|
455
|
+
value: model,
|
|
456
|
+
label: model,
|
|
457
|
+
indicator: (
|
|
458
|
+
<span
|
|
459
|
+
className={`model-status-dot model-status-dot--${status}`}
|
|
460
|
+
title={mh?.error ?? status}
|
|
461
|
+
/>
|
|
462
|
+
),
|
|
463
|
+
};
|
|
464
|
+
}) ?? []}
|
|
465
|
+
placeholder={t("chat.modelPlaceholder")}
|
|
466
|
+
title={activeProvider ? t("chat.providerTitle", { name: activeProvider.name }) : t("chat.noActiveProvider")}
|
|
467
|
+
value={selectedModel}
|
|
468
|
+
/>
|
|
469
|
+
}
|
|
470
|
+
sendButton={
|
|
471
|
+
<ComposerSendButton
|
|
472
|
+
sessionId={sessionId}
|
|
473
|
+
canSend={canSend}
|
|
474
|
+
label={t("chat.aria.send")}
|
|
475
|
+
/>
|
|
476
|
+
}
|
|
477
|
+
/>
|
|
462
478
|
</div>
|
|
463
479
|
|
|
464
480
|
</form>
|
|
@@ -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) {
|