canopy-ui 0.12.2 → 0.14.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 +1 -1
- package/src/chat/ChatPanel.multiplayer.test.tsx +209 -0
- package/src/chat/ChatPanel.tsx +77 -41
- package/src/chat/MessageItem.tsx +32 -2
- package/src/chat/MessageList.tsx +9 -0
- package/src/chat/PresenceChips.tsx +5 -35
- package/src/chat/QueuedRows.tsx +42 -0
- package/src/chat/SendBox.test.tsx +138 -37
- package/src/chat/SendBox.tsx +70 -109
- package/src/chat/TypingRows.test.tsx +33 -0
- package/src/chat/TypingRows.tsx +25 -0
- package/src/chat/agui.fixture.json +78 -23
- package/src/chat/agui.ts +6 -1
- package/src/chat/drafts.test.ts +43 -65
- package/src/chat/drafts.ts +41 -19
- package/src/chat/identity.ts +37 -0
- package/src/chat/index.ts +10 -3
- package/src/chat/protocol.ts +97 -5
- package/src/chat/sessionReducer.test.ts +168 -0
- package/src/chat/sessionReducer.ts +71 -20
- package/src/chat/useSessionSocket.http.test.tsx +50 -1
- package/src/chat/useSessionSocket.ts +208 -20
- package/src/chat/useSessionSocket.typingVisibility.test.tsx +280 -0
|
@@ -42,14 +42,11 @@ function setup(props: Partial<Parameters<typeof SendBox>[0]> = {}) {
|
|
|
42
42
|
<SendBox
|
|
43
43
|
draft={draft()}
|
|
44
44
|
connected
|
|
45
|
-
currentUserId={ME}
|
|
46
|
-
holderIsPresent={false}
|
|
47
45
|
isStreaming={false}
|
|
48
46
|
streamingMessageId={null}
|
|
49
47
|
onUpdate={onUpdate}
|
|
50
48
|
onSend={onSend}
|
|
51
49
|
onStop={vi.fn()}
|
|
52
|
-
onTakeOver={vi.fn()}
|
|
53
50
|
{...props}
|
|
54
51
|
/>,
|
|
55
52
|
);
|
|
@@ -67,14 +64,11 @@ describe("SendBox — local-first composer", () => {
|
|
|
67
64
|
<SendBox
|
|
68
65
|
draft={draft({ body: "hel", version: 2, last_editor: ME })}
|
|
69
66
|
connected
|
|
70
|
-
currentUserId={ME}
|
|
71
|
-
holderIsPresent={false}
|
|
72
67
|
isStreaming={false}
|
|
73
68
|
streamingMessageId={null}
|
|
74
69
|
onUpdate={vi.fn()}
|
|
75
70
|
onSend={vi.fn()}
|
|
76
71
|
onStop={vi.fn()}
|
|
77
|
-
onTakeOver={vi.fn()}
|
|
78
72
|
/>,
|
|
79
73
|
);
|
|
80
74
|
|
|
@@ -91,41 +85,38 @@ describe("SendBox — local-first composer", () => {
|
|
|
91
85
|
<SendBox
|
|
92
86
|
draft={draft({ id: "d1", body: "a long", version: 9, last_editor: ME })}
|
|
93
87
|
connected
|
|
94
|
-
currentUserId={ME}
|
|
95
|
-
holderIsPresent={false}
|
|
96
88
|
isStreaming={false}
|
|
97
89
|
streamingMessageId={null}
|
|
98
90
|
onUpdate={vi.fn()}
|
|
99
91
|
onSend={vi.fn()}
|
|
100
92
|
onStop={vi.fn()}
|
|
101
|
-
onTakeOver={vi.fn()}
|
|
102
93
|
/>,
|
|
103
94
|
);
|
|
104
95
|
|
|
105
96
|
expect(textarea().value).toBe("a long message");
|
|
106
97
|
});
|
|
107
98
|
|
|
108
|
-
it("
|
|
109
|
-
//
|
|
110
|
-
//
|
|
99
|
+
it("never adopts another editor's body — this box is only ever your own draft now", () => {
|
|
100
|
+
// Everyone gets their own draft (`SessionState.active_draft` is the
|
|
101
|
+
// CALLER's own); a teammate's live text arrives via `peer_drafts` /
|
|
102
|
+
// <TypingRows> instead, never through this component's `draft` prop. So a
|
|
103
|
+
// `last_editor` that isn't you must NOT overwrite what you're typing.
|
|
111
104
|
const { textarea, rerender } = setup();
|
|
105
|
+
fireEvent.change(textarea(), { target: { value: "my own words" } });
|
|
112
106
|
|
|
113
107
|
rerender(
|
|
114
108
|
<SendBox
|
|
115
109
|
draft={draft({ body: "from my teammate", version: 3, last_editor: THEM })}
|
|
116
110
|
connected
|
|
117
|
-
currentUserId={ME}
|
|
118
|
-
holderIsPresent
|
|
119
111
|
isStreaming={false}
|
|
120
112
|
streamingMessageId={null}
|
|
121
113
|
onUpdate={vi.fn()}
|
|
122
114
|
onSend={vi.fn()}
|
|
123
115
|
onStop={vi.fn()}
|
|
124
|
-
onTakeOver={vi.fn()}
|
|
125
116
|
/>,
|
|
126
117
|
);
|
|
127
118
|
|
|
128
|
-
expect(textarea().value).toBe("
|
|
119
|
+
expect(textarea().value).toBe("my own words");
|
|
129
120
|
});
|
|
130
121
|
|
|
131
122
|
it("still reports every keystroke upstream", () => {
|
|
@@ -163,12 +154,87 @@ describe("SendBox — local-first composer", () => {
|
|
|
163
154
|
expect(textarea().value).toBe("");
|
|
164
155
|
});
|
|
165
156
|
|
|
166
|
-
it("
|
|
157
|
+
it("never locks editing for a teammate's draft — this box is never theirs to hold", () => {
|
|
158
|
+
// There is no shared lock any more: every editor has their own draft, so a
|
|
159
|
+
// `last_editor` naming someone else describes no state this box renders.
|
|
167
160
|
const { textarea } = setup({
|
|
168
161
|
draft: draft({ last_editor: THEM, body: "theirs" }),
|
|
169
|
-
holderIsPresent: true,
|
|
170
162
|
});
|
|
171
|
-
expect(textarea().disabled).toBe(
|
|
163
|
+
expect(textarea().disabled).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("SendBox — a local draft (contact / widget) restores a failed send", () => {
|
|
168
|
+
// A contact's or the widget's own composer has no server draft to co-edit;
|
|
169
|
+
// `useSessionSocket` stands in a LOCAL one (`id: "local"`) instead. Sending
|
|
170
|
+
// clears `localBody` optimistically (see "clears the composer when you
|
|
171
|
+
// send" above) before the round trip even starts — so a failure has no way
|
|
172
|
+
// back into the box except through this draft, once the hook re-seeds its
|
|
173
|
+
// body in the catch handler (`sendChatOverHttp`).
|
|
174
|
+
function localDraft(body: string): Draft {
|
|
175
|
+
return {
|
|
176
|
+
id: "local", slot: "next", status: "open", body, version: 0,
|
|
177
|
+
last_editor: 0, last_edit_at: new Date().toISOString(),
|
|
178
|
+
} as Draft;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
it("shows the text again once the local draft's body is restored after a send", () => {
|
|
182
|
+
const { textarea, rerender } = setup({ draft: localDraft("hello") });
|
|
183
|
+
expect(textarea().value).toBe("hello");
|
|
184
|
+
|
|
185
|
+
// Pressing Send clears the box optimistically — `handleSend`'s own
|
|
186
|
+
// `setLocalBody("")`, independent of whatever the draft prop says.
|
|
187
|
+
fireEvent.click(screen.getByRole("button", { name: /^send$/i }));
|
|
188
|
+
expect(textarea().value).toBe("");
|
|
189
|
+
|
|
190
|
+
// The send already went out — the hook cleared its local draft to null the
|
|
191
|
+
// instant it fired, well before any round trip could resolve.
|
|
192
|
+
rerender(
|
|
193
|
+
<SendBox
|
|
194
|
+
draft={null}
|
|
195
|
+
connected
|
|
196
|
+
isStreaming={false}
|
|
197
|
+
streamingMessageId={null}
|
|
198
|
+
onUpdate={vi.fn()}
|
|
199
|
+
onSend={vi.fn()}
|
|
200
|
+
onStop={vi.fn()}
|
|
201
|
+
/>,
|
|
202
|
+
);
|
|
203
|
+
expect(textarea().value).toBe("");
|
|
204
|
+
|
|
205
|
+
// The HTTP send failed; the hook re-seeds the SAME local draft with the
|
|
206
|
+
// original text (`updateLocalDraft` in its catch handler).
|
|
207
|
+
rerender(
|
|
208
|
+
<SendBox
|
|
209
|
+
draft={localDraft("hello")}
|
|
210
|
+
connected
|
|
211
|
+
isStreaming={false}
|
|
212
|
+
streamingMessageId={null}
|
|
213
|
+
onUpdate={vi.fn()}
|
|
214
|
+
onSend={vi.fn()}
|
|
215
|
+
onStop={vi.fn()}
|
|
216
|
+
/>,
|
|
217
|
+
);
|
|
218
|
+
expect(textarea().value).toBe("hello");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("does not restore a real multiplayer draft the same way", () => {
|
|
222
|
+
// The mirror is scoped to `id === "local"` on purpose: a server-assigned
|
|
223
|
+
// id is a real co-editor's draft, and adopting it unconditionally is
|
|
224
|
+
// exactly the deleted `theirEdit` bug (it could overwrite live typing).
|
|
225
|
+
const { textarea, rerender } = setup({ draft: null });
|
|
226
|
+
rerender(
|
|
227
|
+
<SendBox
|
|
228
|
+
draft={draft({ id: "d1", body: "not mine to adopt", last_editor: THEM })}
|
|
229
|
+
connected
|
|
230
|
+
isStreaming={false}
|
|
231
|
+
streamingMessageId={null}
|
|
232
|
+
onUpdate={vi.fn()}
|
|
233
|
+
onSend={vi.fn()}
|
|
234
|
+
onStop={vi.fn()}
|
|
235
|
+
/>,
|
|
236
|
+
);
|
|
237
|
+
expect(textarea().value).toBe("");
|
|
172
238
|
});
|
|
173
239
|
});
|
|
174
240
|
|
|
@@ -207,14 +273,11 @@ describe("SendBox — cancelling a queued turn", () => {
|
|
|
207
273
|
<SendBox
|
|
208
274
|
draft={draft()}
|
|
209
275
|
connected
|
|
210
|
-
currentUserId={ME}
|
|
211
|
-
holderIsPresent={false}
|
|
212
276
|
isStreaming
|
|
213
277
|
streamingMessageId={null}
|
|
214
278
|
onUpdate={vi.fn()}
|
|
215
279
|
onSend={vi.fn()}
|
|
216
280
|
onStop={onStop}
|
|
217
|
-
onTakeOver={vi.fn()}
|
|
218
281
|
/>,
|
|
219
282
|
);
|
|
220
283
|
|
|
@@ -229,14 +292,11 @@ describe("SendBox — cancelling a queued turn", () => {
|
|
|
229
292
|
<SendBox
|
|
230
293
|
draft={draft()}
|
|
231
294
|
connected
|
|
232
|
-
currentUserId={ME}
|
|
233
|
-
holderIsPresent={false}
|
|
234
295
|
isStreaming
|
|
235
296
|
streamingMessageId="m1"
|
|
236
297
|
onUpdate={vi.fn()}
|
|
237
298
|
onSend={vi.fn()}
|
|
238
299
|
onStop={onStop}
|
|
239
|
-
onTakeOver={vi.fn()}
|
|
240
300
|
/>,
|
|
241
301
|
);
|
|
242
302
|
|
|
@@ -244,20 +304,39 @@ describe("SendBox — cancelling a queued turn", () => {
|
|
|
244
304
|
|
|
245
305
|
expect(onStop).toHaveBeenCalledWith("m1");
|
|
246
306
|
});
|
|
307
|
+
|
|
308
|
+
it("stays available to send while the agent is replying", () => {
|
|
309
|
+
// The composer used to lock Send for the whole reply; a send made while
|
|
310
|
+
// streaming is QUEUED server-side now, not refused client-side.
|
|
311
|
+
const onSend = vi.fn();
|
|
312
|
+
render(
|
|
313
|
+
<SendBox
|
|
314
|
+
draft={draft()}
|
|
315
|
+
connected
|
|
316
|
+
isStreaming
|
|
317
|
+
streamingMessageId="m1"
|
|
318
|
+
onUpdate={vi.fn()}
|
|
319
|
+
onSend={onSend}
|
|
320
|
+
onStop={vi.fn()}
|
|
321
|
+
/>,
|
|
322
|
+
);
|
|
323
|
+
fireEvent.change(screen.getByRole("textbox"), { target: { value: "queue me up" } });
|
|
324
|
+
|
|
325
|
+
expect(screen.getByTestId("send").hasAttribute("disabled")).toBe(false);
|
|
326
|
+
fireEvent.click(screen.getByTestId("send"));
|
|
327
|
+
expect(onSend).toHaveBeenCalled();
|
|
328
|
+
});
|
|
247
329
|
});
|
|
248
330
|
|
|
249
331
|
describe("SendBox — attaching files", () => {
|
|
250
332
|
const attachProps = {
|
|
251
333
|
draft: draft(),
|
|
252
334
|
connected: true,
|
|
253
|
-
currentUserId: ME,
|
|
254
|
-
holderIsPresent: false,
|
|
255
335
|
isStreaming: false,
|
|
256
336
|
streamingMessageId: null,
|
|
257
337
|
onUpdate: vi.fn(),
|
|
258
338
|
onSend: vi.fn(),
|
|
259
339
|
onStop: vi.fn(),
|
|
260
|
-
onTakeOver: vi.fn(),
|
|
261
340
|
};
|
|
262
341
|
|
|
263
342
|
it("hides attaching entirely when the host provides no handler", () => {
|
|
@@ -345,13 +424,14 @@ describe("SendBox — attaching files", () => {
|
|
|
345
424
|
expect(screen.getByText(/10MB limit/)).toBeTruthy();
|
|
346
425
|
});
|
|
347
426
|
|
|
348
|
-
it("does not offer attaching while
|
|
427
|
+
it("does not offer attaching while sending is blocked", () => {
|
|
428
|
+
// Attaching used to be gated on a teammate's draft lock, which no longer
|
|
429
|
+
// exists; the one real reason to withhold it now is `disabledReason`.
|
|
349
430
|
render(
|
|
350
431
|
<SendBox
|
|
351
432
|
{...attachProps}
|
|
352
|
-
draft={draft({ last_editor: THEM })}
|
|
353
|
-
holderIsPresent
|
|
354
433
|
onAttach={vi.fn()}
|
|
434
|
+
disabledReason="answer the question above to continue"
|
|
355
435
|
/>,
|
|
356
436
|
);
|
|
357
437
|
expect(screen.queryByRole("button", { name: /attach/i })).toBeNull();
|
|
@@ -385,14 +465,11 @@ describe("SendBox — draft persistence across unmount", () => {
|
|
|
385
465
|
<SendBox
|
|
386
466
|
draft={draft()}
|
|
387
467
|
connected
|
|
388
|
-
currentUserId={ME}
|
|
389
|
-
holderIsPresent={false}
|
|
390
468
|
isStreaming={false}
|
|
391
469
|
streamingMessageId={null}
|
|
392
470
|
onUpdate={vi.fn()}
|
|
393
471
|
onSend={vi.fn()}
|
|
394
472
|
onStop={vi.fn()}
|
|
395
|
-
onTakeOver={vi.fn()}
|
|
396
473
|
persistKey="sess-1"
|
|
397
474
|
storage={storage}
|
|
398
475
|
{...props}
|
|
@@ -444,14 +521,11 @@ describe("SendBox — draft persistence across unmount", () => {
|
|
|
444
521
|
<SendBox
|
|
445
522
|
draft={draft()}
|
|
446
523
|
connected
|
|
447
|
-
currentUserId={ME}
|
|
448
|
-
holderIsPresent={false}
|
|
449
524
|
isStreaming={false}
|
|
450
525
|
streamingMessageId={null}
|
|
451
526
|
onUpdate={vi.fn()}
|
|
452
527
|
onSend={vi.fn()}
|
|
453
528
|
onStop={vi.fn()}
|
|
454
|
-
onTakeOver={vi.fn()}
|
|
455
529
|
persistKey="sess-2"
|
|
456
530
|
storage={storage}
|
|
457
531
|
/>,
|
|
@@ -508,3 +582,30 @@ describe("SendBox — draft persistence across unmount", () => {
|
|
|
508
582
|
expect(box().value).toBe("still typeable");
|
|
509
583
|
});
|
|
510
584
|
});
|
|
585
|
+
|
|
586
|
+
describe("SendBox — typing visibility control", () => {
|
|
587
|
+
it("is absent when neither prop is given", () => {
|
|
588
|
+
setup();
|
|
589
|
+
expect(screen.queryByTestId("typing-visibility")).toBeNull();
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("is absent when only the value is given, with no handler", () => {
|
|
593
|
+
setup({ typingVisibility: "live" });
|
|
594
|
+
expect(screen.queryByTestId("typing-visibility")).toBeNull();
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("shows the control when both props are given, and calls the handler on selection", () => {
|
|
598
|
+
const onChange = vi.fn();
|
|
599
|
+
setup({ typingVisibility: "live", onTypingVisibilityChange: onChange });
|
|
600
|
+
const control = screen.getByTestId("typing-visibility") as HTMLSelectElement;
|
|
601
|
+
expect(control.value).toBe("live");
|
|
602
|
+
fireEvent.change(control, { target: { value: "typing" } });
|
|
603
|
+
expect(onChange).toHaveBeenCalledWith("typing");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("has a tooltip explaining what it does", () => {
|
|
607
|
+
setup({ typingVisibility: "hidden", onTypingVisibilityChange: vi.fn() });
|
|
608
|
+
const control = screen.getByTestId("typing-visibility") as HTMLSelectElement;
|
|
609
|
+
expect(control.title.length).toBeGreaterThan(0);
|
|
610
|
+
});
|
|
611
|
+
});
|
package/src/chat/SendBox.tsx
CHANGED
|
@@ -7,12 +7,10 @@ import {
|
|
|
7
7
|
} from "react";
|
|
8
8
|
import type React from "react";
|
|
9
9
|
|
|
10
|
-
import type { Draft } from "./protocol";
|
|
10
|
+
import type { Draft, TypingVisibility } from "./protocol";
|
|
11
11
|
import {
|
|
12
12
|
clearStoredDraft,
|
|
13
13
|
defaultDraftStorage,
|
|
14
|
-
isDraftIdle,
|
|
15
|
-
msUntilDraftIdle,
|
|
16
14
|
readStoredDraft,
|
|
17
15
|
writeStoredDraft,
|
|
18
16
|
type DraftStorage,
|
|
@@ -34,12 +32,6 @@ interface Props {
|
|
|
34
32
|
draft: Draft | null;
|
|
35
33
|
/** Live socket. Typing never depends on this; SENDING does — see canSend. */
|
|
36
34
|
connected: boolean;
|
|
37
|
-
currentUserId: number;
|
|
38
|
-
holderIsPresent: boolean;
|
|
39
|
-
/** Display name of the teammate holding the draft, when it is not you.
|
|
40
|
-
* The composer is where their words appear, so it is where their name
|
|
41
|
-
* belongs — it used to live only in a 28px chip in the opposite corner. */
|
|
42
|
-
holderName?: string | null;
|
|
43
35
|
isStreaming: boolean;
|
|
44
36
|
streamingMessageId: string | null;
|
|
45
37
|
onUpdate: (body: string) => void;
|
|
@@ -52,7 +44,6 @@ interface Props {
|
|
|
52
44
|
* button that asked for it, because that is where the person who pressed it
|
|
53
45
|
* is looking. Undefined = no stop has been asked for on this turn. */
|
|
54
46
|
stopState?: "requested" | "stopped" | "failed";
|
|
55
|
-
onTakeOver: () => void;
|
|
56
47
|
/** Optional app-supplied banner rendered above the composer (e.g. an
|
|
57
48
|
* imported-session note). The kit itself has no CLI-auth banners. */
|
|
58
49
|
banner?: ReactNode;
|
|
@@ -72,21 +63,25 @@ interface Props {
|
|
|
72
63
|
/** Storage backing `persistKey`. Defaults to localStorage; inject a fake in
|
|
73
64
|
* tests, or sessionStorage for per-tab drafts. */
|
|
74
65
|
storage?: DraftStorage | null;
|
|
66
|
+
/** This person's choice of how much of THEIR OWN in-progress message peers
|
|
67
|
+
* get to see: the words live (`live`), just the fact of typing (`typing`),
|
|
68
|
+
* or nothing until send (`hidden`). Both this and `onTypingVisibilityChange`
|
|
69
|
+
* are optional, and the control renders only when BOTH are given — a host
|
|
70
|
+
* that passes neither (a contact's socket, which has no draft sync) gets no
|
|
71
|
+
* control at all rather than a dead one. */
|
|
72
|
+
typingVisibility?: TypingVisibility;
|
|
73
|
+
onTypingVisibilityChange?: (visibility: TypingVisibility) => void;
|
|
75
74
|
}
|
|
76
75
|
|
|
77
76
|
export function SendBox({
|
|
78
77
|
draft,
|
|
79
78
|
connected,
|
|
80
|
-
currentUserId,
|
|
81
|
-
holderIsPresent,
|
|
82
|
-
holderName,
|
|
83
79
|
isStreaming,
|
|
84
80
|
streamingMessageId,
|
|
85
81
|
onUpdate,
|
|
86
82
|
onSend,
|
|
87
83
|
onStop,
|
|
88
84
|
stopState,
|
|
89
|
-
onTakeOver,
|
|
90
85
|
banner,
|
|
91
86
|
disabledReason,
|
|
92
87
|
attachments,
|
|
@@ -94,38 +89,20 @@ export function SendBox({
|
|
|
94
89
|
onRemoveAttachment,
|
|
95
90
|
persistKey,
|
|
96
91
|
storage,
|
|
92
|
+
typingVisibility,
|
|
93
|
+
onTypingVisibilityChange,
|
|
97
94
|
}: Props) {
|
|
98
95
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
99
|
-
// Force a re-render when the lock transitions from live to idle.
|
|
100
|
-
// Without this, nothing would trigger a re-render exactly at T+2s
|
|
101
|
-
// after the last edit, and another user's UI would stay locked
|
|
102
|
-
// indefinitely until some unrelated event happens to arrive.
|
|
103
|
-
const [, forceTick] = useState(0);
|
|
104
|
-
|
|
105
|
-
useEffect(() => {
|
|
106
|
-
if (!draft) return;
|
|
107
|
-
const remaining = msUntilDraftIdle(draft);
|
|
108
|
-
if (remaining === 0) return;
|
|
109
|
-
const t = window.setTimeout(() => forceTick((n) => n + 1), remaining + 10);
|
|
110
|
-
return () => window.clearTimeout(t);
|
|
111
|
-
}, [draft?.last_edit_at, draft]);
|
|
112
|
-
|
|
113
|
-
const holderId = draft?.last_editor ?? null;
|
|
114
|
-
const isHolder = holderId != null && holderId === currentUserId;
|
|
115
|
-
const holderIsIdle = isDraftIdle(draft);
|
|
116
96
|
|
|
117
97
|
// LOCAL-FIRST: the textarea's value is local state, never server state.
|
|
118
98
|
// Rendering `draft.body` directly made every inbound frame a chance to
|
|
119
99
|
// overwrite the user mid-keystroke — a stale echo of your own debounced
|
|
120
100
|
// update, or a `session.state` snapshot on reconnect (which replaces state
|
|
121
|
-
// wholesale), would rewind the composer to a body from 150ms ago.
|
|
122
|
-
// single-player that reconciliation protects against nothing at all, since
|
|
123
|
-
// there is no co-editor whose edits could be lost.
|
|
101
|
+
// wholesale), would rewind the composer to a body from 150ms ago.
|
|
124
102
|
//
|
|
125
103
|
// Seeded from persisted storage when there is one, because a body typed
|
|
126
104
|
// before an unmount exists NOWHERE else: single-player never mirrors it to
|
|
127
|
-
// the server (see drafts.shouldSyncDraftLive)
|
|
128
|
-
// deliberately ignores our own server draft. A stored body wins over
|
|
105
|
+
// the server (see drafts.shouldSyncDraftLive). A stored body wins over
|
|
129
106
|
// `draft.body` — it is strictly newer, being what was in the box when we
|
|
130
107
|
// last left it.
|
|
131
108
|
const store = storage === undefined ? defaultDraftStorage() : storage;
|
|
@@ -141,11 +118,6 @@ export function SendBox({
|
|
|
141
118
|
const persist = (body: string) => {
|
|
142
119
|
if (persistKey) writeStoredDraft(store, persistKey, body);
|
|
143
120
|
};
|
|
144
|
-
// Nothing persists a CO-EDITOR's text, deliberately: a draft someone else is
|
|
145
|
-
// editing is by definition being live-synced to the server, so the existing
|
|
146
|
-
// adopt rule below restores it from `session.state` on the way back in. The
|
|
147
|
-
// gap this whole mechanism closes is the single-player one, where the server
|
|
148
|
-
// is never told at all.
|
|
149
121
|
|
|
150
122
|
// The panel can swap sessions without remounting (same route, new :id), so
|
|
151
123
|
// the box must follow the key rather than carry one session's text into the
|
|
@@ -158,46 +130,43 @@ export function SendBox({
|
|
|
158
130
|
setLocalBody(readStoredDraft(store, persistKey ?? "") ?? "");
|
|
159
131
|
}
|
|
160
132
|
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
133
|
+
// A draft with `id === "local"` is never a co-editor's — it is the LOCAL
|
|
134
|
+
// stand-in a host keeps for a principal with no server-side draft (a
|
|
135
|
+
// contact's `useSessionSocket.sendOverHttp` path, or the widget's start
|
|
136
|
+
// screen `contactDraft`). Its body is always either an echo of what THIS
|
|
137
|
+
// box just typed (harmless to mirror back) or a failed send being restored
|
|
138
|
+
// after `handleSend` already cleared the box — the one case with no other
|
|
139
|
+
// way to reach the screen, since sending clears `localBody` optimistically
|
|
140
|
+
// rather than waiting for the round trip. Scoped to `id === "local"` so a
|
|
141
|
+
// real multiplayer draft (a server-assigned id) is never adopted this way —
|
|
142
|
+
// that was the deleted `theirEdit` effect's mistake, which adopted ANY
|
|
143
|
+
// other editor's draft and could overwrite what you were mid-typing.
|
|
167
144
|
useEffect(() => {
|
|
168
|
-
if (
|
|
169
|
-
|
|
145
|
+
if (draft?.id === "local" && draft.body !== localBody) {
|
|
146
|
+
setLocalBody(draft.body);
|
|
147
|
+
}
|
|
148
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
149
|
+
}, [draft?.id, draft?.body]);
|
|
170
150
|
|
|
171
|
-
// Typing is ALWAYS allowed
|
|
172
|
-
//
|
|
151
|
+
// Typing is ALWAYS allowed now — this box is only ever YOUR OWN draft, and
|
|
152
|
+
// every editor gets one (see protocol.ts::SessionState.active_draft). It
|
|
153
|
+
// used to require `draft != null`, so the composer was disabled until
|
|
173
154
|
// session.state landed — locking you out of your own input on first paint
|
|
174
155
|
// and again on every reconnect. Keystrokes typed early are held locally and
|
|
175
156
|
// flushed when the draft exists (see useSessionSocket.sendChat).
|
|
176
|
-
const canEdit = isHolder || holderIsIdle || !holderIsPresent;
|
|
177
|
-
|
|
178
|
-
useEffect(() => {
|
|
179
|
-
if (canEdit && !isHolder && textareaRef.current) {
|
|
180
|
-
textareaRef.current.focus();
|
|
181
|
-
}
|
|
182
|
-
}, [canEdit, isHolder]);
|
|
183
|
-
|
|
184
157
|
const body = localBody;
|
|
185
158
|
const blocked = Boolean(disabledReason);
|
|
186
|
-
// Locked BY SOMEONE ELSE, as opposed to blocked for an unrelated reason.
|
|
187
|
-
// The two look identical to `disabled` and want opposite treatments: a
|
|
188
|
-
// blocked box is inert, a co-edited one is showing you live content.
|
|
189
|
-
const lockedByTeammate = !canEdit && holderIsPresent && !holderIsIdle && !blocked;
|
|
190
159
|
// Sending needs a draft (`chat.send` commits the SERVER's copy, so there must
|
|
191
160
|
// be one) AND a live socket. The socket check is load-bearing now that the
|
|
192
161
|
// composer clears optimistically: `send()` drops every frame but chat.stop
|
|
193
162
|
// when the socket is closed, so an allowed-but-undeliverable send would clear
|
|
194
|
-
// the box and lose the message outright.
|
|
163
|
+
// the box and lose the message outright. Sending stays available while the
|
|
164
|
+
// agent is replying — a send made mid-reply is QUEUED, not blocked, and
|
|
165
|
+
// <QueuedRows> is what shows it landed.
|
|
195
166
|
const canSend =
|
|
196
|
-
canEdit &&
|
|
197
167
|
connected &&
|
|
198
168
|
draft != null &&
|
|
199
169
|
body.trim().length > 0 &&
|
|
200
|
-
!isStreaming &&
|
|
201
170
|
!blocked;
|
|
202
171
|
|
|
203
172
|
const handleChange = (value: string) => {
|
|
@@ -206,7 +175,7 @@ export function SendBox({
|
|
|
206
175
|
onUpdate(value);
|
|
207
176
|
};
|
|
208
177
|
|
|
209
|
-
const canAttach = typeof onAttach === "function" &&
|
|
178
|
+
const canAttach = typeof onAttach === "function" && !blocked;
|
|
210
179
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
211
180
|
const [dragging, setDragging] = useState(false);
|
|
212
181
|
|
|
@@ -227,8 +196,7 @@ export function SendBox({
|
|
|
227
196
|
|
|
228
197
|
const handleSend = () => {
|
|
229
198
|
// Clear locally rather than waiting for the server's cleared draft to
|
|
230
|
-
// echo back
|
|
231
|
-
// above (correctly) ignores, so nothing else would empty the box.
|
|
199
|
+
// echo back — a round trip the person sending has no reason to wait on.
|
|
232
200
|
setLocalBody("");
|
|
233
201
|
if (persistKey) clearStoredDraft(store, persistKey);
|
|
234
202
|
onSend();
|
|
@@ -253,10 +221,10 @@ export function SendBox({
|
|
|
253
221
|
|
|
254
222
|
const placeholder = blocked
|
|
255
223
|
? disabledReason
|
|
256
|
-
: !
|
|
257
|
-
? "
|
|
258
|
-
:
|
|
259
|
-
? "Type a message
|
|
224
|
+
: !draft
|
|
225
|
+
? "Type a message… (connecting…)"
|
|
226
|
+
: isStreaming
|
|
227
|
+
? "Type a message — it will be sent after the current reply"
|
|
260
228
|
: "Type a message… (Enter to send, Shift+Enter for newline)";
|
|
261
229
|
|
|
262
230
|
const staged = attachments ?? [];
|
|
@@ -307,39 +275,11 @@ export function SendBox({
|
|
|
307
275
|
))}
|
|
308
276
|
</ul>
|
|
309
277
|
)}
|
|
310
|
-
{/* Co-edit attribution, at the box rather than across the screen.
|
|
311
|
-
Their text arrives INSIDE this textarea, and a disabled textarea
|
|
312
|
-
renders it in muted grey — pixel-identical to a placeholder. So the
|
|
313
|
-
single most important thing multiplayer does (showing you what your
|
|
314
|
-
teammate is writing) read as an EMPTY box with a hint in it. */}
|
|
315
|
-
{lockedByTeammate && (
|
|
316
|
-
<div
|
|
317
|
-
data-testid="coedit-banner"
|
|
318
|
-
className="mb-1.5 flex items-center gap-2 rounded-md border border-primary/30 bg-primary/10 px-2 py-1 text-xs"
|
|
319
|
-
>
|
|
320
|
-
<span className="relative flex h-1.5 w-1.5 shrink-0" aria-hidden="true">
|
|
321
|
-
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
|
|
322
|
-
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary" />
|
|
323
|
-
</span>
|
|
324
|
-
<span className="text-foreground">
|
|
325
|
-
<span className="font-medium">{holderName ?? "A teammate"}</span> is
|
|
326
|
-
writing — you are seeing their draft
|
|
327
|
-
</span>
|
|
328
|
-
<button
|
|
329
|
-
type="button"
|
|
330
|
-
data-testid="take-over"
|
|
331
|
-
onClick={onTakeOver}
|
|
332
|
-
className="ml-auto shrink-0 rounded border border-border px-1.5 py-0.5 font-medium text-foreground hover:bg-muted"
|
|
333
|
-
>
|
|
334
|
-
take over
|
|
335
|
-
</button>
|
|
336
|
-
</div>
|
|
337
|
-
)}
|
|
338
278
|
<textarea
|
|
339
279
|
ref={textareaRef}
|
|
340
280
|
data-testid="composer"
|
|
341
281
|
value={body}
|
|
342
|
-
disabled={
|
|
282
|
+
disabled={blocked}
|
|
343
283
|
onChange={(e) => handleChange(e.target.value)}
|
|
344
284
|
onKeyDown={handleKey}
|
|
345
285
|
onPaste={handlePaste}
|
|
@@ -349,13 +289,7 @@ export function SendBox({
|
|
|
349
289
|
"w-full resize-none rounded-md border bg-transparent p-2 text-sm shadow-sm",
|
|
350
290
|
"placeholder:text-muted-foreground focus-visible:outline-none",
|
|
351
291
|
"focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed",
|
|
352
|
-
|
|
353
|
-
// to `text-muted-foreground` made their sentence look like the
|
|
354
|
-
// placeholder it sits next to — the one styling choice that made
|
|
355
|
-
// co-editing appear not to work at all.
|
|
356
|
-
lockedByTeammate
|
|
357
|
-
? "border-primary/40 bg-primary/5 text-foreground"
|
|
358
|
-
: "border-input text-foreground disabled:bg-muted disabled:text-muted-foreground",
|
|
292
|
+
"border-input text-foreground disabled:bg-muted disabled:text-muted-foreground",
|
|
359
293
|
].join(" ")}
|
|
360
294
|
/>
|
|
361
295
|
<div className="mt-1 flex items-center justify-end gap-2">
|
|
@@ -419,6 +353,33 @@ export function SendBox({
|
|
|
419
353
|
{stopState === "requested" ? "stopping…" : "stop"}
|
|
420
354
|
</Button>
|
|
421
355
|
) : null}
|
|
356
|
+
{typingVisibility != null && onTypingVisibilityChange != null && (
|
|
357
|
+
<label className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
358
|
+
<span className="hidden sm:inline">Others see:</span>
|
|
359
|
+
<select
|
|
360
|
+
data-testid="typing-visibility"
|
|
361
|
+
value={typingVisibility}
|
|
362
|
+
title="Choose what others see of your message while you're still typing it."
|
|
363
|
+
onChange={(e) =>
|
|
364
|
+
onTypingVisibilityChange(e.target.value as TypingVisibility)
|
|
365
|
+
}
|
|
366
|
+
className={[
|
|
367
|
+
// bg-background, not bg-transparent: a native <select>'s
|
|
368
|
+
// OPTION list paints on the OS's own background, and a
|
|
369
|
+
// transparent trigger left the closed control's text
|
|
370
|
+
// sitting on whatever was behind it — unreadable in dark
|
|
371
|
+
// mode, where that was the page's own dark-on-dark text.
|
|
372
|
+
"rounded-md border border-input bg-background px-1.5 py-1 text-xs",
|
|
373
|
+
"text-foreground focus-visible:outline-none focus-visible:ring-1",
|
|
374
|
+
"focus-visible:ring-ring",
|
|
375
|
+
].join(" ")}
|
|
376
|
+
>
|
|
377
|
+
<option value="live">My text</option>
|
|
378
|
+
<option value="typing">Typing…</option>
|
|
379
|
+
<option value="hidden">Nothing</option>
|
|
380
|
+
</select>
|
|
381
|
+
</label>
|
|
382
|
+
)}
|
|
422
383
|
<Button
|
|
423
384
|
type="button"
|
|
424
385
|
size="sm"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { TypingRows } from "./TypingRows";
|
|
6
|
+
import type { PeerDraft } from "./protocol";
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
|
|
10
|
+
describe("TypingRows", () => {
|
|
11
|
+
it("renders nothing when nobody is typing", () => {
|
|
12
|
+
const { container } = render(<TypingRows peers={[]} />);
|
|
13
|
+
expect(container.firstChild).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("shows a peer's live words when a body is present", () => {
|
|
17
|
+
const peers: PeerDraft[] = [{ author: { id: 2, name: "Bo B" }, body: "hello there", at: null }];
|
|
18
|
+
render(<TypingRows peers={peers} />);
|
|
19
|
+
const row = screen.getByTestId("typing-row");
|
|
20
|
+
expect(row.textContent).toContain("Bo B");
|
|
21
|
+
expect(row.textContent).toContain("hello there");
|
|
22
|
+
expect(row.textContent).toContain("is typing:");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("renders 'is typing…' with no words when the body is withheld", () => {
|
|
26
|
+
const peers: PeerDraft[] = [{ author: { id: 2, name: "Bo B" }, body: "", at: null, typing: true }];
|
|
27
|
+
render(<TypingRows peers={peers} />);
|
|
28
|
+
const row = screen.getByTestId("typing-row");
|
|
29
|
+
expect(row.textContent).toContain("Bo B");
|
|
30
|
+
expect(row.textContent).toContain("is typing…");
|
|
31
|
+
expect(row.textContent).not.toContain("is typing:");
|
|
32
|
+
});
|
|
33
|
+
});
|