canopy-ui 0.12.2 → 0.13.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.
@@ -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,
@@ -343,17 +380,17 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
343
380
  return { ...prev, active_draft: incoming };
344
381
  }
345
382
 
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;
383
+ case "draft.typing": {
384
+ // Someone else's box, live. Keyed by author; an empty body means they
385
+ // sent, discarded or cleared it.
386
+ const rest = (prev.peer_drafts ?? []).filter((d) => d.author.id !== frame.data.author.id);
387
+ return { ...prev, peer_drafts: frame.data.body ? [...rest, frame.data] : rest };
388
+ }
389
+
390
+ case "session.queued":
391
+ // Wholesale, like session.turn_status: a client that just connected has
392
+ // no correct prior to merge a delta into.
393
+ return { ...prev, queued: frame.data.queued };
357
394
 
358
395
  case "draft.committed": {
359
396
  // The sender already shows its line (a pending row stamped with this
@@ -439,6 +476,10 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
439
476
  presence_user_ids: prev.presence_user_ids.filter(
440
477
  (id) => id !== frame.data.user_id,
441
478
  ),
479
+ // A closed tab is the retirement path for its typing row too — without
480
+ // this a peer who left mid-keystroke leaves a stale "is typing" behind
481
+ // with nothing left to clear it.
482
+ peer_drafts: (prev.peer_drafts ?? []).filter((d) => d.author.id !== frame.data.user_id),
442
483
  };
443
484
 
444
485
  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(' '))
@@ -23,17 +23,37 @@ const SEND_RECEIPT_TIMEOUT_MS = 4_000;
23
23
  const NOT_SENT =
24
24
  "Not sent: the connection dropped before canopy got it. Your words are kept here, so copy them and send again.";
25
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)}`;
26
+ /**
27
+ * A client-generated nonce for a send's `client_id` — exported so a host
28
+ * driving its own HTTP send (the widget's opening message, before a socket
29
+ * exists to send over; `ChatPage`'s `/share` command) can mint the SAME id
30
+ * for both the REST body and the matching `noteLocalSend` call, which is what
31
+ * lets `QueuedRows.hideClientIds` recognise the two as one send rather than
32
+ * rendering it twice.
33
+ */
34
+ export function newClientId(): string {
35
+ // An idempotency nonce, not a secret — but it rides a request, so it comes
36
+ // from the platform CSPRNG, never Math.random (CodeQL js/insecure-randomness).
37
+ // randomUUID needs a secure context; getRandomValues does not.
38
+ const c = (globalThis as {
39
+ crypto?: { randomUUID?: () => string; getRandomValues?: (a: Uint8Array) => Uint8Array };
40
+ }).crypto;
41
+ if (c?.randomUUID) return c.randomUUID();
42
+ if (c?.getRandomValues) {
43
+ const bytes = c.getRandomValues(new Uint8Array(16));
44
+ return `c${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
45
+ }
46
+ return `c${Date.now().toString(36)}${(clientIdCounter++).toString(36)}`;
29
47
  }
30
48
 
49
+ let clientIdCounter = 0;
50
+
31
51
  const INITIAL_STATE: SessionState = {
32
52
  messages: [],
33
53
  active_draft: null,
34
54
  participants: [],
35
55
  presence_user_ids: [],
36
- current_user_id: 0,
56
+ current_user_id: null,
37
57
  };
38
58
 
