@opengeni/react 0.29.0 → 0.32.1
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/dist/approvals.d.ts +14 -0
- package/dist/{chunk-M7X4JZOD.js → chunk-4QOQ2DSR.js} +8 -1
- package/dist/chunk-4QOQ2DSR.js.map +1 -0
- package/dist/{chunk-HQFPO7VX.js → chunk-7CJOAW3V.js} +24 -3
- package/dist/{chunk-HQFPO7VX.js.map → chunk-7CJOAW3V.js.map} +1 -1
- package/dist/{chunk-CSJBF3QJ.js → chunk-A2WITZTI.js} +377 -113
- package/dist/chunk-A2WITZTI.js.map +1 -0
- package/dist/{chunk-BC6RKIHS.js → chunk-GZ4KR67U.js} +11 -11
- package/dist/{chunk-AVLMIWYO.js → chunk-KZRUYCL4.js} +12 -6
- package/dist/chunk-KZRUYCL4.js.map +1 -0
- package/dist/{chunk-OKXCECLG.js → chunk-TCCE5ZU5.js} +171 -22
- package/dist/chunk-TCCE5ZU5.js.map +1 -0
- package/dist/chunk-U255M5EZ.js +178 -0
- package/dist/chunk-U255M5EZ.js.map +1 -0
- package/dist/{chunk-IPVOVOW6.js → chunk-WY2QN2CX.js} +2539 -2174
- package/dist/chunk-WY2QN2CX.js.map +1 -0
- package/dist/chunk-ZNNE2VES.js +6413 -0
- package/dist/chunk-ZNNE2VES.js.map +1 -0
- package/dist/client.d.ts +33 -0
- package/dist/commands/index.d.ts +3 -0
- package/dist/commands/registry.d.ts +41 -0
- package/dist/commands/types.d.ts +85 -0
- package/dist/components/approval-surface.d.ts +28 -0
- package/dist/components/chat-composer.d.ts +51 -0
- package/dist/components/code-editor.d.ts +43 -0
- package/dist/components/command-palette.d.ts +25 -0
- package/dist/components/composer-transcription-control.d.ts +36 -0
- package/dist/components/composer.d.ts +277 -0
- package/dist/components/copy-button.d.ts +26 -0
- package/dist/components/desktop-viewer.d.ts +85 -0
- package/dist/components/diff-view.d.ts +38 -0
- package/dist/components/enrollment-consent.d.ts +33 -0
- package/dist/components/enrollment-device-flow.d.ts +27 -0
- package/dist/components/file-browser.d.ts +47 -0
- package/dist/components/fleet-tile.d.ts +18 -0
- package/dist/components/human-input-form.d.ts +47 -0
- package/dist/components/machine-card.d.ts +21 -0
- package/dist/components/machine-dock-bar.d.ts +31 -0
- package/dist/components/machine-health-pill.d.ts +9 -0
- package/dist/components/machine-metrics.d.ts +15 -0
- package/dist/components/machine-status-pill.d.ts +54 -0
- package/dist/components/machines/health.d.ts +25 -0
- package/dist/components/machines/machine-detail.d.ts +14 -0
- package/dist/components/machines/metric-history-chart.d.ts +24 -0
- package/dist/components/machines/metric-sparkline.d.ts +9 -0
- package/dist/components/machines/series.d.ts +24 -0
- package/dist/components/machines-dashboard.d.ts +30 -0
- package/dist/components/markdown.d.ts +31 -0
- package/dist/components/message-timeline.d.ts +98 -0
- package/dist/components/model-picker.d.ts +19 -0
- package/dist/components/pierre-diff.d.ts +34 -0
- package/dist/components/pierre-file.d.ts +27 -0
- package/dist/components/queue-draft-policy.d.ts +7 -0
- package/dist/components/queue-surface-implementation.d.ts +20 -0
- package/dist/components/queue-surface-state.d.ts +12 -0
- package/dist/components/queue-surface.d.ts +12 -0
- package/dist/components/sandbox-files.d.ts +49 -0
- package/dist/components/sandbox-terminal.d.ts +123 -0
- package/dist/components/sandbox-workspace.d.ts +128 -0
- package/dist/components/session-chrome.d.ts +70 -0
- package/dist/components/session-status.d.ts +25 -0
- package/dist/components/soften-streaming-markdown.d.ts +13 -0
- package/dist/components/stream-reveal.d.ts +37 -0
- package/dist/components/tip-follow.d.ts +139 -0
- package/dist/components/tooltip.d.ts +16 -0
- package/dist/components/workbench-changes.d.ts +34 -0
- package/dist/components/workspace-dock.d.ts +35 -0
- package/dist/composer.d.ts +11 -7
- package/dist/composer.js +9 -6
- package/dist/hooks/internal.d.ts +46 -0
- package/dist/hooks/use-available-models.d.ts +37 -0
- package/dist/hooks/use-billing-usage.d.ts +26 -0
- package/dist/hooks/use-codex-accounts.d.ts +69 -0
- package/dist/{use-composer-ClHy1rkL.d.ts → hooks/use-composer.d.ts} +16 -24
- package/dist/hooks/use-desktop-stream.d.ts +43 -0
- package/dist/hooks/use-environments.d.ts +35 -0
- package/dist/{use-file-attachments-BmT8WtqO.d.ts → hooks/use-file-attachments.d.ts} +6 -9
- package/dist/hooks/use-goal.d.ts +46 -0
- package/dist/hooks/use-human-input.d.ts +26 -0
- package/dist/hooks/use-last-started-turn-policy.d.ts +27 -0
- package/dist/hooks/use-machine-chip.d.ts +54 -0
- package/dist/hooks/use-machines.d.ts +81 -0
- package/dist/hooks/use-packs.d.ts +27 -0
- package/dist/hooks/use-relay-frame-stream.d.ts +53 -0
- package/dist/hooks/use-rigs.d.ts +87 -0
- package/dist/hooks/use-sandbox-files.d.ts +108 -0
- package/dist/hooks/use-sandbox-git.d.ts +58 -0
- package/dist/hooks/use-sandbox-terminal.d.ts +62 -0
- package/dist/hooks/use-scheduled-tasks.d.ts +15 -0
- package/dist/hooks/use-session-capabilities.d.ts +83 -0
- package/dist/hooks/use-session-control.d.ts +22 -0
- package/dist/hooks/use-session-events.d.ts +92 -0
- package/dist/hooks/use-session-lineage.d.ts +17 -0
- package/dist/hooks/use-session-mcp-approval-policy.d.ts +23 -0
- package/dist/hooks/use-session.d.ts +23 -0
- package/dist/hooks/use-slash-commands.d.ts +66 -0
- package/dist/hooks/use-terminal-stream.d.ts +46 -0
- package/dist/hooks/use-transcription.d.ts +73 -0
- package/dist/hooks/use-turn-queue.d.ts +44 -0
- package/dist/hooks/use-voice-input.d.ts +25 -0
- package/dist/hooks/use-windowed-sections.d.ts +42 -0
- package/dist/hooks/use-workspace-capture.d.ts +49 -0
- package/dist/hooks/use-workspace-edit.d.ts +73 -0
- package/dist/hooks/use-workspace-sessions.d.ts +30 -0
- package/dist/hooks/use-workspaces.d.ts +22 -0
- package/dist/human-input.d.ts +18 -0
- package/dist/index.d.ts +144 -2119
- package/dist/index.js +421 -324
- package/dist/index.js.map +1 -1
- package/dist/lib/clipboard.d.ts +7 -0
- package/dist/lib/cn.d.ts +3 -0
- package/dist/lib/format.d.ts +48 -0
- package/dist/lib/git-patch.d.ts +8 -0
- package/dist/lib/motion-inspect.d.ts +5 -0
- package/dist/lib/motion.d.ts +14 -0
- package/dist/lib/noto-sans-arabic-loader.d.ts +1 -0
- package/dist/lib/noto-sans-jp-loader.generated.d.ts +1 -0
- package/dist/lib/relay-wire.d.ts +30 -0
- package/dist/lib/use-portal-token-style.d.ts +9 -0
- package/dist/lib/use-theme-type.d.ts +5 -0
- package/dist/lib/use-unicode-fonts.d.ts +12 -0
- package/dist/lib/xterm-renderer.d.ts +19 -0
- package/dist/lib/xterm-theme.d.ts +75 -0
- package/dist/machines.d.ts +30 -413
- package/dist/machines.js +3 -3
- package/dist/model-policy.d.ts +32 -0
- package/dist/model-policy.js +33 -0
- package/dist/model-policy.js.map +1 -0
- package/dist/provider.d.ts +16 -0
- package/dist/{queue-surface-implementation-UQ535OKQ.js → queue-surface-implementation-THPAKGMD.js} +2 -2
- package/dist/{queue-surface-implementation-UQ535OKQ.js.map → queue-surface-implementation-THPAKGMD.js.map} +1 -1
- package/dist/session-context.d.ts +82 -0
- package/dist/session-ui.d.ts +10 -7
- package/dist/session-ui.js +9 -5
- package/dist/session.d.ts +28 -6
- package/dist/session.js +11 -11
- package/dist/timeline/activity-rail.d.ts +19 -0
- package/dist/timeline/disclosure-context.d.ts +14 -0
- package/dist/timeline/entrance.d.ts +14 -0
- package/dist/timeline/fleet-decision-projection.d.ts +9 -0
- package/dist/timeline/fleet-decision-row.d.ts +11 -0
- package/dist/timeline/fold-memory.d.ts +15 -0
- package/dist/timeline/index.d.ts +18 -0
- package/dist/timeline/parsers.d.ts +96 -0
- package/dist/timeline/projection.d.ts +38 -0
- package/dist/timeline/registry.d.ts +40 -0
- package/dist/timeline/screenshot-lightbox.d.ts +26 -0
- package/dist/timeline/seen-activity-ids.d.ts +2 -0
- package/dist/timeline/shared.d.ts +125 -0
- package/dist/timeline/tool-diff.d.ts +8 -0
- package/dist/timeline/tool-display-name.d.ts +10 -0
- package/dist/timeline/tool-renderers.d.ts +5 -0
- package/dist/timeline/turn-summary.d.ts +116 -0
- package/dist/{use-turn-queue-D_1a8W_l.d.ts → timeline/types.d.ts} +41 -69
- package/dist/types/machines.d.ts +11 -0
- package/package.json +8 -4
- package/src/client.ts +1 -0
- package/src/components/composer-transcription-control.tsx +278 -130
- package/src/components/composer.tsx +20 -15
- package/src/components/copy-button.tsx +127 -0
- package/src/components/markdown.tsx +231 -32
- package/src/components/message-timeline.tsx +1304 -378
- package/src/components/model-picker.tsx +83 -43
- package/src/components/queue-surface-implementation.tsx +1 -1
- package/src/components/session-chrome.tsx +942 -0
- package/src/components/soften-streaming-markdown.ts +205 -0
- package/src/components/stream-reveal.ts +195 -0
- package/src/components/tip-follow.ts +459 -0
- package/src/components/tooltip.tsx +103 -0
- package/src/composer.ts +6 -0
- package/src/hooks/internal.ts +30 -4
- package/src/hooks/use-available-models.ts +51 -4
- package/src/hooks/use-composer.ts +23 -3
- package/src/hooks/use-last-started-turn-policy.ts +95 -0
- package/src/hooks/use-session-events.ts +359 -5
- package/src/hooks/use-voice-input.ts +232 -0
- package/src/index.ts +47 -1
- package/src/lib/clipboard.ts +58 -0
- package/src/lib/format.ts +18 -0
- package/src/lib/motion-inspect.ts +5 -0
- package/src/lib/motion.ts +80 -0
- package/src/model-policy.ts +210 -0
- package/src/session-ui.ts +7 -0
- package/src/timeline/activity-rail.tsx +46 -7
- package/src/timeline/entrance.tsx +10 -0
- package/src/timeline/fold-memory.ts +52 -0
- package/src/timeline/index.ts +9 -1
- package/src/timeline/projection.ts +226 -41
- package/src/timeline/seen-activity-ids.ts +19 -0
- package/src/timeline/shared.tsx +1 -1
- package/src/timeline/tool-renderers.tsx +75 -6
- package/src/timeline/turn-summary.tsx +387 -97
- package/src/timeline/types.ts +21 -0
- package/styles/index.css +99 -7
- package/styles/tokens.css +44 -3
- package/dist/chunk-AVLMIWYO.js.map +0 -1
- package/dist/chunk-CSJBF3QJ.js.map +0 -1
- package/dist/chunk-IPVOVOW6.js.map +0 -1
- package/dist/chunk-M7X4JZOD.js.map +0 -1
- package/dist/chunk-OKXCECLG.js.map +0 -1
- package/dist/chunk-ZMNPFZWL.js +0 -3918
- package/dist/chunk-ZMNPFZWL.js.map +0 -1
- package/dist/composer-BMzimGTO.d.ts +0 -528
- package/dist/session-DaONt-dY.d.ts +0 -264
- package/dist/session-context-CAjlaeZb.d.ts +0 -84
- package/dist/session-ui-B5_roA95.d.ts +0 -259
- /package/dist/{chunk-BC6RKIHS.js.map → chunk-GZ4KR67U.js.map} +0 -0
|
@@ -2,6 +2,7 @@ import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
|
|
|
2
2
|
import {
|
|
3
3
|
ArrowDownIcon,
|
|
4
4
|
ArrowRightIcon,
|
|
5
|
+
ArrowUpToLineIcon,
|
|
5
6
|
BotIcon,
|
|
6
7
|
CheckCircle2Icon,
|
|
7
8
|
CheckIcon,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
PencilLineIcon,
|
|
12
13
|
PlayIcon,
|
|
13
14
|
RefreshCwIcon,
|
|
15
|
+
ShrinkIcon,
|
|
14
16
|
TargetIcon,
|
|
15
17
|
Trash2Icon,
|
|
16
18
|
TriangleAlertIcon,
|
|
@@ -31,7 +33,18 @@ import {
|
|
|
31
33
|
} from "react";
|
|
32
34
|
import { cn } from "../lib/cn";
|
|
33
35
|
import { formatRelativeTime, truncate } from "../lib/format";
|
|
36
|
+
import { prefersReducedMotion } from "../lib/motion";
|
|
34
37
|
import { Markdown } from "./markdown";
|
|
38
|
+
import {
|
|
39
|
+
createTipFollowState,
|
|
40
|
+
readerScrollUpPx,
|
|
41
|
+
tipFollowCancel,
|
|
42
|
+
tipFollowCompensateShrink,
|
|
43
|
+
tipFollowStep,
|
|
44
|
+
TIP_FOLLOW_READER_UP_EPS_PX,
|
|
45
|
+
TIP_FOLLOW_SHRINK_EPS_PX,
|
|
46
|
+
type TipFollowState,
|
|
47
|
+
} from "./tip-follow";
|
|
35
48
|
import {
|
|
36
49
|
ActivityRail,
|
|
37
50
|
buildTimeline,
|
|
@@ -41,6 +54,7 @@ import {
|
|
|
41
54
|
type ActivityItem,
|
|
42
55
|
type AgentMessageItem,
|
|
43
56
|
type AuthNeededItem,
|
|
57
|
+
type ContextCompactionItem,
|
|
44
58
|
type GoalItem,
|
|
45
59
|
type MachineInputBatchItem,
|
|
46
60
|
type NoticeItem,
|
|
@@ -49,18 +63,26 @@ import {
|
|
|
49
63
|
type ToolRegistry,
|
|
50
64
|
type TurnSummaryOptions,
|
|
51
65
|
type UserMessageItem,
|
|
66
|
+
type FoldRestingState,
|
|
52
67
|
type WorkerCompletionItem,
|
|
68
|
+
FoldMemoryProvider,
|
|
69
|
+
inheritFoldRestingState,
|
|
53
70
|
TurnSummary,
|
|
71
|
+
useFoldMemory,
|
|
72
|
+
useTurnSettleOpen,
|
|
54
73
|
} from "../timeline";
|
|
74
|
+
import { CopyHoverFrame } from "./copy-button";
|
|
55
75
|
import { SESSION_STATUS_META, StatusDot } from "./session-status";
|
|
56
76
|
import { EntranceAnimationProvider, useEntranceAnimation } from "../timeline/entrance";
|
|
77
|
+
import { SeenActivityIdsProvider } from "../timeline/seen-activity-ids";
|
|
78
|
+
import { TooltipProvider } from "./tooltip";
|
|
57
79
|
|
|
58
80
|
export type MessageTimelineProps = {
|
|
59
81
|
/** Raw session events (projected internally) … */
|
|
60
82
|
events?: SessionEvent[] | undefined;
|
|
61
83
|
/** … or pre-projected items (e.g. from `useSessionEvents().timeline`). */
|
|
62
84
|
items?: TimelineItem[] | undefined;
|
|
63
|
-
/** Current session status;
|
|
85
|
+
/** Current session status (reserved; tip "Working…" chrome removed for now). */
|
|
64
86
|
status?: SessionStatus | null | undefined;
|
|
65
87
|
/** Plug a markdown renderer for message bodies (e.g. streamdown). */
|
|
66
88
|
renderMessageText?:
|
|
@@ -109,11 +131,112 @@ export type MessageTimelineProps = {
|
|
|
109
131
|
loadingOlder?: boolean | undefined;
|
|
110
132
|
/** Called when the reader nears the top and older history should backfill. */
|
|
111
133
|
onLoadOlder?: (() => void) | undefined;
|
|
134
|
+
/** Jump to the durable session start (bounded oldest window, no middle). */
|
|
135
|
+
onJumpToStart?: (() => void | Promise<void>) | undefined;
|
|
136
|
+
/** True while the oldest window is loading. */
|
|
137
|
+
loadingOldest?: boolean | undefined;
|
|
138
|
+
/** Newer durable history exists below the current (history) window. */
|
|
139
|
+
hasNewer?: boolean | undefined;
|
|
140
|
+
/** A newer history page is being fetched. */
|
|
141
|
+
loadingNewer?: boolean | undefined;
|
|
142
|
+
/** Page forward through history without loading the whole gap to the tip. */
|
|
143
|
+
onLoadNewer?: (() => void) | undefined;
|
|
144
|
+
/**
|
|
145
|
+
* Reload the live tip window. When omitted, Jump to latest only re-pins and
|
|
146
|
+
* scrolls the in-memory window.
|
|
147
|
+
*/
|
|
148
|
+
onJumpToLatest?: (() => void | Promise<void>) | undefined;
|
|
112
149
|
emptyState?: ReactNode | undefined;
|
|
113
150
|
className?: string | undefined;
|
|
114
151
|
};
|
|
115
152
|
|
|
116
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Scroll ownership, from first principles. Everything the events hook has
|
|
155
|
+
* loaded is mounted — no tip-lock window, no per-frame progressive reveal.
|
|
156
|
+
* (The in-memory window is already byte/count-bounded by useSessionEvents, and
|
|
157
|
+
* rows are memoized, so a full mount is cheap; the drip-feed machinery this
|
|
158
|
+
* replaces was the "content is hidden, then pops in in batches" wobble.)
|
|
159
|
+
*
|
|
160
|
+
* Scroll invariant (tip-follow camera — see `./tip-follow.ts`):
|
|
161
|
+
* - DOM truth mounts immediately; the camera eases down to the tip (not
|
|
162
|
+
* tip-glued feed-forward — that is the one-line yank).
|
|
163
|
+
* - One continuous follow while hot (faster τ when behind); sleeps when cold.
|
|
164
|
+
* - While pinned, tip-debt from growth/collapse must NEVER unpin — only
|
|
165
|
+
* wheel/keys/pointer-armed scroll-up. Height shrink compensates scrollTop
|
|
166
|
+
* by Δh (collapse owns motion); tip-ease pauses briefly so the two don't fight.
|
|
167
|
+
* - overflow-anchor off while pinned so the browser cannot instant-correct.
|
|
168
|
+
* - Scrolled up → history prepends restore via the retained group anchor
|
|
169
|
+
* (offsetTop delta); loadOlder can truncate the tip, so scrollHeight delta
|
|
170
|
+
* alone is wrong. Late layout while unpinned stays browser-owned.
|
|
171
|
+
*/
|
|
172
|
+
const PIN_THRESHOLD_PX = 48;
|
|
173
|
+
/**
|
|
174
|
+
* Prefetch older history when the top sentinel is this far from the viewport.
|
|
175
|
+
* After a page loads we stay cool until the reader leaves this band (scrolls
|
|
176
|
+
* down into content) — never re-fire from continued scroll toward y=0.
|
|
177
|
+
*/
|
|
178
|
+
const OLDER_PREFETCH_MARGIN_PX = 400;
|
|
179
|
+
const OLDER_PREFETCH_ROOT_MARGIN = `${OLDER_PREFETCH_MARGIN_PX}px 0px 0px 0px`;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Pinned = the viewport bottom is within PIN_THRESHOLD_PX of the content
|
|
183
|
+
* bottom. When the scroll range itself is shorter than the threshold, the
|
|
184
|
+
* whole range would count as "at the bottom" and the reader could never unpin
|
|
185
|
+
* to reach older history — so the effective threshold shrinks to the range,
|
|
186
|
+
* making the very top of a short window count as scrolled up. A window that
|
|
187
|
+
* cannot scroll at all is always pinned.
|
|
188
|
+
*/
|
|
189
|
+
function maxScrollOf(node: HTMLElement): number {
|
|
190
|
+
return Math.max(0, node.scrollHeight - node.clientHeight);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Wheel bubbled from a nested overflow scroller that can still move up — not
|
|
195
|
+
* timeline intent (code blocks / notice `<pre>`).
|
|
196
|
+
*/
|
|
197
|
+
function wheelConsumedByNestedScrollable(event: {
|
|
198
|
+
deltaY: number;
|
|
199
|
+
target: EventTarget | null;
|
|
200
|
+
currentTarget: EventTarget | null;
|
|
201
|
+
}): boolean {
|
|
202
|
+
if (event.deltaY >= 0) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
let el = event.target instanceof Element ? event.target : null;
|
|
206
|
+
const root = event.currentTarget instanceof Element ? event.currentTarget : null;
|
|
207
|
+
while (el && el !== root) {
|
|
208
|
+
if (el instanceof HTMLElement) {
|
|
209
|
+
const style = getComputedStyle(el);
|
|
210
|
+
const overflowY = style.overflowY;
|
|
211
|
+
if (
|
|
212
|
+
(overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") &&
|
|
213
|
+
el.scrollHeight > el.clientHeight + 1 &&
|
|
214
|
+
el.scrollTop > 0
|
|
215
|
+
) {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
el = el.parentElement;
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isNearBottom(node: HTMLElement): boolean {
|
|
225
|
+
const maxScroll = maxScrollOf(node);
|
|
226
|
+
if (maxScroll <= 1) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
const gap = maxScroll - node.scrollTop;
|
|
230
|
+
return gap < Math.min(PIN_THRESHOLD_PX, maxScroll);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Escape a value for use inside a CSS attribute selector. */
|
|
234
|
+
function cssEscapeAttribute(value: string): string {
|
|
235
|
+
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
236
|
+
return CSS.escape(value);
|
|
237
|
+
}
|
|
238
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
239
|
+
}
|
|
117
240
|
|
|
118
241
|
/**
|
|
119
242
|
* The session timeline: chat messages with streaming deltas, collapsed
|
|
@@ -124,7 +247,7 @@ const INITIAL_MOUNTED_GROUPS = 1;
|
|
|
124
247
|
export function MessageTimeline({
|
|
125
248
|
events,
|
|
126
249
|
items,
|
|
127
|
-
status,
|
|
250
|
+
status: _status,
|
|
128
251
|
renderMessageText,
|
|
129
252
|
onOpenSession,
|
|
130
253
|
onMemoryClick,
|
|
@@ -136,146 +259,441 @@ export function MessageTimeline({
|
|
|
136
259
|
hasOlder = false,
|
|
137
260
|
loadingOlder = false,
|
|
138
261
|
onLoadOlder,
|
|
262
|
+
onJumpToStart,
|
|
263
|
+
loadingOldest = false,
|
|
264
|
+
hasNewer = false,
|
|
265
|
+
loadingNewer = false,
|
|
266
|
+
onLoadNewer,
|
|
267
|
+
onJumpToLatest,
|
|
139
268
|
emptyState,
|
|
140
269
|
className,
|
|
141
270
|
}: MessageTimelineProps) {
|
|
142
271
|
const resolvedItems = useMemo(() => items ?? buildTimeline(events ?? []), [items, events]);
|
|
143
272
|
const allGroups = useMemo(() => groupTimeline(resolvedItems), [resolvedItems]);
|
|
144
|
-
const
|
|
145
|
-
const { mountedGroups: groups, mountingOlderGroups } = useProgressivelyMountedGroups(keyedGroups);
|
|
146
|
-
const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
|
|
147
|
-
|
|
273
|
+
const groups = useStableTimelineGroupKeys(allGroups);
|
|
148
274
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
149
275
|
const topSentinelRef = useRef<HTMLDivElement | null>(null);
|
|
276
|
+
const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
|
|
150
277
|
const previousBulkFirstKeyRef = useRef<string | null | undefined>(undefined);
|
|
151
278
|
const [pinned, setPinned] = useState(true);
|
|
152
279
|
const [bulkActive, setBulkActive] = useState(true);
|
|
280
|
+
// Older history prefetch is user-driven: a window shorter than the viewport
|
|
281
|
+
// + rootMargin would otherwise keep the top sentinel intersecting and fetch
|
|
282
|
+
// history forever while the reader sits at the tip. Arm on first scroll-up.
|
|
283
|
+
const olderPrefetchArmedRef = useRef(false);
|
|
284
|
+
const [olderPrefetchArmed, setOlderPrefetchArmed] = useState(false);
|
|
285
|
+
// One loadOlder per visit to the top band. Re-arm only after the reader
|
|
286
|
+
// leaves that band (scrolls down / sentinel exits) — never from scrolling
|
|
287
|
+
// further toward y=0 (that was the batch-top load loop).
|
|
288
|
+
const olderLoadGateRef = useRef<"armed" | "cooling">("armed");
|
|
289
|
+
const resizeFollowRafRef = useRef<number | null>(null);
|
|
290
|
+
const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
|
|
153
291
|
// Content stays invisible until its first bottom-anchored frame, so a flash
|
|
154
|
-
// of the window's TOP while a large timeline lays out
|
|
155
|
-
//
|
|
156
|
-
// bottom. An empty timeline reveals immediately (there is nothing to anchor).
|
|
292
|
+
// of the window's TOP while a large timeline lays out is structurally
|
|
293
|
+
// impossible — the reader only ever sees it already at the bottom.
|
|
157
294
|
const [revealed, setRevealed] = useState(false);
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// dispatches them. If reader input lands at a different position, its value
|
|
162
|
-
// wins and clears the pending echoes. A matching echo must not recapture the
|
|
163
|
-
// visual anchor while progressive prepend is still mounting older rows.
|
|
164
|
-
const programmaticScrollTargetsRef = useRef<number[]>([]);
|
|
165
|
-
const assignScrollTop = useCallback((node: HTMLElement, value: number) => {
|
|
166
|
-
const previous = node.scrollTop;
|
|
167
|
-
node.scrollTop = value;
|
|
168
|
-
if (node.scrollTop === previous) {
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
const pending = programmaticScrollTargetsRef.current;
|
|
172
|
-
pending.push(node.scrollTop);
|
|
173
|
-
if (pending.length > 32) {
|
|
174
|
-
pending.splice(0, pending.length - 32);
|
|
175
|
-
}
|
|
176
|
-
}, []);
|
|
177
|
-
// Mirror `pinned` into a ref so the ResizeObserver callback (a stable closure)
|
|
178
|
-
// always reads the live value without re-subscribing on every scroll.
|
|
295
|
+
// Mirror `pinned` into a ref, written ONLY by applyPinned, so the
|
|
296
|
+
// ResizeObserver rAF (a stable closure) reads the live value and a snap can
|
|
297
|
+
// never race a just-unpinned reader across a pending React commit.
|
|
179
298
|
const pinnedRef = useRef(true);
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
299
|
+
// History windows (`hasNewer`) have a bottom that is not the live tip.
|
|
300
|
+
// Pin/follow must ignore that floor — otherwise loadNewer appends yank the
|
|
301
|
+
// reader to the new page bottom. LoadOlder prepends already stay put because
|
|
302
|
+
// the reader is unpinned and scroll anchoring / delta correction owns place.
|
|
303
|
+
const hasNewerRef = useRef(hasNewer);
|
|
304
|
+
hasNewerRef.current = hasNewer;
|
|
305
|
+
// Jump-to-latest pressed while a history window is showing: the pin must
|
|
306
|
+
// wait for the tip window to actually land (`hasNewer` → false) — pinning
|
|
307
|
+
// immediately would snap to the bottom of the CURRENT history page and
|
|
308
|
+
// page-crawl forward through the gap.
|
|
309
|
+
const wantPinRef = useRef(false);
|
|
310
|
+
// Jump-to-start pressed: consume on the commit that swaps the window so the
|
|
311
|
+
// scroll-to-top write races neither the old DOM nor the prepend correction.
|
|
312
|
+
const pendingJumpToStartRef = useRef(false);
|
|
313
|
+
// Identifies the newest Jump-to-start click so a settling promise callback
|
|
314
|
+
// from an earlier click can never clear a re-click's pending flag.
|
|
315
|
+
const jumpToStartSeqRef = useRef(0);
|
|
316
|
+
// Prepend detection: the oldest loaded item's id changes exactly when older
|
|
317
|
+
// history lands (including the merge-into-first-group case where the first
|
|
318
|
+
// GROUP key is retained). Item ids, not group keys, are the durable signal.
|
|
319
|
+
const previousFirstItemIdRef = useRef<string | null>(null);
|
|
320
|
+
const previousScrollHeightRef = useRef(0);
|
|
321
|
+
// Per-commit place memory for unpinned prepend restore (see layout effect).
|
|
322
|
+
// Paired with max/height/client for clamp-conservation reader-intent math.
|
|
323
|
+
const lastScrollTopRef = useRef(0);
|
|
324
|
+
const lastMaxScrollRef = useRef(0);
|
|
325
|
+
const lastScrollHeightRef = useRef(0);
|
|
326
|
+
const lastClientHeightRef = useRef(0);
|
|
327
|
+
/**
|
|
328
|
+
* Armed by pointerdown on the scroller. Geometric scroll-up only unpins
|
|
329
|
+
* while armed — mid-turn fold+growth also drops scrollTop with a flat
|
|
330
|
+
* maxScroll and must NOT count as leave. Wheel/keys unpin directly.
|
|
331
|
+
*/
|
|
332
|
+
const readerIntentArmRef = useRef(false);
|
|
333
|
+
// Resting fold state per durable group id (see fold-memory.ts). Outlives the
|
|
334
|
+
// deliberate chip remounts (activity→turn wrap, nested key flips) so a fold
|
|
335
|
+
// that already settled closed — or that the reader closed — never reopens.
|
|
336
|
+
const foldMemoryRef = useRef<Map<string, FoldRestingState>>(new Map());
|
|
337
|
+
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
|
338
|
+
const groupKeyByItemIdRef = useRef<Map<string, string>>(new Map());
|
|
339
|
+
const groupOffsetByKeyRef = useRef<Map<string, number>>(new Map());
|
|
340
|
+
const firstItemId = resolvedItems[0]?.id ?? null;
|
|
191
341
|
// Bulk paints (the initial tail window, a prepended older window — detected
|
|
192
342
|
// by the first group key changing) must not run per-row entrance animations.
|
|
193
343
|
const firstKeyChangedForBulk =
|
|
194
344
|
previousBulkFirstKeyRef.current !== undefined &&
|
|
195
345
|
previousBulkFirstKeyRef.current !== firstGroupKey;
|
|
196
|
-
const bulkRender =
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// drop the correction mid-prepend.
|
|
206
|
-
const captureAnchor = useCallback(() => {
|
|
207
|
-
const node = scrollRef.current;
|
|
208
|
-
const inner = node?.firstElementChild;
|
|
209
|
-
if (!node || !inner) {
|
|
210
|
-
anchorRef.current = null;
|
|
211
|
-
return;
|
|
346
|
+
const bulkRender = allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk);
|
|
347
|
+
|
|
348
|
+
// The ONLY writer of the pinned flag. Ref and state move together, so
|
|
349
|
+
// behavior (refs read by rAF callbacks) and rendering (the anchor class,
|
|
350
|
+
// the Jump-to-latest button) can never desync.
|
|
351
|
+
const applyPinned = useCallback((value: boolean) => {
|
|
352
|
+
if (pinnedRef.current !== value) {
|
|
353
|
+
pinnedRef.current = value;
|
|
354
|
+
setPinned(value);
|
|
212
355
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
356
|
+
}, []);
|
|
357
|
+
|
|
358
|
+
const revealedRef = useRef(revealed);
|
|
359
|
+
revealedRef.current = revealed;
|
|
360
|
+
|
|
361
|
+
// Pure tip-follow camera. Pin intent uses clamp conservation, not timers.
|
|
362
|
+
const followRef = useRef<TipFollowState>(createTipFollowState());
|
|
363
|
+
const followFrameRef = useRef<number | null>(null);
|
|
364
|
+
|
|
365
|
+
const syncScrollBaseline = useCallback((node: HTMLElement) => {
|
|
366
|
+
lastScrollTopRef.current = node.scrollTop;
|
|
367
|
+
lastMaxScrollRef.current = maxScrollOf(node);
|
|
368
|
+
lastScrollHeightRef.current = node.scrollHeight;
|
|
369
|
+
lastClientHeightRef.current = node.clientHeight;
|
|
370
|
+
}, []);
|
|
371
|
+
|
|
372
|
+
const stopFollow = useCallback(() => {
|
|
373
|
+
followRef.current = tipFollowCancel(followRef.current);
|
|
374
|
+
if (followFrameRef.current !== null) {
|
|
375
|
+
cancelFrame(followFrameRef.current);
|
|
376
|
+
followFrameRef.current = null;
|
|
377
|
+
}
|
|
378
|
+
}, []);
|
|
379
|
+
|
|
380
|
+
/** Reader left the tip — wheel, keyboard, or pointer-armed scroll-up. */
|
|
381
|
+
const releasePinFromReader = useCallback(
|
|
382
|
+
(node?: HTMLElement | null) => {
|
|
383
|
+
if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
|
|
384
|
+
return;
|
|
223
385
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
386
|
+
// Unscrollable window: unpin strands Jump-to-latest with no way back.
|
|
387
|
+
if (node && maxScrollOf(node) <= 1) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
readerIntentArmRef.current = false;
|
|
391
|
+
stopFollow();
|
|
392
|
+
applyPinned(false);
|
|
393
|
+
if (wantPinRef.current) {
|
|
394
|
+
wantPinRef.current = false;
|
|
395
|
+
}
|
|
396
|
+
if (!olderPrefetchArmedRef.current) {
|
|
397
|
+
olderPrefetchArmedRef.current = true;
|
|
398
|
+
setOlderPrefetchArmed(true);
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
[autoFollow, applyPinned, stopFollow],
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const onWheel = (event: {
|
|
405
|
+
deltaY: number;
|
|
406
|
+
deltaX: number;
|
|
407
|
+
target: EventTarget | null;
|
|
408
|
+
currentTarget: EventTarget | null;
|
|
409
|
+
}) => {
|
|
410
|
+
// Nested overflow (code / notice pre) or mostly-horizontal pan: not tip leave.
|
|
411
|
+
if (event.deltaY >= 0) {
|
|
232
412
|
return;
|
|
233
413
|
}
|
|
234
|
-
|
|
235
|
-
|
|
414
|
+
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (wheelConsumedByNestedScrollable(event)) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const node =
|
|
421
|
+
event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
|
|
422
|
+
releasePinFromReader(node);
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/** Touch / stylus / mouse drag on the scroller — explicit leave (not layout). */
|
|
426
|
+
const onPointerDown = (event: {
|
|
427
|
+
button: number;
|
|
428
|
+
pointerType: string;
|
|
429
|
+
target: EventTarget | null;
|
|
430
|
+
}) => {
|
|
431
|
+
// Primary button / touch / pen only. Ignore right-click etc.
|
|
432
|
+
if (event.button !== 0 && event.pointerType === "mouse") {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
// Clicks on chips/buttons/links must not arm — their settle collapse
|
|
436
|
+
// also drops scrollTop and would false-unpin. Drag on prose/scroller may.
|
|
437
|
+
if (
|
|
438
|
+
event.target instanceof Element &&
|
|
439
|
+
event.target.closest("button, a, input, textarea, select, [role='button']")
|
|
440
|
+
) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
readerIntentArmRef.current = true;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const onKeyDown = (event: { key: string; currentTarget: EventTarget | null }) => {
|
|
447
|
+
if (event.key !== "ArrowUp" && event.key !== "PageUp" && event.key !== "Home") {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const node =
|
|
451
|
+
event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
|
|
452
|
+
releasePinFromReader(node);
|
|
453
|
+
};
|
|
236
454
|
|
|
237
|
-
|
|
238
|
-
// ResizeObserver remains a late-measurement fallback, but progressive history
|
|
239
|
-
// frames must be corrected before the browser can paint an intermediate jump.
|
|
240
|
-
const restoreAnchor = useCallback(
|
|
455
|
+
const snapToBottom = useCallback(
|
|
241
456
|
(node: HTMLElement) => {
|
|
242
|
-
|
|
243
|
-
|
|
457
|
+
stopFollow();
|
|
458
|
+
node.scrollTop = Math.max(0, node.scrollHeight - node.clientHeight);
|
|
459
|
+
syncScrollBaseline(node);
|
|
460
|
+
followRef.current = {
|
|
461
|
+
...followRef.current,
|
|
462
|
+
lastHeight: node.scrollHeight,
|
|
463
|
+
};
|
|
464
|
+
},
|
|
465
|
+
[stopFollow, syncScrollBaseline],
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(() => undefined);
|
|
469
|
+
const driveFollow = useCallback(
|
|
470
|
+
(node: HTMLElement, nowMs?: number) => {
|
|
471
|
+
if (!pinnedRef.current || hasNewerRef.current) {
|
|
472
|
+
stopFollow();
|
|
244
473
|
return;
|
|
245
474
|
}
|
|
246
|
-
|
|
247
|
-
|
|
475
|
+
// Prefer the rAF timestamp so ease integrates against vsync (and tests
|
|
476
|
+
// can advance a synthetic clock via requestAnimationFrame callbacks).
|
|
477
|
+
const now =
|
|
478
|
+
typeof nowMs === "number"
|
|
479
|
+
? nowMs
|
|
480
|
+
: typeof performance !== "undefined"
|
|
481
|
+
? performance.now()
|
|
482
|
+
: Date.now();
|
|
483
|
+
const previousHeight = followRef.current.lastHeight;
|
|
484
|
+
// Settle-collapse: compensate Δh from the pre-shrink baseline (browser
|
|
485
|
+
// may already have clamped — don't double-subtract). Keep the follow rAF
|
|
486
|
+
// alive so when collapse ends (or stream resumes) we ease instead of a
|
|
487
|
+
// hard stop → flick. Do NOT tip-ease on the same frame as a real shrink
|
|
488
|
+
// (that fight was the top-of-viewport flicker).
|
|
489
|
+
if (previousHeight > 0 && node.scrollHeight < previousHeight - TIP_FOLLOW_SHRINK_EPS_PX) {
|
|
490
|
+
const nextTop = tipFollowCompensateShrink(
|
|
491
|
+
lastScrollTopRef.current,
|
|
492
|
+
previousHeight,
|
|
493
|
+
node.scrollHeight,
|
|
494
|
+
node.clientHeight,
|
|
495
|
+
);
|
|
496
|
+
if (node.scrollTop !== nextTop) {
|
|
497
|
+
node.scrollTop = nextTop;
|
|
498
|
+
}
|
|
499
|
+
syncScrollBaseline(node);
|
|
500
|
+
followRef.current = {
|
|
501
|
+
...followRef.current,
|
|
502
|
+
lastHeight: node.scrollHeight,
|
|
503
|
+
running: true,
|
|
504
|
+
lastTs: now,
|
|
505
|
+
};
|
|
506
|
+
if (followFrameRef.current === null) {
|
|
507
|
+
followFrameRef.current = requestFrame((frameNow) => {
|
|
508
|
+
followFrameRef.current = null;
|
|
509
|
+
const current = scrollRef.current;
|
|
510
|
+
if (current) {
|
|
511
|
+
driveFollowRef.current(current, frameNow);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
}
|
|
248
515
|
return;
|
|
249
516
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
517
|
+
// Sub-eps height noise: adopt height without moving the camera.
|
|
518
|
+
if (previousHeight > 0 && node.scrollHeight < previousHeight) {
|
|
519
|
+
followRef.current = {
|
|
520
|
+
...followRef.current,
|
|
521
|
+
lastHeight: node.scrollHeight,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const result = tipFollowStep(followRef.current, {
|
|
525
|
+
scrollTop: node.scrollTop,
|
|
526
|
+
scrollHeight: node.scrollHeight,
|
|
527
|
+
clientHeight: node.clientHeight,
|
|
528
|
+
now,
|
|
529
|
+
pinned: true,
|
|
530
|
+
reducedMotion: prefersReducedMotion(),
|
|
531
|
+
revealed: revealedRef.current,
|
|
532
|
+
});
|
|
533
|
+
followRef.current = result.state;
|
|
534
|
+
if (node.scrollTop !== result.scrollTop) {
|
|
535
|
+
node.scrollTop = result.scrollTop;
|
|
536
|
+
}
|
|
537
|
+
syncScrollBaseline(node);
|
|
538
|
+
if (result.state.running) {
|
|
539
|
+
if (followFrameRef.current === null) {
|
|
540
|
+
followFrameRef.current = requestFrame((frameNow) => {
|
|
541
|
+
followFrameRef.current = null;
|
|
542
|
+
const current = scrollRef.current;
|
|
543
|
+
if (current) {
|
|
544
|
+
driveFollowRef.current(current, frameNow);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
} else if (followFrameRef.current !== null) {
|
|
549
|
+
cancelFrame(followFrameRef.current);
|
|
550
|
+
followFrameRef.current = null;
|
|
255
551
|
}
|
|
256
552
|
},
|
|
257
|
-
[
|
|
553
|
+
[stopFollow, syncScrollBaseline],
|
|
258
554
|
);
|
|
555
|
+
driveFollowRef.current = driveFollow;
|
|
259
556
|
|
|
260
|
-
|
|
261
|
-
|
|
557
|
+
useEffect(() => stopFollow, [stopFollow]);
|
|
558
|
+
|
|
559
|
+
// The single post-commit scroll authority. Runs after EVERY commit (no dep
|
|
560
|
+
// list): any commit may change content height, and the decision is cheap.
|
|
561
|
+
// Also the ONLY writer of the prepend-correction baselines
|
|
562
|
+
// (previousFirstItemIdRef / previousScrollHeightRef / group offset maps).
|
|
563
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Deliberately runs after every commit.
|
|
262
564
|
useLayoutEffect(() => {
|
|
263
565
|
const node = scrollRef.current;
|
|
264
|
-
if (node) {
|
|
265
|
-
|
|
566
|
+
if (!node) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const previousFirstItemId = previousFirstItemIdRef.current;
|
|
570
|
+
const previousGroupKeyByItemId = groupKeyByItemIdRef.current;
|
|
571
|
+
const previousGroupOffsetByKey = groupOffsetByKeyRef.current;
|
|
572
|
+
const previousScrollTop = lastScrollTopRef.current;
|
|
573
|
+
const firstItemChanged = previousFirstItemId !== null && firstItemId !== previousFirstItemId;
|
|
574
|
+
const prepended =
|
|
575
|
+
firstItemChanged && resolvedItems.some((item) => item.id === previousFirstItemId);
|
|
576
|
+
if (pendingJumpToStartRef.current && firstItemChanged) {
|
|
577
|
+
// The oldest window landed — jump against the NEW DOM, and skip the
|
|
578
|
+
// prepend correction (it would shift the reader away from the top).
|
|
579
|
+
pendingJumpToStartRef.current = false;
|
|
580
|
+
stopFollow();
|
|
581
|
+
node.scrollTop = 0;
|
|
582
|
+
} else if (wantPinRef.current && !hasNewer) {
|
|
583
|
+
// Jump-to-latest was pressed on a history window and the tip window is
|
|
584
|
+
// in THIS commit — consume pre-paint so the first tip frame is already
|
|
585
|
+
// at the bottom (post-paint consumption flashed one clamped frame).
|
|
586
|
+
wantPinRef.current = false;
|
|
587
|
+
if (autoFollow) {
|
|
588
|
+
applyPinned(true);
|
|
589
|
+
snapToBottom(node);
|
|
590
|
+
}
|
|
591
|
+
} else if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
592
|
+
// Live tip: tip-follow camera eases down (snap only first paint / jumps).
|
|
593
|
+
driveFollow(node);
|
|
594
|
+
} else if (prepended) {
|
|
595
|
+
// Keep the reader on the same retained rows. Prefer the offsetTop delta
|
|
596
|
+
// of the group that still holds the previous first item — that stays
|
|
597
|
+
// correct when loadOlder also truncates the tip (height delta then lies).
|
|
598
|
+
// If native anchoring already applied the same shift, leave scrollTop.
|
|
599
|
+
const anchorKey =
|
|
600
|
+
previousFirstItemId !== null
|
|
601
|
+
? previousGroupKeyByItemId.get(previousFirstItemId)
|
|
602
|
+
: undefined;
|
|
603
|
+
const previousAnchorTop =
|
|
604
|
+
anchorKey !== undefined ? previousGroupOffsetByKey.get(anchorKey) : undefined;
|
|
605
|
+
const anchorEl =
|
|
606
|
+
anchorKey !== undefined
|
|
607
|
+
? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(anchorKey)}"]`)
|
|
608
|
+
: null;
|
|
609
|
+
let delta: number | null = null;
|
|
610
|
+
if (anchorEl instanceof HTMLElement && previousAnchorTop !== undefined) {
|
|
611
|
+
const moved = Math.round(anchorEl.offsetTop - previousAnchorTop);
|
|
612
|
+
if (moved !== 0) {
|
|
613
|
+
delta = moved;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (delta === null) {
|
|
617
|
+
const heightDelta = Math.round(node.scrollHeight - previousScrollHeightRef.current);
|
|
618
|
+
if (heightDelta > 0) {
|
|
619
|
+
delta = heightDelta;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (delta !== null) {
|
|
623
|
+
const expected = previousScrollTop + delta;
|
|
624
|
+
if (Math.abs(node.scrollTop - expected) > 2) {
|
|
625
|
+
node.scrollTop = expected;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// After a prepend, if restore left us below the top prefetch band,
|
|
630
|
+
// re-arm so a later approach can load again. Still cooling while parked
|
|
631
|
+
// inside the band (short pages) — that stops the y=0 load loop.
|
|
632
|
+
if (
|
|
633
|
+
prepended &&
|
|
634
|
+
!pinnedRef.current &&
|
|
635
|
+
olderLoadGateRef.current === "cooling" &&
|
|
636
|
+
node.scrollTop > OLDER_PREFETCH_MARGIN_PX
|
|
637
|
+
) {
|
|
638
|
+
olderLoadGateRef.current = "armed";
|
|
639
|
+
}
|
|
640
|
+
previousFirstItemIdRef.current = firstItemId;
|
|
641
|
+
previousScrollHeightRef.current = node.scrollHeight;
|
|
642
|
+
syncScrollBaseline(node);
|
|
643
|
+
// Item→group keys are cheap (data only). offsetTop queries are O(groups)
|
|
644
|
+
// layout reads — skip while pinned at the live tip (every stream token
|
|
645
|
+
// used to remeasure the whole timeline; that was the long-run lag).
|
|
646
|
+
const nextKeyByItemId = new Map<string, string>();
|
|
647
|
+
for (const { group, key } of groups) {
|
|
648
|
+
for (const itemId of timelineGroupItemIds(group)) {
|
|
649
|
+
nextKeyByItemId.set(itemId, key);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
groupKeyByItemIdRef.current = nextKeyByItemId;
|
|
653
|
+
const needOffsets = prepended || firstItemChanged || !pinnedRef.current || Boolean(hasNewer);
|
|
654
|
+
if (needOffsets) {
|
|
655
|
+
const nextOffsetByKey = new Map<string, number>();
|
|
656
|
+
for (const { key } of groups) {
|
|
657
|
+
const el = node.querySelector(`[data-og-group-key="${cssEscapeAttribute(key)}"]`);
|
|
658
|
+
if (el instanceof HTMLElement) {
|
|
659
|
+
nextOffsetByKey.set(key, el.offsetTop);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
groupOffsetByKeyRef.current = nextOffsetByKey;
|
|
266
663
|
}
|
|
267
664
|
if (!revealed && groups.length > 0) {
|
|
268
665
|
setRevealed(true);
|
|
269
666
|
}
|
|
270
|
-
}
|
|
667
|
+
});
|
|
271
668
|
|
|
272
|
-
// A cleared timeline (stream identity change) re-arms the reveal
|
|
273
|
-
//
|
|
669
|
+
// A cleared timeline (stream identity change) re-arms the reveal + prefetch
|
|
670
|
+
// gate and returns to bottom-follow for the next session's first paint.
|
|
274
671
|
useLayoutEffect(() => {
|
|
275
|
-
if (allGroups.length
|
|
672
|
+
if (allGroups.length > 0) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (revealed) {
|
|
276
676
|
setRevealed(false);
|
|
277
677
|
}
|
|
278
|
-
|
|
678
|
+
if (olderPrefetchArmedRef.current) {
|
|
679
|
+
olderPrefetchArmedRef.current = false;
|
|
680
|
+
setOlderPrefetchArmed(false);
|
|
681
|
+
}
|
|
682
|
+
olderLoadGateRef.current = "armed";
|
|
683
|
+
wantPinRef.current = false;
|
|
684
|
+
pendingJumpToStartRef.current = false;
|
|
685
|
+
previousFirstItemIdRef.current = null;
|
|
686
|
+
previousScrollHeightRef.current = 0;
|
|
687
|
+
lastScrollTopRef.current = 0;
|
|
688
|
+
lastMaxScrollRef.current = 0;
|
|
689
|
+
lastScrollHeightRef.current = 0;
|
|
690
|
+
lastClientHeightRef.current = 0;
|
|
691
|
+
groupKeyByItemIdRef.current = new Map();
|
|
692
|
+
groupOffsetByKeyRef.current = new Map();
|
|
693
|
+
foldMemoryRef.current.clear();
|
|
694
|
+
seenActivityIdsRef.current.clear();
|
|
695
|
+
applyPinned(true);
|
|
696
|
+
}, [allGroups.length, revealed, applyPinned]);
|
|
279
697
|
|
|
280
698
|
// Clear the bulk-paint marker a frame after it renders, so rows appended
|
|
281
699
|
// live (streams, new turns) animate exactly as before.
|
|
@@ -285,27 +703,21 @@ export function MessageTimeline({
|
|
|
285
703
|
return;
|
|
286
704
|
}
|
|
287
705
|
setBulkActive(true);
|
|
288
|
-
// Initial tails and prepended history mount from newest to oldest across
|
|
289
|
-
// frames. Keep every row born during that bulk window animation-free; only
|
|
290
|
-
// clear the marker after the authoritative group list is fully mounted.
|
|
291
|
-
if (mountingOlderGroups) {
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
706
|
const frame = requestFrame(() => setBulkActive(false));
|
|
295
707
|
return () => cancelFrame(frame);
|
|
296
|
-
}, [bulkRender, firstGroupKey
|
|
708
|
+
}, [bulkRender, firstGroupKey]);
|
|
297
709
|
|
|
298
|
-
// Prefetch older history
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
710
|
+
// Prefetch older history only after the reader scrolls up from the tip.
|
|
711
|
+
// Once armed, the sentinel trips early so backfill is usually rendered
|
|
712
|
+
// (and its scroll delta corrected) before the reader reaches it. Gated so
|
|
713
|
+
// a short prepend that leaves the sentinel intersecting cannot loop.
|
|
302
714
|
useEffect(() => {
|
|
303
715
|
const root = scrollRef.current;
|
|
304
716
|
const target = topSentinelRef.current;
|
|
305
717
|
if (
|
|
306
718
|
!root ||
|
|
307
719
|
!target ||
|
|
308
|
-
|
|
720
|
+
!olderPrefetchArmed ||
|
|
309
721
|
!hasOlder ||
|
|
310
722
|
loadingOlder ||
|
|
311
723
|
!onLoadOlder ||
|
|
@@ -313,24 +725,57 @@ export function MessageTimeline({
|
|
|
313
725
|
) {
|
|
314
726
|
return;
|
|
315
727
|
}
|
|
728
|
+
const observer = new IntersectionObserver(
|
|
729
|
+
(entries) => {
|
|
730
|
+
const intersecting = entries.some((entry) => entry.isIntersecting);
|
|
731
|
+
if (!intersecting) {
|
|
732
|
+
// Left the top band — next approach may load once.
|
|
733
|
+
olderLoadGateRef.current = "armed";
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (olderLoadGateRef.current !== "armed") {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
olderLoadGateRef.current = "cooling";
|
|
740
|
+
onLoadOlder();
|
|
741
|
+
},
|
|
742
|
+
{ root, rootMargin: OLDER_PREFETCH_ROOT_MARGIN },
|
|
743
|
+
);
|
|
744
|
+
observer.observe(target);
|
|
745
|
+
return () => observer.disconnect();
|
|
746
|
+
}, [olderPrefetchArmed, hasOlder, loadingOlder, onLoadOlder, firstGroupKey]);
|
|
747
|
+
|
|
748
|
+
// History view: page forward when the reader nears the bottom of the current
|
|
749
|
+
// non-tip window. Does not pull the whole gap — one density-bounded page.
|
|
750
|
+
useEffect(() => {
|
|
751
|
+
const root = scrollRef.current;
|
|
752
|
+
const target = bottomSentinelRef.current;
|
|
753
|
+
if (
|
|
754
|
+
!root ||
|
|
755
|
+
!target ||
|
|
756
|
+
!hasNewer ||
|
|
757
|
+
loadingNewer ||
|
|
758
|
+
!onLoadNewer ||
|
|
759
|
+
typeof IntersectionObserver === "undefined"
|
|
760
|
+
) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
316
763
|
const observer = new IntersectionObserver(
|
|
317
764
|
(entries) => {
|
|
318
765
|
if (entries.some((entry) => entry.isIntersecting)) {
|
|
319
|
-
|
|
766
|
+
onLoadNewer();
|
|
320
767
|
}
|
|
321
768
|
},
|
|
322
|
-
{ root, rootMargin: "
|
|
769
|
+
{ root, rootMargin: "0px 0px 1200px 0px" },
|
|
323
770
|
);
|
|
324
771
|
observer.observe(target);
|
|
325
772
|
return () => observer.disconnect();
|
|
326
|
-
}, [
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
// A change BELOW the anchor (a bottom append while scrolled up) leaves the
|
|
333
|
-
// anchor put, so `diff` is 0 and we leave scrollTop alone.
|
|
773
|
+
}, [hasNewer, loadingNewer, onLoadNewer, firstGroupKey]);
|
|
774
|
+
|
|
775
|
+
// Late layout that React commits cannot see (images decoding, fonts, code
|
|
776
|
+
// blocks) grows content without a commit. While pinned, soft-follow the tip;
|
|
777
|
+
// unpinned: do nothing — chasing those shifts was the wobble. Coalesce RO
|
|
778
|
+
// into one rAF.
|
|
334
779
|
useEffect(() => {
|
|
335
780
|
const node = scrollRef.current;
|
|
336
781
|
const inner = node?.firstElementChild;
|
|
@@ -338,138 +783,393 @@ export function MessageTimeline({
|
|
|
338
783
|
return;
|
|
339
784
|
}
|
|
340
785
|
const observer = new ResizeObserver(() => {
|
|
341
|
-
|
|
342
|
-
if (!current) {
|
|
786
|
+
if (resizeFollowRafRef.current !== null) {
|
|
343
787
|
return;
|
|
344
788
|
}
|
|
345
|
-
|
|
789
|
+
resizeFollowRafRef.current = requestFrame(() => {
|
|
790
|
+
resizeFollowRafRef.current = null;
|
|
791
|
+
const current = scrollRef.current;
|
|
792
|
+
if (!current) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (autoFollow && pinnedRef.current && !hasNewerRef.current) {
|
|
796
|
+
driveFollow(current);
|
|
797
|
+
}
|
|
798
|
+
});
|
|
346
799
|
});
|
|
347
800
|
observer.observe(inner);
|
|
348
|
-
|
|
349
|
-
|
|
801
|
+
// The scroller's own box moves the bottom too (window resize, composer
|
|
802
|
+
// growing): clientHeight changes with no inner resize and no commit.
|
|
803
|
+
observer.observe(node);
|
|
804
|
+
return () => {
|
|
805
|
+
observer.disconnect();
|
|
806
|
+
if (resizeFollowRafRef.current !== null) {
|
|
807
|
+
cancelFrame(resizeFollowRafRef.current);
|
|
808
|
+
resizeFollowRafRef.current = null;
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
}, [autoFollow, driveFollow]);
|
|
350
812
|
|
|
813
|
+
// Entering a non-tip history window: drop any live pin so the page bottom
|
|
814
|
+
// cannot re-stick follow across loadNewer. Leaving it (the tip window
|
|
815
|
+
// landed): honor a pending Jump-to-latest, or re-pin a reader already parked
|
|
816
|
+
// at what just became the live bottom — paging forward to the tip must not
|
|
817
|
+
// strand them unpinned watching new content grow below.
|
|
818
|
+
useEffect(() => {
|
|
819
|
+
if (hasNewer) {
|
|
820
|
+
stopFollow();
|
|
821
|
+
applyPinned(false);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const node = scrollRef.current;
|
|
825
|
+
if (!node) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (wantPinRef.current) {
|
|
829
|
+
wantPinRef.current = false;
|
|
830
|
+
if (autoFollow) {
|
|
831
|
+
applyPinned(true);
|
|
832
|
+
snapToBottom(node);
|
|
833
|
+
}
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (autoFollow && !pinnedRef.current && isNearBottom(node)) {
|
|
837
|
+
applyPinned(true);
|
|
838
|
+
}
|
|
839
|
+
}, [hasNewer, autoFollow, applyPinned, snapToBottom, stopFollow]);
|
|
840
|
+
|
|
841
|
+
// Unpin: wheel / keys always; pointer-armed geometric scroll-up only.
|
|
842
|
+
// Mid-turn fold + narration drops scrollTop while maxScroll stays flat —
|
|
843
|
+
// treating that as reader-up was the Jump-to-latest bug on soft-follow.
|
|
351
844
|
const onScroll = () => {
|
|
352
845
|
const node = scrollRef.current;
|
|
353
846
|
if (!node) {
|
|
354
847
|
return;
|
|
355
848
|
}
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
849
|
+
const previousTop = lastScrollTopRef.current;
|
|
850
|
+
const previousMaxScroll = lastMaxScrollRef.current;
|
|
851
|
+
const nextTop = node.scrollTop;
|
|
852
|
+
const nextMaxScroll = maxScrollOf(node);
|
|
853
|
+
const nextHeight = node.scrollHeight;
|
|
854
|
+
const readerUp = readerScrollUpPx(previousTop, nextTop, previousMaxScroll, nextMaxScroll);
|
|
855
|
+
const maxFell = nextMaxScroll < previousMaxScroll - 1;
|
|
856
|
+
const readerArmed = readerIntentArmRef.current;
|
|
857
|
+
const heightShrunk =
|
|
858
|
+
followRef.current.lastHeight > 0 &&
|
|
859
|
+
nextHeight < followRef.current.lastHeight - TIP_FOLLOW_SHRINK_EPS_PX;
|
|
860
|
+
|
|
861
|
+
if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
862
|
+
// Fold / composer shrink: compensate before baseline sync so driveFollow
|
|
863
|
+
// still sees the pre-shrink scrollTop (avoid double-subtract after clamp).
|
|
864
|
+
if (heightShrunk || maxFell) {
|
|
865
|
+
readerIntentArmRef.current = false;
|
|
866
|
+
driveFollow(node);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
syncScrollBaseline(node);
|
|
870
|
+
const nearBottomPinned = isNearBottom(node);
|
|
871
|
+
// Pointer-dragged scroll-up away from tip. Layout churn never arms this.
|
|
872
|
+
if (readerArmed && readerUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
|
|
873
|
+
readerIntentArmRef.current = false;
|
|
874
|
+
releasePinFromReader(node);
|
|
875
|
+
if (olderLoadGateRef.current === "cooling" && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
876
|
+
olderLoadGateRef.current = "armed";
|
|
877
|
+
}
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
// Growth debt / fold noise / camera echo: stay pinned and recover tip.
|
|
881
|
+
if (!nearBottomPinned || readerUp > TIP_FOLLOW_READER_UP_EPS_PX) {
|
|
882
|
+
driveFollow(node);
|
|
883
|
+
} else {
|
|
884
|
+
followRef.current = {
|
|
885
|
+
...followRef.current,
|
|
886
|
+
lastHeight: nextHeight,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
syncScrollBaseline(node);
|
|
893
|
+
const nearBottom = isNearBottom(node);
|
|
894
|
+
|
|
895
|
+
// Re-pin only when the reader moved toward/at the tip — not when a fold
|
|
896
|
+
// clamp dragged scrollTop down onto nearBottom.
|
|
897
|
+
const nextPinned =
|
|
898
|
+
!hasNewer && nearBottom && nextTop >= previousTop - TIP_FOLLOW_READER_UP_EPS_PX;
|
|
899
|
+
if (!nextPinned) {
|
|
900
|
+
stopFollow();
|
|
901
|
+
}
|
|
902
|
+
applyPinned(nextPinned);
|
|
903
|
+
// A far-from-bottom scroll while a Jump-to-latest is pending is the reader
|
|
904
|
+
// changing their mind: drop the latch, or a stale one (host rejected or
|
|
905
|
+
// never flipped hasNewer) would fire a surprise pin + snap whenever the
|
|
906
|
+
// reader later pages to the tip themselves. Our own snaps land AT the
|
|
907
|
+
// bottom, so their echoes read nearBottom and keep a live latch.
|
|
908
|
+
if (wantPinRef.current && !nearBottom) {
|
|
909
|
+
wantPinRef.current = false;
|
|
364
910
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
// scrolling away releases the bottom-follow.
|
|
368
|
-
if (nextPinned || !programmatic) {
|
|
369
|
-
pinnedRef.current = nextPinned;
|
|
370
|
-
setPinned(nextPinned);
|
|
911
|
+
if (nextPinned) {
|
|
912
|
+
return;
|
|
371
913
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
914
|
+
if (!olderPrefetchArmedRef.current) {
|
|
915
|
+
olderPrefetchArmedRef.current = true;
|
|
916
|
+
setOlderPrefetchArmed(true);
|
|
917
|
+
}
|
|
918
|
+
// Re-arm older prefetch only after leaving the top band (scroll down into
|
|
919
|
+
// content). Never re-arm/load from continued scroll toward y=0.
|
|
920
|
+
if (olderLoadGateRef.current === "cooling" && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
921
|
+
olderLoadGateRef.current = "armed";
|
|
377
922
|
}
|
|
378
923
|
};
|
|
379
924
|
|
|
380
925
|
return (
|
|
381
926
|
<LightboxProvider>
|
|
382
|
-
<
|
|
383
|
-
<
|
|
384
|
-
{
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
style={groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined}
|
|
390
|
-
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 [overflow-anchor:none] sm:px-6"
|
|
391
|
-
>
|
|
392
|
-
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
|
|
393
|
-
{groups.length === 0 && !working
|
|
394
|
-
? (emptyState ?? (
|
|
395
|
-
<p className="py-10 text-center text-sm text-og-fg-subtle">No activity yet.</p>
|
|
396
|
-
))
|
|
397
|
-
: null}
|
|
398
|
-
{hasOlder && !mountingOlderGroups ? (
|
|
927
|
+
<FoldMemoryProvider value={foldMemoryRef.current}>
|
|
928
|
+
<SeenActivityIdsProvider value={seenActivityIdsRef.current}>
|
|
929
|
+
<EntranceAnimationProvider value={!bulkRender}>
|
|
930
|
+
<TooltipProvider delayDuration={400}>
|
|
931
|
+
<div className={cn("og-root relative flex min-h-0 flex-col", className)}>
|
|
932
|
+
{/* Pinned: anchoring off so the tip-follow camera owns the motion.
|
|
933
|
+
Unpinned: native scroll anchoring holds the reader's place. */}
|
|
399
934
|
<div
|
|
400
|
-
ref={
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
935
|
+
ref={scrollRef}
|
|
936
|
+
tabIndex={-1}
|
|
937
|
+
onScroll={onScroll}
|
|
938
|
+
onWheel={onWheel}
|
|
939
|
+
onPointerDown={onPointerDown}
|
|
940
|
+
onKeyDown={onKeyDown}
|
|
941
|
+
style={groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined}
|
|
942
|
+
className={cn(
|
|
943
|
+
// tabIndex=-1 is programmatic only — never paint a focus ring on
|
|
944
|
+
// the whole scroller (click + Shift used to flash a blue outline).
|
|
945
|
+
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 sm:px-6 outline-none",
|
|
946
|
+
autoFollow && pinned && !hasNewer
|
|
947
|
+
? "[overflow-anchor:none]"
|
|
948
|
+
: "[overflow-anchor:auto]",
|
|
949
|
+
)}
|
|
950
|
+
>
|
|
951
|
+
<div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
|
|
952
|
+
{groups.length === 0
|
|
953
|
+
? (emptyState ?? (
|
|
954
|
+
<p className="py-10 text-center text-sm text-og-fg-subtle">
|
|
955
|
+
No activity yet.
|
|
956
|
+
</p>
|
|
957
|
+
))
|
|
958
|
+
: null}
|
|
959
|
+
{hasOlder && olderPrefetchArmed ? (
|
|
960
|
+
// Overlaid, not a layout row: mounting/unmounting the sentinel
|
|
961
|
+
// must never shift content (that shift was itself a wobble).
|
|
962
|
+
<div
|
|
963
|
+
ref={topSentinelRef}
|
|
964
|
+
data-og-top-sentinel=""
|
|
965
|
+
data-og-timeline-chrome=""
|
|
966
|
+
aria-hidden="true"
|
|
967
|
+
className="pointer-events-none absolute inset-x-0 top-0 h-px"
|
|
968
|
+
/>
|
|
969
|
+
) : null}
|
|
970
|
+
{groups.map(({ group, key }, index) => {
|
|
971
|
+
const next = groups[index + 1]?.group;
|
|
972
|
+
const contextCompactionCount =
|
|
973
|
+
group.kind === "turn"
|
|
974
|
+
? (group.contextCompactionCount ?? 0)
|
|
975
|
+
: group.kind === "activity" &&
|
|
976
|
+
next?.kind === "item" &&
|
|
977
|
+
next.item.kind === "context-compaction" &&
|
|
978
|
+
next.item.phase === "compacted"
|
|
979
|
+
? 1
|
|
980
|
+
: 0;
|
|
981
|
+
return (
|
|
982
|
+
<div key={key} data-og-timeline-group-anchor="" data-og-group-key={key}>
|
|
983
|
+
<TimelineGroupView
|
|
984
|
+
group={group}
|
|
985
|
+
renderMessageText={renderMessageText}
|
|
986
|
+
onOpenSession={onOpenSession}
|
|
987
|
+
onMemoryClick={onMemoryClick}
|
|
988
|
+
onReconnect={onReconnect}
|
|
989
|
+
resolveProviderLogo={resolveProviderLogo}
|
|
990
|
+
toolRegistry={toolRegistry}
|
|
991
|
+
turnSummary={turnSummary}
|
|
992
|
+
foldLiveCluster={isAgentProgress(next)}
|
|
993
|
+
trailingAgentText={trailingAgentTextAfterTurn(group, next)}
|
|
994
|
+
contextCompactionCount={
|
|
995
|
+
contextCompactionCount > 0 ? contextCompactionCount : undefined
|
|
996
|
+
}
|
|
997
|
+
/>
|
|
998
|
+
</div>
|
|
999
|
+
);
|
|
1000
|
+
})}
|
|
1001
|
+
{hasNewer ? (
|
|
1002
|
+
<div
|
|
1003
|
+
ref={bottomSentinelRef}
|
|
1004
|
+
data-og-bottom-sentinel=""
|
|
1005
|
+
data-og-timeline-chrome=""
|
|
1006
|
+
aria-hidden="true"
|
|
1007
|
+
className="h-px w-full"
|
|
1008
|
+
/>
|
|
1009
|
+
) : null}
|
|
1010
|
+
</div>
|
|
430
1011
|
</div>
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
1012
|
+
<AnimatePresence>
|
|
1013
|
+
{loadingOlder ||
|
|
1014
|
+
loadingOldest ||
|
|
1015
|
+
(hasOlder && onJumpToStart && olderPrefetchArmed) ? (
|
|
1016
|
+
// Floating over the scroller (not a timeline row) so showing and
|
|
1017
|
+
// hiding it never reflows history under the reader.
|
|
1018
|
+
<motion.div
|
|
1019
|
+
initial={{ opacity: 0, y: -6 }}
|
|
1020
|
+
animate={{ opacity: 1, y: 0 }}
|
|
1021
|
+
exit={{ opacity: 0, y: -6 }}
|
|
1022
|
+
transition={{ duration: 0.15, ease: "easeOut" }}
|
|
1023
|
+
data-og-loading-older=""
|
|
1024
|
+
aria-live="polite"
|
|
1025
|
+
className="absolute inset-x-0 top-3 z-10 flex justify-center gap-2"
|
|
1026
|
+
>
|
|
1027
|
+
{loadingOlder || loadingOldest ? (
|
|
1028
|
+
<span className="pointer-events-none inline-flex items-center rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1 text-xs font-medium shadow-og-md backdrop-blur">
|
|
1029
|
+
<span className="og-shimmer-text">
|
|
1030
|
+
{loadingOldest ? "Jumping to start…" : "Loading earlier activity…"}
|
|
1031
|
+
</span>
|
|
1032
|
+
</span>
|
|
1033
|
+
) : null}
|
|
1034
|
+
{hasOlder && onJumpToStart && !loadingOldest ? (
|
|
1035
|
+
<button
|
|
1036
|
+
type="button"
|
|
1037
|
+
data-og-jump-to-start=""
|
|
1038
|
+
disabled={loadingOlder}
|
|
1039
|
+
onClick={() => {
|
|
1040
|
+
applyPinned(false);
|
|
1041
|
+
pendingJumpToStartRef.current = true;
|
|
1042
|
+
const seq = ++jumpToStartSeqRef.current;
|
|
1043
|
+
const node = scrollRef.current;
|
|
1044
|
+
void Promise.resolve(onJumpToStart()).then(
|
|
1045
|
+
() => {
|
|
1046
|
+
// The commit that swaps in the oldest window consumes
|
|
1047
|
+
// the flag against the new DOM; this write covers the
|
|
1048
|
+
// already-committed order and the no-window-change
|
|
1049
|
+
// case (jumping within the current window).
|
|
1050
|
+
const scroller = scrollRef.current ?? node;
|
|
1051
|
+
if (scroller) {
|
|
1052
|
+
scroller.scrollTop = 0;
|
|
1053
|
+
}
|
|
1054
|
+
// A host may resolve without ever changing the
|
|
1055
|
+
// window (already on the oldest page). Any swap
|
|
1056
|
+
// commit runs its layout effect before the next
|
|
1057
|
+
// frame, so a flag still armed by then is the
|
|
1058
|
+
// no-change case — clear it, or a LATER prepend
|
|
1059
|
+
// would spuriously jump the reader to the top.
|
|
1060
|
+
requestFrame(() => {
|
|
1061
|
+
if (jumpToStartSeqRef.current === seq) {
|
|
1062
|
+
pendingJumpToStartRef.current = false;
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
},
|
|
1066
|
+
() => {
|
|
1067
|
+
if (jumpToStartSeqRef.current === seq) {
|
|
1068
|
+
pendingJumpToStartRef.current = false;
|
|
1069
|
+
}
|
|
1070
|
+
},
|
|
1071
|
+
);
|
|
1072
|
+
}}
|
|
1073
|
+
className={cn(
|
|
1074
|
+
"inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
|
|
1075
|
+
"text-xs font-medium text-og-fg shadow-og-md backdrop-blur",
|
|
1076
|
+
"hover:border-og-border-strong disabled:opacity-60",
|
|
1077
|
+
)}
|
|
1078
|
+
>
|
|
1079
|
+
<ArrowUpToLineIcon className="size-3.5" />
|
|
1080
|
+
Jump to start
|
|
1081
|
+
</button>
|
|
1082
|
+
) : null}
|
|
1083
|
+
</motion.div>
|
|
1084
|
+
) : null}
|
|
1085
|
+
</AnimatePresence>
|
|
1086
|
+
<AnimatePresence>
|
|
1087
|
+
{loadingNewer ? (
|
|
1088
|
+
<motion.div
|
|
1089
|
+
initial={{ opacity: 0, y: 6 }}
|
|
1090
|
+
animate={{ opacity: 1, y: 0 }}
|
|
1091
|
+
exit={{ opacity: 0, y: 6 }}
|
|
1092
|
+
transition={{ duration: 0.15, ease: "easeOut" }}
|
|
1093
|
+
data-og-loading-newer=""
|
|
1094
|
+
aria-live="polite"
|
|
1095
|
+
className="pointer-events-none absolute inset-x-0 bottom-14 z-10 flex justify-center"
|
|
1096
|
+
>
|
|
1097
|
+
<span className="inline-flex items-center rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1 text-xs font-medium shadow-og-md backdrop-blur">
|
|
1098
|
+
<span className="og-shimmer-text">Loading later activity…</span>
|
|
1099
|
+
</span>
|
|
1100
|
+
</motion.div>
|
|
1101
|
+
) : null}
|
|
1102
|
+
</AnimatePresence>
|
|
1103
|
+
<AnimatePresence>
|
|
1104
|
+
{((!pinned && autoFollow) || hasNewer) && autoFollow ? (
|
|
1105
|
+
<motion.button
|
|
1106
|
+
type="button"
|
|
1107
|
+
initial={{ opacity: 0, y: 8 }}
|
|
1108
|
+
animate={{ opacity: 1, y: 0 }}
|
|
1109
|
+
exit={{ opacity: 0, y: 8 }}
|
|
1110
|
+
transition={{ duration: 0.15, ease: "easeOut" }}
|
|
1111
|
+
onClick={() => {
|
|
1112
|
+
if (hasNewer) {
|
|
1113
|
+
// Do not pin against the current history page — its bottom
|
|
1114
|
+
// is not the tip. The pin + snap run when the tip window
|
|
1115
|
+
// actually lands (`hasNewer` flips false).
|
|
1116
|
+
wantPinRef.current = true;
|
|
1117
|
+
const node = scrollRef.current;
|
|
1118
|
+
if (onJumpToLatest) {
|
|
1119
|
+
void Promise.resolve(onJumpToLatest()).then(
|
|
1120
|
+
() => {
|
|
1121
|
+
// Covers a host that flipped hasNewer before
|
|
1122
|
+
// resolving; otherwise the tip-window commit
|
|
1123
|
+
// consumes the flag.
|
|
1124
|
+
const current = scrollRef.current;
|
|
1125
|
+
if (current && wantPinRef.current && !hasNewerRef.current) {
|
|
1126
|
+
wantPinRef.current = false;
|
|
1127
|
+
applyPinned(true);
|
|
1128
|
+
snapToBottom(current);
|
|
1129
|
+
}
|
|
1130
|
+
},
|
|
1131
|
+
() => {
|
|
1132
|
+
// The tip reload failed (ordinary network error):
|
|
1133
|
+
// an armed latch would fire a surprise snap when
|
|
1134
|
+
// the reader later pages to the tip themselves.
|
|
1135
|
+
wantPinRef.current = false;
|
|
1136
|
+
},
|
|
1137
|
+
);
|
|
1138
|
+
} else if (node) {
|
|
1139
|
+
// No tip reload available: jump within the in-memory
|
|
1140
|
+
// window so the newer sentinel can page forward; the
|
|
1141
|
+
// latch pins if the tip window eventually lands.
|
|
1142
|
+
snapToBottom(node);
|
|
1143
|
+
}
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
const node = scrollRef.current;
|
|
1147
|
+
if (node) {
|
|
1148
|
+
applyPinned(true);
|
|
1149
|
+
snapToBottom(node);
|
|
1150
|
+
}
|
|
1151
|
+
}}
|
|
1152
|
+
className={cn(
|
|
1153
|
+
"absolute bottom-4 left-1/2 -translate-x-1/2",
|
|
1154
|
+
"inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
|
|
1155
|
+
"text-xs font-medium text-og-fg shadow-og-md backdrop-blur",
|
|
1156
|
+
"hover:border-og-border-strong",
|
|
1157
|
+
)}
|
|
1158
|
+
>
|
|
1159
|
+
<ArrowDownIcon className="size-3.5" />
|
|
1160
|
+
Jump to latest
|
|
1161
|
+
</motion.button>
|
|
1162
|
+
) : null}
|
|
1163
|
+
</AnimatePresence>
|
|
1164
|
+
</div>
|
|
1165
|
+
</TooltipProvider>
|
|
1166
|
+
</EntranceAnimationProvider>
|
|
1167
|
+
</SeenActivityIdsProvider>
|
|
1168
|
+
</FoldMemoryProvider>
|
|
464
1169
|
</LightboxProvider>
|
|
465
1170
|
);
|
|
466
1171
|
}
|
|
467
1172
|
|
|
468
|
-
type MountedGroupWindow = {
|
|
469
|
-
groupKeys: string[];
|
|
470
|
-
visibleStart: number;
|
|
471
|
-
};
|
|
472
|
-
|
|
473
1173
|
type KeyedTimelineGroup = {
|
|
474
1174
|
group: TimelineGroup;
|
|
475
1175
|
key: string;
|
|
@@ -499,7 +1199,10 @@ function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGr
|
|
|
499
1199
|
let retainedKey: string | undefined;
|
|
500
1200
|
for (const itemId of itemIds) {
|
|
501
1201
|
const previous = previousByItemId.get(itemId);
|
|
502
|
-
|
|
1202
|
+
// Retain only same-kind matches. Activity → turn wrap must NOT keep the
|
|
1203
|
+
// activity chip's React key: that reused a collapsed TurnSummary and
|
|
1204
|
+
// skipped the settle beat (insta-collapse / content flash).
|
|
1205
|
+
if (previous && previous.group.kind === group.kind && !usedKeys.has(previous.key)) {
|
|
503
1206
|
retainedKey = previous.key;
|
|
504
1207
|
break;
|
|
505
1208
|
}
|
|
@@ -524,82 +1227,6 @@ function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGr
|
|
|
524
1227
|
return keyedGroups;
|
|
525
1228
|
}
|
|
526
1229
|
|
|
527
|
-
/**
|
|
528
|
-
* Bulk history is already projected into its authoritative order before this
|
|
529
|
-
* hook runs. Mount its newest group first, then prepend one older group per
|
|
530
|
-
* animation frame so low-end browsers can paint and service input between
|
|
531
|
-
* React/Markdown commits instead of doing the entire tail in one long task.
|
|
532
|
-
*
|
|
533
|
-
* A live append does not move the first mounted key, so it is included
|
|
534
|
-
* immediately in the mounted suffix. A history prepend moves that key deeper
|
|
535
|
-
* in the array; shifting `visibleStart` by that exact prefix keeps every
|
|
536
|
-
* existing row mounted while the new prefix is revealed. If projection
|
|
537
|
-
* coalesces the seam item itself away, the earliest surviving mounted key
|
|
538
|
-
* provides the same anchor. Replacements with no shared mounted key (for
|
|
539
|
-
* example a session switch or clear-view) start a fresh suffix.
|
|
540
|
-
*/
|
|
541
|
-
function useProgressivelyMountedGroups(allGroups: KeyedTimelineGroup[]): {
|
|
542
|
-
mountedGroups: KeyedTimelineGroup[];
|
|
543
|
-
mountingOlderGroups: boolean;
|
|
544
|
-
} {
|
|
545
|
-
const previousGroupKeysRef = useRef<string[]>([]);
|
|
546
|
-
const groupKeys = useMemo(() => {
|
|
547
|
-
const nextKeys = allGroups.map((group) => group.key);
|
|
548
|
-
const previousKeys = previousGroupKeysRef.current;
|
|
549
|
-
return nextKeys.length === previousKeys.length &&
|
|
550
|
-
nextKeys.every((key, index) => key === previousKeys[index])
|
|
551
|
-
? previousKeys
|
|
552
|
-
: nextKeys;
|
|
553
|
-
}, [allGroups]);
|
|
554
|
-
useLayoutEffect(() => {
|
|
555
|
-
previousGroupKeysRef.current = groupKeys;
|
|
556
|
-
}, [groupKeys]);
|
|
557
|
-
const [window, setWindow] = useState<MountedGroupWindow>(() => ({
|
|
558
|
-
groupKeys,
|
|
559
|
-
visibleStart: Math.max(0, allGroups.length - INITIAL_MOUNTED_GROUPS),
|
|
560
|
-
}));
|
|
561
|
-
|
|
562
|
-
const lastPossibleStart = Math.max(0, allGroups.length - INITIAL_MOUNTED_GROUPS);
|
|
563
|
-
let visibleStart = 0;
|
|
564
|
-
if (allGroups.length > 0) {
|
|
565
|
-
const currentIndexByKey = new Map(groupKeys.map((key, index) => [key, index]));
|
|
566
|
-
let retainedStart: number | undefined;
|
|
567
|
-
for (const key of window.groupKeys.slice(window.visibleStart)) {
|
|
568
|
-
const index = currentIndexByKey.get(key);
|
|
569
|
-
if (index !== undefined) {
|
|
570
|
-
retainedStart = index;
|
|
571
|
-
break;
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
visibleStart =
|
|
575
|
-
retainedStart === undefined ? lastPossibleStart : Math.min(retainedStart, lastPossibleStart);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
useEffect(() => {
|
|
579
|
-
// Synchronize a prepend/replacement before scheduling its first reveal.
|
|
580
|
-
if (window.groupKeys !== groupKeys || window.visibleStart !== visibleStart) {
|
|
581
|
-
setWindow({ groupKeys, visibleStart });
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
if (visibleStart === 0) {
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
const frame = requestFrame(() => {
|
|
588
|
-
setWindow((current) =>
|
|
589
|
-
current.groupKeys === groupKeys && current.visibleStart > 0
|
|
590
|
-
? { ...current, visibleStart: current.visibleStart - 1 }
|
|
591
|
-
: current,
|
|
592
|
-
);
|
|
593
|
-
});
|
|
594
|
-
return () => cancelFrame(frame);
|
|
595
|
-
}, [groupKeys, visibleStart, window.groupKeys, window.visibleStart]);
|
|
596
|
-
|
|
597
|
-
return {
|
|
598
|
-
mountedGroups: allGroups.slice(visibleStart),
|
|
599
|
-
mountingOlderGroups: visibleStart > 0,
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
|
|
603
1230
|
function requestFrame(callback: FrameRequestCallback): number {
|
|
604
1231
|
if (typeof requestAnimationFrame === "function") {
|
|
605
1232
|
return requestAnimationFrame(callback);
|
|
@@ -615,10 +1242,11 @@ function cancelFrame(id: number): void {
|
|
|
615
1242
|
window.clearTimeout(id);
|
|
616
1243
|
}
|
|
617
1244
|
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
// creates a new group object, and
|
|
621
|
-
// so ordinary streaming and host
|
|
1245
|
+
// The full loaded window stays mounted, so settled history rows must be cheap
|
|
1246
|
+
// on every commit: projection reuses group objects for unchanged groups, so
|
|
1247
|
+
// memo skips them. Live projection creates a new group object, and
|
|
1248
|
+
// behavior/callback changes are separate props, so ordinary streaming and host
|
|
1249
|
+
// updates still invalidate immediately.
|
|
622
1250
|
const TimelineGroupView = memo(function TimelineGroupView({
|
|
623
1251
|
group,
|
|
624
1252
|
renderMessageText,
|
|
@@ -629,7 +1257,10 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
629
1257
|
toolRegistry,
|
|
630
1258
|
turnSummary,
|
|
631
1259
|
insideTurn = false,
|
|
1260
|
+
nestClusterChips = false,
|
|
632
1261
|
foldLiveCluster = false,
|
|
1262
|
+
trailingAgentText,
|
|
1263
|
+
contextCompactionCount,
|
|
633
1264
|
}: {
|
|
634
1265
|
group: TimelineGroup;
|
|
635
1266
|
renderMessageText?:
|
|
@@ -649,39 +1280,126 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
649
1280
|
failure surface, so nested chips stay tinted but quiet (no repeated
|
|
650
1281
|
failure text, no auto-open) — one loud error, N calm sub-expands. */
|
|
651
1282
|
insideTurn?: boolean;
|
|
1283
|
+
/**
|
|
1284
|
+
* Parent turn has ≥2 foldable activity clusters. Only then do we wrap settled
|
|
1285
|
+
* clusters in nested chips — a single cluster under "N steps" is redundant.
|
|
1286
|
+
* During outer settle chrome, nested chips stay force-open (structure kept,
|
|
1287
|
+
* height stable) instead of flat-mapping to bare rails.
|
|
1288
|
+
*/
|
|
1289
|
+
nestClusterChips?: boolean;
|
|
1290
|
+
/**
|
|
1291
|
+
* Final agent answer extracted as a sibling after a settled turn — folded
|
|
1292
|
+
* into "Copy turn" so the chip copies the whole assistant reply, not only
|
|
1293
|
+
* mid-turn narration still inside the fold.
|
|
1294
|
+
*/
|
|
1295
|
+
trailingAgentText?: string | undefined;
|
|
1296
|
+
/** Secondary chip facet when this fold sits next to a compaction landmark. */
|
|
1297
|
+
contextCompactionCount?: number | undefined;
|
|
652
1298
|
}) {
|
|
1299
|
+
const enter = useEntranceAnimation();
|
|
1300
|
+
const settleChrome = useTurnSettleOpen();
|
|
1301
|
+
const foldMemory = useFoldMemory();
|
|
1302
|
+
// Settled (or live-fold) activity clusters get a chip. Inside an expanded
|
|
1303
|
+
// turn that is the second layer — quiet nested chips under the outer turn
|
|
1304
|
+
// summary when contiguous activity naturally clusters (≥2 only).
|
|
1305
|
+
const activityShouldFold =
|
|
1306
|
+
group.kind === "activity" &&
|
|
1307
|
+
Boolean(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
|
|
1308
|
+
// Latch live→folded so a top-level shell that was already mounted open can
|
|
1309
|
+
// start the settle beat without remounting bare rail → wrapper.
|
|
1310
|
+
const liveActivitySettle = useLiveSettleFold(activityShouldFold && !insideTurn);
|
|
1311
|
+
const turnDefaultOpen = !insideTurn && group.kind === "turn" && group.outcome === "failed";
|
|
1312
|
+
// activity-* → turn-* remount: carry resting state so settleFold does not
|
|
1313
|
+
// re-open a chip the reader already watched collapse.
|
|
1314
|
+
if (group.kind === "turn" && foldMemory && !insideTurn) {
|
|
1315
|
+
inheritFoldRestingState(
|
|
1316
|
+
foldMemory,
|
|
1317
|
+
group.id,
|
|
1318
|
+
group.groups.flatMap((child) => (child.kind === "activity" ? [child.id] : [])),
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
const settleFold =
|
|
1322
|
+
group.kind === "turn" ? Boolean(enter && !insideTurn && !turnDefaultOpen) : liveActivitySettle;
|
|
653
1323
|
switch (group.kind) {
|
|
654
1324
|
case "activity":
|
|
655
|
-
|
|
1325
|
+
if (insideTurn) {
|
|
1326
|
+
// Nested chips whenever the parent has ≥2 clusters. During outer settle
|
|
1327
|
+
// chrome they stay force-open so structure is visible and height stays
|
|
1328
|
+
// stable through collapse — never flat-map to bare rails (that flash
|
|
1329
|
+
// was the "inner steps vanish then reappear nested" bug). Key flips
|
|
1330
|
+
// when chrome clears so they remount closed inside the already-hidden
|
|
1331
|
+
// parent (safe; mid-collapse remount of closed chips was the snap).
|
|
1332
|
+
// foldKey memory overrides the force-open: a cluster that already
|
|
1333
|
+
// settled closed pre-wrap was showing as a CHIP, so mounting it closed
|
|
1334
|
+
// is both the stable height and the honest state — force-opening it
|
|
1335
|
+
// was the "already-collapsed cluster auto-expands at the end" reopen.
|
|
1336
|
+
const useNestedChip = nestClusterChips && activityShouldFold;
|
|
1337
|
+
if (!useNestedChip) {
|
|
1338
|
+
return (
|
|
1339
|
+
<ActivityRail
|
|
1340
|
+
items={group.items}
|
|
1341
|
+
onOpenSession={onOpenSession}
|
|
1342
|
+
onMemoryClick={onMemoryClick}
|
|
1343
|
+
toolRegistry={toolRegistry}
|
|
1344
|
+
bare
|
|
1345
|
+
/>
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
return (
|
|
1349
|
+
<TurnSummary
|
|
1350
|
+
key={settleChrome ? "settle" : "rest"}
|
|
1351
|
+
items={group.items}
|
|
1352
|
+
outcome={group.outcome}
|
|
1353
|
+
failureText={undefined}
|
|
1354
|
+
bare
|
|
1355
|
+
defaultOpen={settleChrome ? true : undefined}
|
|
1356
|
+
foldKey={group.id}
|
|
1357
|
+
facets={turnSummary?.facets}
|
|
1358
|
+
contextCompactionCount={contextCompactionCount}
|
|
1359
|
+
>
|
|
1360
|
+
<ActivityRail
|
|
1361
|
+
items={group.items}
|
|
1362
|
+
onOpenSession={onOpenSession}
|
|
1363
|
+
onMemoryClick={onMemoryClick}
|
|
1364
|
+
toolRegistry={toolRegistry}
|
|
1365
|
+
bare
|
|
1366
|
+
/>
|
|
1367
|
+
</TurnSummary>
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
// Always the same TurnSummary shell while live so mid-turn fold only
|
|
1371
|
+
// flips settleFold (collapse) instead of remounting bare rail → wrapper.
|
|
1372
|
+
return (
|
|
656
1373
|
<TurnSummary
|
|
657
1374
|
items={group.items}
|
|
658
1375
|
outcome={group.outcome}
|
|
659
|
-
failureText={
|
|
660
|
-
defaultOpen={!
|
|
661
|
-
|
|
1376
|
+
failureText={group.failureText}
|
|
1377
|
+
defaultOpen={!activityShouldFold || group.outcome === "failed" ? true : undefined}
|
|
1378
|
+
foldKey={group.id}
|
|
662
1379
|
facets={turnSummary?.facets}
|
|
1380
|
+
settleFold={settleFold}
|
|
1381
|
+
contextCompactionCount={contextCompactionCount}
|
|
663
1382
|
>
|
|
664
|
-
<
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1383
|
+
<FoldBody>
|
|
1384
|
+
<TurnRailFrame>
|
|
1385
|
+
<ActivityRail
|
|
1386
|
+
items={group.items}
|
|
1387
|
+
onOpenSession={onOpenSession}
|
|
1388
|
+
onMemoryClick={onMemoryClick}
|
|
1389
|
+
toolRegistry={toolRegistry}
|
|
1390
|
+
bare
|
|
1391
|
+
/>
|
|
1392
|
+
</TurnRailFrame>
|
|
1393
|
+
</FoldBody>
|
|
671
1394
|
</TurnSummary>
|
|
672
|
-
) : (
|
|
673
|
-
// A nested live cluster hangs on the turn's rail (bare); a top-level one
|
|
674
|
-
// owns its own rail.
|
|
675
|
-
<ActivityRail
|
|
676
|
-
items={group.items}
|
|
677
|
-
onOpenSession={onOpenSession}
|
|
678
|
-
onMemoryClick={onMemoryClick}
|
|
679
|
-
toolRegistry={toolRegistry}
|
|
680
|
-
bare={insideTurn}
|
|
681
|
-
/>
|
|
682
1395
|
);
|
|
683
1396
|
case "turn": {
|
|
684
1397
|
const activityItems = flattenActivityItems(group.groups);
|
|
1398
|
+
// Second-layer chips only when there are natural multi-cluster seams —
|
|
1399
|
+
// otherwise the outer turn chip alone is enough ("N steps" wrapping one
|
|
1400
|
+
// more "N steps" was the redundant double fold).
|
|
1401
|
+
const nestClusters = foldableActivityClusterCount(group.groups) >= 2;
|
|
1402
|
+
const turnCopyText = collectTurnCopyText(group.groups, trailingAgentText);
|
|
685
1403
|
const body = group.groups.map((child) => (
|
|
686
1404
|
<TimelineGroupView
|
|
687
1405
|
key={timelineGroupKey(child)}
|
|
@@ -694,32 +1412,32 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
694
1412
|
toolRegistry={toolRegistry}
|
|
695
1413
|
turnSummary={turnSummary}
|
|
696
1414
|
insideTurn
|
|
1415
|
+
nestClusterChips={nestClusters}
|
|
697
1416
|
/>
|
|
698
1417
|
));
|
|
699
1418
|
return (
|
|
700
1419
|
<TurnSummary
|
|
701
1420
|
items={activityItems}
|
|
702
1421
|
outcome={group.outcome}
|
|
703
|
-
// One loud error at the top; nested sub-turn chips stay calm — the
|
|
704
|
-
// parent already renders the failure reason, so clear it here exactly
|
|
705
|
-
// as the nested activity-cluster case does.
|
|
706
1422
|
failureText={insideTurn ? undefined : group.failureText}
|
|
707
1423
|
durationMs={durationBetween(group.startedAt, group.endedAt)}
|
|
708
|
-
defaultOpen={
|
|
1424
|
+
defaultOpen={turnDefaultOpen ? true : undefined}
|
|
709
1425
|
bare={insideTurn}
|
|
1426
|
+
foldKey={group.id}
|
|
710
1427
|
facets={turnSummary?.facets}
|
|
1428
|
+
settleFold={settleFold}
|
|
1429
|
+
copyText={insideTurn ? undefined : turnCopyText}
|
|
1430
|
+
contextCompactionCount={contextCompactionCount ?? group.contextCompactionCount}
|
|
711
1431
|
>
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
</div>
|
|
722
|
-
)}
|
|
1432
|
+
<FoldBody>
|
|
1433
|
+
{insideTurn ? (
|
|
1434
|
+
// A nested turn is already on an ancestor rail — its body just stacks
|
|
1435
|
+
// flush (the bare-node body already indents it), so no second rule.
|
|
1436
|
+
<div className="flex flex-col gap-4">{body}</div>
|
|
1437
|
+
) : (
|
|
1438
|
+
<TurnRailFrame>{body}</TurnRailFrame>
|
|
1439
|
+
)}
|
|
1440
|
+
</FoldBody>
|
|
723
1441
|
</TurnSummary>
|
|
724
1442
|
);
|
|
725
1443
|
}
|
|
@@ -736,6 +1454,42 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
736
1454
|
}
|
|
737
1455
|
});
|
|
738
1456
|
|
|
1457
|
+
/**
|
|
1458
|
+
* Body under a turn/activity chip. Remount flashes are gated by the timeline
|
|
1459
|
+
* seen-activity-id map (not by killing entrance): FoldBody used to force
|
|
1460
|
+
* entrance off, which made every live tool pop with no fade.
|
|
1461
|
+
*/
|
|
1462
|
+
function FoldBody({ children }: { children: ReactNode }) {
|
|
1463
|
+
return <>{children}</>;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/** Stable left rule for turn/activity bodies — always present so settle wrap
|
|
1467
|
+
never inserts or removes the rail chrome. */
|
|
1468
|
+
function TurnRailFrame({ children }: { children: ReactNode }) {
|
|
1469
|
+
return (
|
|
1470
|
+
<div className="flex flex-col gap-4 border-l-2 border-og-border pl-3 sm:pl-4">{children}</div>
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* True once THIS component instance has seen its group transition from
|
|
1476
|
+
* unfolded to folded — i.e. the reader watched the rows live and the fold is
|
|
1477
|
+
* new information worth choreographing. Latched: TurnSummary captures the flag
|
|
1478
|
+
* at its own mount (the flip render), so later prop churn is inert. History
|
|
1479
|
+
* that mounts already folded initializes folded and never latches.
|
|
1480
|
+
*/
|
|
1481
|
+
function useLiveSettleFold(folded: boolean): boolean {
|
|
1482
|
+
const previousFoldedRef = useRef(folded);
|
|
1483
|
+
const latchedRef = useRef(false);
|
|
1484
|
+
if (!previousFoldedRef.current && folded) {
|
|
1485
|
+
latchedRef.current = true;
|
|
1486
|
+
}
|
|
1487
|
+
useLayoutEffect(() => {
|
|
1488
|
+
previousFoldedRef.current = folded;
|
|
1489
|
+
});
|
|
1490
|
+
return latchedRef.current;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
739
1493
|
function timelineGroupKey(group: TimelineGroup): string {
|
|
740
1494
|
switch (group.kind) {
|
|
741
1495
|
case "item":
|
|
@@ -790,6 +1544,17 @@ function clusterIsSettled(group: Extract<TimelineGroup, { kind: "activity" }>):
|
|
|
790
1544
|
});
|
|
791
1545
|
}
|
|
792
1546
|
|
|
1547
|
+
/** Settled activity clusters that could become nested chips under a turn. */
|
|
1548
|
+
function foldableActivityClusterCount(groups: readonly TimelineGroup[]): number {
|
|
1549
|
+
let count = 0;
|
|
1550
|
+
for (const child of groups) {
|
|
1551
|
+
if (child.kind === "activity" && (child.outcome || clusterIsSettled(child))) {
|
|
1552
|
+
count += 1;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
return count;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
793
1558
|
function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
|
|
794
1559
|
const items: ActivityItem[] = [];
|
|
795
1560
|
for (const group of groups) {
|
|
@@ -802,6 +1567,89 @@ function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
|
|
|
802
1567
|
return items;
|
|
803
1568
|
}
|
|
804
1569
|
|
|
1570
|
+
/** Assistant prose inside a turn fold (mid-turn narration), joined for copy. */
|
|
1571
|
+
function collectAgentMessageText(groups: readonly TimelineGroup[]): string {
|
|
1572
|
+
const parts: string[] = [];
|
|
1573
|
+
for (const group of groups) {
|
|
1574
|
+
if (group.kind === "item" && group.item.kind === "agent-message") {
|
|
1575
|
+
const text = group.item.text.trim();
|
|
1576
|
+
if (text.length > 0) {
|
|
1577
|
+
parts.push(text);
|
|
1578
|
+
}
|
|
1579
|
+
} else if (group.kind === "turn") {
|
|
1580
|
+
const nested = collectAgentMessageText(group.groups);
|
|
1581
|
+
if (nested.length > 0) {
|
|
1582
|
+
parts.push(nested);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
return parts.join("\n\n");
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/** Non-null turnIds projected into a turn body (activity + nested messages). */
|
|
1590
|
+
function collectTurnIdsFromGroups(groups: readonly TimelineGroup[]): Set<string> {
|
|
1591
|
+
const ids = new Set<string>();
|
|
1592
|
+
for (const group of groups) {
|
|
1593
|
+
if (group.kind === "item") {
|
|
1594
|
+
const turnId = "turnId" in group.item ? group.item.turnId : null;
|
|
1595
|
+
if (typeof turnId === "string" && turnId.length > 0) {
|
|
1596
|
+
ids.add(turnId);
|
|
1597
|
+
}
|
|
1598
|
+
} else if (group.kind === "activity") {
|
|
1599
|
+
for (const item of group.items) {
|
|
1600
|
+
if (item.turnId) {
|
|
1601
|
+
ids.add(item.turnId);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
} else if (group.kind === "turn") {
|
|
1605
|
+
for (const nested of collectTurnIdsFromGroups(group.groups)) {
|
|
1606
|
+
ids.add(nested);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
return ids;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* Settled turns lift the final agent answer out as a sibling group. Include it
|
|
1615
|
+
* in "Copy turn" when present so the chip copies the full assistant reply.
|
|
1616
|
+
* Fenced by turnId so the next turn's answer is never stolen.
|
|
1617
|
+
*/
|
|
1618
|
+
export function trailingAgentTextAfterTurn(
|
|
1619
|
+
group: TimelineGroup,
|
|
1620
|
+
next: TimelineGroup | undefined,
|
|
1621
|
+
): string | undefined {
|
|
1622
|
+
if (group.kind !== "turn") {
|
|
1623
|
+
return undefined;
|
|
1624
|
+
}
|
|
1625
|
+
if (next?.kind === "item" && next.item.kind === "agent-message") {
|
|
1626
|
+
const turnIds = collectTurnIdsFromGroups(group.groups);
|
|
1627
|
+
// Degenerate body with no turnIds: do not guess — safer than lifting wrong.
|
|
1628
|
+
if (turnIds.size === 0) {
|
|
1629
|
+
return undefined;
|
|
1630
|
+
}
|
|
1631
|
+
if (!next.item.turnId || !turnIds.has(next.item.turnId)) {
|
|
1632
|
+
return undefined;
|
|
1633
|
+
}
|
|
1634
|
+
const text = next.item.text.trim();
|
|
1635
|
+
return text.length > 0 ? text : undefined;
|
|
1636
|
+
}
|
|
1637
|
+
return undefined;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function collectTurnCopyText(
|
|
1641
|
+
groups: readonly TimelineGroup[],
|
|
1642
|
+
trailingAgentText: string | undefined,
|
|
1643
|
+
): string | undefined {
|
|
1644
|
+
const parts = [collectAgentMessageText(groups), trailingAgentText?.trim() ?? ""].filter(
|
|
1645
|
+
(part) => part.length > 0,
|
|
1646
|
+
);
|
|
1647
|
+
if (parts.length === 0) {
|
|
1648
|
+
return undefined;
|
|
1649
|
+
}
|
|
1650
|
+
return parts.join("\n\n");
|
|
1651
|
+
}
|
|
1652
|
+
|
|
805
1653
|
function durationBetween(startedAt: string, endedAt: string): number | undefined {
|
|
806
1654
|
const started = Date.parse(startedAt);
|
|
807
1655
|
const ended = Date.parse(endedAt);
|
|
@@ -848,6 +1696,8 @@ export function TimelineRow({
|
|
|
848
1696
|
return <MachineInputBatchRow item={item} />;
|
|
849
1697
|
case "notice":
|
|
850
1698
|
return <NoticeRow item={item} />;
|
|
1699
|
+
case "context-compaction":
|
|
1700
|
+
return <CompactionRow item={item} />;
|
|
851
1701
|
case "auth-needed":
|
|
852
1702
|
return (
|
|
853
1703
|
<AuthNeededRow
|
|
@@ -861,6 +1711,82 @@ export function TimelineRow({
|
|
|
861
1711
|
}
|
|
862
1712
|
}
|
|
863
1713
|
|
|
1714
|
+
const COMPACTION_TRIGGER_LABEL: Record<NonNullable<ContextCompactionItem["trigger"]>, string> = {
|
|
1715
|
+
auto: "Auto",
|
|
1716
|
+
operator: "Manual",
|
|
1717
|
+
proactive: "Auto",
|
|
1718
|
+
overflow: "Overflow",
|
|
1719
|
+
};
|
|
1720
|
+
|
|
1721
|
+
function CompactionRow({ item }: { item: ContextCompactionItem }) {
|
|
1722
|
+
const enter = useEntranceAnimation();
|
|
1723
|
+
const trigger =
|
|
1724
|
+
item.trigger && item.phase !== "started" ? COMPACTION_TRIGGER_LABEL[item.trigger] : null;
|
|
1725
|
+
const before =
|
|
1726
|
+
item.estimatedTokensBefore !== null
|
|
1727
|
+
? Math.round(item.estimatedTokensBefore).toLocaleString("en-US")
|
|
1728
|
+
: null;
|
|
1729
|
+
const after =
|
|
1730
|
+
item.estimatedTokensAfter !== null
|
|
1731
|
+
? Math.round(item.estimatedTokensAfter).toLocaleString("en-US")
|
|
1732
|
+
: null;
|
|
1733
|
+
const title =
|
|
1734
|
+
item.phase === "started"
|
|
1735
|
+
? "Compacting conversation memory…"
|
|
1736
|
+
: item.phase === "compacted"
|
|
1737
|
+
? before && after
|
|
1738
|
+
? `Conversation memory compacted · ~${before} → ~${after} tokens`
|
|
1739
|
+
: "Conversation memory compacted"
|
|
1740
|
+
: "Couldn’t compact conversation memory";
|
|
1741
|
+
const subtitle =
|
|
1742
|
+
item.phase === "compacted"
|
|
1743
|
+
? "Chat history above is unchanged"
|
|
1744
|
+
: item.phase === "skipped"
|
|
1745
|
+
? compactionSkipSubtitle(item.skipReason)
|
|
1746
|
+
: null;
|
|
1747
|
+
const pill =
|
|
1748
|
+
item.phase === "skipped" && item.skipReason === "summarization_failed"
|
|
1749
|
+
? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
|
|
1750
|
+
: item.phase === "started"
|
|
1751
|
+
? "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting"
|
|
1752
|
+
: "border-og-border bg-og-surface-1 text-og-fg-muted";
|
|
1753
|
+
return (
|
|
1754
|
+
<div className={cn(enter && "animate-og-enter", "flex justify-center")}>
|
|
1755
|
+
<div
|
|
1756
|
+
className={cn(
|
|
1757
|
+
"inline-flex max-w-full flex-col items-center gap-0.5 rounded-full border px-3 py-1.5 text-og-sm",
|
|
1758
|
+
pill,
|
|
1759
|
+
)}
|
|
1760
|
+
role="status"
|
|
1761
|
+
>
|
|
1762
|
+
<span className="inline-flex max-w-full items-center gap-1.5">
|
|
1763
|
+
<ShrinkIcon className="size-3.5 shrink-0" />
|
|
1764
|
+
<span className="truncate">
|
|
1765
|
+
{title}
|
|
1766
|
+
{trigger ? ` · ${trigger}` : ""}
|
|
1767
|
+
</span>
|
|
1768
|
+
</span>
|
|
1769
|
+
{subtitle ? <span className="truncate text-og-xs opacity-80">{subtitle}</span> : null}
|
|
1770
|
+
</div>
|
|
1771
|
+
</div>
|
|
1772
|
+
);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function compactionSkipSubtitle(reason: string | null): string {
|
|
1776
|
+
switch (reason) {
|
|
1777
|
+
case "no_history":
|
|
1778
|
+
return "No active history to compact";
|
|
1779
|
+
case "replacement_not_smaller":
|
|
1780
|
+
return "Checkpoint would not reduce memory size";
|
|
1781
|
+
case "replacement_unchanged":
|
|
1782
|
+
return "Checkpoint made no progress";
|
|
1783
|
+
case "summarization_failed":
|
|
1784
|
+
return "Request it again to retry. Chat history is unchanged.";
|
|
1785
|
+
default:
|
|
1786
|
+
return "Compaction was not needed. Chat history is unchanged.";
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
864
1790
|
function UserMessageRow({
|
|
865
1791
|
item,
|
|
866
1792
|
renderMessageText,
|
|
@@ -874,13 +1800,19 @@ function UserMessageRow({
|
|
|
874
1800
|
return (
|
|
875
1801
|
<div className={cn(enter && "animate-og-enter", "flex justify-end")}>
|
|
876
1802
|
<div className="flex max-w-[85%] min-w-0 flex-col items-end gap-1">
|
|
877
|
-
<
|
|
878
|
-
{
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1803
|
+
<CopyHoverFrame
|
|
1804
|
+
copyText={item.text}
|
|
1805
|
+
label="Copy message"
|
|
1806
|
+
className="w-fit max-w-full min-w-0"
|
|
1807
|
+
>
|
|
1808
|
+
<div className="w-fit max-w-full min-w-0 rounded-og-lg rounded-br-og-xs border border-og-border bg-og-surface-2 px-4 py-2.5 text-og-md leading-6 text-og-fg">
|
|
1809
|
+
{renderMessageText ? (
|
|
1810
|
+
renderMessageText(item.text, item)
|
|
1811
|
+
) : (
|
|
1812
|
+
<Markdown>{item.text}</Markdown>
|
|
1813
|
+
)}
|
|
1814
|
+
</div>
|
|
1815
|
+
</CopyHoverFrame>
|
|
884
1816
|
</div>
|
|
885
1817
|
</div>
|
|
886
1818
|
);
|
|
@@ -896,29 +1828,23 @@ function AgentMessageRow({
|
|
|
896
1828
|
| undefined;
|
|
897
1829
|
}) {
|
|
898
1830
|
const enter = useEntranceAnimation();
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1831
|
+
// No streaming caret: it fought the trailing block layout (inline ↔ block)
|
|
1832
|
+
// and snapped on exit. Live text carries the stream via tip ink.
|
|
1833
|
+
const body = renderMessageText ? (
|
|
1834
|
+
renderMessageText(item.text, item)
|
|
1835
|
+
) : (
|
|
1836
|
+
<Markdown streaming={item.streaming}>{item.text}</Markdown>
|
|
1837
|
+
);
|
|
1838
|
+
// While streaming, copy is still useful (current text) but keep chrome calm.
|
|
905
1839
|
return (
|
|
906
|
-
<
|
|
907
|
-
{
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
// the trailing block (usually a <p>) flows inline so the caret sits on
|
|
915
|
-
// its baseline instead of dropping to a new line.
|
|
916
|
-
<div className={item.streaming ? "[&_>div>:last-child]:inline" : undefined}>
|
|
917
|
-
<Markdown>{item.text}</Markdown>
|
|
918
|
-
{caret}
|
|
919
|
-
</div>
|
|
920
|
-
)}
|
|
921
|
-
</div>
|
|
1840
|
+
<CopyHoverFrame
|
|
1841
|
+
copyText={item.text}
|
|
1842
|
+
label="Copy message"
|
|
1843
|
+
align="end"
|
|
1844
|
+
className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}
|
|
1845
|
+
>
|
|
1846
|
+
{body}
|
|
1847
|
+
</CopyHoverFrame>
|
|
922
1848
|
);
|
|
923
1849
|
}
|
|
924
1850
|
|