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.
- package/package.json +1 -1
- package/src/chat/ChatPanel.multiplayer.test.tsx +209 -0
- package/src/chat/ChatPanel.tsx +77 -41
- package/src/chat/MessageItem.tsx +32 -2
- package/src/chat/MessageList.tsx +9 -0
- package/src/chat/PresenceChips.tsx +5 -35
- package/src/chat/QueuedRows.tsx +42 -0
- package/src/chat/SendBox.test.tsx +138 -37
- package/src/chat/SendBox.tsx +70 -109
- package/src/chat/TypingRows.test.tsx +33 -0
- package/src/chat/TypingRows.tsx +25 -0
- package/src/chat/agui.fixture.json +78 -23
- package/src/chat/agui.ts +6 -1
- package/src/chat/drafts.test.ts +43 -65
- package/src/chat/drafts.ts +41 -19
- package/src/chat/identity.ts +37 -0
- package/src/chat/index.ts +10 -3
- package/src/chat/protocol.ts +97 -5
- package/src/chat/sessionReducer.test.ts +168 -0
- package/src/chat/sessionReducer.ts +71 -20
- package/src/chat/useSessionSocket.http.test.tsx +50 -1
- package/src/chat/useSessionSocket.ts +208 -20
- package/src/chat/useSessionSocket.typingVisibility.test.tsx +280 -0
|
@@ -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 {
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
});
|