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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Pressing send must change the TRANSCRIPT, not just the header.
3
+ *
4
+ * The gap this closes: no assistant row exists until `chat.stream_start`, and
5
+ * the server emits that together with `stream_complete` from one `assistant`
6
+ * ledger event (apps/canopy_sessions/stream_map.py) — i.e. only once the reply's
7
+ * first text exists, which on a laptop runner is a claim poll plus an emdash
8
+ * drive plus however long the agent thinks. So the conversation sat visibly
9
+ * unchanged for seconds-to-minutes after a send, which reads as the app having
10
+ * ignored you. Reported on a phone, twice, after the header-chip fix (#490)
11
+ * had supposedly addressed it.
12
+ */
13
+ // @vitest-environment jsdom
14
+ import { afterEach, describe, expect, it, vi } from "vitest";
15
+ import { cleanup, render, screen } from "@testing-library/react";
16
+
17
+ import { ChatPanel } from "./ChatPanel";
18
+ import type { Message, SessionState } from "./protocol";
19
+
20
+ afterEach(cleanup);
21
+
22
+ const ME = 1;
23
+
24
+ function message(overrides: Partial<Message> = {}): Message {
25
+ return {
26
+ id: "m1",
27
+ turn_index: 1,
28
+ role: "user",
29
+ content: { text: "hi" },
30
+ plaintext: "hi",
31
+ status: "complete",
32
+ error_detail: null,
33
+ started_at: null,
34
+ completed_at: null,
35
+ created_at: "2026-07-30T00:00:00Z",
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ function state(overrides: Partial<SessionState> = {}): SessionState {
41
+ return {
42
+ messages: [message()],
43
+ active_draft: {
44
+ id: "d1",
45
+ body: "",
46
+ version: 1,
47
+ last_editor: ME,
48
+ last_edit_at: "2026-07-30T00:00:00Z",
49
+ slot: "next",
50
+ status: "open",
51
+ },
52
+ participants: [],
53
+ presence_user_ids: [ME],
54
+ current_user_id: ME,
55
+ ...overrides,
56
+ };
57
+ }
58
+
59
+ function panel(props: Partial<Parameters<typeof ChatPanel>[0]> = {}) {
60
+ return render(
61
+ <ChatPanel
62
+ state={state()}
63
+ connected
64
+ currentUserId={ME}
65
+ onSend={vi.fn()}
66
+ onStop={vi.fn()}
67
+ onUpdateDraft={vi.fn()}
68
+ onTakeOver={vi.fn()}
69
+ onDiscard={vi.fn()}
70
+ {...props}
71
+ />,
72
+ );
73
+ }
74
+
75
+ describe("the pending-reply row", () => {
76
+ it("is absent while nothing is outstanding", () => {
77
+ panel();
78
+ expect(screen.queryByTestId("pending-reply")).toBeNull();
79
+ });
80
+
81
+ it("appears the instant a send goes out, with no server round trip", () => {
82
+ // `awaitingReply` is set synchronously by sendChat. NOTHING server-side can
83
+ // answer sooner: the turn has to be enqueued, claimed, and driven into the
84
+ // agent before any report could exist.
85
+ panel({ awaitingReply: true });
86
+ expect(screen.getByTestId("pending-reply")).not.toBeNull();
87
+ expect(screen.getByText("Queued…")).not.toBeNull();
88
+ });
89
+
90
+ it("says the agent is thinking once the hook reports it started", () => {
91
+ // The distinction is the useful part: queued means canopy has not started
92
+ // your turn, thinking means the agent has it. Folding them together hides
93
+ // where the delay actually is.
94
+ panel({ state: state({ activity: "working" }), awaitingReply: false });
95
+ expect(screen.getByText("Thinking…")).not.toBeNull();
96
+ });
97
+
98
+ it("survives the first reply block, because the turn has not ended", () => {
99
+ // `awaitingReply` clears on the FIRST stream_complete, but the bridge posts
100
+ // each assistant block as its own event — so a turn that says one sentence
101
+ // and then works for another minute would otherwise go silent again.
102
+ panel({ state: state({ activity: "working" }), awaitingReply: false });
103
+ expect(screen.getByTestId("pending-reply")).not.toBeNull();
104
+ });
105
+
106
+ it("withdraws when the agent is BLOCKED on you", () => {
107
+ // The worst way to get this wrong: waiting on an agent that is waiting on
108
+ // you. `blocked` must never render as work in progress.
109
+ panel({ state: state({ activity: "blocked" }), awaitingReply: true });
110
+ expect(screen.queryByTestId("pending-reply")).toBeNull();
111
+ });
112
+
113
+ it("yields to a real assistant row once one exists", () => {
114
+ // Otherwise the reply and the placeholder are both on screen at once.
115
+ panel({
116
+ state: state({
117
+ messages: [
118
+ message(),
119
+ message({ id: "m2", turn_index: 2, role: "assistant", plaintext: "", content: {}, status: "streaming" }),
120
+ ],
121
+ }),
122
+ awaitingReply: true,
123
+ });
124
+ expect(screen.queryByTestId("pending-reply")).toBeNull();
125
+ });
126
+
127
+ it("shows on a first-ever send, when the empty state would otherwise win", () => {
128
+ panel({
129
+ state: state({ messages: [] }),
130
+ awaitingReply: true,
131
+ emptyState: <div>no messages yet</div>,
132
+ });
133
+ expect(screen.getByTestId("pending-reply")).not.toBeNull();
134
+ expect(screen.queryByText("no messages yet")).toBeNull();
135
+ });
136
+ });
@@ -6,7 +6,7 @@ import { ConnectionStatus } from "./ConnectionStatus";
6
6
  import { MessageList } from "./MessageList";
7
7
  import { PresenceChips } from "./PresenceChips";
8
8
  import { SendBox, type PendingAttachment } from "./SendBox";
9
- import { isDraftIdle, msUntilDraftIdle } from "./drafts";
9
+ import { isDraftIdle, msUntilDraftIdle, type DraftStorage } from "./drafts";
10
10
  import { useStickyBottom } from "./useStickyBottom";
11
11
 
12
12
  export interface ChatPanelProps {
@@ -34,6 +34,11 @@ export interface ChatPanelProps {
34
34
  disabledReason?: string;
35
35
  /** Rendered at the top of the scroll container (e.g. a "Load earlier" button / offline banner). */
36
36
  historySlot?: ReactNode;
37
+ /** Persist the half-typed composer body under this key (the session id) so
38
+ * it survives routing away and back. Omit for in-memory-only drafts. */
39
+ draftPersistKey?: string;
40
+ /** Storage backing `draftPersistKey`; defaults to localStorage. */
41
+ draftStorage?: DraftStorage | null;
37
42
  }
38
43
 
39
44
  /**
@@ -60,6 +65,8 @@ export function ChatPanel({
60
65
  emptyState,
61
66
  disabledReason,
62
67
  historySlot,
68
+ draftPersistKey,
69
+ draftStorage,
63
70
  }: ChatPanelProps) {
64
71
  // `onDiscard` is part of the public surface (co-edit teardown) even though
65
72
  // the default composer doesn't render a discard button. Referenced to keep
@@ -82,6 +89,14 @@ export function ChatPanel({
82
89
  const holderId = state.active_draft?.last_editor ?? null;
83
90
  const holderIsPresent =
84
91
  holderId != null && state.presence_user_ids.includes(holderId);
92
+ // The holder's NAME, for the composer. SendBox has ids and no roster, so
93
+ // without this the one place a person actually looks — the box their
94
+ // teammate's words are appearing in — could only say "Another teammate",
95
+ // while the name sat in a chip at the far corner of the screen.
96
+ const holderName =
97
+ holderId != null && holderId !== currentUserId
98
+ ? (state.participants.find((p) => p.user_id === holderId)?.display_name ?? null)
99
+ : null;
85
100
 
86
101
  // A turn is "in flight" from the moment the assistant row appears
87
102
  // (status=pending/streaming) until chat.stream_complete flips it to
@@ -96,13 +111,37 @@ export function ChatPanel({
96
111
  [state.messages],
97
112
  );
98
113
 
114
+ // Feedback where the eye actually is. Pressing send used to change NOTHING in
115
+ // the transcript: no assistant row exists until `chat.stream_start`, which the
116
+ // server emits together with `stream_complete` from a single `assistant`
117
+ // ledger event — i.e. only once the reply's first TEXT exists, seconds to
118
+ // minutes later. So MessageItem's own "Thinking…" treatment was unreachable on
119
+ // the real runner path, and the only signal was a 12px chip in the header,
120
+ // which on a phone is the far corner of the screen from your thumb.
121
+ //
122
+ // Two sources, deliberately: `awaitingReply` is client-side and answers
123
+ // INSTANTLY with no round trip (nothing server-side can — the turn has to be
124
+ // enqueued, claimed and driven into the agent before anything could report),
125
+ // and `activity === "working"` keeps it up for the rest of the turn, across
126
+ // the gaps between the assistant's separate text blocks. `blocked` withdraws
127
+ // it: an agent waiting on YOU must never render as an agent working.
128
+ const agentHasFloor =
129
+ state.activity !== "blocked" &&
130
+ (awaitingReply || state.activity === "working");
131
+ const showPendingReply = inFlightMessage == null && agentHasFloor;
132
+ // "Queued" until something reports the agent actually started — the useful
133
+ // distinction is where the delay is, not that there is one.
134
+ const pendingLabel = state.activity === "working" ? "Thinking…" : "Queued…";
135
+
99
136
  // Sticky-bottom scroll: dep changes on (a) new message arrival and (b)
100
137
  // streaming text growth on the last message. length-only (cheap) instead
101
138
  // of the full string so the effect doesn't re-run on equal characters.
139
+ // `showPendingReply` is in the dep too — the bubble is a new row at the
140
+ // bottom, and appearing below the fold would defeat the whole point of it.
102
141
  const messages = state.messages;
103
142
  const lastMessageLen =
104
143
  messages.length > 0 ? messages[messages.length - 1].plaintext.length : 0;
105
- const scrollDep = `${messages.length}:${lastMessageLen}`;
144
+ const scrollDep = `${messages.length}:${lastMessageLen}:${showPendingReply}`;
106
145
  const { containerRef, onScroll } = useStickyBottom(scrollDep);
107
146
 
108
147
  return (
@@ -115,6 +154,7 @@ export function ChatPanel({
115
154
  presenceUserIds={state.presence_user_ids}
116
155
  draftHolderId={holderId}
117
156
  draftHolderIdle={isDraftIdle(state.active_draft)}
157
+ currentUserId={currentUserId}
118
158
  />
119
159
  </div>
120
160
  </div>
@@ -131,6 +171,8 @@ export function ChatPanel({
131
171
  messages={state.messages}
132
172
  emptyState={emptyState}
133
173
  renderMarkdown={renderMarkdown}
174
+ pendingReply={showPendingReply}
175
+ pendingLabel={pendingLabel}
134
176
  />
135
177
  </div>
136
178
  <SendBox
@@ -138,17 +180,21 @@ export function ChatPanel({
138
180
  connected={connected}
139
181
  currentUserId={currentUserId}
140
182
  holderIsPresent={holderIsPresent}
183
+ holderName={holderName}
141
184
  isStreaming={inFlightMessage != null || awaitingReply}
142
185
  streamingMessageId={inFlightMessage?.id ?? null}
143
186
  onUpdate={onUpdateDraft}
144
187
  onSend={onSend}
145
188
  onStop={onStop}
189
+ stopState={state.stopState}
146
190
  onTakeOver={onTakeOver}
147
191
  banner={banner}
148
192
  disabledReason={disabledReason}
149
193
  attachments={attachments}
150
194
  onAttach={onAttach}
151
195
  onRemoveAttachment={onRemoveAttachment}
196
+ persistKey={draftPersistKey}
197
+ storage={draftStorage}
152
198
  />
153
199
  </div>
154
200
  );
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { menuAge } from "./MenuPrompt";
3
+ import type { SessionMenu } from "./protocol";
4
+
5
+ const base: SessionMenu = { question: "Pick one", options: [{ number: 1, label: "A" }] };
6
+
7
+ describe("menuAge", () => {
8
+ it("says nothing while the dialog is fresh", () => {
9
+ // The runner re-reports every ~10s, so recent lag is normal and naming it
10
+ // would make every menu look doubtful.
11
+ expect(menuAge({ ...base, observed_at: 1_000 }, 1_000_000 + 30_000)).toBe("");
12
+ });
13
+
14
+ it("shows an age once nobody has confirmed it lately", () => {
15
+ const now = 1_000_000_000_000;
16
+ expect(menuAge({ ...base, observed_at: now / 1000 - 600 }, now)).toBe("last seen 10m ago");
17
+ expect(menuAge({ ...base, observed_at: now / 1000 - 7200 }, now)).toBe("last seen 2h ago");
18
+ });
19
+
20
+ it("says nothing when the producer did not stamp", () => {
21
+ // Pre-`observed_at` rows exist; an unstamped menu must not read as ancient.
22
+ expect(menuAge(base, Date.now())).toBe("");
23
+ });
24
+ });
@@ -1,10 +1,55 @@
1
- import type { SessionMenu } from "./protocol";
1
+ import { useMemo, useState } from "react";
2
+ import type { MenuQuestion, SessionMenu } from "./protocol";
2
3
 
3
4
  export interface MenuPromptProps {
4
5
  menu: SessionMenu;
5
6
  busy?: boolean;
6
7
  error?: string;
7
- onAnswer: (option: number | null) => void;
8
+ /** `selections` is the whole answer — one list of chosen option numbers per
9
+ * question. Omitted for the single-question single-select dialogs that a
10
+ * lone `option` has always been able to answer.
11
+ *
12
+ * `texts` carries the answer that is NOT on the menu, per question. The TUI
13
+ * appends a "Type something" row to every question, and it is frequently
14
+ * where the real answer goes — the July closeout's notes were all typed, not
15
+ * picked. Without it a phone can only choose from what the agent guessed. */
16
+ onAnswer: (
17
+ option: number | null,
18
+ selections?: number[][] | null,
19
+ texts?: (string | null)[] | null,
20
+ ) => void;
21
+ /** Injectable for tests; defaults to the wall clock. */
22
+ now?: number;
23
+ }
24
+
25
+ /** Whether this ask needs the full form rather than a row of buttons.
26
+ *
27
+ * One single-select question is the shape a lone keypress answers, and it is
28
+ * the overwhelmingly common one — a permission prompt, a yes/no. Anything else
29
+ * (several questions, or one that takes several answers) cannot be completed by
30
+ * pressing a single button, so rendering buttons for it is a lie. */
31
+ export function needsForm(menu: SessionMenu): boolean {
32
+ const qs = menu.questions;
33
+ if (!qs || qs.length === 0) return false;
34
+ return qs.length > 1 || qs.some((q) => q.multi_select);
35
+ }
36
+
37
+ function isAnswered(picks: number[] | undefined): boolean {
38
+ return Boolean(picks && picks.length > 0);
39
+ }
40
+
41
+ /** How old a dialog may be before we say so. The runner re-reports every ~10s,
42
+ * so anything past a couple of minutes is not merely lagging — it is a dialog
43
+ * nobody has confirmed lately, and the honest thing is to show its age rather
44
+ * than let a confident-looking button be the way you discover it. */
45
+ const STALE_AFTER_MS = 120_000;
46
+
47
+ export function menuAge(menu: SessionMenu, now: number): string {
48
+ if (!menu.observed_at) return "";
49
+ const ms = now - menu.observed_at * 1000;
50
+ if (ms < STALE_AFTER_MS) return "";
51
+ const mins = Math.round(ms / 60_000);
52
+ return mins < 60 ? `last seen ${mins}m ago` : `last seen ${Math.round(mins / 60)}h ago`;
8
53
  }
9
54
 
10
55
  /**
@@ -17,32 +62,258 @@ export interface MenuPromptProps {
17
62
  * Refusing is always offered separately from the numbered options. Every dialog
18
63
  * accepts Escape, and it is the only answer that stays correct if the dialog on
19
64
  * screen is not the one rendered here.
65
+ *
66
+ * A tap that was relayed and then refused BY THE RUNNER comes back as
67
+ * `menu.answer_note`, and it wins over the local `error` prop because it is the
68
+ * later, more specific half of the same story: `error` means the server would
69
+ * not relay the tap, `answer_note` means it did and the keystroke still did not
70
+ * land. Showing neither is what made this button look dead for 45 minutes — the
71
+ * API answers `ok:true` the instant it relays, so silence read as success.
72
+ *
73
+ * The options render in one of two shapes, chosen by whether the dialog carries
74
+ * descriptions. A permission prompt's options ("Yes" / "No") are a row of chips.
75
+ * An AskUserQuestion's are not: its descriptions are the decision itself — on
76
+ * the run that motivated this, "Proceed to Phase 4" and "Stop the run here"
77
+ * were separated entirely by prose the labels did not contain (that Phase 4 is
78
+ * test-gated and reaches no real payment). Rendering those as bare chips asks
79
+ * somebody to choose blind, which is the same failure as showing no menu, only
80
+ * quieter.
81
+ */
82
+ /** Every question at once, answered in one go.
83
+ *
84
+ * Deliberately NOT a tab strip mirroring the terminal's. The terminal shows one
85
+ * question at a time because it has one screen and a cursor; a phone has a
86
+ * scroll, and the thing that actually went wrong was somebody answering what
87
+ * looked like the whole ask and it turning out to be tab 2 of 3. Showing all of
88
+ * them, with one Send at the end, makes "have I finished?" answerable by
89
+ * looking — which is the only question this surface has ever got wrong.
20
90
  */
21
- export function MenuPrompt({ menu, busy = false, error, onAnswer }: MenuPromptProps) {
91
+ function AnswerForm({
92
+ menu,
93
+ questions,
94
+ busy,
95
+ onAnswer,
96
+ }: {
97
+ menu: SessionMenu;
98
+ questions: MenuQuestion[];
99
+ busy: boolean;
100
+ onAnswer: MenuPromptProps["onAnswer"];
101
+ }) {
102
+ const [picks, setPicks] = useState<Record<number, number[]>>({});
103
+ const [typed, setTyped] = useState<Record<number, string>>({});
104
+
105
+ const toggle = (q: MenuQuestion, number: number) => {
106
+ setPicks((prev) => {
107
+ const current = prev[q.index] ?? [];
108
+ if (!q.multi_select) return { ...prev, [q.index]: [number] };
109
+ return {
110
+ ...prev,
111
+ [q.index]: current.includes(number)
112
+ ? current.filter((n) => n !== number)
113
+ : [...current, number].sort((a, b) => a - b),
114
+ };
115
+ });
116
+ };
117
+
118
+ // Typing your own answer to a SINGLE-select question replaces the pick,
119
+ // because that is what the TUI does — selecting "Type something" moves the
120
+ // selection onto the text row. On a multi-select the text is an extra
121
+ // checkbox, so both can stand.
122
+ const write = (q: MenuQuestion, value: string) => {
123
+ setTyped((prev) => ({ ...prev, [q.index]: value }));
124
+ if (!q.multi_select && value.trim()) {
125
+ setPicks((prev) => ({ ...prev, [q.index]: [] }));
126
+ }
127
+ };
128
+
129
+ const answered = (q: MenuQuestion) =>
130
+ isAnswered(picks[q.index]) || Boolean((typed[q.index] ?? "").trim());
131
+
132
+ const remaining = useMemo(
133
+ () => questions.filter((q) => !answered(q)).length,
134
+ // eslint-disable-next-line react-hooks/exhaustive-deps
135
+ [questions, picks, typed],
136
+ );
137
+
138
+ const send = () => {
139
+ // Positional, and every question gets an entry even when unanswered: the
140
+ // runner walks the tabs in this order, so a missing entry would silently
141
+ // shift every later answer onto the wrong question.
142
+ const selections = questions.map((q) => picks[q.index] ?? []);
143
+ const texts = questions.map((q) => (typed[q.index] ?? "").trim() || null);
144
+ // `option` is the first pick, for a runner too old to read `selections`.
145
+ // Sending null there would read as "refuse" and cancel the dialog.
146
+ onAnswer(selections[0]?.[0] ?? null, selections, texts);
147
+ };
148
+
149
+ return (
150
+ <div className="mt-2 flex flex-col gap-3">
151
+ {questions.map((q) => (
152
+ <div key={q.index}>
153
+ <div className="text-[12px] font-medium uppercase tracking-wide text-muted-foreground">
154
+ {q.header || `Question ${q.index + 1}`}
155
+ {q.multi_select ? " · pick any" : ""}
156
+ </div>
157
+ <div className="mt-0.5 text-[13px] text-foreground">{q.question}</div>
158
+ <div className="mt-1.5 flex flex-col gap-1">
159
+ {q.options.map((option) => {
160
+ const on = (picks[q.index] ?? []).includes(option.number);
161
+ return (
162
+ <button
163
+ key={option.number}
164
+ type="button"
165
+ disabled={busy}
166
+ aria-pressed={on}
167
+ onClick={() => toggle(q, option.number)}
168
+ className={`flex items-start gap-2 rounded border px-2.5 py-2 text-left disabled:opacity-50 ${
169
+ on ? "border-warning bg-warning/10" : "border-input bg-card hover:bg-muted"
170
+ }`}
171
+ >
172
+ <span aria-hidden className="mt-[1px] shrink-0 text-[13px]">
173
+ {on ? (q.multi_select ? "☑" : "◉") : q.multi_select ? "☐" : "○"}
174
+ </span>
175
+ <span>
176
+ <span className="block text-[13px] font-medium text-foreground">
177
+ {option.label}
178
+ </span>
179
+ {option.description ? (
180
+ <span className="mt-0.5 block text-[12px] leading-snug text-muted-foreground">
181
+ {option.description}
182
+ </span>
183
+ ) : null}
184
+ </span>
185
+ </button>
186
+ );
187
+ })}
188
+ {/* The answer that is not on the menu. The terminal offers this on
189
+ every question ("Type something"), and it is where the real
190
+ answer often goes, so a phone without it can only pick from what
191
+ the agent happened to guess.
192
+ */}
193
+ <input
194
+ type="text"
195
+ value={typed[q.index] ?? ""}
196
+ disabled={busy}
197
+ onChange={(e) => write(q, e.target.value)}
198
+ placeholder="…or type your own answer"
199
+ className="mt-0.5 rounded border border-input bg-card px-2.5 py-2 text-[13px] text-foreground placeholder:text-muted-foreground disabled:opacity-50"
200
+ />
201
+ </div>
202
+ </div>
203
+ ))}
204
+ <div className="flex flex-wrap items-center gap-2">
205
+ <button
206
+ type="button"
207
+ // Sendable as soon as ANYTHING is answered, not only when everything
208
+ // is. The terminal submits a partly-filled ask and merely warns, and
209
+ // refusing here made a question you meant to skip unskippable from a
210
+ // phone. Still not sendable when NOTHING is answered — that is what
211
+ // Cancel says, and says better.
212
+ disabled={busy || remaining === questions.length}
213
+ onClick={send}
214
+ className="rounded bg-warning px-3 py-1.5 text-[13px] font-medium text-warning-foreground disabled:opacity-50"
215
+ >
216
+ {busy ? "Sending…" : "Send answers"}
217
+ </button>
218
+ {remaining > 0 ? (
219
+ <span className="text-[12px] text-muted-foreground">
220
+ {remaining === questions.length
221
+ ? "answer at least one to send"
222
+ : `${remaining} unanswered — will send anyway`}
223
+ </span>
224
+ ) : null}
225
+ </div>
226
+ {menu.body ? (
227
+ <div className="text-[12px] text-muted-foreground">{menu.body}</div>
228
+ ) : null}
229
+ </div>
230
+ );
231
+ }
232
+
233
+ export function MenuPrompt({ menu, busy = false, error, onAnswer, now = Date.now() }: MenuPromptProps) {
234
+ const described = menu.options.some((o) => o.description);
235
+ // A dialog Claude Code drew with no tool call behind it — a permission prompt,
236
+ // a trust gate. The `Notification` hook says a human is wanted and carries a
237
+ // message, but no options: reading those means driving CDP, which steals
238
+ // focus, so they are genuinely not available here. Saying so beats an empty
239
+ // box, and beats the previous behaviour of showing nothing at all.
240
+ //
241
+ // Escape is still REAL on one of these: the runner re-reads the actual screen
242
+ // before pressing anything, and a permission prompt parses there — so the
243
+ // refuse button below is an action, not a placeholder. When the screen turns
244
+ // out to be an ordinary prompt, that same re-read answers NO_DIALOG and the
245
+ // marker is dropped, which is the way out of a marker that was never a dialog.
246
+ //
247
+ // It may also not be a dialog at all — nothing was parsed to produce this, so
248
+ // the copy no longer promises one is there. The composer stays live behind it
249
+ // for the same reason (`menuBlocksComposer`).
250
+ const optionless = menu.options.length === 0;
251
+ const age = menuAge(menu, now);
252
+ const form = needsForm(menu);
22
253
  return (
23
254
  <div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2.5 text-sm">
24
255
  <div className="flex items-center gap-1.5 font-medium text-warning">
25
256
  <span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-warning" />
26
257
  {menu.title || "Waiting on you"}
258
+ {age ? (
259
+ <span className="ml-auto text-[11px] font-normal text-muted-foreground">{age}</span>
260
+ ) : null}
27
261
  </div>
28
- {menu.body ? (
262
+ {menu.body && !form ? (
29
263
  <pre className="mt-1.5 max-h-32 overflow-auto whitespace-pre-wrap break-all rounded bg-muted px-2 py-1.5 font-mono text-[12px] text-foreground-secondary">
30
264
  {menu.body}
31
265
  </pre>
32
266
  ) : null}
33
- <div className="mt-2 text-foreground">{menu.question}</div>
34
- <div className="mt-2 flex flex-wrap gap-1.5">
35
- {menu.options.map((option) => (
36
- <button
37
- key={option.number}
38
- type="button"
39
- disabled={busy}
40
- onClick={() => onAnswer(option.number)}
41
- className="rounded border border-input bg-card px-2 py-1 text-[13px] text-foreground hover:bg-muted disabled:opacity-50"
42
- >
43
- {option.label}
44
- </button>
45
- ))}
267
+ {/* The form prints each question against its own options, so the single
268
+ top-level question would be question 1 shown twice. */}
269
+ {form ? null : <div className="mt-2 text-foreground">{menu.question}</div>}
270
+ {form ? (
271
+ <AnswerForm
272
+ menu={menu}
273
+ questions={menu.questions ?? []}
274
+ busy={busy}
275
+ onAnswer={onAnswer}
276
+ />
277
+ ) : optionless ? (
278
+ <div className="mt-1.5 text-[12px] leading-snug text-muted-foreground">
279
+ No options came with this one — open the session in emdash to see what is on
280
+ screen. Cancelling clears it if nothing is, and the composer below still sends.
281
+ </div>
282
+ ) : described ? (
283
+ <div className="mt-2 flex flex-col gap-1.5">
284
+ {menu.options.map((option) => (
285
+ <button
286
+ key={option.number}
287
+ type="button"
288
+ disabled={busy}
289
+ onClick={() => onAnswer(option.number)}
290
+ className="rounded border border-input bg-card px-2.5 py-2 text-left hover:bg-muted disabled:opacity-50"
291
+ >
292
+ <div className="text-[13px] font-medium text-foreground">{option.label}</div>
293
+ {option.description ? (
294
+ <div className="mt-0.5 text-[12px] leading-snug text-muted-foreground">
295
+ {option.description}
296
+ </div>
297
+ ) : null}
298
+ </button>
299
+ ))}
300
+ </div>
301
+ ) : (
302
+ <div className="mt-2 flex flex-wrap gap-1.5">
303
+ {menu.options.map((option) => (
304
+ <button
305
+ key={option.number}
306
+ type="button"
307
+ disabled={busy}
308
+ onClick={() => onAnswer(option.number)}
309
+ className="rounded border border-input bg-card px-2 py-1 text-[13px] text-foreground hover:bg-muted disabled:opacity-50"
310
+ >
311
+ {option.label}
312
+ </button>
313
+ ))}
314
+ </div>
315
+ )}
316
+ <div className="mt-2">
46
317
  <button
47
318
  type="button"
48
319
  disabled={busy}
@@ -52,7 +323,9 @@ export function MenuPrompt({ menu, busy = false, error, onAnswer }: MenuPromptPr
52
323
  Cancel (Esc)
53
324
  </button>
54
325
  </div>
55
- {error ? <div className="mt-1.5 text-[12px] text-destructive">{error}</div> : null}
326
+ {menu.answer_note || error ? (
327
+ <div className="mt-1.5 text-[12px] text-destructive">{menu.answer_note || error}</div>
328
+ ) : null}
56
329
  </div>
57
330
  );
58
331
  }
@@ -44,6 +44,26 @@ function classifyError(detail: string | null) {
44
44
  };
45
45
  }
46
46
 
47
+ /**
48
+ * The animated "the agent has the floor" treatment.
49
+ *
50
+ * Exported because it is rendered from two places that must not drift: inside a
51
+ * real assistant row that has started but has no text yet, and as MessageList's
52
+ * TRAILING row while no assistant row exists at all (see `pendingReply` there).
53
+ */
54
+ export function ThinkingIndicator({ label = "Thinking…" }: { label?: string }) {
55
+ return (
56
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
57
+ <span className="inline-flex gap-0.5" aria-label="thinking">
58
+ <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
59
+ <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
60
+ <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current" />
61
+ </span>
62
+ <span className="text-xs italic">{label}</span>
63
+ </span>
64
+ );
65
+ }
66
+
47
67
  export function MessageItem({
48
68
  message,
49
69
  forceToolOpen,
@@ -104,14 +124,7 @@ export function MessageItem({
104
124
  aria-live={isStreaming || isPending ? "polite" : undefined}
105
125
  >
106
126
  {showThinking ? (
107
- <span className="inline-flex items-center gap-1.5 text-muted-foreground">
108
- <span className="inline-flex gap-0.5" aria-label="thinking">
109
- <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
110
- <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
111
- <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current" />
112
- </span>
113
- <span className="text-xs italic">Thinking…</span>
114
- </span>
127
+ <ThinkingIndicator />
115
128
  ) : message.role === "assistant" ? (
116
129
  renderMarkdown(text)
117
130
  ) : (