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.
@@ -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.lock_changed", "draft.committed", "draft.discarded",
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 sameText = (m: Message) =>
156
- m.role === "user" &&
157
- m.plaintext.trim() !== "" &&
158
- m.plaintext.trim() === frame.data.plaintext.trim();
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
- content: { text: frame.data.plaintext },
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.lock_changed":
347
- if (prev.active_draft && prev.active_draft.id === frame.data.draft_id) {
348
- return {
349
- ...prev,
350
- active_draft: {
351
- ...prev.active_draft,
352
- last_editor: frame.data.holder_user_id ?? prev.active_draft.last_editor,
353
- },
354
- };
355
- }
356
- return prev;
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(' '))