canopy-ui 0.12.2 → 0.14.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.
@@ -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,
@@ -23,17 +29,37 @@ const SEND_RECEIPT_TIMEOUT_MS = 4_000;
23
29
  const NOT_SENT =
24
30
  "Not sent: the connection dropped before canopy got it. Your words are kept here, so copy them and send again.";
25
31
 
26
- function newClientId(): string {
27
- const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
28
- return c?.randomUUID ? c.randomUUID() : `c${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
32
+ /**
33
+ * A client-generated nonce for a send's `client_id` — exported so a host
34
+ * driving its own HTTP send (the widget's opening message, before a socket
35
+ * exists to send over; `ChatPage`'s `/share` command) can mint the SAME id
36
+ * for both the REST body and the matching `noteLocalSend` call, which is what
37
+ * lets `QueuedRows.hideClientIds` recognise the two as one send rather than
38
+ * rendering it twice.
39
+ */
40
+ export function newClientId(): string {
41
+ // An idempotency nonce, not a secret — but it rides a request, so it comes
42
+ // from the platform CSPRNG, never Math.random (CodeQL js/insecure-randomness).
43
+ // randomUUID needs a secure context; getRandomValues does not.
44
+ const c = (globalThis as {
45
+ crypto?: { randomUUID?: () => string; getRandomValues?: (a: Uint8Array) => Uint8Array };
46
+ }).crypto;
47
+ if (c?.randomUUID) return c.randomUUID();
48
+ if (c?.getRandomValues) {
49
+ const bytes = c.getRandomValues(new Uint8Array(16));
50
+ return `c${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
51
+ }
52
+ return `c${Date.now().toString(36)}${(clientIdCounter++).toString(36)}`;
29
53
  }
30
54
 
55
+ let clientIdCounter = 0;
56
+
31
57
  const INITIAL_STATE: SessionState = {
32
58
  messages: [],
33
59
  active_draft: null,
34
60
  participants: [],
35
61
  presence_user_ids: [],
36
- current_user_id: 0,
62
+ current_user_id: null,
37
63
  };
38
64
 
