canopy-ui 0.12.2 → 0.13.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 +202 -0
- package/src/chat/ChatPanel.tsx +67 -40
- 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 +111 -37
- package/src/chat/SendBox.tsx +32 -108
- package/src/chat/TypingRows.tsx +18 -0
- package/src/chat/agui.fixture.json +78 -23
- package/src/chat/agui.ts +6 -1
- package/src/chat/drafts.test.ts +1 -65
- package/src/chat/drafts.ts +0 -20
- package/src/chat/identity.ts +37 -0
- package/src/chat/index.ts +7 -3
- package/src/chat/protocol.ts +77 -5
- package/src/chat/sessionReducer.test.ts +121 -0
- package/src/chat/sessionReducer.ts +61 -20
- package/src/chat/useSessionSocket.http.test.tsx +50 -1
- package/src/chat/useSessionSocket.ts +87 -16
|
@@ -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
|
/>,
|
package/src/chat/SendBox.tsx
CHANGED
|
@@ -11,8 +11,6 @@ import type { Draft } 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;
|
|
@@ -77,16 +68,12 @@ interface Props {
|
|
|
77
68
|
export function SendBox({
|
|
78
69
|
draft,
|
|
79
70
|
connected,
|
|
80
|
-
currentUserId,
|
|
81
|
-
holderIsPresent,
|
|
82
|
-
holderName,
|
|
83
71
|
isStreaming,
|
|
84
72
|
streamingMessageId,
|
|
85
73
|
onUpdate,
|
|
86
74
|
onSend,
|
|
87
75
|
onStop,
|
|
88
76
|
stopState,
|
|
89
|
-
onTakeOver,
|
|
90
77
|
banner,
|
|
91
78
|
disabledReason,
|
|
92
79
|
attachments,
|
|
@@ -96,36 +83,16 @@ export function SendBox({
|
|
|
96
83
|
storage,
|
|
97
84
|
}: Props) {
|
|
98
85
|
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
86
|
|
|
117
87
|
// LOCAL-FIRST: the textarea's value is local state, never server state.
|
|
118
88
|
// Rendering `draft.body` directly made every inbound frame a chance to
|
|
119
89
|
// overwrite the user mid-keystroke — a stale echo of your own debounced
|
|
120
90
|
// 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.
|
|
91
|
+
// wholesale), would rewind the composer to a body from 150ms ago.
|
|
124
92
|
//
|
|
125
93
|
// Seeded from persisted storage when there is one, because a body typed
|
|
126
94
|
// 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
|
|
95
|
+
// the server (see drafts.shouldSyncDraftLive). A stored body wins over
|
|
129
96
|
// `draft.body` — it is strictly newer, being what was in the box when we
|
|
130
97
|
// last left it.
|
|
131
98
|
const store = storage === undefined ? defaultDraftStorage() : storage;
|
|
@@ -141,11 +108,6 @@ export function SendBox({
|
|
|
141
108
|
const persist = (body: string) => {
|
|
142
109
|
if (persistKey) writeStoredDraft(store, persistKey, body);
|
|
143
110
|
};
|
|
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
111
|
|
|
150
112
|
// The panel can swap sessions without remounting (same route, new :id), so
|
|
151
113
|
// the box must follow the key rather than carry one session's text into the
|
|
@@ -158,46 +120,43 @@ export function SendBox({
|
|
|
158
120
|
setLocalBody(readStoredDraft(store, persistKey ?? "") ?? "");
|
|
159
121
|
}
|
|
160
122
|
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
123
|
+
// A draft with `id === "local"` is never a co-editor's — it is the LOCAL
|
|
124
|
+
// stand-in a host keeps for a principal with no server-side draft (a
|
|
125
|
+
// contact's `useSessionSocket.sendOverHttp` path, or the widget's start
|
|
126
|
+
// screen `contactDraft`). Its body is always either an echo of what THIS
|
|
127
|
+
// box just typed (harmless to mirror back) or a failed send being restored
|
|
128
|
+
// after `handleSend` already cleared the box — the one case with no other
|
|
129
|
+
// way to reach the screen, since sending clears `localBody` optimistically
|
|
130
|
+
// rather than waiting for the round trip. Scoped to `id === "local"` so a
|
|
131
|
+
// real multiplayer draft (a server-assigned id) is never adopted this way —
|
|
132
|
+
// that was the deleted `theirEdit` effect's mistake, which adopted ANY
|
|
133
|
+
// other editor's draft and could overwrite what you were mid-typing.
|
|
167
134
|
useEffect(() => {
|
|
168
|
-
if (
|
|
169
|
-
|
|
135
|
+
if (draft?.id === "local" && draft.body !== localBody) {
|
|
136
|
+
setLocalBody(draft.body);
|
|
137
|
+
}
|
|
138
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
139
|
+
}, [draft?.id, draft?.body]);
|
|
170
140
|
|
|
171
|
-
// Typing is ALWAYS allowed
|
|
172
|
-
//
|
|
141
|
+
// Typing is ALWAYS allowed now — this box is only ever YOUR OWN draft, and
|
|
142
|
+
// every editor gets one (see protocol.ts::SessionState.active_draft). It
|
|
143
|
+
// used to require `draft != null`, so the composer was disabled until
|
|
173
144
|
// session.state landed — locking you out of your own input on first paint
|
|
174
145
|
// and again on every reconnect. Keystrokes typed early are held locally and
|
|
175
146
|
// 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
147
|
const body = localBody;
|
|
185
148
|
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
149
|
// Sending needs a draft (`chat.send` commits the SERVER's copy, so there must
|
|
191
150
|
// be one) AND a live socket. The socket check is load-bearing now that the
|
|
192
151
|
// composer clears optimistically: `send()` drops every frame but chat.stop
|
|
193
152
|
// when the socket is closed, so an allowed-but-undeliverable send would clear
|
|
194
|
-
// the box and lose the message outright.
|
|
153
|
+
// the box and lose the message outright. Sending stays available while the
|
|
154
|
+
// agent is replying — a send made mid-reply is QUEUED, not blocked, and
|
|
155
|
+
// <QueuedRows> is what shows it landed.
|
|
195
156
|
const canSend =
|
|
196
|
-
canEdit &&
|
|
197
157
|
connected &&
|
|
198
158
|
draft != null &&
|
|
199
159
|
body.trim().length > 0 &&
|
|
200
|
-
!isStreaming &&
|
|
201
160
|
!blocked;
|
|
202
161
|
|
|
203
162
|
const handleChange = (value: string) => {
|
|
@@ -206,7 +165,7 @@ export function SendBox({
|
|
|
206
165
|
onUpdate(value);
|
|
207
166
|
};
|
|
208
167
|
|
|
209
|
-
const canAttach = typeof onAttach === "function" &&
|
|
168
|
+
const canAttach = typeof onAttach === "function" && !blocked;
|
|
210
169
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
211
170
|
const [dragging, setDragging] = useState(false);
|
|
212
171
|
|
|
@@ -227,8 +186,7 @@ export function SendBox({
|
|
|
227
186
|
|
|
228
187
|
const handleSend = () => {
|
|
229
188
|
// 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.
|
|
189
|
+
// echo back — a round trip the person sending has no reason to wait on.
|
|
232
190
|
setLocalBody("");
|
|
233
191
|
if (persistKey) clearStoredDraft(store, persistKey);
|
|
234
192
|
onSend();
|
|
@@ -253,10 +211,10 @@ export function SendBox({
|
|
|
253
211
|
|
|
254
212
|
const placeholder = blocked
|
|
255
213
|
? disabledReason
|
|
256
|
-
: !
|
|
257
|
-
? "
|
|
258
|
-
:
|
|
259
|
-
? "Type a message
|
|
214
|
+
: !draft
|
|
215
|
+
? "Type a message… (connecting…)"
|
|
216
|
+
: isStreaming
|
|
217
|
+
? "Type a message — it will be sent after the current reply"
|
|
260
218
|
: "Type a message… (Enter to send, Shift+Enter for newline)";
|
|
261
219
|
|
|
262
220
|
const staged = attachments ?? [];
|
|
@@ -307,39 +265,11 @@ export function SendBox({
|
|
|
307
265
|
))}
|
|
308
266
|
</ul>
|
|
309
267
|
)}
|
|
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
268
|
<textarea
|
|
339
269
|
ref={textareaRef}
|
|
340
270
|
data-testid="composer"
|
|
341
271
|
value={body}
|
|
342
|
-
disabled={
|
|
272
|
+
disabled={blocked}
|
|
343
273
|
onChange={(e) => handleChange(e.target.value)}
|
|
344
274
|
onKeyDown={handleKey}
|
|
345
275
|
onPaste={handlePaste}
|
|
@@ -349,13 +279,7 @@ export function SendBox({
|
|
|
349
279
|
"w-full resize-none rounded-md border bg-transparent p-2 text-sm shadow-sm",
|
|
350
280
|
"placeholder:text-muted-foreground focus-visible:outline-none",
|
|
351
281
|
"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",
|
|
282
|
+
"border-input text-foreground disabled:bg-muted disabled:text-muted-foreground",
|
|
359
283
|
].join(" ")}
|
|
360
284
|
/>
|
|
361
285
|
<div className="mt-1 flex items-center justify-end gap-2">
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PeerDraft } from "./protocol";
|
|
2
|
+
|
|
3
|
+
/** Everyone else's box, live — the multiplayer half of the composer. One row
|
|
4
|
+
* per person, newest edit last, right above your own box. */
|
|
5
|
+
export function TypingRows({ peers }: { peers: PeerDraft[] }) {
|
|
6
|
+
if (peers.length === 0) return null;
|
|
7
|
+
return (
|
|
8
|
+
<ul className="space-y-1 border-t border-border bg-background px-3 py-2" aria-live="polite">
|
|
9
|
+
{peers.map((p) => (
|
|
10
|
+
<li key={p.author.id} data-testid="typing-row" className="flex min-w-0 gap-2 text-xs">
|
|
11
|
+
<span className="shrink-0 font-medium text-foreground">{p.author.name}</span>
|
|
12
|
+
<span className="shrink-0 text-muted-foreground">is typing:</span>
|
|
13
|
+
<span className="min-w-0 truncate italic text-foreground-secondary">{p.body}</span>
|
|
14
|
+
</li>
|
|
15
|
+
))}
|
|
16
|
+
</ul>
|
|
17
|
+
);
|
|
18
|
+
}
|