canopy-ui 0.6.3 → 0.7.0

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.
@@ -4,7 +4,7 @@ import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Loader2 } from "lucide-re
4
4
  import type { Message } from "./protocol";
5
5
  import { Button } from "../ui/button";
6
6
  import type { RenderMarkdown } from "./MessageItem";
7
- import { MessageItem } from "./MessageItem";
7
+ import { MessageItem, ThinkingIndicator } from "./MessageItem";
8
8
  import { ToolCallPair } from "./ToolCallPair";
9
9
  import { pairToolMessages } from "./pairToolMessages";
10
10
  import { groupToolRuns, runHasError, runIsActive, summariseRun } from "./groupToolRuns";
@@ -14,6 +14,20 @@ interface Props {
14
14
  /** Rendered when there are no messages yet (replaces ace's WelcomePanel). */
15
15
  emptyState?: ReactNode;
16
16
  renderMarkdown?: RenderMarkdown;
17
+ /**
18
+ * The agent has the floor but has produced no row yet — render a trailing
19
+ * "thinking" bubble at the bottom of the conversation.
20
+ *
21
+ * A trailing ELEMENT, not a row spliced into `messages`: the message array is
22
+ * a projection of the durable record and must keep exactly one writer, and a
23
+ * placeholder with a made-up id would need upsert/eviction rules against every
24
+ * frame that can follow it. This has none — it is on screen while the flag is
25
+ * true and gone when it isn't.
26
+ */
27
+ pendingReply?: boolean;
28
+ /** Wording for that bubble — the caller knows whether the turn is still
29
+ * queued for a runner or the agent is actually working. */
30
+ pendingLabel?: string;
17
31
  }
18
32
 
19
33
  // Show the bulk expand/collapse toolbar once a session has more than this
@@ -22,7 +36,13 @@ const TOOLBAR_THRESHOLD = 5;
22
36
 
23
37
  type BulkState = "default" | "all" | "none";
24
38
 
25
- export function MessageList({ messages, emptyState, renderMarkdown }: Props) {
39
+ export function MessageList({
40
+ messages,
41
+ emptyState,
42
+ renderMarkdown,
43
+ pendingReply = false,
44
+ pendingLabel,
45
+ }: Props) {
26
46
  const paired = useMemo(() => pairToolMessages(messages), [messages]);
27
47
  // Collapse back-to-back tool calls into one row. An agent mid-task emits long
28
48
  // stretches of them, and one row each pushes the prose you actually read off
@@ -38,7 +58,7 @@ export function MessageList({ messages, emptyState, renderMarkdown }: Props) {
38
58
  // control back to per-row state.
39
59
  const [bulkState, setBulkState] = useState<BulkState>("default");
40
60
 
41
- if (messages.length === 0) {
61
+ if (messages.length === 0 && !pendingReply) {
42
62
  return <>{emptyState ?? null}</>;
43
63
  }
44
64
  const forceToolOpen =
@@ -139,6 +159,15 @@ export function MessageList({ messages, emptyState, renderMarkdown }: Props) {
139
159
  />
140
160
  );
141
161
  })}
162
+ {pendingReply && (
163
+ <div
164
+ data-testid="pending-reply"
165
+ aria-live="polite"
166
+ className="my-2 mr-auto max-w-[80%] rounded-2xl bg-muted px-4 py-2 text-foreground"
167
+ >
168
+ <ThinkingIndicator label={pendingLabel} />
169
+ </div>
170
+ )}
142
171
  </div>
143
172
  </div>
144
173
  );
@@ -94,6 +94,81 @@ describe("PlacementBanner", () => {
94
94
  expect(screen.getByText("Could not place the turn.")).toBeTruthy();
95
95
  });
96
96
 
