@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/assets/{index-j3rGyO6m.js → index-162Pskp8.js} +104 -111
- package/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/__tests__/composerSendTools.test.tsx +37 -0
- package/src/__tests__/sidebarResize.test.ts +46 -0
- package/src/components/chat/ComposerSendTools.tsx +31 -0
- package/src/components/chat/PromptComposer.tsx +65 -134
- package/src/components/shell/DesktopShell.tsx +15 -8
- package/src/components/shell/sidebarResize.ts +49 -0
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-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.
|
|
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.
|
|
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,
|
|
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(
|
|
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
|
|
241
|
-
//
|
|
242
|
-
if (!ok) {
|
|
243
|
-
|
|
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
|
-
|
|
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>
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|