canopy-ui 0.3.0 → 0.6.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.3.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -11,6 +11,8 @@
11
11
  ".": "./src/index.ts",
12
12
  "./lib": "./src/lib/index.ts",
13
13
  "./ui": "./src/ui/index.ts",
14
+ "./chat": "./src/chat/index.ts",
15
+ "./presence": "./src/presence/index.ts",
14
16
  "./shell": "./src/shell/index.ts",
15
17
  "./tokens": "./src/tokens/index.ts",
16
18
  "./tokens/preset.css": "./src/tokens/preset.css",
@@ -41,5 +43,7 @@
41
43
  "devDependencies": {
42
44
  "sonner": "^2.0.7"
43
45
  },
44
- "files": ["src"]
46
+ "files": [
47
+ "src"
48
+ ]
45
49
  }
@@ -0,0 +1,155 @@
1
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
2
+
3
+ import type { SessionState } from "./protocol";
4
+ import type { RenderMarkdown } from "./MessageItem";
5
+ import { ConnectionStatus } from "./ConnectionStatus";
6
+ import { MessageList } from "./MessageList";
7
+ import { PresenceChips } from "./PresenceChips";
8
+ import { SendBox, type PendingAttachment } from "./SendBox";
9
+ import { isDraftIdle, msUntilDraftIdle } from "./drafts";
10
+ import { useStickyBottom } from "./useStickyBottom";
11
+
12
+ export interface ChatPanelProps {
13
+ state: SessionState;
14
+ connected: boolean;
15
+ currentUserId: number;
16
+ onSend: () => void;
17
+ onStop: (messageId: string | null) => void;
18
+ /** A send is outstanding but no reply has begun — the turn is queued,
19
+ * waiting for a runner to claim it. Keeps Stop reachable in that window. */
20
+ awaitingReply?: boolean;
21
+ /** Files staged for the next send; omit to hide attaching entirely. */
22
+ attachments?: PendingAttachment[];
23
+ onAttach?: (files: File[]) => void;
24
+ onRemoveAttachment?: (id: string) => void;
25
+ onUpdateDraft: (body: string) => void;
26
+ onTakeOver: () => void;
27
+ onDiscard: () => void;
28
+ renderMarkdown?: RenderMarkdown;
29
+ /** Optional banner rendered above the composer. */
30
+ banner?: ReactNode;
31
+ /** Rendered when there are no messages yet. */
32
+ emptyState?: ReactNode;
33
+ /** When set, sending is disabled and this reason is shown. */
34
+ disabledReason?: string;
35
+ /** Rendered at the top of the scroll container (e.g. a "Load earlier" button / offline banner). */
36
+ historySlot?: ReactNode;
37
+ }
38
+
39
+ /**
40
+ * Presentational, app-agnostic chat surface: connection chip + presence,
41
+ * a sticky-bottom message list, and the composer. Props-in / callbacks-out —
42
+ * NO data fetching, NO WebSocket, NO CLI-auth. The container (e.g. canopy's
43
+ * ChatPage) wires `useSessionSocket` returns into these props.
44
+ */
45
+ export function ChatPanel({
46
+ state,
47
+ connected,
48
+ currentUserId,
49
+ onSend,
50
+ onStop,
51
+ awaitingReply = false,
52
+ attachments,
53
+ onAttach,
54
+ onRemoveAttachment,
55
+ onUpdateDraft,
56
+ onTakeOver,
57
+ onDiscard,
58
+ renderMarkdown,
59
+ banner,
60
+ emptyState,
61
+ disabledReason,
62
+ historySlot,
63
+ }: ChatPanelProps) {
64
+ // `onDiscard` is part of the public surface (co-edit teardown) even though
65
+ // the default composer doesn't render a discard button. Referenced to keep
66
+ // it wired without an unused-var error; a future toolbar can surface it.
67
+ void onDiscard;
68
+
69
+ // Force a re-render when the draft lock transitions from live to idle so
70
+ // PresenceChips' amber-highlight updates at T+2s without waiting for some
71
+ // unrelated event to arrive.
72
+ const [, forceIdleTick] = useState(0);
73
+ useEffect(() => {
74
+ const draft = state.active_draft;
75
+ if (!draft) return;
76
+ const remaining = msUntilDraftIdle(draft);
77
+ if (remaining === 0) return;
78
+ const t = window.setTimeout(() => forceIdleTick((n) => n + 1), remaining + 10);
79
+ return () => window.clearTimeout(t);
80
+ }, [state.active_draft?.last_edit_at, state.active_draft]);
81
+
82
+ const holderId = state.active_draft?.last_editor ?? null;
83
+ const holderIsPresent =
84
+ holderId != null && state.presence_user_ids.includes(holderId);
85
+
86
+ // A turn is "in flight" from the moment the assistant row appears
87
+ // (status=pending/streaming) until chat.stream_complete flips it to
88
+ // complete. Treat pending AND streaming as in-flight so the send button
89
+ // stays locked out and the stop button is reachable during the "waiting
90
+ // for first token" window.
91
+ const inFlightMessage = useMemo(
92
+ () =>
93
+ state.messages.find(
94
+ (m) => m.status === "streaming" || m.status === "pending",
95
+ ) ?? null,
96
+ [state.messages],
97
+ );
98
+
99
+ // Sticky-bottom scroll: dep changes on (a) new message arrival and (b)
100
+ // streaming text growth on the last message. length-only (cheap) instead
101
+ // of the full string so the effect doesn't re-run on equal characters.
102
+ const messages = state.messages;
103
+ const lastMessageLen =
104
+ messages.length > 0 ? messages[messages.length - 1].plaintext.length : 0;
105
+ const scrollDep = `${messages.length}:${lastMessageLen}`;
106
+ const { containerRef, onScroll } = useStickyBottom(scrollDep);
107
+
108
+ return (
109
+ <div className="flex h-full flex-col">
110
+ <div className="flex items-center gap-3 border-b border-border bg-background px-3 py-1.5 text-xs">
111
+ <ConnectionStatus connected={connected} />
112
+ <div className="ml-auto">
113
+ <PresenceChips
114
+ participants={state.participants}
115
+ presenceUserIds={state.presence_user_ids}
116
+ draftHolderId={holderId}
117
+ draftHolderIdle={isDraftIdle(state.active_draft)}
118
+ />
119
+ </div>
120
+ </div>
121
+ {/* Pinned ABOVE the scroll container, not inside it. The container is
122
+ auto-scrolled to the bottom (useStickyBottom), so a slot rendered as
123
+ the first child of the scroll content sits above the visible area —
124
+ on prod the "Load full session" control on an empty runner-discovered
125
+ session was in the DOM but scrolled out of view and covered by the
126
+ header, i.e. unreachable. Pinning keeps "Load earlier"/"Load full"
127
+ always visible. */}
128
+ {historySlot}
129
+ <div ref={containerRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
130
+ <MessageList
131
+ messages={state.messages}
132
+ emptyState={emptyState}
133
+ renderMarkdown={renderMarkdown}
134
+ />
135
+ </div>
136
+ <SendBox
137
+ draft={state.active_draft}
138
+ connected={connected}
139
+ currentUserId={currentUserId}
140
+ holderIsPresent={holderIsPresent}
141
+ isStreaming={inFlightMessage != null || awaitingReply}
142
+ streamingMessageId={inFlightMessage?.id ?? null}
143
+ onUpdate={onUpdateDraft}
144
+ onSend={onSend}
145
+ onStop={onStop}
146
+ onTakeOver={onTakeOver}
147
+ banner={banner}
148
+ disabledReason={disabledReason}
149
+ attachments={attachments}
150
+ onAttach={onAttach}
151
+ onRemoveAttachment={onRemoveAttachment}
152
+ />
153
+ </div>
154
+ );
155
+ }
@@ -0,0 +1,30 @@
1
+ interface Props {
2
+ connected: boolean;
3
+ }
4
+
5
+ /**
6
+ * WS connection chip — "Connected" (success) or "Reconnecting…" (warning).
7
+ * The ace CLI-auth variant is stripped; the kit only knows about the socket.
8
+ */
9
+ export function ConnectionStatus({ connected }: Props) {
10
+ if (!connected) {
11
+ return (
12
+ <span
13
+ className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-2 py-0.5 text-xs text-warning"
14
+ title="Trying to reconnect to the chat server."
15
+ >
16
+ <span className="h-2 w-2 animate-pulse rounded-full bg-warning" />
17
+ Reconnecting…
18
+ </span>
19
+ );
20
+ }
21
+ return (
22
+ <span
23
+ className="inline-flex items-center gap-1.5 rounded-full border border-success/40 bg-success/10 px-2 py-0.5 text-xs text-success"
24
+ title="Connected."
25
+ >
26
+ <span className="h-2 w-2 rounded-full bg-success" />
27
+ Connected
28
+ </span>
29
+ );
30
+ }
@@ -0,0 +1,198 @@
1
+ import type { ReactNode } from "react";
2
+ import { AlertTriangle, ChevronRight, OctagonX } from "lucide-react";
3
+
4
+ import type { Message } from "./protocol";
5
+ import { ToolCallPair } from "./ToolCallPair";
6
+
7
+ /** How to render assistant/system markdown. Injected by the app so the kit
8
+ * stays free of `react-markdown`. Defaults to plain text in a <span>. */
9
+ export type RenderMarkdown = (text: string) => ReactNode;
10
+
11
+ const plainText: RenderMarkdown = (text) => (
12
+ <span className="whitespace-pre-wrap">{text}</span>
13
+ );
14
+
15
+ interface Props {
16
+ message: Message;
17
+ /** When set, expand/collapse this row regardless of native toggle. Lets
18
+ * MessageList's toolbar drive bulk expand/collapse without having to
19
+ * duplicate the rendering logic per row. */
20
+ forceToolOpen?: boolean;
21
+ renderMarkdown?: RenderMarkdown;
22
+ }
23
+
24
+ /** Count visible lines for the "▸ System context (N lines)" header. */
25
+ function countLines(text: string): number {
26
+ if (!text) return 0;
27
+ return text.split("\n").length;
28
+ }
29
+
30
+ // The backend marks cancelled-by-user turns as status=error with
31
+ // error_detail prefixed by "cancelled". Treat that visually as a
32
+ // neutral "stopped" state, not a scary "error" state.
33
+ function classifyError(detail: string | null) {
34
+ const text = (detail ?? "").trim();
35
+ if (text.toLowerCase().startsWith("cancelled")) {
36
+ return {
37
+ kind: "stopped" as const,
38
+ label: text.replace(/^cancelled/i, "Stopped").trim() || "Stopped",
39
+ };
40
+ }
41
+ return {
42
+ kind: "error" as const,
43
+ label: text || "Something went wrong",
44
+ };
45
+ }
46
+
47
+ export function MessageItem({
48
+ message,
49
+ forceToolOpen,
50
+ renderMarkdown = plainText,
51
+ }: Props) {
52
+ const text = message.plaintext;
53
+ const isStreaming = message.status === "streaming";
54
+ const isPending = message.status === "pending";
55
+ const isError = message.status === "error";
56
+
57
+ // tool_use and tool_result rows that survived the pairing pass in
58
+ // MessageList didn't find a partner — render as standalone with the
59
+ // same component for visual consistency. The common case (paired
60
+ // tool_use+tool_result) is rendered by MessageList itself via
61
+ // ToolCallPair so we never reach here for those.
62
+ if (message.role === "tool_use") {
63
+ return <ToolCallPair use={message} result={null} forceOpen={forceToolOpen} />;
64
+ }
65
+ if (message.role === "tool_result") {
66
+ // Synthesize a fake "use" message so the pair component can render
67
+ // a uniform header. Defensive — should be rare.
68
+ const fakeUse: Message = {
69
+ ...message,
70
+ role: "tool_use",
71
+ content: { name: "tool_result (orphan)" },
72
+ };
73
+ return (
74
+ <ToolCallPair use={fakeUse} result={message} forceOpen={forceToolOpen} />
75
+ );
76
+ }
77
+
78
+ // System messages are seed context for the agent: load-bearing for the
79
+ // assistant's first response, but a wall-of-text from the human reader's
80
+ // POV. Render collapsed by default with a chevron header so the send box
81
+ // stays the focal point on session open.
82
+ if (message.role === "system") {
83
+ return (
84
+ <SystemSeedRow
85
+ message={message}
86
+ forceOpen={forceToolOpen}
87
+ renderMarkdown={renderMarkdown}
88
+ />
89
+ );
90
+ }
91
+
92
+ const bubbleClass =
93
+ message.role === "user"
94
+ ? "ml-auto bg-primary text-primary-foreground"
95
+ : "mr-auto bg-muted text-foreground";
96
+ // Hold the "Thinking…" treatment through the gap between
97
+ // chat.stream_start (status flips to "streaming") and the first
98
+ // chat.delta (text becomes non-empty).
99
+ const showThinking =
100
+ (isPending || isStreaming) && message.role === "assistant" && !text;
101
+ return (
102
+ <div
103
+ className={`my-2 max-w-[80%] rounded-2xl px-4 py-2 ${bubbleClass}`}
104
+ aria-live={isStreaming || isPending ? "polite" : undefined}
105
+ >
106
+ {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>
115
+ ) : message.role === "assistant" ? (
116
+ renderMarkdown(text)
117
+ ) : (
118
+ <div className="whitespace-pre-wrap">{text}</div>
119
+ )}
120
+ {isStreaming && text && (
121
+ <span className="ml-1 inline-block h-3 w-1 animate-pulse bg-current align-middle" />
122
+ )}
123
+ {isError && message.role === "assistant" && (
124
+ <ErrorFooter detail={message.error_detail} hasPartial={Boolean(text)} />
125
+ )}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Collapsed-by-default rendering for a seed/system message. Uses a native
132
+ * <details> element so it's keyboard-accessible and screen-reader friendly.
133
+ * ``forceToolOpen`` (from MessageList's bulk toolbar) overrides the native
134
+ * state to match the surrounding tool rows.
135
+ */
136
+ function SystemSeedRow({
137
+ message,
138
+ forceOpen,
139
+ renderMarkdown,
140
+ }: {
141
+ message: Message;
142
+ forceOpen: boolean | undefined;
143
+ renderMarkdown: RenderMarkdown;
144
+ }) {
145
+ const text = message.plaintext;
146
+ const lineCount = countLines(text);
147
+ const openProp = forceOpen === undefined ? undefined : forceOpen;
148
+ return (
149
+ <details
150
+ className="group my-2 mr-auto max-w-[80%] rounded-2xl border border-border bg-muted/40 px-3 py-1.5 text-sm"
151
+ data-testid="system-seed-row"
152
+ {...(openProp !== undefined ? { open: openProp } : {})}
153
+ >
154
+ <summary className="flex cursor-pointer items-center gap-1.5 text-muted-foreground hover:text-foreground select-none list-none [&::-webkit-details-marker]:hidden">
155
+ <ChevronRight className="h-3.5 w-3.5 transition-transform group-open:rotate-90" />
156
+ <span className="text-xs font-medium uppercase tracking-wide">
157
+ System context
158
+ </span>
159
+ {lineCount > 0 && (
160
+ <span className="text-xs text-muted-foreground/70">
161
+ · {lineCount} line{lineCount === 1 ? "" : "s"}
162
+ </span>
163
+ )}
164
+ </summary>
165
+ <div className="mt-2 border-t border-border/40 pt-2 text-foreground">
166
+ {renderMarkdown(text)}
167
+ </div>
168
+ </details>
169
+ );
170
+ }
171
+
172
+ function ErrorFooter({
173
+ detail,
174
+ hasPartial,
175
+ }: {
176
+ detail: string | null;
177
+ hasPartial: boolean;
178
+ }) {
179
+ const { kind, label } = classifyError(detail);
180
+ const isStopped = kind === "stopped";
181
+ // Stopped = neutral muted treatment; error = amber warning. Avoid
182
+ // destructive red — even a real error is recoverable (the user resends
183
+ // the next turn) and red bubbles reflexively read as "your chat is broken".
184
+ const Icon = isStopped ? OctagonX : AlertTriangle;
185
+ const tone = isStopped ? "text-muted-foreground" : "text-warning";
186
+ return (
187
+ <div
188
+ className={`mt-2 flex items-start gap-1.5 border-t border-border/40 pt-1.5 text-xs italic ${tone}`}
189
+ role="status"
190
+ >
191
+ <Icon className="mt-0.5 h-3 w-3 shrink-0" aria-hidden="true" />
192
+ <span>
193
+ {label}
194
+ {hasPartial ? " · partial response shown above" : ""}
195
+ </span>
196
+ </div>
197
+ );
198
+ }
@@ -0,0 +1,145 @@
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Loader2 } from "lucide-react";
3
+
4
+ import type { Message } from "./protocol";
5
+ import { Button } from "../ui/button";
6
+ import type { RenderMarkdown } from "./MessageItem";
7
+ import { MessageItem } from "./MessageItem";
8
+ import { ToolCallPair } from "./ToolCallPair";
9
+ import { pairToolMessages } from "./pairToolMessages";
10
+ import { groupToolRuns, runHasError, runIsActive, summariseRun } from "./groupToolRuns";
11
+
12
+ interface Props {
13
+ messages: Message[];
14
+ /** Rendered when there are no messages yet (replaces ace's WelcomePanel). */
15
+ emptyState?: ReactNode;
16
+ renderMarkdown?: RenderMarkdown;
17
+ }
18
+
19
+ // Show the bulk expand/collapse toolbar once a session has more than this
20
+ // many tool rows. Below that, the per-row toggle is enough.
21
+ const TOOLBAR_THRESHOLD = 5;
22
+
23
+ type BulkState = "default" | "all" | "none";
24
+
25
+ export function MessageList({ messages, emptyState, renderMarkdown }: Props) {
26
+ const paired = useMemo(() => pairToolMessages(messages), [messages]);
27
+ // Collapse back-to-back tool calls into one row. An agent mid-task emits long
28
+ // stretches of them, and one row each pushes the prose you actually read off
29
+ // the screen.
30
+ const rows = useMemo(() => groupToolRuns(paired), [paired]);
31
+ const toolPairCount = useMemo(
32
+ () => paired.filter((r) => r.kind === "tool_pair").length,
33
+ [paired],
34
+ );
35
+ // ``default`` = each <details> uses its own native state (collapsed
36
+ // initially, user can toggle individually). The bulk toggles flip
37
+ // every row open or closed at once. Reverting to "default" hands
38
+ // control back to per-row state.
39
+ const [bulkState, setBulkState] = useState<BulkState>("default");
40
+
41
+ if (messages.length === 0) {
42
+ return <>{emptyState ?? null}</>;
43
+ }
44
+ const forceToolOpen =
45
+ bulkState === "all" ? true : bulkState === "none" ? false : undefined;
46
+
47
+ return (
48
+ <div className="flex flex-col">
49
+ {toolPairCount > TOOLBAR_THRESHOLD && (
50
+ <div className="sticky top-0 z-10 flex items-center justify-end gap-2 border-b border-border bg-background/80 px-4 py-1.5 backdrop-blur">
51
+ <span className="text-xs text-muted-foreground">
52
+ {toolPairCount} tool calls
53
+ </span>
54
+ <Button
55
+ variant={bulkState === "all" ? "secondary" : "ghost"}
56
+ size="xs"
57
+ onClick={() => setBulkState(bulkState === "all" ? "default" : "all")}
58
+ aria-pressed={bulkState === "all"}
59
+ >
60
+ <ChevronsUpDown className="h-3 w-3" />
61
+ Expand all
62
+ </Button>
63
+ <Button
64
+ variant={bulkState === "none" ? "secondary" : "ghost"}
65
+ size="xs"
66
+ onClick={() =>
67
+ setBulkState(bulkState === "none" ? "default" : "none")
68
+ }
69
+ aria-pressed={bulkState === "none"}
70
+ >
71
+ <ChevronsDownUp className="h-3 w-3" />
72
+ Collapse all
73
+ </Button>
74
+ </div>
75
+ )}
76
+ <div className="flex flex-col gap-2 p-4">
77
+ {rows.map((row) => {
78
+ if (row.kind === "tool_run") {
79
+ // One line for a whole run, open on demand. `open` when the bulk
80
+ // toggle says so, and always when something in it failed — a
81
+ // collapsed group must never hide an error.
82
+ const failed = runHasError(row.rows);
83
+ // A run with a call still in flight must say so: collapsed, an agent
84
+ // mid-task would otherwise look idle.
85
+ const active = runIsActive(row.rows);
86
+ return (
87
+ <details
88
+ key={row.key}
89
+ open={forceToolOpen ?? (failed || undefined)}
90
+ className="group my-1 rounded border border-border bg-muted/40 text-sm"
91
+ >
92
+ <summary className="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-muted-foreground hover:bg-muted/60 select-none [&::-webkit-details-marker]:hidden">
93
+ <ChevronRight className="h-3 w-3 shrink-0 transition-transform group-open:rotate-90" />
94
+ <span className="text-xs font-medium text-foreground">
95
+ {summariseRun(row.rows)}
96
+ </span>
97
+ {active && (
98
+ <span className="flex items-center gap-1 text-xs text-muted-foreground">
99
+ <Loader2 className="h-3 w-3 animate-spin" /> running
100
+ </span>
101
+ )}
102
+ {failed && (
103
+ <span className="text-xs font-medium text-destructive">
104
+ · contains an error
105
+ </span>
106
+ )}
107
+ </summary>
108
+ <div className="space-y-1 border-t border-border/60 p-2">
109
+ {row.rows.map((r) =>
110
+ r.kind === "tool_pair" ? (
111
+ <ToolCallPair
112
+ key={r.key}
113
+ use={r.use}
114
+ result={r.result}
115
+ forceOpen={forceToolOpen}
116
+ />
117
+ ) : null,
118
+ )}
119
+ </div>
120
+ </details>
121
+ );
122
+ }
123
+ if (row.kind === "tool_pair") {
124
+ return (
125
+ <ToolCallPair
126
+ key={row.key}
127
+ use={row.use}
128
+ result={row.result}
129
+ forceOpen={forceToolOpen}
130
+ />
131
+ );
132
+ }
133
+ return (
134
+ <MessageItem
135
+ key={row.key}
136
+ message={row.message}
137
+ forceToolOpen={forceToolOpen}
138
+ renderMarkdown={renderMarkdown}
139
+ />
140
+ );
141
+ })}
142
+ </div>
143
+ </div>
144
+ );
145
+ }
@@ -0,0 +1,112 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
4
+
5
+ import { PlacementBanner, type PlacementRunner } from "./PlacementBanner";
6
+
7
+ function runner(id: string, overrides: Partial<PlacementRunner> = {}): PlacementRunner {
8
+ return { id, name: `Runner ${id}`, online: true, ...overrides };
9
+ }
10
+
11
+ afterEach(() => {
12
+ cleanup();
13
+ });
14
+
15
+ describe("PlacementBanner", () => {
16
+ it("renders the bound-but-offline runner's name", () => {
17
+ render(
18
+ <PlacementBanner
19
+ runnerName="Laptop"
20
+ eligibleRunners={[]}
21
+ onWait={vi.fn()}
22
+ onPlace={vi.fn()}
23
+ />,
24
+ );
25
+
26
+ expect(screen.getByText("Laptop is unavailable")).toBeTruthy();
27
+ });
28
+
29
+ it("fires onPlace with the picked runner id", () => {
30
+ const onPlace = vi.fn();
31
+ render(
32
+ <PlacementBanner
33
+ runnerName="Laptop"
34
+ eligibleRunners={[runner("r1", { name: "Cloud" }), runner("r2", { name: "Backup" })]}
35
+ onWait={vi.fn()}
36
+ onPlace={onPlace}
37
+ />,
38
+ );
39
+
40
+ // The picker is collapsed until "Continue on…" is clicked.
41
+ expect(screen.queryByLabelText("Continue on")).toBeNull();
42
+ fireEvent.click(screen.getByText("Continue on…"));
43
+
44
+ const select = screen.getByLabelText("Continue on") as HTMLSelectElement;
45
+ fireEvent.change(select, { target: { value: "r2" } });
46
+
47
+ expect(onPlace).toHaveBeenCalledWith("r2");
48
+ expect(onPlace).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ it("disables both actions when busy", () => {
52
+ render(
53
+ <PlacementBanner
54
+ runnerName="Laptop"
55
+ eligibleRunners={[runner("r1")]}
56
+ busy
57
+ onWait={vi.fn()}
58
+ onPlace={vi.fn()}
59
+ />,
60
+ );
61
+
62
+ const waitButton = screen.getByText("Wait for it") as HTMLButtonElement;
63
+ const continueButton = screen.getByText("Continue on…") as HTMLButtonElement;
64
+ expect(waitButton.disabled).toBe(true);
65
+ expect(continueButton.disabled).toBe(true);
66
+ });
67
+
68
+ it("fires onWait when 'Wait for it' is clicked", () => {
69
+ const onWait = vi.fn();
70
+ render(
71
+ <PlacementBanner
72
+ runnerName="Laptop"
73
+ eligibleRunners={[]}
74
+ onWait={onWait}
75
+ onPlace={vi.fn()}
76
+ />,
77
+ );
78
+
79
+ fireEvent.click(screen.getByText("Wait for it"));
80
+ expect(onWait).toHaveBeenCalledTimes(1);
81
+ });
82
+
83
+ it("renders an error message when provided", () => {
84
+ render(
85
+ <PlacementBanner
86
+ runnerName="Laptop"
87
+ eligibleRunners={[]}
88
+ error="Could not place the turn."
89
+ onWait={vi.fn()}
90
+ onPlace={vi.fn()}
91
+ />,
92
+ );
93
+
94
+ expect(screen.getByText("Could not place the turn.")).toBeTruthy();
95
+ });
96
+
97
+ it("renders error and info distinctly styled when both are present", () => {
98
+ render(
99
+ <PlacementBanner
100
+ runnerName="Laptop"
101
+ eligibleRunners={[]}
102
+ error="boom"
103
+ info="Placed."
104
+ onWait={vi.fn()}
105
+ onPlace={vi.fn()}
106
+ />,
107
+ );
108
+
109
+ expect(screen.getByText("Placed.").className).toContain("text-muted-foreground");
110
+ expect(screen.getByText("boom").className).toContain("text-destructive");
111
+ });
112
+ });