canopy-ui 0.3.0 → 0.6.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.
@@ -0,0 +1,368 @@
1
+ import type { Draft, Message, SessionState, WsEvent } from "./protocol";
2
+
3
+ // Pure reducer for SessionState — extracted from useSessionSocket so it
4
+ // can be unit-tested without WebSocket plumbing. Side-effect events
5
+ // (session.title_updated → optional injected callback; session.error →
6
+ // setLastError + clear draft debounce) stay in the hook itself.
7
+ //
8
+ // Keep this file dependency-free (no React) so a vitest run doesn't pull
9
+ // jsdom or RTL.
10
+ //
11
+ // canopy adaptation vs ace: message/draft ids are STRINGS, and
12
+ // `chat.stream_start` UPSERTS the assistant message — canopy's
13
+ // `draft.committed` carries only `user_message_id` (no assistant id to
14
+ // pre-insert), so the assistant row is created lazily when its first stream
15
+ // frame arrives.
16
+ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState {
17
+ switch (frame.event) {
18
+ case "session.state":
19
+ return frame.data;
20
+
21
+ case "chat.stream_start": {
22
+ // Upsert: if the assistant message already exists (rare — a runner that
23
+ // pre-inserts it), flip it to streaming; otherwise create it. canopy's
24
+ // draft.committed cannot pre-send the assistant id, so this is the
25
+ // normal path for making the streamed reply visible.
26
+ const exists = prev.messages.some((m) => m.id === frame.data.message_id);
27
+ if (exists) {
28
+ return {
29
+ ...prev,
30
+ messages: prev.messages.map((m) =>
31
+ m.id === frame.data.message_id
32
+ ? { ...m, status: "streaming" as const }
33
+ : m,
34
+ ),
35
+ };
36
+ }
37
+ const nowIso = new Date().toISOString();
38
+ const assistant: Message = {
39
+ id: frame.data.message_id,
40
+ turn_index: frame.data.turn_index,
41
+ role: "assistant",
42
+ content: {},
43
+ plaintext: "",
44
+ status: "streaming",
45
+ error_detail: null,
46
+ started_at: nowIso,
47
+ completed_at: null,
48
+ created_at: nowIso,
49
+ };
50
+ return { ...prev, messages: [...prev.messages, assistant] };
51
+ }
52
+
53
+ case "session.activity":
54
+ return { ...prev, activity: frame.data.state };
55
+
56
+ case "chat.user_message": {
57
+ // Someone typed into emdash, OR into this page. Both reach here, and that
58
+ // is why matching on turn_index alone is not enough: a web send writes its
59
+ // row at a DENSE index (services._next_index), then the agent reads the
60
+ // message and the transcript re-ships the same text at a COMPOSITE ordinal
61
+ // (record * BLOCK_STRIDE + block). Same words, two different indices, two
62
+ // different ids — so the upsert missed and the message rendered twice
63
+ // live, while a reload showed it once (get_or_create dedupes server-side).
64
+ //
65
+ // Falling back to matching identical text on a recent user row closes it.
66
+ // Deliberately narrow: same role, same text, and only against the tail, so
67
+ // a genuine repeat of a short message ("yes") sent much later still lands
68
+ // as its own row.
69
+ const RECENT_USER_ROWS = 6;
70
+ const sameText = (m: Message) =>
71
+ m.role === "user" &&
72
+ m.plaintext.trim() !== "" &&
73
+ m.plaintext.trim() === frame.data.plaintext.trim();
74
+ const recentUsers = prev.messages.filter((m) => m.role === "user").slice(-RECENT_USER_ROWS);
75
+ const existing =
76
+ prev.messages.find(
77
+ (m) => m.id === frame.data.message_id ||
78
+ (m.role === "user" && m.turn_index === frame.data.turn_index),
79
+ ) ?? recentUsers.find(sameText);
80
+ if (existing) {
81
+ return {
82
+ ...prev,
83
+ messages: prev.messages.map((m) =>
84
+ m === existing
85
+ ? {
86
+ ...m,
87
+ id: frame.data.message_id,
88
+ // Take the incoming ordinal: the transcript's composite index
89
+ // is the durable one, so the row sorts where a reload puts it.
90
+ turn_index: frame.data.turn_index,
91
+ plaintext: frame.data.plaintext,
92
+ }
93
+ : m,
94
+ ),
95
+ };
96
+ }
97
+ const nowIso = new Date().toISOString();
98
+ const user: Message = {
99
+ id: frame.data.message_id,
100
+ turn_index: frame.data.turn_index,
101
+ role: "user",
102
+ content: { text: frame.data.plaintext },
103
+ plaintext: frame.data.plaintext,
104
+ status: "complete",
105
+ error_detail: null,
106
+ started_at: null,
107
+ completed_at: null,
108
+ created_at: nowIso,
109
+ };
110
+ return { ...prev, messages: [...prev.messages, user] };
111
+ }
112
+
113
+ case "chat.delta":
114
+ return {
115
+ ...prev,
116
+ messages: prev.messages.map((m) =>
117
+ m.id === frame.data.message_id
118
+ ? { ...m, plaintext: m.plaintext + frame.data.text }
119
+ : m,
120
+ ),
121
+ };
122
+
123
+ case "chat.stream_complete":
124
+ return {
125
+ ...prev,
126
+ messages: prev.messages.map((m) =>
127
+ m.id === frame.data.message_id
128
+ ? {
129
+ ...m,
130
+ plaintext: frame.data.plaintext,
131
+ status: "complete" as const,
132
+ }
133
+ : m,
134
+ ),
135
+ };
136
+
137
+ case "chat.stream_error":
138
+ // NOTE: backend emits chat.stream_error with detail="cancelled"
139
+ // for stop-driven cancellation; there's no separate
140
+ // chat.stream_cancelled event in practice. Distinguished by detail.
141
+ return {
142
+ ...prev,
143
+ messages: prev.messages.map((m) =>
144
+ m.id === frame.data.message_id
145
+ ? {
146
+ ...m,
147
+ status: "error" as const,
148
+ error_detail: frame.data.detail,
149
+ }
150
+ : m,
151
+ ),
152
+ };
153
+
154
+ case "chat.stream_cancelled":
155
+ return {
156
+ ...prev,
157
+ messages: prev.messages.map((m) =>
158
+ m.id === frame.data.message_id
159
+ ? {
160
+ ...m,
161
+ status: "error" as const,
162
+ error_detail: `cancelled (partial: ${frame.data.partial_len} chars)`,
163
+ }
164
+ : m,
165
+ ),
166
+ };
167
+
168
+ case "chat.tool_use":
169
+ case "chat.tool_result": {
170
+ // Tool rows are real Message rows on the server; these frames are the
171
+ // same row arriving live. Upsert by id so a re-delivery (reconnect
172
+ // catch-up, a retried post) updates in place instead of doubling the
173
+ // row — and so a tool_result that lands twice can't orphan its pair.
174
+ const role = frame.event === "chat.tool_use" ? "tool_use" : "tool_result";
175
+ const block = frame.data.block ?? {};
176
+ const plaintext = typeof block.text === "string" ? block.text : "";
177
+ const id = frame.data.tool_message_id;
178
+ // Reconcile on the CORRELATION key, not the message id. The same tool call
179
+ // arrives twice by design: once live from a hook (no ordinal, id `seq:-1`)
180
+ // and once durably from the transcript (ordinal-keyed, a real id). They
181
+ // share only `tool_use_id` / `id` in content, so matching on that is what
182
+ // makes the live row a placeholder the durable row REPLACES rather than a
183
+ // duplicate that sits beside it forever.
184
+ const correlation =
185
+ role === "tool_use"
186
+ ? (block.id as string | undefined)
187
+ : (block.tool_use_id as string | undefined);
188
+ const existing = prev.messages.find(
189
+ (m) =>
190
+ m.id === id ||
191
+ (m.role === role &&
192
+ correlation !== undefined &&
193
+ correlation !== "" &&
194
+ (m.content as Record<string, unknown>)?.[
195
+ role === "tool_use" ? "id" : "tool_use_id"
196
+ ] === correlation),
197
+ );
198
+ if (existing) {
199
+ // Adopt the incoming id and ordinal: a durable row superseding a live
200
+ // placeholder must take over its identity, or the next update keys on a
201
+ // `seq:-1` that no longer means anything.
202
+ return {
203
+ ...prev,
204
+ messages: prev.messages.map((m) =>
205
+ m === existing
206
+ ? {
207
+ ...m,
208
+ id,
209
+ turn_index: frame.data.turn_index ?? m.turn_index,
210
+ content: block,
211
+ plaintext,
212
+ }
213
+ : m,
214
+ ),
215
+ };
216
+ }
217
+ const nowIso = new Date().toISOString();
218
+ const message: Message = {
219
+ id,
220
+ // Fall back to appending after the newest row when the server didn't
221
+ // send an ordinal — order still holds, since frames arrive in order.
222
+ turn_index:
223
+ frame.data.turn_index ??
224
+ (prev.messages[prev.messages.length - 1]?.turn_index ?? 0) + 1,
225
+ role,
226
+ content: block,
227
+ plaintext,
228
+ status: block.is_error === true ? "error" : "complete",
229
+ error_detail: null,
230
+ started_at: nowIso,
231
+ completed_at: nowIso,
232
+ created_at: nowIso,
233
+ };
234
+ return { ...prev, messages: [...prev.messages, message] };
235
+ }
236
+
237
+ case "draft.updated": {
238
+ const incoming = frame.data as Draft;
239
+ // If we're the current editor, keep our local body — the server
240
+ // echo is stale relative to keystrokes that happened since the
241
+ // debounced send. Only accept metadata (version, last_editor, etc).
242
+ if (
243
+ prev.active_draft &&
244
+ incoming.last_editor === prev.current_user_id
245
+ ) {
246
+ return {
247
+ ...prev,
248
+ active_draft: {
249
+ ...prev.active_draft,
250
+ version: incoming.version,
251
+ last_editor: incoming.last_editor,
252
+ last_edit_at: incoming.last_edit_at,
253
+ },
254
+ };
255
+ }
256
+ return { ...prev, active_draft: incoming };
257
+ }
258
+
259
+ case "draft.lock_changed":
260
+ if (prev.active_draft && prev.active_draft.id === frame.data.draft_id) {
261
+ return {
262
+ ...prev,
263
+ active_draft: {
264
+ ...prev.active_draft,
265
+ last_editor: frame.data.holder_user_id ?? prev.active_draft.last_editor,
266
+ },
267
+ };
268
+ }
269
+ return prev;
270
+
271
+ case "draft.committed": {
272
+ // Insert the optimistic USER message from the draft body that's about
273
+ // to be cleared. The assistant reply is NOT inserted here — canopy's
274
+ // draft.committed carries no assistant id; `chat.stream_start` upserts
275
+ // that row when the reply begins.
276
+ //
277
+ // Also clear active_draft.body here. The server creates a new empty
278
+ // draft with last_editor=sender, so the follow-up draft.updated hits
279
+ // the "keep local body" branch above and would otherwise leave the
280
+ // just-sent text in the textarea — which lets Enter re-send the same
281
+ // turn repeatedly.
282
+ const prevDraftBody = prev.active_draft?.body ?? "";
283
+ const maxTurnIndex = prev.messages.reduce(
284
+ (acc, msg) => Math.max(acc, msg.turn_index),
285
+ 0,
286
+ );
287
+ const nowIso = new Date().toISOString();
288
+ const userMessage: Message = {
289
+ id: frame.data.user_message_id,
290
+ turn_index: maxTurnIndex + 1,
291
+ role: "user",
292
+ content: { text: prevDraftBody },
293
+ plaintext: prevDraftBody,
294
+ status: "complete",
295
+ error_detail: null,
296
+ started_at: null,
297
+ completed_at: nowIso,
298
+ created_at: nowIso,
299
+ };
300
+ return {
301
+ ...prev,
302
+ active_draft: prev.active_draft
303
+ ? { ...prev.active_draft, body: "" }
304
+ : prev.active_draft,
305
+ messages: [...prev.messages, userMessage],
306
+ };
307
+ }
308
+
309
+ case "draft.discarded":
310
+ if (prev.active_draft && prev.active_draft.id === frame.data.draft_id) {
311
+ return {
312
+ ...prev,
313
+ active_draft: { ...prev.active_draft, body: "" },
314
+ };
315
+ }
316
+ return prev;
317
+
318
+ case "presence.joined": {
319
+ const ids = new Set(prev.presence_user_ids);
320
+ ids.add(frame.data.user_id);
321
+ return { ...prev, presence_user_ids: [...ids] };
322
+ }
323
+
324
+ case "presence.left":
325
+ return {
326
+ ...prev,
327
+ presence_user_ids: prev.presence_user_ids.filter(
328
+ (id) => id !== frame.data.user_id,
329
+ ),
330
+ };
331
+
332
+ case "session.error": {
333
+ // Side effects (setLastError, clear draft debounce) are handled
334
+ // by the hook; the reducer only knows about the version-mismatch
335
+ // recovery, which mutates active_draft.
336
+ if (
337
+ frame.data.code === "draft_version_mismatch" &&
338
+ frame.data.detail &&
339
+ typeof frame.data.detail === "object"
340
+ ) {
341
+ const detail = frame.data.detail as {
342
+ current_version: number;
343
+ current_body: string;
344
+ };
345
+ return prev.active_draft
346
+ ? {
347
+ ...prev,
348
+ active_draft: {
349
+ ...prev.active_draft,
350
+ version: detail.current_version,
351
+ body: detail.current_body,
352
+ },
353
+ }
354
+ : prev;
355
+ }
356
+ return prev;
357
+ }
358
+
359
+ case "session.title_updated":
360
+ // Pure reducer leaves this alone — the hook calls its optional
361
+ // onTitleUpdated callback on receipt and short-circuits. Included
362
+ // here so an exhaustive switch type-checks.
363
+ return prev;
364
+
365
+ default:
366
+ return prev;
367
+ }
368
+ }
@@ -0,0 +1,332 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ import type { Message, SessionState, WsEvent } from "./protocol";
4
+ import { shouldSyncDraftLive } from "./drafts";
5
+ import { prependHistory } from "./history";
6
+ import { sessionReducer } from "./sessionReducer";
7
+
8
+ const HEARTBEAT_INTERVAL_MS = 20_000;
9
+ const RECONNECT_DELAYS_MS = [1_000, 2_000, 5_000, 10_000];
10
+ const DRAFT_UPDATE_DEBOUNCE_MS = 150;
11
+
12
+ const INITIAL_STATE: SessionState = {
13
+ messages: [],
14
+ active_draft: null,
15
+ participants: [],
16
+ presence_user_ids: [],
17
+ current_user_id: 0,
18
+ };
19
+
20
+ export interface UseSessionSocketOptions {
21
+ /** The chat session id (UUID string). */
22
+ sessionId: string;
23
+ /**
24
+ * App-injected WebSocket URL builder. The kit never imports app routing/base
25
+ * helpers; the container passes one (e.g. canopy's `wsUrl`). Called with the
26
+ * relative path `ws/canopy-sessions/${sessionId}/`.
27
+ */
28
+ wsUrl: (path: string) => string;
29
+ /**
30
+ * Optional side-effect callback fired when the server broadcasts a
31
+ * `session.title_updated` (replaces ace's `notifySessionsUpdated`). The kit
32
+ * has no opinion on what to do with it.
33
+ */
34
+ onTitleUpdated?: () => void;
35
+ }
36
+
37
+ export interface UseSessionSocketResult {
38
+ state: SessionState;
39
+ connected: boolean;
40
+ /** A send is outstanding with no reply yet — the turn is QUEUED, waiting for
41
+ * a runner. Nothing in `state` can express this (there is no assistant
42
+ * message until the first token), and it is what keeps Stop reachable while
43
+ * a turn is stuck. */
44
+ awaitingReply: boolean;
45
+ sendChat: () => void;
46
+ stopChat: (messageId: string | null) => void;
47
+ updateDraft: (body: string) => void;
48
+ takeOverDraft: () => void;
49
+ discardDraft: () => void;
50
+ prependMessages: (older: Message[]) => void;
51
+ lastError: string | null;
52
+ }
53
+
54
+ export function useSessionSocket({
55
+ sessionId,
56
+ wsUrl,
57
+ onTitleUpdated,
58
+ }: UseSessionSocketOptions): UseSessionSocketResult {
59
+ const [state, setState] = useState<SessionState>(INITIAL_STATE);
60
+ const [connected, setConnected] = useState(false);
61
+ const [lastError, setLastError] = useState<string | null>(null);
62
+ // A send has gone out but no reply has begun — i.e. the turn is QUEUED,
63
+ // waiting for a runner to claim it. There is no assistant message during
64
+ // this window, so nothing else in the state can express it, and without it
65
+ // the Stop control is unreachable exactly when the turn is stuck.
66
+ const [awaitingReply, setAwaitingReply] = useState(false);
67
+
68
+ const socketRef = useRef<WebSocket | null>(null);
69
+ const stateRef = useRef<SessionState>(INITIAL_STATE);
70
+ const reconnectAttemptRef = useRef(0);
71
+ const heartbeatTimerRef = useRef<number | null>(null);
72
+ const draftDebounceRef = useRef<number | null>(null);
73
+ const pendingDraftBodyRef = useRef<string | null>(null);
74
+ const closedByUserRef = useRef(false);
75
+ const onTitleUpdatedRef = useRef(onTitleUpdated);
76
+ // Control frames that must not be lost across a reconnect (currently
77
+ // only chat.stop). The WS-world analogue of an abortable chat transport.
78
+ const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
79
+
80
+ useEffect(() => {
81
+ stateRef.current = state;
82
+ }, [state]);
83
+
84
+ useEffect(() => {
85
+ onTitleUpdatedRef.current = onTitleUpdated;
86
+ }, [onTitleUpdated]);
87
+
88
+ const send = useCallback((frame: { action: string; data: unknown }) => {
89
+ const ws = socketRef.current;
90
+ if (ws && ws.readyState === WebSocket.OPEN) {
91
+ ws.send(JSON.stringify(frame));
92
+ return;
93
+ }
94
+ // Queue chat.stop so a stop clicked while the socket is reconnecting
95
+ // is delivered on next OPEN instead of silently dropped. Draft updates
96
+ // are intentionally NOT queued — they have a version guard and the
97
+ // user's next keystroke will refresh the body anyway.
98
+ if (frame.action === "chat.stop") {
99
+ pendingFramesRef.current.push(frame);
100
+ }
101
+ }, []);
102
+
103
+ const applyEvent = useCallback((frame: WsEvent) => {
104
+ // Any of these means the queued window is over: the reply began, ended,
105
+ // was cancelled, or the send failed outright.
106
+ if (
107
+ frame.event === "chat.stream_start" ||
108
+ frame.event === "chat.stream_complete" ||
109
+ frame.event === "chat.stream_error" ||
110
+ frame.event === "chat.stream_cancelled" ||
111
+ frame.event === "session.error"
112
+ ) {
113
+ setAwaitingReply(false);
114
+ }
115
+ // Side-effect events: handle BEFORE setState so React strict-mode's
116
+ // double-invocation of the updater doesn't double-fire the effect.
117
+ if (frame.event === "session.title_updated") {
118
+ onTitleUpdatedRef.current?.();
119
+ return;
120
+ }
121
+ if (frame.event === "session.error") {
122
+ setLastError(frame.data.message);
123
+ if (
124
+ frame.data.code === "draft_version_mismatch" &&
125
+ frame.data.detail &&
126
+ typeof frame.data.detail === "object"
127
+ ) {
128
+ // Clear any pending optimistic body so the user's stale local
129
+ // text doesn't auto-re-send with the new version.
130
+ pendingDraftBodyRef.current = null;
131
+ if (draftDebounceRef.current != null) {
132
+ window.clearTimeout(draftDebounceRef.current);
133
+ draftDebounceRef.current = null;
134
+ }
135
+ }
136
+ }
137
+ setState((prev) => sessionReducer(prev, frame));
138
+ }, []);
139
+
140
+ const connect = useCallback(() => {
141
+ if (closedByUserRef.current) return;
142
+ const ws = new WebSocket(wsUrl(`ws/canopy-sessions/${sessionId}/`));
143
+ socketRef.current = ws;
144
+
145
+ ws.onopen = () => {
146
+ setConnected(true);
147
+ reconnectAttemptRef.current = 0;
148
+ // Flush any control frames that were queued while the socket was
149
+ // closed. See `send` above.
150
+ const queued = pendingFramesRef.current;
151
+ pendingFramesRef.current = [];
152
+ for (const frame of queued) {
153
+ ws.send(JSON.stringify(frame));
154
+ }
155
+ if (heartbeatTimerRef.current != null) {
156
+ window.clearInterval(heartbeatTimerRef.current);
157
+ }
158
+ heartbeatTimerRef.current = window.setInterval(() => {
159
+ send({ action: "presence.heartbeat", data: {} });
160
+ }, HEARTBEAT_INTERVAL_MS);
161
+ };
162
+
163
+ ws.onmessage = (e) => {
164
+ try {
165
+ const frame = JSON.parse(e.data) as WsEvent;
166
+ applyEvent(frame);
167
+ } catch {
168
+ // ignore malformed frames
169
+ }
170
+ };
171
+
172
+ ws.onclose = () => {
173
+ setConnected(false);
174
+ if (heartbeatTimerRef.current != null) {
175
+ window.clearInterval(heartbeatTimerRef.current);
176
+ heartbeatTimerRef.current = null;
177
+ }
178
+ if (closedByUserRef.current) return;
179
+ const attempt = reconnectAttemptRef.current;
180
+ const delay =
181
+ RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
182
+ reconnectAttemptRef.current = attempt + 1;
183
+ window.setTimeout(connect, delay);
184
+ };
185
+
186
+ ws.onerror = () => {
187
+ // onclose will fire next; nothing to do here.
188
+ };
189
+ }, [applyEvent, send, sessionId, wsUrl]);
190
+
191
+ useEffect(() => {
192
+ closedByUserRef.current = false;
193
+ reconnectAttemptRef.current = 0;
194
+ connect();
195
+ return () => {
196
+ closedByUserRef.current = true;
197
+ if (heartbeatTimerRef.current != null) {
198
+ window.clearInterval(heartbeatTimerRef.current);
199
+ }
200
+ if (socketRef.current) {
201
+ socketRef.current.close();
202
+ socketRef.current = null;
203
+ }
204
+ };
205
+ }, [connect]);
206
+
207
+ const sendChat = useCallback(() => {
208
+ // Flush the local body BEFORE committing. `chat.send` commits the SERVER's
209
+ // draft, so this is the moment the body has to exist there — and when
210
+ // live sync is off (single-player) it is the ONLY time it is sent.
211
+ //
212
+ // Unconditional on purpose: keying this off a pending debounce timer meant
213
+ // nothing was flushed when there was no timer, which is now the normal case.
214
+ if (draftDebounceRef.current != null) {
215
+ window.clearTimeout(draftDebounceRef.current);
216
+ draftDebounceRef.current = null;
217
+ }
218
+ if (pendingDraftBodyRef.current != null && stateRef.current.active_draft) {
219
+ send({
220
+ action: "draft.update",
221
+ data: {
222
+ version: stateRef.current.active_draft.version,
223
+ body: pendingDraftBodyRef.current,
224
+ },
225
+ });
226
+ }
227
+ pendingDraftBodyRef.current = null;
228
+ setAwaitingReply(true);
229
+ send({ action: "chat.send", data: {} });
230
+ }, [send]);
231
+
232
+ const stopChat = useCallback(
233
+ (messageId: string | null) => {
234
+ // messageId is null when the turn is still queued. The server's
235
+ // chat.stop cancels every non-terminal turn on the session and only
236
+ // echoes the id back, so a null one cancels just as effectively.
237
+ setAwaitingReply(false);
238
+ send({ action: "chat.stop", data: { message_id: messageId } });
239
+ },
240
+ [send],
241
+ );
242
+
243
+ const updateDraft = useCallback(
244
+ (body: string) => {
245
+ // Optimistic local update so the textarea feels snappy.
246
+ setState((prev) =>
247
+ prev.active_draft
248
+ ? { ...prev, active_draft: { ...prev.active_draft, body } }
249
+ : prev,
250
+ );
251
+ pendingDraftBodyRef.current = body;
252
+ // Alone in the session? Don't mirror keystrokes at all. The body is
253
+ // flushed once by sendChat, which is the only moment the server actually
254
+ // needs it. This is what makes single-player typing purely local — no
255
+ // round trip, no echo, no version to disagree about.
256
+ if (!shouldSyncDraftLive(stateRef.current.presence_user_ids)) {
257
+ if (draftDebounceRef.current != null) {
258
+ window.clearTimeout(draftDebounceRef.current);
259
+ draftDebounceRef.current = null;
260
+ }
261
+ return;
262
+ }
263
+ if (draftDebounceRef.current != null) {
264
+ window.clearTimeout(draftDebounceRef.current);
265
+ }
266
+ draftDebounceRef.current = window.setTimeout(() => {
267
+ draftDebounceRef.current = null;
268
+ const current = stateRef.current.active_draft;
269
+ const pending = pendingDraftBodyRef.current;
270
+ if (current != null && pending != null) {
271
+ // Only consumed once it has actually gone out. Clearing it
272
+ // unconditionally dropped anything typed before session.state
273
+ // arrived (no draft yet ⇒ nothing sent, body forgotten).
274
+ pendingDraftBodyRef.current = null;
275
+ send({
276
+ action: "draft.update",
277
+ data: { version: current.version, body: pending },
278
+ });
279
+ }
280
+ }, DRAFT_UPDATE_DEBOUNCE_MS);
281
+ },
282
+ [send],
283
+ );
284
+
285
+ const takeOverDraft = useCallback(() => {
286
+ send({ action: "draft.take_over", data: {} });
287
+ }, [send]);
288
+
289
+ const discardDraft = useCallback(() => {
290
+ send({ action: "draft.discard", data: {} });
291
+ }, [send]);
292
+
293
+ // Someone joining mid-compose must see what is ALREADY typed. Nothing was
294
+ // mirrored while we were alone, so without this one catch-up flush their view
295
+ // would sit empty until the next keystroke. Closes the only gap that skipping
296
+ // live sync opens up.
297
+ const liveSync = shouldSyncDraftLive(state.presence_user_ids);
298
+ useEffect(() => {
299
+ if (!liveSync) return;
300
+ const pending = pendingDraftBodyRef.current;
301
+ const current = stateRef.current.active_draft;
302
+ if (pending == null || current == null) return;
303
+ pendingDraftBodyRef.current = null;
304
+ send({
305
+ action: "draft.update",
306
+ data: { version: current.version, body: pending },
307
+ });
308
+ }, [liveSync, send]);
309
+
310
+ const prependMessages = useCallback((older: Message[]) => {
311
+ // Apply a REST "Load earlier" page into the live socket state. A later
312
+ // session.state snapshot (e.g. reconnect) resets to the tail — acceptable;
313
+ // the user re-loads earlier if needed.
314
+ setState((prev) => {
315
+ const merged = prependHistory(prev.messages, older);
316
+ return merged === prev.messages ? prev : { ...prev, messages: merged };
317
+ });
318
+ }, []);
319
+
320
+ return {
321
+ state,
322
+ connected,
323
+ awaitingReply,
324
+ sendChat,
325
+ stopChat,
326
+ updateDraft,
327
+ takeOverDraft,
328
+ discardDraft,
329
+ prependMessages,
330
+ lastError,
331
+ };
332
+ }