@saadnvd1/agent-os 0.3.479 → 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/README.md CHANGED
@@ -745,6 +745,22 @@ next minute until the brake lifts or a newer run is due (Run now obeys them
745
745
  too, once). A schedule whose project has moved to another workspace fails
746
746
  until it's edited. Removing a schedule keeps its history.
747
747
 
748
+ **One-off schedules.** Pick **Once** in the form (a date and time in the
749
+ schedule's zone), or `aos schedule add --at "tomorrow 10am" ...` (an ISO time,
750
+ `in 2h`, a weekday; America/Chicago unless `--tz`). It works with every kind,
751
+ runs once and then turns itself off; the run stays in its history, marked
752
+ done, and the schedule moves to the **Done** group. Upcoming one-offs are
753
+ listed first with their run time, how far off it is and who made it (you or an
754
+ orchestrator), each with Run now, Edit and Cancel; the count shows in the
755
+ sidebar menu and the orchestrator's header. The same claim-in-SQLite rule
756
+ applies, so it never runs twice, and one AgentOS missed while off runs when it
757
+ returns, marked **caught up**. Pause holds a one-off until you resume rather
758
+ than skipping it, and the brakes apply as for any run. A past time is refused.
759
+ Run now uses up its only run; edit it to a new time to run it again. An
760
+ orchestrator makes its own with `remind_me`: at least ten minutes ahead, 20
761
+ pending at most, in its own workspace, and it arrives as a scheduled prompt,
762
+ never an approval.
763
+
748
764
  Task and session schedules run at most hourly, message schedules at most
749
765
  every 10 minutes and skip a run (`still working`) while their session is
750
766
  still busy, mid-turn or holding a queued message. A task schedule waits
@@ -993,7 +1009,7 @@ it may do on its own, and the lines that always come back to you as asks.
993
1009
  workspace, or the project already at that path moves in from no workspace;
994
1010
  folders that hold secrets and projects of another workspace are refused),
995
1011
  `note`,
996
- `rule_add`, `rule_remove`, `review`, `sign_off`, `arm`, `disarm` and `ask_saad`. All are served in-process to its chat, refuse
1012
+ `rule_add`, `rule_remove`, `review`, `sign_off`, `arm`, `disarm`, `remind_me`, `reminders`, `cancel_reminder` and `ask_saad`. All are served in-process to its chat, refuse
997
1013
  any target outside its workspace, and validate their arguments. The one
998
1014
  exception is other orchestrators: `orchestrators` lists every workspace and
999
1015
  whether it has one, and `message_orchestrator` messages another workspace's
@@ -28,6 +28,7 @@ export async function POST(request: NextRequest) {
28
28
  from: body.from ? String(body.from) : null,
29
29
  every: body.every,
30
30
  cron: body.cron,
31
+ at: body.at,
31
32
  prompt: String(body.prompt ?? ""),
32
33
  name: body.name,
33
34
  timezone: body.timezone,
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;