97
+ it("names a pause as a pause, with the reason it was given", () => {
98
+ render(
99
+ <PlacementBanner
100
+ runnerName="Laptop"
101
+ eligibleRunners={[]}
102
+ paused
103
+ pausedNote="token limit on this account"
104
+ onWait={vi.fn()}
105
+ onPlace={vi.fn()}
106
+ />,
107
+ );
108
+
109
+ expect(screen.getByText("Laptop is paused")).toBeTruthy();
110
+ // The note is what tells you whether resuming is actually safe.
111
+ expect(screen.getByText("— token limit on this account")).toBeTruthy();
112
+ });
113
+
114
+ it("offers Resume when the viewer can un-park the runner", () => {
115
+ const onResume = vi.fn();
116
+ render(
117
+ <PlacementBanner
118
+ runnerName="Laptop"
119
+ eligibleRunners={[]}
120
+ paused
121
+ onResume={onResume}
122
+ onWait={vi.fn()}
123
+ onPlace={vi.fn()}
124
+ />,
125
+ );
126
+
127
+ fireEvent.click(screen.getByText("Resume"));
128
+ expect(onResume).toHaveBeenCalledTimes(1);
129
+ });
130
+
131
+ it("omits Resume when no handler is given — only the pairer may resume", () => {
132
+ render(
133
+ <PlacementBanner
134
+ runnerName="Laptop"
135
+ eligibleRunners={[]}
136
+ paused
137
+ onWait={vi.fn()}
138
+ onPlace={vi.fn()}
139
+ />,
140
+ );
141
+
142
+ // A button that 404s reads as the runner refusing to come back.
143
+ expect(screen.queryByText("Resume")).toBeNull();
144
+ });
145
+
146
+ it("keeps waiting reachable but demoted below the acting exits", () => {
147
+ // Waiting leaves the message QUEUED until the box returns — real for a
148
+ // reboot, wrong as a default. It stays a button (role, disabled state,
149
+ // accessible name) but is styled as a link rather than a peer action.
150
+ render(
151
+ <PlacementBanner
152
+ runnerName="Laptop"
153
+ eligibleRunners={[]}
154
+ paused
155
+ onResume={vi.fn()}
156
+ onWait={vi.fn()}
157
+ onPlace={vi.fn()}
158
+ />,
159
+ );
160
+
161
+ const wait = screen.getByText("Wait for it") as HTMLButtonElement;
162
+ expect(wait.tagName).toBe("BUTTON");
163
+ expect(wait.className).toContain("underline");
164
+ expect(wait.className).not.toContain("border");
165
+ // Resume comes first in reading order — the fix before the deferral.
166
+ expect(
167
+ screen.getByText("Resume").compareDocumentPosition(wait) &
168
+ Node.DOCUMENT_POSITION_FOLLOWING,
169
+ ).toBeTruthy();
170
+ });
171
+
97
172
  it("renders error and info distinctly styled when both are present", () => {
98
173
  render(
99
174
  <PlacementBanner
@@ -24,15 +24,32 @@ export interface PlacementBannerProps {
24
24
  onWait: () => void;
25
25
  /** Re-place the turn onto the given runner id. */
26
26
  onPlace: (runnerId: string) => void;
27
+ /** True when the runner is PARKED rather than gone — a decision someone made,
28
+ * which someone can therefore undo. Changes the headline and, with `onResume`,
29
+ * puts the one-tap fix first. */
30
+ paused?: boolean;
31
+ /** The reason recorded at pause time, shown so the reader can judge whether
32
+ * resuming is safe (e.g. "token limit on this account" usually is not). */
33
+ pausedNote?: string;
34
+ /** Un-park the runner in place. Omit when the viewer cannot — only the human
35
+ * who paired a runner may pause or resume it, so offering this to anyone else
36
+ * would render a button that 404s. */
37
+ onResume?: () => void;
27
38
  }
28
39
 
29
40
  /**
30
- * Offline-runner placement banner: the chat session's bound runner has gone
31
- * unavailable, and the user must decide whether to wait for it or continue
32
- * on a different session-capable runner. Presentational only — NO fetch, NO
33
- * WS. The container (canopy's ChatPage) owns the fleet poll, the derived
34
- * eligible-runner list, and the placement POST; this component just renders
35
- * the decision and forwards the pick.
41
+ * Offline-runner placement banner: the chat session's bound runner cannot act,
42
+ * and the user must get it acting again resume it, or continue on a different
43
+ * session-capable runner. Presentational only — NO fetch, NO WS. The container
44
+ * (canopy's ChatPage) owns the fleet poll, the derived eligible-runner list, and
45
+ * the placement POST; this component just renders the decision and forwards it.
46
+ *
47
+ * The actions are deliberately not peers. Waiting means your message sits QUEUED
48
+ * until the box returns — which, for a pause you applied yourself, may be never —
49
+ * and a queued send reads as a sent one right up until you notice no reply came.
50
+ * So the exits that make the chat WORK are buttons, and waiting is a link you
51
+ * have to mean. It stays reachable because a box rebooting in ninety seconds is
52
+ * a real case; it is just not the shape of the default.
36
53
  */
37
54
  export function PlacementBanner({
38
55
  runnerName,
@@ -42,6 +59,9 @@ export function PlacementBanner({
42
59
  info,
43
60
  onWait,
44
61
  onPlace,
62
+ paused = false,
63
+ pausedNote,
64
+ onResume,
45
65
  }: PlacementBannerProps) {
46
66
  // Whether the "Continue on…" picker is expanded — purely local UI state,
47
67
  // not fetch-driven, so it lives in the kit rather than round-tripping
@@ -50,15 +70,22 @@ export function PlacementBanner({
50
70
 
51
71
  return (
52
72
  <div className="flex flex-wrap items-center gap-2 border-b border-warning/30 bg-warning/10 px-4 py-2 text-[12px] text-warning">
53
- <span className="font-medium">{runnerName} is unavailable</span>
54
- <button
55
- type="button"
56
- onClick={onWait}
57
- disabled={busy}
58
- className="rounded-md border border-warning/40 px-2 py-0.5 text-warning hover:bg-warning/20 disabled:opacity-50"
59
- >
60
- Wait for it
61
- </button>
73
+ <span className="font-medium">
74
+ {runnerName} is {paused ? "paused" : "unavailable"}
75
+ </span>
76
+ {paused && pausedNote && (
77
+ <span className="text-warning/80">— {pausedNote}</span>
78
+ )}
79
+ {onResume && (
80
+ <button
81
+ type="button"
82
+ onClick={onResume}
83
+ disabled={busy}
84
+ className="rounded-md border border-warning/40 bg-warning/20 px-2 py-0.5 font-medium text-warning hover:bg-warning/30 disabled:opacity-50"
85
+ >
86
+ Resume
87
+ </button>
88
+ )}
62
89
  <button
63
90
  type="button"
64
91
  onClick={() => setShowPicker((v) => !v)}
@@ -85,6 +112,16 @@ export function PlacementBanner({
85
112
  ))}
86
113
  </select>
87
114
  )}
115
+ {/* Demoted, not removed — see the component doc. Still a <button> so it
116
+ keeps its role, its disabled state and its accessible name. */}
117
+ <button
118
+ type="button"
119
+ onClick={onWait}
120
+ disabled={busy}
121
+ className="ml-auto text-warning/70 underline underline-offset-2 hover:text-warning disabled:opacity-50"
122
+ >
123
+ Wait for it
124
+ </button>
88
125
  {error && <span className="text-destructive">{error}</span>}
89
126
  {info && <span className="text-muted-foreground">{info}</span>}
90
127
  </div>
@@ -5,42 +5,136 @@ interface Props {
5
5
  presenceUserIds: number[];
6
6
  draftHolderId: number | null;
7
7
  draftHolderIdle: boolean;
8
+ /** Who is looking. Required so the row can exclude YOU.
9
+ *
10
+ * Without it this rendered your own chip and the empty state said "nobody
11
+ * else here" — copy and logic disagreeing about whether "else" meant
12
+ * anything. In practice the empty state was unreachable: alone in a session
13
+ * you saw one chip, which is indistinguishable from being watched by a
14
+ * stranger. ChatPanel had `currentUserId` the whole time and never passed
15
+ * it. Caught by the first two-browser e2e this surface ever had. */
16
+ currentUserId: number;
8
17
  }
9
18
 
19
+ /** A stable colour per person, so two people are told apart at a glance.
20
+ *
21
+ * Every chip used to be `bg-muted`, which meant identity rested entirely on
22
+ * two initials — and initials collide constantly on a small team (two J.K.s
23
+ * render identically). Hue is derived from the user id so a given person is
24
+ * the same colour in every session, for everyone, with no state to keep. */
25
+ const PALETTE = [
26
+ "bg-sky-500/20 text-sky-700 dark:text-sky-200 ring-sky-500/40",
27
+ "bg-emerald-500/20 text-emerald-700 dark:text-emerald-200 ring-emerald-500/40",
28
+ "bg-violet-500/20 text-violet-700 dark:text-violet-200 ring-violet-500/40",
29
+ "bg-amber-500/20 text-amber-700 dark:text-amber-200 ring-amber-500/40",
30
+ "bg-rose-500/20 text-rose-700 dark:text-rose-200 ring-rose-500/40",
31
+ "bg-teal-500/20 text-teal-700 dark:text-teal-200 ring-teal-500/40",
32
+ ];
33
+
34
+ const colorFor = (userId: number) => PALETTE[Math.abs(userId) % PALETTE.length];
35
+
36
+ /** How many faces before collapsing into "+N". Beyond a handful the row stops
37
+ * being a glance and starts being a list, and it shares a thin header bar. */
38
+ const MAX_FACES = 4;
39
+
10
40
  export function PresenceChips({
11
41
  participants,
12
42
  presenceUserIds,
13
43
  draftHolderId,
14
44
  draftHolderIdle,
45
+ currentUserId,
15
46
  }: Props) {
16
- const present = participants.filter((p) =>
17
- presenceUserIds.includes(p.user_id),
47
+ const present = participants.filter(
48
+ (p) => presenceUserIds.includes(p.user_id) && p.user_id !== currentUserId,
18
49
  );
50
+
19
51
  if (present.length === 0) {
20
- return <div className="text-sm text-muted-foreground">nobody else here</div>;
52
+ return (
53
+ <div
54
+ className="text-xs text-muted-foreground"
55
+ data-testid="presence-empty"
56
+ >
57
+ just you
58
+ </div>
59
+ );
21
60
  }
61
+
62
+ const editor = present.find(
63
+ (p) => p.user_id === draftHolderId && !draftHolderIdle,
64
+ );
65
+ const faces = present.slice(0, MAX_FACES);
66
+ const overflow = present.length - faces.length;
67
+
22
68
  return (
23
- <div className="flex gap-2">
24
- {present.map((p) => {
25
- const isHolder = p.user_id === draftHolderId && !draftHolderIdle;
26
- return (
27
- <div
28
- key={p.user_id}
29
- title={p.display_name + (isHolder ? " — editing…" : "")}
30
- className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium ${
31
- isHolder
32
- ? "bg-primary text-primary-foreground ring-2 ring-primary/30"
33
- : "bg-muted text-muted-foreground"
34
- }`}
69
+ <div className="flex items-center gap-2" data-testid="presence-chips">
70
+ {/* The one thing worth WORDS rather than a ring: somebody is typing into
71
+ the box you share, and the old UI said so only in a `title` tooltip —
72
+ invisible on touch, invisible to a screen reader, and invisible to
73
+ anyone not hovering the exact 28px circle. */}
74
+ {editor && (
75
+ <span
76
+ className="hidden items-center gap-1 text-xs text-muted-foreground sm:flex"
77
+ data-testid="presence-editing-label"
78
+ >
79
+ <span className="relative flex h-1.5 w-1.5" aria-hidden="true">
80
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
81
+ <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary" />
82
+ </span>
83
+ {firstName(editor.display_name)} is typing…
84
+ </span>
85
+ )}
86
+ <ul
87
+ className="flex items-center -space-x-1.5"
88
+ aria-label={describe(present, editor)}
89
+ data-testid="presence-list"
90
+ >
91
+ {faces.map((p) => {
92
+ const isEditor = p.user_id === editor?.user_id;
93
+ return (
94
+ <li
95
+ key={p.user_id}
96
+ data-testid="presence-chip"
97
+ data-user-id={p.user_id}
98
+ data-editing={isEditor ? "true" : "false"}
99
+ title={p.display_name + (isEditor ? " — editing…" : "")}
100
+ className={[
101
+ "flex h-7 w-7 items-center justify-center rounded-full",
102
+ "text-[11px] font-semibold ring-2 ring-background",
103
+ "transition-transform hover:z-10 hover:scale-110",
104
+ colorFor(p.user_id),
105
+ isEditor ? "z-10 !ring-primary" : "",
106
+ ].join(" ")}
107
+ >
108
+ {initials(p.display_name)}
109
+ </li>
110
+ );
111
+ })}
112
+ {overflow > 0 && (
113
+ <li
114
+ data-testid="presence-overflow"
115
+ title={present.slice(MAX_FACES).map((p) => p.display_name).join(", ")}
116
+ className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-[11px] font-semibold text-muted-foreground ring-2 ring-background"
35
117
  >
36
- {initials(p.display_name)}
37
- </div>
38
- );
39
- })}
118
+ +{overflow}
119
+ </li>
120
+ )}
121
+ </ul>
40
122
  </div>
41
123
  );
42
124
  }
43
125
 
126
+ /** The accessible name for the row — the same fact the faces carry, in words.
127
+ * A row of coloured circles is meaningless without this. */
128
+ function describe(present: Participant[], editor?: Participant): string {
129
+ const names = present.map((p) => p.display_name).join(", ");
130
+ const who = present.length === 1 ? "1 other person here" : `${present.length} other people here`;
131
+ return editor ? `${who}: ${names}. ${editor.display_name} is editing.` : `${who}: ${names}`;
132
+ }
133
+
134
+ function firstName(name: string): string {
135
+ return name.trim().split(/\s+/)[0] || name;
136
+ }
137
+
44
138
  function initials(name: string): string {
45
139
  return name
46
140
  .split(" ")
@@ -357,3 +357,154 @@ describe("SendBox — attaching files", () => {
357
357
  expect(screen.queryByRole("button", { name: /attach/i })).toBeNull();
358
358
  });
359
359
  });
360
+
361
+ /**
362
+ * A half-typed message must survive leaving the page.
363
+ *
364
+ * It lives nowhere but this component's state: single-player never mirrors the
365
+ * body to the server (drafts.shouldSyncDraftLive), and the adopt rule above
366
+ * deliberately ignores our OWN server draft — so an unmount used to destroy the
367
+ * only copy. `persistKey` gives it somewhere to land.
368
+ */
369
+ describe("SendBox — draft persistence across unmount", () => {
370
+ function fakeStorage(seed: Record<string, string> = {}) {
371
+ const map = new Map(Object.entries(seed));
372
+ return {
373
+ map,
374
+ getItem: (k: string) => map.get(k) ?? null,
375
+ setItem: (k: string, v: string) => void map.set(k, v),
376
+ removeItem: (k: string) => void map.delete(k),
377
+ };
378
+ }
379
+
380
+ function mount(
381
+ storage: ReturnType<typeof fakeStorage>,
382
+ props: Partial<Parameters<typeof SendBox>[0]> = {},
383
+ ) {
384
+ return render(
385
+ <SendBox
386
+ draft={draft()}
387
+ connected
388
+ currentUserId={ME}
389
+ holderIsPresent={false}
390
+ isStreaming={false}
391
+ streamingMessageId={null}
392
+ onUpdate={vi.fn()}
393
+ onSend={vi.fn()}
394
+ onStop={vi.fn()}
395
+ onTakeOver={vi.fn()}
396
+ persistKey="sess-1"
397
+ storage={storage}
398
+ {...props}
399
+ />,
400
+ );
401
+ }
402
+
403
+ const box = () => screen.getByRole("textbox") as HTMLTextAreaElement;
404
+
405
+ it("restores what was typed after unmounting and mounting again", () => {
406
+ const storage = fakeStorage();
407
+ const first = mount(storage);
408
+ fireEvent.change(box(), { target: { value: "the thing I was saying" } });
409
+ first.unmount();
410
+
411
+ mount(storage);
412
+ expect(box().value).toBe("the thing I was saying");
413
+ });
414
+
415
+ it("comes back empty once the message has been sent", () => {
416
+ const storage = fakeStorage();
417
+ const onSend = vi.fn();
418
+ const first = mount(storage, { onSend });
419
+ fireEvent.change(box(), { target: { value: "shipping it" } });
420
+ fireEvent.click(screen.getByRole("button", { name: /^send$/i }));
421
+ expect(onSend).toHaveBeenCalled();
422
+ first.unmount();
423
+
424
+ mount(storage);
425
+ expect(box().value).toBe("");
426
+ });
427
+
428
+ it("does not carry one session's text into another", () => {
429
+ const storage = fakeStorage();
430
+ const first = mount(storage, { persistKey: "sess-1" });
431
+ fireEvent.change(box(), { target: { value: "for session one" } });
432
+ first.unmount();
433
+
434
+ mount(storage, { persistKey: "sess-2" });
435
+ expect(box().value).toBe("");
436
+ });
437
+
438
+ it("follows the key when the panel swaps sessions without remounting", () => {
439
+ const storage = fakeStorage();
440
+ const view = mount(storage, { persistKey: "sess-1" });
441
+ fireEvent.change(box(), { target: { value: "for session one" } });
442
+
443
+ view.rerender(
444
+ <SendBox
445
+ draft={draft()}
446
+ connected
447
+ currentUserId={ME}
448
+ holderIsPresent={false}
449
+ isStreaming={false}
450
+ streamingMessageId={null}
451
+ onUpdate={vi.fn()}
452
+ onSend={vi.fn()}
453
+ onStop={vi.fn()}
454
+ onTakeOver={vi.fn()}
455
+ persistKey="sess-2"
456
+ storage={storage}
457
+ />,
458
+ );
459
+ expect(box().value).toBe("");
460
+
461
+ // ...and session one is still waiting where we left it.
462
+ view.unmount();
463
+ mount(storage, { persistKey: "sess-1" });
464
+ expect(box().value).toBe("for session one");
465
+ });
466
+
467
+ it("prefers the stored body over the server draft", () => {
468
+ // The stored one is strictly newer: it is what was in the box when we left.
469
+ const storage = fakeStorage();
470
+ const first = mount(storage, { draft: draft({ body: "from the server" }) });
471
+ fireEvent.change(box(), { target: { value: "what I actually typed" } });
472
+ first.unmount();
473
+
474
+ mount(storage, { draft: draft({ body: "from the server" }) });
475
+ expect(box().value).toBe("what I actually typed");
476
+ });
477
+
478
+ it("still shows the server draft when nothing was stored", () => {
479
+ mount(fakeStorage(), { draft: draft({ body: "from the server" }) });
480
+ expect(box().value).toBe("from the server");
481
+ });
482
+
483
+ it("without a persistKey, behaves exactly as it did before", () => {
484
+ const storage = fakeStorage();
485
+ const first = mount(storage, { persistKey: undefined });
486
+ fireEvent.change(box(), { target: { value: "nowhere to land" } });
487
+ first.unmount();
488
+ expect(storage.map.size).toBe(0);
489
+
490
+ mount(storage, { persistKey: undefined });
491
+ expect(box().value).toBe("");
492
+ });
493
+
494
+ it("keeps typing working when storage throws", () => {
495
+ const hostile = {
496
+ getItem: () => {
497
+ throw new Error("SecurityError");
498
+ },
499
+ setItem: () => {
500
+ throw new Error("SecurityError");
501
+ },
502
+ removeItem: () => {
503
+ throw new Error("SecurityError");
504
+ },
505
+ };
506
+ mount(hostile as unknown as ReturnType<typeof fakeStorage>);
507
+ fireEvent.change(box(), { target: { value: "still typeable" } });
508
+ expect(box().value).toBe("still typeable");
509
+ });
510
+ });