canopy-ui 0.13.0 → 0.14.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,6 +38,13 @@ describe("multiplayer composer", () => {
38
38
  expect(screen.queryByTestId("coedit-banner")).toBeNull();
39
39
  });
40
40
 
41
+ it("shows 'is typing…' with no words when a teammate withholds them", () => {
42
+ render(<ChatPanel {...props} state={state({ peer_drafts: [{ author: { id: 2, name: "Bo B" }, body: "", at: null, typing: true }] })} />);
43
+ const row = screen.getByTestId("typing-row");
44
+ expect(row.textContent).toContain("Bo B");
45
+ expect(row.textContent).toContain("is typing…");
46
+ });
47
+
41
48
  it("send stays available while the agent is replying", () => {
42
49
  const streaming = { id: "a1", turn_index: 2, role: "assistant" as const, content: {}, plaintext: "working", status: "streaming" as const, error_detail: null, started_at: null, completed_at: null, created_at: "" };
43
50
  render(<ChatPanel {...props} state={state({ messages: [streaming] })} />);
@@ -1,6 +1,6 @@
1
1
  import { useMemo, type ReactNode } from "react";
2
2
 
3
- import type { SessionState } from "./protocol";
3
+ import type { SessionState, TypingVisibility } from "./protocol";
4
4
  import type { RenderMarkdown } from "./MessageItem";
5
5
  import { ConnectionStatus } from "./ConnectionStatus";
6
6
  import { MessageList } from "./MessageList";
@@ -49,6 +49,11 @@ export interface ChatPanelProps {
49
49
  draftPersistKey?: string;
50
50
  /** Storage backing `draftPersistKey`; defaults to localStorage. */
51
51
  draftStorage?: DraftStorage | null;
52
+ /** This person's chosen typing-visibility mode, and its setter. Both
53
+ * optional; the control renders only when both are given (see SendBox) —
54
+ * a host with no draft sync (a contact) passes neither. */
55
+ typingVisibility?: TypingVisibility;
56
+ onTypingVisibilityChange?: (visibility: TypingVisibility) => void;
52
57
  }
53
58
 
54
59
  /**
@@ -76,6 +81,8 @@ export function ChatPanel({
76
81
  historySlot,
77
82
  draftPersistKey,
78
83
  draftStorage,
84
+ typingVisibility,
85
+ onTypingVisibilityChange,
79
86
  }: ChatPanelProps) {
80
87
  // `onDiscard` is part of the public surface (co-edit teardown) even though
81
88
  // the default composer doesn't render a discard button. Referenced to keep
@@ -129,7 +136,9 @@ export function ChatPanel({
129
136
  // underneath would state the same fact twice and put a dead-end restatement
130
137
  // directly below the thing you can actually press. The fallback is for hosts
131
138
  // with no banner of their own, which is exactly the embedded widget.
132
- const notice = banner ? null : turnNotice(status);
139
+ const latestMessageAt =
140
+ state.messages.length > 0 ? state.messages[state.messages.length - 1].created_at : null;
141
+ const notice = banner ? null : turnNotice(status, { latestMessageAt });
133
142
 
134
143
  // Sticky-bottom scroll: dep changes on (a) new message arrival and (b)
135
144
  // streaming text growth on the last message. length-only (cheap) instead
@@ -254,6 +263,8 @@ export function ChatPanel({
254
263
  onRemoveAttachment={onRemoveAttachment}
255
264
  persistKey={draftPersistKey}
256
265
  storage={draftStorage}
266
+ typingVisibility={typingVisibility}
267
+ onTypingVisibilityChange={onTypingVisibilityChange}
257
268
  />
258
269
  </div>
259
270
  );
@@ -582,3 +582,30 @@ describe("SendBox — draft persistence across unmount", () => {
582
582
  expect(box().value).toBe("still typeable");
583
583
  });
584
584
  });
585
+
586
+ describe("SendBox — typing visibility control", () => {
587
+ it("is absent when neither prop is given", () => {
588
+ setup();
589
+ expect(screen.queryByTestId("typing-visibility")).toBeNull();
590
+ });
591
+
592
+ it("is absent when only the value is given, with no handler", () => {
593
+ setup({ typingVisibility: "live" });
594
+ expect(screen.queryByTestId("typing-visibility")).toBeNull();
595
+ });
596
+
597
+ it("shows the control when both props are given, and calls the handler on selection", () => {
598
+ const onChange = vi.fn();
599
+ setup({ typingVisibility: "live", onTypingVisibilityChange: onChange });
600
+ const control = screen.getByTestId("typing-visibility") as HTMLSelectElement;
601
+ expect(control.value).toBe("live");
602
+ fireEvent.change(control, { target: { value: "typing" } });
603
+ expect(onChange).toHaveBeenCalledWith("typing");
604
+ });
605
+
606
+ it("has a tooltip explaining what it does", () => {
607
+ setup({ typingVisibility: "hidden", onTypingVisibilityChange: vi.fn() });
608
+ const control = screen.getByTestId("typing-visibility") as HTMLSelectElement;
609
+ expect(control.title.length).toBeGreaterThan(0);
610
+ });
611
+ });
@@ -7,7 +7,7 @@ import {
7
7
  } from "react";
8
8
  import type React from "react";
9
9
 
10
- import type { Draft } from "./protocol";
10
+ import type { Draft, TypingVisibility } from "./protocol";
11
11
  import {
12
12
  clearStoredDraft,
13
13
  defaultDraftStorage,
@@ -63,6 +63,14 @@ interface Props {
63
63
  /** Storage backing `persistKey`. Defaults to localStorage; inject a fake in
64
64
  * tests, or sessionStorage for per-tab drafts. */
65
65
  storage?: DraftStorage | null;
66
+ /** This person's choice of how much of THEIR OWN in-progress message peers
67
+ * get to see: the words live (`live`), just the fact of typing (`typing`),
68
+ * or nothing until send (`hidden`). Both this and `onTypingVisibilityChange`
69
+ * are optional, and the control renders only when BOTH are given — a host
70
+ * that passes neither (a contact's socket, which has no draft sync) gets no
71
+ * control at all rather than a dead one. */
72
+ typingVisibility?: TypingVisibility;
73
+ onTypingVisibilityChange?: (visibility: TypingVisibility) => void;
66
74
  }
67
75
 
68
76
  export function SendBox({
@@ -81,6 +89,8 @@ export function SendBox({
81
89
  onRemoveAttachment,
82
90
  persistKey,
83
91
  storage,
92
+ typingVisibility,
93
+ onTypingVisibilityChange,
84
94
  }: Props) {
85
95
  const textareaRef = useRef<HTMLTextAreaElement>(null);
86
96
 
@@ -343,6 +353,33 @@ export function SendBox({
343
353
  {stopState === "requested" ? "stopping…" : "stop"}
344
354
  </Button>
345
355
  ) : null}
356
+ {typingVisibility != null && onTypingVisibilityChange != null && (
357
+ <label className="flex items-center gap-1 text-xs text-muted-foreground">
358
+ <span className="hidden sm:inline">Others see:</span>
359
+ <select
360
+ data-testid="typing-visibility"
361
+ value={typingVisibility}
362
+ title="Choose what others see of your message while you're still typing it."
363
+ onChange={(e) =>
364
+ onTypingVisibilityChange(e.target.value as TypingVisibility)
365
+ }
366
+ className={[
367
+ // bg-background, not bg-transparent: a native <select>'s
368
+ // OPTION list paints on the OS's own background, and a
369
+ // transparent trigger left the closed control's text
370
+ // sitting on whatever was behind it — unreadable in dark
371
+ // mode, where that was the page's own dark-on-dark text.
372
+ "rounded-md border border-input bg-background px-1.5 py-1 text-xs",
373
+ "text-foreground focus-visible:outline-none focus-visible:ring-1",
374
+ "focus-visible:ring-ring",
375
+ ].join(" ")}
376
+ >
377
+ <option value="live">My text</option>
378
+ <option value="typing">Typing…</option>
379
+ <option value="hidden">Nothing</option>
380
+ </select>
381
+ </label>
382
+ )}
346
383
  <Button
347
384
  type="button"
348
385
  size="sm"
@@ -0,0 +1,33 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { TypingRows } from "./TypingRows";
6
+ import type { PeerDraft } from "./protocol";
7
+
8
+ afterEach(cleanup);
9
+
10
+ describe("TypingRows", () => {
11
+ it("renders nothing when nobody is typing", () => {
12
+ const { container } = render(<TypingRows peers={[]} />);
13
+ expect(container.firstChild).toBeNull();
14
+ });
15
+
16
+ it("shows a peer's live words when a body is present", () => {
17
+ const peers: PeerDraft[] = [{ author: { id: 2, name: "Bo B" }, body: "hello there", at: null }];
18
+ render(<TypingRows peers={peers} />);
19
+ const row = screen.getByTestId("typing-row");
20
+ expect(row.textContent).toContain("Bo B");
21
+ expect(row.textContent).toContain("hello there");
22
+ expect(row.textContent).toContain("is typing:");
23
+ });
24
+
25
+ it("renders 'is typing…' with no words when the body is withheld", () => {
26
+ const peers: PeerDraft[] = [{ author: { id: 2, name: "Bo B" }, body: "", at: null, typing: true }];
27
+ render(<TypingRows peers={peers} />);
28
+ const row = screen.getByTestId("typing-row");
29
+ expect(row.textContent).toContain("Bo B");
30
+ expect(row.textContent).toContain("is typing…");
31
+ expect(row.textContent).not.toContain("is typing:");
32
+ });
33
+ });
@@ -6,13 +6,20 @@ export function TypingRows({ peers }: { peers: PeerDraft[] }) {
6
6
  if (peers.length === 0) return null;
7
7
  return (
8
8
  <ul className="space-y-1 border-t border-border bg-background px-3 py-2" aria-live="polite">
9
- {peers.map((p) => (
10
- <li key={p.author.id} data-testid="typing-row" className="flex min-w-0 gap-2 text-xs">
11
- <span className="shrink-0 font-medium text-foreground">{p.author.name}</span>
12
- <span className="shrink-0 text-muted-foreground">is typing:</span>
13
- <span className="min-w-0 truncate italic text-foreground-secondary">{p.body}</span>
14
- </li>
15
- ))}
9
+ {peers.map((p) =>
10
+ p.body === "" ? (
11
+ <li key={p.author.id} data-testid="typing-row" className="flex min-w-0 gap-2 text-xs">
12
+ <span className="shrink-0 font-medium text-foreground">{p.author.name}</span>
13
+ <span className="shrink-0 text-muted-foreground">is typing…</span>
14
+ </li>
15
+ ) : (
16
+ <li key={p.author.id} data-testid="typing-row" className="flex min-w-0 gap-2 text-xs">
17
+ <span className="shrink-0 font-medium text-foreground">{p.author.name}</span>
18
+ <span className="shrink-0 text-muted-foreground">is typing:</span>
19
+ <span className="min-w-0 truncate italic text-foreground-secondary">{p.body}</span>
20
+ </li>
21
+ ),
22
+ )}
16
23
  </ul>
17
24
  );
18
25
  }
@@ -5,8 +5,10 @@ import {
5
5
  clearStoredDraft,
6
6
  draftStorageKey,
7
7
  readStoredDraft,
8
+ readStoredTypingVisibility,
8
9
  shouldSyncDraftLive,
9
10
  writeStoredDraft,
11
+ writeStoredTypingVisibility,
10
12
  } from "./drafts"
11
13
 
12
14
  const NOW = 1_700_000_000_000
@@ -144,3 +146,43 @@ describe("clearStoredDraft", () => {
144
146
  expect(readStoredDraft(s, KEY, NOW)).toBeNull()
145
147
  })
146
148
  })
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Typing-visibility persistence — per browser, not per session.
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe("readStoredTypingVisibility / writeStoredTypingVisibility", () => {
155
+ it("defaults to null when nothing is stored", () => {
156
+ expect(readStoredTypingVisibility(fakeStorage())).toBeNull()
157
+ })
158
+
159
+ it("round-trips a written mode", () => {
160
+ const s = fakeStorage()
161
+ writeStoredTypingVisibility(s, "typing")
162
+ expect(readStoredTypingVisibility(s)).toBe("typing")
163
+ })
164
+
165
+ it("rejects a value that isn't one of the three modes", () => {
166
+ const s = fakeStorage({ "canopy.chat.typingVisibility": "loud" })
167
+ expect(readStoredTypingVisibility(s)).toBeNull()
168
+ })
169
+
170
+ it("is inert without a storage", () => {
171
+ expect(readStoredTypingVisibility(null)).toBeNull()
172
+ expect(() => writeStoredTypingVisibility(null, "hidden")).not.toThrow()
173
+ })
174
+
175
+ it("survives a storage that throws", () => {
176
+ const hostile = {
177
+ getItem: () => {
178
+ throw new Error("SecurityError")
179
+ },
180
+ setItem: () => {
181
+ throw new Error("SecurityError")
182
+ },
183
+ removeItem: () => {},
184
+ }
185
+ expect(readStoredTypingVisibility(hostile)).toBeNull()
186
+ expect(() => writeStoredTypingVisibility(hostile, "live")).not.toThrow()
187
+ })
188
+ })
@@ -1,3 +1,5 @@
1
+ import type { TypingVisibility } from "./protocol";
2
+
1
3
  /**
2
4
  * Whether keystrokes need to be mirrored to the server AS YOU TYPE.
3
5
  *
@@ -149,3 +151,43 @@ function remove(storage: DraftStorage, key: string): void {
149
151
  // same as above — best effort
150
152
  }
151
153
  }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Typing-visibility persistence — per BROWSER, not per session: it is a
157
+ // standing choice about how you type, not something that resets when you
158
+ // switch chats. One key, not namespaced by `persistKey` like a draft body.
159
+ // ---------------------------------------------------------------------------
160
+
161
+ // Exported so a listener can recognise a `storage` event for this key coming
162
+ // from another tab (see useSessionSocket's cross-tab sync).
163
+ export const TYPING_VISIBILITY_STORAGE_KEY = "canopy.chat.typingVisibility";
164
+
165
+ const VALID_VISIBILITIES: readonly TypingVisibility[] = ["live", "typing", "hidden"];
166
+
167
+ /** The persisted mode, or null when there is nothing stored or it is
168
+ * unreadable/invalid — same try/catch-safe shape as `readStoredDraft`. */
169
+ export function readStoredTypingVisibility(storage: DraftStorage | null): TypingVisibility | null {
170
+ if (!storage) return null;
171
+ let raw: string | null = null;
172
+ try {
173
+ raw = storage.getItem(TYPING_VISIBILITY_STORAGE_KEY);
174
+ } catch {
175
+ return null;
176
+ }
177
+ return (VALID_VISIBILITIES as readonly string[]).includes(raw ?? "")
178
+ ? (raw as TypingVisibility)
179
+ : null;
180
+ }
181
+
182
+ export function writeStoredTypingVisibility(
183
+ storage: DraftStorage | null,
184
+ visibility: TypingVisibility,
185
+ ): void {
186
+ if (!storage) return;
187
+ try {
188
+ storage.setItem(TYPING_VISIBILITY_STORAGE_KEY, visibility);
189
+ } catch {
190
+ // Quota exceeded, or storage disabled mid-session — best effort, same as
191
+ // `writeStoredDraft`.
192
+ }
193
+ }
package/src/chat/index.ts CHANGED
@@ -17,6 +17,7 @@ export type {
17
17
  SessionMenu,
18
18
  SessionState,
19
19
  TurnStatus,
20
+ TypingVisibility,
20
21
  WsAction,
21
22
  WsEvent,
22
23
  } from "./protocol";
@@ -57,6 +58,8 @@ export {
57
58
  draftStorageKey,
58
59
  readStoredDraft,
59
60
  writeStoredDraft,
61
+ readStoredTypingVisibility,
62
+ writeStoredTypingVisibility,
60
63
  type DraftStorage,
61
64
  } from "./drafts";
62
65
 
@@ -51,6 +51,12 @@ export interface Message {
51
51
  author?: MessageAuthor | null;
52
52
  }
53
53
 
54
+ /** How much of an in-progress message its author lets OTHERS see: the words
55
+ * live as typed (`live`, today's behaviour), just the fact of typing
56
+ * (`typing`), or nothing at all until it's sent (`hidden`). Chosen per
57
+ * person, per browser (persisted in localStorage) — see useSessionSocket. */
58
+ export type TypingVisibility = "live" | "typing" | "hidden";
59
+
54
60
  export interface Draft {
55
61
  /** String PK (canopy sends `str(draft.pk)`). */
56
62
  id: string;
@@ -66,16 +72,24 @@ export interface Draft {
66
72
  * against `current_user_id` for echo-suppression; `last_editor` is kept
67
73
  * for servers that don't send it yet. */
68
74
  author_id?: number;
75
+ /** This author's chosen mode. Absent on an older server. */
76
+ visibility?: TypingVisibility;
69
77
  }
70
78
 
71
79
  /** A peer's live, uncommitted draft — everyone else's box, broadcast as they
72
80
  * type. Keyed by author, never by draft id: a client only ever needs "what
73
81
  * is this person typing right now", and an empty `body` means they sent,
74
- * discarded, or cleared it. */
82
+ * discarded, or cleared it — OR its author is in `typing`/`hidden` mode,
83
+ * where the server withholds the words but still says whether they're
84
+ * typing via `typing`. */
75
85
  export interface PeerDraft {
76
86
  author: { id: number; name: string };
77
87
  body: string;
78
88
  at: string | null;
89
+ /** Whether this person is actively typing right now — the one fact the
90
+ * server still tells peers when `body` is withheld. Absent from an older
91
+ * server, where a non-empty `body` was the only signal. */
92
+ typing?: boolean;
79
93
  }
80
94
 
81
95
  /** A send that landed on the server but hasn't reached the agent's turn yet
@@ -196,6 +210,12 @@ export interface TurnStatus {
196
210
  /** The agent is sitting on a dialog. A separate axis from `state`: a turn
197
211
  * blocked on a question is still RUNNING as far as the turn is concerned. */
198
212
  menu_pending: boolean;
213
+ /** Why a `failed` turn failed, in the runner's words. Null for every other
214
+ * state. Absent on an older server. */
215
+ detail?: string | null;
216
+ /** When a settled turn finished — lets the kit drop a failure the
217
+ * conversation has since moved past. Absent on an older server. */
218
+ finished_at?: string | null;
199
219
  /** Nothing more will happen without someone asking for it. */
200
220
  settled: boolean;
201
221
  /** Nothing is moving and only a person can change that. The one question a
@@ -263,6 +283,12 @@ export type WsAction =
263
283
  | { action: "chat.send"; data: Record<string, never> }
264
284
  | { action: "chat.stop"; data: { message_id: string } }
265
285
  | { action: "draft.update"; data: { version: number; body: string } }
286
+ // The mode is its OWN idempotent frame, not a field on the version-guarded
287
+ // `draft.update` above — applied unconditionally server-side (no version
288
+ // check), so it can never race a keystroke and never itself causes a
289
+ // `draft_version_mismatch`. A `draft.update` carrying a stray `visibility`
290
+ // (an in-flight older 0.14 client) is simply ignored server-side.
291
+ | { action: "draft.set_visibility"; data: { visibility: TypingVisibility } }
266
292
  /** @deprecated accepted and ignored by the server since 0.13 — every editor
267
293
  * gets their own draft now, so there is no lock left to take over. */
268
294
  | { action: "draft.take_over"; data: Record<string, never> }
@@ -227,6 +227,29 @@ describe("sessionReducer — drafts", () => {
227
227
  expect(next.active_draft?.version).toBe(3)
228
228
  })
229
229
 
230
+ it("draft.updated carries the server's visibility into active_draft even while keeping the local body", () => {
231
+ // The drift check in useSessionSocket (`current.visibility !==
232
+ // typingVisibilityRef.current`) compares against THIS field — if the
233
+ // echo-suppression branch never updated it, that check would compare
234
+ // against a permanently stale value.
235
+ const prev = makeState({
236
+ current_user_id: 5,
237
+ active_draft: { ...baseDraft, body: "local typing", version: 3, visibility: "live" },
238
+ })
239
+ const next = sessionReducer(prev, {
240
+ event: "draft.updated",
241
+ data: {
242
+ ...baseDraft,
243
+ body: "stale server echo",
244
+ last_editor: 5,
245
+ version: 4,
246
+ visibility: "hidden",
247
+ } as Draft,
248
+ } as WsEvent)
249
+ expect(next.active_draft?.body).toBe("local typing")
250
+ expect(next.active_draft?.visibility).toBe("hidden")
251
+ })
252
+
230
253
  it("draft.updated accepts the body when another user is editing", () => {
231
254
  const prev = makeState({
232
255
  current_user_id: 5,
@@ -746,6 +769,30 @@ describe("per-person drafts", () => {
746
769
  expect(s.peer_drafts).toEqual([]);
747
770
  });
748
771
 
772
+ it("keeps an empty-body row when the server says the person is still typing", () => {
773
+ // `typing`/`hidden` visibility withholds the words but not the fact of
774
+ // typing — the row must survive rather than being read as "sent/cleared".
775
+ const s = sessionReducer(makeState(), {
776
+ event: "draft.typing",
777
+ data: { author: { id: 2, name: "Bo" }, body: "", at: null, typing: true },
778
+ } as WsEvent);
779
+ expect(s.peer_drafts).toEqual([
780
+ { author: { id: 2, name: "Bo" }, body: "", at: null, typing: true },
781
+ ]);
782
+ });
783
+
784
+ it("removes the row when the body is empty and typing is not true", () => {
785
+ let s = sessionReducer(makeState(), {
786
+ event: "draft.typing",
787
+ data: { author: { id: 2, name: "Bo" }, body: "", at: null, typing: true },
788
+ } as WsEvent);
789
+ s = sessionReducer(s, {
790
+ event: "draft.typing",
791
+ data: { author: { id: 2, name: "Bo" }, body: "", at: null, typing: false },
792
+ } as WsEvent);
793
+ expect(s.peer_drafts).toEqual([]);
794
+ });
795
+
749
796
  it("presence.left drops that person's typing row", () => {
750
797
  let s = sessionReducer(makeState(), {
751
798
  event: "draft.typing",
@@ -374,6 +374,13 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
374
374
  version: incoming.version,
375
375
  last_editor: incoming.last_editor,
376
376
  last_edit_at: incoming.last_edit_at,
377
+ // The server's CURRENT visibility, even while the body itself is
378
+ // kept local — `useSessionSocket`'s drift check compares against
379
+ // this field, and it must reflect what the server actually has,
380
+ // not a value frozen at whatever it was when this draft first
381
+ // loaded. `?? prev.active_draft.visibility` only for an older
382
+ // server that omits the field.
383
+ visibility: incoming.visibility ?? prev.active_draft.visibility,
377
384
  },
378
385
  };
379
386
  }
@@ -382,9 +389,12 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
382
389
 
383
390
  case "draft.typing": {
384
391
  // Someone else's box, live. Keyed by author; an empty body means they
385
- // sent, discarded or cleared it.
392
+ // sent, discarded or cleared it — UNLESS `typing` is true, which means
393
+ // the words are simply withheld (typing/hidden visibility) while the
394
+ // person is still mid-thought, so the row is kept rather than removed.
386
395
  const rest = (prev.peer_drafts ?? []).filter((d) => d.author.id !== frame.data.author.id);
387
- return { ...prev, peer_drafts: frame.data.body ? [...rest, frame.data] : rest };
396
+ const keep = frame.data.body !== "" || frame.data.typing === true;
397
+ return { ...prev, peer_drafts: keep ? [...rest, frame.data] : rest };
388
398
  }
389
399
 
390
400
  case "session.queued":
@@ -136,4 +136,53 @@ describe("turnNotice", () => {
136
136
  // An older client meeting a newer server must fall silent, not crash.
137
137
  expect(turnNotice(status({ state: "some_future_state" }))).toBeNull();
138
138
  });
139
+
140
+ it("says why a turn failed when the runner gave a reason", () => {
141
+ // 2026-09-27: a bare "Could not finish." hid a note that said exactly
142
+ // what to do.
143
+ const n = turnNotice(status({
144
+ state: "failed", settled: true,
145
+ detail: "Your message was not delivered: the emdash session has unsent text in its prompt.",
146
+ }));
147
+ expect(n?.tone).toBe("error");
148
+ expect(n?.text).toBe(
149
+ "Could not finish — Your message was not delivered: the emdash session has unsent text in its prompt.",
150
+ );
151
+ });
152
+
153
+ it("keeps the bare wording for a failure with no reason, or an older server", () => {
154
+ expect(turnNotice(status({ state: "failed", settled: true }))?.text).toBe("Could not finish.");
155
+ expect(turnNotice(status({ state: "failed", settled: true, detail: null }))?.text)
156
+ .toBe("Could not finish.");
157
+ });
158
+
159
+ it("drops a settled failure once the conversation has moved past it", () => {
160
+ // Work typed straight into the session makes messages but no Turn, so the
161
+ // latest Turn can be a day-old failure under a thread that is fine.
162
+ const failed = status({
163
+ state: "failed", settled: true, finished_at: "2026-09-26T22:07:07Z",
164
+ });
165
+ expect(turnNotice(failed, { latestMessageAt: "2026-09-27T17:21:38Z" })).toBeNull();
166
+ expect(turnNotice(status({
167
+ state: "lost", settled: true, finished_at: "2026-09-26T22:07:07Z",
168
+ }), { latestMessageAt: "2026-09-27T17:21:38Z" })).toBeNull();
169
+ });
170
+
171
+ it("still shows a failure that is the conversation's last word", () => {
172
+ const failed = status({
173
+ state: "failed", settled: true, finished_at: "2026-09-26T22:07:07Z",
174
+ });
175
+ expect(turnNotice(failed, { latestMessageAt: "2026-09-26T22:06:01Z" })?.tone).toBe("error");
176
+ expect(turnNotice(failed, { latestMessageAt: null })?.tone).toBe("error");
177
+ expect(turnNotice(failed)?.tone).toBe("error");
178
+ });
179
+
180
+ it("never lets a newer message hide a turn that is not settled", () => {
181
+ // A live or stuck turn is still owed something, whatever else was said.
182
+ const n = turnNotice(
183
+ status({ state: "waiting_runner", stuck: true, finished_at: "2026-09-26T22:07:07Z" }),
184
+ { latestMessageAt: "2026-09-27T17:21:38Z" },
185
+ );
186
+ expect(n?.tone).toBe("warn");
187
+ });
139
188
  });
@@ -95,9 +95,21 @@ export interface TurnNotice {
95
95
  * `blocked` deliberately returns null: the dialog itself is the notice, and
96
96
  * the host renders it as a menu with buttons. A sentence beside it would say
97
97
  * the same thing twice and compete with the thing you can actually press.
98
+ *
99
+ * `latestMessageAt` is the newest message on screen. A settled turn's notice
100
+ * is only true while that turn is the conversation's last word: work typed
101
+ * straight into the session makes messages but no Turn, so without this a
102
+ * day-old failure sat in red under a thread that was plainly working.
98
103
  */
99
- export function turnNotice(status?: TurnStatus | null): TurnNotice | null {
104
+ export function turnNotice(
105
+ status?: TurnStatus | null,
106
+ { latestMessageAt }: { latestMessageAt?: string | null } = {},
107
+ ): TurnNotice | null {
100
108
  if (!status) return null;
109
+ if (status.settled && status.finished_at && latestMessageAt
110
+ && Date.parse(latestMessageAt) > Date.parse(status.finished_at)) {
111
+ return null;
112
+ }
101
113
  const offer = status.cloud_runner && status.cloud_runner_id
102
114
  ? { name: status.cloud_runner, id: status.cloud_runner_id }
103
115
  : null;
@@ -139,7 +151,14 @@ export function turnNotice(status?: TurnStatus | null): TurnNotice | null {
139
151
  offer,
140
152
  };
141
153
  case "failed":
142
- return { tone: "error", text: "Could not finish.", offer: null };
154
+ // The reason, when the runner gave one: "failed" covers an undelivered
155
+ // message, a stop that did not take and a create that never rendered,
156
+ // and each has a different fix — which the note usually spells out.
157
+ return {
158
+ tone: "error",
159
+ text: status.detail ? `Could not finish — ${status.detail}` : "Could not finish.",
160
+ offer: null,
161
+ };
143
162
  case "missed":
144
163
  return { tone: "warn", text: "Missed — nothing picked it up in time.", offer: null };
145
164
  default:
@@ -1,8 +1,14 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
 
3
3
  import { fromAgui, resetAguiState } from "./agui";
4
- import type { Draft, Message, SessionState, WsEvent } from "./protocol";
5
- import { shouldSyncDraftLive } from "./drafts";
4
+ import type { Draft, Message, SessionState, TypingVisibility, WsEvent } from "./protocol";
5
+ import {
6
+ TYPING_VISIBILITY_STORAGE_KEY,
7
+ defaultDraftStorage,
8
+ readStoredTypingVisibility,
9
+ shouldSyncDraftLive,
10
+ writeStoredTypingVisibility,
11
+ } from "./drafts";
6
12
  import { prependHistory } from "./history";
7
13
  import {
8
14
  REDUCER_EVENTS,
@@ -185,6 +191,14 @@ export interface UseSessionSocketResult {
185
191
  * they're the same send rather than rendering it twice. */
186
192
  noteLocalSend: (text: string, clientId?: string) => void;
187
193
  lastError: string | null;
194
+ /** This person's chosen mode for how their OWN in-progress message appears
195
+ * to others — persisted per browser (`canopy.chat.typingVisibility`).
196
+ * Defaults to `"live"`, today's behaviour. */
197
+ typingVisibility: TypingVisibility;
198
+ /** Change the mode. When there is a non-empty draft body and live sync is
199
+ * on (somebody else is present), sends one `draft.update` immediately so
200
+ * peers switch right away rather than waiting for the next keystroke. */
201
+ setTypingVisibility: (visibility: TypingVisibility) => void;
188
202
  }
189
203
 
190
204
  // Frames the reducer handles: its own list, so the two cannot drift (see
@@ -211,6 +225,11 @@ export function useSessionSocket({
211
225
  // this window, so nothing else in the state can express it, and without it
212
226
  // the Stop control is unreachable exactly when the turn is stuck.
213
227
  const [awaitingReply, setAwaitingReply] = useState(false);
228
+ // Per-browser, not per-session — a standing choice about how you type, read
229
+ // once at mount from the same try/catch-safe storage `SendBox` uses.
230
+ const [typingVisibility, setTypingVisibilityState] = useState<TypingVisibility>(
231
+ () => readStoredTypingVisibility(defaultDraftStorage()) ?? "live",
232
+ );
214
233
 
215
234
  const socketRef = useRef<WebSocket | null>(null);
216
235
  const stateRef = useRef<SessionState>(INITIAL_STATE);
@@ -227,10 +246,21 @@ export function useSessionSocket({
227
246
  // vocabulary mid-session.
228
247
  const protocolRef = useRef(protocol);
229
248
  protocolRef.current = protocol;
249
+ // Read wherever the mode needs to be sent or compared, closing over
250
+ // whatever is CURRENT rather than whatever a callback was created with.
251
+ const typingVisibilityRef = useRef(typingVisibility);
252
+ typingVisibilityRef.current = typingVisibility;
230
253
  const warnedNativeRef = useRef(false);
231
254
  // Control frames that must not be lost across a reconnect (currently
232
255
  // only chat.stop). The WS-world analogue of an abortable chat transport.
233
256
  const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
257
+ // A mode the person CHOSE while this socket could not carry it (closed, or
258
+ // open but before the first snapshot told us what the server holds). Sent
259
+ // once on the next snapshot, then cleared. Nothing else is ever pushed on
260
+ // connect: a device with nothing stored has only a DEFAULT, and pushing a
261
+ // default would expose words its author hid from another device.
262
+ const pendingVisibilityRef = useRef<TypingVisibility | null>(null);
263
+ const snapshotSeenRef = useRef(false);
234
264
  // Sends awaiting the server's receipt: client_id -> {text, timer}.
235
265
  const unconfirmedRef = useRef(new Map<string, { text: string; timer: number | null }>());
236
266
  const resendRef = useRef(resendOverHttp);
@@ -248,6 +278,24 @@ export function useSessionSocket({
248
278
  onUnknownEventRef.current = onUnknownEvent;
249
279
  }, [onUnknownEvent]);
250
280
 
281
+ // Belt-and-braces for the SAME cross-tab sync `draft.updated` adoption
282
+ // covers: a native `storage` event fires in every OTHER tab the instant one
283
+ // tab writes the key (never the writer's own tab), so it catches the case
284
+ // no socket frame can — a tab with no live socket right now.
285
+ useEffect(() => {
286
+ if (typeof window === "undefined") return;
287
+ const onStorage = (e: StorageEvent) => {
288
+ if (e.key !== TYPING_VISIBILITY_STORAGE_KEY) return;
289
+ const next = readStoredTypingVisibility(defaultDraftStorage());
290
+ if (next != null && next !== typingVisibilityRef.current) {
291
+ typingVisibilityRef.current = next;
292
+ setTypingVisibilityState(next);
293
+ }
294
+ };
295
+ window.addEventListener("storage", onStorage);
296
+ return () => window.removeEventListener("storage", onStorage);
297
+ }, []);
298
+
251
299
  const send = useCallback((frame: { action: string; data: unknown }) => {
252
300
  const ws = socketRef.current;
253
301
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -298,12 +346,50 @@ export function useSessionSocket({
298
346
  ) {
299
347
  setAwaitingReply(false);
300
348
  }
349
+ // `draft.updated` only ever reaches the AUTHOR's own sockets (see
350
+ // `_broadcast_draft`), so this is always MY draft, echoed — including
351
+ // from a mode change in a DIFFERENT tab of my own, or from THIS tab's own
352
+ // `draft.set_visibility` round-tripping back. Adopted UNCONDITIONALLY:
353
+ // since the mode is now its own idempotent, unversioned frame
354
+ // (`draft.set_visibility`) rather than a field racing keystrokes on
355
+ // `draft.update`, the only thing that ever changes it server-side is an
356
+ // explicit choice — made here, in another tab, or on another device — so
357
+ // last-choice-wins is simply correct, with no downgrade race to guard
358
+ // against.
359
+ if (frame.event === "draft.updated" && frame.data.visibility) {
360
+ if (frame.data.visibility !== typingVisibilityRef.current) {
361
+ typingVisibilityRef.current = frame.data.visibility;
362
+ setTypingVisibilityState(frame.data.visibility);
363
+ writeStoredTypingVisibility(defaultDraftStorage(), frame.data.visibility);
364
+ }
365
+ }
301
366
  // The server's receipt for one of OUR sends: stop waiting on it.
302
367
  if (frame.event === "draft.committed" && frame.data.client_id) {
303
368
  const entry = unconfirmedRef.current.get(frame.data.client_id);
304
369
  if (entry?.timer != null) window.clearTimeout(entry.timer);
305
370
  unconfirmedRef.current.delete(frame.data.client_id);
306
371
  }
372
+ // On a snapshot (connect or reconnect) the SERVER's stored mode is the
373
+ // truth — it is the last explicit choice from any tab or device. The one
374
+ // exception is a choice made HERE while the socket could not carry it
375
+ // (`pendingVisibilityRef`): that is newer, so it is sent now, before any
376
+ // later joiner's snapshot can read the older mode. A local value that was
377
+ // never chosen (nothing stored, a private window, the widget's partitioned
378
+ // storage) is only a default, and is never pushed. No own draft (a viewer
379
+ // or a contact) means there is nothing of ours to set.
380
+ if (frame.event === "session.state") {
381
+ snapshotSeenRef.current = true;
382
+ const ad = frame.data.active_draft;
383
+ const pending = pendingVisibilityRef.current;
384
+ pendingVisibilityRef.current = null;
385
+ if (ad != null && pending != null) {
386
+ send({ action: "draft.set_visibility", data: { visibility: pending } });
387
+ } else if (ad?.visibility && ad.visibility !== typingVisibilityRef.current) {
388
+ typingVisibilityRef.current = ad.visibility;
389
+ setTypingVisibilityState(ad.visibility);
390
+ writeStoredTypingVisibility(defaultDraftStorage(), ad.visibility);
391
+ }
392
+ }
307
393
  // Side-effect events: handle BEFORE setState so React strict-mode's
308
394
  // double-invocation of the updater doesn't double-fire the effect.
309
395
  if (frame.event === "session.title_updated") {
@@ -318,7 +404,9 @@ export function useSessionSocket({
318
404
  typeof frame.data.detail === "object"
319
405
  ) {
320
406
  // Clear any pending optimistic body so the user's stale local
321
- // text doesn't auto-re-send with the new version.
407
+ // text doesn't auto-re-send with the new version. The mode is
408
+ // unaffected by this — it never rides `draft.update`, so a body
409
+ // conflict here has nothing to do with it and needs no recovery.
322
410
  pendingDraftBodyRef.current = null;
323
411
  if (draftDebounceRef.current != null) {
324
412
  window.clearTimeout(draftDebounceRef.current);
@@ -334,7 +422,7 @@ export function useSessionSocket({
334
422
  return;
335
423
  }
336
424
  setState((prev) => sessionReducer(prev, frame));
337
- }, []);
425
+ }, [send]);
338
426
 
339
427
  const connect = useCallback(() => {
340
428
  if (closedByUserRef.current) return;
@@ -349,6 +437,7 @@ export function useSessionSocket({
349
437
  socketRef.current = ws;
350
438
 
351
439
  ws.onopen = () => {
440
+ snapshotSeenRef.current = false;
352
441
  setConnected(true);
353
442
  reconnectAttemptRef.current = 0;
354
443
  // Flush any control frames that were queued while the socket was
@@ -398,6 +487,7 @@ export function useSessionSocket({
398
487
  };
399
488
 
400
489
  ws.onclose = () => {
490
+ snapshotSeenRef.current = false;
401
491
  setConnected(false);
402
492
  // A send still waiting for its receipt will not get one on this socket.
403
493
  for (const clientId of [...unconfirmedRef.current.keys()]) rescueSend(clientId);
@@ -515,6 +605,31 @@ export function useSessionSocket({
515
605
  [send],
516
606
  );
517
607
 
608
+ const setTypingVisibility = useCallback(
609
+ (visibility: TypingVisibility) => {
610
+ setTypingVisibilityState(visibility);
611
+ typingVisibilityRef.current = visibility;
612
+ writeStoredTypingVisibility(defaultDraftStorage(), visibility);
613
+ // The mode is its OWN idempotent frame — applied unconditionally
614
+ // server-side, no version check — so there is no presence gate: a mode
615
+ // change must reach the server even while ALONE, or a peer who joins
616
+ // later reads the old mode straight off their connect snapshot. Until
617
+ // the first snapshot has told us what the server holds (or while the
618
+ // socket is closed) the choice waits in `pendingVisibilityRef` and the
619
+ // snapshot sends it. With no own draft (a viewer or a contact) there is
620
+ // nothing to set, and the server would refuse it anyway.
621
+ const ws = socketRef.current;
622
+ const open = ws != null && ws.readyState === WebSocket.OPEN;
623
+ if (!open || !snapshotSeenRef.current) {
624
+ pendingVisibilityRef.current = visibility;
625
+ return;
626
+ }
627
+ if (stateRef.current.active_draft == null) return;
628
+ send({ action: "draft.set_visibility", data: { visibility } });
629
+ },
630
+ [send],
631
+ );
632
+
518
633
  // @deprecated no-op since 0.13 — the server accepts and ignores
519
634
  // `draft.take_over` too (see WsAction), so this is kept only so a host
520
635
  // wired to the old prop doesn't need an upgrade-day edit.
@@ -645,5 +760,7 @@ export function useSessionSocket({
645
760
  prependMessages,
646
761
  noteLocalSend,
647
762
  lastError,
763
+ typingVisibility,
764
+ setTypingVisibility,
648
765
  };
649
766
  }
@@ -0,0 +1,280 @@
1
+ // @vitest-environment jsdom
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { useSessionSocket } from "./useSessionSocket";
6
+
7
+ /** Per-person typing visibility: a person chooses how their own in-progress
8
+ * message appears to others (live / typing / hidden). The mode is its OWN
9
+ * idempotent, unversioned frame (`draft.set_visibility`) — never a field on
10
+ * the version-guarded `draft.update` keystroke frame, which is exactly what
11
+ * let a stale keystroke echo race a mode change and downgrade it
12
+ * server-side (canopy-ui#… "hidden->live->hidden" regression). The hook
13
+ * holds the choice, persists it per browser, and applies it unconditionally
14
+ * both ways: every explicit choice reaches the server regardless of
15
+ * presence or body, and a `draft.updated` echo's visibility is adopted
16
+ * unconditionally (only an explicit choice — here, another tab, or another
17
+ * device — ever changes it server-side, so last-choice-wins is simply
18
+ * correct). */
19
+
20
+ class FakeSocket {
21
+ static OPEN = 1;
22
+ static last: FakeSocket | null = null;
23
+ sent: string[] = [];
24
+ onopen: (() => void) | null = null;
25
+ onmessage: ((e: { data: string }) => void) | null = null;
26
+ onclose: (() => void) | null = null;
27
+ onerror: (() => void) | null = null;
28
+ readyState = 1;
29
+ constructor(public url: string) {
30
+ FakeSocket.last = this;
31
+ }
32
+ send(data: string) {
33
+ this.sent.push(data);
34
+ }
35
+ close() {}
36
+ receive(frame: unknown) {
37
+ this.onmessage?.({ data: JSON.stringify(frame) });
38
+ }
39
+ }
40
+
41
+ const DRAFT = { id: "d1", slot: "next", status: "open", body: "", version: 1, last_editor: 0, last_edit_at: "" };
42
+
43
+ function snapshot(presenceIds: number[]) {
44
+ return {
45
+ event: "session.state",
46
+ data: { messages: [], active_draft: DRAFT, participants: [], presence_user_ids: presenceIds, current_user_id: 1 },
47
+ };
48
+ }
49
+
50
+ // A minimal, real Storage-shaped fake — the hook reads/writes it through
51
+ // `defaultDraftStorage()` (which returns `window.localStorage`), so replacing
52
+ // the global is how a remount is proven to persist across.
53
+ function fakeLocalStorage() {
54
+ const map = new Map<string, string>();
55
+ return {
56
+ getItem: (k: string) => map.get(k) ?? null,
57
+ setItem: (k: string, v: string) => void map.set(k, v),
58
+ removeItem: (k: string) => void map.delete(k),
59
+ clear: () => map.clear(),
60
+ };
61
+ }
62
+
63
+ let storage: ReturnType<typeof fakeLocalStorage>;
64
+
65
+ beforeEach(() => {
66
+ vi.useFakeTimers();
67
+ vi.stubGlobal("WebSocket", FakeSocket);
68
+ storage = fakeLocalStorage();
69
+ vi.stubGlobal("localStorage", storage);
70
+ });
71
+ afterEach(() => {
72
+ vi.useRealTimers();
73
+ vi.unstubAllGlobals();
74
+ });
75
+
76
+ const wsUrl = () => "wss://h/x";
77
+
78
+ function connectedWith(presenceIds: number[]) {
79
+ const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
80
+ act(() => {
81
+ FakeSocket.last!.onopen?.();
82
+ FakeSocket.last!.receive(snapshot(presenceIds));
83
+ });
84
+ return hook;
85
+ }
86
+
87
+ function sentFrames(action: string) {
88
+ return FakeSocket.last!.sent.map((f) => JSON.parse(f)).filter((f) => f.action === action);
89
+ }
90
+ const draftUpdateFrames = () => sentFrames("draft.update");
91
+ const setVisibilityFrames = () => sentFrames("draft.set_visibility");
92
+
93
+ describe("typingVisibility", () => {
94
+ it("defaults to live", () => {
95
+ const hook = connectedWith([1, 2]);
96
+ expect(hook.result.current.typingVisibility).toBe("live");
97
+ });
98
+
99
+ it("persists the mode across a remount", () => {
100
+ const first = connectedWith([1, 2]);
101
+ act(() => first.result.current.setTypingVisibility("hidden"));
102
+ first.unmount();
103
+
104
+ const second = connectedWith([1, 2]);
105
+ expect(second.result.current.typingVisibility).toBe("hidden");
106
+ });
107
+
108
+ it("choosing a mode sends draft.set_visibility, presence or not, body or not", () => {
109
+ // No gate at all: it's one tiny idempotent frame, unconditional
110
+ // server-side too — there is nothing left to gate on.
111
+ const alone = connectedWith([1]);
112
+ act(() => alone.result.current.setTypingVisibility("hidden"));
113
+ expect(setVisibilityFrames()).toEqual([{ action: "draft.set_visibility", data: { visibility: "hidden" } }]);
114
+ });
115
+
116
+ it("a keystroke's draft.update never carries a visibility field", () => {
117
+ const hook = connectedWith([1, 2]);
118
+ act(() => hook.result.current.setTypingVisibility("typing"));
119
+ act(() => hook.result.current.updateDraft("hello"));
120
+ act(() => vi.advanceTimersByTime(200));
121
+ const last = draftUpdateFrames().at(-1)!;
122
+ expect(last.data).toEqual({ version: 1, body: "hello" });
123
+ });
124
+
125
+ it("adopts a mode carried on my own draft.updated frame (other-tab sync)", () => {
126
+ const hook = connectedWith([1, 2]);
127
+ expect(hook.result.current.typingVisibility).toBe("live");
128
+ act(() =>
129
+ FakeSocket.last!.receive({
130
+ event: "draft.updated",
131
+ data: { ...DRAFT, visibility: "hidden" },
132
+ }),
133
+ );
134
+ expect(hook.result.current.typingVisibility).toBe("hidden");
135
+ expect(storage.getItem("canopy.chat.typingVisibility")).toBe("hidden");
136
+ });
137
+
138
+ it("adopts a LOOSENING carried on my own draft.updated frame too — last choice wins", () => {
139
+ // The mode can only ever change server-side via an explicit choice now
140
+ // (this tab, another tab, or another device), so a looser echo is a real
141
+ // choice made elsewhere, not a stale race — it must be adopted exactly
142
+ // like a tightening one.
143
+ const hook = connectedWith([1, 2]);
144
+ act(() => hook.result.current.setTypingVisibility("hidden"));
145
+ act(() =>
146
+ FakeSocket.last!.receive({
147
+ event: "draft.updated",
148
+ data: { ...DRAFT, visibility: "live" },
149
+ }),
150
+ );
151
+ expect(hook.result.current.typingVisibility).toBe("live");
152
+ expect(storage.getItem("canopy.chat.typingVisibility")).toBe("live");
153
+ });
154
+
155
+ // -- the connect snapshot: the SERVER's stored mode is the truth --
156
+ //
157
+ // A device with nothing stored (fresh phone, private window, blocked or
158
+ // partitioned storage — the embedded widget in a third-party iframe) only
159
+ // has a DEFAULT, and a default is not a choice: pushing it would expose
160
+ // words its author hid from another device. Only a choice made while this
161
+ // socket could not carry it (pending) is sent on connect.
162
+
163
+ function ownSnapshot(activeDraft: unknown) {
164
+ return {
165
+ event: "session.state",
166
+ data: {
167
+ messages: [],
168
+ active_draft: activeDraft,
169
+ participants: [],
170
+ presence_user_ids: [1, 2],
171
+ current_user_id: 1,
172
+ },
173
+ };
174
+ }
175
+
176
+ it("a fresh device adopts the server's mode off the snapshot and sends nothing", () => {
177
+ // Nothing stored: the local value is only the "live" default.
178
+ const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
179
+ act(() => {
180
+ FakeSocket.last!.onopen?.();
181
+ FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, body: "x", visibility: "hidden" }));
182
+ });
183
+ expect(setVisibilityFrames()).toEqual([]);
184
+ expect(hook.result.current.typingVisibility).toBe("hidden");
185
+ expect(storage.getItem("canopy.chat.typingVisibility")).toBe("hidden");
186
+ });
187
+
188
+ it("a stale stored mode yields to the server's on connect, sending nothing", () => {
189
+ storage.setItem("canopy.chat.typingVisibility", "live");
190
+ const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
191
+ act(() => {
192
+ FakeSocket.last!.onopen?.();
193
+ FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, visibility: "hidden" }));
194
+ });
195
+ expect(setVisibilityFrames()).toEqual([]);
196
+ expect(hook.result.current.typingVisibility).toBe("hidden");
197
+ expect(storage.getItem("canopy.chat.typingVisibility")).toBe("hidden");
198
+ });
199
+
200
+ it("a choice made while the socket was closed is sent once on the snapshot, then cleared", () => {
201
+ const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
202
+ FakeSocket.last!.readyState = 0; // CONNECTING
203
+ act(() => hook.result.current.setTypingVisibility("hidden"));
204
+ expect(setVisibilityFrames()).toEqual([]);
205
+ act(() => {
206
+ FakeSocket.last!.readyState = 1;
207
+ FakeSocket.last!.onopen?.();
208
+ FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, visibility: "live" }));
209
+ });
210
+ expect(setVisibilityFrames()).toEqual([
211
+ { action: "draft.set_visibility", data: { visibility: "hidden" } },
212
+ ]);
213
+ expect(hook.result.current.typingVisibility).toBe("hidden");
214
+ // Pending is spent: a later snapshot (a reconnect) sends nothing more,
215
+ // and a server mode that disagrees then is simply adopted.
216
+ act(() => FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, visibility: "hidden" })));
217
+ expect(setVisibilityFrames()).toHaveLength(1);
218
+ });
219
+
220
+ it("a choice made after open but before the first snapshot is pending too", () => {
221
+ const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
222
+ act(() => FakeSocket.last!.onopen?.());
223
+ act(() => hook.result.current.setTypingVisibility("typing"));
224
+ expect(setVisibilityFrames()).toEqual([]);
225
+ act(() => FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, visibility: "hidden" })));
226
+ expect(setVisibilityFrames()).toEqual([
227
+ { action: "draft.set_visibility", data: { visibility: "typing" } },
228
+ ]);
229
+ expect(hook.result.current.typingVisibility).toBe("typing");
230
+ });
231
+
232
+ it("a choice made while connected is sent immediately and leaves nothing pending", () => {
233
+ const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
234
+ act(() => {
235
+ FakeSocket.last!.onopen?.();
236
+ FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, visibility: "live" }));
237
+ });
238
+ act(() => hook.result.current.setTypingVisibility("hidden"));
239
+ expect(setVisibilityFrames()).toHaveLength(1);
240
+ act(() => FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, visibility: "hidden" })));
241
+ expect(setVisibilityFrames()).toHaveLength(1);
242
+ });
243
+
244
+ it("a viewer or contact (no own draft) never sends set_visibility", () => {
245
+ const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
246
+ FakeSocket.last!.readyState = 0;
247
+ act(() => hook.result.current.setTypingVisibility("hidden")); // pending
248
+ act(() => {
249
+ FakeSocket.last!.readyState = 1;
250
+ FakeSocket.last!.onopen?.();
251
+ FakeSocket.last!.receive(ownSnapshot(null));
252
+ });
253
+ act(() => hook.result.current.setTypingVisibility("typing")); // while open
254
+ expect(setVisibilityFrames()).toEqual([]);
255
+ expect(hook.result.current.typingVisibility).toBe("typing");
256
+ });
257
+
258
+ it("a draft_version_mismatch never touches the mode", () => {
259
+ const hook = connectedWith([1, 2]);
260
+ act(() => hook.result.current.setTypingVisibility("hidden"));
261
+ act(() => hook.result.current.updateDraft("secret"));
262
+ act(() => vi.advanceTimersByTime(200));
263
+
264
+ act(() =>
265
+ FakeSocket.last!.receive({
266
+ event: "session.error",
267
+ data: {
268
+ code: "draft_version_mismatch",
269
+ message: "Draft changed since your last edit.",
270
+ detail: { current_version: 2, current_body: "secret" },
271
+ },
272
+ }),
273
+ );
274
+
275
+ // No automatic resend of anything mode-related — a body conflict has
276
+ // nothing to do with the mode any more.
277
+ expect(hook.result.current.typingVisibility).toBe("hidden");
278
+ expect(setVisibilityFrames()).toHaveLength(1); // only the explicit choice above
279
+ });
280
+ });