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.
@@ -0,0 +1,92 @@
1
+ import { useState } from "react";
2
+
3
+ /** A candidate runner the user may re-place a queued turn onto. */
4
+ export interface PlacementRunner {
5
+ id: string;
6
+ name: string;
7
+ online: boolean;
8
+ }
9
+
10
+ export interface PlacementBannerProps {
11
+ /** The bound-but-offline runner's display name. */
12
+ runnerName: string;
13
+ /** Alternatives the user may re-place onto (the "Continue on…" picker). */
14
+ eligibleRunners: PlacementRunner[];
15
+ /** True while a placement POST is in flight — disables both actions. */
16
+ busy?: boolean;
17
+ /** A failure message to surface below the actions (e.g. "no pending
18
+ * message to place"). Rendered with destructive styling. */
19
+ error?: string | null;
20
+ /** A non-failure status message (e.g. "Placed — the new runner will pick it
21
+ * up shortly."). Rendered muted, visually distinct from `error`. */
22
+ info?: string | null;
23
+ /** Keep the turn queued for the bound runner to come back online. */
24
+ onWait: () => void;
25
+ /** Re-place the turn onto the given runner id. */
26
+ onPlace: (runnerId: string) => void;
27
+ }
28
+
29
+ /**
30
+ * Offline-runner placement banner: the chat session's bound runner has gone
31
+ * unavailable, and the user must decide whether to wait for it or continue
32
+ * on a different session-capable runner. Presentational only — NO fetch, NO
33
+ * WS. The container (canopy's ChatPage) owns the fleet poll, the derived
34
+ * eligible-runner list, and the placement POST; this component just renders
35
+ * the decision and forwards the pick.
36
+ */
37
+ export function PlacementBanner({
38
+ runnerName,
39
+ eligibleRunners,
40
+ busy = false,
41
+ error,
42
+ info,
43
+ onWait,
44
+ onPlace,
45
+ }: PlacementBannerProps) {
46
+ // Whether the "Continue on…" picker is expanded — purely local UI state,
47
+ // not fetch-driven, so it lives in the kit rather than round-tripping
48
+ // through the container.
49
+ const [showPicker, setShowPicker] = useState(false);
50
+
51
+ return (
52
+ <div className="flex flex-wrap items-center gap-2 border-b border-warning/30 bg-warning/10 px-4 py-2 text-[12px] text-warning">
53
+ <span className="font-medium">{runnerName} is unavailable</span>
54
+ <button
55
+ type="button"
56
+ onClick={onWait}
57
+ disabled={busy}
58
+ className="rounded-md border border-warning/40 px-2 py-0.5 text-warning hover:bg-warning/20 disabled:opacity-50"
59
+ >
60
+ Wait for it
61
+ </button>
62
+ <button
63
+ type="button"
64
+ onClick={() => setShowPicker((v) => !v)}
65
+ disabled={busy}
66
+ className="rounded-md border border-warning/40 px-2 py-0.5 text-warning hover:bg-warning/20 disabled:opacity-50"
67
+ >
68
+ Continue on…
69
+ </button>
70
+ {showPicker && (
71
+ <select
72
+ defaultValue=""
73
+ disabled={busy}
74
+ onChange={(e) => onPlace(e.target.value)}
75
+ className="rounded-md border border-warning/40 bg-card px-1.5 py-0.5 text-[12px] text-foreground disabled:opacity-50"
76
+ aria-label="Continue on"
77
+ >
78
+ <option value="" disabled>
79
+ Choose a runner…
80
+ </option>
81
+ {eligibleRunners.map((r) => (
82
+ <option key={r.id} value={r.id}>
83
+ {r.online ? "●" : "○"} {r.name}
84
+ </option>
85
+ ))}
86
+ </select>
87
+ )}
88
+ {error && <span className="text-destructive">{error}</span>}
89
+ {info && <span className="text-muted-foreground">{info}</span>}
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,52 @@
1
+ import type { Participant } from "./protocol";
2
+
3
+ interface Props {
4
+ participants: Participant[];
5
+ presenceUserIds: number[];
6
+ draftHolderId: number | null;
7
+ draftHolderIdle: boolean;
8
+ }
9
+
10
+ export function PresenceChips({
11
+ participants,
12
+ presenceUserIds,
13
+ draftHolderId,
14
+ draftHolderIdle,
15
+ }: Props) {
16
+ const present = participants.filter((p) =>
17
+ presenceUserIds.includes(p.user_id),
18
+ );
19
+ if (present.length === 0) {
20
+ return <div className="text-sm text-muted-foreground">nobody else here</div>;
21
+ }
22
+ return (
23
+ <div className="flex gap-2">
24
+ {present.map((p) => {
25
+ const isHolder = p.user_id === draftHolderId && !draftHolderIdle;
26
+ return (
27
+ <div
28
+ key={p.user_id}
29
+ title={p.display_name + (isHolder ? " — editing…" : "")}
30
+ className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium ${
31
+ isHolder
32
+ ? "bg-primary text-primary-foreground ring-2 ring-primary/30"
33
+ : "bg-muted text-muted-foreground"
34
+ }`}
35
+ >
36
+ {initials(p.display_name)}
37
+ </div>
38
+ );
39
+ })}
40
+ </div>
41
+ );
42
+ }
43
+
44
+ function initials(name: string): string {
45
+ return name
46
+ .split(" ")
47
+ .map((w) => w[0])
48
+ .filter(Boolean)
49
+ .join("")
50
+ .slice(0, 2)
51
+ .toUpperCase();
52
+ }
@@ -0,0 +1,359 @@
1
+ /**
2
+ * The composer is LOCAL-FIRST: what you are typing is local component state,
3
+ * never server state.
4
+ *
5
+ * It used to render `value={draft.body}` straight off the websocket reducer, so
6
+ * every inbound frame was a chance to overwrite the user mid-keystroke — and in
7
+ * SINGLE-PLAYER that trade bought nothing, because there is no co-editor to
8
+ * reconcile with. Three ways it went wrong, all reproduced below:
9
+ * * `session.state` replaces state wholesale on every reconnect, reverting
10
+ * anything typed since the last 150ms flush;
11
+ * * a stale echo of your OWN debounced update rewound the textarea;
12
+ * * two clients on one account (phone + desktop, which this app encourages)
13
+ * fight over the draft version until a mismatch clears the pending body.
14
+ */
15
+ // @vitest-environment jsdom
16
+ import { afterEach, describe, expect, it, vi } from "vitest";
17
+ import { cleanup, render, screen, fireEvent } from "@testing-library/react";
18
+
19
+ import { SendBox } from "./SendBox";
20
+ import type { Draft } from "./protocol";
21
+
22
+ afterEach(cleanup);
23
+
24
+ const ME = 1;
25
+ const THEM = 2;
26
+
27
+ function draft(overrides: Partial<Draft> = {}): Draft {
28
+ return {
29
+ id: "d1",
30
+ body: "",
31
+ version: 1,
32
+ last_editor: ME,
33
+ last_edit_at: new Date().toISOString(),
34
+ ...overrides,
35
+ } as Draft;
36
+ }
37
+
38
+ function setup(props: Partial<Parameters<typeof SendBox>[0]> = {}) {
39
+ const onUpdate = vi.fn();
40
+ const onSend = vi.fn();
41
+ const view = render(
42
+ <SendBox
43
+ draft={draft()}
44
+ connected
45
+ currentUserId={ME}
46
+ holderIsPresent={false}
47
+ isStreaming={false}
48
+ streamingMessageId={null}
49
+ onUpdate={onUpdate}
50
+ onSend={onSend}
51
+ onStop={vi.fn()}
52
+ onTakeOver={vi.fn()}
53
+ {...props}
54
+ />,
55
+ );
56
+ const textarea = () => screen.getByRole("textbox") as HTMLTextAreaElement;
57
+ return { ...view, textarea, onUpdate, onSend };
58
+ }
59
+
60
+ describe("SendBox — local-first composer", () => {
61
+ it("keeps what you typed when a stale echo of your own draft arrives", () => {
62
+ const { textarea, rerender } = setup();
63
+ fireEvent.change(textarea(), { target: { value: "hello wor" } });
64
+
65
+ // The server echoes the previous debounced update back at us.
66
+ rerender(
67
+ <SendBox
68
+ draft={draft({ body: "hel", version: 2, last_editor: ME })}
69
+ connected
70
+ currentUserId={ME}
71
+ holderIsPresent={false}
72
+ isStreaming={false}
73
+ streamingMessageId={null}
74
+ onUpdate={vi.fn()}
75
+ onSend={vi.fn()}
76
+ onStop={vi.fn()}
77
+ onTakeOver={vi.fn()}
78
+ />,
79
+ );
80
+
81
+ expect(textarea().value).toBe("hello wor");
82
+ });
83
+
84
+ it("survives a reconnect snapshot carrying a stale body", () => {
85
+ // session.state replaces the whole state object, so this is exactly what a
86
+ // reconnect looked like: everything typed since the last flush, gone.
87
+ const { textarea, rerender } = setup();
88
+ fireEvent.change(textarea(), { target: { value: "a long message" } });
89
+
90
+ rerender(
91
+ <SendBox
92
+ draft={draft({ id: "d1", body: "a long", version: 9, last_editor: ME })}
93
+ connected
94
+ currentUserId={ME}
95
+ holderIsPresent={false}
96
+ isStreaming={false}
97
+ streamingMessageId={null}
98
+ onUpdate={vi.fn()}
99
+ onSend={vi.fn()}
100
+ onStop={vi.fn()}
101
+ onTakeOver={vi.fn()}
102
+ />,
103
+ );
104
+
105
+ expect(textarea().value).toBe("a long message");
106
+ });
107
+
108
+ it("DOES adopt an edit made by someone else", () => {
109
+ // Multiplayer still works: a teammate's edit is the one case where the
110
+ // server genuinely knows better than this client.
111
+ const { textarea, rerender } = setup();
112
+
113
+ rerender(
114
+ <SendBox
115
+ draft={draft({ body: "from my teammate", version: 3, last_editor: THEM })}
116
+ connected
117
+ currentUserId={ME}
118
+ holderIsPresent
119
+ isStreaming={false}
120
+ streamingMessageId={null}
121
+ onUpdate={vi.fn()}
122
+ onSend={vi.fn()}
123
+ onStop={vi.fn()}
124
+ onTakeOver={vi.fn()}
125
+ />,
126
+ );
127
+
128
+ expect(textarea().value).toBe("from my teammate");
129
+ });
130
+
131
+ it("still reports every keystroke upstream", () => {
132
+ // Local-first governs what is DISPLAYED; the hook still needs the body so
133
+ // it can sync (multiplayer) and flush before chat.send commits the
134
+ // server-side draft.
135
+ const { textarea, onUpdate } = setup();
136
+ fireEvent.change(textarea(), { target: { value: "hi" } });
137
+ expect(onUpdate).toHaveBeenCalledWith("hi");
138
+ });
139
+
140
+ it("lets you type before the draft has arrived", () => {
141
+ // The textarea used to be disabled until session.state landed, so every
142
+ // reconnect locked you out of your own composer.
143
+ const { textarea } = setup({ draft: null });
144
+ expect(textarea().disabled).toBe(false);
145
+
146
+ fireEvent.change(textarea(), { target: { value: "typed while connecting" } });
147
+ expect(textarea().value).toBe("typed while connecting");
148
+ });
149
+
150
+ it("cannot send until a draft exists, since chat.send commits the server copy", () => {
151
+ const { textarea } = setup({ draft: null });
152
+ fireEvent.change(textarea(), { target: { value: "hi" } });
153
+ expect(screen.getByRole("button", { name: /send/i }).hasAttribute("disabled")).toBe(true);
154
+ });
155
+
156
+ it("clears the composer when you send", () => {
157
+ const { textarea, onSend } = setup();
158
+ fireEvent.change(textarea(), { target: { value: "ship it" } });
159
+
160
+ fireEvent.click(screen.getByRole("button", { name: /send/i }));
161
+
162
+ expect(onSend).toHaveBeenCalled();
163
+ expect(textarea().value).toBe("");
164
+ });
165
+
166
+ it("still blocks editing while a teammate holds the draft", () => {
167
+ const { textarea } = setup({
168
+ draft: draft({ last_editor: THEM, body: "theirs" }),
169
+ holderIsPresent: true,
170
+ });
171
+ expect(textarea().disabled).toBe(true);
172
+ });
173
+ });
174
+
175
+ describe("SendBox — sending is gated on the socket", () => {
176
+ it("keeps your text instead of clearing it when the socket is down", () => {
177
+ // The composer clears optimistically, and the hook's send() silently drops
178
+ // every frame but chat.stop while closed — so an allowed send here would
179
+ // empty the box and lose the message.
180
+ const { textarea, onSend } = setup({ connected: false });
181
+ fireEvent.change(textarea(), { target: { value: "do not lose me" } });
182
+
183
+ fireEvent.click(screen.getByRole("button", { name: /send/i }));
184
+
185
+ expect(onSend).not.toHaveBeenCalled();
186
+ expect(textarea().value).toBe("do not lose me");
187
+ });
188
+
189
+ it("still lets you type while disconnected", () => {
190
+ const { textarea } = setup({ connected: false });
191
+ fireEvent.change(textarea(), { target: { value: "composed offline" } });
192
+ expect(textarea().disabled).toBe(false);
193
+ expect(textarea().value).toBe("composed offline");
194
+ });
195
+ });
196
+
197
+ describe("SendBox — cancelling a queued turn", () => {
198
+ it("offers stop while a send is outstanding, before any reply exists", () => {
199
+ // The gap this closes: between send and the first token there is NO
200
+ // assistant message, so `inFlightMessage` is null and the stop button never
201
+ // rendered — exactly the window where you most want out, because a queued
202
+ // turn means no runner has picked it up (offline, or busy with another).
203
+ // The server has always handled it: chat.stop cancels every non-terminal
204
+ // turn and ignores message_id.
205
+ const onStop = vi.fn();
206
+ render(
207
+ <SendBox
208
+ draft={draft()}
209
+ connected
210
+ currentUserId={ME}
211
+ holderIsPresent={false}
212
+ isStreaming
213
+ streamingMessageId={null}
214
+ onUpdate={vi.fn()}
215
+ onSend={vi.fn()}
216
+ onStop={onStop}
217
+ onTakeOver={vi.fn()}
218
+ />,
219
+ );
220
+
221
+ fireEvent.click(screen.getByRole("button", { name: /stop/i }));
222
+
223
+ expect(onStop).toHaveBeenCalledWith(null);
224
+ });
225
+
226
+ it("passes the message id through once a reply is streaming", () => {
227
+ const onStop = vi.fn();
228
+ render(
229
+ <SendBox
230
+ draft={draft()}
231
+ connected
232
+ currentUserId={ME}
233
+ holderIsPresent={false}
234
+ isStreaming
235
+ streamingMessageId="m1"
236
+ onUpdate={vi.fn()}
237
+ onSend={vi.fn()}
238
+ onStop={onStop}
239
+ onTakeOver={vi.fn()}
240
+ />,
241
+ );
242
+
243
+ fireEvent.click(screen.getByRole("button", { name: /stop/i }));
244
+
245
+ expect(onStop).toHaveBeenCalledWith("m1");
246
+ });
247
+ });
248
+
249
+ describe("SendBox — attaching files", () => {
250
+ const attachProps = {
251
+ draft: draft(),
252
+ connected: true,
253
+ currentUserId: ME,
254
+ holderIsPresent: false,
255
+ isStreaming: false,
256
+ streamingMessageId: null,
257
+ onUpdate: vi.fn(),
258
+ onSend: vi.fn(),
259
+ onStop: vi.fn(),
260
+ onTakeOver: vi.fn(),
261
+ };
262
+
263
+ it("hides attaching entirely when the host provides no handler", () => {
264
+ // The kit must stay usable by a host with no upload endpoint.
265
+ render(<SendBox {...attachProps} />);
266
+ expect(screen.queryByRole("button", { name: /attach/i })).toBeNull();
267
+ });
268
+
269
+ it("hands picked files to the host", () => {
270
+ const onAttach = vi.fn();
271
+ render(<SendBox {...attachProps} onAttach={onAttach} />);
272
+
273
+ const input = screen.getByTestId("attachment-input") as HTMLInputElement;
274
+ const file = new File(["x"], "shot.png", { type: "image/png" });
275
+ fireEvent.change(input, { target: { files: [file] } });
276
+
277
+ expect(onAttach).toHaveBeenCalledWith([file]);
278
+ });
279
+
280
+ it("takes a pasted screenshot", () => {
281
+ // The point on desktop: a screenshot is already on the clipboard, and
282
+ // making people save it to disk first is most of the friction.
283
+ const onAttach = vi.fn();
284
+ render(<SendBox {...attachProps} onAttach={onAttach} />);
285
+
286
+ const file = new File(["x"], "clip.png", { type: "image/png" });
287
+ fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { files: [file] } });
288
+
289
+ expect(onAttach).toHaveBeenCalledWith([file]);
290
+ });
291
+
292
+ it("renders a chip per staged file, and marks one still uploading", () => {
293
+ render(
294
+ <SendBox
295
+ {...attachProps}
296
+ onAttach={vi.fn()}
297
+ attachments={[
298
+ { id: "a1", filename: "done.png" },
299
+ { id: "a2", filename: "slow.png", uploading: true },
300
+ ]}
301
+ />,
302
+ );
303
+
304
+ expect(screen.getByText("done.png")).toBeTruthy();
305
+ expect(screen.getByText("slow.png")).toBeTruthy();
306
+ expect(screen.getByText(/uploading/)).toBeTruthy();
307
+ });
308
+
309
+ it("cannot remove a chip that is still uploading", () => {
310
+ // There is no server-side id to remove yet.
311
+ render(
312
+ <SendBox
313
+ {...attachProps}
314
+ onAttach={vi.fn()}
315
+ onRemoveAttachment={vi.fn()}
316
+ attachments={[{ id: "a2", filename: "slow.png", uploading: true }]}
317
+ />,
318
+ );
319
+ expect(screen.queryByRole("button", { name: /remove slow.png/i })).toBeNull();
320
+ });
321
+
322
+ it("removes a staged file on request", () => {
323
+ const onRemoveAttachment = vi.fn();
324
+ render(
325
+ <SendBox
326
+ {...attachProps}
327
+ onAttach={vi.fn()}
328
+ onRemoveAttachment={onRemoveAttachment}
329
+ attachments={[{ id: "a1", filename: "done.png" }]}
330
+ />,
331
+ );
332
+
333
+ fireEvent.click(screen.getByRole("button", { name: /remove done.png/i }));
334
+ expect(onRemoveAttachment).toHaveBeenCalledWith("a1");
335
+ });
336
+
337
+ it("shows why an upload failed instead of dropping it silently", () => {
338
+ render(
339
+ <SendBox
340
+ {...attachProps}
341
+ onAttach={vi.fn()}
342
+ attachments={[{ id: "a3", filename: "huge.png", error: "file is larger than the 10MB limit" }]}
343
+ />,
344
+ );
345
+ expect(screen.getByText(/10MB limit/)).toBeTruthy();
346
+ });
347
+
348
+ it("does not offer attaching while a teammate holds the draft", () => {
349
+ render(
350
+ <SendBox
351
+ {...attachProps}
352
+ draft={draft({ last_editor: THEM })}
353
+ holderIsPresent
354
+ onAttach={vi.fn()}
355
+ />,
356
+ );
357
+ expect(screen.queryByRole("button", { name: /attach/i })).toBeNull();
358
+ });
359
+ });