@saadnvd1/agent-os 0.3.481 → 0.3.483
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 +14 -7
- package/app/api/fresh-start/route.ts +3 -1
- package/app/globals.css +7 -0
- package/components/Chat/ChatPanel.tsx +96 -66
- package/components/Chat/Code.tsx +4 -1
- package/components/Chat/Messages.tsx +2 -2
- package/components/Chat/copy-selection.ts +82 -0
- package/components/Chat/stick-to-bottom.test.ts +65 -5
- package/components/Chat/stick-to-bottom.ts +46 -16
- package/components/Chat/timeline-selection.test.ts +146 -0
- package/components/Workspaces/FreshStartForm.tsx +21 -9
- package/lib/chat/compact-context.ts +55 -0
- package/lib/chat/copy-selection.ts +58 -0
- package/lib/chat/driver.ts +4 -0
- package/lib/chat/drivers/claude-approvals.ts +32 -0
- package/lib/chat/drivers/claude.ts +4 -0
- package/lib/chat/refresh.ts +19 -6
- package/lib/chat/stale.ts +5 -4
- package/lib/chat/worker/host.ts +9 -1
- package/lib/chat/working-state.ts +111 -0
- package/lib/db/migrations.ts +13 -0
- package/lib/usage/turns.ts +11 -4
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -323,11 +323,11 @@ The ring in the tab bar (in the composer on a phone) is how full the
|
|
|
323
323
|
conversation's context is, measured against the room before auto-compact:
|
|
324
324
|
amber past 60%, red past 85%. Tap it for the breakdown and **Compact now**.
|
|
325
325
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
turns AgentOS catches what it didn't: a chat over
|
|
326
|
+
Two thresholds (Settings → Workspaces → Compact; 0 turns one off). Mid-turn,
|
|
327
|
+
the agent compacts itself at 300k by default: AgentOS launches Claude Code
|
|
328
|
+
with `CLAUDE_CODE_AUTO_COMPACT_WINDOW` set to that size (its floor is 100k),
|
|
329
|
+
high enough that a long job isn't cut in half. Between
|
|
330
|
+
turns AgentOS catches what it didn't: a chat over its 150k threshold is compacted
|
|
331
331
|
before the next message, and so is one idle past its prompt cache (5 minutes
|
|
332
332
|
or an hour, read from the agent's own usage), whose next message would
|
|
333
333
|
re-read the whole conversation at the cache-write price. A chip above the
|
|
@@ -339,6 +339,13 @@ shows one quiet line with the context before and after. An orchestrator
|
|
|
339
339
|
isn't compacted (a summary would lose the fence around what it read from
|
|
340
340
|
tasks and CI): it starts a fresh conversation from its brief and the current
|
|
341
341
|
state at its fresh-start threshold, or earlier once idle past its cache.
|
|
342
|
+
What survives a compaction: the summary is asked to keep the task, scope
|
|
343
|
+
changes, every edited file by path, what's done, in progress and next, and
|
|
344
|
+
the decisions. Right after it, AgentOS hands back a note built from its own
|
|
345
|
+
records (a task's brief and scope changes, the files it edited, its todos,
|
|
346
|
+
the latest message and `git status`), which doesn't depend on what the
|
|
347
|
+
summary kept, and tells the agent not to re-read or rewrite whole files.
|
|
348
|
+
Terminal agents don't get the note yet.
|
|
342
349
|
On a desktop browser the screen button attaches a screenshot of a screen or
|
|
343
350
|
window you pick; on a phone, attach a screenshot as a photo.
|
|
344
351
|
|
|
@@ -349,8 +356,8 @@ turn ends. **Gate reviews** below it shows what the orchestrator's
|
|
|
349
356
|
independent reviewer and scope check cost, by PR: they run as `claude -p`
|
|
350
357
|
and leave no transcript, so each run's tokens, model and duration are read
|
|
351
358
|
from its JSON result into `check_runs`, and the review's verdict says them
|
|
352
|
-
too. Every compaction is kept in `chat_compactions` (who ran it,
|
|
353
|
-
context before and after), so cost per task can be compared by date.
|
|
359
|
+
too. Every compaction is kept in `chat_compactions` (who ran it, whether it
|
|
360
|
+
fired `mid_turn` or `between_turns`, and the context before and after), so cost per task can be compared by date.
|
|
354
361
|
|
|
355
362
|
**Esc** stops a running turn, as it does in Claude Code's terminal, unless a
|
|
356
363
|
menu, dialog or the command list is open (those close first). On a phone the
|
|
@@ -3,7 +3,8 @@ import { refreshSettings, setRefreshSettings } from "@/lib/chat/refresh";
|
|
|
3
3
|
|
|
4
4
|
// When a chat's conversation starts over (lib/chat/refresh): context
|
|
5
5
|
// tokens for orchestrators and for tasks, and at which chats and tasks
|
|
6
|
-
// compact (lib/chat/stale)
|
|
6
|
+
// compact: between turns (lib/chat/stale) and mid-turn, by the agent itself;
|
|
7
|
+
// 0 for never.
|
|
7
8
|
export async function GET() {
|
|
8
9
|
return NextResponse.json(refreshSettings());
|
|
9
10
|
}
|
|
@@ -16,6 +17,7 @@ export async function PUT(request: NextRequest) {
|
|
|
16
17
|
taskTokens: body.taskTokens,
|
|
17
18
|
// Its name before it also set the agents' own auto-compact.
|
|
18
19
|
compactTokens: body.compactTokens ?? body.staleCompactTokens,
|
|
20
|
+
autoCompactTokens: body.autoCompactTokens,
|
|
19
21
|
});
|
|
20
22
|
return NextResponse.json(refreshSettings());
|
|
21
23
|
} catch (error) {
|
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 {
|
|
4
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
<
|
|
427
|
-
// A new conversation starts
|
|
473
|
+
<div
|
|
474
|
+
// A new conversation starts over, scrolled to the end.
|
|
428
475
|
key={sessionId}
|
|
429
476
|
ref={listRef}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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 && (
|
package/components/Chat/Code.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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<
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
|
188
|
-
//
|
|
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 > * > * >
|
|
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
|
|
236
|
-
*
|
|
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
|
-
|
|
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 =
|
|
255
|
-
if (!
|
|
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
|
-
}, [
|
|
300
|
+
}, [scroller, key]);
|
|
271
301
|
|
|
272
302
|
useEffect(() => {
|
|
273
303
|
latestRef.current = latest;
|