canopy-ui 0.4.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 +5 -2
- package/src/chat/ChatPanel.tsx +18 -3
- package/src/chat/MessageList.tsx +54 -4
- package/src/chat/SendBox.test.tsx +359 -0
- package/src/chat/SendBox.tsx +183 -21
- package/src/chat/drafts.test.ts +25 -1
- package/src/chat/drafts.ts +18 -0
- package/src/chat/groupToolRuns.test.ts +81 -0
- package/src/chat/groupToolRuns.ts +82 -0
- package/src/chat/index.ts +1 -1
- package/src/chat/pairToolMessages.test.ts +152 -0
- package/src/chat/pairToolMessages.ts +90 -14
- package/src/chat/protocol.ts +15 -2
- package/src/chat/sessionReducer.test.ts +259 -2
- package/src/chat/sessionReducer.ts +127 -4
- package/src/chat/useSessionSocket.ts +77 -14
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canopy-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"./lib": "./src/lib/index.ts",
|
|
13
13
|
"./ui": "./src/ui/index.ts",
|
|
14
14
|
"./chat": "./src/chat/index.ts",
|
|
15
|
+
"./presence": "./src/presence/index.ts",
|
|
15
16
|
"./shell": "./src/shell/index.ts",
|
|
16
17
|
"./tokens": "./src/tokens/index.ts",
|
|
17
18
|
"./tokens/preset.css": "./src/tokens/preset.css",
|
|
@@ -42,5 +43,7 @@
|
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"sonner": "^2.0.7"
|
|
44
45
|
},
|
|
45
|
-
"files": [
|
|
46
|
+
"files": [
|
|
47
|
+
"src"
|
|
48
|
+
]
|
|
46
49
|
}
|
package/src/chat/ChatPanel.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import type { RenderMarkdown } from "./MessageItem";
|
|
|
5
5
|
import { ConnectionStatus } from "./ConnectionStatus";
|
|
6
6
|
import { MessageList } from "./MessageList";
|
|
7
7
|
import { PresenceChips } from "./PresenceChips";
|
|
8
|
-
import { SendBox } from "./SendBox";
|
|
8
|
+
import { SendBox, type PendingAttachment } from "./SendBox";
|
|
9
9
|
import { isDraftIdle, msUntilDraftIdle } from "./drafts";
|
|
10
10
|
import { useStickyBottom } from "./useStickyBottom";
|
|
11
11
|
|
|
@@ -14,7 +14,14 @@ export interface ChatPanelProps {
|
|
|
14
14
|
connected: boolean;
|
|
15
15
|
currentUserId: number;
|
|
16
16
|
onSend: () => void;
|
|
17
|
-
onStop: (messageId: string) => 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;
|
|
18
25
|
onUpdateDraft: (body: string) => void;
|
|
19
26
|
onTakeOver: () => void;
|
|
20
27
|
onDiscard: () => void;
|
|
@@ -41,6 +48,10 @@ export function ChatPanel({
|
|
|
41
48
|
currentUserId,
|
|
42
49
|
onSend,
|
|
43
50
|
onStop,
|
|
51
|
+
awaitingReply = false,
|
|
52
|
+
attachments,
|
|
53
|
+
onAttach,
|
|
54
|
+
onRemoveAttachment,
|
|
44
55
|
onUpdateDraft,
|
|
45
56
|
onTakeOver,
|
|
46
57
|
onDiscard,
|
|
@@ -124,9 +135,10 @@ export function ChatPanel({
|
|
|
124
135
|
</div>
|
|
125
136
|
<SendBox
|
|
126
137
|
draft={state.active_draft}
|
|
138
|
+
connected={connected}
|
|
127
139
|
currentUserId={currentUserId}
|
|
128
140
|
holderIsPresent={holderIsPresent}
|
|
129
|
-
isStreaming={inFlightMessage != null}
|
|
141
|
+
isStreaming={inFlightMessage != null || awaitingReply}
|
|
130
142
|
streamingMessageId={inFlightMessage?.id ?? null}
|
|
131
143
|
onUpdate={onUpdateDraft}
|
|
132
144
|
onSend={onSend}
|
|
@@ -134,6 +146,9 @@ export function ChatPanel({
|
|
|
134
146
|
onTakeOver={onTakeOver}
|
|
135
147
|
banner={banner}
|
|
136
148
|
disabledReason={disabledReason}
|
|
149
|
+
attachments={attachments}
|
|
150
|
+
onAttach={onAttach}
|
|
151
|
+
onRemoveAttachment={onRemoveAttachment}
|
|
137
152
|
/>
|
|
138
153
|
</div>
|
|
139
154
|
);
|
package/src/chat/MessageList.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMemo, useState, type ReactNode } from "react";
|
|
2
|
-
import { ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
|
2
|
+
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Loader2 } from "lucide-react";
|
|
3
3
|
|
|
4
4
|
import type { Message } from "./protocol";
|
|
5
5
|
import { Button } from "../ui/button";
|
|
@@ -7,6 +7,7 @@ import type { RenderMarkdown } from "./MessageItem";
|
|
|
7
7
|
import { MessageItem } from "./MessageItem";
|
|
8
8
|
import { ToolCallPair } from "./ToolCallPair";
|
|
9
9
|
import { pairToolMessages } from "./pairToolMessages";
|
|
10
|
+
import { groupToolRuns, runHasError, runIsActive, summariseRun } from "./groupToolRuns";
|
|
10
11
|
|
|
11
12
|
interface Props {
|
|
12
13
|
messages: Message[];
|
|
@@ -22,10 +23,14 @@ const TOOLBAR_THRESHOLD = 5;
|
|
|
22
23
|
type BulkState = "default" | "all" | "none";
|
|
23
24
|
|
|
24
25
|
export function MessageList({ messages, emptyState, renderMarkdown }: Props) {
|
|
25
|
-
const
|
|
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]);
|
|
26
31
|
const toolPairCount = useMemo(
|
|
27
|
-
() =>
|
|
28
|
-
[
|
|
32
|
+
() => paired.filter((r) => r.kind === "tool_pair").length,
|
|
33
|
+
[paired],
|
|
29
34
|
);
|
|
30
35
|
// ``default`` = each <details> uses its own native state (collapsed
|
|
31
36
|
// initially, user can toggle individually). The bulk toggles flip
|
|
@@ -70,6 +75,51 @@ export function MessageList({ messages, emptyState, renderMarkdown }: Props) {
|
|
|
70
75
|
)}
|
|
71
76
|
<div className="flex flex-col gap-2 p-4">
|
|
72
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
|
+
}
|
|
73
123
|
if (row.kind === "tool_pair") {
|
|
74
124
|
return (
|
|
75
125
|
<ToolCallPair
|
|
@@ -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
|
+
});
|