canopy-ui 0.2.0 → 0.4.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,11 +1,17 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/jjackson/canopy-web.git",
8
+ "directory": "frontend/packages/canopy-ui"
9
+ },
5
10
  "exports": {
6
11
  ".": "./src/index.ts",
7
12
  "./lib": "./src/lib/index.ts",
8
13
  "./ui": "./src/ui/index.ts",
14
+ "./chat": "./src/chat/index.ts",
9
15
  "./shell": "./src/shell/index.ts",
10
16
  "./tokens": "./src/tokens/index.ts",
11
17
  "./tokens/preset.css": "./src/tokens/preset.css",
@@ -19,9 +25,22 @@
19
25
  "@base-ui/react": "^1.3.0",
20
26
  "class-variance-authority": "^0.7.1",
21
27
  "clsx": "^2.1.1",
28
+ "lucide-react": "^1.8.0",
22
29
  "react": "^19.0.0",
23
30
  "react-dom": "^19.0.0",
31
+ "sonner": "^2.0.7",
24
32
  "tailwind-merge": "^3.5.0"
25
33
  },
34
+ "peerDependenciesMeta": {
35
+ "lucide-react": {
36
+ "optional": true
37
+ },
38
+ "sonner": {
39
+ "optional": true
40
+ }
41
+ },
42
+ "devDependencies": {
43
+ "sonner": "^2.0.7"
44
+ },
26
45
  "files": ["src"]
27
46
  }
