canopy-ui 0.6.3 → 0.7.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.
@@ -8,7 +8,15 @@ import {
8
8
  import type React from "react";
9
9
 
10
10
  import type { Draft } from "./protocol";
11
- import { isDraftIdle, msUntilDraftIdle } from "./drafts";
11
+ import {
12
+ clearStoredDraft,
13
+ defaultDraftStorage,
14
+ isDraftIdle,
15
+ msUntilDraftIdle,
16
+ readStoredDraft,
17
+ writeStoredDraft,
18
+ type DraftStorage,
19
+ } from "./drafts";
12
20
  import { Button } from "../ui/button";
13
21
 
14
22
  /** An attachment the composer is holding, uploaded but not yet sent. */
@@ -28,6 +36,10 @@ interface Props {
28
36
  connected: boolean;
29
37
  currentUserId: number;
30
38
  holderIsPresent: boolean;
39
+ /** Display name of the teammate holding the draft, when it is not you.
40
+ * The composer is where their words appear, so it is where their name
41
+ * belongs — it used to live only in a 28px chip in the opposite corner. */
42
+ holderName?: string | null;
31
43
  isStreaming: boolean;
32
44
  streamingMessageId: string | null;
33
45
  onUpdate: (body: string) => void;
@@ -36,6 +48,10 @@ interface Props {
36
48
  * The server cancels every non-terminal turn regardless, so a null id is
37
49
  * a valid cancel, not a no-op. */
38
50
  onStop: (messageId: string | null) => void;
51
+ /** Whether the stop the human asked for actually landed. Rendered next to the
52
+ * button that asked for it, because that is where the person who pressed it
53
+ * is looking. Undefined = no stop has been asked for on this turn. */
54
+ stopState?: "requested" | "stopped" | "failed";
39
55
  onTakeOver: () => void;
40
56
  /** Optional app-supplied banner rendered above the composer (e.g. an
41
57
  * imported-session note). The kit itself has no CLI-auth banners. */
@@ -49,6 +65,13 @@ interface Props {
49
65
  * paths); it re-renders `attachments` as they progress. */
50
66
  onAttach?: (files: File[]) => void;
51
67
  onRemoveAttachment?: (id: string) => void;
68
+ /** Persist what is typed under this key (the session id) so it survives
69
+ * unmounting — routing away and back, or closing the tab. Omit to keep the
70
+ * purely in-memory behaviour. */
71
+ persistKey?: string;
72
+ /** Storage backing `persistKey`. Defaults to localStorage; inject a fake in
73
+ * tests, or sessionStorage for per-tab drafts. */
74
+ storage?: DraftStorage | null;
52
75
  }
53
76
 
54
77
  export function SendBox({
@@ -56,17 +79,21 @@ export function SendBox({
56
79
  connected,
57
80
  currentUserId,
58
81
  holderIsPresent,
82
+ holderName,
59
83
  isStreaming,
60
84
  streamingMessageId,
61
85
  onUpdate,
62
86
  onSend,
63
87
  onStop,
88
+ stopState,
64
89
  onTakeOver,
65
90
  banner,
66
91
  disabledReason,
67
92
  attachments,
68
93
  onAttach,
69
94
  onRemoveAttachment,
95
+ persistKey,
96
+ storage,
70
97
  }: Props) {
71
98
  const textareaRef = useRef<HTMLTextAreaElement>(null);
72
99
  // Force a re-render when the lock transitions from live to idle.
@@ -94,7 +121,42 @@ export function SendBox({
94
121
  // wholesale), would rewind the composer to a body from 150ms ago. In
95
122
  // single-player that reconciliation protects against nothing at all, since
96
123
  // there is no co-editor whose edits could be lost.
97
- const [localBody, setLocalBody] = useState(draft?.body ?? "");
124
+ //
125
+ // Seeded from persisted storage when there is one, because a body typed
126
+ // before an unmount exists NOWHERE else: single-player never mirrors it to
127
+ // the server (see drafts.shouldSyncDraftLive), and the adopt rule below
128
+ // deliberately ignores our own server draft. A stored body wins over
129
+ // `draft.body` — it is strictly newer, being what was in the box when we
130
+ // last left it.
131
+ const store = storage === undefined ? defaultDraftStorage() : storage;
132
+ const [localBody, setLocalBody] = useState(
133
+ () => readStoredDraft(store, persistKey ?? "") ?? draft?.body ?? "",
134
+ );
135
+
136
+ // Persistence is a synchronous localStorage write per keystroke — no
137
+ // debounce on purpose. The payload is a chat message, the write is
138
+ // microseconds, and every timer-based alternative has to solve flush-on-
139
+ // unmount and flush-on-tab-close to be correct at exactly the moments this
140
+ // feature exists for.
141
+ const persist = (body: string) => {
142
+ if (persistKey) writeStoredDraft(store, persistKey, body);
143
+ };
144
+ // Nothing persists a CO-EDITOR's text, deliberately: a draft someone else is
145
+ // editing is by definition being live-synced to the server, so the existing
146
+ // adopt rule below restores it from `session.state` on the way back in. The
147
+ // gap this whole mechanism closes is the single-player one, where the server
148
+ // is never told at all.
149
+
150
+ // The panel can swap sessions without remounting (same route, new :id), so
151
+ // the box must follow the key rather than carry one session's text into the
152
+ // next. Adjusted during render rather than in an effect — React's documented
153
+ // shape for "reset state when a prop changes", and the one that avoids a
154
+ // paint showing the previous session's text.
155
+ const [keyOnScreen, setKeyOnScreen] = useState(persistKey);
156
+ if (keyOnScreen !== persistKey) {
157
+ setKeyOnScreen(persistKey);
158
+ setLocalBody(readStoredDraft(store, persistKey ?? "") ?? "");
159
+ }
98
160
 
99
161
  // The ONE case where the server genuinely knows better than this client:
100
162
  // somebody ELSE edited the shared draft. Our own echo is ignored, which is
@@ -121,6 +183,10 @@ export function SendBox({
121
183
 
122
184
  const body = localBody;
123
185
  const blocked = Boolean(disabledReason);
186
+ // Locked BY SOMEONE ELSE, as opposed to blocked for an unrelated reason.
187
+ // The two look identical to `disabled` and want opposite treatments: a
188
+ // blocked box is inert, a co-edited one is showing you live content.
189
+ const lockedByTeammate = !canEdit && holderIsPresent && !holderIsIdle && !blocked;
124
190
  // Sending needs a draft (`chat.send` commits the SERVER's copy, so there must
125
191
  // be one) AND a live socket. The socket check is load-bearing now that the
126
192
  // composer clears optimistically: `send()` drops every frame but chat.stop
@@ -136,6 +202,7 @@ export function SendBox({
136
202
 
137
203
  const handleChange = (value: string) => {
138
204
  setLocalBody(value);
205
+ persist(value);
139
206
  onUpdate(value);
140
207
  };
141
208
 
@@ -163,6 +230,7 @@ export function SendBox({
163
230
  // echo back: that echo carries last_editor === us, which the adopt rule
164
231
  // above (correctly) ignores, so nothing else would empty the box.
165
232
  setLocalBody("");
233
+ if (persistKey) clearStoredDraft(store, persistKey);
166
234
  onSend();
167
235
  };
168
236
 
@@ -239,8 +307,37 @@ export function SendBox({
239
307
  ))}
240
308
  </ul>
241
309
  )}
310
+ {/* Co-edit attribution, at the box rather than across the screen.
311
+ Their text arrives INSIDE this textarea, and a disabled textarea
312
+ renders it in muted grey — pixel-identical to a placeholder. So the
313
+ single most important thing multiplayer does (showing you what your
314
+ teammate is writing) read as an EMPTY box with a hint in it. */}
315
+ {lockedByTeammate && (
316
+ <div
317
+ data-testid="coedit-banner"
318
+ className="mb-1.5 flex items-center gap-2 rounded-md border border-primary/30 bg-primary/10 px-2 py-1 text-xs"
319
+ >
320
+ <span className="relative flex h-1.5 w-1.5 shrink-0" aria-hidden="true">
321
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
322
+ <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary" />
323
+ </span>
324
+ <span className="text-foreground">
325
+ <span className="font-medium">{holderName ?? "A teammate"}</span> is
326
+ writing — you are seeing their draft
327
+ </span>
328
+ <button
329
+ type="button"
330
+ data-testid="take-over"
331
+ onClick={onTakeOver}
332
+ className="ml-auto shrink-0 rounded border border-border px-1.5 py-0.5 font-medium text-foreground hover:bg-muted"
333
+ >
334
+ take over
335
+ </button>
336
+ </div>
337
+ )}
242
338
  <textarea
243
339
  ref={textareaRef}
340
+ data-testid="composer"
244
341
  value={body}
245
342
  disabled={!canEdit || blocked}
246
343
  onChange={(e) => handleChange(e.target.value)}
@@ -248,7 +345,18 @@ export function SendBox({
248
345
  onPaste={handlePaste}
249
346
  placeholder={placeholder}
250
347
  rows={3}
251
- className="w-full resize-none rounded-md border border-input bg-transparent p-2 text-sm text-foreground shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground"
348
+ className={[
349
+ "w-full resize-none rounded-md border bg-transparent p-2 text-sm shadow-sm",
350
+ "placeholder:text-muted-foreground focus-visible:outline-none",
351
+ "focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed",
352
+ // A teammate's draft is CONTENT, not a disabled control. Dimming it
353
+ // to `text-muted-foreground` made their sentence look like the
354
+ // placeholder it sits next to — the one styling choice that made
355
+ // co-editing appear not to work at all.
356
+ lockedByTeammate
357
+ ? "border-primary/40 bg-primary/5 text-foreground"
358
+ : "border-input text-foreground disabled:bg-muted disabled:text-muted-foreground",
359
+ ].join(" ")}
252
360
  />
253
361
  <div className="mt-1 flex items-center justify-end gap-2">
254
362
  {canAttach && (
@@ -281,6 +389,26 @@ export function SendBox({
281
389
  {disabledReason}
282
390
  </span>
283
391
  )}
392
+ {stopState === "failed" ? (
393
+ // The one state that MUST be loud. Everything else here is either
394
+ // self-evident (the reply stopped) or transient. A stop that did not
395
+ // take looks exactly like a stop that worked — the agent keeps
396
+ // running either way — so without this it is invisible, which is the
397
+ // whole reason Stop could not be trusted.
398
+ <span
399
+ role="status"
400
+ className="mr-auto text-xs font-medium text-destructive"
401
+ title="Escape was pressed and the agent is still running. Try again, or stop it in the terminal."
402
+ >
403
+ stop didn&rsquo;t take — still running
404
+ </span>
405
+ ) : null}
406
+ {/* Never DISABLED while a stop is in flight, only relabelled. If the
407
+ runner dies between the request and the verdict, `stopState` never
408
+ advances — and a button that latched off would leave the human with
409
+ no way to ask again, the exact trap #519 documents for the composer
410
+ lock. Pressing again is harmless: the runner dedupes by session_key,
411
+ so three impatient presses are still one Escape. */}
284
412
  {isStreaming ? (
285
413
  <Button
286
414
  type="button"
@@ -288,15 +416,16 @@ export function SendBox({
288
416
  size="sm"
289
417
  onClick={handleStopClick}
290
418
  >
291
- stop
292
- </Button>
293
- ) : null}
294
- {!canEdit && holderIsPresent && !holderIsIdle ? (
295
- <Button type="button" variant="outline" size="sm" onClick={onTakeOver}>
296
- take over
419
+ {stopState === "requested" ? "stopping…" : "stop"}
297
420
  </Button>
298
421
  ) : null}
299
- <Button type="button" size="sm" disabled={!canSend} onClick={handleSend}>
422
+ <Button
423
+ type="button"
424
+ size="sm"
425
+ data-testid="send"
426
+ disabled={!canSend}
427
+ onClick={handleSend}
428
+ >
300
429
  send
301
430
  </Button>
302
431
  </div>
@@ -2,10 +2,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"
2
2
 
3
3
  import type { Draft } from "./protocol"
4
4
  import {
5
+ DRAFT_STORAGE_TTL_MS,
5
6
  IDLE_THRESHOLD_MS,
7
+ clearStoredDraft,
8
+ draftStorageKey,
6
9
  isDraftIdle,
7
10
  msUntilDraftIdle,
11
+ readStoredDraft,
8
12
  shouldSyncDraftLive,
13
+ writeStoredDraft,
9
14
  } from "./drafts"
10
15
 
11
16
  const NOW = 1_700_000_000_000
@@ -88,3 +93,118 @@ describe("shouldSyncDraftLive", () => {
88
93
  expect(shouldSyncDraftLive([1, 2])).toBe(true)
89
94
  })
90
95
  })
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Composer persistence
99
+ // ---------------------------------------------------------------------------
100
+
101
+ function fakeStorage(seed: Record<string, string> = {}) {
102
+ const map = new Map(Object.entries(seed))
103
+ return {
104
+ map,
105
+ getItem: (k: string) => map.get(k) ?? null,
106
+ setItem: (k: string, v: string) => void map.set(k, v),
107
+ removeItem: (k: string) => void map.delete(k),
108
+ }
109
+ }
110
+
111
+ const KEY = "sess-1"
112
+
113
+ describe("readStoredDraft", () => {
114
+ it("returns null when there is nothing stored", () => {
115
+ expect(readStoredDraft(fakeStorage(), KEY)).toBeNull()
116
+ })
117
+
118
+ it("round-trips a written body", () => {
119
+ const s = fakeStorage()
120
+ writeStoredDraft(s, KEY, "half a thought", NOW)
121
+ expect(readStoredDraft(s, KEY, NOW + 1000)).toBe("half a thought")
122
+ })
123
+
124
+ it("drops and prunes an entry past the TTL", () => {
125
+ const s = fakeStorage()
126
+ writeStoredDraft(s, KEY, "stale", NOW)
127
+ expect(readStoredDraft(s, KEY, NOW + DRAFT_STORAGE_TTL_MS + 1)).toBeNull()
128
+ expect(s.map.has(draftStorageKey(KEY))).toBe(false)
129
+ })
130
+
131
+ it("keeps an entry right up to the TTL boundary", () => {
132
+ const s = fakeStorage()
133
+ writeStoredDraft(s, KEY, "fresh enough", NOW)
134
+ expect(readStoredDraft(s, KEY, NOW + DRAFT_STORAGE_TTL_MS)).toBe("fresh enough")
135
+ })
136
+
137
+ it("drops and prunes malformed JSON", () => {
138
+ const s = fakeStorage({ [draftStorageKey(KEY)]: "{not json" })
139
+ expect(readStoredDraft(s, KEY, NOW)).toBeNull()
140
+ expect(s.map.has(draftStorageKey(KEY))).toBe(false)
141
+ })
142
+
143
+ it("drops an entry of the wrong shape", () => {
144
+ const s = fakeStorage({ [draftStorageKey(KEY)]: JSON.stringify({ body: 42 }) })
145
+ expect(readStoredDraft(s, KEY, NOW)).toBeNull()
146
+ })
147
+
148
+ it("reports an empty stored body as nothing to restore", () => {
149
+ // "" must not shadow a server draft the host does want rendered.
150
+ const s = fakeStorage({
151
+ [draftStorageKey(KEY)]: JSON.stringify({ body: "", at: NOW }),
152
+ })
153
+ expect(readStoredDraft(s, KEY, NOW)).toBeNull()
154
+ })
155
+
156
+ it("is inert without a storage or without a key", () => {
157
+ expect(readStoredDraft(null, KEY)).toBeNull()
158
+ expect(readStoredDraft(fakeStorage(), "")).toBeNull()
159
+ })
160
+
161
+ it("survives a storage that throws on read", () => {
162
+ // Safari private mode / blocked third-party storage.
163
+ const s = {
164
+ getItem: () => {
165
+ throw new Error("SecurityError")
166
+ },
167
+ setItem: () => {},
168
+ removeItem: () => {},
169
+ }
170
+ expect(() => readStoredDraft(s, KEY)).not.toThrow()
171
+ expect(readStoredDraft(s, KEY)).toBeNull()
172
+ })
173
+ })
174
+
175
+ describe("writeStoredDraft", () => {
176
+ it("clears the entry instead of storing an empty body", () => {
177
+ const s = fakeStorage()
178
+ writeStoredDraft(s, KEY, "typed", NOW)
179
+ writeStoredDraft(s, KEY, "", NOW)
180
+ expect(s.map.has(draftStorageKey(KEY))).toBe(false)
181
+ })
182
+
183
+ it("keeps one entry per session", () => {
184
+ const s = fakeStorage()
185
+ writeStoredDraft(s, "a", "for a", NOW)
186
+ writeStoredDraft(s, "b", "for b", NOW)
187
+ expect(readStoredDraft(s, "a", NOW)).toBe("for a")
188
+ expect(readStoredDraft(s, "b", NOW)).toBe("for b")
189
+ })
190
+
191
+ it("swallows a quota error rather than breaking a keystroke", () => {
192
+ const s = {
193
+ getItem: () => null,
194
+ setItem: () => {
195
+ throw new Error("QuotaExceededError")
196
+ },
197
+ removeItem: () => {},
198
+ }
199
+ expect(() => writeStoredDraft(s, KEY, "x")).not.toThrow()
200
+ })
201
+ })
202
+
203
+ describe("clearStoredDraft", () => {
204
+ it("removes a stored draft", () => {
205
+ const s = fakeStorage()
206
+ writeStoredDraft(s, KEY, "sent now", NOW)
207
+ clearStoredDraft(s, KEY)
208
+ expect(readStoredDraft(s, KEY, NOW)).toBeNull()
209
+ })
210
+ })
@@ -35,3 +35,137 @@ export function msUntilDraftIdle(draft: Draft | null | undefined): number {
35
35
  const elapsed = Date.now() - new Date(draft.last_edit_at).getTime();
36
36
  return Math.max(0, IDLE_THRESHOLD_MS - elapsed);
37
37
  }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Composer persistence — surviving a navigation the server never hears about.
41
+ //
42
+ // The composer is local-first (see SendBox) and, alone in a session,
43
+ // `shouldSyncDraftLive` keeps it that way: nothing is mirrored to the server
44
+ // until the moment before `chat.send`. Those two facts are right on their own
45
+ // and, together, mean a half-typed message dies the instant SendBox unmounts —
46
+ // route away from /chat/:id and back and the box is empty, with no copy of the
47
+ // text anywhere. (Adopting your OWN server draft on mount wouldn't fix it:
48
+ // single-player never put one there.)
49
+ //
50
+ // So the copy has to be local too. localStorage rather than sessionStorage
51
+ // because "come back to it" includes closing the tab; per-browser, deliberately
52
+ // not synced across devices.
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /** Just the slice of `Storage` this needs — so tests can pass a plain fake and
56
+ * a host can pass sessionStorage if it prefers per-tab drafts. */
57
+ export interface DraftStorage {
58
+ getItem(key: string): string | null;
59
+ setItem(key: string, value: string): void;
60
+ removeItem(key: string): void;
61
+ }
62
+
63
+ const DRAFT_STORAGE_PREFIX = "canopy.chat.draft.";
64
+
65
+ /** Drafts older than this are treated as gone. A month-old half-sentence is
66
+ * not something you meant to come back to, and restoring it into a live
67
+ * session is worse than losing it. */
68
+ export const DRAFT_STORAGE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
69
+
70
+ export function draftStorageKey(persistKey: string): string {
71
+ return `${DRAFT_STORAGE_PREFIX}${persistKey}`;
72
+ }
73
+
74
+ /**
75
+ * `window.localStorage` when it is usable, else null.
76
+ *
77
+ * Merely TOUCHING the property throws in a sandboxed iframe or with
78
+ * third-party storage blocked, so this is a try/catch and not a truthiness
79
+ * check. Null disables persistence and changes nothing else — typing must
80
+ * never depend on storage being available.
81
+ */
82
+ export function defaultDraftStorage(): DraftStorage | null {
83
+ try {
84
+ return typeof window === "undefined" ? null : window.localStorage;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * The stored body for a session, or null when there is nothing worth
92
+ * restoring. Anything unreadable — absent, malformed, wrong shape, expired —
93
+ * is dropped and reported as null, and an expired or corrupt entry is pruned on
94
+ * the way past so it cannot accumulate.
95
+ */
96
+ export function readStoredDraft(
97
+ storage: DraftStorage | null,
98
+ persistKey: string,
99
+ now: number = Date.now(),
100
+ ): string | null {
101
+ if (!storage || !persistKey) return null;
102
+ const key = draftStorageKey(persistKey);
103
+ let raw: string | null = null;
104
+ try {
105
+ raw = storage.getItem(key);
106
+ } catch {
107
+ return null;
108
+ }
109
+ if (raw == null) return null;
110
+
111
+ let parsed: unknown;
112
+ try {
113
+ parsed = JSON.parse(raw);
114
+ } catch {
115
+ remove(storage, key);
116
+ return null;
117
+ }
118
+
119
+ const record = parsed as { body?: unknown; at?: unknown } | null;
120
+ const body = record?.body;
121
+ const at = record?.at;
122
+ if (typeof body !== "string" || typeof at !== "number") {
123
+ remove(storage, key);
124
+ return null;
125
+ }
126
+ if (now - at > DRAFT_STORAGE_TTL_MS) {
127
+ remove(storage, key);
128
+ return null;
129
+ }
130
+ // An empty body is not a draft. Returning "" would be indistinguishable from
131
+ // a real restore and would shadow a server draft the host DOES want shown.
132
+ return body === "" ? null : body;
133
+ }
134
+
135
+ /** Persist the composer body. Writing an empty body clears the entry rather
136
+ * than storing one, so an emptied box leaves nothing behind to restore. */
137
+ export function writeStoredDraft(
138
+ storage: DraftStorage | null,
139
+ persistKey: string,
140
+ body: string,
141
+ now: number = Date.now(),
142
+ ): void {
143
+ if (!storage || !persistKey) return;
144
+ const key = draftStorageKey(persistKey);
145
+ if (body === "") {
146
+ remove(storage, key);
147
+ return;
148
+ }
149
+ try {
150
+ storage.setItem(key, JSON.stringify({ body, at: now }));
151
+ } catch {
152
+ // Quota exceeded, or storage disabled mid-session. A dropped draft backup
153
+ // is not worth breaking a keystroke over.
154
+ }
155
+ }
156
+
157
+ export function clearStoredDraft(
158
+ storage: DraftStorage | null,
159
+ persistKey: string,
160
+ ): void {
161
+ if (!storage || !persistKey) return;
162
+ remove(storage, draftStorageKey(persistKey));
163
+ }
164
+
165
+ function remove(storage: DraftStorage, key: string): void {
166
+ try {
167
+ storage.removeItem(key);
168
+ } catch {
169
+ // same as above — best effort
170
+ }
171
+ }
package/src/chat/index.ts CHANGED
@@ -32,6 +32,17 @@ export { useStickyBottom } from "./useStickyBottom";
32
32
  // Draft idle helpers
33
33
  export { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts";
34
34
 
35
+ // Composer persistence (survives unmount / tab close)
36
+ export {
37
+ DRAFT_STORAGE_TTL_MS,
38
+ clearStoredDraft,
39
+ defaultDraftStorage,
40
+ draftStorageKey,
41
+ readStoredDraft,
42
+ writeStoredDraft,
43
+ type DraftStorage,
44
+ } from "./drafts";
45
+
35
46
  // Tool-message pairing helpers
36
47
  export {
37
48
  pairToolMessages,
@@ -12,7 +12,7 @@ import type { Message } from "./protocol";
12
12
  * has no other way to tell which result belongs to which call.
13
13
  *
14
14
  * Not every producer stamps ids yet: events already in the ledger predate
15
- * this field, and some runners (see `packages/canopy_runner`) don't emit it
15
+ * this field, and some runners (see `runner/canopy_runner`) don't emit it
16
16
  * until updated separately. When a ``tool_result`` carries no id at all, it
17
17
  * falls back to the FIRST still-open ``tool_use`` that *also* has no id
18
18
  * (oldest-first, FIFO) — today's pre-correlation pairing heuristic. That
@@ -60,16 +60,64 @@ export interface Participant {
60
60
  last_seen_at: string | null;
61
61
  }
62
62
 
63
- /** A dialog an agent is blocked on, read off its terminal.
63
+ /** A dialog an agent is blocked on.
64
64
  *
65
65
  * `title` and `body` are what makes it answerable away from the keyboard:
66
- * "Do you want to proceed?" tells you nothing without the command it means. */
66
+ * "Do you want to proceed?" tells you nothing without the command it means.
67
+ *
68
+ * Two producers, one shape, so this never grows a second reader: the session
69
+ * report derives it from the transcript (an `AskUserQuestion` tool call — what
70
+ * actually blocks a fleet running `bypass permissions`), and the runner can
71
+ * still read it off the rendered screen for the dialogs a transcript cannot
72
+ * see. `source` says which, and a client is free to ignore it. */
73
+ /** One question of an `AskUserQuestion`. The TUI draws these as TABS and will
74
+ * not submit until each has an answer — so a surface that renders only the
75
+ * first one cannot complete the ask no matter which button is pressed. That
76
+ * was the bug: a two-question closeout showed one question, and a tap toggled
77
+ * a checkbox on a dialog that then sat waiting for a Submit nobody could
78
+ * reach (eva, 2026-08-12). */
79
+ export interface MenuQuestion {
80
+ index: number;
81
+ question: string;
82
+ header?: string;
83
+ /** Whether this question takes ANY number of answers. The TUI renders it as
84
+ * checkboxes and a number key TOGGLES one instead of answering, which is why
85
+ * a client that cannot see this flag renders the wrong control AND the
86
+ * runner presses the wrong key. */
87
+ multi_select?: boolean;
88
+ options: { number: number; label: string; description?: string }[];
89
+ }
90
+
67
91
  export interface SessionMenu {
68
92
  question: string;
69
93
  title?: string;
70
94
  body?: string;
71
95
  selected?: number | null;
72
- options: { number: number; label: string }[];
96
+ source?: string;
97
+ /** Every question in the ask, in declaration order. Absent on a dialog with
98
+ * no tool call behind it (a permission prompt, a trust gate) and on a menu
99
+ * from a producer older than this — in both cases the client falls back to
100
+ * the single-question fields above, which still describe question 1. */
101
+ questions?: MenuQuestion[];
102
+ /** `description` is present on the transcript path and is often the only
103
+ * thing that distinguishes two options — "Proceed to Phase 4" does not say
104
+ * that Phase 4 is test-gated, and its description does. */
105
+ options: { number: number; label: string; description?: string }[];
106
+ /** Set when a human's tap was RELAYED to the runner and then refused there —
107
+ * a stale dialog, a shell tab selected in emdash, an unreachable box. The
108
+ * API answers `ok:true` the moment it relays the frame, so without this a
109
+ * correct refusal is indistinguishable from a press that worked, and the
110
+ * button reads as dead. `answer_note` is the sentence to show; the code is
111
+ * for logs. The menu stays up alongside it, so there is something to retry. */
112
+ answer_error?: string;
113
+ answer_note?: string;
114
+ /** Carried across a runner restart rather than observed this process. Nothing
115
+ * should branch on it — a tap verifies against the real screen either way. */
116
+ restored?: boolean;
117
+ /** Epoch seconds when a producer last SAW this dialog. The dialog lives on a
118
+ * terminal; this object is a copy, and without an age the only way to find
119
+ * out the copy is stale is to tap it and be refused. */
120
+ observed_at?: number;
73
121
  }
74
122
 
75
123
  export interface SessionState {
@@ -83,9 +131,20 @@ export interface SessionState {
83
131
  * those apart — but it is the difference between "still thinking, wait" and
84
132
  * "it is waiting on YOU", which previously rendered identically. */
85
133
  activity?: "working" | "idle" | "blocked";
86
- /** The dialog the agent is waiting on, when one could be read off its screen.
87
- * Absent when the agent is not blocked, or when the runner has no way to look
88
- * (no CDP) "blocked" still arrives, it just has no buttons. */
134
+ /** Whether a stop the human asked for actually landed. A SEPARATE axis from
135
+ * `activity`, deliberately: a stop that failed leaves the agent `working`,
136
+ * which is true and must stay true, so the outcome of the stop cannot be a
137
+ * value of activity without either lying or being lost.
138
+ *
139
+ * "requested" is set the moment the server publishes to the runner — nothing
140
+ * has pressed Escape yet. Only the runner can say "stopped" or "failed", and
141
+ * it only says so after verifying the terminal (see #649: an unverified
142
+ * Escape reported as success is what made Stop untrustworthy). */
143
+ stopState?: "requested" | "stopped" | "failed";
144
+ /** The dialog the agent is waiting on. Carried in the CONNECT SNAPSHOT, not
145
+ * only in live frames: `session.activity` is view-only and reaches a client
146
+ * only if it was already connected when the agent blocked — which is exactly
147
+ * the case that fails, because you go and look BECAUSE it stopped. */
89
148
  menu?: SessionMenu;
90
149
  active_draft: Draft | null;
91
150
  participants: Participant[];
@@ -113,6 +172,13 @@ export type WsEvent =
113
172
  // The agent started or finished a turn. Distinct from tool events: it fires
114
173
  // while Claude is THINKING, before any content exists to show.
115
174
  | { event: "session.activity"; data: { state: "working" | "idle" | "blocked"; menu?: SessionMenu } }
175
+ | { event: "session.stop"; data: { state: "requested" | "stopped" | "failed" } }
176
+ // The agent started, or stopped, waiting on a dialog. Its own frame rather
177
+ // than an overloaded `session.activity`: activity answers "is it producing",
178
+ // which the hook path owns on a much faster clock, and inventing a state here
179
+ // to carry a menu would report an agent as idle or blocked on the wrong one.
180
+ // `menu: null` is the retraction — somebody answered at the keyboard.
181
+ | { event: "session.menu"; data: { menu: SessionMenu | null } }
116
182
  // A human typed into emdash rather than into this page. No client echoed it,
117
183
  // so this is the only way it reaches the browser before a reload.
118
184
  | { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string } }
@@ -129,5 +195,19 @@ export type WsEvent =
129
195
  | { event: "draft.lock_changed"; data: { draft_id: string; holder_user_id: number | null; expires_at: number | null } }
130
196
  | { event: "draft.committed"; data: { draft_id: string; user_message_id: string } }
131
197
  | { event: "draft.discarded"; data: { draft_id: string } }
132
- | { event: "presence.joined"; data: { user_id: number; email?: string; display_name?: string } }
198
+ // `participant` carries WHO joined, so a client can add them to its
199
+ // participant list. Without it a first-time joiner has an id and no name,
200
+ // and the presence row (which renders participants filtered by presence)
201
+ // cannot show them at all. Optional so an older server degrades rather than
202
+ // breaks. The bare `email`/`display_name` below are the vestigial shape that
203
+ // was declared but never sent by anything.
204
+ | {
205
+ event: "presence.joined";
206
+ data: {
207
+ user_id: number;
208
+ participant?: Participant;
209
+ email?: string;
210
+ display_name?: string;
211
+ };
212
+ }
133
213
  | { event: "presence.left"; data: { user_id: number } };