@opengeni/react 0.28.2 → 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-PMSGE233.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-4F2YLLHP.js → chunk-KZRUYCL4.js} +14 -17
- package/dist/chunk-KZRUYCL4.js.map +1 -0
- package/dist/{chunk-OCHBZ2X7.js → chunk-TCCE5ZU5.js} +192 -28
- 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-XVVQP7A4.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-Drm_ypue.d.ts → timeline/types.d.ts} +57 -67
- 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 +1382 -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 +24 -18
- 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 +49 -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/session.ts +2 -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 +11 -1
- package/src/timeline/projection.ts +252 -47
- 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 +47 -0
- package/styles/index.css +99 -7
- package/styles/tokens.css +44 -3
- package/dist/chunk-4F2YLLHP.js.map +0 -1
- package/dist/chunk-M7X4JZOD.js.map +0 -1
- package/dist/chunk-OCHBZ2X7.js.map +0 -1
- package/dist/chunk-PMSGE233.js.map +0 -1
- package/dist/chunk-WAA247AB.js +0 -3858
- package/dist/chunk-WAA247AB.js.map +0 -1
- package/dist/chunk-XVVQP7A4.js.map +0 -1
- package/dist/composer-BMzimGTO.d.ts +0 -528
- package/dist/session-C3tIXLim.d.ts +0 -264
- package/dist/session-context-CAjlaeZb.d.ts +0 -84
- package/dist/session-ui-B_7cfN5u.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,25 +54,35 @@ import {
|
|
|
41
54
|
type ActivityItem,
|
|
42
55
|
type AgentMessageItem,
|
|
43
56
|
type AuthNeededItem,
|
|
57
|
+
type ContextCompactionItem,
|
|
44
58
|
type GoalItem,
|
|
59
|
+
type MachineInputBatchItem,
|
|
45
60
|
type NoticeItem,
|
|
46
61
|
type TimelineGroup,
|
|
47
62
|
type TimelineItem,
|
|
48
63
|
type ToolRegistry,
|
|
49
64
|
type TurnSummaryOptions,
|
|
50
65
|
type UserMessageItem,
|
|
66
|
+
type FoldRestingState,
|
|
51
67
|
type WorkerCompletionItem,
|
|
68
|
+
FoldMemoryProvider,
|
|
69
|
+
inheritFoldRestingState,
|
|
52
70
|
TurnSummary,
|
|
71
|
+
useFoldMemory,
|
|
72
|
+
useTurnSettleOpen,
|
|
53
73
|
} from "../timeline";
|
|
74
|
+
import { CopyHoverFrame } from "./copy-button";
|
|
54
75
|
import { SESSION_STATUS_META, StatusDot } from "./session-status";
|
|
55
76
|
import { EntranceAnimationProvider, useEntranceAnimation } from "../timeline/entrance";
|
|
77
|
+
import { SeenActivityIdsProvider } from "../timeline/seen-activity-ids";
|
|
78
|
+
import { TooltipProvider } from "./tooltip";
|
|
56
79
|
|
|
57
80
|
export type MessageTimelineProps = {
|
|
58
81
|
/** Raw session events (projected internally) … */
|
|
59
82
|
events?: SessionEvent[] | undefined;
|
|
60
83
|
/** … or pre-projected items (e.g. from `useSessionEvents().timeline`). */
|
|
61
84
|
items?: TimelineItem[] | undefined;
|
|
62
|
-
/** Current session status;
|
|
85
|
+
/** Current session status (reserved; tip "Working…" chrome removed for now). */
|
|
63
86
|
status?: SessionStatus | null | undefined;
|
|
64
87
|
/** Plug a markdown renderer for message bodies (e.g. streamdown). */
|
|
65
88
|
renderMessageText?:
|
|
@@ -108,11 +131,112 @@ export type MessageTimelineProps = {
|
|
|
108
131
|
loadingOlder?: boolean | undefined;
|
|
109
132
|
/** Called when the reader nears the top and older history should backfill. */
|
|
110
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;
|
|
111
149
|
emptyState?: ReactNode | undefined;
|
|
112
150
|
className?: string | undefined;
|
|
113
151
|
};
|
|
114
152
|
|
|
115
|
-
|
|
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
|
+
}
|
|
116
240
|
|
|
117
241
|
/**
|
|
118
242
|
* The session timeline: chat messages with streaming deltas, collapsed
|
|
@@ -123,7 +247,7 @@ const INITIAL_MOUNTED_GROUPS = 1;
|
|
|
123
247
|
export function MessageTimeline({
|
|
124
248
|
events,
|
|
125
249
|
items,
|
|
126
|
-
status,
|
|
250
|
+
status: _status,
|
|
127
251
|
renderMessageText,
|
|
128
252
|
onOpenSession,
|
|
129
253
|
onMemoryClick,
|
|
@@ -135,146 +259,441 @@ export function MessageTimeline({
|
|
|
135
259
|
hasOlder = false,
|
|
136
260
|
loadingOlder = false,
|
|
137
261
|
onLoadOlder,
|
|
262
|
+
onJumpToStart,
|
|
263
|
+
loadingOldest = false,
|
|
264
|
+
hasNewer = false,
|
|
265
|
+
loadingNewer = false,
|
|
266
|
+
onLoadNewer,
|
|
267
|
+
onJumpToLatest,
|
|
138
268
|
emptyState,
|
|
139
269
|
className,
|
|
140
270
|
}: MessageTimelineProps) {
|
|
141
271
|
const resolvedItems = useMemo(() => items ?? buildTimeline(events ?? []), [items, events]);
|
|
142
272
|
const allGroups = useMemo(() => groupTimeline(resolvedItems), [resolvedItems]);
|
|
143
|
-
const
|
|
144
|
-
const { mountedGroups: groups, mountingOlderGroups } = useProgressivelyMountedGroups(keyedGroups);
|
|
145
|
-
const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
|
|
146
|
-
|
|
273
|
+
const groups = useStableTimelineGroupKeys(allGroups);
|
|
147
274
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
148
275
|
const topSentinelRef = useRef<HTMLDivElement | null>(null);
|
|
276
|
+
const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
|
|
149
277
|
const previousBulkFirstKeyRef = useRef<string | null | undefined>(undefined);
|
|
150
278
|
const [pinned, setPinned] = useState(true);
|
|
151
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;
|
|
152
291
|
// Content stays invisible until its first bottom-anchored frame, so a flash
|
|
153
|
-
// of the window's TOP while a large timeline lays out
|
|
154
|
-
//
|
|
155
|
-
// 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.
|
|
156
294
|
const [revealed, setRevealed] = useState(false);
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// dispatches them. If reader input lands at a different position, its value
|
|
161
|
-
// wins and clears the pending echoes. A matching echo must not recapture the
|
|
162
|
-
// visual anchor while progressive prepend is still mounting older rows.
|
|
163
|
-
const programmaticScrollTargetsRef = useRef<number[]>([]);
|
|
164
|
-
const assignScrollTop = useCallback((node: HTMLElement, value: number) => {
|
|
165
|
-
const previous = node.scrollTop;
|
|
166
|
-
node.scrollTop = value;
|
|
167
|
-
if (node.scrollTop === previous) {
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
const pending = programmaticScrollTargetsRef.current;
|
|
171
|
-
pending.push(node.scrollTop);
|
|
172
|
-
if (pending.length > 32) {
|
|
173
|
-
pending.splice(0, pending.length - 32);
|
|
174
|
-
}
|
|
175
|
-
}, []);
|
|
176
|
-
// Mirror `pinned` into a ref so the ResizeObserver callback (a stable closure)
|
|
177
|
-
// 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.
|
|
178
298
|
const pinnedRef = useRef(true);
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
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;
|
|
190
341
|
// Bulk paints (the initial tail window, a prepended older window — detected
|
|
191
342
|
// by the first group key changing) must not run per-row entrance animations.
|
|
192
343
|
const firstKeyChangedForBulk =
|
|
193
344
|
previousBulkFirstKeyRef.current !== undefined &&
|
|
194
345
|
previousBulkFirstKeyRef.current !== firstGroupKey;
|
|
195
|
-
const bulkRender =
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// drop the correction mid-prepend.
|
|
205
|
-
const captureAnchor = useCallback(() => {
|
|
206
|
-
const node = scrollRef.current;
|
|
207
|
-
const inner = node?.firstElementChild;
|
|
208
|
-
if (!node || !inner) {
|
|
209
|
-
anchorRef.current = null;
|
|
210
|
-
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);
|
|
211
355
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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;
|
|
222
385
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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) {
|
|
231
412
|
return;
|
|
232
413
|
}
|
|
233
|
-
|
|
234
|
-
|
|
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
|
+
};
|
|
235
424
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
};
|
|
454
|
+
|
|
455
|
+
const snapToBottom = useCallback(
|
|
240
456
|
(node: HTMLElement) => {
|
|
241
|
-
|
|
242
|
-
|
|
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();
|
|
243
473
|
return;
|
|
244
474
|
}
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
}
|
|
247
515
|
return;
|
|
248
516
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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;
|
|
254
551
|
}
|
|
255
552
|
},
|
|
256
|
-
[
|
|
553
|
+
[stopFollow, syncScrollBaseline],
|
|
257
554
|
);
|
|
555
|
+
driveFollowRef.current = driveFollow;
|
|
258
556
|
|
|
259
|
-
|
|
260
|
-
|
|
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.
|
|
261
564
|
useLayoutEffect(() => {
|
|
262
565
|
const node = scrollRef.current;
|
|
263
|
-
if (node) {
|
|
264
|
-
|
|
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;
|
|
265
663
|
}
|
|
266
664
|
if (!revealed && groups.length > 0) {
|
|
267
665
|
setRevealed(true);
|
|
268
666
|
}
|
|
269
|
-
}
|
|
667
|
+
});
|
|
270
668
|
|
|
271
|
-
// A cleared timeline (stream identity change) re-arms the reveal
|
|
272
|
-
//
|
|
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.
|
|
273
671
|
useLayoutEffect(() => {
|
|
274
|
-
if (allGroups.length
|
|
672
|
+
if (allGroups.length > 0) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (revealed) {
|
|
275
676
|
setRevealed(false);
|
|
276
677
|
}
|
|
277
|
-
|
|
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]);
|
|
278
697
|
|
|
279
698
|
// Clear the bulk-paint marker a frame after it renders, so rows appended
|
|
280
699
|
// live (streams, new turns) animate exactly as before.
|
|
@@ -284,27 +703,21 @@ export function MessageTimeline({
|
|
|
284
703
|
return;
|
|
285
704
|
}
|
|
286
705
|
setBulkActive(true);
|
|
287
|
-
// Initial tails and prepended history mount from newest to oldest across
|
|
288
|
-
// frames. Keep every row born during that bulk window animation-free; only
|
|
289
|
-
// clear the marker after the authoritative group list is fully mounted.
|
|
290
|
-
if (mountingOlderGroups) {
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
706
|
const frame = requestFrame(() => setBulkActive(false));
|
|
294
707
|
return () => cancelFrame(frame);
|
|
295
|
-
}, [bulkRender, firstGroupKey
|
|
708
|
+
}, [bulkRender, firstGroupKey]);
|
|
296
709
|
|
|
297
|
-
// Prefetch older history
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
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.
|
|
301
714
|
useEffect(() => {
|
|
302
715
|
const root = scrollRef.current;
|
|
303
716
|
const target = topSentinelRef.current;
|
|
304
717
|
if (
|
|
305
718
|
!root ||
|
|
306
719
|
!target ||
|
|
307
|
-
|
|
720
|
+
!olderPrefetchArmed ||
|
|
308
721
|
!hasOlder ||
|
|
309
722
|
loadingOlder ||
|
|
310
723
|
!onLoadOlder ||
|
|
@@ -312,24 +725,57 @@ export function MessageTimeline({
|
|
|
312
725
|
) {
|
|
313
726
|
return;
|
|
314
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
|
+
}
|
|
315
763
|
const observer = new IntersectionObserver(
|
|
316
764
|
(entries) => {
|
|
317
765
|
if (entries.some((entry) => entry.isIntersecting)) {
|
|
318
|
-
|
|
766
|
+
onLoadNewer();
|
|
319
767
|
}
|
|
320
768
|
},
|
|
321
|
-
{ root, rootMargin: "
|
|
769
|
+
{ root, rootMargin: "0px 0px 1200px 0px" },
|
|
322
770
|
);
|
|
323
771
|
observer.observe(target);
|
|
324
772
|
return () => observer.disconnect();
|
|
325
|
-
}, [
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
// A change BELOW the anchor (a bottom append while scrolled up) leaves the
|
|
332
|
-
// 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.
|
|
333
779
|
useEffect(() => {
|
|
334
780
|
const node = scrollRef.current;
|
|
335
781
|
const inner = node?.firstElementChild;
|
|
@@ -337,138 +783,393 @@ export function MessageTimeline({
|
|
|
337
783
|
return;
|
|
338
784
|
}
|
|
339
785
|
const observer = new ResizeObserver(() => {
|
|
340
|
-
|
|
341
|
-
if (!current) {
|
|
786
|
+
if (resizeFollowRafRef.current !== null) {
|
|
342
787
|
return;
|
|
343
788
|
}
|
|
344
|
-
|
|
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
|
+
});
|
|
345
799
|
});
|
|
346
800
|
observer.observe(inner);
|
|
347
|
-
|
|
348
|
-
|
|
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]);
|
|
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]);
|
|
349
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.
|
|
350
844
|
const onScroll = () => {
|
|
351
845
|
const node = scrollRef.current;
|
|
352
846
|
if (!node) {
|
|
353
847
|
return;
|
|
354
848
|
}
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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;
|
|
363
890
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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();
|
|
370
901
|
}
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
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;
|
|
910
|
+
}
|
|
911
|
+
if (nextPinned) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
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";
|
|
376
922
|
}
|
|
377
923
|
};
|
|
378
924
|
|
|
379
925
|
return (
|
|
380
926
|
<LightboxProvider>
|
|
381
|
-
<
|
|
382
|
-
<
|
|
383
|
-
{
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
style={groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined}
|
|
389
|
-
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 [overflow-anchor:none] sm:px-6"
|
|
390
|
-
>
|
|
391
|
-
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
|
|
392
|
-
{groups.length === 0 && !working
|
|
393
|
-
? (emptyState ?? (
|
|
394
|
-
<p className="py-10 text-center text-sm text-og-fg-subtle">No activity yet.</p>
|
|
395
|
-
))
|
|
396
|
-
: null}
|
|
397
|
-
{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. */}
|
|
398
934
|
<div
|
|
399
|
-
ref={
|
|
400
|
-
|
|
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
|
-
|
|
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>
|
|
429
1011
|
</div>
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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>
|
|
463
1169
|
</LightboxProvider>
|
|
464
1170
|
);
|
|
465
1171
|
}
|
|
466
1172
|
|
|
467
|
-
type MountedGroupWindow = {
|
|
468
|
-
groupKeys: string[];
|
|
469
|
-
visibleStart: number;
|
|
470
|
-
};
|
|
471
|
-
|
|
472
1173
|
type KeyedTimelineGroup = {
|
|
473
1174
|
group: TimelineGroup;
|
|
474
1175
|
key: string;
|
|
@@ -498,7 +1199,10 @@ function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGr
|
|
|
498
1199
|
let retainedKey: string | undefined;
|
|
499
1200
|
for (const itemId of itemIds) {
|
|
500
1201
|
const previous = previousByItemId.get(itemId);
|
|
501
|
-
|
|
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)) {
|
|
502
1206
|
retainedKey = previous.key;
|
|
503
1207
|
break;
|
|
504
1208
|
}
|
|
@@ -523,82 +1227,6 @@ function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGr
|
|
|
523
1227
|
return keyedGroups;
|
|
524
1228
|
}
|
|
525
1229
|
|
|
526
|
-
/**
|
|
527
|
-
* Bulk history is already projected into its authoritative order before this
|
|
528
|
-
* hook runs. Mount its newest group first, then prepend one older group per
|
|
529
|
-
* animation frame so low-end browsers can paint and service input between
|
|
530
|
-
* React/Markdown commits instead of doing the entire tail in one long task.
|
|
531
|
-
*
|
|
532
|
-
* A live append does not move the first mounted key, so it is included
|
|
533
|
-
* immediately in the mounted suffix. A history prepend moves that key deeper
|
|
534
|
-
* in the array; shifting `visibleStart` by that exact prefix keeps every
|
|
535
|
-
* existing row mounted while the new prefix is revealed. If projection
|
|
536
|
-
* coalesces the seam item itself away, the earliest surviving mounted key
|
|
537
|
-
* provides the same anchor. Replacements with no shared mounted key (for
|
|
538
|
-
* example a session switch or clear-view) start a fresh suffix.
|
|
539
|
-
*/
|
|
540
|
-
function useProgressivelyMountedGroups(allGroups: KeyedTimelineGroup[]): {
|
|
541
|
-
mountedGroups: KeyedTimelineGroup[];
|
|
542
|
-
mountingOlderGroups: boolean;
|
|
543
|
-
} {
|
|
544
|
-
const previousGroupKeysRef = useRef<string[]>([]);
|
|
545
|
-
const groupKeys = useMemo(() => {
|
|
546
|
-
const nextKeys = allGroups.map((group) => group.key);
|
|
547
|
-
const previousKeys = previousGroupKeysRef.current;
|
|
548
|
-
return nextKeys.length === previousKeys.length &&
|
|
549
|
-
nextKeys.every((key, index) => key === previousKeys[index])
|
|
550
|
-
? previousKeys
|
|
551
|
-
: nextKeys;
|
|
552
|
-
}, [allGroups]);
|
|
553
|
-
useLayoutEffect(() => {
|
|
554
|
-
previousGroupKeysRef.current = groupKeys;
|
|
555
|
-
}, [groupKeys]);
|
|
556
|
-
const [window, setWindow] = useState<MountedGroupWindow>(() => ({
|
|
557
|
-
groupKeys,
|
|
558
|
-
visibleStart: Math.max(0, allGroups.length - INITIAL_MOUNTED_GROUPS),
|
|
559
|
-
}));
|
|
560
|
-
|
|
561
|
-
const lastPossibleStart = Math.max(0, allGroups.length - INITIAL_MOUNTED_GROUPS);
|
|
562
|
-
let visibleStart = 0;
|
|
563
|
-
if (allGroups.length > 0) {
|
|
564
|
-
const currentIndexByKey = new Map(groupKeys.map((key, index) => [key, index]));
|
|
565
|
-
let retainedStart: number | undefined;
|
|
566
|
-
for (const key of window.groupKeys.slice(window.visibleStart)) {
|
|
567
|
-
const index = currentIndexByKey.get(key);
|
|
568
|
-
if (index !== undefined) {
|
|
569
|
-
retainedStart = index;
|
|
570
|
-
break;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
visibleStart =
|
|
574
|
-
retainedStart === undefined ? lastPossibleStart : Math.min(retainedStart, lastPossibleStart);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
useEffect(() => {
|
|
578
|
-
// Synchronize a prepend/replacement before scheduling its first reveal.
|
|
579
|
-
if (window.groupKeys !== groupKeys || window.visibleStart !== visibleStart) {
|
|
580
|
-
setWindow({ groupKeys, visibleStart });
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
if (visibleStart === 0) {
|
|
584
|
-
return;
|
|
585
|
-
}
|
|
586
|
-
const frame = requestFrame(() => {
|
|
587
|
-
setWindow((current) =>
|
|
588
|
-
current.groupKeys === groupKeys && current.visibleStart > 0
|
|
589
|
-
? { ...current, visibleStart: current.visibleStart - 1 }
|
|
590
|
-
: current,
|
|
591
|
-
);
|
|
592
|
-
});
|
|
593
|
-
return () => cancelFrame(frame);
|
|
594
|
-
}, [groupKeys, visibleStart, window.groupKeys, window.visibleStart]);
|
|
595
|
-
|
|
596
|
-
return {
|
|
597
|
-
mountedGroups: allGroups.slice(visibleStart),
|
|
598
|
-
mountingOlderGroups: visibleStart > 0,
|
|
599
|
-
};
|
|
600
|
-
}
|
|
601
|
-
|
|
602
1230
|
function requestFrame(callback: FrameRequestCallback): number {
|
|
603
1231
|
if (typeof requestAnimationFrame === "function") {
|
|
604
1232
|
return requestAnimationFrame(callback);
|
|
@@ -614,10 +1242,11 @@ function cancelFrame(id: number): void {
|
|
|
614
1242
|
window.clearTimeout(id);
|
|
615
1243
|
}
|
|
616
1244
|
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
// creates a new group object, and
|
|
620
|
-
// 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.
|
|
621
1250
|
const TimelineGroupView = memo(function TimelineGroupView({
|
|
622
1251
|
group,
|
|
623
1252
|
renderMessageText,
|
|
@@ -628,7 +1257,10 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
628
1257
|
toolRegistry,
|
|
629
1258
|
turnSummary,
|
|
630
1259
|
insideTurn = false,
|
|
1260
|
+
nestClusterChips = false,
|
|
631
1261
|
foldLiveCluster = false,
|
|
1262
|
+
trailingAgentText,
|
|
1263
|
+
contextCompactionCount,
|
|
632
1264
|
}: {
|
|
633
1265
|
group: TimelineGroup;
|
|
634
1266
|
renderMessageText?:
|
|
@@ -648,39 +1280,126 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
648
1280
|
failure surface, so nested chips stay tinted but quiet (no repeated
|
|
649
1281
|
failure text, no auto-open) — one loud error, N calm sub-expands. */
|
|
650
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;
|
|
651
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;
|
|
652
1323
|
switch (group.kind) {
|
|
653
1324
|
case "activity":
|
|
654
|
-
|
|
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 (
|
|
655
1373
|
<TurnSummary
|
|
656
1374
|
items={group.items}
|
|
657
1375
|
outcome={group.outcome}
|
|
658
|
-
failureText={
|
|
659
|
-
defaultOpen={!
|
|
660
|
-
|
|
1376
|
+
failureText={group.failureText}
|
|
1377
|
+
defaultOpen={!activityShouldFold || group.outcome === "failed" ? true : undefined}
|
|
1378
|
+
foldKey={group.id}
|
|
661
1379
|
facets={turnSummary?.facets}
|
|
1380
|
+
settleFold={settleFold}
|
|
1381
|
+
contextCompactionCount={contextCompactionCount}
|
|
662
1382
|
>
|
|
663
|
-
<
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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>
|
|
670
1394
|
</TurnSummary>
|
|
671
|
-
) : (
|
|
672
|
-
// A nested live cluster hangs on the turn's rail (bare); a top-level one
|
|
673
|
-
// owns its own rail.
|
|
674
|
-
<ActivityRail
|
|
675
|
-
items={group.items}
|
|
676
|
-
onOpenSession={onOpenSession}
|
|
677
|
-
onMemoryClick={onMemoryClick}
|
|
678
|
-
toolRegistry={toolRegistry}
|
|
679
|
-
bare={insideTurn}
|
|
680
|
-
/>
|
|
681
1395
|
);
|
|
682
1396
|
case "turn": {
|
|
683
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);
|
|
684
1403
|
const body = group.groups.map((child) => (
|
|
685
1404
|
<TimelineGroupView
|
|
686
1405
|
key={timelineGroupKey(child)}
|
|
@@ -693,32 +1412,32 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
693
1412
|
toolRegistry={toolRegistry}
|
|
694
1413
|
turnSummary={turnSummary}
|
|
695
1414
|
insideTurn
|
|
1415
|
+
nestClusterChips={nestClusters}
|
|
696
1416
|
/>
|
|
697
1417
|
));
|
|
698
1418
|
return (
|
|
699
1419
|
<TurnSummary
|
|
700
1420
|
items={activityItems}
|
|
701
1421
|
outcome={group.outcome}
|
|
702
|
-
// One loud error at the top; nested sub-turn chips stay calm — the
|
|
703
|
-
// parent already renders the failure reason, so clear it here exactly
|
|
704
|
-
// as the nested activity-cluster case does.
|
|
705
1422
|
failureText={insideTurn ? undefined : group.failureText}
|
|
706
1423
|
durationMs={durationBetween(group.startedAt, group.endedAt)}
|
|
707
|
-
defaultOpen={
|
|
1424
|
+
defaultOpen={turnDefaultOpen ? true : undefined}
|
|
708
1425
|
bare={insideTurn}
|
|
1426
|
+
foldKey={group.id}
|
|
709
1427
|
facets={turnSummary?.facets}
|
|
1428
|
+
settleFold={settleFold}
|
|
1429
|
+
copyText={insideTurn ? undefined : turnCopyText}
|
|
1430
|
+
contextCompactionCount={contextCompactionCount ?? group.contextCompactionCount}
|
|
710
1431
|
>
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
</div>
|
|
721
|
-
)}
|
|
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>
|
|
722
1441
|
</TurnSummary>
|
|
723
1442
|
);
|
|
724
1443
|
}
|
|
@@ -735,6 +1454,42 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
735
1454
|
}
|
|
736
1455
|
});
|
|
737
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
|
+
|
|
738
1493
|
function timelineGroupKey(group: TimelineGroup): string {
|
|
739
1494
|
switch (group.kind) {
|
|
740
1495
|
case "item":
|
|
@@ -789,6 +1544,17 @@ function clusterIsSettled(group: Extract<TimelineGroup, { kind: "activity" }>):
|
|
|
789
1544
|
});
|
|
790
1545
|
}
|
|
791
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
|
+
|
|
792
1558
|
function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
|
|
793
1559
|
const items: ActivityItem[] = [];
|
|
794
1560
|
for (const group of groups) {
|
|
@@ -801,6 +1567,89 @@ function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
|
|
|
801
1567
|
return items;
|
|
802
1568
|
}
|
|
803
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
|
+
|
|
804
1653
|
function durationBetween(startedAt: string, endedAt: string): number | undefined {
|
|
805
1654
|
const started = Date.parse(startedAt);
|
|
806
1655
|
const ended = Date.parse(endedAt);
|
|
@@ -843,8 +1692,12 @@ export function TimelineRow({
|
|
|
843
1692
|
return <SessionStatusRow item={item} />;
|
|
844
1693
|
case "goal":
|
|
845
1694
|
return <GoalRow item={item} />;
|
|
1695
|
+
case "machine-input-batch":
|
|
1696
|
+
return <MachineInputBatchRow item={item} />;
|
|
846
1697
|
case "notice":
|
|
847
1698
|
return <NoticeRow item={item} />;
|
|
1699
|
+
case "context-compaction":
|
|
1700
|
+
return <CompactionRow item={item} />;
|
|
848
1701
|
case "auth-needed":
|
|
849
1702
|
return (
|
|
850
1703
|
<AuthNeededRow
|
|
@@ -858,6 +1711,82 @@ export function TimelineRow({
|
|
|
858
1711
|
}
|
|
859
1712
|
}
|
|
860
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
|
+
|
|
861
1790
|
function UserMessageRow({
|
|
862
1791
|
item,
|
|
863
1792
|
renderMessageText,
|
|
@@ -871,13 +1800,19 @@ function UserMessageRow({
|
|
|
871
1800
|
return (
|
|
872
1801
|
<div className={cn(enter && "animate-og-enter", "flex justify-end")}>
|
|
873
1802
|
<div className="flex max-w-[85%] min-w-0 flex-col items-end gap-1">
|
|
874
|
-
<
|
|
875
|
-
{
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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>
|
|
881
1816
|
</div>
|
|
882
1817
|
</div>
|
|
883
1818
|
);
|
|
@@ -893,29 +1828,23 @@ function AgentMessageRow({
|
|
|
893
1828
|
| undefined;
|
|
894
1829
|
}) {
|
|
895
1830
|
const enter = useEntranceAnimation();
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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.
|
|
902
1839
|
return (
|
|
903
|
-
<
|
|
904
|
-
{
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
// the trailing block (usually a <p>) flows inline so the caret sits on
|
|
912
|
-
// its baseline instead of dropping to a new line.
|
|
913
|
-
<div className={item.streaming ? "[&_>div>:last-child]:inline" : undefined}>
|
|
914
|
-
<Markdown>{item.text}</Markdown>
|
|
915
|
-
{caret}
|
|
916
|
-
</div>
|
|
917
|
-
)}
|
|
918
|
-
</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>
|
|
919
1848
|
);
|
|
920
1849
|
}
|
|
921
1850
|
|
|
@@ -1165,6 +2094,81 @@ function GoalRow({ item }: { item: GoalItem }) {
|
|
|
1165
2094
|
);
|
|
1166
2095
|
}
|
|
1167
2096
|
|
|
2097
|
+
const MACHINE_INPUT_META: Record<MachineInputBatchItem["members"][number]["kind"], string> = {
|
|
2098
|
+
scheduled_occurrence: "Scheduled update",
|
|
2099
|
+
goal_continuation: "Goal continued",
|
|
2100
|
+
agent_message: "Agent update",
|
|
2101
|
+
agent_steer_instruction: "Agent direction",
|
|
2102
|
+
child_terminal_result: "Agent finished",
|
|
2103
|
+
};
|
|
2104
|
+
|
|
2105
|
+
function MachineInputBatchRow({ item }: { item: MachineInputBatchItem }) {
|
|
2106
|
+
const enter = useEntranceAnimation();
|
|
2107
|
+
const visible = item.members.slice(0, 3);
|
|
2108
|
+
return (
|
|
2109
|
+
<div
|
|
2110
|
+
className={cn(
|
|
2111
|
+
enter && "animate-og-enter",
|
|
2112
|
+
"rounded-og-md border border-og-border/70 bg-og-surface-1/55 px-3 py-2.5",
|
|
2113
|
+
)}
|
|
2114
|
+
>
|
|
2115
|
+
{item.members.length > 1 && (
|
|
2116
|
+
<div className="mb-2 text-xs font-medium text-og-fg-subtle">
|
|
2117
|
+
{item.members.length} updates joined this turn
|
|
2118
|
+
</div>
|
|
2119
|
+
)}
|
|
2120
|
+
<div className="space-y-2">
|
|
2121
|
+
{visible.map((member) => (
|
|
2122
|
+
<MachineInputRow key={member.id} member={member} />
|
|
2123
|
+
))}
|
|
2124
|
+
</div>
|
|
2125
|
+
{item.members.length > visible.length && (
|
|
2126
|
+
<details className="mt-2 pl-8 text-xs text-og-fg-muted">
|
|
2127
|
+
<summary className="cursor-pointer select-none hover:text-og-fg">
|
|
2128
|
+
Show {item.members.length - visible.length} more
|
|
2129
|
+
</summary>
|
|
2130
|
+
<div className="mt-2 space-y-2">
|
|
2131
|
+
{item.members.slice(visible.length).map((member) => (
|
|
2132
|
+
<MachineInputRow key={member.id} member={member} />
|
|
2133
|
+
))}
|
|
2134
|
+
</div>
|
|
2135
|
+
</details>
|
|
2136
|
+
)}
|
|
2137
|
+
</div>
|
|
2138
|
+
);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
function MachineInputRow({ member }: { member: MachineInputBatchItem["members"][number] }) {
|
|
2142
|
+
const source = readableMachineInputSource(member.sourceId);
|
|
2143
|
+
return (
|
|
2144
|
+
<div className="flex min-w-0 items-start gap-2.5">
|
|
2145
|
+
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-og-fg-subtle" aria-hidden />
|
|
2146
|
+
<div className="min-w-0 flex-1">
|
|
2147
|
+
<span className="text-xs font-medium text-og-fg-muted">
|
|
2148
|
+
{MACHINE_INPUT_META[member.kind]}
|
|
2149
|
+
</span>
|
|
2150
|
+
{source && <span className="ml-1.5 text-xs text-og-fg-subtle">from {source}</span>}
|
|
2151
|
+
{member.summary && (
|
|
2152
|
+
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm leading-5 text-og-fg">
|
|
2153
|
+
{truncate(cleanMachineInputSummary(member.summary), 320)}
|
|
2154
|
+
</p>
|
|
2155
|
+
)}
|
|
2156
|
+
</div>
|
|
2157
|
+
</div>
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
function readableMachineInputSource(sourceId: string): string | null {
|
|
2162
|
+
const value = sourceId.trim();
|
|
2163
|
+
if (!value || /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(value)) return null;
|
|
2164
|
+
if (/^(goal|schedule|system):/i.test(value)) return null;
|
|
2165
|
+
return value.replaceAll("_", " ");
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
function cleanMachineInputSummary(summary: string): string {
|
|
2169
|
+
return summary.replace(/^\[[A-Z][A-Z _-]*(?:\s+\d+\/\d+)?\]\s*/, "").trim();
|
|
2170
|
+
}
|
|
2171
|
+
|
|
1168
2172
|
function NoticeRow({ item }: { item: NoticeItem }) {
|
|
1169
2173
|
const enter = useEntranceAnimation();
|
|
1170
2174
|
const tone =
|