canopy-ui 0.11.0 → 0.12.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -136,6 +136,17 @@ export function MessageItem({
136
136
  {isError && message.role === "assistant" && (
137
137
  <ErrorFooter detail={message.error_detail} hasPartial={Boolean(text)} />
138
138
  )}
139
+ {/* Your own line, before canopy has confirmed it. Shown at once so a send
140
+ never looks like it vanished; flagged plainly if it did not go. */}
141
+ {message.role === "user" && isPending && (
142
+ <div className="mt-1 text-[11px] opacity-70" role="status">Sending…</div>
143
+ )}
144
+ {message.role === "user" && isError && (
145
+ <div className="mt-1 flex items-start gap-1 text-[11px] font-medium" role="alert">
146
+ <AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden="true" />
147
+ <span>{message.error_detail || "Not sent."}</span>
148
+ </div>
149
+ )}
139
150
  </div>
140
151
  );
141
152
  }
@@ -251,7 +251,9 @@ export type WsEvent =
251
251
  | { event: "chat.stream_cancelled"; data: { message_id: string | null; partial_len: number } }
252
252
  | { event: "draft.updated"; data: Draft }
253
253
  | { event: "draft.lock_changed"; data: { draft_id: string; holder_user_id: number | null; expires_at: number | null } }
254
- | { event: "draft.committed"; data: { draft_id: string; user_message_id: string } }
254
+ // `client_id` is the sender's receipt for a send that carried one (see
255
+ // useSessionSocket.sendChat); absent from older servers.
256
+ | { event: "draft.committed"; data: { draft_id: string; user_message_id: string; client_id?: string } }
255
257
  | { event: "draft.discarded"; data: { draft_id: string } }
256
258
  // `participant` carries WHO joined, so a client can add them to its
257
259
  // participant list. Without it a first-time joiner has an id and no name,
@@ -0,0 +1,17 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { REDUCER_EVENTS } from "./sessionReducer";
6
+
7
+ /** The hook passes the reducer only the frames in REDUCER_EVENTS. A case the
8
+ * list lacks is dead code that looks alive, which is how `draft.committed`,
9
+ * `presence.*` and `session.turn_status` went unhandled from 2026-09-13 to
10
+ * 2026-09-23 without a single failing test. */
11
+ describe("REDUCER_EVENTS", () => {
12
+ it("names exactly the frames the reducer's switch handles", () => {
13
+ const src = readFileSync(fileURLToPath(new URL("./sessionReducer.ts", import.meta.url)), "utf8");
14
+ const cases = new Set([...src.matchAll(/case "([a-z_.]+)":/g)].map((m) => m[1]));
15
+ expect([...cases].sort()).toEqual([...REDUCER_EVENTS].sort());
16
+ });
17
+ });
@@ -27,6 +27,25 @@ const UNBLOCKING_FRAMES = new Set([
27
27
  "chat.tool_result",
28
28
  ]);
29
29
 
