@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.
@@ -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, 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>
@@ -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). Drop: reasoning, tool calls/results, hook diagnostics, and
59
- * the interactive ask_user / auto_retry cards (the reasoning graph on the right
60
- * tells the internal story, and the cards have no meaning in a read-only
61
- * replay), plus NO-RENDER placeholders and empties.
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
- const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
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 nextWidth = Math.max(
71
- MIN_SIDEBAR_WIDTH,
72
- Math.min(MAX_SIDEBAR_WIDTH, sidebarResizeRef.current.width + delta),
73
- );
74
- 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);
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={() => setIsSidebarCollapsed((current) => !current)}
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
+ }