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 +1 -1
- package/src/chat/ChatPanel.pending.test.tsx +136 -0
- package/src/chat/ChatPanel.tsx +48 -2
- package/src/chat/MenuPrompt.test.tsx +24 -0
- package/src/chat/MenuPrompt.tsx +291 -18
- package/src/chat/MessageItem.tsx +21 -8
- package/src/chat/MessageList.tsx +32 -3
- package/src/chat/PlacementBanner.test.tsx +75 -0
- package/src/chat/PlacementBanner.tsx +52 -15
- package/src/chat/PresenceChips.tsx +113 -19
- package/src/chat/SendBox.test.tsx +151 -0
- package/src/chat/SendBox.tsx +139 -10
- package/src/chat/drafts.test.ts +120 -0
- package/src/chat/drafts.ts +134 -0
- package/src/chat/index.ts +11 -0
- package/src/chat/pairToolMessages.ts +1 -1
- package/src/chat/protocol.ts +87 -7
- package/src/chat/sessionReducer.test.ts +102 -1
- package/src/chat/sessionReducer.ts +50 -5
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/chat/ChatPanel.tsx
CHANGED
|
@@ -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
|
+
});
|
package/src/chat/MenuPrompt.tsx
CHANGED
|
@@ -1,10 +1,55 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
{
|
|
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
|
}
|
package/src/chat/MessageItem.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
) : (
|