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.
- package/package.json +6 -2
- package/src/chat/ChatPanel.tsx +155 -0
- package/src/chat/ConnectionStatus.tsx +30 -0
- package/src/chat/MessageItem.tsx +198 -0
- package/src/chat/MessageList.tsx +145 -0
- package/src/chat/PlacementBanner.test.tsx +112 -0
- package/src/chat/PlacementBanner.tsx +92 -0
- package/src/chat/PresenceChips.tsx +52 -0
- package/src/chat/SendBox.test.tsx +359 -0
- package/src/chat/SendBox.tsx +306 -0
- package/src/chat/ToolCallPair.tsx +86 -0
- package/src/chat/drafts.test.ts +90 -0
- package/src/chat/drafts.ts +37 -0
- package/src/chat/groupToolRuns.test.ts +81 -0
- package/src/chat/groupToolRuns.ts +82 -0
- package/src/chat/history.test.ts +35 -0
- package/src/chat/history.ts +16 -0
- package/src/chat/index.ts +56 -0
- package/src/chat/pairToolMessages.test.ts +360 -0
- package/src/chat/pairToolMessages.ts +238 -0
- package/src/chat/protocol.ts +112 -0
- package/src/chat/sessionReducer.test.ts +541 -0
- package/src/chat/sessionReducer.ts +368 -0
- package/src/chat/useSessionSocket.ts +332 -0
- package/src/chat/useStickyBottom.ts +77 -0
- package/src/presence/PresenceBadge.test.tsx +58 -0
- package/src/presence/PresenceBadge.tsx +105 -0
- package/src/presence/avatar.test.ts +42 -0
- package/src/presence/avatar.ts +36 -0
- package/src/presence/index.ts +4 -0
- package/src/presence/pageKey.test.ts +53 -0
- package/src/presence/pageKey.ts +37 -0
- package/src/presence/usePresence.test.ts +130 -0
- package/src/presence/usePresence.ts +170 -0
|
@@ -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
|
+
}
|