@brainpilot/web 0.0.6 → 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-162Pskp8.js +438 -0
- package/dist/assets/index-DWOsU22G.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +3 -3
- package/src/__tests__/chatScrollBehavior.test.ts +48 -0
- package/src/__tests__/composerSendTools.test.tsx +37 -0
- package/src/__tests__/demoConversation.test.ts +25 -2
- package/src/__tests__/internalToolStrip.test.ts +108 -0
- package/src/__tests__/sidebarResize.test.ts +46 -0
- package/src/components/chat/ComposerSendTools.tsx +31 -0
- package/src/components/chat/MessageStream.tsx +7 -0
- package/src/components/chat/PromptComposer.tsx +65 -134
- package/src/components/demo/DemoView.tsx +11 -4
- package/src/components/shell/DesktopShell.tsx +49 -12
- package/src/components/shell/sidebarResize.ts +49 -0
- package/src/components/sidebar/SessionList.tsx +127 -0
- package/src/components/sidebar/Sidebar.tsx +92 -100
- package/src/contexts/SessionContext.tsx +44 -1
- package/src/contexts/messageGroups.ts +56 -0
- package/src/contexts/messageReducer.ts +4 -0
- package/src/i18n/messages/shell.ts +2 -0
- package/src/styles/global.css +85 -15
- package/dist/assets/index-Br55rkHb.css +0 -1
- package/dist/assets/index-CeUzk-ej.js +0 -445
|
@@ -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
|
+
}
|
|
@@ -166,6 +166,11 @@ function MessageStreamImpl({
|
|
|
166
166
|
const apply = () => {
|
|
167
167
|
const n = stackRef.current;
|
|
168
168
|
if (!n) return;
|
|
169
|
+
// #133 — force an instant jump for the restore. The container CSS no
|
|
170
|
+
// longer sets `scroll-behavior: smooth`, but pin it locally too so a
|
|
171
|
+
// future global rule (or an inherited one) can never turn this restore
|
|
172
|
+
// into a visible top-to-bottom replay through the history.
|
|
173
|
+
n.style.scrollBehavior = "auto";
|
|
169
174
|
n.scrollTop = resolveScrollTop(mem, n.scrollHeight);
|
|
170
175
|
};
|
|
171
176
|
apply();
|
|
@@ -190,6 +195,8 @@ function MessageStreamImpl({
|
|
|
190
195
|
if (!node || !isPinnedRef.current) {
|
|
191
196
|
return;
|
|
192
197
|
}
|
|
198
|
+
// #133 — pinned-bottom live append also jumps instantly (no smooth replay).
|
|
199
|
+
node.style.scrollBehavior = "auto";
|
|
193
200
|
node.scrollTop = node.scrollHeight;
|
|
194
201
|
}, [messages, autoScroll]);
|
|
195
202
|
|
|
@@ -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>
|
|
@@ -55,15 +55,22 @@ function basename(path: string): string {
|
|
|
55
55
|
*
|
|
56
56
|
* Keep: user prompts; assistant/system plain-text replies from ANY agent;
|
|
57
57
|
* error and system_message bubbles (the agent-attributed warnings/alerts the
|
|
58
|
-
* live Chat shows)
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
58
|
+
* live Chat shows), plus answered ask_user cards (the question + the user's
|
|
59
|
+
* answer are a user-facing decision point, issue #132 — rendered read-only by
|
|
60
|
+
* AskUserCard since DemoView passes no onAskUserSubmit). Drop: reasoning, tool
|
|
61
|
+
* calls/results, hook diagnostics, the auto_retry card and UNANSWERED ask_user
|
|
62
|
+
* prompts (no meaning in a read-only replay), plus NO-RENDER placeholders and
|
|
63
|
+
* empties.
|
|
62
64
|
*/
|
|
63
65
|
export function isDemoConversational(m: ChatMessage): boolean {
|
|
64
66
|
if (m.role === "user") {
|
|
65
67
|
return !!m.content?.trim();
|
|
66
68
|
}
|
|
69
|
+
// Answered ask_user: keep as a read-only Q&A step. Unanswered prompts have no
|
|
70
|
+
// meaning in a replay and are dropped.
|
|
71
|
+
if (m.kind === "ask_user") {
|
|
72
|
+
return m.askUser?.answer !== undefined;
|
|
73
|
+
}
|
|
67
74
|
// Agent-attributed warnings/errors the live Chat surfaces as standalone
|
|
68
75
|
// bubbles. system_message carries its own payload; error carries content.
|
|
69
76
|
if (m.kind === "system_message") {
|
|
@@ -17,16 +17,20 @@ 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();
|
|
26
24
|
const { currentSandbox, operation, error, stats } = useSandbox();
|
|
27
|
-
const { currentSession, currentView, isRefreshingMessages, refreshMessages, setCurrentView } = useSessions();
|
|
25
|
+
const { currentSession, currentView, isRefreshingMessages, refreshMessages, setCurrentView, traceUnread } = useSessions();
|
|
28
26
|
const t = useT();
|
|
29
|
-
|
|
27
|
+
// #131 — the sidebar collapses to an icon rail either manually (user toggle)
|
|
28
|
+
// or automatically at narrow widths. Both feed the same `isCollapsed` state so
|
|
29
|
+
// the collapsed rail's session popover trigger is available in both cases. A
|
|
30
|
+
// manual toggle wins until the viewport crosses the breakpoint again.
|
|
31
|
+
const [userCollapsed, setUserCollapsed] = useState<boolean | null>(null);
|
|
32
|
+
const [isNarrow, setIsNarrow] = useState(false);
|
|
33
|
+
const isSidebarCollapsed = userCollapsed ?? isNarrow;
|
|
30
34
|
const [activePage, setActivePage] = useState<"workspace" | "demo">("workspace");
|
|
31
35
|
// Bumped on every sidebar "Live Demo" click so DemoView returns to its
|
|
32
36
|
// session-selection landing even when the demo page is already open (#111).
|
|
@@ -49,6 +53,21 @@ export function DesktopShell() {
|
|
|
49
53
|
}
|
|
50
54
|
}, [operation]);
|
|
51
55
|
|
|
56
|
+
// #131 — track the narrow breakpoint. Crossing it resets the manual override
|
|
57
|
+
// so the layout follows the viewport again (a user who manually expanded on a
|
|
58
|
+
// wide screen still gets the auto-rail when they shrink the window, and vice
|
|
59
|
+
// versa). 860px matches the existing responsive rail breakpoint in global.css.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const mql = window.matchMedia("(max-width: 860px)");
|
|
62
|
+
const apply = () => {
|
|
63
|
+
setIsNarrow(mql.matches);
|
|
64
|
+
setUserCollapsed(null);
|
|
65
|
+
};
|
|
66
|
+
setIsNarrow(mql.matches);
|
|
67
|
+
mql.addEventListener("change", apply);
|
|
68
|
+
return () => mql.removeEventListener("change", apply);
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
52
71
|
// Show warning dialog once per page session when disk usage is >= 90% but < 100%
|
|
53
72
|
useEffect(() => {
|
|
54
73
|
const percent = stats?.disk.percentOfQuota ?? 0;
|
|
@@ -66,12 +85,21 @@ export function DesktopShell() {
|
|
|
66
85
|
return;
|
|
67
86
|
}
|
|
68
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).
|
|
69
91
|
const delta = event.clientX - sidebarResizeRef.current.pointerX;
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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);
|
|
75
103
|
};
|
|
76
104
|
|
|
77
105
|
const handlePointerUp = () => {
|
|
@@ -128,7 +156,7 @@ export function DesktopShell() {
|
|
|
128
156
|
sidebarResizeRef.current = { pointerX, width: sidebarWidth };
|
|
129
157
|
setIsSidebarResizing(true);
|
|
130
158
|
}}
|
|
131
|
-
onToggle={() =>
|
|
159
|
+
onToggle={() => setUserCollapsed(!isSidebarCollapsed)}
|
|
132
160
|
/>
|
|
133
161
|
|
|
134
162
|
{activePage === "demo" ? (
|
|
@@ -189,7 +217,7 @@ export function DesktopShell() {
|
|
|
189
217
|
</button>
|
|
190
218
|
<button
|
|
191
219
|
aria-selected={currentView === "trace"}
|
|
192
|
-
className={currentView === "trace" ? "is-active" : ""}
|
|
220
|
+
className={`workspace-view-tab--badged ${currentView === "trace" ? "is-active" : ""}`}
|
|
193
221
|
onClick={() => setCurrentView("trace")}
|
|
194
222
|
role="tab"
|
|
195
223
|
title={t("shell.view.trace")}
|
|
@@ -197,6 +225,15 @@ export function DesktopShell() {
|
|
|
197
225
|
>
|
|
198
226
|
<GitBranch size={14} />
|
|
199
227
|
<span className="sr-only">{t("shell.view.trace")}</span>
|
|
228
|
+
{/* #134 — quiet unread dot: trace changed for this session and
|
|
229
|
+
the user hasn't opened the Trace view since. Cleared on open. */}
|
|
230
|
+
{traceUnread && currentView !== "trace" ? (
|
|
231
|
+
<span
|
|
232
|
+
className="workspace-view-tab__badge"
|
|
233
|
+
aria-label={t("shell.view.traceUpdated")}
|
|
234
|
+
role="status"
|
|
235
|
+
/>
|
|
236
|
+
) : null}
|
|
200
237
|
</button>
|
|
201
238
|
</div>
|
|
202
239
|
{currentView === "chat" ? (
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Check, MessageCircle, PenLine, Search, Trash2, X } from "lucide-react";
|
|
2
|
+
import { FormEvent, useState } from "react";
|
|
3
|
+
import type { Session } from "../../contracts/backend";
|
|
4
|
+
import { useT } from "../../i18n/useT";
|
|
5
|
+
import { IconButton } from "../primitives/IconButton";
|
|
6
|
+
|
|
7
|
+
type SessionListProps = {
|
|
8
|
+
sessions: Session[];
|
|
9
|
+
currentId: string | undefined;
|
|
10
|
+
isLoading: boolean;
|
|
11
|
+
/** Select an existing session (callers also switch to the workspace page). */
|
|
12
|
+
onSelect: (sessionId: string) => void;
|
|
13
|
+
/** Rename a session by id. */
|
|
14
|
+
onRename: (sessionId: string, title: string) => void | Promise<void>;
|
|
15
|
+
/** Delete a session by id. */
|
|
16
|
+
onDelete: (sessionId: string) => void | Promise<void>;
|
|
17
|
+
/** Open the search dialog. */
|
|
18
|
+
onOpenSearch: () => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* #131 — the conversation list, extracted from Sidebar so the same markup and
|
|
23
|
+
* rename/delete affordances render both inline (expanded sidebar) and inside
|
|
24
|
+
* the icon-rail session popover. Owns only its transient edit/confirm UI state;
|
|
25
|
+
* the session data and mutations are passed in by the host.
|
|
26
|
+
*/
|
|
27
|
+
export function SessionList({
|
|
28
|
+
sessions,
|
|
29
|
+
currentId,
|
|
30
|
+
isLoading,
|
|
31
|
+
onSelect,
|
|
32
|
+
onRename,
|
|
33
|
+
onDelete,
|
|
34
|
+
onOpenSearch,
|
|
35
|
+
}: SessionListProps) {
|
|
36
|
+
const t = useT();
|
|
37
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
38
|
+
const [editingTitle, setEditingTitle] = useState("");
|
|
39
|
+
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
40
|
+
|
|
41
|
+
const submitRename = async (event: FormEvent) => {
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
if (!editingId || !editingTitle.trim()) {
|
|
44
|
+
setEditingId(null);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
await onRename(editingId, editingTitle.trim());
|
|
48
|
+
setEditingId(null);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="conversation-stack">
|
|
53
|
+
<button className="conversation-search-trigger" onClick={onOpenSearch} type="button">
|
|
54
|
+
<Search size={14} />
|
|
55
|
+
<span>{t("sidebar.search")}</span>
|
|
56
|
+
</button>
|
|
57
|
+
<p className="muted-label">
|
|
58
|
+
{isLoading ? t("sidebar.loading") : t("sidebar.sessionCount", { count: sessions.length })}
|
|
59
|
+
</p>
|
|
60
|
+
{sessions.length === 0 && !isLoading ? <p className="sidebar-empty">{t("sidebar.empty")}</p> : null}
|
|
61
|
+
{sessions.map((session) => {
|
|
62
|
+
const isEditing = editingId === session.id;
|
|
63
|
+
const isConfirming = confirmDeleteId === session.id;
|
|
64
|
+
return (
|
|
65
|
+
<div className={`conversation-item ${currentId === session.id ? "is-active" : ""}`} key={session.id}>
|
|
66
|
+
{isEditing ? (
|
|
67
|
+
<form className="conversation-edit" onSubmit={submitRename}>
|
|
68
|
+
<input
|
|
69
|
+
autoFocus
|
|
70
|
+
onChange={(event) => setEditingTitle(event.target.value)}
|
|
71
|
+
value={editingTitle}
|
|
72
|
+
/>
|
|
73
|
+
<IconButton label={t("sidebar.aria.saveTitle")} type="submit">
|
|
74
|
+
<Check size={14} />
|
|
75
|
+
</IconButton>
|
|
76
|
+
<IconButton label={t("sidebar.aria.cancelRename")} onClick={() => setEditingId(null)}>
|
|
77
|
+
<X size={14} />
|
|
78
|
+
</IconButton>
|
|
79
|
+
</form>
|
|
80
|
+
) : (
|
|
81
|
+
<>
|
|
82
|
+
<button className="conversation-row" onClick={() => onSelect(session.id)} type="button">
|
|
83
|
+
<MessageCircle size={16} />
|
|
84
|
+
<span>{session.title}</span>
|
|
85
|
+
<small>{new Date(session.updatedAt).toLocaleDateString()}</small>
|
|
86
|
+
</button>
|
|
87
|
+
<div className="conversation-actions">
|
|
88
|
+
{isConfirming ? (
|
|
89
|
+
<>
|
|
90
|
+
<IconButton
|
|
91
|
+
label={t("sidebar.aria.confirmDelete")}
|
|
92
|
+
onClick={() => {
|
|
93
|
+
void onDelete(session.id);
|
|
94
|
+
setConfirmDeleteId(null);
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<Check size={14} />
|
|
98
|
+
</IconButton>
|
|
99
|
+
<IconButton label={t("sidebar.aria.cancelDelete")} onClick={() => setConfirmDeleteId(null)}>
|
|
100
|
+
<X size={14} />
|
|
101
|
+
</IconButton>
|
|
102
|
+
</>
|
|
103
|
+
) : (
|
|
104
|
+
<>
|
|
105
|
+
<IconButton
|
|
106
|
+
label={t("sidebar.aria.rename")}
|
|
107
|
+
onClick={() => {
|
|
108
|
+
setEditingId(session.id);
|
|
109
|
+
setEditingTitle(session.title);
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<PenLine size={14} />
|
|
113
|
+
</IconButton>
|
|
114
|
+
<IconButton label={t("sidebar.aria.delete")} onClick={() => setConfirmDeleteId(session.id)}>
|
|
115
|
+
<Trash2 size={14} />
|
|
116
|
+
</IconButton>
|
|
117
|
+
</>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|