@@ -0,0 +1,140 @@
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 } 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) => void;
18
+ onUpdateDraft: (body: string) => void;
19
+ onTakeOver: () => void;
20
+ onDiscard: () => void;
21
+ renderMarkdown?: RenderMarkdown;
22
+ /** Optional banner rendered above the composer. */
23
+ banner?: ReactNode;
24
+ /** Rendered when there are no messages yet. */
25
+ emptyState?: ReactNode;
26
+ /** When set, sending is disabled and this reason is shown. */
27
+ disabledReason?: string;
28
+ /** Rendered at the top of the scroll container (e.g. a "Load earlier" button / offline banner). */
29
+ historySlot?: ReactNode;
30
+ }
31
+
32
+ /**
33
+ * Presentational, app-agnostic chat surface: connection chip + presence,
34
+ * a sticky-bottom message list, and the composer. Props-in / callbacks-out —
35
+ * NO data fetching, NO WebSocket, NO CLI-auth. The container (e.g. canopy's
36
+ * ChatPage) wires `useSessionSocket` returns into these props.
37
+ */
38
+ export function ChatPanel({
39
+ state,
40
+ connected,
41
+ currentUserId,
42
+ onSend,
43
+ onStop,
44
+ onUpdateDraft,
45
+ onTakeOver,
46
+ onDiscard,
47
+ renderMarkdown,
48
+ banner,
49
+ emptyState,
50
+ disabledReason,
51
+ historySlot,
52
+ }: ChatPanelProps) {
53
+ // `onDiscard` is part of the public surface (co-edit teardown) even though
54
+ // the default composer doesn't render a discard button. Referenced to keep
55
+ // it wired without an unused-var error; a future toolbar can surface it.
56
+ void onDiscard;
57
+
58
+ // Force a re-render when the draft lock transitions from live to idle so
59
+ // PresenceChips' amber-highlight updates at T+2s without waiting for some
60
+ // unrelated event to arrive.
61
+ const [, forceIdleTick] = useState(0);
62
+ useEffect(() => {
63
+ const draft = state.active_draft;
64
+ if (!draft) return;
65
+ const remaining = msUntilDraftIdle(draft);
66
+ if (remaining === 0) return;
67
+ const t = window.setTimeout(() => forceIdleTick((n) => n + 1), remaining + 10);
68
+ return () => window.clearTimeout(t);
69
+ }, [state.active_draft?.last_edit_at, state.active_draft]);
70
+
71
+ const holderId = state.active_draft?.last_editor ?? null;
72
+ const holderIsPresent =
73
+ holderId != null && state.presence_user_ids.includes(holderId);
74
+
75
+ // A turn is "in flight" from the moment the assistant row appears
76
+ // (status=pending/streaming) until chat.stream_complete flips it to
77
+ // complete. Treat pending AND streaming as in-flight so the send button
78
+ // stays locked out and the stop button is reachable during the "waiting
79
+ // for first token" window.
80
+ const inFlightMessage = useMemo(
81
+ () =>
82
+ state.messages.find(
83
+ (m) => m.status === "streaming" || m.status === "pending",
84
+ ) ?? null,
85
+ [state.messages],
86
+ );
87
+
88
+ // Sticky-bottom scroll: dep changes on (a) new message arrival and (b)
89
+ // streaming text growth on the last message. length-only (cheap) instead
90
+ // of the full string so the effect doesn't re-run on equal characters.
91
+ const messages = state.messages;
92
+ const lastMessageLen =
93
+ messages.length > 0 ? messages[messages.length - 1].plaintext.length : 0;
94
+ const scrollDep = `${messages.length}:${lastMessageLen}`;
95
+ const { containerRef, onScroll } = useStickyBottom(scrollDep);
96
+
97
+ return (
98
+ <div className="flex h-full flex-col">
99
+ <div className="flex items-center gap-3 border-b border-border bg-background px-3 py-1.5 text-xs">
100
+ <ConnectionStatus connected={connected} />
101
+ <div className="ml-auto">
102
+ <PresenceChips
103
+ participants={state.participants}
104
+ presenceUserIds={state.presence_user_ids}
105
+ draftHolderId={holderId}
106
+ draftHolderIdle={isDraftIdle(state.active_draft)}
107
+ />
108
+ </div>
109
+ </div>
110
+ {/* Pinned ABOVE the scroll container, not inside it. The container is
111
+ auto-scrolled to the bottom (useStickyBottom), so a slot rendered as
112
+ the first child of the scroll content sits above the visible area —
113
+ on prod the "Load full session" control on an empty runner-discovered
114
+ session was in the DOM but scrolled out of view and covered by the
115
+ header, i.e. unreachable. Pinning keeps "Load earlier"/"Load full"
116
+ always visible. */}
117
+ {historySlot}
118
+ <div ref={containerRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
119
+ <MessageList
120
+ messages={state.messages}
121
+ emptyState={emptyState}
122
+ renderMarkdown={renderMarkdown}
123
+ />
124
+ </div>
125
+ <SendBox
126
+ draft={state.active_draft}
127
+ currentUserId={currentUserId}
128
+ holderIsPresent={holderIsPresent}
129
+ isStreaming={inFlightMessage != null}
130
+ streamingMessageId={inFlightMessage?.id ?? null}
131
+ onUpdate={onUpdateDraft}
132
+ onSend={onSend}
133
+ onStop={onStop}
134
+ onTakeOver={onTakeOver}
135
+ banner={banner}
136
+ disabledReason={disabledReason}
137
+ />
138
+ </div>
139
+ );
140
+ }
@@ -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,95 @@
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import { ChevronsDownUp, ChevronsUpDown } 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
+
11
+ interface Props {
12
+ messages: Message[];
13
+ /** Rendered when there are no messages yet (replaces ace's WelcomePanel). */
14
+ emptyState?: ReactNode;
15
+ renderMarkdown?: RenderMarkdown;
16
+ }
17
+
18
+ // Show the bulk expand/collapse toolbar once a session has more than this
19
+ // many tool rows. Below that, the per-row toggle is enough.
20
+ const TOOLBAR_THRESHOLD = 5;
21
+
22
+ type BulkState = "default" | "all" | "none";
23
+
24
+ export function MessageList({ messages, emptyState, renderMarkdown }: Props) {
25
+ const rows = useMemo(() => pairToolMessages(messages), [messages]);
26
+ const toolPairCount = useMemo(
27
+ () => rows.filter((r) => r.kind === "tool_pair").length,
28
+ [rows],
29
+ );
30
+ // ``default`` = each <details> uses its own native state (collapsed
31
+ // initially, user can toggle individually). The bulk toggles flip
32
+ // every row open or closed at once. Reverting to "default" hands
33
+ // control back to per-row state.
34
+ const [bulkState, setBulkState] = useState<BulkState>("default");
35
+
36
+ if (messages.length === 0) {
37
+ return <>{emptyState ?? null}</>;
38
+ }
39
+ const forceToolOpen =
40
+ bulkState === "all" ? true : bulkState === "none" ? false : undefined;
41
+
42
+ return (
43
+ <div className="flex flex-col">
44
+ {toolPairCount > TOOLBAR_THRESHOLD && (
45
+ <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">
46
+ <span className="text-xs text-muted-foreground">
47
+ {toolPairCount} tool calls
48
+ </span>
49
+ <Button
50
+ variant={bulkState === "all" ? "secondary" : "ghost"}
51
+ size="xs"
52
+ onClick={() => setBulkState(bulkState === "all" ? "default" : "all")}
53
+ aria-pressed={bulkState === "all"}
54
+ >
55
+ <ChevronsUpDown className="h-3 w-3" />
56
+ Expand all
57
+ </Button>
58
+ <Button
59
+ variant={bulkState === "none" ? "secondary" : "ghost"}
60
+ size="xs"
61
+ onClick={() =>
62
+ setBulkState(bulkState === "none" ? "default" : "none")
63
+ }
64
+ aria-pressed={bulkState === "none"}
65
+ >
66
+ <ChevronsDownUp className="h-3 w-3" />
67
+ Collapse all
68
+ </Button>
69
+ </div>
70
+ )}
71
+ <div className="flex flex-col gap-2 p-4">
72
+ {rows.map((row) => {
73
+ if (row.kind === "tool_pair") {
74
+ return (
75
+ <ToolCallPair
76
+ key={row.key}
77
+ use={row.use}
78
+ result={row.result}
79
+ forceOpen={forceToolOpen}
80
+ />
81
+ );
82
+ }
83
+ return (
84
+ <MessageItem
85
+ key={row.key}
86
+ message={row.message}
87
+ forceToolOpen={forceToolOpen}
88
+ renderMarkdown={renderMarkdown}
89
+ />
90
+ );
91
+ })}
92
+ </div>
93
+ </div>
94
+ );
95
+ }
@@ -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
+ });