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.
@@ -0,0 +1,25 @@
1
+ import type { PeerDraft } from "./protocol";
2
+
3
+ /** Everyone else's box, live — the multiplayer half of the composer. One row
4
+ * per person, newest edit last, right above your own box. */
5
+ export function TypingRows({ peers }: { peers: PeerDraft[] }) {
6
+ if (peers.length === 0) return null;
7
+ return (
8
+ <ul className="space-y-1 border-t border-border bg-background px-3 py-2" aria-live="polite">
9
+ {peers.map((p) =>
10
+ p.body === "" ? (
11
+ <li key={p.author.id} data-testid="typing-row" className="flex min-w-0 gap-2 text-xs">
12
+ <span className="shrink-0 font-medium text-foreground">{p.author.name}</span>
13
+ <span className="shrink-0 text-muted-foreground">is typing…</span>
14
+ </li>
15
+ ) : (
16
+ <li key={p.author.id} data-testid="typing-row" className="flex min-w-0 gap-2 text-xs">
17
+ <span className="shrink-0 font-medium text-foreground">{p.author.name}</span>
18
+ <span className="shrink-0 text-muted-foreground">is typing:</span>
19
+ <span className="min-w-0 truncate italic text-foreground-secondary">{p.body}</span>
20
+ </li>
21
+ ),
22
+ )}
23
+ </ul>
24
+ );
25
+ }
@@ -62,7 +62,11 @@
62
62
  "data": {
63
63
  "message_id": "u1",
64
64
  "turn_index": 3,
65
- "plaintext": "hi there"
65
+ "plaintext": "hi there",
66
+ "author": {
67
+ "name": "Jon",
68
+ "user_id": 7
69
+ }
66
70
  }
67
71
  },
68
72
  "agui": [
@@ -70,7 +74,11 @@
70
74
  "type": "TEXT_MESSAGE_CHUNK",
71
75
  "metadata": {
72
76
  "canopy": {
73
- "turn_index": 3
77
+ "turn_index": 3,
78
+ "author": {
79
+ "name": "Jon",
80
+ "user_id": 7
81
+ }
74
82
  }
75
83
  },
76
84
  "messageId": "u1",
@@ -198,6 +206,33 @@
198
206
  }
199
207
  ]
200
208
  },
209
+ {
210
+ "canopy": {
211
+ "event": "draft.typing",
212
+ "data": {
213
+ "author": {
214
+ "id": 7,
215
+ "name": "Beth"
216
+ },
217
+ "body": "wip",
218
+ "at": "2026-09-26T12:00:00+00:00"
219
+ }
220
+ },
221
+ "agui": [
222
+ {
223
+ "type": "CUSTOM",
224
+ "name": "canopy.draft.typing",
225
+ "value": {
226
+ "author": {
227
+ "id": 7,
228
+ "name": "Beth"
229
+ },
230
+ "body": "wip",
231
+ "at": "2026-09-26T12:00:00+00:00"
232
+ }
233
+ }
234
+ ]
235
+ },
201
236
  {
202
237
  "canopy": {
203
238
  "event": "presence.joined",
@@ -441,6 +476,47 @@
441
476
  }
442
477
  ]
443
478
  },
479
+ {
480
+ "canopy": {
481
+ "event": "session.queued",
482
+ "data": {
483
+ "queued": [
484
+ {
485
+ "turn_id": "8f2e",
486
+ "client_id": "c9",
487
+ "author": {
488
+ "name": "Jon",
489
+ "user_id": 7
490
+ },
491
+ "text": "from jj",
492
+ "sent_at": "2026-09-26T12:00:00+00:00",
493
+ "state": "queued"
494
+ }
495
+ ]
496
+ }
497
+ },
498
+ "agui": [
499
+ {
500
+ "type": "CUSTOM",
501
+ "name": "canopy.session.queued",
502
+ "value": {
503
+ "queued": [
504
+ {
505
+ "turn_id": "8f2e",
506
+ "client_id": "c9",
507
+ "author": {
508
+ "name": "Jon",
509
+ "user_id": 7
510
+ },
511
+ "text": "from jj",
512
+ "sent_at": "2026-09-26T12:00:00+00:00",
513
+ "state": "queued"
514
+ }
515
+ ]
516
+ }
517
+ }
518
+ ]
519
+ },
444
520
  {
445
521
  "canopy": {
446
522
  "event": "session.stop",
@@ -614,27 +690,6 @@
614
690
  }
615
691
  ]