39
59
  export interface UseSessionSocketOptions {
@@ -84,8 +104,15 @@ export interface UseSessionSocketOptions {
84
104
  * Everything else is unchanged — the same `state`, the same pending row, the
85
105
  * same "waiting for a reply" — so one chat UI serves a user and a contact
86
106
  * without a second send path in every host. Omit for a user (the default).
107
+ *
108
+ * Must pass `clientId` through as the send's `client_id` (both `/api/canopy-
109
+ * sessions/{id}/send` and `/api/contact/sessions/{id}/send` accept it) — it
110
+ * is the SAME id `sendChatOverHttp` stamps on the optimistic row, and without
111
+ * it the server's queued-turn projection has no way to recognise that row as
112
+ * the one it already showed, so the sender's own send rendered a second time
113
+ * as an unowned "queued" placeholder.
87
114
  */
88
- sendOverHttp?: (text: string) => Promise<unknown>;
115
+ sendOverHttp?: (text: string, clientId: string) => Promise<unknown>;
89
116
  /**
90
117
  * The HTTP route for a USER's send, used only when a socket send gets no
91
118
  * receipt (see `SEND_RECEIPT_TIMEOUT_MS`). Must pass `clientId` through as the
@@ -128,6 +155,10 @@ export interface UseSessionSocketResult {
128
155
  sendChat: () => void;
129
156
  stopChat: (messageId: string | null) => void;
130
157
  updateDraft: (body: string) => void;
158
+ /** @deprecated ignored since 0.13 — everyone has their own draft now, so
159
+ * there is no lock left to take over. Kept as a no-op so a host still
160
+ * passing it through (e.g. ace-web's `onTakeOver={socket.takeOverDraft}`)
161
+ * keeps compiling across the upgrade. */
131
162
  takeOverDraft: () => void;
132
163
  discardDraft: () => void;
133
164
  prependMessages: (older: Message[]) => void;
@@ -144,8 +175,15 @@ export interface UseSessionSocketResult {
144
175
  * by `sendChat` alone, and the user's own line is not a server row until
145
176
  * the agent's transcript ships it back. So you typed, pressed send, and got
146
177
  * an empty panel — for as long as the reply took, and forever if its runner
147
- * was offline. */
148
- noteLocalSend: (text: string) => void;
178
+ * was offline.
179
+ *
180
+ * Pass the SAME `clientId` the matching REST body sent (`sendChatOverHttp`
181
+ * does this internally; a host driving its own HTTP send — the widget's
182
+ * first message, `ChatPage`'s `/share` command — must generate one and pass
183
+ * it to both), so the optimistic row's `content.client_id` matches the
184
+ * server's queued-turn projection and `QueuedRows.hideClientIds` can tell
185
+ * they're the same send rather than rendering it twice. */
186
+ noteLocalSend: (text: string, clientId?: string) => void;
149
187
  lastError: string | null;
150
188
  }
151
189
 
@@ -477,9 +515,10 @@ export function useSessionSocket({
477
515
  [send],
478
516
  );
479
517
 
480
- const takeOverDraft = useCallback(() => {
481
- send({ action: "draft.take_over", data: {} });
482
- }, [send]);
518
+ // @deprecated no-op since 0.13 — the server accepts and ignores
519
+ // `draft.take_over` too (see WsAction), so this is kept only so a host
520
+ // wired to the old prop doesn't need an upgrade-day edit.
521
+ const takeOverDraft = useCallback(() => undefined, []);
483
522
 
484
523
  const discardDraft = useCallback(() => {
485
524
  send({ action: "draft.discard", data: {} });
@@ -521,7 +560,29 @@ export function useSessionSocket({
521
560
  });
522
561
  }, []);
523
562
 
524
- const noteLocalSend = useCallback((text: string) => {
563
+ // A failed send's restore, NOT the same as `updateLocalDraft`: this is a
564
+ // stale write racing whatever the person typed while the request was in
565
+ // flight, so it must never overwrite it. A functional update reads the
566
+ // CURRENT draft rather than one closed over at send time — sendChatOverHttp
567
+ // cleared the draft to null before the request even went out, so "the local
568
+ // draft is still null or empty" means nothing newer has been typed since,
569
+ // and only then is it safe to put the failed body back. If the person has
570
+ // already started something else, that stays exactly as they left it; the
571
+ // failed send is simply lost rather than clobbering live typing (2026-09-26
572
+ // review: "contact sends hello, starts typing world, hello's failure
573
+ // overwrote world").
574
+ const restoreFailedLocalDraft = useCallback((body: string) => {
575
+ setLocalDraft((prev) =>
576
+ prev == null || prev.body === ""
577
+ ? {
578
+ id: "local", slot: "next", status: "open", body, version: 0,
579
+ last_editor: 0, last_edit_at: new Date().toISOString(),
580
+ }
581
+ : prev,
582
+ );
583
+ }, []);
584
+
585
+ const noteLocalSend = useCallback((text: string, clientId?: string) => {
525
586
  setAwaitingReply(true);
526
587
  const body = text.trim();
527
588
  if (!body) return;
@@ -539,6 +600,7 @@ export function useSessionSocket({
539
600
  message_id: `local:${Date.now()}`,
540
601
  turn_index: prev.messages.reduce((acc, m) => Math.max(acc, m.turn_index), 0) + 1,
541
602
  plaintext: body,
603
+ client_id: clientId,
542
604
  },
543
605
  }),
544
606
  );
@@ -547,15 +609,24 @@ export function useSessionSocket({
547
609
  const sendChatOverHttp = useCallback(() => {
548
610
  const body = (localDraft?.body ?? "").trim();
549
611
  if (!body || !sendOverHttp) return;
550
- noteLocalSend(body);
612
+ // ONE id for both halves of this send — the optimistic row and the REST
613
+ // body — so the server's queued-turn projection and this row compare
614
+ // equal (`QueuedRows.hideClientIds`) instead of rendering the same send
615
+ // twice, once as the sender's own bubble and once as an unowned "queued"
616
+ // placeholder (a contact's `author.user_id` is never set, so without a
617
+ // shared id that placeholder didn't even read as "mine").
618
+ const clientId = newClientId();
619
+ noteLocalSend(body, clientId);
551
620
  setLocalDraft(null);
552
- sendOverHttp(body).catch((err: unknown) => {
621
+ sendOverHttp(body, clientId).catch((err: unknown) => {
553
622
  setAwaitingReply(false);
554
623
  setLastError(err instanceof Error ? err.message : "the message could not be sent");
555
- // Put the words back, so a failed send never costs what was typed.
556
- updateLocalDraft(body);
624
+ // Put the words back, so a failed send never costs what was typed —
625
+ // but only if nothing NEWER has been typed since (see
626
+ // `restoreFailedLocalDraft`).
627
+ restoreFailedLocalDraft(body);
557
628
  });
558
- }, [localDraft, sendOverHttp, noteLocalSend, updateLocalDraft]);
629
+ }, [localDraft, sendOverHttp, noteLocalSend, restoreFailedLocalDraft]);
559
630
 
560
631
  const exposedState = useMemo(
561
632
  () => (sendOverHttp ? { ...state, active_draft: localDraft } : state),