canopy-ui 0.12.1 → 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.
@@ -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("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.
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("from my teammate");
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("still blocks editing while a teammate holds the draft", () => {
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(true);
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 a teammate holds the draft", () => {
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
  />,
@@ -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. In
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), and the adopt rule below
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
- // The ONE case where the server genuinely knows better than this client:
162
- // somebody ELSE edited the shared draft. Our own echo is ignored, which is
163
- // also what stops two clients on one account (phone + desktop) from
164
- // fighting — each keeps its own text instead of clobbering the other.
165
- const theirEdit =
166
- draft != null && draft.last_editor !== currentUserId ? draft.body : null;
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 (theirEdit != null) setLocalBody(theirEdit);
169
- }, [theirEdit]);
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 unless a teammate is actively holding the draft.
172
- // It used to require `draft != null`, so the composer was disabled until
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" && canEdit && !blocked;
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: that echo carries last_editor === us, which the adopt rule
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
- : !canEdit
257
- ? "Another teammate is editing…"
258
- : !draft
259
- ? "Type a message… (connecting…)"
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={!canEdit || blocked}
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
- // A teammate's draft is CONTENT, not a disabled control. Dimming it
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
+ }