@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 +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/lib/chat/copy-selection.ts +58 -0
- package/package.json +1 -2
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;
|
|
@@ -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.
|
|
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",
|