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 +6 -2
- package/src/chat/ChatPanel.tsx +155 -0
- package/src/chat/ConnectionStatus.tsx +30 -0
- package/src/chat/MessageItem.tsx +198 -0
- package/src/chat/MessageList.tsx +145 -0
- package/src/chat/PlacementBanner.test.tsx +112 -0
- package/src/chat/PlacementBanner.tsx +92 -0
- package/src/chat/PresenceChips.tsx +52 -0
- package/src/chat/SendBox.test.tsx +359 -0
- package/src/chat/SendBox.tsx +306 -0
- package/src/chat/ToolCallPair.tsx +86 -0
- package/src/chat/drafts.test.ts +90 -0
- package/src/chat/drafts.ts +37 -0
- package/src/chat/groupToolRuns.test.ts +81 -0
- package/src/chat/groupToolRuns.ts +82 -0
- package/src/chat/history.test.ts +35 -0
- package/src/chat/history.ts +16 -0
- package/src/chat/index.ts +56 -0
- package/src/chat/pairToolMessages.test.ts +360 -0
- package/src/chat/pairToolMessages.ts +238 -0
- package/src/chat/protocol.ts +112 -0
- package/src/chat/sessionReducer.test.ts +541 -0
- package/src/chat/sessionReducer.ts +368 -0
- package/src/chat/useSessionSocket.ts +332 -0
- package/src/chat/useStickyBottom.ts +77 -0
- package/src/presence/PresenceBadge.test.tsx +58 -0
- package/src/presence/PresenceBadge.tsx +105 -0
- package/src/presence/avatar.test.ts +42 -0
- package/src/presence/avatar.ts +36 -0
- package/src/presence/index.ts +4 -0
- package/src/presence/pageKey.test.ts +53 -0
- package/src/presence/pageKey.ts +37 -0
- package/src/presence/usePresence.test.ts +130 -0
- package/src/presence/usePresence.ts +170 -0
|
@@ -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
|
+
});
|