30
+ /**
31
+ * Every frame `sessionReducer` handles. The socket hook hands the reducer ONLY
32
+ * these, and gives anything else to `onUnknownEvent`, so a frame missing from
33
+ * here is a frame the reducer never sees. That happened: from #775
34
+ * (2026-09-13) the hook kept its own hand-written list, which lacked every
35
+ * `draft.*` and `presence.*` frame and `session.turn_status`. Your own line did
36
+ * not appear when you sent it (`draft.committed`), and the co-edited draft,
37
+ * the presence roster and the live turn status all went quiet, with no error.
38
+ * `sessionReducer.events.test.ts` fails if this and the switch below differ.
39
+ */
40
+ export const REDUCER_EVENTS: ReadonlySet<string> = new Set([
41
+ "session.state", "session.activity", "session.stop", "session.menu",
42
+ "session.turn_status", "session.error", "session.title_updated",
43
+ "chat.stream_start", "chat.user_message", "chat.delta", "chat.stream_complete",
44
+ "chat.stream_error", "chat.stream_cancelled", "chat.tool_use", "chat.tool_result",
45
+ "draft.updated", "draft.lock_changed", "draft.committed", "draft.discarded",
46
+ "presence.joined", "presence.left",
47
+ ]);
48
+
30
49
  export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState {
31
50
  if (frame.event === "chat.stream_start" || frame.event === "draft.committed") {
32
51
  // A new turn is starting, so the previous turn's stop outcome is history —
@@ -41,8 +60,10 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
41
60
  prev = { ...prev, activity: "working", menu: undefined };
42
61
  }
43
62
  switch (frame.event) {
44
- case "session.state":
45
- return frame.data;
63
+ case "session.state": {
64
+ const messages = keepUnechoedSends(prev.messages, frame.data.messages);
65
+ return messages === frame.data.messages ? frame.data : { ...frame.data, messages };
66
+ }
46
67
 
47
68
  case "chat.stream_start": {
48
69
  // Upsert: if the assistant message already exists (rare — a runner that
@@ -153,6 +174,8 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
153
174
  // is the durable one, so the row sorts where a reload puts it.
154
175
  turn_index: frame.data.turn_index,
155
176
  plaintext: frame.data.plaintext,
177
+ // The agent read it, so a pending or unconfirmed row is sent.
178
+ ...(m.role === "user" ? { status: "complete" as const, error_detail: null } : {}),
156
179
  }
157
180
  : m,
158
181
  ),
@@ -333,6 +356,15 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
333
356
  return prev;
334
357
 
335
358
  case "draft.committed": {
359
+ // The sender already shows its line (a pending row stamped with this
360
+ // client_id, see `withPendingSend`). Confirm it rather than add a second.
361
+ const cid = frame.data.client_id;
362
+ if (cid && prev.messages.some((m) => m.content?.client_id === cid)) {
363
+ return {
364
+ ...markSendDelivered(prev, cid, frame.data.user_message_id),
365
+ active_draft: prev.active_draft ? { ...prev.active_draft, body: "" } : prev.active_draft,
366
+ };
367
+ }
336
368
  // Insert the optimistic USER message from the draft body that's about
337
369
  // to be cleared. The assistant reply is NOT inserted here — canopy's
338
370
  // draft.committed carries no assistant id; `chat.stream_start` upserts
@@ -446,3 +478,89 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
446
478
  return prev;
447
479
  }
448
480
  }