616
692
  },
617
- {
618
- "canopy": {
619
- "event": "draft.lock_changed",
620
- "data": {
621
- "draft_id": "d1",
622
- "holder_user_id": 7,
623
- "expires_at": "2026-09-18T12:01:00+00:00"
624
- }
625
- },
626
- "agui": [
627
- {
628
- "type": "CUSTOM",
629
- "name": "canopy.draft.lock_changed",
630
- "value": {
631
- "draft_id": "d1",
632
- "holder_user_id": 7,
633
- "expires_at": "2026-09-18T12:01:00+00:00"
634
- }
635
- }
636
- ]
637
- },
638
693
  {
639
694
  "canopy": {
640
695
  "event": "page.invalidate",
package/src/chat/agui.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  * standard.
28
28
  */
29
29
 
30
- import type { SessionMenu, WsEvent } from "./protocol";
30
+ import type { MessageAuthor, SessionMenu, WsEvent } from "./protocol";
31
31
 
32
32
  /** Where canopy's own fields ride. Mirrors `agui.METADATA_KEY`. */
33
33
  export const METADATA_KEY = "canopy";
@@ -117,6 +117,11 @@ export function fromAgui(frame: AguiFrame): WsEvent[] {
117
117
  message_id: str(frame.messageId),
118
118
  turn_index: num(m.turn_index),
119
119
  plaintext: str(frame.delta),
120
+ // Who typed it — a transcript-sourced session's durable row carries
121
+ // no user beyond the runner's own login, so this metadata is
122
+ // canopy's only chance to say (spec 2026-09-26). Absent when the
123
+ // server sent none (an agent/scheduled/email turn's own send).
124
+ author: m.author as MessageAuthor | undefined,
120
125
  },
121
126
  },
122
127
  ];
@@ -1,80 +1,18 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest"
1
+ import { describe, expect, it } from "vitest"
2
2
 
3
- import type { Draft } from "./protocol"
4
3
  import {
5
4
  DRAFT_STORAGE_TTL_MS,
6
- IDLE_THRESHOLD_MS,
7
5
  clearStoredDraft,
8
6
  draftStorageKey,
9
- isDraftIdle,
10
- msUntilDraftIdle,
11
7
  readStoredDraft,
8
+ readStoredTypingVisibility,
12
9
  shouldSyncDraftLive,
13
10
  writeStoredDraft,
11
+ writeStoredTypingVisibility,
14
12
  } from "./drafts"
15
13
 
16
14
  const NOW = 1_700_000_000_000
17
15
 
