canopy-ui 0.6.2 → 0.7.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.pending.test.tsx +136 -0
- package/src/chat/ChatPanel.tsx +48 -2
- package/src/chat/MenuPrompt.test.tsx +24 -0
- package/src/chat/MenuPrompt.tsx +331 -0
- package/src/chat/MessageItem.tsx +21 -8
- package/src/chat/MessageList.tsx +32 -3
- package/src/chat/PlacementBanner.test.tsx +75 -0
- package/src/chat/PlacementBanner.tsx +52 -15
- package/src/chat/PresenceChips.tsx +113 -19
- package/src/chat/SendBox.test.tsx +151 -0
- package/src/chat/SendBox.tsx +139 -10
- package/src/chat/drafts.test.ts +120 -0
- package/src/chat/drafts.ts +134 -0
- package/src/chat/index.ts +13 -0
- package/src/chat/pairToolMessages.ts +1 -1
- package/src/chat/protocol.ts +98 -2
- package/src/chat/sessionReducer.test.ts +143 -0
- package/src/chat/sessionReducer.ts +54 -3
- package/src/presence/PresenceBadge.test.tsx +84 -0
- package/src/presence/PresenceBadge.tsx +111 -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 +199 -0
- package/src/presence/usePresence.ts +188 -0
package/src/chat/MessageList.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Loader2 } from "lucide-re
|
|
|
4
4
|
import type { Message } from "./protocol";
|
|
5
5
|
import { Button } from "../ui/button";
|
|
6
6
|
import type { RenderMarkdown } from "./MessageItem";
|
|
7
|
-
import { MessageItem } from "./MessageItem";
|
|
7
|
+
import { MessageItem, ThinkingIndicator } from "./MessageItem";
|
|
8
8
|
import { ToolCallPair } from "./ToolCallPair";
|
|
9
9
|
import { pairToolMessages } from "./pairToolMessages";
|
|
10
10
|
import { groupToolRuns, runHasError, runIsActive, summariseRun } from "./groupToolRuns";
|
|
@@ -14,6 +14,20 @@ interface Props {
|
|
|
14
14
|
/** Rendered when there are no messages yet (replaces ace's WelcomePanel). */
|
|
15
15
|
emptyState?: ReactNode;
|
|
16
16
|
renderMarkdown?: RenderMarkdown;
|
|
17
|
+
/**
|
|
18
|
+
* The agent has the floor but has produced no row yet — render a trailing
|
|
19
|
+
* "thinking" bubble at the bottom of the conversation.
|
|
20
|
+
*
|
|
21
|
+
* A trailing ELEMENT, not a row spliced into `messages`: the message array is
|
|
22
|
+
* a projection of the durable record and must keep exactly one writer, and a
|
|
23
|
+
* placeholder with a made-up id would need upsert/eviction rules against every
|
|
24
|
+
* frame that can follow it. This has none — it is on screen while the flag is
|
|
25
|
+
* true and gone when it isn't.
|
|
26
|
+
*/
|
|
27
|
+
pendingReply?: boolean;
|
|
28
|
+
/** Wording for that bubble — the caller knows whether the turn is still
|
|
29
|
+
* queued for a runner or the agent is actually working. */
|
|
30
|
+
pendingLabel?: string;
|
|
17
31
|
}
|
|
18
32
|
|
|
19
33
|
// Show the bulk expand/collapse toolbar once a session has more than this
|
|
@@ -22,7 +36,13 @@ const TOOLBAR_THRESHOLD = 5;
|
|
|
22
36
|
|
|
23
37
|
type BulkState = "default" | "all" | "none";
|
|
24
38
|
|
|
25
|
-
export function MessageList({
|
|
39
|
+
export function MessageList({
|
|
40
|
+
messages,
|
|
41
|
+
emptyState,
|
|
42
|
+
renderMarkdown,
|
|
43
|
+
pendingReply = false,
|
|
44
|
+
pendingLabel,
|
|
45
|
+
}: Props) {
|
|
26
46
|
const paired = useMemo(() => pairToolMessages(messages), [messages]);
|
|
27
47
|
// Collapse back-to-back tool calls into one row. An agent mid-task emits long
|
|
28
48
|
// stretches of them, and one row each pushes the prose you actually read off
|
|
@@ -38,7 +58,7 @@ export function MessageList({ messages, emptyState, renderMarkdown }: Props) {
|
|
|
38
58
|
// control back to per-row state.
|
|
39
59
|
const [bulkState, setBulkState] = useState<BulkState>("default");
|
|
40
60
|
|
|
41
|
-
if (messages.length === 0) {
|
|
61
|
+
if (messages.length === 0 && !pendingReply) {
|
|
42
62
|
return <>{emptyState ?? null}</>;
|
|
43
63
|
}
|
|
44
64
|
const forceToolOpen =
|
|
@@ -139,6 +159,15 @@ export function MessageList({ messages, emptyState, renderMarkdown }: Props) {
|
|
|
139
159
|
/>
|
|
140
160
|
);
|
|
141
161
|
})}
|
|
162
|
+
{pendingReply && (
|
|
163
|
+
<div
|
|
164
|
+
data-testid="pending-reply"
|
|
165
|
+
aria-live="polite"
|
|
166
|
+
className="my-2 mr-auto max-w-[80%] rounded-2xl bg-muted px-4 py-2 text-foreground"
|
|
167
|
+
>
|
|
168
|
+
<ThinkingIndicator label={pendingLabel} />
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
142
171
|
</div>
|
|
143
172
|
</div>
|
|
144
173
|
);
|
|
@@ -94,6 +94,81 @@ describe("PlacementBanner", () => {
|
|
|
94
94
|
expect(screen.getByText("Could not place the turn.")).toBeTruthy();
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
it("names a pause as a pause, with the reason it was given", () => {
|
|
98
|
+
render(
|
|
99
|
+
<PlacementBanner
|
|
100
|
+
runnerName="Laptop"
|
|
101
|
+
eligibleRunners={[]}
|
|
102
|
+
paused
|
|
103
|
+
pausedNote="token limit on this account"
|
|
104
|
+
onWait={vi.fn()}
|
|
105
|
+
onPlace={vi.fn()}
|
|
106
|
+
/>,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(screen.getByText("Laptop is paused")).toBeTruthy();
|
|
110
|
+
// The note is what tells you whether resuming is actually safe.
|
|
111
|
+
expect(screen.getByText("— token limit on this account")).toBeTruthy();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("offers Resume when the viewer can un-park the runner", () => {
|
|
115
|
+
const onResume = vi.fn();
|
|
116
|
+
render(
|
|
117
|
+
<PlacementBanner
|
|
118
|
+
runnerName="Laptop"
|
|
119
|
+
eligibleRunners={[]}
|
|
120
|
+
paused
|
|
121
|
+
onResume={onResume}
|
|
122
|
+
onWait={vi.fn()}
|
|
123
|
+
onPlace={vi.fn()}
|
|
124
|
+
/>,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
fireEvent.click(screen.getByText("Resume"));
|
|
128
|
+
expect(onResume).toHaveBeenCalledTimes(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("omits Resume when no handler is given — only the pairer may resume", () => {
|
|
132
|
+
render(
|
|
133
|
+
<PlacementBanner
|
|
134
|
+
runnerName="Laptop"
|
|
135
|
+
eligibleRunners={[]}
|
|
136
|
+
paused
|
|
137
|
+
onWait={vi.fn()}
|
|
138
|
+
onPlace={vi.fn()}
|
|
139
|
+
/>,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// A button that 404s reads as the runner refusing to come back.
|
|
143
|
+
expect(screen.queryByText("Resume")).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("keeps waiting reachable but demoted below the acting exits", () => {
|
|
147
|
+
// Waiting leaves the message QUEUED until the box returns — real for a
|
|
148
|
+
// reboot, wrong as a default. It stays a button (role, disabled state,
|
|
149
|
+
// accessible name) but is styled as a link rather than a peer action.
|
|
150
|
+
render(
|
|
151
|
+
<PlacementBanner
|
|
152
|
+
runnerName="Laptop"
|
|
153
|
+
eligibleRunners={[]}
|
|
154
|
+
paused
|
|
155
|
+
onResume={vi.fn()}
|
|
156
|
+
onWait={vi.fn()}
|
|
157
|
+
onPlace={vi.fn()}
|
|
158
|
+
/>,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const wait = screen.getByText("Wait for it") as HTMLButtonElement;
|
|
162
|
+
expect(wait.tagName).toBe("BUTTON");
|
|
163
|
+
expect(wait.className).toContain("underline");
|
|
164
|
+
expect(wait.className).not.toContain("border");
|
|
165
|
+
// Resume comes first in reading order — the fix before the deferral.
|
|
166
|
+
expect(
|
|
167
|
+
screen.getByText("Resume").compareDocumentPosition(wait) &
|
|
168
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
169
|
+
).toBeTruthy();
|
|
170
|
+
});
|
|
171
|
+
|
|
97
172
|
it("renders error and info distinctly styled when both are present", () => {
|
|
98
173
|
render(
|
|
99
174
|
<PlacementBanner
|
|
@@ -24,15 +24,32 @@ export interface PlacementBannerProps {
|
|
|
24
24
|
onWait: () => void;
|
|
25
25
|
/** Re-place the turn onto the given runner id. */
|
|
26
26
|
onPlace: (runnerId: string) => void;
|
|
27
|
+
/** True when the runner is PARKED rather than gone — a decision someone made,
|
|
28
|
+
* which someone can therefore undo. Changes the headline and, with `onResume`,
|
|
29
|
+
* puts the one-tap fix first. */
|
|
30
|
+
paused?: boolean;
|
|
31
|
+
/** The reason recorded at pause time, shown so the reader can judge whether
|
|
32
|
+
* resuming is safe (e.g. "token limit on this account" usually is not). */
|
|
33
|
+
pausedNote?: string;
|
|
34
|
+
/** Un-park the runner in place. Omit when the viewer cannot — only the human
|
|
35
|
+
* who paired a runner may pause or resume it, so offering this to anyone else
|
|
36
|
+
* would render a button that 404s. */
|
|
37
|
+
onResume?: () => void;
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
/**
|
|
30
|
-
* Offline-runner placement banner: the chat session's bound runner
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
41
|
+
* Offline-runner placement banner: the chat session's bound runner cannot act,
|
|
42
|
+
* and the user must get it acting again — resume it, or continue on a different
|
|
43
|
+
* session-capable runner. Presentational only — NO fetch, NO WS. The container
|
|
44
|
+
* (canopy's ChatPage) owns the fleet poll, the derived eligible-runner list, and
|
|
45
|
+
* the placement POST; this component just renders the decision and forwards it.
|
|
46
|
+
*
|
|
47
|
+
* The actions are deliberately not peers. Waiting means your message sits QUEUED
|
|
48
|
+
* until the box returns — which, for a pause you applied yourself, may be never —
|
|
49
|
+
* and a queued send reads as a sent one right up until you notice no reply came.
|
|
50
|
+
* So the exits that make the chat WORK are buttons, and waiting is a link you
|
|
51
|
+
* have to mean. It stays reachable because a box rebooting in ninety seconds is
|
|
52
|
+
* a real case; it is just not the shape of the default.
|
|
36
53
|
*/
|
|
37
54
|
export function PlacementBanner({
|
|
38
55
|
runnerName,
|
|
@@ -42,6 +59,9 @@ export function PlacementBanner({
|
|
|
42
59
|
info,
|
|
43
60
|
onWait,
|
|
44
61
|
onPlace,
|
|
62
|
+
paused = false,
|
|
63
|
+
pausedNote,
|
|
64
|
+
onResume,
|
|
45
65
|
}: PlacementBannerProps) {
|
|
46
66
|
// Whether the "Continue on…" picker is expanded — purely local UI state,
|
|
47
67
|
// not fetch-driven, so it lives in the kit rather than round-tripping
|
|
@@ -50,15 +70,22 @@ export function PlacementBanner({
|
|
|
50
70
|
|
|
51
71
|
return (
|
|
52
72
|
<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">
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
<span className="font-medium">
|
|
74
|
+
{runnerName} is {paused ? "paused" : "unavailable"}
|
|
75
|
+
</span>
|
|
76
|
+
{paused && pausedNote && (
|
|
77
|
+
<span className="text-warning/80">— {pausedNote}</span>
|
|
78
|
+
)}
|
|
79
|
+
{onResume && (
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
onClick={onResume}
|
|
83
|
+
disabled={busy}
|
|
84
|
+
className="rounded-md border border-warning/40 bg-warning/20 px-2 py-0.5 font-medium text-warning hover:bg-warning/30 disabled:opacity-50"
|
|
85
|
+
>
|
|
86
|
+
Resume
|
|
87
|
+
</button>
|
|
88
|
+
)}
|
|
62
89
|
<button
|
|
63
90
|
type="button"
|
|
64
91
|
onClick={() => setShowPicker((v) => !v)}
|
|
@@ -85,6 +112,16 @@ export function PlacementBanner({
|
|
|
85
112
|
))}
|
|
86
113
|
</select>
|
|
87
114
|
)}
|
|
115
|
+
{/* Demoted, not removed — see the component doc. Still a <button> so it
|
|
116
|
+
keeps its role, its disabled state and its accessible name. */}
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={onWait}
|
|
120
|
+
disabled={busy}
|
|
121
|
+
className="ml-auto text-warning/70 underline underline-offset-2 hover:text-warning disabled:opacity-50"
|
|
122
|
+
>
|
|
123
|
+
Wait for it
|
|
124
|
+
</button>
|
|
88
125
|
{error && <span className="text-destructive">{error}</span>}
|
|
89
126
|
{info && <span className="text-muted-foreground">{info}</span>}
|
|
90
127
|
</div>
|
|
@@ -5,42 +5,136 @@ interface Props {
|
|
|
5
5
|
presenceUserIds: number[];
|
|
6
6
|
draftHolderId: number | null;
|
|
7
7
|
draftHolderIdle: boolean;
|
|
8
|
+
/** Who is looking. Required so the row can exclude YOU.
|
|
9
|
+
*
|
|
10
|
+
* Without it this rendered your own chip and the empty state said "nobody
|
|
11
|
+
* else here" — copy and logic disagreeing about whether "else" meant
|
|
12
|
+
* anything. In practice the empty state was unreachable: alone in a session
|
|
13
|
+
* you saw one chip, which is indistinguishable from being watched by a
|
|
14
|
+
* stranger. ChatPanel had `currentUserId` the whole time and never passed
|
|
15
|
+
* it. Caught by the first two-browser e2e this surface ever had. */
|
|
16
|
+
currentUserId: number;
|
|
8
17
|
}
|
|
9
18
|
|
|
19
|
+
/** A stable colour per person, so two people are told apart at a glance.
|
|
20
|
+
*
|
|
21
|
+
* Every chip used to be `bg-muted`, which meant identity rested entirely on
|
|
22
|
+
* two initials — and initials collide constantly on a small team (two J.K.s
|
|
23
|
+
* render identically). Hue is derived from the user id so a given person is
|
|
24
|
+
* the same colour in every session, for everyone, with no state to keep. */
|
|
25
|
+
const PALETTE = [
|
|
26
|
+
"bg-sky-500/20 text-sky-700 dark:text-sky-200 ring-sky-500/40",
|
|
27
|
+
"bg-emerald-500/20 text-emerald-700 dark:text-emerald-200 ring-emerald-500/40",
|
|
28
|
+
"bg-violet-500/20 text-violet-700 dark:text-violet-200 ring-violet-500/40",
|
|
29
|
+
"bg-amber-500/20 text-amber-700 dark:text-amber-200 ring-amber-500/40",
|
|
30
|
+
"bg-rose-500/20 text-rose-700 dark:text-rose-200 ring-rose-500/40",
|
|
31
|
+
"bg-teal-500/20 text-teal-700 dark:text-teal-200 ring-teal-500/40",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const colorFor = (userId: number) => PALETTE[Math.abs(userId) % PALETTE.length];
|
|
35
|
+
|
|
36
|
+
/** How many faces before collapsing into "+N". Beyond a handful the row stops
|
|
37
|
+
* being a glance and starts being a list, and it shares a thin header bar. */
|
|
38
|
+
const MAX_FACES = 4;
|
|
39
|
+
|
|
10
40
|
export function PresenceChips({
|
|
11
41
|
participants,
|
|
12
42
|
presenceUserIds,
|
|
13
43
|
draftHolderId,
|
|
14
44
|
draftHolderIdle,
|
|
45
|
+
currentUserId,
|
|
15
46
|
}: Props) {
|
|
16
|
-
const present = participants.filter(
|
|
17
|
-
presenceUserIds.includes(p.user_id),
|
|
47
|
+
const present = participants.filter(
|
|
48
|
+
(p) => presenceUserIds.includes(p.user_id) && p.user_id !== currentUserId,
|
|
18
49
|
);
|
|
50
|
+
|
|
19
51
|
if (present.length === 0) {
|
|
20
|
-
return
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
className="text-xs text-muted-foreground"
|
|
55
|
+
data-testid="presence-empty"
|
|
56
|
+
>
|
|
57
|
+
just you
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
21
60
|
}
|
|
61
|
+
|
|
62
|
+
const editor = present.find(
|
|
63
|
+
(p) => p.user_id === draftHolderId && !draftHolderIdle,
|
|
64
|
+
);
|
|
65
|
+
const faces = present.slice(0, MAX_FACES);
|
|
66
|
+
const overflow = present.length - faces.length;
|
|
67
|
+
|
|
22
68
|
return (
|
|
23
|
-
<div className="flex gap-2">
|
|
24
|
-
{
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
69
|
+
<div className="flex items-center gap-2" data-testid="presence-chips">
|
|
70
|
+
{/* The one thing worth WORDS rather than a ring: somebody is typing into
|
|
71
|
+
the box you share, and the old UI said so only in a `title` tooltip —
|
|
72
|
+
invisible on touch, invisible to a screen reader, and invisible to
|
|
73
|
+
anyone not hovering the exact 28px circle. */}
|
|
74
|
+
{editor && (
|
|
75
|
+
<span
|
|
76
|
+
className="hidden items-center gap-1 text-xs text-muted-foreground sm:flex"
|
|
77
|
+
data-testid="presence-editing-label"
|
|
78
|
+
>
|
|
79
|
+
<span className="relative flex h-1.5 w-1.5" aria-hidden="true">
|
|
80
|
+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
|
|
81
|
+
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary" />
|
|
82
|
+
</span>
|
|
83
|
+
{firstName(editor.display_name)} is typing…
|
|
84
|
+
</span>
|
|
85
|
+
)}
|
|
86
|
+
<ul
|
|
87
|
+
className="flex items-center -space-x-1.5"
|
|
88
|
+
aria-label={describe(present, editor)}
|
|
89
|
+
data-testid="presence-list"
|
|
90
|
+
>
|
|
91
|
+
{faces.map((p) => {
|
|
92
|
+
const isEditor = p.user_id === editor?.user_id;
|
|
93
|
+
return (
|
|
94
|
+
<li
|
|
95
|
+
key={p.user_id}
|
|
96
|
+
data-testid="presence-chip"
|
|
97
|
+
data-user-id={p.user_id}
|
|
98
|
+
data-editing={isEditor ? "true" : "false"}
|
|
99
|
+
title={p.display_name + (isEditor ? " — editing…" : "")}
|
|
100
|
+
className={[
|
|
101
|
+
"flex h-7 w-7 items-center justify-center rounded-full",
|
|
102
|
+
"text-[11px] font-semibold ring-2 ring-background",
|
|
103
|
+
"transition-transform hover:z-10 hover:scale-110",
|
|
104
|
+
colorFor(p.user_id),
|
|
105
|
+
isEditor ? "z-10 !ring-primary" : "",
|
|
106
|
+
].join(" ")}
|
|
107
|
+
>
|
|
108
|
+
{initials(p.display_name)}
|
|
109
|
+
</li>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
{overflow > 0 && (
|
|
113
|
+
<li
|
|
114
|
+
data-testid="presence-overflow"
|
|
115
|
+
title={present.slice(MAX_FACES).map((p) => p.display_name).join(", ")}
|
|
116
|
+
className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-[11px] font-semibold text-muted-foreground ring-2 ring-background"
|
|
35
117
|
>
|
|
36
|
-
{
|
|
37
|
-
</
|
|
38
|
-
)
|
|
39
|
-
|
|
118
|
+
+{overflow}
|
|
119
|
+
</li>
|
|
120
|
+
)}
|
|
121
|
+
</ul>
|
|
40
122
|
</div>
|
|
41
123
|
);
|
|
42
124
|
}
|
|
43
125
|
|
|
126
|
+
/** The accessible name for the row — the same fact the faces carry, in words.
|
|
127
|
+
* A row of coloured circles is meaningless without this. */
|
|
128
|
+
function describe(present: Participant[], editor?: Participant): string {
|
|
129
|
+
const names = present.map((p) => p.display_name).join(", ");
|
|
130
|
+
const who = present.length === 1 ? "1 other person here" : `${present.length} other people here`;
|
|
131
|
+
return editor ? `${who}: ${names}. ${editor.display_name} is editing.` : `${who}: ${names}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function firstName(name: string): string {
|
|
135
|
+
return name.trim().split(/\s+/)[0] || name;
|
|
136
|
+
}
|
|
137
|
+
|
|
44
138
|
function initials(name: string): string {
|
|
45
139
|
return name
|
|
46
140
|
.split(" ")
|
|
@@ -357,3 +357,154 @@ describe("SendBox — attaching files", () => {
|
|
|
357
357
|
expect(screen.queryByRole("button", { name: /attach/i })).toBeNull();
|
|
358
358
|
});
|
|
359
359
|
});
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* A half-typed message must survive leaving the page.
|
|
363
|
+
*
|
|
364
|
+
* It lives nowhere but this component's state: single-player never mirrors the
|
|
365
|
+
* body to the server (drafts.shouldSyncDraftLive), and the adopt rule above
|
|
366
|
+
* deliberately ignores our OWN server draft — so an unmount used to destroy the
|
|
367
|
+
* only copy. `persistKey` gives it somewhere to land.
|
|
368
|
+
*/
|
|
369
|
+
describe("SendBox — draft persistence across unmount", () => {
|
|
370
|
+
function fakeStorage(seed: Record<string, string> = {}) {
|
|
371
|
+
const map = new Map(Object.entries(seed));
|
|
372
|
+
return {
|
|
373
|
+
map,
|
|
374
|
+
getItem: (k: string) => map.get(k) ?? null,
|
|
375
|
+
setItem: (k: string, v: string) => void map.set(k, v),
|
|
376
|
+
removeItem: (k: string) => void map.delete(k),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function mount(
|
|
381
|
+
storage: ReturnType<typeof fakeStorage>,
|
|
382
|
+
props: Partial<Parameters<typeof SendBox>[0]> = {},
|
|
383
|
+
) {
|
|
384
|
+
return render(
|
|
385
|
+
<SendBox
|
|
386
|
+
draft={draft()}
|
|
387
|
+
connected
|
|
388
|
+
currentUserId={ME}
|
|
389
|
+
holderIsPresent={false}
|
|
390
|
+
isStreaming={false}
|
|
391
|
+
streamingMessageId={null}
|
|
392
|
+
onUpdate={vi.fn()}
|
|
393
|
+
onSend={vi.fn()}
|
|
394
|
+
onStop={vi.fn()}
|
|
395
|
+
onTakeOver={vi.fn()}
|
|
396
|
+
persistKey="sess-1"
|
|
397
|
+
storage={storage}
|
|
398
|
+
{...props}
|
|
399
|
+
/>,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const box = () => screen.getByRole("textbox") as HTMLTextAreaElement;
|
|
404
|
+
|
|
405
|
+
it("restores what was typed after unmounting and mounting again", () => {
|
|
406
|
+
const storage = fakeStorage();
|
|
407
|
+
const first = mount(storage);
|
|
408
|
+
fireEvent.change(box(), { target: { value: "the thing I was saying" } });
|
|
409
|
+
first.unmount();
|
|
410
|
+
|
|
411
|
+
mount(storage);
|
|
412
|
+
expect(box().value).toBe("the thing I was saying");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("comes back empty once the message has been sent", () => {
|
|
416
|
+
const storage = fakeStorage();
|
|
417
|
+
const onSend = vi.fn();
|
|
418
|
+
const first = mount(storage, { onSend });
|
|
419
|
+
fireEvent.change(box(), { target: { value: "shipping it" } });
|
|
420
|
+
fireEvent.click(screen.getByRole("button", { name: /^send$/i }));
|
|
421
|
+
expect(onSend).toHaveBeenCalled();
|
|
422
|
+
first.unmount();
|
|
423
|
+
|
|
424
|
+
mount(storage);
|
|
425
|
+
expect(box().value).toBe("");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("does not carry one session's text into another", () => {
|
|
429
|
+
const storage = fakeStorage();
|
|
430
|
+
const first = mount(storage, { persistKey: "sess-1" });
|
|
431
|
+
fireEvent.change(box(), { target: { value: "for session one" } });
|
|
432
|
+
first.unmount();
|
|
433
|
+
|
|
434
|
+
mount(storage, { persistKey: "sess-2" });
|
|
435
|
+
expect(box().value).toBe("");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("follows the key when the panel swaps sessions without remounting", () => {
|
|
439
|
+
const storage = fakeStorage();
|
|
440
|
+
const view = mount(storage, { persistKey: "sess-1" });
|
|
441
|
+
fireEvent.change(box(), { target: { value: "for session one" } });
|
|
442
|
+
|
|
443
|
+
view.rerender(
|
|
444
|
+
<SendBox
|
|
445
|
+
draft={draft()}
|
|
446
|
+
connected
|
|
447
|
+
currentUserId={ME}
|
|
448
|
+
holderIsPresent={false}
|
|
449
|
+
isStreaming={false}
|
|
450
|
+
streamingMessageId={null}
|
|
451
|
+
onUpdate={vi.fn()}
|
|
452
|
+
onSend={vi.fn()}
|
|
453
|
+
onStop={vi.fn()}
|
|
454
|
+
onTakeOver={vi.fn()}
|
|
455
|
+
persistKey="sess-2"
|
|
456
|
+
storage={storage}
|
|
457
|
+
/>,
|
|
458
|
+
);
|
|
459
|
+
expect(box().value).toBe("");
|
|
460
|
+
|
|
461
|
+
// ...and session one is still waiting where we left it.
|
|
462
|
+
view.unmount();
|
|
463
|
+
mount(storage, { persistKey: "sess-1" });
|
|
464
|
+
expect(box().value).toBe("for session one");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("prefers the stored body over the server draft", () => {
|
|
468
|
+
// The stored one is strictly newer: it is what was in the box when we left.
|
|
469
|
+
const storage = fakeStorage();
|
|
470
|
+
const first = mount(storage, { draft: draft({ body: "from the server" }) });
|
|
471
|
+
fireEvent.change(box(), { target: { value: "what I actually typed" } });
|
|
472
|
+
first.unmount();
|
|
473
|
+
|
|
474
|
+
mount(storage, { draft: draft({ body: "from the server" }) });
|
|
475
|
+
expect(box().value).toBe("what I actually typed");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("still shows the server draft when nothing was stored", () => {
|
|
479
|
+
mount(fakeStorage(), { draft: draft({ body: "from the server" }) });
|
|
480
|
+
expect(box().value).toBe("from the server");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("without a persistKey, behaves exactly as it did before", () => {
|
|
484
|
+
const storage = fakeStorage();
|
|
485
|
+
const first = mount(storage, { persistKey: undefined });
|
|
486
|
+
fireEvent.change(box(), { target: { value: "nowhere to land" } });
|
|
487
|
+
first.unmount();
|
|
488
|
+
expect(storage.map.size).toBe(0);
|
|
489
|
+
|
|
490
|
+
mount(storage, { persistKey: undefined });
|
|
491
|
+
expect(box().value).toBe("");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("keeps typing working when storage throws", () => {
|
|
495
|
+
const hostile = {
|
|
496
|
+
getItem: () => {
|
|
497
|
+
throw new Error("SecurityError");
|
|
498
|
+
},
|
|
499
|
+
setItem: () => {
|
|
500
|
+
throw new Error("SecurityError");
|
|
501
|
+
},
|
|
502
|
+
removeItem: () => {
|
|
503
|
+
throw new Error("SecurityError");
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
mount(hostile as unknown as ReturnType<typeof fakeStorage>);
|
|
507
|
+
fireEvent.change(box(), { target: { value: "still typeable" } });
|
|
508
|
+
expect(box().value).toBe("still typeable");
|
|
509
|
+
});
|
|
510
|
+
});
|