39
65
  export interface UseSessionSocketOptions {
@@ -84,8 +110,15 @@ export interface UseSessionSocketOptions {
84
110
  * Everything else is unchanged — the same `state`, the same pending row, the
85
111
  * same "waiting for a reply" — so one chat UI serves a user and a contact
86
112
  * without a second send path in every host. Omit for a user (the default).
113
+ *
114
+ * Must pass `clientId` through as the send's `client_id` (both `/api/canopy-
115
+ * sessions/{id}/send` and `/api/contact/sessions/{id}/send` accept it) — it
116
+ * is the SAME id `sendChatOverHttp` stamps on the optimistic row, and without
117
+ * it the server's queued-turn projection has no way to recognise that row as
118
+ * the one it already showed, so the sender's own send rendered a second time
119
+ * as an unowned "queued" placeholder.
87
120
  */
88
- sendOverHttp?: (text: string) => Promise<unknown>;
121
+ sendOverHttp?: (text: string, clientId: string) => Promise<unknown>;
89
122
  /**
90
123
  * The HTTP route for a USER's send, used only when a socket send gets no
91
124
  * receipt (see `SEND_RECEIPT_TIMEOUT_MS`). Must pass `clientId` through as the
@@ -128,6 +161,10 @@ export interface UseSessionSocketResult {
128
161
  sendChat: () => void;
129
162
  stopChat: (messageId: string | null) => void;
130
163
  updateDraft: (body: string) => void;
164
+ /** @deprecated ignored since 0.13 — everyone has their own draft now, so
165
+ * there is no lock left to take over. Kept as a no-op so a host still
166
+ * passing it through (e.g. ace-web's `onTakeOver={socket.takeOverDraft}`)
167
+ * keeps compiling across the upgrade. */
131
168
  takeOverDraft: () => void;
132
169
  discardDraft: () => void;
133
170
  prependMessages: (older: Message[]) => void;
@@ -144,9 +181,24 @@ export interface UseSessionSocketResult {
144
181
  * by `sendChat` alone, and the user's own line is not a server row until
145
182
  * the agent's transcript ships it back. So you typed, pressed send, and got
146
183
  * an empty panel — for as long as the reply took, and forever if its runner
147
- * was offline. */
148
- noteLocalSend: (text: string) => void;
184
+ * was offline.
185
+ *
186
+ * Pass the SAME `clientId` the matching REST body sent (`sendChatOverHttp`
187
+ * does this internally; a host driving its own HTTP send — the widget's
188
+ * first message, `ChatPage`'s `/share` command — must generate one and pass
189
+ * it to both), so the optimistic row's `content.client_id` matches the
190
+ * server's queued-turn projection and `QueuedRows.hideClientIds` can tell
191
+ * they're the same send rather than rendering it twice. */
192
+ noteLocalSend: (text: string, clientId?: string) => void;
149
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;
150
202
  }
151
203
 
152
204
  // Frames the reducer handles: its own list, so the two cannot drift (see
@@ -173,6 +225,11 @@ export function useSessionSocket({
173
225
  // this window, so nothing else in the state can express it, and without it
174
226
  // the Stop control is unreachable exactly when the turn is stuck.
175
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
+ );
176
233
 
177
234
  const socketRef = useRef<WebSocket | null>(null);
178
235
  const stateRef = useRef<SessionState>(INITIAL_STATE);
@@ -189,10 +246,21 @@ export function useSessionSocket({
189
246
  // vocabulary mid-session.
190
247
  const protocolRef = useRef(protocol);
191
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;
192
253
  const warnedNativeRef = useRef(false);
193
254
  // Control frames that must not be lost across a reconnect (currently
194
255
  // only chat.stop). The WS-world analogue of an abortable chat transport.
195
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);
196
264
  // Sends awaiting the server's receipt: client_id -> {text, timer}.
197
265
  const unconfirmedRef = useRef(new Map<string, { text: string; timer: number | null }>());
198
266
  const resendRef = useRef(resendOverHttp);
@@ -210,6 +278,24 @@ export function useSessionSocket({
210
278
  onUnknownEventRef.current = onUnknownEvent;
211
279
  }, [onUnknownEvent]);
212
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
+
213
299
  const send = useCallback((frame: { action: string; data: unknown }) => {
214
300
  const ws = socketRef.current;
215
301
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -260,12 +346,50 @@ export function useSessionSocket({
260
346
  ) {
261
347
  setAwaitingReply(false);
262
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
+ }
263
366
  // The server's receipt for one of OUR sends: stop waiting on it.
264
367
  if (frame.event === "draft.committed" && frame.data.client_id) {
265
368
  const entry = unconfirmedRef.current.get(frame.data.client_id);
266
369
  if (entry?.timer != null) window.clearTimeout(entry.timer);
267
370
  unconfirmedRef.current.delete(frame.data.client_id);
268
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
+ }
269
393
  // Side-effect events: handle BEFORE setState so React strict-mode's
270
394
  // double-invocation of the updater doesn't double-fire the effect.
271
395
  if (frame.event === "session.title_updated") {
@@ -280,7 +404,9 @@ export function useSessionSocket({
280
404
  typeof frame.data.detail === "object"
281
405
  ) {
282
406
  // Clear any pending optimistic body so the user's stale local
283
- // 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.
284
410
  pendingDraftBodyRef.current = null;
285
411
  if (draftDebounceRef.current != null) {
286
412
  window.clearTimeout(draftDebounceRef.current);
@@ -296,7 +422,7 @@ export function useSessionSocket({
296
422
  return;
297
423
  }
298
424
  setState((prev) => sessionReducer(prev, frame));
299
- }, []);
425
+ }, [send]);
300
426
 
301
427
  const connect = useCallback(() => {
302
428
  if (closedByUserRef.current) return;
@@ -311,6 +437,7 @@ export function useSessionSocket({
311
437
  socketRef.current = ws;
312
438
 
313
439
  ws.onopen = () => {
440
+ snapshotSeenRef.current = false;
314
441
  setConnected(true);
315
442
  reconnectAttemptRef.current = 0;
316
443
  // Flush any control frames that were queued while the socket was
@@ -360,6 +487,7 @@ export function useSessionSocket({
360
487
  };
361
488
 
362
489
  ws.onclose = () => {
490
+ snapshotSeenRef.current = false;
363
491
  setConnected(false);
364
492
  // A send still waiting for its receipt will not get one on this socket.
365
493
  for (const clientId of [...unconfirmedRef.current.keys()]) rescueSend(clientId);
@@ -477,9 +605,35 @@ export function useSessionSocket({
477
605
  [send],
478
606
  );
479
607
 
480
- const takeOverDraft = useCallback(() => {
481
- send({ action: "draft.take_over", data: {} });
482
- }, [send]);
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
+
633
+ // @deprecated no-op since 0.13 — the server accepts and ignores
634
+ // `draft.take_over` too (see WsAction), so this is kept only so a host
635
+ // wired to the old prop doesn't need an upgrade-day edit.
636
+ const takeOverDraft = useCallback(() => undefined, []);
483
637
 
484
638
  const discardDraft = useCallback(() => {
485
639
  send({ action: "draft.discard", data: {} });
@@ -521,7 +675,29 @@ export function useSessionSocket({
521
675
  });
522
676
  }, []);
523
677
 
524
- const noteLocalSend = useCallback((text: string) => {
678
+ // A failed send's restore, NOT the same as `updateLocalDraft`: this is a
679
+ // stale write racing whatever the person typed while the request was in
680
+ // flight, so it must never overwrite it. A functional update reads the
681
+ // CURRENT draft rather than one closed over at send time — sendChatOverHttp
682
+ // cleared the draft to null before the request even went out, so "the local
683
+ // draft is still null or empty" means nothing newer has been typed since,
684
+ // and only then is it safe to put the failed body back. If the person has
685
+ // already started something else, that stays exactly as they left it; the
686
+ // failed send is simply lost rather than clobbering live typing (2026-09-26
687
+ // review: "contact sends hello, starts typing world, hello's failure
688
+ // overwrote world").
689
+ const restoreFailedLocalDraft = useCallback((body: string) => {
690
+ setLocalDraft((prev) =>
691
+ prev == null || prev.body === ""
692
+ ? {
693
+ id: "local", slot: "next", status: "open", body, version: 0,
694
+ last_editor: 0, last_edit_at: new Date().toISOString(),
695
+ }
696
+ : prev,
697
+ );
698
+ }, []);
699
+
700
+ const noteLocalSend = useCallback((text: string, clientId?: string) => {
525
701
  setAwaitingReply(true);
526
702
  const body = text.trim();
527
703
  if (!body) return;
@@ -539,6 +715,7 @@ export function useSessionSocket({
539
715
  message_id: `local:${Date.now()}`,
540
716
  turn_index: prev.messages.reduce((acc, m) => Math.max(acc, m.turn_index), 0) + 1,
541
717
  plaintext: body,
718
+ client_id: clientId,
542
719
  },
543
720
  }),
544
721
  );
@@ -547,15 +724,24 @@ export function useSessionSocket({
547
724
  const sendChatOverHttp = useCallback(() => {
548
725
  const body = (localDraft?.body ?? "").trim();
549
726
  if (!body || !sendOverHttp) return;
550
- noteLocalSend(body);
727
+ // ONE id for both halves of this send — the optimistic row and the REST
728
+ // body — so the server's queued-turn projection and this row compare
729
+ // equal (`QueuedRows.hideClientIds`) instead of rendering the same send
730
+ // twice, once as the sender's own bubble and once as an unowned "queued"
731
+ // placeholder (a contact's `author.user_id` is never set, so without a
732
+ // shared id that placeholder didn't even read as "mine").
733
+ const clientId = newClientId();
734
+ noteLocalSend(body, clientId);
551
735
  setLocalDraft(null);
552
- sendOverHttp(body).catch((err: unknown) => {
736
+ sendOverHttp(body, clientId).catch((err: unknown) => {
553
737
  setAwaitingReply(false);
554
738
  setLastError(err instanceof Error ? err.message : "the message could not be sent");
555
- // Put the words back, so a failed send never costs what was typed.
556
- updateLocalDraft(body);
739
+ // Put the words back, so a failed send never costs what was typed —
740
+ // but only if nothing NEWER has been typed since (see
741
+ // `restoreFailedLocalDraft`).
742
+ restoreFailedLocalDraft(body);
557
743
  });
558
- }, [localDraft, sendOverHttp, noteLocalSend, updateLocalDraft]);
744
+ }, [localDraft, sendOverHttp, noteLocalSend, restoreFailedLocalDraft]);
559
745
 
560
746
  const exposedState = useMemo(
561
747
  () => (sendOverHttp ? { ...state, active_draft: localDraft } : state),
@@ -574,5 +760,7 @@ export function useSessionSocket({
574
760
  prependMessages,
575
761
  noteLocalSend,
576
762
  lastError,
763
+ typingVisibility,
764
+ setTypingVisibility,
577
765
  };
578
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
+ });