18
- function draftEditedAt(msAgo: number): Draft {
19
- return {
20
- id: "d1",
21
- slot: "next",
22
- status: "open",
23
- body: "",
24
- version: 0,
25
- last_editor: 1,
26
- last_edit_at: new Date(NOW - msAgo).toISOString(),
27
- }
28
- }
29
-
30
- afterEach(() => {
31
- vi.useRealTimers()
32
- })
33
-
34
- describe("isDraftIdle", () => {
35
- it("treats a null/undefined draft as idle", () => {
36
- expect(isDraftIdle(null)).toBe(true)
37
- expect(isDraftIdle(undefined)).toBe(true)
38
- })
39
-
40
- it("treats a draft with no last_edit_at as idle", () => {
41
- const d = { ...draftEditedAt(0), last_edit_at: "" }
42
- expect(isDraftIdle(d)).toBe(true)
43
- })
44
-
45
- it("is NOT idle immediately after an edit", () => {
46
- vi.useFakeTimers()
47
- vi.setSystemTime(NOW)
48
- // edited 500ms ago — well within the 2s threshold
49
- expect(isDraftIdle(draftEditedAt(500))).toBe(false)
50
- })
51
-
52
- it("IS idle once more than the threshold has elapsed", () => {
53
- vi.useFakeTimers()
54
- vi.setSystemTime(NOW)
55
- expect(isDraftIdle(draftEditedAt(IDLE_THRESHOLD_MS + 1))).toBe(true)
56
- })
57
- })
58
-
59
- describe("msUntilDraftIdle", () => {
60
- it("returns 0 for a null draft", () => {
61
- expect(msUntilDraftIdle(null)).toBe(0)
62
- })
63
-
64
- it("returns the remaining time before the idle transition", () => {
65
- vi.useFakeTimers()
66
- vi.setSystemTime(NOW)
67
- // edited 500ms ago → 1500ms remain
68
- expect(msUntilDraftIdle(draftEditedAt(500))).toBe(1500)
69
- })
70
-
71
- it("clamps to 0 once past the threshold", () => {
72
- vi.useFakeTimers()
73
- vi.setSystemTime(NOW)
74
- expect(msUntilDraftIdle(draftEditedAt(IDLE_THRESHOLD_MS + 5000))).toBe(0)
75
- })
76
- })
77
-
78
16
  describe("shouldSyncDraftLive", () => {
79
17
  it("does not mirror keystrokes when you are alone", () => {
80
18
  // The single-player case: a per-keystroke draft.update costs a round trip,
@@ -208,3 +146,43 @@ describe("clearStoredDraft", () => {
208
146
  expect(readStoredDraft(s, KEY, NOW)).toBeNull()
209
147
  })
210
148
  })
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Typing-visibility persistence — per browser, not per session.
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe("readStoredTypingVisibility / writeStoredTypingVisibility", () => {
155
+ it("defaults to null when nothing is stored", () => {
156
+ expect(readStoredTypingVisibility(fakeStorage())).toBeNull()
157
+ })
158
+
159
+ it("round-trips a written mode", () => {
160
+ const s = fakeStorage()
161
+ writeStoredTypingVisibility(s, "typing")
162
+ expect(readStoredTypingVisibility(s)).toBe("typing")
163
+ })
164
+
165
+ it("rejects a value that isn't one of the three modes", () => {
166
+ const s = fakeStorage({ "canopy.chat.typingVisibility": "loud" })
167
+ expect(readStoredTypingVisibility(s)).toBeNull()
168
+ })
169
+
170
+ it("is inert without a storage", () => {
171
+ expect(readStoredTypingVisibility(null)).toBeNull()
172
+ expect(() => writeStoredTypingVisibility(null, "hidden")).not.toThrow()
173
+ })
174
+
175
+ it("survives a storage that throws", () => {
176
+ const hostile = {
177
+ getItem: () => {
178
+ throw new Error("SecurityError")
179
+ },
180
+ setItem: () => {
181
+ throw new Error("SecurityError")
182
+ },
183
+ removeItem: () => {},
184
+ }
185
+ expect(readStoredTypingVisibility(hostile)).toBeNull()
186
+ expect(() => writeStoredTypingVisibility(hostile, "live")).not.toThrow()
187
+ })
188
+ })
@@ -1,11 +1,4 @@
1
- import type { Draft } from "./protocol";
2
-
3
- export const IDLE_THRESHOLD_MS = 2_000;
4
-
5
- export function isDraftIdle(draft: Draft | null | undefined): boolean {
6
- if (!draft?.last_edit_at) return true;
7
- return Date.now() - new Date(draft.last_edit_at).getTime() > IDLE_THRESHOLD_MS;
8
- }
1
+ import type { TypingVisibility } from "./protocol";
9
2
 
10
3
  /**
11
4
  * Whether keystrokes need to be mirrored to the server AS YOU TYPE.
@@ -25,17 +18,6 @@ export function shouldSyncDraftLive(presenceUserIds: readonly number[]): boolean
25
18
  return presenceUserIds.length > 1;
26
19
  }
27
20
 
28
- /**
29
- * Milliseconds until the draft lock becomes idle. Returns 0 if already
30
- * idle. Use this to schedule a timer that forces a re-render at the
31
- * idle transition point.
32
- */
33
- export function msUntilDraftIdle(draft: Draft | null | undefined): number {
34
- if (!draft?.last_edit_at) return 0;
35
- const elapsed = Date.now() - new Date(draft.last_edit_at).getTime();
36
- return Math.max(0, IDLE_THRESHOLD_MS - elapsed);
37
- }
38
-
39
21
  // ---------------------------------------------------------------------------
40
22
  // Composer persistence — surviving a navigation the server never hears about.
41
23
  //
@@ -169,3 +151,43 @@ function remove(storage: DraftStorage, key: string): void {
169
151
  // same as above — best effort
170
152
  }
171
153
  }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Typing-visibility persistence — per BROWSER, not per session: it is a
157
+ // standing choice about how you type, not something that resets when you
158
+ // switch chats. One key, not namespaced by `persistKey` like a draft body.
159
+ // ---------------------------------------------------------------------------
160
+
161
+ // Exported so a listener can recognise a `storage` event for this key coming
162
+ // from another tab (see useSessionSocket's cross-tab sync).
163
+ export const TYPING_VISIBILITY_STORAGE_KEY = "canopy.chat.typingVisibility";
164
+
165
+ const VALID_VISIBILITIES: readonly TypingVisibility[] = ["live", "typing", "hidden"];
166
+
167
+ /** The persisted mode, or null when there is nothing stored or it is
168
+ * unreadable/invalid — same try/catch-safe shape as `readStoredDraft`. */
169
+ export function readStoredTypingVisibility(storage: DraftStorage | null): TypingVisibility | null {
170
+ if (!storage) return null;
171
+ let raw: string | null = null;
172
+ try {
173
+ raw = storage.getItem(TYPING_VISIBILITY_STORAGE_KEY);
174
+ } catch {
175
+ return null;
176
+ }
177
+ return (VALID_VISIBILITIES as readonly string[]).includes(raw ?? "")
178
+ ? (raw as TypingVisibility)
179
+ : null;
180
+ }
181
+
182
+ export function writeStoredTypingVisibility(
183
+ storage: DraftStorage | null,
184
+ visibility: TypingVisibility,
185
+ ): void {
186
+ if (!storage) return;
187
+ try {
188
+ storage.setItem(TYPING_VISIBILITY_STORAGE_KEY, visibility);
189
+ } catch {
190
+ // Quota exceeded, or storage disabled mid-session — best effort, same as
191
+ // `writeStoredDraft`.
192
+ }
193
+ }
@@ -0,0 +1,37 @@
1
+ import type { Message, MessageAuthor } from "./protocol";
2
+
3
+ /** Is this line the viewer's own? A viewer is a canopy user (`currentUserId`)
4
+ * OR a contact — a widget visitor, who has no user id and is named by the
5
+ * snapshot's `current_contact_id` instead. Comparing user ids alone made every
6
+ * line a contact wrote render as someone else's, under their own name. */
7
+ export function isMine(
8
+ author: MessageAuthor | null | undefined,
9
+ currentUserId: number | null | undefined,
10
+ currentContactId: number | null | undefined,
11
+ ): boolean {
12
+ if (!author) return false;
13
+ return (
14
+ (author.user_id != null && author.user_id === currentUserId) ||
15
+ (author.contact_id != null && author.contact_id === currentContactId)
16
+ );
17
+ }
18
+
19
+ /** The same person, or no way to tell (either side unauthored). */
20
+ export function sameAuthor(
21
+ a: MessageAuthor | null | undefined,
22
+ b: MessageAuthor | null | undefined,
23
+ ): boolean {
24
+ if (!a || !b) return true;
25
+ if (a.user_id != null || b.user_id != null) return a.user_id === b.user_id;
26
+ return a.contact_id === b.contact_id;
27
+ }
28
+
29
+ /** A user row still waiting for the durable line it will become: an optimistic
30
+ * send (`local:`), a receipted transcript-sourced send (`transient:` — the
31
+ * server wrote no row, the transcript will), or one still pending/failed. */
32
+ export function isUnconfirmed(m: Message): boolean {
33
+ return (
34
+ m.status === "pending" || m.status === "error" ||
35
+ m.id.startsWith("local:") || m.id.startsWith("transient:")
36
+ );
37
+ }
package/src/chat/index.ts CHANGED
@@ -7,13 +7,17 @@
7
7
  // Protocol types
8
8
  export type {
9
9
  Message,
10
+ MessageAuthor,
10
11
  MessageRole,
11
12
  MessageStatus,
12
13
  Draft,
14
+ PeerDraft,
15
+ QueuedMessage,
13
16
  Participant,
14
17
  SessionMenu,
15
18
  SessionState,
16
19
  TurnStatus,
20
+ TypingVisibility,
17
21
  WsAction,
18
22
  WsEvent,
19
23
  } from "./protocol";
@@ -29,6 +33,7 @@ export { prependHistory } from "./history";
29
33
  // Hooks
30
34
  export {
31
35
  useSessionSocket,
36
+ newClientId,
32
37
  type UseSessionSocketOptions,
33
38
  type UseSessionSocketResult,
34
39
  } from "./useSessionSocket";
@@ -45,9 +50,6 @@ export {
45
50
  type TurnNotice,
46
51
  } from "./turnStatus";
47
52
 
48
- // Draft idle helpers
49
- export { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts";
50
-
51
53
  // Composer persistence (survives unmount / tab close)
52
54
  export {
53
55
  DRAFT_STORAGE_TTL_MS,
@@ -56,6 +58,8 @@ export {
56
58
  draftStorageKey,
57
59
  readStoredDraft,
58
60
  writeStoredDraft,
61
+ readStoredTypingVisibility,
62
+ writeStoredTypingVisibility,
59
63
  type DraftStorage,
60
64
  } from "./drafts";
61
65
 
@@ -77,6 +81,9 @@ export { MessageItem, type RenderMarkdown } from "./MessageItem";
77
81
  export { ToolCallPair } from "./ToolCallPair";
78
82
  export { SendBox, type PendingAttachment } from "./SendBox";
79
83
  export { PresenceChips } from "./PresenceChips";
84
+ export { TypingRows } from "./TypingRows";
85
+ export { QueuedRows } from "./QueuedRows";
86
+ export { isMine } from "./identity";
80
87
  export { ConnectionStatus } from "./ConnectionStatus";
81
88
  export {
82
89
  PlacementBanner,
@@ -26,6 +26,14 @@ export type MessageRole =
26
26
  // Session + message shapes (the `session.state` snapshot payload)
27
27
  // ---------------------------------------------------------------------------
28
28
 
29
+ /** Who sent a human line — a canopy user or a contact, never both.
30
+ * Mirrors `apps/canopy_sessions/authorship.py::parse`'s return shape. */
31
+ export interface MessageAuthor {
32
+ name: string;
33
+ user_id?: number;
34
+ contact_id?: number;
35
+ }
36
+
29
37
  export interface Message {
30
38
  /** String PK (canopy sends `str(msg.pk)`), or a synthetic stream id. */
31
39
  id: string;
@@ -38,8 +46,17 @@ export interface Message {
38
46
  started_at: string | null;
39
47
  completed_at: string | null;
40
48
  created_at: string;
49
+ /** Who typed this line — a canopy user or a contact. Absent/null on rows
50
+ * from before authorship was tracked, or on the agent's own messages. */
51
+ author?: MessageAuthor | null;
41
52
  }
42
53
 
54
+ /** How much of an in-progress message its author lets OTHERS see: the words
55
+ * live as typed (`live`, today's behaviour), just the fact of typing
56
+ * (`typing`), or nothing at all until it's sent (`hidden`). Chosen per
57
+ * person, per browser (persisted in localStorage) — see useSessionSocket. */
58
+ export type TypingVisibility = "live" | "typing" | "hidden";
59
+
43
60
  export interface Draft {
44
61
  /** String PK (canopy sends `str(draft.pk)`). */
45
62
  id: string;
@@ -49,6 +66,42 @@ export interface Draft {
49
66
  version: number;
50
67
  last_editor: number;
51
68
  last_edit_at: string;
69
+ /** The draft's OWNER — distinct from `last_editor`, which is whoever most
70
+ * recently typed in it. Since a `draft.updated` frame now only ever
71
+ * reaches its author's own sockets, this is the reliable field to compare
72
+ * against `current_user_id` for echo-suppression; `last_editor` is kept
73
+ * for servers that don't send it yet. */
74
+ author_id?: number;
75
+ /** This author's chosen mode. Absent on an older server. */
76
+ visibility?: TypingVisibility;
77
+ }
78
+
79
+ /** A peer's live, uncommitted draft — everyone else's box, broadcast as they
80
+ * type. Keyed by author, never by draft id: a client only ever needs "what
81
+ * is this person typing right now", and an empty `body` means they sent,
82
+ * discarded, or cleared it — OR its author is in `typing`/`hidden` mode,
83
+ * where the server withholds the words but still says whether they're
84
+ * typing via `typing`. */
85
+ export interface PeerDraft {
86
+ author: { id: number; name: string };
87
+ body: string;
88
+ at: string | null;
89
+ /** Whether this person is actively typing right now — the one fact the
90
+ * server still tells peers when `body` is withheld. Absent from an older
91
+ * server, where a non-empty `body` was the only signal. */
92
+ typing?: boolean;
93
+ }
94
+
95
+ /** A send that landed on the server but hasn't reached the agent's turn yet
96
+ * — the server-derived queue, replacing whatever a client used to infer
97
+ * locally from its own optimistic sends. */
98
+ export interface QueuedMessage {
99
+ turn_id: string;
100
+ client_id: string;
101
+ author: MessageAuthor | null;
102
+ text: string;
103
+ sent_at: string;
104
+ state: "queued" | "delivering";
52
105
  }
53
106
 
54
107
  export interface Participant {
@@ -197,10 +250,23 @@ export interface SessionState {
197
250
  * changed. Null/undefined when nothing has been asked on this session yet —
198
251
  * which is distinct from "finished", and must stay distinct. */
199
252
  turn_status?: TurnStatus | null;
253
+ /** The CALLER's own draft — null for a viewer, who has nothing to draft. */
200
254
  active_draft: Draft | null;
255
+ /** Everyone else's live drafts, server-derived. Absent on an older server. */
256
+ peer_drafts?: PeerDraft[];
257
+ /** Sends that landed but haven't reached the agent's turn yet, server-
258
+ * derived and replaced wholesale on every frame — like `turn_status`, a
259
+ * client that just connected has no correct prior to merge a delta into.
260
+ * Absent on an older server. */
261
+ queued?: QueuedMessage[];
201
262
  participants: Participant[];
202
263
  presence_user_ids: number[];
203
- current_user_id: number;
264
+ /** The connecting user — null for a contact (a widget visitor), who has no
265
+ * user id. */
266
+ current_user_id: number | null;
267
+ /** The connecting CONTACT, when the viewer is one; null for a member.
268
+ * Absent on an older server. */
269
+ current_contact_id?: number | null;
204
270
  }
205
271
 
206
272
  // ---------------------------------------------------------------------------
@@ -211,6 +277,14 @@ export type WsAction =
211
277
  | { action: "chat.send"; data: Record<string, never> }
212
278
  | { action: "chat.stop"; data: { message_id: string } }
213
279
  | { action: "draft.update"; data: { version: number; body: string } }
280
+ // The mode is its OWN idempotent frame, not a field on the version-guarded
281
+ // `draft.update` above — applied unconditionally server-side (no version
282
+ // check), so it can never race a keystroke and never itself causes a
283
+ // `draft_version_mismatch`. A `draft.update` carrying a stray `visibility`
284
+ // (an in-flight older 0.14 client) is simply ignored server-side.
285
+ | { action: "draft.set_visibility"; data: { visibility: TypingVisibility } }
286
+ /** @deprecated accepted and ignored by the server since 0.13 — every editor
287
+ * gets their own draft now, so there is no lock left to take over. */
214
288
  | { action: "draft.take_over"; data: Record<string, never> }
215
289
  | { action: "draft.discard"; data: Record<string, never> }
216
290
  | { action: "presence.heartbeat"; data: Record<string, never> };
@@ -238,8 +312,17 @@ export type WsEvent =
238
312
  // offline box, unroutable) are ones activity has no way to spell.
239
313
  | { event: "session.turn_status"; data: { status: TurnStatus | null } }
240
314
  // A human typed into emdash rather than into this page. No client echoed it,
241
- // so this is the only way it reaches the browser before a reload.
242
- | { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string } }
315
+ // so this is the only way it reaches the browser before a reload. `author`
316
+ // rides here for the same reason it rides the queued list (spec 2026-09-26):
317
+ // a transcript-sourced session's durable row carries no user beyond the
318
+ // runner's own login, so this is canopy's only chance to say who typed it.
319
+ // `client_id` is likewise the only way an HTTP send's OPTIMISTIC row (built
320
+ // locally by `noteLocalSend`, never a real server frame) can carry the same
321
+ // id its REST body sent — without it `QueuedRows.hideClientIds` cannot match
322
+ // this row to the server's queued-turn projection, and a contact's or
323
+ // widget's own send rendered twice: once as their bubble, once as a "queued"
324
+ // placeholder for the same send.
325
+ | { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string; author?: MessageAuthor | null; client_id?: string } }
243
326
  | { event: "chat.delta"; data: { message_id: string; text: string } }
244
327
  // `turn_index` is the row's transcript ordinal — the same key the persisted
245
328
  // Message carries, so a live tool row sorts into exactly the position it will
@@ -250,7 +333,13 @@ export type WsEvent =
250
333
  | { event: "chat.stream_error"; data: { message_id: string; detail: string } }
251
334
  | { event: "chat.stream_cancelled"; data: { message_id: string | null; partial_len: number } }
252
335
  | { event: "draft.updated"; data: Draft }
253
- | { event: "draft.lock_changed"; data: { draft_id: string; holder_user_id: number | null; expires_at: number | null } }
336
+ // Someone else's box, live — keyed by author, not draft id, because a peer's
337
+ // draft is never addressable by this client and never needs to be: only
338
+ // "what are they typing right now" does. An empty `body` means they sent,
339
+ // discarded, or cleared it. Replaces `draft.lock_changed`, which existed
340
+ // only because a session had one shared next-draft to fight over; now
341
+ // everyone has their own, so there is no lock left to report.
342
+ | { event: "draft.typing"; data: PeerDraft }
254
343
  // `client_id` is the sender's receipt for a send that carried one (see
255
344
  // useSessionSocket.sendChat); absent from older servers.
256
345
  | { event: "draft.committed"; data: { draft_id: string; user_message_id: string; client_id?: string } }
@@ -270,4 +359,7 @@ export type WsEvent =
270
359
  display_name?: string;
271
360
  };
272
361
  }
273
- | { event: "presence.left"; data: { user_id: number } };
362
+ | { event: "presence.left"; data: { user_id: number } }
363
+ // The whole queue, wholesale, like `session.turn_status` — a client that
364
+ // just connected has no correct prior to merge a delta into.
365
+ | { event: "session.queued"; data: { queued: QueuedMessage[] } };