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
|
@@ -227,6 +227,29 @@ describe("sessionReducer — drafts", () => {
|
|
|
227
227
|
expect(next.active_draft?.version).toBe(3)
|
|
228
228
|
})
|
|
229
229
|
|
|
230
|
+
it("draft.updated carries the server's visibility into active_draft even while keeping the local body", () => {
|
|
231
|
+
// The drift check in useSessionSocket (`current.visibility !==
|
|
232
|
+
// typingVisibilityRef.current`) compares against THIS field — if the
|
|
233
|
+
// echo-suppression branch never updated it, that check would compare
|
|
234
|
+
// against a permanently stale value.
|
|
235
|
+
const prev = makeState({
|
|
236
|
+
current_user_id: 5,
|
|
237
|
+
active_draft: { ...baseDraft, body: "local typing", version: 3, visibility: "live" },
|
|
238
|
+
})
|
|
239
|
+
const next = sessionReducer(prev, {
|
|
240
|
+
event: "draft.updated",
|
|
241
|
+
data: {
|
|
242
|
+
...baseDraft,
|
|
243
|
+
body: "stale server echo",
|
|
244
|
+
last_editor: 5,
|
|
245
|
+
version: 4,
|
|
246
|
+
visibility: "hidden",
|
|
247
|
+
} as Draft,
|
|
248
|
+
} as WsEvent)
|
|
249
|
+
expect(next.active_draft?.body).toBe("local typing")
|
|
250
|
+
expect(next.active_draft?.visibility).toBe("hidden")
|
|
251
|
+
})
|
|
252
|
+
|
|
230
253
|
it("draft.updated accepts the body when another user is editing", () => {
|
|
231
254
|
const prev = makeState({
|
|
232
255
|
current_user_id: 5,
|
|
@@ -727,3 +750,148 @@ describe("session.stop — whether the stop actually landed", () => {
|
|
|
727
750
|
expect(out.stopState).toBeUndefined();
|
|
728
751
|
});
|
|
729
752
|
});
|
|
753
|
+
|
|
754
|
+
describe("per-person drafts", () => {
|
|
755
|
+
it("draft.typing upserts a peer by author and an empty body removes it", () => {
|
|
756
|
+
let s = sessionReducer(makeState(), {
|
|
757
|
+
event: "draft.typing",
|
|
758
|
+
data: { author: { id: 2, name: "Bo" }, body: "hi", at: null },
|
|
759
|
+
} as WsEvent);
|
|
760
|
+
s = sessionReducer(s, {
|
|
761
|
+
event: "draft.typing",
|
|
762
|
+
data: { author: { id: 2, name: "Bo" }, body: "hi there", at: null },
|
|
763
|
+
} as WsEvent);
|
|
764
|
+
expect(s.peer_drafts).toEqual([{ author: { id: 2, name: "Bo" }, body: "hi there", at: null }]);
|
|
765
|
+
s = sessionReducer(s, {
|
|
766
|
+
event: "draft.typing",
|
|
767
|
+
data: { author: { id: 2, name: "Bo" }, body: "", at: null },
|
|
768
|
+
} as WsEvent);
|
|
769
|
+
expect(s.peer_drafts).toEqual([]);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("keeps an empty-body row when the server says the person is still typing", () => {
|
|
773
|
+
// `typing`/`hidden` visibility withholds the words but not the fact of
|
|
774
|
+
// typing — the row must survive rather than being read as "sent/cleared".
|
|
775
|
+
const s = sessionReducer(makeState(), {
|
|
776
|
+
event: "draft.typing",
|
|
777
|
+
data: { author: { id: 2, name: "Bo" }, body: "", at: null, typing: true },
|
|
778
|
+
} as WsEvent);
|
|
779
|
+
expect(s.peer_drafts).toEqual([
|
|
780
|
+
{ author: { id: 2, name: "Bo" }, body: "", at: null, typing: true },
|
|
781
|
+
]);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("removes the row when the body is empty and typing is not true", () => {
|
|
785
|
+
let s = sessionReducer(makeState(), {
|
|
786
|
+
event: "draft.typing",
|
|
787
|
+
data: { author: { id: 2, name: "Bo" }, body: "", at: null, typing: true },
|
|
788
|
+
} as WsEvent);
|
|
789
|
+
s = sessionReducer(s, {
|
|
790
|
+
event: "draft.typing",
|
|
791
|
+
data: { author: { id: 2, name: "Bo" }, body: "", at: null, typing: false },
|
|
792
|
+
} as WsEvent);
|
|
793
|
+
expect(s.peer_drafts).toEqual([]);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it("presence.left drops that person's typing row", () => {
|
|
797
|
+
let s = sessionReducer(makeState(), {
|
|
798
|
+
event: "draft.typing",
|
|
799
|
+
data: { author: { id: 2, name: "Bo" }, body: "x", at: null },
|
|
800
|
+
} as WsEvent);
|
|
801
|
+
s = sessionReducer(s, { event: "presence.left", data: { user_id: 2 } } as WsEvent);
|
|
802
|
+
expect(s.peer_drafts).toEqual([]);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("session.queued replaces the whole list", () => {
|
|
806
|
+
const q = {
|
|
807
|
+
turn_id: "t1",
|
|
808
|
+
client_id: "c1",
|
|
809
|
+
author: { name: "Bo", user_id: 2 },
|
|
810
|
+
text: "yo",
|
|
811
|
+
sent_at: "2026-09-26T00:00:00Z",
|
|
812
|
+
state: "queued" as const,
|
|
813
|
+
};
|
|
814
|
+
let s = sessionReducer(makeState(), {
|
|
815
|
+
event: "session.queued",
|
|
816
|
+
data: { queued: [q] },
|
|
817
|
+
} as WsEvent);
|
|
818
|
+
expect(s.queued).toEqual([q]);
|
|
819
|
+
s = sessionReducer(s, { event: "session.queued", data: { queued: [] } } as WsEvent);
|
|
820
|
+
expect(s.queued).toEqual([]);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it("chat.user_message keeps the author", () => {
|
|
824
|
+
const s = sessionReducer(makeState(), {
|
|
825
|
+
event: "chat.user_message",
|
|
826
|
+
data: { message_id: "m1", turn_index: 5, plaintext: "hi", author: { name: "Bo", user_id: 2 } },
|
|
827
|
+
} as WsEvent);
|
|
828
|
+
expect(s.messages.at(-1)?.author).toEqual({ name: "Bo", user_id: 2 });
|
|
829
|
+
});
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
describe("sessionReducer — two people saying the same thing (final review I5)", () => {
|
|
833
|
+
const yes = (over: Partial<Message>) =>
|
|
834
|
+
makeMessage({ role: "user", plaintext: "yes", status: "complete", ...over })
|
|
835
|
+
|
|
836
|
+
it("two confirmed 'yes' rows from different authors stay two", () => {
|
|
837
|
+
const seeded = makeState({
|
|
838
|
+
messages: [yes({ id: "a1", turn_index: 10, author: { name: "Alice", user_id: 1 } })],
|
|
839
|
+
})
|
|
840
|
+
const next = sessionReducer(seeded, {
|
|
841
|
+
event: "chat.user_message",
|
|
842
|
+
data: { message_id: "b1", turn_index: 20, plaintext: "yes", author: { name: "Bo", user_id: 2 } },
|
|
843
|
+
} as WsEvent)
|
|
844
|
+
expect(next.messages).toHaveLength(2)
|
|
845
|
+
expect(next.messages.map((m) => m.author?.name)).toEqual(["Alice", "Bo"])
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
it("a confirmed unauthored row is not adopted by someone else's line", () => {
|
|
849
|
+
const seeded = makeState({ messages: [yes({ id: "a1", turn_index: 10 })] })
|
|
850
|
+
const next = sessionReducer(seeded, {
|
|
851
|
+
event: "chat.user_message",
|
|
852
|
+
data: { message_id: "b1", turn_index: 20, plaintext: "yes", author: { name: "Bo", user_id: 2 } },
|
|
853
|
+
} as WsEvent)
|
|
854
|
+
expect(next.messages).toHaveLength(2)
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
it("an optimistic pending 'yes' still reconciles with its echo", () => {
|
|
858
|
+
const seeded = makeState({
|
|
859
|
+
current_user_id: 1,
|
|
860
|
+
messages: [yes({ id: "local:c1", turn_index: 3, status: "pending", content: { client_id: "c1" } })],
|
|
861
|
+
})
|
|
862
|
+
const next = sessionReducer(seeded, {
|
|
863
|
+
event: "chat.user_message",
|
|
864
|
+
data: { message_id: "901", turn_index: 144448, plaintext: "yes", author: { name: "Alice", user_id: 1 } },
|
|
865
|
+
} as WsEvent)
|
|
866
|
+
expect(next.messages).toHaveLength(1)
|
|
867
|
+
expect(next.messages[0].id).toBe("901")
|
|
868
|
+
expect(next.messages[0].status).toBe("complete")
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
it("a receipted send (transient id) still reconciles with its transcript echo", () => {
|
|
872
|
+
// A transcript-sourced send writes no durable row; its receipt carries a
|
|
873
|
+
// `transient:` id, so the row is still waiting for the line the agent read.
|
|
874
|
+
const seeded = makeState({
|
|
875
|
+
current_user_id: 1,
|
|
876
|
+
messages: [yes({ id: "transient:abc", turn_index: 3, content: { client_id: "c1" } })],
|
|
877
|
+
})
|
|
878
|
+
const next = sessionReducer(seeded, {
|
|
879
|
+
event: "chat.user_message",
|
|
880
|
+
data: { message_id: "901", turn_index: 144448, plaintext: "yes", author: { name: "Alice", user_id: 1 } },
|
|
881
|
+
} as WsEvent)
|
|
882
|
+
expect(next.messages).toHaveLength(1)
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
it("my pending 'yes' is not taken by a teammate's 'yes'", () => {
|
|
886
|
+
const seeded = makeState({
|
|
887
|
+
current_user_id: 1,
|
|
888
|
+
messages: [yes({ id: "local:c1", turn_index: 3, status: "pending", content: { client_id: "c1" } })],
|
|
889
|
+
})
|
|
890
|
+
const next = sessionReducer(seeded, {
|
|
891
|
+
event: "chat.user_message",
|
|
892
|
+
data: { message_id: "b1", turn_index: 20, plaintext: "yes", author: { name: "Bo", user_id: 2 } },
|
|
893
|
+
} as WsEvent)
|
|
894
|
+
expect(next.messages).toHaveLength(2)
|
|
895
|
+
expect(next.messages[0].id).toBe("local:c1")
|
|
896
|
+
})
|
|
897
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Draft, Message, SessionState, WsEvent } from "./protocol";
|
|
1
|
+
import type { Draft, Message, MessageAuthor, SessionState, WsEvent } from "./protocol";
|
|
2
|
+
import { isUnconfirmed, sameAuthor } from "./identity";
|
|
2
3
|
|
|
3
4
|
// Pure reducer for SessionState — extracted from useSessionSocket so it
|
|
4
5
|
// can be unit-tested without WebSocket plumbing. Side-effect events
|
|
@@ -39,10 +40,10 @@ const UNBLOCKING_FRAMES = new Set([
|
|
|
39
40
|
*/
|
|
40
41
|
export const REDUCER_EVENTS: ReadonlySet<string> = new Set([
|
|
41
42
|
"session.state", "session.activity", "session.stop", "session.menu",
|
|
42
|
-
"session.turn_status", "session.error", "session.title_updated",
|
|
43
|
+
"session.turn_status", "session.error", "session.title_updated", "session.queued",
|
|
43
44
|
"chat.stream_start", "chat.user_message", "chat.delta", "chat.stream_complete",
|
|
44
45
|
"chat.stream_error", "chat.stream_cancelled", "chat.tool_use", "chat.tool_result",
|
|
45
|
-
"draft.updated", "draft.
|
|
46
|
+
"draft.updated", "draft.typing", "draft.committed", "draft.discarded",
|
|
46
47
|
"presence.joined", "presence.left",
|
|
47
48
|
]);
|
|
48
49
|
|
|
@@ -151,11 +152,31 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
151
152
|
// Deliberately narrow: same role, same text, and only against the tail, so
|
|
152
153
|
// a genuine repeat of a short message ("yes") sent much later still lands
|
|
153
154
|
// as its own row.
|
|
155
|
+
//
|
|
156
|
+
// And only against a row that can still be THIS line (final review I5):
|
|
157
|
+
// two people answering "yes" are two lines, so the fallback never lets a
|
|
158
|
+
// different author's frame adopt a row. A row is a candidate while it is
|
|
159
|
+
// unconfirmed (an optimistic or receipted send still waiting for the line
|
|
160
|
+
// the agent read) or when it already names the same person — the ledger
|
|
161
|
+
// path's authored row meeting its transcript echo. An unauthored optimistic
|
|
162
|
+
// row is the viewer's own, so it is compared as the viewer.
|
|
154
163
|
const RECENT_USER_ROWS = 6;
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
164
|
+
const incomingAuthor = frame.data.author ?? null;
|
|
165
|
+
const me: MessageAuthor | null =
|
|
166
|
+
prev.current_user_id != null
|
|
167
|
+
? { name: "", user_id: prev.current_user_id }
|
|
168
|
+
: prev.current_contact_id != null
|
|
169
|
+
? { name: "", contact_id: prev.current_contact_id }
|
|
170
|
+
: null;
|
|
171
|
+
const sameText = (m: Message) => {
|
|
172
|
+
if (
|
|
173
|
+
m.role !== "user" ||
|
|
174
|
+
m.plaintext.trim() === "" ||
|
|
175
|
+
m.plaintext.trim() !== frame.data.plaintext.trim()
|
|
176
|
+
) return false;
|
|
177
|
+
if (isUnconfirmed(m)) return sameAuthor(m.author ?? me, incomingAuthor);
|
|
178
|
+
return m.author != null && incomingAuthor != null && sameAuthor(m.author, incomingAuthor);
|
|
179
|
+
};
|
|
159
180
|
const recentUsers = prev.messages.filter((m) => m.role === "user").slice(-RECENT_USER_ROWS);
|
|
160
181
|
const existing =
|
|
161
182
|
prev.messages.find(
|
|
@@ -174,6 +195,12 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
174
195
|
// is the durable one, so the row sorts where a reload puts it.
|
|
175
196
|
turn_index: frame.data.turn_index,
|
|
176
197
|
plaintext: frame.data.plaintext,
|
|
198
|
+
// A transcript-sourced session's durable row carries no user
|
|
199
|
+
// beyond the runner's own login, so this frame is the only
|
|
200
|
+
// chance to say who actually typed it — keep it if we already
|
|
201
|
+
// had it (an optimistic send knows its own author) and the
|
|
202
|
+
// frame is silent, but let the frame win otherwise.
|
|
203
|
+
author: frame.data.author ?? m.author ?? null,
|
|
177
204
|
// The agent read it, so a pending or unconfirmed row is sent.
|
|
178
205
|
...(m.role === "user" ? { status: "complete" as const, error_detail: null } : {}),
|
|
179
206
|
}
|
|
@@ -186,13 +213,19 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
186
213
|
id: frame.data.message_id,
|
|
187
214
|
turn_index: frame.data.turn_index,
|
|
188
215
|
role: "user",
|
|
189
|
-
|
|
216
|
+
// `client_id` (when the caller has one — an HTTP send) lets
|
|
217
|
+
// `QueuedRows.hideClientIds` recognise the server's queued-turn
|
|
218
|
+
// projection as THIS row rather than a duplicate.
|
|
219
|
+
content: frame.data.client_id
|
|
220
|
+
? { text: frame.data.plaintext, client_id: frame.data.client_id }
|
|
221
|
+
: { text: frame.data.plaintext },
|
|
190
222
|
plaintext: frame.data.plaintext,
|
|
191
223
|
status: "complete",
|
|
192
224
|
error_detail: null,
|
|
193
225
|
started_at: null,
|
|
194
226
|
completed_at: null,
|
|
195
227
|
created_at: nowIso,
|
|
228
|
+
author: frame.data.author ?? null,
|
|
196
229
|
};
|
|
197
230
|
return { ...prev, messages: [...prev.messages, user] };
|
|
198
231
|
}
|
|
@@ -326,9 +359,13 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
326
359
|
// If we're the current editor, keep our local body — the server
|
|
327
360
|
// echo is stale relative to keystrokes that happened since the
|
|
328
361
|
// debounced send. Only accept metadata (version, last_editor, etc).
|
|
362
|
+
// This frame now only ever carries MY OWN draft (everyone else's live
|
|
363
|
+
// typing arrives as `draft.typing` instead), so `author_id` — the
|
|
364
|
+
// draft's actual owner — is the reliable field to compare; fall back to
|
|
365
|
+
// `last_editor` for a server that doesn't send it yet.
|
|
329
366
|
if (
|
|
330
367
|
prev.active_draft &&
|
|
331
|
-
incoming.last_editor === prev.current_user_id
|
|
368
|
+
(incoming.author_id ?? incoming.last_editor) === prev.current_user_id
|
|
332
369
|
) {
|
|
333
370
|
return {
|
|
334
371
|
...prev,
|
|
@@ -337,23 +374,33 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
337
374
|
version: incoming.version,
|
|
338
375
|
last_editor: incoming.last_editor,
|
|
339
376
|
last_edit_at: incoming.last_edit_at,
|
|
377
|
+
// The server's CURRENT visibility, even while the body itself is
|
|
378
|
+
// kept local — `useSessionSocket`'s drift check compares against
|
|
379
|
+
// this field, and it must reflect what the server actually has,
|
|
380
|
+
// not a value frozen at whatever it was when this draft first
|
|
381
|
+
// loaded. `?? prev.active_draft.visibility` only for an older
|
|
382
|
+
// server that omits the field.
|
|
383
|
+
visibility: incoming.visibility ?? prev.active_draft.visibility,
|
|
340
384
|
},
|
|
341
385
|
};
|
|
342
386
|
}
|
|
343
387
|
return { ...prev, active_draft: incoming };
|
|
344
388
|
}
|
|
345
389
|
|
|
346
|
-
case "draft.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
390
|
+
case "draft.typing": {
|
|
391
|
+
// Someone else's box, live. Keyed by author; an empty body means they
|
|
392
|
+
// sent, discarded or cleared it — UNLESS `typing` is true, which means
|
|
393
|
+
// the words are simply withheld (typing/hidden visibility) while the
|
|
394
|
+
// person is still mid-thought, so the row is kept rather than removed.
|
|
395
|
+
const rest = (prev.peer_drafts ?? []).filter((d) => d.author.id !== frame.data.author.id);
|
|
396
|
+
const keep = frame.data.body !== "" || frame.data.typing === true;
|
|
397
|
+
return { ...prev, peer_drafts: keep ? [...rest, frame.data] : rest };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
case "session.queued":
|
|
401
|
+
// Wholesale, like session.turn_status: a client that just connected has
|
|
402
|
+
// no correct prior to merge a delta into.
|
|
403
|
+
return { ...prev, queued: frame.data.queued };
|
|
357
404
|
|
|
358
405
|
case "draft.committed": {
|
|
359
406
|
// The sender already shows its line (a pending row stamped with this
|
|
@@ -439,6 +486,10 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
439
486
|
presence_user_ids: prev.presence_user_ids.filter(
|
|
440
487
|
(id) => id !== frame.data.user_id,
|
|
441
488
|
),
|
|
489
|
+
// A closed tab is the retirement path for its typing row too — without
|
|
490
|
+
// this a peer who left mid-keystroke leaves a stale "is typing" behind
|
|
491
|
+
// with nothing left to clear it.
|
|
492
|
+
peer_drafts: (prev.peer_drafts ?? []).filter((d) => d.author.id !== frame.data.user_id),
|
|
442
493
|
};
|
|
443
494
|
|
|
444
495
|
case "session.error": {
|
|
@@ -44,7 +44,7 @@ describe('sending over HTTP', () => {
|
|
|
44
44
|
act(() => hook.result.current.updateDraft('hello ace'))
|
|
45
45
|
expect(hook.result.current.state.active_draft?.body).toBe('hello ace')
|
|
46
46
|
await act(async () => hook.result.current.sendChat())
|
|
47
|
-
expect(sendOverHttp).toHaveBeenCalledWith('hello ace')
|
|
47
|
+
expect(sendOverHttp).toHaveBeenCalledWith('hello ace', expect.any(String))
|
|
48
48
|
expect(FakeSocket.sent.filter((f) => f.includes('chat.send') || f.includes('draft.update'))).toEqual([])
|
|
49
49
|
})
|
|
50
50
|
|
|
@@ -57,6 +57,23 @@ describe('sending over HTTP', () => {
|
|
|
57
57
|
expect(hook.result.current.state.active_draft).toBeNull()
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
+
it('stamps the SAME client_id on the optimistic row and the HTTP body', async () => {
|
|
61
|
+
// The server's queued-turn projection carries back whatever client_id the
|
|
62
|
+
// POST sent (apps/canopy_sessions/services.py::queued_messages parses it
|
|
63
|
+
// off the turn's idempotency_key). Without a matching id on this row's
|
|
64
|
+
// `content`, `QueuedRows.hideClientIds` cannot recognise the two as one
|
|
65
|
+
// send, and a contact's (or the widget's) own send rendered twice — once
|
|
66
|
+
// as their bubble, once as an unowned "queued" placeholder, since neither
|
|
67
|
+
// path gives a contact an `author.user_id` to compare against.
|
|
68
|
+
const { hook, sendOverHttp } = contact()
|
|
69
|
+
act(() => hook.result.current.updateDraft('please help'))
|
|
70
|
+
await act(async () => hook.result.current.sendChat())
|
|
71
|
+
const sentClientId = sendOverHttp.mock.calls[0][1] as string
|
|
72
|
+
expect(typeof sentClientId).toBe('string')
|
|
73
|
+
expect(sentClientId.length).toBeGreaterThan(0)
|
|
74
|
+
expect(hook.result.current.state.messages.at(-1)?.content?.client_id).toBe(sentClientId)
|
|
75
|
+
})
|
|
76
|
+
|
|
60
77
|
it('a failed send puts the words back and says why', async () => {
|
|
61
78
|
const { hook } = contact(vi.fn().mockRejectedValue(new Error('canopy said 429')))
|
|
62
79
|
act(() => hook.result.current.updateDraft('keep me'))
|
|
@@ -69,6 +86,38 @@ describe('sending over HTTP', () => {
|
|
|
69
86
|
expect(hook.result.current.awaitingReply).toBe(false)
|
|
70
87
|
})
|
|
71
88
|
|
|
89
|
+
it('does not clobber newer typing with a stale failed send', async () => {
|
|
90
|
+
// 2026-09-26 review: contact sends "hello" (draft clears to null), starts
|
|
91
|
+
// typing "world" while the POST is still in flight, "hello"'s request
|
|
92
|
+
// fails — the restore must not overwrite "world" with "hello". The old
|
|
93
|
+
// `updateLocalDraft(body)` closed over "hello" at send time and always
|
|
94
|
+
// won; the fix (`restoreFailedLocalDraft`) only restores when nothing
|
|
95
|
+
// newer has been typed since (the draft is still null/empty).
|
|
96
|
+
let rejectSend!: (err: Error) => void
|
|
97
|
+
const pending: Promise<unknown> = new Promise((_resolve, reject) => {
|
|
98
|
+
rejectSend = reject
|
|
99
|
+
})
|
|
100
|
+
const sendOverHttp = vi.fn().mockReturnValue(pending)
|
|
101
|
+
const { hook } = contact(sendOverHttp)
|
|
102
|
+
|
|
103
|
+
act(() => hook.result.current.updateDraft('hello'))
|
|
104
|
+
act(() => {
|
|
105
|
+
hook.result.current.sendChat()
|
|
106
|
+
})
|
|
107
|
+
expect(hook.result.current.state.active_draft).toBeNull()
|
|
108
|
+
|
|
109
|
+
// Typed BEFORE the request resolves — this must win.
|
|
110
|
+
act(() => hook.result.current.updateDraft('world'))
|
|
111
|
+
expect(hook.result.current.state.active_draft?.body).toBe('world')
|
|
112
|
+
|
|
113
|
+
await act(async () => {
|
|
114
|
+
rejectSend(new Error('network blip'))
|
|
115
|
+
await pending.catch(() => undefined)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(hook.result.current.state.active_draft?.body).toBe('world')
|
|
119
|
+
})
|
|
120
|
+
|
|
72
121
|
it('an empty draft sends nothing', async () => {
|
|
73
122
|
const { hook, sendOverHttp } = contact()
|
|
74
123
|
act(() => hook.result.current.updateDraft(' '))
|