481
+
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // A send the SENDER is waiting to hear back about.
485
+ //
486
+ // Your line goes on screen the moment you press send, as "pending", stamped
487
+ // with the send's client_id. It becomes "complete" on the server's receipt
488
+ // (`draft.committed` carrying that id), or when an HTTP resend succeeds, or when
489
+ // the transcript echoes it back. If none of those happen it becomes "error",
490
+ // and the words stay on screen. Before this the composer cleared on send and
491
+ // nothing showed the line until the agent read it, so a send lost on a dead
492
+ // socket simply vanished (2026-09-23).
493
+ // ---------------------------------------------------------------------------
494
+
495
+ export function withPendingSend(
496
+ prev: SessionState,
497
+ { clientId, text }: { clientId: string; text: string },
498
+ ): SessionState {
499
+ const nowIso = new Date().toISOString();
500
+ const row: Message = {
501
+ id: `local:${clientId}`,
502
+ turn_index: prev.messages.reduce((acc, m) => Math.max(acc, m.turn_index), 0) + 1,
503
+ role: "user",
504
+ content: { text, client_id: clientId },
505
+ plaintext: text,
506
+ status: "pending",
507
+ error_detail: null,
508
+ started_at: null,
509
+ completed_at: null,
510
+ created_at: nowIso,
511
+ };
512
+ return { ...prev, messages: [...prev.messages, row] };
513
+ }
514
+
515
+ function updateSend(prev: SessionState, clientId: string, patch: Partial<Message>): SessionState {
516
+ let changed = false;
517
+ const messages = prev.messages.map((m) => {
518
+ if (m.content?.client_id !== clientId) return m;
519
+ changed = true;
520
+ return { ...m, ...patch };
521
+ });
522
+ return changed ? { ...prev, messages } : prev;
523
+ }
524
+
525
+ export function markSendDelivered(prev: SessionState, clientId: string, messageId?: string): SessionState {
526
+ return updateSend(prev, clientId, {
527
+ status: "complete",
528
+ error_detail: null,
529
+ completed_at: new Date().toISOString(),
530
+ ...(messageId ? { id: messageId } : {}),
531
+ });
532
+ }
533
+
534
+ export function markSendFailed(prev: SessionState, clientId: string, detail: string): SessionState {
535
+ // Only a row still waiting can fail: a late failure must not overturn a
536
+ // receipt or a transcript echo that already proved the send landed.
537
+ const row = prev.messages.find((m) => m.content?.client_id === clientId);
538
+ if (!row || row.status !== "pending") return prev;
539
+ return updateSend(prev, clientId, { status: "error", error_detail: detail });
540
+ }
541
+
542
+ /** Your own sends the snapshot does not hold yet, carried across it.
543
+ *
544
+ * A snapshot REPLACES the message list, and in a transcript-sourced session
545
+ * your line is not a server row until the agent has read it. That can be
546
+ * minutes if a turn is still running, since a session runs one turn at a
547
+ * time. So every reconnect in that window (a phone resuming, a flaky network)
548
+ * took the line off screen, and it came back only when the agent read it. It
549
+ * looked lost. A row is kept only while the snapshot lacks it: once the
550
+ * transcript echo is in the snapshot (same words among its recent user rows),
551
+ * the snapshot's copy wins.
552
+ */
553
+ function keepUnechoedSends(previous: Message[], snapshot: Message[]): Message[] {
554
+ const mine = previous.filter(
555
+ (m) => m.role === "user" && typeof m.content?.client_id === "string" &&
556
+ !snapshot.some((s) => s.id === m.id),
557
+ );
558
+ if (mine.length === 0) return snapshot;
559
+ const recent = new Set(
560
+ snapshot.filter((m) => m.role === "user").slice(-12).map((m) => m.plaintext.trim()),
561
+ );
562
+ const carried = mine.filter((m) => !recent.has(m.plaintext.trim()));
563
+ if (carried.length === 0) return snapshot;
564
+ const top = snapshot.reduce((acc, m) => Math.max(acc, m.turn_index), 0);
565
+ return [...snapshot, ...carried.map((m, i) => ({ ...m, turn_index: top + 1 + i }))];
566
+ }
@@ -78,6 +78,7 @@ describe('sending over HTTP', () => {
78
78
 
79
79
  it('without it, a user sends over the socket exactly as before', async () => {
80
80
  const hook = renderHook(() => useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/x' }))
81
+ act(() => hook.result.current.updateDraft('hello'))
81
82
  await act(async () => hook.result.current.sendChat())
82
83
  expect(FakeSocket.sent.some((f) => f.includes('chat.send'))).toBe(true)
83
84
  })
@@ -0,0 +1,161 @@
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
+ /**
8
+ * A send must never vanish. A phone resuming from the background can hold a
9
+ * socket that reads OPEN but delivers nothing, and before this the composer
10
+ * cleared, no line appeared, and the prompt was simply gone (2026-09-23).
11
+ */
12
+
13
+ class FakeSocket {
14
+ static OPEN = 1
15
+ static last: FakeSocket | null = null
16
+ sent: string[] = []
17
+ onopen: (() => void) | null = null
18
+ onmessage: ((e: { data: string }) => void) | null = null
19
+ onclose: (() => void) | null = null
20
+ onerror: (() => void) | null = null
21
+ readyState = 1
22
+ constructor(public url: string) {
23
+ FakeSocket.last = this
24
+ }
25
+ send(data: string) {
26
+ this.sent.push(data)
27
+ }
28
+ close() {}
29
+ receive(frame: unknown) {
30
+ this.onmessage?.({ data: JSON.stringify(frame) })
31
+ }
32
+ }
33
+
34
+ const DRAFT = { id: 'd1', slot: 'next', status: 'open', body: '', version: 1, last_editor: 0, last_edit_at: '' }
35
+ const SNAPSHOT = {
36
+ event: 'session.state',
37
+ data: { messages: [], active_draft: DRAFT, participants: [], presence_user_ids: [], current_user_id: 1 },
38
+ }
39
+
40
+ beforeEach(() => {
41
+ vi.useFakeTimers()
42
+ vi.stubGlobal('WebSocket', FakeSocket)
43
+ })
44
+ afterEach(() => {
45
+ vi.useRealTimers()
46
+ vi.unstubAllGlobals()
47
+ })
48
+
49
+ // Stable, as a real caller's is: a new function every render re-keys `connect`
50
+ // and opens a fresh socket, which would hide what the first one was sent.
51
+ const wsUrl = () => 'wss://h/x'
52
+
53
+ function user(resendOverHttp?: (text: string, clientId: string) => Promise<unknown>) {
54
+ const hook = renderHook(() =>
55
+ useSessionSocket({ sessionId: 's1', wsUrl, resendOverHttp }),
56
+ )
57
+ act(() => {
58
+ FakeSocket.last!.onopen?.()
59
+ FakeSocket.last!.receive(SNAPSHOT)
60
+ })
61
+ return hook
62
+ }
63
+
64
+ function sentSend() {
65
+ const frame = FakeSocket.last!.sent.map((f) => JSON.parse(f)).find((f) => f.action === 'chat.send')
66
+ return frame?.data as { client_id: string; text: string }
67
+ }
68
+
69
+ describe('a send carries its own text and waits for a receipt', () => {
70
+ it('shows the line at once as pending, and sends the text with a client_id', () => {
71
+ const hook = user()
72
+ act(() => hook.result.current.updateDraft('deploy it'))
73
+ act(() => hook.result.current.sendChat())
74
+ const row = hook.result.current.state.messages.at(-1)!
75
+ expect(row.plaintext).toBe('deploy it')
76
+ expect(row.status).toBe('pending')
77
+ expect(sentSend().text).toBe('deploy it')
78
+ expect(sentSend().client_id).toBeTruthy()
79
+ })
80
+
81
+ it('the receipt confirms the SAME row rather than adding a second', () => {
82
+ const hook = user()
83
+ act(() => hook.result.current.updateDraft('deploy it'))
84
+ act(() => hook.result.current.sendChat())
85
+ act(() => FakeSocket.last!.receive({
86
+ event: 'draft.committed',
87
+ data: { draft_id: 'd1', user_message_id: 'transient:abc', client_id: sentSend().client_id },
88
+ }))
89
+ const users = hook.result.current.state.messages.filter((m) => m.role === 'user')
90
+ expect(users).toHaveLength(1)
91
+ expect(users[0].status).toBe('complete')
92
+ })
93
+
94
+ it('no receipt: resent over HTTP under the SAME client_id, so it is one turn', async () => {
95
+ const resend = vi.fn().mockResolvedValue({})
96
+ const hook = user(resend)
97
+ act(() => hook.result.current.updateDraft('deploy it'))
98
+ act(() => hook.result.current.sendChat())
99
+ await act(async () => {
100
+ vi.advanceTimersByTime(5_000)
101
+ await Promise.resolve()
102
+ })
103
+ expect(resend).toHaveBeenCalledWith('deploy it', sentSend().client_id)
104
+ expect(hook.result.current.state.messages.at(-1)?.status).toBe('complete')
105
+ })
106
+
107
+ it('a socket that closes before the receipt resends at once', async () => {
108
+ const resend = vi.fn().mockResolvedValue({})
109
+ const hook = user(resend)
110
+ act(() => hook.result.current.updateDraft('deploy it'))
111
+ act(() => hook.result.current.sendChat())
112
+ await act(async () => {
113
+ FakeSocket.last!.onclose?.()
114
+ await Promise.resolve()
115
+ })
116
+ expect(resend).toHaveBeenCalledTimes(1)
117
+ })
118
+
119
+ it('if the resend fails too, the words stay on screen marked not sent', async () => {
120
+ const hook = user(vi.fn().mockRejectedValue(new Error('offline')))
121
+ act(() => hook.result.current.updateDraft('keep me'))
122
+ act(() => hook.result.current.sendChat())
123
+ await act(async () => {
124
+ vi.advanceTimersByTime(5_000)
125
+ await Promise.resolve()
126
+ await Promise.resolve()
127
+ })
128
+ const row = hook.result.current.state.messages.at(-1)!
129
+ expect(row.plaintext).toBe('keep me')
130
+ expect(row.status).toBe('error')
131
+ expect(row.error_detail).toMatch(/Not sent/)
132
+ })
133
+
134
+ it('a reconnect snapshot that does not hold the line yet keeps it on screen', () => {
135
+ const hook = user()
136
+ act(() => hook.result.current.updateDraft('queued behind a running turn'))
137
+ act(() => hook.result.current.sendChat())
138
+ act(() => FakeSocket.last!.receive({
139
+ event: 'draft.committed',
140
+ data: { draft_id: 'd1', user_message_id: 'transient:abc', client_id: sentSend().client_id },
141
+ }))
142
+ // Reconnect: the snapshot has no row for it yet (the agent has not read it).
143
+ act(() => FakeSocket.last!.receive(SNAPSHOT))
144
+ expect(hook.result.current.state.messages.map((m) => m.plaintext)).toEqual([
145
+ 'queued behind a running turn',
146
+ ])
147
+ // And once the transcript has it, the snapshot's copy wins: one row.
148
+ act(() => FakeSocket.last!.receive({
149
+ ...SNAPSHOT,
150
+ data: {
151
+ ...SNAPSHOT.data,
152
+ messages: [{
153
+ id: 'm9', turn_index: 90001, role: 'user', content: {}, plaintext: 'queued behind a running turn',
154
+ status: 'complete', error_detail: null, started_at: null, completed_at: null, created_at: '',
155
+ }],
156
+ },
157
+ }))
158
+ expect(hook.result.current.state.messages).toHaveLength(1)
159
+ expect(hook.result.current.state.messages[0].id).toBe('m9')
160
+ })
161
+ })
@@ -4,11 +4,29 @@ import { fromAgui, resetAguiState } from "./agui";
4
4
  import type { Draft, Message, SessionState, WsEvent } from "./protocol";
5
5
  import { shouldSyncDraftLive } from "./drafts";
6
6
  import { prependHistory } from "./history";
7
- import { sessionReducer } from "./sessionReducer";
7
+ import {
8
+ REDUCER_EVENTS,
9
+ markSendDelivered,
10
+ markSendFailed,
11
+ sessionReducer,
12
+ withPendingSend,
13
+ } from "./sessionReducer";
8
14
 
9
15
  const HEARTBEAT_INTERVAL_MS = 20_000;
10
16
  const RECONNECT_DELAYS_MS = [1_000, 2_000, 5_000, 10_000];
11
17
  const DRAFT_UPDATE_DEBOUNCE_MS = 150;
18
+ /** How long a socket send may go without the server's receipt before the page
19
+ * resends it over HTTP. Short on purpose: the receipt normally arrives within
20
+ * a round trip, and a missing one means the socket is dead (a phone resuming
21
+ * from the background can hold one that still reads OPEN). */
22
+ const SEND_RECEIPT_TIMEOUT_MS = 4_000;
23
+ const NOT_SENT =
24
+ "Not sent: the connection dropped before canopy got it. Your words are kept here, so copy them and send again.";
25
+
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)}`;
29
+ }
12
30
 
13
31
  const INITIAL_STATE: SessionState = {
14
32
  messages: [],
@@ -68,6 +86,14 @@ export interface UseSessionSocketOptions {
68
86
  * without a second send path in every host. Omit for a user (the default).
69
87
  */
70
88
  sendOverHttp?: (text: string) => Promise<unknown>;
89
+ /**
90
+ * The HTTP route for a USER's send, used only when a socket send gets no
91
+ * receipt (see `SEND_RECEIPT_TIMEOUT_MS`). Must pass `clientId` through as the
92
+ * send's `client_id`: the server derives the turn's idempotency key from it,
93
+ * so a socket frame that did arrive late and this resend make ONE turn.
94
+ * Without it a send on a dead socket is marked "not sent" instead.
95
+ */
96
+ resendOverHttp?: (text: string, clientId: string) => Promise<unknown>;
71
97
  }
72
98
 
73
99
  /**
@@ -123,20 +149,10 @@ export interface UseSessionSocketResult {
123
149
  lastError: string | null;
124
150
  }
125
151
 
126
- /**
127
- * Frames `sessionReducer` understands. Anything else is handed to
128
- * `onUnknownEvent` rather than dropped — the reducer ignores what it does not
129
- * recognise, which silently swallows an app-specific frame and leaves the
130
- * container wondering why its feature never fires.
131
- *
132
- * Keep in step with sessionReducer's own switch.
133
- */
134
- const KNOWN_EVENTS = new Set([
135
- "chat.delta", "chat.stream_cancelled", "chat.stream_complete",
136
- "chat.stream_error", "chat.stream_start", "chat.tool_result",
137
- "chat.tool_use", "chat.user_message", "session.activity", "session.error",
138
- "session.menu", "session.state", "session.stop", "session.title_updated",
139
- ]);
152
+ // Frames the reducer handles: its own list, so the two cannot drift (see
153
+ // `REDUCER_EVENTS`). Anything else goes to `onUnknownEvent` rather than being
154
+ // dropped.
155
+ const KNOWN_EVENTS = REDUCER_EVENTS;
140
156
 
141
157
  export function useSessionSocket({
142
158
  sessionId,
@@ -145,6 +161,7 @@ export function useSessionSocket({
145
161
  onUnknownEvent,
146
162
  protocol = "canopy",
147
163
  sendOverHttp,
164
+ resendOverHttp,
148
165
  }: UseSessionSocketOptions): UseSessionSocketResult {
149
166
  // The local draft a read-only principal types into (see `sendOverHttp`).
150
167
  const [localDraft, setLocalDraft] = useState<Draft | null>(null);
@@ -176,6 +193,10 @@ export function useSessionSocket({
176
193
  // Control frames that must not be lost across a reconnect (currently
177
194
  // only chat.stop). The WS-world analogue of an abortable chat transport.
178
195
  const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
196
+ // Sends awaiting the server's receipt: client_id -> {text, timer}.
197
+ const unconfirmedRef = useRef(new Map<string, { text: string; timer: number | null }>());
198
+ const resendRef = useRef(resendOverHttp);
199
+ resendRef.current = resendOverHttp;
179
200
 
180
201
  useEffect(() => {
181
202
  stateRef.current = state;
@@ -193,7 +214,7 @@ export function useSessionSocket({
193
214
  const ws = socketRef.current;
194
215
  if (ws && ws.readyState === WebSocket.OPEN) {
195
216
  ws.send(JSON.stringify(frame));
196
- return;
217
+ return true;
197
218
  }
198
219
  // Queue chat.stop so a stop clicked while the socket is reconnecting
199
220
  // is delivered on next OPEN instead of silently dropped. Draft updates
@@ -202,6 +223,29 @@ export function useSessionSocket({
202
223
  if (frame.action === "chat.stop") {
203
224
  pendingFramesRef.current.push(frame);
204
225
  }
226
+ return false;
227
+ }, []);
228
+
229
+ // A send that got no receipt: resend it over HTTP under the same client_id,
230
+ // or, with no HTTP route, say plainly that it did not go. Never silent.
231
+ const rescueSend = useCallback((clientId: string) => {
232
+ const entry = unconfirmedRef.current.get(clientId);
233
+ if (!entry) return; // the receipt got here first
234
+ if (entry.timer != null) window.clearTimeout(entry.timer);
235
+ unconfirmedRef.current.delete(clientId);
236
+ const resend = resendRef.current;
237
+ if (!resend) {
238
+ setAwaitingReply(false);
239
+ setState((prev) => markSendFailed(prev, clientId, NOT_SENT));
240
+ return;
241
+ }
242
+ resend(entry.text, clientId).then(
243
+ () => setState((prev) => markSendDelivered(prev, clientId)),
244
+ () => {
245
+ setAwaitingReply(false);
246
+ setState((prev) => markSendFailed(prev, clientId, NOT_SENT));
247
+ },
248
+ );
205
249
  }, []);
206
250
 
207
251
  const applyEvent = useCallback((frame: WsEvent) => {
@@ -216,6 +260,12 @@ export function useSessionSocket({
216
260
  ) {
217
261
  setAwaitingReply(false);
218
262
  }
263
+ // The server's receipt for one of OUR sends: stop waiting on it.
264
+ if (frame.event === "draft.committed" && frame.data.client_id) {
265
+ const entry = unconfirmedRef.current.get(frame.data.client_id);
266
+ if (entry?.timer != null) window.clearTimeout(entry.timer);
267
+ unconfirmedRef.current.delete(frame.data.client_id);
268
+ }
219
269
  // Side-effect events: handle BEFORE setState so React strict-mode's
220
270
  // double-invocation of the updater doesn't double-fire the effect.
221
271
  if (frame.event === "session.title_updated") {
@@ -311,6 +361,8 @@ export function useSessionSocket({
311
361
 
312
362
  ws.onclose = () => {
313
363
  setConnected(false);
364
+ // A send still waiting for its receipt will not get one on this socket.
365
+ for (const clientId of [...unconfirmedRef.current.keys()]) rescueSend(clientId);
314
366
  if (heartbeatTimerRef.current != null) {
315
367
  window.clearInterval(heartbeatTimerRef.current);
316
368
  heartbeatTimerRef.current = null;
@@ -326,7 +378,7 @@ export function useSessionSocket({
326
378
  ws.onerror = () => {
327
379
  // onclose will fire next; nothing to do here.
328
380
  };
329
- }, [applyEvent, send, sessionId, wsUrl]);
381
+ }, [applyEvent, rescueSend, send, sessionId, wsUrl]);
330
382
 
331
383
  useEffect(() => {
332
384
  closedByUserRef.current = false;
@@ -345,29 +397,32 @@ export function useSessionSocket({
345
397
  }, [connect]);
346
398
 
347
399
  const sendChat = useCallback(() => {
348
- // Flush the local body BEFORE committing. `chat.send` commits the SERVER's
349
- // draft, so this is the moment the body has to exist there — and when
350
- // live sync is off (single-player) it is the ONLY time it is sent.
351
- //
352
- // Unconditional on purpose: keying this off a pending debounce timer meant
353
- // nothing was flushed when there was no timer, which is now the normal case.
400
+ // The text goes IN the send, with a client_id, rather than relying on an
401
+ // earlier draft.update having reached the server. That frame and this one
402
+ // could both vanish into a socket that reads OPEN but is dead (a phone
403
+ // resuming from the background), and the composer had already cleared, so
404
+ // the prompt was simply lost (2026-09-23). Now the line shows at once as
405
+ // pending, the server's receipt (draft.committed + client_id) confirms it,
406
+ // and without one it is resent over HTTP under the same id.
354
407
  if (draftDebounceRef.current != null) {
355
408
  window.clearTimeout(draftDebounceRef.current);
356
409
  draftDebounceRef.current = null;
357
410
  }
358
- if (pendingDraftBodyRef.current != null && stateRef.current.active_draft) {
359
- send({
360
- action: "draft.update",
361
- data: {
362
- version: stateRef.current.active_draft.version,
363
- body: pendingDraftBodyRef.current,
364
- },
365
- });
366
- }
411
+ const body = (pendingDraftBodyRef.current ?? stateRef.current.active_draft?.body ?? "").trim();
367
412
  pendingDraftBodyRef.current = null;
413
+ if (!body) return;
414
+ const clientId = newClientId();
368
415
  setAwaitingReply(true);
369
- send({ action: "chat.send", data: {} });
370
- }, [send]);
416
+ setState((prev) => withPendingSend(prev, { clientId, text: body }));
417
+ unconfirmedRef.current.set(clientId, { text: body, timer: null });
418
+ const sent = send({ action: "chat.send", data: { client_id: clientId, text: body } });
419
+ if (!sent) {
420
+ rescueSend(clientId);
421
+ return;
422
+ }
423
+ const entry = unconfirmedRef.current.get(clientId);
424
+ if (entry) entry.timer = window.setTimeout(() => rescueSend(clientId), SEND_RECEIPT_TIMEOUT_MS);
425
+ }, [send, rescueSend]);
371
426
 
372
427
  const stopChat = useCallback(
373
428
  (messageId: string | null) => {