@saadnvd1/agent-os 0.3.481 → 0.3.482

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/app/globals.css CHANGED
@@ -245,6 +245,13 @@ body {
245
245
  background: hsl(var(--background));
246
246
  }
247
247
 
248
+ /* A conversation's rows stay in the page (so a selection can span them), and
249
+ the ones off screen skip layout and paint until they are near. */
250
+ .chat-row {
251
+ content-visibility: auto;
252
+ contain-intrinsic-size: auto 90px;
253
+ }
254
+
248
255
  /* Streaming chat text ends in a blinking caret. */
249
256
  .streaming-cursor .prose > :last-child::after {
250
257
  content: "▍";
@@ -1,8 +1,16 @@
1
1
  "use client";
2
2
 
3
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { LegendList, type LegendListRef } from "@legendapp/list/react";
3
+ import {
4
+ memo,
5
+ useCallback,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
5
12
  import { ArrowDown, Loader2 } from "lucide-react";
13
+ import type { ClipboardEvent } from "react";
6
14
  import { useChat } from "@/data/chat/useChat";
7
15
  import {
8
16
  groupTimeline,
@@ -42,6 +50,7 @@ import { SetupCard } from "./SetupCard";
42
50
  import { UserTurn } from "./EventRow";
43
51
  import { useViewport } from "@/hooks/useViewport";
44
52
  import { useChatCommands } from "./useChatCommands";
53
+ import { copySelection } from "./copy-selection";
45
54
  import { arrivalKey, useStickToBottom } from "./stick-to-bottom";
46
55
  import {
47
56
  AssistantMessage,
@@ -139,8 +148,6 @@ function Timeline({
139
148
  }
140
149
 
141
150
  const blockKey = (b: TimelineBlock) => (b.type === "item" ? b.item.id : b.id);
142
- const blockType = (b: TimelineBlock) =>
143
- b.type === "item" ? b.item.kind : b.type;
144
151
 
145
152
  // One block, re-rendered only when its items or the actions change: a long
146
153
  // conversation doesn't redraw every message for each streamed word.
@@ -173,6 +180,27 @@ const Block = memo(
173
180
  prev.actions === next.actions && sameBlock(prev.block, next.block)
174
181
  );
175
182
 
183
+ // The conversation's rows in the page's flow, not a virtualized list: a
184
+ // selection follows the DOM, and a list that recycles and re-orders its rows
185
+ // breaks it (docs/decisions/2026-10-11-chat-timeline-in-flow.md).
186
+ export function TimelineRows({
187
+ blocks,
188
+ actions,
189
+ }: {
190
+ blocks: TimelineBlock[];
191
+ actions: ItemActions;
192
+ }) {
193
+ return blocks.map((b) => (
194
+ <div
195
+ key={blockKey(b)}
196
+ data-block={blockKey(b)}
197
+ className="chat-row mx-auto w-full max-w-3xl px-4 pb-4"
198
+ >
199
+ <Block block={b} actions={actions} />
200
+ </div>
201
+ ));
202
+ }
203
+
176
204
  export function ChatPanel({
177
205
  sessionId,
178
206
  sessionName,
@@ -232,7 +260,7 @@ export function ChatPanel({
232
260
  } = useChat(sessionId, (text) => setPrefill({ text, at: Date.now() }));
233
261
  const { isMobile } = useViewport();
234
262
  const scrollRef = useRef<HTMLDivElement>(null);
235
- const listRef = useRef<LegendListRef>(null);
263
+ const listRef = useRef<HTMLDivElement>(null);
236
264
  // Follows new output only while the reader is at the end; sending brings
237
265
  // them back there, wherever they'd scrolled.
238
266
  const {
@@ -339,10 +367,34 @@ export function ChatPanel({
339
367
  () => ({ bodies: toolBodies, request: requestToolBody, connected }),
340
368
  [toolBodies, requestToolBody, connected]
341
369
  );
342
- const blocksRef = useRef(blocks);
370
+ const itemsById = useMemo(
371
+ () => new Map(items.map((i) => [i.id, i])),
372
+ [items]
373
+ );
374
+ const itemsByIdRef = useRef(itemsById);
375
+ useEffect(() => {
376
+ itemsByIdRef.current = itemsById;
377
+ });
378
+ const onCopy = useCallback(
379
+ (e: ClipboardEvent<HTMLDivElement>) =>
380
+ copySelection(e, itemsByIdRef.current),
381
+ []
382
+ );
383
+ // Starts at the end; older pages load as the reader nears the top.
384
+ useLayoutEffect(() => {
385
+ const el = listRef.current;
386
+ if (el) el.scrollTop = el.scrollHeight;
387
+ }, [sessionId]);
388
+ const olderRef = useRef({ hasMore, loadingOlder, loadOlder });
343
389
  useEffect(() => {
344
- blocksRef.current = blocks;
390
+ olderRef.current = { hasMore, loadingOlder, loadOlder };
345
391
  });
392
+ const onListScroll = useCallback(() => {
393
+ const el = listRef.current;
394
+ const o = olderRef.current;
395
+ if (el && o.hasMore && !o.loadingOlder && el.scrollTop < el.clientHeight)
396
+ o.loadOlder();
397
+ }, []);
346
398
  const find = useFind({
347
399
  search,
348
400
  items,
@@ -352,18 +404,13 @@ export function ChatPanel({
352
404
  rows: rowState,
353
405
  release,
354
406
  scrollToBlock: useCallback(async (key: string) => {
355
- const index = blocksRef.current.findIndex((b) => blockKey(b) === key);
356
- if (index !== -1)
357
- await listRef.current?.scrollToIndex({
358
- index,
359
- viewPosition: 0.3,
360
- animated: false,
361
- });
362
- }, []),
363
- scroller: useCallback(() => {
364
- const el = listRef.current?.getScrollableNode();
365
- return el instanceof HTMLElement ? el : null;
407
+ // The row may only just have been added.
408
+ await new Promise((r) => requestAnimationFrame(r));
409
+ listRef.current
410
+ ?.querySelector(`[data-block="${CSS.escape(key)}"]`)
411
+ ?.scrollIntoView({ block: "center" });
366
412
  }, []),
413
+ scroller: useCallback(() => listRef.current, []),
367
414
  });
368
415
  const activeRef = useRef(active);
369
416
  useEffect(() => {
@@ -423,56 +470,39 @@ export function ChatPanel({
423
470
  <div ref={scrollRef} className="relative min-h-0 flex-1">
424
471
  <RowState.Provider value={rowState}>
425
472
  <ToolBodies.Provider value={bodies}>
426
- <LegendList<TimelineBlock>
427
- // A new conversation starts its list over, scrolled to the end.
473
+ <div
474
+ // A new conversation starts over, scrolled to the end.
428
475
  key={sessionId}
429
476
  ref={listRef}
430
- data={blocks}
431
- extraData={actions}
432
- keyExtractor={blockKey}
433
- getItemType={blockType}
434
- renderItem={({ item }) => (
435
- <div
436
- data-block={blockKey(item)}
437
- className="mx-auto w-full max-w-3xl px-4 pb-4"
438
- >
439
- <Block block={item} actions={actions} />
440
- </div>
441
- )}
442
- estimatedItemSize={90}
443
- initialScrollAtEnd
444
- // Older pages land above without moving what the reader sees.
445
- maintainVisibleContentPosition
446
- onStartReached={hasMore ? loadOlder : undefined}
447
- onStartReachedThreshold={1}
448
- style={{ height: "100%" }}
449
- className="overscroll-y-contain"
450
- ListHeaderComponent={
451
- <div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pt-5 pb-4">
452
- {hasMore && (
453
- <div className="text-muted-foreground flex min-h-11 items-center justify-center text-xs">
454
- {loadingOlder ? (
455
- <Loader2 className="h-4 w-4 animate-spin" />
456
- ) : (
457
- <button
458
- type="button"
459
- onClick={loadOlder}
460
- className="hover:text-foreground min-h-11 px-3"
461
- >
462
- Show earlier messages
463
- </button>
464
- )}
465
- </div>
466
- )}
467
- <SetupCard sessionId={sessionId} />
468
- {blocks.length === 0 && !queue.length && (
469
- <p className="text-muted-foreground py-16 text-center text-sm">
470
- Ask anything to start.
471
- </p>
472
- )}
473
- </div>
474
- }
475
- />
477
+ onScroll={onListScroll}
478
+ onCopy={onCopy}
479
+ className="h-full overflow-y-auto overscroll-y-contain"
480
+ >
481
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pt-5 pb-4">
482
+ {hasMore && (
483
+ <div className="text-muted-foreground flex min-h-11 items-center justify-center text-xs">
484
+ {loadingOlder ? (
485
+ <Loader2 className="h-4 w-4 animate-spin" />
486
+ ) : (
487
+ <button
488
+ type="button"
489
+ onClick={loadOlder}
490
+ className="hover:text-foreground min-h-11 px-3"
491
+ >
492
+ Show earlier messages
493
+ </button>
494
+ )}
495
+ </div>
496
+ )}
497
+ <SetupCard sessionId={sessionId} />
498
+ {blocks.length === 0 && !queue.length && (
499
+ <p className="text-muted-foreground py-16 text-center text-sm">
500
+ Ask anything to start.
501
+ </p>
502
+ )}
503
+ </div>
504
+ <TimelineRows blocks={blocks} actions={actions} />
505
+ </div>
476
506
  </ToolBodies.Provider>
477
507
  </RowState.Provider>
478
508
  {find.open && (
@@ -119,7 +119,10 @@ export function CodeBlock({
119
119
  if (language === "mermaid") return <Mermaid code={code} />;
120
120
  return (
121
121
  <div className="bg-foreground/[0.04] not-prose my-3 overflow-hidden rounded-xl">
122
- <div className="text-muted-foreground flex items-center justify-between pr-1 pl-3 font-mono text-[11px]">
122
+ <div
123
+ data-copy-skip
124
+ className="text-muted-foreground flex items-center justify-between pr-1 pl-3 font-mono text-[11px]"
125
+ >
123
126
  <span>{language || "text"}</span>
124
127
  <CopyButton text={code} label="Copy code" />
125
128
  </div>
@@ -34,12 +34,12 @@ export function UserMessage({
34
34
  return (
35
35
  <div className="group flex flex-col items-end gap-1">
36
36
  {item.from && (
37
- <span className="label-mono text-muted-foreground">
37
+ <span data-copy-skip className="label-mono text-muted-foreground">
38
38
  from {item.from}
39
39
  </span>
40
40
  )}
41
41
  {item.images?.length ? (
42
- <div className="flex flex-wrap justify-end gap-1.5">
42
+ <div data-copy-skip className="flex flex-wrap justify-end gap-1.5">
43
43
  {item.images.map((img, i) => (
44
44
  <ImageThumb
45
45
  key={i}
@@ -0,0 +1,82 @@
1
+ import type { ClipboardEvent } from "react";
2
+ import type { ChatItem } from "@/lib/chat/events";
3
+ import {
4
+ formatTranscript,
5
+ sourceText,
6
+ speakerOf,
7
+ type TranscriptPart,
8
+ } from "@/lib/chat/copy-selection";
9
+
10
+ // What isn't part of a message's words: buttons, times, and labels that
11
+ // only the interface shows.
12
+ const SKIP = "button, time, [aria-hidden='true'], [data-copy-skip]";
13
+
14
+ // Range's own constants, which tests without a browser global don't have.
15
+ const START_TO_START = 0;
16
+ const END_TO_END = 2;
17
+
18
+ const covers = (range: Range, el: Element) => {
19
+ const whole = el.ownerDocument.createRange();
20
+ whole.selectNodeContents(el);
21
+ return (
22
+ range.compareBoundaryPoints(START_TO_START, whole) <= 0 &&
23
+ range.compareBoundaryPoints(END_TO_END, whole) >= 0
24
+ );
25
+ };
26
+
27
+ // The part of a message inside the selection, as it reads on screen.
28
+ function clippedText(range: Range, el: Element): string {
29
+ const doc = el.ownerDocument;
30
+ const whole = doc.createRange();
31
+ whole.selectNodeContents(el);
32
+ const part = range.cloneRange();
33
+ if (part.compareBoundaryPoints(START_TO_START, whole) < 0)
34
+ part.setStart(whole.startContainer, whole.startOffset);
35
+ if (part.compareBoundaryPoints(END_TO_END, whole) > 0)
36
+ part.setEnd(whole.endContainer, whole.endOffset);
37
+ const holder = doc.createElement("div");
38
+ holder.style.cssText =
39
+ "position:fixed;left:-9999px;top:0;width:600px;white-space:pre-wrap";
40
+ holder.append(part.cloneContents());
41
+ for (const skip of holder.querySelectorAll(SKIP)) skip.remove();
42
+ doc.body.append(holder);
43
+ const text = holder.innerText ?? holder.textContent ?? "";
44
+ holder.remove();
45
+ return text.replace(/\n{3,}/g, "\n\n");
46
+ }
47
+
48
+ /**
49
+ * Copy across several messages as a transcript: who said what, a blank line
50
+ * between. A selection inside one message is left to the browser, which
51
+ * already copies that cleanly.
52
+ */
53
+ export function copySelection(
54
+ e: ClipboardEvent,
55
+ itemsById: ReadonlyMap<string, ChatItem>
56
+ ) {
57
+ const root = e.currentTarget as Element;
58
+ const sel = root.ownerDocument.getSelection();
59
+ if (!sel || sel.isCollapsed || !sel.rangeCount) return;
60
+ const ranges = Array.from({ length: sel.rangeCount }, (_, i) =>
61
+ sel.getRangeAt(i)
62
+ );
63
+ const parts: (TranscriptPart & { el: Element })[] = [];
64
+ for (const el of root.querySelectorAll("[data-item]")) {
65
+ const item = itemsById.get(el.getAttribute("data-item") ?? "");
66
+ const speaker = item && speakerOf(item);
67
+ if (!item || !speaker) continue;
68
+ const hit = ranges.filter((r) => r.intersectsNode(el));
69
+ if (!hit.length) continue;
70
+ const whole = hit.some((r) => covers(r, el));
71
+ parts.push({
72
+ el,
73
+ speaker,
74
+ text: whole
75
+ ? sourceText(item)
76
+ : hit.map((r) => clippedText(r, el)).join("\n"),
77
+ });
78
+ }
79
+ if (parts.length < 2) return;
80
+ e.clipboardData.setData("text/plain", formatTranscript(parts));
81
+ e.preventDefault();
82
+ }
@@ -219,15 +219,75 @@ describe("stickToBottom", () => {
219
219
  expect(box.top).toBe(top);
220
220
  });
221
221
 
222
- it("doesn't count a press inside the content as a scrollbar drag", () => {
222
+ it("doesn't count a click inside the content as a scrollbar drag", () => {
223
223
  const row = document.createElement("p");
224
224
  el.append(row);
225
225
  row.dispatchEvent(new window.Event("pointerdown", { bubbles: true }));
226
+ window.dispatchEvent(new window.Event("pointerup"));
227
+ clock += 300;
226
228
  box.height -= 300;
227
229
  el.scrollTop = box.top - 50; // moved by the page, not the reader
228
230
  expect(ctl.isStuck()).toBe(true);
229
231
  });
230
232
 
233
+ describe("while text is being selected", () => {
234
+ const text = () => {
235
+ const p = document.createElement("p");
236
+ p.textContent = "some words to select";
237
+ el.append(p);
238
+ return p;
239
+ };
240
+ const select = (node: Node) => {
241
+ const sel = document.getSelection()!;
242
+ sel.removeAllRanges();
243
+ const r = document.createRange();
244
+ r.selectNodeContents(node);
245
+ sel.addRange(r);
246
+ };
247
+ afterEach(() => document.getSelection()?.removeAllRanges());
248
+
249
+ it("doesn't pin under a drag in progress, and catches up when it ends", () => {
250
+ const p = text();
251
+ p.dispatchEvent(new window.Event("pointerdown", { bubbles: true }));
252
+ const top = box.top;
253
+ grow(300);
254
+ expect(box.top).toBe(top);
255
+ window.dispatchEvent(new window.Event("pointerup"));
256
+ expect(box.top).toBe(box.height - box.view);
257
+ });
258
+
259
+ it("doesn't pin under a standing selection, and catches up when it clears", () => {
260
+ select(text());
261
+ const top = box.top;
262
+ grow(300);
263
+ expect(box.top).toBe(top);
264
+ expect(ctl.isStuck()).toBe(true);
265
+ document.getSelection()!.removeAllRanges();
266
+ document.dispatchEvent(new window.Event("selectionchange"));
267
+ expect(box.top).toBe(box.height - box.view);
268
+ });
269
+
270
+ it("pins as usual for a selection elsewhere on the page", () => {
271
+ const outside = document.createElement("p");
272
+ outside.textContent = "composer text";
273
+ document.body.append(outside);
274
+ select(outside);
275
+ grow(300);
276
+ expect(box.top).toBe(box.height - box.view);
277
+ outside.remove();
278
+ });
279
+
280
+ it("lets go when the drag autoscrolls up", () => {
281
+ text().dispatchEvent(new window.Event("pointerdown", { bubbles: true }));
282
+ el.scrollTop -= 200;
283
+ expect(ctl.isStuck()).toBe(false);
284
+ window.dispatchEvent(new window.Event("pointerup"));
285
+ const top = box.top;
286
+ grow(300);
287
+ expect(box.top).toBe(top);
288
+ });
289
+ });
290
+
231
291
  it("stops counting scrolls as the reader's once the drag ends", () => {
232
292
  el.dispatchEvent(new window.Event("pointerdown"));
233
293
  window.dispatchEvent(new window.Event("pointerup"));
@@ -309,7 +369,7 @@ describe("useStickToBottom", () => {
309
369
  let hook: Hook;
310
370
  let el: HTMLElement;
311
371
  let box: { top: number; height: number; view: number };
312
- let list: RefObject<{ getScrollableNode(): unknown } | null>;
372
+ let list: RefObject<HTMLElement | null>;
313
373
 
314
374
  function Probe(props: {
315
375
  id: string;
@@ -329,7 +389,7 @@ describe("useStickToBottom", () => {
329
389
  beforeEach(async () => {
330
390
  ({ el, box } = scroller());
331
391
  list = createRef();
332
- (list as { current: unknown }).current = { getScrollableNode: () => el };
392
+ (list as { current: unknown }).current = el;
333
393
  root = createRoot(document.createElement("div"));
334
394
  await render("s1", "a:0");
335
395
  });
@@ -371,12 +431,12 @@ describe("useStickToBottom", () => {
371
431
  expect(box.top).toBe(box.height - box.view);
372
432
  });
373
433
 
374
- it("binds once the list has mounted its scroller", async () => {
434
+ it("binds once the scroller has mounted", async () => {
375
435
  act(() => root.unmount());
376
436
  (list as { current: unknown }).current = null;
377
437
  root = createRoot(document.createElement("div"));
378
438
  await render("s1", "a:0");
379
- (list as { current: unknown }).current = { getScrollableNode: () => el };
439
+ (list as { current: unknown }).current = el;
380
440
  await frame();
381
441
  scrollUp();
382
442
  expect(hook.stuck).toBe(false);
@@ -8,9 +8,14 @@
8
8
  * Content growing, rows expanding and images loading never let go, since only
9
9
  * the reader's own scrolling does. While they're away from the end, the text
10
10
  * under their eyes is held in place the way CSS scroll anchoring would (the
11
- * virtualized list turns that off, and Safari lacks it): the element at the
12
- * top of the view is remembered, and when a layout change moves it, the
13
- * scroll moves by the same amount.
11
+ * list turns that off, and Safari lacks it): the element at the top of the
12
+ * view is remembered, and when a layout change moves it, the scroll moves by
13
+ * the same amount.
14
+ *
15
+ * Nothing is pinned while the reader is selecting text in the conversation
16
+ * (a drag in progress, or a selection left standing): moving the view under a
17
+ * selection drags its end along or loses it. The catch-up happens once the
18
+ * selection is gone.
14
19
  */
15
20
 
16
21
  import {
@@ -81,6 +86,9 @@ export function stickToBottom(
81
86
  let touching = false;
82
87
  let touchY = 0;
83
88
  let dragging = false;
89
+ // A press inside the content: a text selection drag, or a tap.
90
+ let pressing = false;
91
+ let wasSelecting = false;
84
92
  let anchors: { node: Element; top: number }[] = [];
85
93
  const doc = el.ownerDocument;
86
94
  const win = doc.defaultView;
@@ -120,8 +128,18 @@ export function stickToBottom(
120
128
  }
121
129
  pickAnchors();
122
130
  };
131
+ const selecting = () => {
132
+ if (pressing) return true;
133
+ const sel = doc.getSelection?.();
134
+ if (!sel || sel.isCollapsed || !sel.rangeCount) return false;
135
+ return el.contains(sel.anchorNode) || el.contains(sel.focusNode);
136
+ };
123
137
  const pin = () => {
124
138
  if (!stuck) return holdAnchor();
139
+ if (selecting()) {
140
+ wasSelecting = true;
141
+ return;
142
+ }
125
143
  const end = el.scrollHeight - el.clientHeight;
126
144
  if (el.scrollTop < end) el.scrollTop = end;
127
145
  prevTop = el.scrollTop;
@@ -134,7 +152,7 @@ export function stickToBottom(
134
152
  };
135
153
 
136
154
  const onScroll = () => {
137
- const byUser = touching || dragging || now() < intentUntil;
155
+ const byUser = touching || dragging || pressing || now() < intentUntil;
138
156
  set(nextStuck(stuck, { prevTop, metrics: el, byUser }));
139
157
  prevTop = el.scrollTop;
140
158
  pickAnchors();
@@ -165,11 +183,21 @@ export function stickToBottom(
165
183
  // Only a press on the container itself is its scrollbar.
166
184
  const onPointerDown = (e: PointerEvent) => {
167
185
  if (e.target === el) dragging = true;
186
+ else pressing = true;
168
187
  };
169
188
  const onPointerUp = () => {
170
- if (!dragging) return;
189
+ const was = dragging || pressing;
171
190
  dragging = false;
191
+ pressing = false;
192
+ if (!was) return;
172
193
  intentUntil = now() + INTENT_MS;
194
+ catchUp();
195
+ };
196
+ // Following resumes the moment the selection that held it back is gone.
197
+ const catchUp = () => {
198
+ const live = selecting();
199
+ if (wasSelecting && !live && stuck) pin();
200
+ wasSelecting = live;
173
201
  };
174
202
 
175
203
  const passive = { passive: true } as const;
@@ -182,11 +210,12 @@ export function stickToBottom(
182
210
  el.addEventListener("keydown", onKeyDown);
183
211
  el.addEventListener("pointerdown", onPointerDown, passive);
184
212
  win?.addEventListener("pointerup", onPointerUp, passive);
213
+ win?.addEventListener("pointercancel", onPointerUp, passive);
214
+ doc.addEventListener("selectionchange", catchUp);
185
215
 
186
216
  // Content or the container changing size keeps a stuck reader at the end.
187
- // The rows are watched too (the list's are three levels down, and come and
188
- // go as it virtualizes), so a row growing is corrected before it's painted
189
- // rather than a frame later, when the list catches up.
217
+ // The rows are watched too, so a row growing is corrected before it's
218
+ // painted rather than a frame later.
190
219
  const resize =
191
220
  typeof ResizeObserver === "undefined"
192
221
  ? null
@@ -196,7 +225,7 @@ export function stickToBottom(
196
225
  });
197
226
  function observeRows() {
198
227
  for (const row of el.querySelectorAll(
199
- ":scope > *, :scope > * > *, :scope > * > * > *, :scope > * > * > * > *"
228
+ ":scope > *, :scope > * > *, :scope > * > * > *"
200
229
  ))
201
230
  resize?.observe(row);
202
231
  }
@@ -227,18 +256,20 @@ export function stickToBottom(
227
256
  el.removeEventListener("keydown", onKeyDown);
228
257
  el.removeEventListener("pointerdown", onPointerDown);
229
258
  win?.removeEventListener("pointerup", onPointerUp);
259
+ win?.removeEventListener("pointercancel", onPointerUp);
260
+ doc.removeEventListener("selectionchange", catchUp);
230
261
  },
231
262
  };
232
263
  }
233
264
 
234
265
  /**
235
- * The hook over `stickToBottom`, for a list that exposes its scroll
236
- * container (`key` re-binds when it's replaced); `latest` changes whenever
266
+ * The hook over `stickToBottom`, for a scroll container in a ref (`key`
267
+ * re-binds when it's replaced); `latest` changes whenever
237
268
  * something new arrives, which pins a stuck reader and marks it unread for
238
269
  * one who has scrolled away.
239
270
  */
240
271
  export function useStickToBottom(
241
- list: RefObject<{ getScrollableNode(): unknown } | null>,
272
+ scroller: RefObject<HTMLElement | null>,
242
273
  key: unknown,
243
274
  latest: unknown
244
275
  ) {
@@ -249,10 +280,9 @@ export function useStickToBottom(
249
280
 
250
281
  useEffect(() => {
251
282
  let frame = 0;
252
- // The list mounts its scroller a frame after it renders.
253
283
  const bind = () => {
254
- const el = list.current?.getScrollableNode();
255
- if (!(el instanceof HTMLElement)) {
284
+ const el = scroller.current;
285
+ if (!el) {
256
286
  frame = requestAnimationFrame(bind);
257
287
  return;
258
288
  }
@@ -267,7 +297,7 @@ export function useStickToBottom(
267
297
  ctl.current = null;
268
298
  setAway(null);
269
299
  };
270
- }, [list, key]);
300
+ }, [scroller, key]);
271
301
 
272
302
  useEffect(() => {
273
303
  latestRef.current = latest;
@@ -0,0 +1,146 @@
1
+ import "./composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { ChatItem } from "@/lib/chat/events";
6
+ import { groupTimeline } from "@/lib/chat/group";
7
+ import { copySelection } from "./copy-selection";
8
+ import { TimelineRows } from "./ChatPanel";
9
+
10
+ (
11
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
12
+ ).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ const user = (id: string, text: string): ChatItem => ({
15
+ id,
16
+ kind: "user",
17
+ createdAt: 1,
18
+ text,
19
+ tagged: true,
20
+ });
21
+ const reply = (id: string, text: string, streaming = false): ChatItem => ({
22
+ id,
23
+ kind: "assistant",
24
+ createdAt: 2,
25
+ text,
26
+ streaming,
27
+ });
28
+
29
+ describe("the conversation's rows", () => {
30
+ let host: HTMLElement;
31
+ let root: Root;
32
+ const actions = () => ({ respond: vi.fn() }) as never;
33
+ const show = (items: ChatItem[], a = actions()) =>
34
+ act(() =>
35
+ root.render(
36
+ createElement(TimelineRows, {
37
+ blocks: groupTimeline(items),
38
+ actions: a,
39
+ })
40
+ )
41
+ );
42
+ const rows = () => [...host.querySelectorAll("[data-block]")];
43
+
44
+ beforeEach(() => {
45
+ host = document.createElement("div");
46
+ document.body.append(host);
47
+ root = createRoot(host);
48
+ });
49
+ afterEach(() => {
50
+ act(() => root.unmount());
51
+ host.remove();
52
+ document.getSelection()?.removeAllRanges();
53
+ });
54
+
55
+ it("keeps a selection across messages through streaming updates", async () => {
56
+ const items = [
57
+ user("u1", "first"),
58
+ reply("a1", "second", true),
59
+ user("u2", "third"),
60
+ ];
61
+ await show(items);
62
+ const [r1, , r3] = rows();
63
+ const range = document.createRange();
64
+ range.setStart(r1, 0);
65
+ range.setEnd(r3, r3.childNodes.length);
66
+ document.getSelection()!.removeAllRanges();
67
+ document.getSelection()!.addRange(range);
68
+
69
+ await show([
70
+ items[0],
71
+ reply("a1", "second, and more words streamed in", true),
72
+ items[2],
73
+ reply("a2", "a new reply at the end", true),
74
+ ]);
75
+ await show(
76
+ [
77
+ items[0],
78
+ reply("a1", "second, and more words streamed in", false),
79
+ items[2],
80
+ reply("a2", "a new reply at the end, done", false),
81
+ ],
82
+ actions()
83
+ );
84
+
85
+ const after = rows();
86
+ expect(after).toHaveLength(4);
87
+ expect(after[0]).toBe(r1);
88
+ expect(after[2]).toBe(r3);
89
+ expect(r1.isConnected && r3.isConnected).toBe(true);
90
+ expect(after.map((r) => r.getAttribute("data-block"))).toEqual([
91
+ "u1",
92
+ "a1",
93
+ "u2",
94
+ "a2",
95
+ ]);
96
+ const sel = document.getSelection()!;
97
+ expect(sel.rangeCount).toBe(1);
98
+ expect(sel.isCollapsed).toBe(false);
99
+ expect(sel.getRangeAt(0).startContainer).toBe(r1);
100
+ expect(sel.getRangeAt(0).endContainer).toBe(r3);
101
+ });
102
+
103
+ describe("copying", () => {
104
+ const copy = (items: ChatItem[]) => {
105
+ const data: Record<string, string> = {};
106
+ const e = {
107
+ currentTarget: host,
108
+ clipboardData: { setData: (t: string, v: string) => (data[t] = v) },
109
+ preventDefault: vi.fn(),
110
+ };
111
+ copySelection(e as never, new Map(items.map((i) => [i.id, i])));
112
+ return { data, prevented: e.preventDefault.mock.calls.length > 0 };
113
+ };
114
+
115
+ it("gives speakers and blank lines across messages, from their source", async () => {
116
+ const items = [
117
+ user("u1", "fix `the` bug"),
118
+ reply("a1", "Done.\n\n- one\n- two"),
119
+ user("u2", "thanks"),
120
+ ];
121
+ await show(items);
122
+ const [r1, , r3] = rows();
123
+ const range = document.createRange();
124
+ range.selectNodeContents(host);
125
+ range.setStart(r1, 0);
126
+ range.setEnd(r3, r3.childNodes.length);
127
+ document.getSelection()!.addRange(range);
128
+ const { data, prevented } = copy(items);
129
+ expect(prevented).toBe(true);
130
+ expect(data["text/plain"]).toBe(
131
+ "User:\nfix `the` bug\n\nAssistant:\nDone.\n\n- one\n- two\n\nUser:\nthanks"
132
+ );
133
+ });
134
+
135
+ it("leaves a selection inside one message to the browser", async () => {
136
+ const items = [user("u1", "first"), reply("a1", "second words")];
137
+ await show(items);
138
+ const text = rows()[1].querySelector("p")!.firstChild!;
139
+ const range = document.createRange();
140
+ range.setStart(text, 1);
141
+ range.setEnd(text, 4);
142
+ document.getSelection()!.addRange(range);
143
+ expect(copy(items)).toEqual({ data: {}, prevented: false });
144
+ });
145
+ });
146
+ });
@@ -0,0 +1,58 @@
1
+ import type { ChatItem } from "./events";
2
+ import { eventParts, originOf } from "./origin";
3
+
4
+ export interface TranscriptPart {
5
+ speaker: string;
6
+ text: string;
7
+ }
8
+
9
+ // Who a message is from when copied, or null for rows that aren't part of the
10
+ // conversation itself (tools, turn markers, plans, approvals...).
11
+ export function speakerOf(item: ChatItem): string | null {
12
+ switch (item.kind) {
13
+ case "user": {
14
+ const origin = originOf(item);
15
+ if (!origin) return "User";
16
+ return origin.kind === "decision" ? "Decision" : origin.label;
17
+ }
18
+ case "assistant":
19
+ return "Assistant";
20
+ case "error":
21
+ return "Error";
22
+ default:
23
+ return null;
24
+ }
25
+ }
26
+
27
+ // The whole message as written, for one a selection covers entirely.
28
+ export function sourceText(item: ChatItem): string {
29
+ switch (item.kind) {
30
+ case "user": {
31
+ const origin = originOf(item);
32
+ if (!origin) return item.text;
33
+ const { body, decided } = eventParts(item, origin);
34
+ return [...decided, body].join("\n\n");
35
+ }
36
+ case "assistant":
37
+ return item.text;
38
+ case "error":
39
+ return item.message;
40
+ default:
41
+ return "";
42
+ }
43
+ }
44
+
45
+ // `Speaker:` then the text, a blank line between messages. Neighbours from
46
+ // the same speaker (a reply split around its tools) read as one message.
47
+ export function formatTranscript(parts: TranscriptPart[]): string {
48
+ const merged: TranscriptPart[] = [];
49
+ for (const p of parts) {
50
+ const text = p.text.trim();
51
+ if (!text) continue;
52
+ const last = merged.at(-1);
53
+ if (last && last.speaker === p.speaker)
54
+ last.text = `${last.text}\n\n${text}`;
55
+ else merged.push({ speaker: p.speaker, text });
56
+ }
57
+ return merged.map((p) => `${p.speaker}:\n${p.text}`).join("\n\n");
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.481",
3
+ "version": "0.3.482",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -83,7 +83,6 @@
83
83
  "@codemirror/language": "^6.12.3",
84
84
  "@codemirror/state": "^6.6.0",
85
85
  "@codemirror/view": "^6.41.0",
86
- "@legendapp/list": "3.3.11",
87
86
  "@lezer/highlight": "^1.2.3",
88
87
  "@modelcontextprotocol/sdk": "^1.25.2",
89
88
  "@monaco-editor/react": "^4.7.0",