@brainpilot/web 0.0.7 → 0.0.8

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/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-j3rGyO6m.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-162Pskp8.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.7",
3
+ "version": "0.0.8",
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.7",
34
+ "@brainpilot/protocol": "^0.0.8",
35
35
  "@fontsource-variable/geist": "^5.2.9",
36
36
  "@fontsource-variable/geist-mono": "^5.2.8",
37
37
  "@types/react": "^18.3.12",
@@ -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,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,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
+ }
@@ -1,4 +1,4 @@
1
- import { Bot, Paperclip, Square, X } from "lucide-react";
1
+ import { Bot, Square } 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";
@@ -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() {
@@ -33,11 +34,6 @@ export function PromptComposer() {
33
34
  const commandsRef = useRef<HTMLDivElement | null>(null);
34
35
  const menuRef = useRef<HTMLDivElement | null>(null);
35
36
  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);
41
37
  const { status: sandboxStatus, currentSandbox, reloadConfig } = useSandbox();
42
38
  const [composerError, setComposerError] = useState<string | null>(null);
43
39
  const { currentSession, messages, isSending, error, sendPrompt, isConnected, isDraft, agents, runActive, agentFilters, interruptCurrent, respondToInput, messageFilters } = useSessions();
@@ -224,50 +220,17 @@ export function PromptComposer() {
224
220
  return;
225
221
  }
226
222
  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([]);
233
223
  // Carry the chosen provider/model so a freshly-created session records its
234
224
  // per-session selection (no-op for an already-running session).
235
- const ok = await sendPrompt(`${notice}${content}`, {
225
+ const ok = await sendPrompt(content, {
236
226
  providerId: activeProvider?.id,
237
227
  modelId: selectedModel || undefined,
238
228
  });
239
229
  // #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
230
+ // Restore the draft so they can retry without retyping. Only restore if they
231
+ // haven't already started typing again.
232
+ if (!ok && draftStore.get(sessionId).trim().length === 0) {
233
+ draftStore.set(sessionId, content);
271
234
  }
272
235
  };
273
236
 
@@ -325,26 +288,6 @@ export function PromptComposer() {
325
288
  ariaLabel={t("chat.srAsk")}
326
289
  />
327
290
 
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
-
348
291
  <div className="composer__toolbar">
349
292
  <div className="composer__tools">
350
293
  {/*
@@ -389,76 +332,64 @@ export function PromptComposer() {
389
332
  )}
390
333
  </div>
391
334
 
392
- <div className="composer__send-tools">
393
- <CustomSelect
394
- ariaLabel={t("chat.modelPlaceholder")}
395
- className="model-select"
396
- disabled={!currentSandbox || !activeProvider || activeProvider.models.length === 0}
397
- onChange={async (model) => {
398
- setSelectedModel(model);
399
- setComposerError(null);
400
- try {
401
- await api.settings.update({ model });
402
- } catch (e) {
403
- const msg = e instanceof Error ? e.message : String(e);
404
- console.error("Failed to save model selection", e);
405
- setComposerError(t("chat.error.saveModel", { msg }));
406
- return;
407
- }
408
- try {
409
- await reloadConfig();
410
- } catch (e) {
411
- const msg = e instanceof Error ? e.message : String(e);
412
- console.error("Failed to reload config after model change", e);
413
- setComposerError(t("chat.error.reloadConfig", { msg }));
414
- }
415
- }}
416
- options={activeProvider?.models.map((model) => {
417
- const mh = activeProvider.modelHealth?.find((m) => m.model === model);
418
- const status = mh?.status ?? "unknown";
419
- return {
420
- value: model,
421
- label: model,
422
- indicator: (
423
- <span
424
- className={`model-status-dot model-status-dot--${status}`}
425
- title={mh?.error ?? status}
426
- />
427
- ),
428
- };
429
- }) ?? []}
430
- placeholder={t("chat.modelPlaceholder")}
431
- title={activeProvider ? t("chat.providerTitle", { name: activeProvider.name }) : t("chat.noActiveProvider")}
432
- value={selectedModel}
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.
438
- <IconButton label={t("chat.aria.voice")}>
439
- <Mic size={17} />
440
- </IconButton>
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
- >
454
- <Paperclip size={17} />
455
- </IconButton>
456
- <ComposerSendButton
457
- sessionId={sessionId}
458
- canSend={canSend}
459
- label={t("chat.aria.send")}
460
- />
461
- </div>
335
+ {/*
336
+ issue #47: 语音输入 (Mic) had no capture/permission flow and was
337
+ never shipped; #160 removed the file-upload (Paperclip) button that
338
+ also lived in this cluster (upload was never a supported feature).
339
+ The send cluster is now just the model picker + send button.
340
+ */}
341
+ <ComposerSendTools
342
+ modelSelect={
343
+ <CustomSelect
344
+ ariaLabel={t("chat.modelPlaceholder")}
345
+ className="model-select"
346
+ disabled={!currentSandbox || !activeProvider || activeProvider.models.length === 0}
347
+ onChange={async (model) => {
348
+ setSelectedModel(model);
349
+ setComposerError(null);
350
+ try {
351
+ await api.settings.update({ model });
352
+ } catch (e) {
353
+ const msg = e instanceof Error ? e.message : String(e);
354
+ console.error("Failed to save model selection", e);
355
+ setComposerError(t("chat.error.saveModel", { msg }));
356
+ return;
357
+ }
358
+ try {
359
+ await reloadConfig();
360
+ } catch (e) {
361
+ const msg = e instanceof Error ? e.message : String(e);
362
+ console.error("Failed to reload config after model change", e);
363
+ setComposerError(t("chat.error.reloadConfig", { msg }));
364
+ }
365
+ }}
366
+ options={activeProvider?.models.map((model) => {
367
+ const mh = activeProvider.modelHealth?.find((m) => m.model === model);
368
+ const status = mh?.status ?? "unknown";
369
+ return {
370
+ value: model,
371
+ label: model,
372
+ indicator: (
373
+ <span
374
+ className={`model-status-dot model-status-dot--${status}`}
375
+ title={mh?.error ?? status}
376
+ />
377
+ ),
378
+ };
379
+ }) ?? []}
380
+ placeholder={t("chat.modelPlaceholder")}
381
+ title={activeProvider ? t("chat.providerTitle", { name: activeProvider.name }) : t("chat.noActiveProvider")}
382
+ value={selectedModel}
383
+ />
384
+ }
385
+ sendButton={
386
+ <ComposerSendButton
387
+ sessionId={sessionId}
388
+ canSend={canSend}
389
+ label={t("chat.aria.send")}
390
+ />
391
+ }
392
+ />
462
393
  </div>
463
394
 
464
395
  </form>
@@ -17,9 +17,7 @@ import { SandboxStatus } from "./SandboxStatus";
17
17
  import { Sidebar } from "../sidebar/Sidebar";
18
18
  import { DiskQuotaWarningDialog } from "../quota/DiskQuotaWarningDialog";
19
19
  import { DiskQuotaCriticalDialog } from "../quota/DiskQuotaCriticalDialog";
20
-
21
- const MIN_SIDEBAR_WIDTH = 220;
22
- const MAX_SIDEBAR_WIDTH = 420;
20
+ import { DEFAULT_SIDEBAR_WIDTH, resolveResize } from "./sidebarResize";
23
21
 
24
22
  export function DesktopShell() {
25
23
  const { isAuthReady } = useAuth();
@@ -87,12 +85,21 @@ export function DesktopShell() {
87
85
  return;
88
86
  }
89
87
 
88
+ // #159 — drag the edge left past the collapse threshold and the rail snaps
89
+ // to the icon rail; otherwise apply the clamped expanded width. resolveResize
90
+ // owns the geometry (pure + unit-tested in sidebarResize.test.ts).
90
91
  const delta = event.clientX - sidebarResizeRef.current.pointerX;
91
- const nextWidth = Math.max(
92
- MIN_SIDEBAR_WIDTH,
93
- Math.min(MAX_SIDEBAR_WIDTH, sidebarResizeRef.current.width + delta),
94
- );
95
- setSidebarWidth(nextWidth);
92
+ const outcome = resolveResize(sidebarResizeRef.current.width + delta);
93
+ if (outcome.collapse) {
94
+ setUserCollapsed(true);
95
+ sidebarResizeRef.current = null;
96
+ setIsSidebarResizing(false);
97
+ // Restore a sensible width so expanding again (toggle / drag) isn't stuck
98
+ // at the collapsed remnant.
99
+ setSidebarWidth(DEFAULT_SIDEBAR_WIDTH);
100
+ return;
101
+ }
102
+ setSidebarWidth(outcome.width);
96
103
  };
97
104
 
98
105
  const handlePointerUp = () => {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * sidebarResize.ts — pure geometry for the sidebar resize→collapse interaction
3
+ * (#159). Kept free of React so it can be unit-tested without jsdom (the
4
+ * monorepo has no jsdom/@testing-library; DesktopShell drives the real
5
+ * pointer events, these helpers decide the numbers).
6
+ *
7
+ * Behaviour: while dragging the sidebar's right edge leftward, once the would-be
8
+ * width crosses a collapse threshold that sits *below* the normal minimum, the
9
+ * rail snaps to the collapsed icon rail (rather than refusing to shrink past the
10
+ * minimum, which is what made drag-to-collapse impossible before #159).
11
+ */
12
+
13
+ /** Normal drag bounds — the sidebar clamps here while it stays expanded. */
14
+ export const MIN_SIDEBAR_WIDTH = 220;
15
+ export const MAX_SIDEBAR_WIDTH = 420;
16
+
17
+ /**
18
+ * Drag the edge below this (well under MIN, giving a deliberate "drag past the
19
+ * min a bit more" buffer so a normal min-width drag doesn't accidentally
20
+ * collapse) and the rail snaps shut.
21
+ */
22
+ export const COLLAPSE_THRESHOLD = 160;
23
+
24
+ /** Width the rail restores to when it expands again (matches the default). */
25
+ export const DEFAULT_SIDEBAR_WIDTH = 268;
26
+
27
+ export interface ResizeOutcome {
28
+ /** Clamped width to apply while expanded (ignored when collapse is true). */
29
+ width: number;
30
+ /** True when the drag has gone narrow enough to collapse to the icon rail. */
31
+ collapse: boolean;
32
+ }
33
+
34
+ /**
35
+ * Given the drag's raw proposed width (start width + pointer delta), decide
36
+ * whether to collapse and, if not, the clamped expanded width.
37
+ *
38
+ * - proposed <= COLLAPSE_THRESHOLD → collapse.
39
+ * - otherwise clamp into [MIN, MAX].
40
+ */
41
+ export function resolveResize(proposedWidth: number): ResizeOutcome {
42
+ if (proposedWidth <= COLLAPSE_THRESHOLD) {
43
+ return { width: MIN_SIDEBAR_WIDTH, collapse: true };
44
+ }
45
+ return {
46
+ width: Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, proposedWidth)),
47
+ collapse: false,
48
+ };
49
+ }