@opengeni/react 1.0.1 → 1.0.2-canary.37202341988001
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +660 -21
- package/dist/accounts.d.ts +64 -0
- package/dist/accounts.js +822 -0
- package/dist/accounts.js.map +1 -0
- package/dist/approvals.d.ts +2 -1
- package/dist/artifacts-document.js +3 -2
- package/dist/artifacts-presentation.js +3 -2
- package/dist/artifacts-spreadsheet.js +2 -2
- package/dist/artifacts.d.ts +4 -1
- package/dist/artifacts.js +518 -42
- package/dist/artifacts.js.map +1 -1
- package/dist/browser-viewer-SHYKQRNX.js +16 -0
- package/dist/capability-catalog-row.d.ts +20 -0
- package/dist/capability-logo-fallback.d.ts +3 -0
- package/dist/chat-interactive-block-UGBTNB3L.js +11 -0
- package/dist/chat-interactive-block-UGBTNB3L.js.map +1 -0
- package/dist/chunk-26ZNDDLZ.js +453 -0
- package/dist/chunk-26ZNDDLZ.js.map +1 -0
- package/dist/chunk-3CTRFMAK.js +359 -0
- package/dist/chunk-3CTRFMAK.js.map +1 -0
- package/dist/chunk-447JLCBU.js +209 -0
- package/dist/chunk-447JLCBU.js.map +1 -0
- package/dist/chunk-4RVYOY4O.js +379 -0
- package/dist/chunk-4RVYOY4O.js.map +1 -0
- package/dist/chunk-5CVXZA3Y.js +292 -0
- package/dist/chunk-5CVXZA3Y.js.map +1 -0
- package/dist/chunk-5J4FOW6Q.js +108 -0
- package/dist/chunk-5J4FOW6Q.js.map +1 -0
- package/dist/chunk-742CZ3L3.js +481 -0
- package/dist/chunk-742CZ3L3.js.map +1 -0
- package/dist/chunk-A2PI6UMY.js +41 -0
- package/dist/chunk-A2PI6UMY.js.map +1 -0
- package/dist/{chunk-7WCLAEFV.js → chunk-A4UJB2MM.js} +37 -27
- package/dist/chunk-A4UJB2MM.js.map +1 -0
- package/dist/chunk-B3V6TG77.js +41 -0
- package/dist/chunk-B3V6TG77.js.map +1 -0
- package/dist/chunk-BRM6FVZA.js +302 -0
- package/dist/chunk-BRM6FVZA.js.map +1 -0
- package/dist/chunk-C5X5AJXK.js +182 -0
- package/dist/chunk-C5X5AJXK.js.map +1 -0
- package/dist/{chunk-6KLX2RH5.js → chunk-DO544OSV.js} +1075 -79
- package/dist/chunk-DO544OSV.js.map +1 -0
- package/dist/{chunk-R3AZOAB5.js → chunk-DYWBUX5Q.js} +29 -123
- package/dist/chunk-DYWBUX5Q.js.map +1 -0
- package/dist/{chunk-HA46CYQK.js → chunk-EESD3PY3.js} +792 -537
- package/dist/chunk-EESD3PY3.js.map +1 -0
- package/dist/{chunk-5UQPNLIO.js → chunk-EN3YTHKR.js} +4117 -3896
- package/dist/chunk-EN3YTHKR.js.map +1 -0
- package/dist/chunk-ERGYJIBB.js +21 -0
- package/dist/chunk-ERGYJIBB.js.map +1 -0
- package/dist/chunk-FZCP527Y.js +116 -0
- package/dist/chunk-FZCP527Y.js.map +1 -0
- package/dist/chunk-GIMRELBW.js +14 -0
- package/dist/chunk-GIMRELBW.js.map +1 -0
- package/dist/chunk-GYVRFZDY.js +2733 -0
- package/dist/chunk-GYVRFZDY.js.map +1 -0
- package/dist/{chunk-LLOLUHVG.js → chunk-HDJWDK7W.js} +1358 -2746
- package/dist/chunk-HDJWDK7W.js.map +1 -0
- package/dist/chunk-I7IWRQLN.js +1608 -0
- package/dist/chunk-I7IWRQLN.js.map +1 -0
- package/dist/chunk-IT4BJUGF.js +13055 -0
- package/dist/chunk-IT4BJUGF.js.map +1 -0
- package/dist/chunk-IWIDV7SB.js +1 -0
- package/dist/chunk-IWIDV7SB.js.map +1 -0
- package/dist/chunk-J2QWVSQY.js +312 -0
- package/dist/chunk-J2QWVSQY.js.map +1 -0
- package/dist/chunk-J6DBS4T3.js +371 -0
- package/dist/chunk-J6DBS4T3.js.map +1 -0
- package/dist/{chunk-B6WPZSTT.js → chunk-JIQYUGVL.js} +1331 -1163
- package/dist/chunk-JIQYUGVL.js.map +1 -0
- package/dist/chunk-JT4JW7BC.js +54 -0
- package/dist/chunk-JT4JW7BC.js.map +1 -0
- package/dist/chunk-K3HQAKQX.js +24 -0
- package/dist/chunk-K3HQAKQX.js.map +1 -0
- package/dist/{chunk-PEI6RANI.js → chunk-PNY3JKBJ.js} +309 -74
- package/dist/chunk-PNY3JKBJ.js.map +1 -0
- package/dist/chunk-RHREZ7BO.js +11 -0
- package/dist/chunk-RHREZ7BO.js.map +1 -0
- package/dist/chunk-RLUSC2WA.js +299 -0
- package/dist/chunk-RLUSC2WA.js.map +1 -0
- package/dist/{chunk-IGC6A2ZI.js → chunk-RSI7XYXL.js} +777 -246
- package/dist/chunk-RSI7XYXL.js.map +1 -0
- package/dist/chunk-SCOTPE2I.js +589 -0
- package/dist/chunk-SCOTPE2I.js.map +1 -0
- package/dist/{chunk-C5ZPSSS5.js → chunk-TKOTKRWY.js} +5 -4
- package/dist/{chunk-C5ZPSSS5.js.map → chunk-TKOTKRWY.js.map} +1 -1
- package/dist/chunk-TMYGL6X5.js +742 -0
- package/dist/chunk-TMYGL6X5.js.map +1 -0
- package/dist/chunk-UGLEYEMP.js +33 -0
- package/dist/chunk-UGLEYEMP.js.map +1 -0
- package/dist/chunk-UK4TINKT.js +27 -0
- package/dist/chunk-UK4TINKT.js.map +1 -0
- package/dist/chunk-UTRTG6LH.js +220 -0
- package/dist/chunk-UTRTG6LH.js.map +1 -0
- package/dist/chunk-WEHMPOXK.js +39 -0
- package/dist/chunk-WEHMPOXK.js.map +1 -0
- package/dist/chunk-WGXVEJO5.js +319 -0
- package/dist/chunk-WGXVEJO5.js.map +1 -0
- package/dist/chunk-WRHIZYZF.js +988 -0
- package/dist/chunk-WRHIZYZF.js.map +1 -0
- package/dist/{chunk-HUIKIDM5.js → chunk-WUISZ3WU.js} +223 -115
- package/dist/chunk-WUISZ3WU.js.map +1 -0
- package/dist/chunk-XCHWDJ22.js +107 -0
- package/dist/chunk-XCHWDJ22.js.map +1 -0
- package/dist/{chunk-KCGXPP5V.js → chunk-Y2PRMYLC.js} +11 -6
- package/dist/chunk-Y2PRMYLC.js.map +1 -0
- package/dist/chunk-YBXQDQFX.js +1772 -0
- package/dist/chunk-YBXQDQFX.js.map +1 -0
- package/dist/chunk-YM2DE7BQ.js +3124 -0
- package/dist/chunk-YM2DE7BQ.js.map +1 -0
- package/dist/{chunk-KZ73RL76.js → chunk-YQEAA6LT.js} +7 -3
- package/dist/chunk-YQEAA6LT.js.map +1 -0
- package/dist/chunk-YV4L3ILF.js +9 -0
- package/dist/chunk-YV4L3ILF.js.map +1 -0
- package/dist/chunk-ZYRGEKWS.js +216 -0
- package/dist/chunk-ZYRGEKWS.js.map +1 -0
- package/dist/client.d.ts +7 -4
- package/dist/clipboard.js +1 -0
- package/dist/commands/types.d.ts +2 -0
- package/dist/components/anthropic-mark.d.ts +3 -0
- package/dist/components/approval-surface.d.ts +15 -0
- package/dist/components/artifacts/artifact-chrome.d.ts +120 -0
- package/dist/components/artifacts/artifact-sandbox.d.ts +27 -0
- package/dist/components/artifacts/artifact-surface.d.ts +3 -1
- package/dist/components/artifacts/chat-interactive-block.d.ts +29 -0
- package/dist/components/artifacts/deferred-chat-media.d.ts +15 -0
- package/dist/components/artifacts/document-editor.d.ts +4 -2
- package/dist/components/artifacts/editable-artifact-ui.d.ts +2 -1
- package/dist/components/artifacts/editable-artifact-view.d.ts +46 -0
- package/dist/components/artifacts/editable-document.d.ts +1 -1
- package/dist/components/artifacts/editable-presentation.d.ts +2 -2
- package/dist/components/artifacts/editable-spreadsheet.d.ts +2 -1
- package/dist/components/artifacts/index.d.ts +8 -1
- package/dist/components/artifacts/inline-html-document.d.ts +2 -0
- package/dist/components/artifacts/latest-task-runner.d.ts +15 -0
- package/dist/components/artifacts/presentation-editor.d.ts +6 -2
- package/dist/components/artifacts/published-html-artifact-frame.d.ts +48 -3
- package/dist/components/artifacts/session-artifact-viewer.d.ts +39 -0
- package/dist/components/artifacts/site-view.d.ts +23 -0
- package/dist/components/artifacts/spreadsheet-general-display.d.ts +24 -0
- package/dist/components/artifacts/visualization-assets.gen.d.ts +2 -0
- package/dist/components/browser-select-control.d.ts +16 -0
- package/dist/components/browser-viewer.d.ts +10 -1
- package/dist/components/chat-composer.d.ts +21 -3
- package/dist/components/child-session-link.d.ts +5 -0
- package/dist/components/claude-mark.d.ts +3 -0
- package/dist/components/code-editor.d.ts +1 -4
- package/dist/components/composer.d.ts +45 -4
- package/dist/components/computer-viewer.d.ts +4 -1
- package/dist/components/copy-button.d.ts +3 -1
- package/dist/components/desktop-viewer.d.ts +1 -1
- package/dist/components/enrollment-consent.d.ts +6 -3
- package/dist/components/file-browser.d.ts +10 -1
- package/dist/components/fleet-tile.d.ts +2 -2
- package/dist/components/grok-mark.d.ts +26 -0
- package/dist/components/human-input-form.d.ts +21 -2
- package/dist/components/human-input-surface.d.ts +6 -3
- package/dist/components/machine-card.d.ts +4 -1
- package/dist/components/machines/machine-detail.d.ts +4 -1
- package/dist/components/machines/machine-operation-policy.d.ts +11 -0
- package/dist/components/machines-dashboard.d.ts +3 -1
- package/dist/components/markdown-table-layout.d.ts +12 -0
- package/dist/components/markdown.d.ts +47 -3
- package/dist/components/message-timeline.d.ts +72 -9
- package/dist/components/model-mark.d.ts +29 -0
- package/dist/components/model-policy-picker-menu.d.ts +9 -0
- package/dist/components/model-policy-picker.d.ts +62 -13
- package/dist/components/open-geni-chat.d.ts +58 -0
- package/dist/components/open-geni-links.d.ts +68 -0
- package/dist/components/openrouter-mark.d.ts +6 -0
- package/dist/components/pierre-file.d.ts +1 -1
- package/dist/components/preview-loading.d.ts +2 -0
- package/dist/components/queue-item-content.d.ts +9 -0
- package/dist/components/sandbox-files.d.ts +13 -2
- package/dist/components/sandbox-workspace.d.ts +43 -3
- package/dist/components/session-capability-frame.d.ts +35 -0
- package/dist/components/session-chrome.d.ts +66 -8
- package/dist/components/session-commands-panel.d.ts +6 -0
- package/dist/components/session-commands.d.ts +6 -0
- package/dist/components/session-connection-request.d.ts +8 -0
- package/dist/components/session-conversation.d.ts +89 -0
- package/dist/components/session-list.d.ts +36 -0
- package/dist/components/session-mcp-capability-card.d.ts +41 -0
- package/dist/components/session-proxy-scope.d.ts +21 -0
- package/dist/components/timeline-anchor.d.ts +24 -0
- package/dist/components/timeline-annotation-chrome.d.ts +25 -0
- package/dist/components/timeline-annotation-layout.d.ts +42 -0
- package/dist/components/timeline-annotation-markers.d.ts +8 -0
- package/dist/components/timeline-annotation-reveal-context.d.ts +4 -0
- package/dist/components/timeline-annotation-shared.d.ts +31 -0
- package/dist/components/timeline-annotations-dialog.d.ts +3 -2
- package/dist/components/timeline-annotations.d.ts +13 -3
- package/dist/components/timeline-reading-progress.d.ts +11 -0
- package/dist/components/timeline-search.d.ts +34 -0
- package/dist/components/timeline-settlement.d.ts +16 -0
- package/dist/components/tip-follow.d.ts +16 -0
- package/dist/components/usage-limit-notice.d.ts +49 -0
- package/dist/components/usage-member-list.d.ts +91 -0
- package/dist/components/usage-meter.d.ts +73 -0
- package/dist/components/use-viewer-menu-dismiss.d.ts +2 -0
- package/dist/components/user-message-body.d.ts +10 -1
- package/dist/components/workspace-dock.d.ts +6 -2
- package/dist/composer.d.ts +5 -4
- package/dist/composer.js +15 -9
- package/dist/computer-viewer-6SRGWRUX.js +19 -0
- package/dist/computer-viewer-6SRGWRUX.js.map +1 -0
- package/dist/connect-account-identity.d.ts +8 -0
- package/dist/connect-accounts.d.ts +15 -0
- package/dist/connect-chooser.d.ts +18 -0
- package/dist/connect-panel.d.ts +18 -0
- package/dist/connect-setup.d.ts +16 -0
- package/dist/connect.d.ts +21 -0
- package/dist/connect.js +2129 -0
- package/dist/connect.js.map +1 -0
- package/dist/connection-catalog.d.ts +38 -0
- package/dist/connection-discovery.d.ts +12 -0
- package/dist/connection-installed.d.ts +17 -0
- package/dist/connection-logo.d.ts +9 -0
- package/dist/connection-service-logo.d.ts +8 -0
- package/dist/connection-service-presentation.d.ts +13 -0
- package/dist/connection-skeleton.d.ts +5 -0
- package/dist/connection-type-picker.d.ts +6 -0
- package/dist/conversation-timeline.d.ts +5 -0
- package/dist/desktop.d.ts +7 -0
- package/dist/desktop.js +26 -0
- package/dist/desktop.js.map +1 -0
- package/dist/device-authorization.d.ts +23 -0
- package/dist/diffs.d.ts +8 -0
- package/dist/diffs.js +14 -0
- package/dist/diffs.js.map +1 -0
- package/dist/discovery-cache.d.ts +5 -0
- package/dist/editor.d.ts +7 -0
- package/dist/editor.js +21 -0
- package/dist/editor.js.map +1 -0
- package/dist/embedded-session-client.d.ts +23 -0
- package/dist/file-node-visibility.d.ts +15 -0
- package/dist/{fleet-decision-row-GGCAT4E4.js → fleet-decision-row-YZ3ZKWMM.js} +2 -1
- package/dist/fleet-decision-row-YZ3ZKWMM.js.map +1 -0
- package/dist/hooks/channel-cache.d.ts +23 -0
- package/dist/hooks/latest-question.d.ts +25 -0
- package/dist/hooks/shared-machines-list.d.ts +14 -0
- package/dist/hooks/use-attached-browsers.d.ts +2 -1
- package/dist/hooks/use-browser-frame-stream.d.ts +1 -1
- package/dist/hooks/use-browser-session.d.ts +12 -1
- package/dist/hooks/use-channels.d.ts +1 -0
- package/dist/hooks/use-codex-accounts.d.ts +25 -7
- package/dist/hooks/use-composer.d.ts +45 -9
- package/dist/hooks/use-computer-frame-stream.d.ts +1 -1
- package/dist/hooks/use-computer-input-posture.d.ts +13 -0
- package/dist/hooks/use-computer-session.d.ts +2 -0
- package/dist/hooks/use-connect.d.ts +22 -0
- package/dist/hooks/use-desktop-stream.d.ts +1 -1
- package/dist/hooks/use-file-attachments.d.ts +21 -2
- package/dist/hooks/use-machine-chip.d.ts +3 -3
- package/dist/hooks/use-machines.d.ts +19 -1
- package/dist/hooks/use-sandbox-files.d.ts +5 -1
- package/dist/hooks/use-session-background-commands.d.ts +14 -0
- package/dist/hooks/use-session-events.d.ts +30 -8
- package/dist/hooks/use-session-lineage.d.ts +4 -0
- package/dist/hooks/use-session-mcp-approval-policy.d.ts +2 -2
- package/dist/hooks/use-session.d.ts +6 -0
- package/dist/hooks/use-turn-queue.d.ts +12 -0
- package/dist/hooks/use-usage.d.ts +40 -0
- package/dist/hooks/use-workspace-edit.d.ts +4 -0
- package/dist/hooks/use-workspace-sessions.d.ts +27 -5
- package/dist/human-input.d.ts +3 -1
- package/dist/identity-link-accounts.d.ts +11 -0
- package/dist/identity-link-consent.d.ts +18 -0
- package/dist/index.d.ts +60 -17
- package/dist/index.js +2238 -6201
- package/dist/index.js.map +1 -1
- package/dist/interaction.d.ts +1 -1
- package/dist/interaction.js +35 -184
- package/dist/interaction.js.map +1 -1
- package/dist/latest-question-HTRIS4KJ.js +117 -0
- package/dist/latest-question-HTRIS4KJ.js.map +1 -0
- package/dist/lib/error-message.d.ts +7 -0
- package/dist/lib/format.d.ts +36 -2
- package/dist/lib/host-theme.d.ts +70 -0
- package/dist/lib/interaction-errors.d.ts +6 -0
- package/dist/lib/menu-styles.d.ts +22 -0
- package/dist/lib/pierre-diffs-loader.d.ts +20 -0
- package/dist/lib/workbench-peers.d.ts +36 -0
- package/dist/lib/xterm-theme.d.ts +7 -5
- package/dist/machines.d.ts +4 -2
- package/dist/machines.js +14 -4
- package/dist/markdown-table-layout-QXSLK4BY.js +106 -0
- package/dist/markdown-table-layout-QXSLK4BY.js.map +1 -0
- package/dist/mcp-connection-status.d.ts +14 -0
- package/dist/model-policy-picker-menu-M54ISBMN.js +14 -0
- package/dist/model-policy-picker-menu-M54ISBMN.js.map +1 -0
- package/dist/model-policy.d.ts +19 -3
- package/dist/model-policy.js +9 -1
- package/dist/older-history.d.ts +30 -0
- package/dist/platform-activity-row-627BFZ46.js +182 -0
- package/dist/platform-activity-row-627BFZ46.js.map +1 -0
- package/dist/plugin-details.d.ts +14 -0
- package/dist/plugin-discovery.d.ts +25 -0
- package/dist/provider.d.ts +13 -1
- package/dist/{queue-surface-implementation-2KVJTYXH.js → queue-surface-implementation-ZHFI5JBP.js} +131 -44
- package/dist/queue-surface-implementation-ZHFI5JBP.js.map +1 -0
- package/dist/realtime/realtime-control.d.ts +6 -3
- package/dist/realtime.js +133 -74
- package/dist/realtime.js.map +1 -1
- package/dist/sandbox-files-GORZOUUJ.js +20 -0
- package/dist/sandbox-files-GORZOUUJ.js.map +1 -0
- package/dist/session-auth-recommendation.d.ts +5 -0
- package/dist/session-capability-policy.d.ts +12 -0
- package/dist/session-ui.d.ts +30 -2
- package/dist/session-ui.js +85 -7
- package/dist/session-ui.js.map +1 -1
- package/dist/session.d.ts +8 -2
- package/dist/session.js +30 -19
- package/dist/sites-ui.d.ts +34 -0
- package/dist/sites.d.ts +3 -0
- package/dist/sites.js +11 -0
- package/dist/sites.js.map +1 -0
- package/dist/skill-discovery.d.ts +47 -0
- package/dist/terminal.d.ts +8 -0
- package/dist/terminal.js +31 -0
- package/dist/terminal.js.map +1 -0
- package/dist/timeline/activity-rail.d.ts +11 -2
- package/dist/timeline/allowance-exhausted-row.d.ts +24 -0
- package/dist/timeline/compaction-copy.d.ts +10 -0
- package/dist/timeline/entrance.d.ts +13 -1
- package/dist/timeline/genie-loading.d.ts +34 -0
- package/dist/timeline/index.d.ts +6 -2
- package/dist/timeline/knowledge-receipt.d.ts +17 -0
- package/dist/timeline/platform-activity-row.d.ts +14 -0
- package/dist/timeline/presented-image.d.ts +6 -0
- package/dist/timeline/projection.d.ts +31 -2
- package/dist/timeline/registry.d.ts +2 -0
- package/dist/timeline/retained-image.d.ts +18 -0
- package/dist/timeline/rolling-activity.d.ts +11 -0
- package/dist/timeline/screenshot-lightbox.d.ts +16 -1
- package/dist/timeline/shared.d.ts +8 -3
- package/dist/timeline/startup-preference.d.ts +3 -0
- package/dist/timeline/startup-timings.d.ts +4 -0
- package/dist/timeline/tool-display-name.d.ts +2 -1
- package/dist/timeline/turn-summary.d.ts +34 -3
- package/dist/timeline/types.d.ts +147 -7
- package/dist/timeline-annotation-markers-GNDHTGBD.js +165 -0
- package/dist/timeline-annotation-markers-GNDHTGBD.js.map +1 -0
- package/dist/timeline-annotation-selection-DMLMM2TX.js +241 -0
- package/dist/timeline-annotation-selection-DMLMM2TX.js.map +1 -0
- package/dist/timeline-annotations-dialog-ELRDBHTS.js +273 -0
- package/dist/timeline-annotations-dialog-ELRDBHTS.js.map +1 -0
- package/dist/types/machines.d.ts +2 -2
- package/dist/usage/allowance-copy.d.ts +25 -0
- package/dist/usage/summary.d.ts +35 -0
- package/dist/usage.d.ts +13 -0
- package/dist/usage.js +1074 -0
- package/dist/usage.js.map +1 -0
- package/package.json +44 -4
- package/src/accounts.tsx +1072 -0
- package/src/approvals.ts +8 -1
- package/src/artifacts.ts +42 -0
- package/src/assets/README.md +8 -0
- package/src/assets/gmail.ico +0 -0
- package/src/assets/outlook.ico +0 -0
- package/src/capability-catalog-row.tsx +94 -0
- package/src/capability-logo-fallback.ts +22 -0
- package/src/client.ts +22 -13
- package/src/commands/registry.ts +18 -15
- package/src/commands/types.ts +2 -0
- package/src/components/anthropic-mark.tsx +10 -0
- package/src/components/approval-surface.tsx +171 -37
- package/src/components/artifacts/artifact-chrome.tsx +413 -0
- package/src/components/artifacts/artifact-sandbox.tsx +185 -0
- package/src/components/artifacts/artifact-surface.tsx +39 -19
- package/src/components/artifacts/chat-interactive-block.tsx +260 -0
- package/src/components/artifacts/deferred-chat-media.tsx +67 -0
- package/src/components/artifacts/document-editor.tsx +141 -16
- package/src/components/artifacts/editable-artifact-ui.tsx +4 -2
- package/src/components/artifacts/editable-artifact-view.tsx +187 -0
- package/src/components/artifacts/editable-artifact-workbench.tsx +5 -2
- package/src/components/artifacts/editable-document.tsx +37 -36
- package/src/components/artifacts/editable-presentation.tsx +36 -18
- package/src/components/artifacts/editable-spreadsheet.tsx +5 -2
- package/src/components/artifacts/index.ts +44 -0
- package/src/components/artifacts/inline-html-document.ts +24 -0
- package/src/components/artifacts/latest-task-runner.ts +49 -0
- package/src/components/artifacts/presentation-editor.tsx +323 -67
- package/src/components/artifacts/published-html-artifact-frame.tsx +338 -5
- package/src/components/artifacts/session-artifact-viewer.tsx +376 -0
- package/src/components/artifacts/site-view.tsx +116 -0
- package/src/components/artifacts/spreadsheet-canvas.ts +5 -10
- package/src/components/artifacts/spreadsheet-general-display.ts +43 -0
- package/src/components/artifacts/spreadsheet-grid.tsx +12 -13
- package/src/components/artifacts/visualization-assets/base.css +1015 -0
- package/src/components/artifacts/visualization-assets/helpers.html +349 -0
- package/src/components/artifacts/visualization-assets.gen.ts +3 -0
- package/src/components/browser-select-control.tsx +312 -0
- package/src/components/browser-viewer.tsx +1054 -314
- package/src/components/chat-composer.tsx +131 -85
- package/src/components/child-session-link.tsx +27 -0
- package/src/components/claude-mark.tsx +10 -0
- package/src/components/code-editor.tsx +13 -16
- package/src/components/composer-transcription-control.tsx +7 -5
- package/src/components/composer.tsx +376 -49
- package/src/components/computer-viewer.tsx +1041 -329
- package/src/components/copy-button.tsx +13 -5
- package/src/components/desktop-viewer.tsx +1 -1
- package/src/components/enrollment-consent.tsx +65 -7
- package/src/components/enrollment-device-flow.tsx +1 -1
- package/src/components/file-browser.tsx +158 -34
- package/src/components/fleet-tile.tsx +8 -16
- package/src/components/generated-video-player.tsx +4 -2
- package/src/components/grok-mark.tsx +33 -0
- package/src/components/human-input-form.tsx +455 -114
- package/src/components/human-input-surface.tsx +18 -7
- package/src/components/interaction-intervention-banner.tsx +2 -2
- package/src/components/machine-card.tsx +119 -4
- package/src/components/machine-dock-bar.tsx +1 -1
- package/src/components/machine-input-display.ts +23 -2
- package/src/components/machines/machine-detail.tsx +52 -2
- package/src/components/machines/machine-operation-policy.tsx +179 -0
- package/src/components/machines-dashboard.tsx +8 -17
- package/src/components/markdown-table-layout.ts +132 -0
- package/src/components/markdown.tsx +473 -44
- package/src/components/message-timeline.tsx +2754 -665
- package/src/components/model-mark.tsx +90 -0
- package/src/components/model-picker.tsx +15 -4
- package/src/components/model-policy-picker-menu.tsx +424 -0
- package/src/components/model-policy-picker.tsx +337 -381
- package/src/components/open-geni-chat.tsx +338 -0
- package/src/components/open-geni-links.tsx +179 -0
- package/src/components/openrouter-mark.tsx +13 -0
- package/src/components/pierre-diff.tsx +41 -16
- package/src/components/pierre-file.tsx +36 -11
- package/src/components/preview-loading.tsx +154 -0
- package/src/components/queue-item-content.ts +14 -0
- package/src/components/queue-surface-implementation.tsx +132 -42
- package/src/components/queue-surface-state.tsx +3 -1
- package/src/components/sandbox-files.tsx +267 -35
- package/src/components/sandbox-terminal.tsx +48 -11
- package/src/components/sandbox-workspace.tsx +337 -135
- package/src/components/session-capability-frame.tsx +192 -0
- package/src/components/session-chrome.tsx +1027 -328
- package/src/components/session-commands-panel.tsx +126 -0
- package/src/components/session-commands.tsx +18 -0
- package/src/components/session-connection-request.tsx +97 -0
- package/src/components/session-conversation.tsx +526 -0
- package/src/components/session-list.tsx +250 -0
- package/src/components/session-mcp-capability-card.tsx +680 -0
- package/src/components/session-proxy-scope.tsx +92 -0
- package/src/components/session-status.tsx +1 -1
- package/src/components/timeline-anchor.tsx +119 -0
- package/src/components/timeline-annotation-chrome.tsx +196 -0
- package/src/components/timeline-annotation-layout.ts +268 -0
- package/src/components/timeline-annotation-markers.tsx +185 -0
- package/src/components/timeline-annotation-reveal-context.ts +9 -0
- package/src/components/timeline-annotation-selection.tsx +106 -54
- package/src/components/timeline-annotation-shared.ts +245 -0
- package/src/components/timeline-annotations-dialog.tsx +185 -128
- package/src/components/timeline-annotations.tsx +189 -45
- package/src/components/timeline-reading-progress.ts +80 -0
- package/src/components/timeline-search.tsx +168 -0
- package/src/components/timeline-settlement.ts +60 -0
- package/src/components/tip-follow.ts +70 -7
- package/src/components/tooltip.tsx +38 -5
- package/src/components/usage-limit-notice.tsx +179 -0
- package/src/components/usage-member-list.tsx +792 -0
- package/src/components/usage-meter.tsx +382 -0
- package/src/components/use-viewer-menu-dismiss.ts +33 -0
- package/src/components/user-message-body.tsx +194 -49
- package/src/components/workspace-dock.tsx +141 -175
- package/src/composer.ts +6 -1
- package/src/connect-account-identity.tsx +34 -0
- package/src/connect-accounts.tsx +233 -0
- package/src/connect-chooser.tsx +336 -0
- package/src/connect-panel.tsx +99 -0
- package/src/connect-setup.tsx +307 -0
- package/src/connect.ts +60 -0
- package/src/connection-assets.d.ts +4 -0
- package/src/connection-catalog.tsx +162 -0
- package/src/connection-discovery.tsx +235 -0
- package/src/connection-installed.tsx +51 -0
- package/src/connection-logo.tsx +37 -0
- package/src/connection-service-logo.tsx +55 -0
- package/src/connection-service-presentation.ts +46 -0
- package/src/connection-skeleton.tsx +16 -0
- package/src/connection-type-picker.tsx +36 -0
- package/src/conversation-timeline.ts +92 -0
- package/src/desktop.ts +24 -0
- package/src/device-authorization.tsx +98 -0
- package/src/diffs.ts +16 -0
- package/src/discovery-cache.ts +42 -0
- package/src/editor.ts +13 -0
- package/src/embedded-session-client.ts +104 -0
- package/src/file-node-visibility.ts +66 -0
- package/src/hooks/channel-cache.ts +104 -0
- package/src/hooks/internal.ts +3 -2
- package/src/hooks/latest-question.ts +164 -0
- package/src/hooks/shared-machines-list.ts +239 -0
- package/src/hooks/use-attached-browsers.ts +5 -1
- package/src/hooks/use-available-models.ts +20 -14
- package/src/hooks/use-browser-frame-stream.ts +25 -3
- package/src/hooks/use-browser-session.ts +271 -71
- package/src/hooks/use-browser-sessions.ts +7 -1
- package/src/hooks/use-channels.ts +32 -10
- package/src/hooks/use-codex-accounts.ts +78 -21
- package/src/hooks/use-composer.ts +1034 -320
- package/src/hooks/use-computer-frame-stream.ts +39 -16
- package/src/hooks/use-computer-input-posture.ts +94 -0
- package/src/hooks/use-computer-session.ts +110 -32
- package/src/hooks/use-computer-sessions.ts +8 -1
- package/src/hooks/use-connect.ts +23 -0
- package/src/hooks/use-desktop-stream.ts +19 -28
- package/src/hooks/use-file-attachments.ts +118 -10
- package/src/hooks/use-goal.ts +36 -9
- package/src/hooks/use-human-input.ts +31 -4
- package/src/hooks/use-last-started-turn-policy.ts +15 -13
- package/src/hooks/use-machine-chip.ts +23 -6
- package/src/hooks/use-machines.ts +104 -6
- package/src/hooks/use-sandbox-files.ts +253 -150
- package/src/hooks/use-sandbox-git.ts +58 -29
- package/src/hooks/use-session-background-commands.ts +67 -0
- package/src/hooks/use-session-capabilities.ts +21 -2
- package/src/hooks/use-session-events.ts +759 -433
- package/src/hooks/use-session-lineage.ts +28 -6
- package/src/hooks/use-session-mcp-approval-policy.ts +13 -5
- package/src/hooks/use-session.ts +75 -16
- package/src/hooks/use-slash-commands.ts +6 -11
- package/src/hooks/use-turn-queue.ts +113 -13
- package/src/hooks/use-usage.ts +100 -0
- package/src/hooks/use-video-artifact-playback.ts +1 -1
- package/src/hooks/use-voice-input.ts +12 -1
- package/src/hooks/use-workspace-capture.ts +3 -2
- package/src/hooks/use-workspace-edit.ts +23 -5
- package/src/hooks/use-workspace-sessions.ts +99 -11
- package/src/human-input.ts +12 -1
- package/src/identity-link-accounts.tsx +148 -0
- package/src/identity-link-consent.tsx +192 -0
- package/src/index.ts +157 -12
- package/src/interaction.ts +1 -0
- package/src/lib/error-message.ts +37 -0
- package/src/lib/format.ts +150 -14
- package/src/lib/host-theme.ts +511 -0
- package/src/lib/interaction-errors.ts +33 -0
- package/src/lib/menu-styles.ts +44 -0
- package/src/lib/pierre-diffs-loader.ts +53 -0
- package/src/lib/workbench-peers.ts +62 -0
- package/src/lib/xterm-theme.ts +23 -21
- package/src/machines.ts +9 -1
- package/src/mcp-connection-status.ts +35 -0
- package/src/model-policy.ts +223 -69
- package/src/older-history.ts +87 -0
- package/src/plugin-details.tsx +185 -0
- package/src/plugin-discovery.tsx +196 -0
- package/src/provider.tsx +55 -14
- package/src/realtime/dropdown-menu.tsx +19 -12
- package/src/realtime/realtime-control.tsx +133 -46
- package/src/session-auth-recommendation.ts +48 -0
- package/src/session-capability-policy.ts +83 -0
- package/src/session-context.ts +4 -3
- package/src/session-ui.ts +66 -2
- package/src/session.ts +18 -1
- package/src/sites-ui.tsx +402 -0
- package/src/sites.ts +3 -0
- package/src/skill-discovery.tsx +208 -0
- package/src/terminal.ts +26 -0
- package/src/timeline/activity-rail.tsx +171 -80
- package/src/timeline/allowance-exhausted-row.tsx +86 -0
- package/src/timeline/compaction-copy.ts +42 -0
- package/src/timeline/entrance.tsx +28 -3
- package/src/timeline/fleet-decision-projection.ts +2 -1
- package/src/timeline/fleet-decision-row.tsx +1 -0
- package/src/timeline/genie-loading.tsx +136 -0
- package/src/timeline/index.ts +11 -0
- package/src/timeline/knowledge-receipt.tsx +100 -0
- package/src/timeline/parsers.ts +2 -2
- package/src/timeline/platform-activity-row.tsx +216 -0
- package/src/timeline/presented-image.ts +49 -0
- package/src/timeline/projection.ts +1609 -84
- package/src/timeline/registry.ts +15 -3
- package/src/timeline/retained-image.ts +97 -0
- package/src/timeline/rolling-activity.tsx +92 -0
- package/src/timeline/screenshot-lightbox.tsx +115 -17
- package/src/timeline/shared.tsx +47 -15
- package/src/timeline/startup-preference.ts +33 -0
- package/src/timeline/startup-timings.tsx +145 -0
- package/src/timeline/tool-display-name.ts +8 -6
- package/src/timeline/tool-renderers.tsx +497 -128
- package/src/timeline/turn-summary.tsx +218 -44
- package/src/timeline/types.ts +192 -7
- package/src/types/machines.ts +10 -0
- package/src/usage/allowance-copy.ts +64 -0
- package/src/usage/summary.ts +93 -0
- package/src/usage.ts +50 -0
- package/styles/compiled.css +2098 -757
- package/styles/connect.css +284 -0
- package/styles/connect.d.ts +1 -0
- package/styles/effective-tokens.css +1 -0
- package/styles/index.css +201 -9
- package/styles/responsive.css +27 -1
- package/styles/session-capability.css +44 -0
- package/styles/tokens.css +135 -65
- package/dist/chunk-5UQPNLIO.js.map +0 -1
- package/dist/chunk-6KLX2RH5.js.map +0 -1
- package/dist/chunk-7WCLAEFV.js.map +0 -1
- package/dist/chunk-B6WPZSTT.js.map +0 -1
- package/dist/chunk-FK6VG4LL.js +0 -90
- package/dist/chunk-FK6VG4LL.js.map +0 -1
- package/dist/chunk-HA46CYQK.js.map +0 -1
- package/dist/chunk-HUIKIDM5.js.map +0 -1
- package/dist/chunk-IGC6A2ZI.js.map +0 -1
- package/dist/chunk-KCGXPP5V.js.map +0 -1
- package/dist/chunk-KZ73RL76.js.map +0 -1
- package/dist/chunk-LAKLF4PA.js +0 -141
- package/dist/chunk-LAKLF4PA.js.map +0 -1
- package/dist/chunk-LLOLUHVG.js.map +0 -1
- package/dist/chunk-MKPIAGVL.js +0 -465
- package/dist/chunk-MKPIAGVL.js.map +0 -1
- package/dist/chunk-PEI6RANI.js.map +0 -1
- package/dist/chunk-R3AZOAB5.js.map +0 -1
- package/dist/chunk-WSK7G5LE.js +0 -170
- package/dist/chunk-WSK7G5LE.js.map +0 -1
- package/dist/chunk-XBZWOM2K.js +0 -218
- package/dist/chunk-XBZWOM2K.js.map +0 -1
- package/dist/chunk-Y7OEDUJ5.js +0 -8810
- package/dist/chunk-Y7OEDUJ5.js.map +0 -1
- package/dist/fleet-decision-row-GGCAT4E4.js.map +0 -1
- package/dist/hooks/use-packs.d.ts +0 -32
- package/dist/interactive-workbench-viewers-TOHBXJEE.js +0 -15
- package/dist/queue-surface-implementation-2KVJTYXH.js.map +0 -1
- package/dist/timeline-annotation-selection-3L7LHGG2.js +0 -187
- package/dist/timeline-annotation-selection-3L7LHGG2.js.map +0 -1
- package/dist/timeline-annotations-dialog-U7EVLR75.js +0 -196
- package/dist/timeline-annotations-dialog-U7EVLR75.js.map +0 -1
- package/src/hooks/use-packs.ts +0 -157
- /package/dist/{interactive-workbench-viewers-TOHBXJEE.js.map → browser-viewer-SHYKQRNX.js.map} +0 -0
|
@@ -1,19 +1,36 @@
|
|
|
1
|
+
import { RollingActivity } from "../timeline/rolling-activity";
|
|
2
|
+
import { useErrorMessage } from "../lib/error-message";
|
|
3
|
+
import { formatElapsed } from "../timeline/turn-summary";
|
|
4
|
+
import { ActivityNoteTextContext } from "../timeline/activity-rail";
|
|
5
|
+
import { timelineGroupContainsPresentedImage } from "../timeline/presented-image";
|
|
6
|
+
import {
|
|
7
|
+
isTimelineSearchTarget,
|
|
8
|
+
TimelineSearchRevealContext,
|
|
9
|
+
useTimelineSearchNavigation,
|
|
10
|
+
type TimelineSearchTarget,
|
|
11
|
+
} from "./timeline-search";
|
|
12
|
+
import { GenieLoadingOptionsContext, type GenieLoadingOptions } from "../timeline/genie-loading";
|
|
13
|
+
import { ChildSessionLink } from "./child-session-link";
|
|
14
|
+
import { useStartupDetails } from "../timeline/startup-preference";
|
|
15
|
+
import { compactionSkipSubtitle } from "../timeline/compaction-copy";
|
|
1
16
|
import type {
|
|
2
17
|
DraftTimelineAnnotation,
|
|
3
18
|
MediaGenerationResult,
|
|
4
19
|
SessionEvent,
|
|
5
20
|
SessionStatus,
|
|
6
21
|
} from "@opengeni/sdk";
|
|
22
|
+
import { dequal } from "dequal/lite";
|
|
7
23
|
import {
|
|
8
24
|
ArrowDownIcon,
|
|
9
25
|
ArrowRightIcon,
|
|
10
|
-
|
|
26
|
+
ArrowUpIcon,
|
|
11
27
|
BotIcon,
|
|
12
28
|
CheckCircle2Icon,
|
|
13
29
|
CheckIcon,
|
|
14
30
|
ChevronRightIcon,
|
|
15
31
|
PauseCircleIcon,
|
|
16
32
|
PauseIcon,
|
|
33
|
+
MessageCircleQuestionIcon,
|
|
17
34
|
PencilLineIcon,
|
|
18
35
|
PlayIcon,
|
|
19
36
|
RefreshCwIcon,
|
|
@@ -23,15 +40,17 @@ import {
|
|
|
23
40
|
TriangleAlertIcon,
|
|
24
41
|
XCircleIcon,
|
|
25
42
|
} from "lucide-react";
|
|
26
|
-
import type { ComponentType } from "react";
|
|
27
|
-
import { AnimatePresence, motion } from "motion/react";
|
|
43
|
+
import type { ComponentType, CSSProperties } from "react";
|
|
44
|
+
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
28
45
|
import { Collapsible } from "radix-ui";
|
|
29
46
|
import {
|
|
30
47
|
Component,
|
|
31
48
|
Suspense,
|
|
49
|
+
createContext,
|
|
32
50
|
lazy,
|
|
33
51
|
memo,
|
|
34
52
|
useCallback,
|
|
53
|
+
useContext,
|
|
35
54
|
useEffect,
|
|
36
55
|
useLayoutEffect,
|
|
37
56
|
useMemo,
|
|
@@ -42,22 +61,43 @@ import {
|
|
|
42
61
|
import { cn } from "../lib/cn";
|
|
43
62
|
import { formatClockTime, formatRelativeTime, truncate } from "../lib/format";
|
|
44
63
|
import { prefersReducedMotion } from "../lib/motion";
|
|
45
|
-
import {
|
|
64
|
+
import {
|
|
65
|
+
invokeOlderHistoryLoaderWithReceiptCapture,
|
|
66
|
+
type OlderHistoryLoadReceipt,
|
|
67
|
+
type OlderHistoryLoader,
|
|
68
|
+
} from "../older-history";
|
|
69
|
+
import { Markdown, type MarkdownProps } from "./markdown";
|
|
70
|
+
import { OpenGeniLinkProvider, type OpenGeniLinkResolver } from "./open-geni-links";
|
|
71
|
+
import {
|
|
72
|
+
TimelineBeforeLayout,
|
|
73
|
+
captureTimelineAnchor,
|
|
74
|
+
timelineAnchorCorrection,
|
|
75
|
+
timelineHasReader,
|
|
76
|
+
type TimelineAnchor,
|
|
77
|
+
} from "./timeline-anchor";
|
|
78
|
+
import { useReadingProgress } from "./timeline-reading-progress";
|
|
79
|
+
import {
|
|
80
|
+
animateTimelineSettlement,
|
|
81
|
+
captureTimelineSettlement,
|
|
82
|
+
type TimelineSettlement,
|
|
83
|
+
} from "./timeline-settlement";
|
|
46
84
|
import {
|
|
47
85
|
UserMessageBody,
|
|
48
86
|
UserMessageDisclosureProvider,
|
|
49
87
|
type UserMessageDisclosureContextValue,
|
|
88
|
+
type UserMessageDisclosureLabels,
|
|
50
89
|
} from "./user-message-body";
|
|
51
90
|
import {
|
|
52
91
|
createTipFollowState,
|
|
53
92
|
readerScrollUpPx,
|
|
54
93
|
tipFollowCancel,
|
|
55
|
-
tipFollowCompensateShrink,
|
|
56
94
|
tipFollowCompensateViewportShrink,
|
|
95
|
+
tipFollowObserveContentShrink,
|
|
57
96
|
tipFollowStep,
|
|
58
97
|
supportsScrollEndEvent,
|
|
59
98
|
TIP_FOLLOW_READER_UP_EPS_PX,
|
|
60
99
|
TIP_FOLLOW_SHRINK_EPS_PX,
|
|
100
|
+
type TipFollowContentShrinkBaseline,
|
|
61
101
|
type TipFollowState,
|
|
62
102
|
} from "./tip-follow";
|
|
63
103
|
import {
|
|
@@ -71,6 +111,7 @@ import {
|
|
|
71
111
|
type AuthNeededItem,
|
|
72
112
|
type ContextCompactionItem,
|
|
73
113
|
type GoalItem,
|
|
114
|
+
type HumanInputItem,
|
|
74
115
|
type MachineInputBatchItem,
|
|
75
116
|
type NoticeItem,
|
|
76
117
|
type TimelineGroup,
|
|
@@ -81,6 +122,7 @@ import {
|
|
|
81
122
|
type VideoArtifactPlaybackLoader,
|
|
82
123
|
type ToolRegistry,
|
|
83
124
|
type TurnSummaryOptions,
|
|
125
|
+
type TurnSummaryStatus,
|
|
84
126
|
type UserMessageItem,
|
|
85
127
|
type FoldRestingState,
|
|
86
128
|
type WorkerCompletionItem,
|
|
@@ -91,6 +133,7 @@ import {
|
|
|
91
133
|
useTurnSettleOpen,
|
|
92
134
|
} from "../timeline";
|
|
93
135
|
import { CopyHoverFrame } from "./copy-button";
|
|
136
|
+
import { TimelineAnnotationSourceRootContext } from "./timeline-annotation-reveal-context";
|
|
94
137
|
import { GeneratedVideoPlayer } from "./generated-video-player";
|
|
95
138
|
import {
|
|
96
139
|
MACHINE_INPUT_META,
|
|
@@ -102,23 +145,45 @@ import {
|
|
|
102
145
|
import { SESSION_STATUS_META, StatusDot } from "./session-status";
|
|
103
146
|
import { TimelineComputeLabelProvider } from "../timeline/compute-label";
|
|
104
147
|
import { EntranceAnimationProvider, useEntranceAnimation } from "../timeline/entrance";
|
|
148
|
+
import {
|
|
149
|
+
AllowanceExhaustedRow,
|
|
150
|
+
AllowancePresentationProvider,
|
|
151
|
+
type RenderAllowanceExhausted,
|
|
152
|
+
} from "../timeline/allowance-exhausted-row";
|
|
153
|
+
import type { AllowanceLabels } from "../usage/allowance-copy";
|
|
105
154
|
import { SeenActivityIdsProvider } from "../timeline/seen-activity-ids";
|
|
106
|
-
import {
|
|
155
|
+
import { TimelineAnnotationCards } from "./timeline-annotations";
|
|
107
156
|
import { TooltipProvider } from "./tooltip";
|
|
108
157
|
|
|
109
158
|
const TimelineAnnotationSelection = lazy(() => import("./timeline-annotation-selection"));
|
|
159
|
+
const TimelineAnnotationMarkers = lazy(() => import("./timeline-annotation-markers"));
|
|
110
160
|
|
|
111
161
|
export type MessageTimelineProps = {
|
|
162
|
+
/** Exact durable search hit; clearing it removes highlighting without moving the reader. */
|
|
163
|
+
searchTarget?: TimelineSearchTarget | null | undefined;
|
|
164
|
+
/** Localized user-message disclosure actions, including custom UserMessageBody renderers. */
|
|
165
|
+
userMessageDisclosureLabels?: UserMessageDisclosureLabels | undefined;
|
|
112
166
|
/** Raw session events (projected internally) … */
|
|
113
167
|
events?: SessionEvent[] | undefined;
|
|
114
168
|
/** … or pre-projected items (e.g. from `useSessionEvents().timeline`). */
|
|
115
169
|
items?: TimelineItem[] | undefined;
|
|
116
170
|
/** Current session status (reserved; tip "Working…" chrome removed for now). */
|
|
117
171
|
status?: SessionStatus | null | undefined;
|
|
172
|
+
/** Host-owned controls beside Copy and the timestamp on settled message rows. */
|
|
173
|
+
renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
118
174
|
/** Plug a markdown renderer for message bodies (e.g. streamdown). */
|
|
119
175
|
renderMessageText?:
|
|
120
|
-
| ((
|
|
176
|
+
| ((
|
|
177
|
+
text: string,
|
|
178
|
+
item: AgentMessageItem | UserMessageItem,
|
|
179
|
+
context: { searchTarget: TimelineSearchTarget | null },
|
|
180
|
+
) => ReactNode)
|
|
121
181
|
| undefined;
|
|
182
|
+
/**
|
|
183
|
+
* Render assistant `opengeni-html` / `opengeni-site` fences in the default
|
|
184
|
+
* message renderer (`SessionConversation` supplies the inline Site preview).
|
|
185
|
+
*/
|
|
186
|
+
renderInteractiveBlock?: MarkdownProps["renderInteractiveBlock"];
|
|
122
187
|
/** Drill into a spawned worker session. */
|
|
123
188
|
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
124
189
|
/**
|
|
@@ -138,6 +203,17 @@ export type MessageTimelineProps = {
|
|
|
138
203
|
* a Reconnect button without a handler to run it.
|
|
139
204
|
*/
|
|
140
205
|
onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
|
|
206
|
+
/** Host-owned inline connection setup. Return undefined to use the default recovery card. */
|
|
207
|
+
renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
|
|
208
|
+
/**
|
|
209
|
+
* Replace the "usage limit reached" row shown when a workspace or member
|
|
210
|
+
* usage allowance stops work (an `allowance_exhausted` refusal). Receives the
|
|
211
|
+
* typed refusal (scope, reset time) and the default row; return undefined to
|
|
212
|
+
* keep the default. Use it to point people at your own plan or admin page.
|
|
213
|
+
*/
|
|
214
|
+
renderAllowanceExhausted?: RenderAllowanceExhausted | undefined;
|
|
215
|
+
/** Replace the words of the default "usage limit reached" row. */
|
|
216
|
+
allowanceExhaustedLabels?: Partial<AllowanceLabels> | undefined;
|
|
141
217
|
/**
|
|
142
218
|
* Decide which durable authentication notices this timeline presents.
|
|
143
219
|
* Defaults to showing every notice. Embedded hosts can suppress notices for
|
|
@@ -158,9 +234,19 @@ export type MessageTimelineProps = {
|
|
|
158
234
|
* `createDefaultToolRegistry({ entries })` to add custom tool renderers.
|
|
159
235
|
*/
|
|
160
236
|
toolRegistry?: ToolRegistry | undefined;
|
|
237
|
+
/**
|
|
238
|
+
* Open OpenGeni object links the agent writes in replies and progress notes:
|
|
239
|
+
* `artifact:<file>`, `sandbox:<path>`, editable artifacts, and Sites. Return
|
|
240
|
+
* a host URL (`{ href }`) or action (`{ open }`); unhandled targets render as
|
|
241
|
+
* unavailable text instead of a console link that 404s inside the host.
|
|
242
|
+
* Applies to the default message renderer and to any `Markdown` a custom
|
|
243
|
+
* `renderMessageText` renders. `SessionConversation` supplies file and
|
|
244
|
+
* sandbox downloads by default.
|
|
245
|
+
*/
|
|
246
|
+
resolveLink?: OpenGeniLinkResolver | undefined;
|
|
161
247
|
/** Resolve opaque retained screenshot receipts through the authenticated host SDK. */
|
|
162
248
|
loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
|
|
163
|
-
/** Resolve permanent
|
|
249
|
+
/** Resolve permanent workspace image/file receipts through the authenticated host SDK. */
|
|
164
250
|
loadRetainedArtifact?: RetainedArtifactLoader | undefined;
|
|
165
251
|
/** Mint short-lived native playback sources for retained generated videos. */
|
|
166
252
|
loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
|
|
@@ -170,17 +256,32 @@ export type MessageTimelineProps = {
|
|
|
170
256
|
*/
|
|
171
257
|
computeLabel?: string | null | undefined;
|
|
172
258
|
/** Customize collapsed turn facets for this timeline instance. */
|
|
259
|
+
genieLoading?: GenieLoadingOptions | undefined;
|
|
173
260
|
turnSummary?: TurnSummaryOptions | undefined;
|
|
174
261
|
/** Follow new events when pinned to the bottom. Defaults to true. */
|
|
175
262
|
autoFollow?: boolean | undefined;
|
|
176
263
|
/** Capture a same-row text selection into the host's canonical composer draft. */
|
|
177
264
|
onAnnotate?: ((annotation: DraftTimelineAnnotation) => void) | undefined;
|
|
265
|
+
/**
|
|
266
|
+
* Hide the floating "Back to your message" pill while the timeline viewport
|
|
267
|
+
* is shorter than this (px), e.g. a narrow embed above a decision card.
|
|
268
|
+
* Defaults to 0 (always available).
|
|
269
|
+
*/
|
|
270
|
+
questionNavMinViewportHeight?: number | undefined;
|
|
271
|
+
/** Composer draft quotes currently attached to the next send. */
|
|
272
|
+
draftAnnotations?: readonly DraftTimelineAnnotation[] | undefined;
|
|
273
|
+
/** Open the composer review list for one numbered draft badge. */
|
|
274
|
+
onDraftAnnotationSelect?: ((id: string) => void) | undefined;
|
|
178
275
|
/** Older durable history exists above the current window (see useSessionEvents). */
|
|
179
276
|
hasOlder?: boolean | undefined;
|
|
180
277
|
/** An older window is being fetched; shows the quiet top shimmer. */
|
|
181
278
|
loadingOlder?: boolean | undefined;
|
|
182
|
-
/**
|
|
183
|
-
|
|
279
|
+
/**
|
|
280
|
+
* Called when older history should backfill. Existing void, synchronous-value,
|
|
281
|
+
* and arbitrary-promise callbacks remain supported. Receipt-aware loaders
|
|
282
|
+
* preserve committed-page direction through wrappers and bounded windows.
|
|
283
|
+
*/
|
|
284
|
+
onLoadOlder?: OlderHistoryLoader | undefined;
|
|
184
285
|
/** Jump to the durable session start (bounded oldest window, no middle). */
|
|
185
286
|
onJumpToStart?: (() => void | Promise<void>) | undefined;
|
|
186
287
|
/** True while the oldest window is loading. */
|
|
@@ -189,13 +290,16 @@ export type MessageTimelineProps = {
|
|
|
189
290
|
hasNewer?: boolean | undefined;
|
|
190
291
|
/** A newer history page is being fetched. */
|
|
191
292
|
loadingNewer?: boolean | undefined;
|
|
192
|
-
/** Page forward through history
|
|
193
|
-
onLoadNewer?: (() =>
|
|
293
|
+
/** Page forward through history. Return the request promise to enable inline error/retry. */
|
|
294
|
+
onLoadNewer?: (() => unknown) | undefined;
|
|
194
295
|
/**
|
|
195
296
|
* Reload the live tip window. When omitted, Jump to latest only re-pins and
|
|
196
297
|
* scrolls the in-memory window.
|
|
197
298
|
*/
|
|
198
299
|
onJumpToLatest?: (() => void | Promise<void>) | undefined;
|
|
300
|
+
/** @deprecated Retained for source compatibility; contextual prompt navigation
|
|
301
|
+
* uses only mounted prompts and never invokes this global resolver. */
|
|
302
|
+
onJumpToLatestQuestion?: (() => Promise<number | null>) | undefined;
|
|
199
303
|
/** Host-owned content appended after timeline groups, such as startup progress. */
|
|
200
304
|
trailingState?: ReactNode | undefined;
|
|
201
305
|
emptyState?: ReactNode | undefined;
|
|
@@ -211,8 +315,9 @@ export type MessageTimelineProps = {
|
|
|
211
315
|
*
|
|
212
316
|
* Scroll invariant (tip-follow camera — see `./tip-follow.ts`):
|
|
213
317
|
* - Load/remount: hidden until tip is hard-snapped across a short settle; then
|
|
214
|
-
* reveal. Live tip: DOM
|
|
215
|
-
*
|
|
318
|
+
* reveal. Live tip: DOM growth advances the pinned viewport by the same
|
|
319
|
+
* amount, so rendered content is visible immediately. Only debt that already
|
|
320
|
+
* existed before the growth goes through the camera ease.
|
|
216
321
|
* - One continuous follow while hot (faster τ when behind); sleeps when cold.
|
|
217
322
|
* - While pinned, tip-debt from growth/collapse must NEVER unpin — only
|
|
218
323
|
* wheel/keys/pointer-armed scroll-up, or a settled scrollend away from the
|
|
@@ -226,6 +331,33 @@ export type MessageTimelineProps = {
|
|
|
226
331
|
* alone is wrong. Late layout while unpinned stays browser-owned.
|
|
227
332
|
*/
|
|
228
333
|
const PIN_THRESHOLD_PX = 48;
|
|
334
|
+
/**
|
|
335
|
+
* A pinned timeline can still have meaningful pre-existing debt while the
|
|
336
|
+
* tip-follow camera catches up. Surface the existing explicit jump once that
|
|
337
|
+
* debt is large enough to be useful, without flashing the control for ordinary
|
|
338
|
+
* line-sized streaming movement.
|
|
339
|
+
*/
|
|
340
|
+
const JUMP_TO_LATEST_CATCHUP_DEBT_PX = 240;
|
|
341
|
+
/** Breathing room above the question when following stops at an answer. */
|
|
342
|
+
const QUESTION_NAV_MARGIN_PX = 12;
|
|
343
|
+
/**
|
|
344
|
+
* Floating timeline navigation: two identical small round arrow buttons at fixed
|
|
345
|
+
* spots, centered on the conversation. Back to your message floats 12px below
|
|
346
|
+
* the top edge, or just below an expanded work-header strip while that strip is
|
|
347
|
+
* pinned (32px, 44px on coarse pointers) so the strip stays a full-width
|
|
348
|
+
* target. Jump to latest floats 12px above the bottom edge. They never dodge
|
|
349
|
+
* the content beneath them (floating over a sliver of text is fine), so they
|
|
350
|
+
* cannot wander as rows stream in or the host resizes. Coarse pointers get a
|
|
351
|
+
* larger invisible hit area instead of a larger button.
|
|
352
|
+
*/
|
|
353
|
+
const NAV_BUTTON_CLASS =
|
|
354
|
+
"pointer-events-auto relative inline-flex size-8 items-center justify-center rounded-full border border-og-border bg-og-surface-3/90 text-og-fg-muted shadow-og-md backdrop-blur hover:border-og-border-strong hover:text-og-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-og-accent pointer-coarse:before:absolute pointer-coarse:before:-inset-1.5 pointer-coarse:before:content-['']";
|
|
355
|
+
const NAV_FADE = {
|
|
356
|
+
initial: { opacity: 0 },
|
|
357
|
+
animate: { opacity: 1 },
|
|
358
|
+
exit: { opacity: 0 },
|
|
359
|
+
transition: { duration: 0.15, ease: "easeOut" },
|
|
360
|
+
} as const;
|
|
229
361
|
/**
|
|
230
362
|
* Prefetch older history when the top sentinel is this far from the viewport.
|
|
231
363
|
* After a page loads we stay cool until the reader leaves this band (scrolls
|
|
@@ -233,6 +365,76 @@ const PIN_THRESHOLD_PX = 48;
|
|
|
233
365
|
*/
|
|
234
366
|
const OLDER_PREFETCH_MARGIN_PX = 400;
|
|
235
367
|
const OLDER_PREFETCH_ROOT_MARGIN = `${OLDER_PREFETCH_MARGIN_PX}px 0px 0px 0px`;
|
|
368
|
+
const PRIMARY_ACTION_CLASS =
|
|
369
|
+
"inline-flex w-full shrink-0 items-center justify-center gap-1.5 rounded-og-md border border-og-primary-border bg-og-primary text-og-primary-fg px-3 py-1.5 text-og-menu font-medium sm:w-auto";
|
|
370
|
+
const MESSAGE_BUBBLE_CLASS =
|
|
371
|
+
"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";
|
|
372
|
+
const WAITING_PILL_CLASS =
|
|
373
|
+
"border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting";
|
|
374
|
+
const LOADING_CHIP_CLASS =
|
|
375
|
+
"pointer-events-none inline-flex items-center rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1 text-og-control font-medium shadow-og-md backdrop-blur";
|
|
376
|
+
/** How long live progress notes take to fold into (or out of) their work row. */
|
|
377
|
+
const LIVE_NOTE_FOLD_MS = 220;
|
|
378
|
+
/** The timeline list's `gap-5`; a folded note gives it back with `-mt-5`. */
|
|
379
|
+
const LIVE_NOTE_GAP_PX = 20;
|
|
380
|
+
/** Longest a bottom-space loan waits for an opening work list to grow into it. */
|
|
381
|
+
const LIVE_NOTE_RESERVE_MS = 700;
|
|
382
|
+
/** Lets a live work disclosure report whether it is open (see `reportLiveFold`). */
|
|
383
|
+
const LiveNoteFoldContext = createContext<((workId: string, open: boolean) => void) | null>(null);
|
|
384
|
+
|
|
385
|
+
// State: 0 underfill retry, 1 prefetch pending, 2 compatibility-settled,
|
|
386
|
+
// 3 explicit no-progress waiting to become an underfill retry.
|
|
387
|
+
// The tuple identity is the exact request ownership fence. The boundary may rebase
|
|
388
|
+
// forward while that request is pending when a bounded live-tail append evicts
|
|
389
|
+
// its former oldest row. First-party loaders mark the exact owner when their
|
|
390
|
+
// older page commits; retained direction is the fallback for other hosts.
|
|
391
|
+
type OlderLoadAttempt = [
|
|
392
|
+
boundary: string | undefined,
|
|
393
|
+
state?: number,
|
|
394
|
+
receipt?: OlderHistoryLoadReceipt,
|
|
395
|
+
];
|
|
396
|
+
|
|
397
|
+
function invokeOlderLoad(
|
|
398
|
+
load: OlderHistoryLoader,
|
|
399
|
+
noProgress: () => void,
|
|
400
|
+
attempt: OlderLoadAttempt,
|
|
401
|
+
preserveTail = false,
|
|
402
|
+
): 1 | undefined {
|
|
403
|
+
try {
|
|
404
|
+
// Receipt creation is captured synchronously through legacy wrappers such
|
|
405
|
+
// as `() => void loadOlder()`, even when the wrapper discards the return.
|
|
406
|
+
const result = invokeOlderHistoryLoaderWithReceiptCapture(
|
|
407
|
+
load,
|
|
408
|
+
(receipt) => {
|
|
409
|
+
attempt[2] = receipt;
|
|
410
|
+
},
|
|
411
|
+
preserveTail,
|
|
412
|
+
) as OlderHistoryLoadReceipt | PromiseLike<unknown> | unknown;
|
|
413
|
+
const receipt =
|
|
414
|
+
attempt[2] ??
|
|
415
|
+
(typeof (result as { committed?: unknown } | undefined)?.committed === "boolean"
|
|
416
|
+
? (result as OlderHistoryLoadReceipt)
|
|
417
|
+
: undefined);
|
|
418
|
+
if (receipt) {
|
|
419
|
+
attempt[2] = receipt;
|
|
420
|
+
void receipt.then(
|
|
421
|
+
(value) => value === false && !receipt.committed && noProgress(),
|
|
422
|
+
noProgress,
|
|
423
|
+
);
|
|
424
|
+
return 1;
|
|
425
|
+
}
|
|
426
|
+
if (typeof (result as PromiseLike<unknown> | undefined)?.then != "function") {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
void (result as PromiseLike<unknown>).then(
|
|
430
|
+
(value) => value === false && noProgress(),
|
|
431
|
+
noProgress,
|
|
432
|
+
);
|
|
433
|
+
} catch {
|
|
434
|
+
noProgress();
|
|
435
|
+
}
|
|
436
|
+
return 1;
|
|
437
|
+
}
|
|
236
438
|
|
|
237
439
|
/**
|
|
238
440
|
* Pinned = the viewport bottom is within PIN_THRESHOLD_PX of the content
|
|
@@ -243,7 +445,8 @@ const OLDER_PREFETCH_ROOT_MARGIN = `${OLDER_PREFETCH_MARGIN_PX}px 0px 0px 0px`;
|
|
|
243
445
|
* cannot scroll at all is always pinned.
|
|
244
446
|
*/
|
|
245
447
|
function maxScrollOf(node: HTMLElement): number {
|
|
246
|
-
|
|
448
|
+
// Browser layout guarantees scrollHeight is at least clientHeight.
|
|
449
|
+
return node.scrollHeight - node.clientHeight;
|
|
247
450
|
}
|
|
248
451
|
|
|
249
452
|
/**
|
|
@@ -286,6 +489,58 @@ function isNearBottom(node: HTMLElement): boolean {
|
|
|
286
489
|
return gap < Math.min(PIN_THRESHOLD_PX, maxScroll);
|
|
287
490
|
}
|
|
288
491
|
|
|
492
|
+
/** Content-space top of a timeline group, or null when it is not mounted. */
|
|
493
|
+
function contentTopOf(node: HTMLElement, groupKey: string): number | null {
|
|
494
|
+
const element = node.querySelector(`[data-og-group-key="${cssEscapeAttribute(groupKey)}"]`);
|
|
495
|
+
if (!(element instanceof HTMLElement)) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
return element.getBoundingClientRect().top - node.getBoundingClientRect().top + node.scrollTop;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The question the reader is inside of, once its start has scrolled out of view. */
|
|
502
|
+
/**
|
|
503
|
+
* `belowHeader`: an expanded work header is pinned to the top of the scrollport,
|
|
504
|
+
* so the up arrow sits just below that strip instead of covering it.
|
|
505
|
+
*/
|
|
506
|
+
type QuestionNav = { key: string; belowHeader: boolean };
|
|
507
|
+
|
|
508
|
+
/** How far a question's start must be above the viewport before navigation shows. */
|
|
509
|
+
const QUESTION_NAV_HIDDEN_PX = 24;
|
|
510
|
+
|
|
511
|
+
function readQuestionNav(node: HTMLElement): QuestionNav | null {
|
|
512
|
+
const view = node.getBoundingClientRect();
|
|
513
|
+
const prompts = [...node.querySelectorAll<HTMLElement>("[data-og-prompt]")];
|
|
514
|
+
// The last prompt before the reading position owns the response/work in view.
|
|
515
|
+
// A newer prompt farther down the conversation is not a navigation target.
|
|
516
|
+
const middle = view.top + view.height / 2;
|
|
517
|
+
const prompt = prompts
|
|
518
|
+
.reverse()
|
|
519
|
+
.find((candidate) => candidate.getBoundingClientRect().top <= middle);
|
|
520
|
+
const key = prompt?.dataset.ogGroupKey;
|
|
521
|
+
if (!prompt || !key) return null;
|
|
522
|
+
const bounds = prompt.getBoundingClientRect();
|
|
523
|
+
if (bounds.top >= view.top - QUESTION_NAV_HIDDEN_PX || bounds.bottom > view.top) {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
return { key, belowHeader: workHeaderPinned(node, view) };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** True while an expanded outer work header is stuck to the scrollport's top edge. */
|
|
530
|
+
function workHeaderPinned(node: HTMLElement, view: DOMRect): boolean {
|
|
531
|
+
for (const header of node.querySelectorAll<HTMLElement>(
|
|
532
|
+
'[data-og-work-header="outer"][data-state="open"]',
|
|
533
|
+
)) {
|
|
534
|
+
const box = header.getBoundingClientRect();
|
|
535
|
+
if (Math.abs(box.top - view.top) <= 1 && box.bottom > view.top) return true;
|
|
536
|
+
}
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function sameQuestionNav(a: QuestionNav | null, b: QuestionNav | null): boolean {
|
|
541
|
+
return a === b || (!!a && !!b && a.key === b.key && a.belowHeader === b.belowHeader);
|
|
542
|
+
}
|
|
543
|
+
|
|
289
544
|
/** Escape a value for use inside a CSS attribute selector. */
|
|
290
545
|
function cssEscapeAttribute(value: string): string {
|
|
291
546
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
@@ -301,13 +556,21 @@ function cssEscapeAttribute(value: string): string {
|
|
|
301
556
|
* with a "jump to latest" affordance when the reader scrolls back.
|
|
302
557
|
*/
|
|
303
558
|
export function MessageTimeline({
|
|
559
|
+
resolveLink,
|
|
560
|
+
renderInteractiveBlock,
|
|
561
|
+
userMessageDisclosureLabels,
|
|
562
|
+
searchTarget,
|
|
304
563
|
events,
|
|
305
564
|
items,
|
|
306
565
|
status: _status,
|
|
566
|
+
renderMessageActions,
|
|
307
567
|
renderMessageText,
|
|
308
568
|
onOpenSession,
|
|
309
569
|
onMemoryClick,
|
|
310
570
|
onReconnect,
|
|
571
|
+
renderAuthNeeded,
|
|
572
|
+
renderAllowanceExhausted,
|
|
573
|
+
allowanceExhaustedLabels,
|
|
311
574
|
shouldRenderAuthNeeded,
|
|
312
575
|
resolveProviderLogo,
|
|
313
576
|
toolRegistry = defaultToolRegistry,
|
|
@@ -315,9 +578,13 @@ export function MessageTimeline({
|
|
|
315
578
|
loadRetainedArtifact,
|
|
316
579
|
loadVideoArtifactPlayback,
|
|
317
580
|
computeLabel = null,
|
|
581
|
+
genieLoading,
|
|
318
582
|
turnSummary,
|
|
319
583
|
autoFollow = true,
|
|
320
584
|
onAnnotate,
|
|
585
|
+
questionNavMinViewportHeight = 0,
|
|
586
|
+
draftAnnotations,
|
|
587
|
+
onDraftAnnotationSelect,
|
|
321
588
|
hasOlder = false,
|
|
322
589
|
loadingOlder = false,
|
|
323
590
|
onLoadOlder,
|
|
@@ -331,14 +598,143 @@ export function MessageTimeline({
|
|
|
331
598
|
emptyState,
|
|
332
599
|
className,
|
|
333
600
|
}: MessageTimelineProps) {
|
|
601
|
+
const formatError = useErrorMessage();
|
|
334
602
|
const resolvedItems = useMemo(() => {
|
|
335
|
-
const
|
|
603
|
+
const projectedItems = items ?? buildTimeline(events ?? []);
|
|
336
604
|
if (!shouldRenderAuthNeeded) {
|
|
337
|
-
return
|
|
605
|
+
return projectedItems;
|
|
338
606
|
}
|
|
339
|
-
return
|
|
607
|
+
return projectedItems.filter(
|
|
608
|
+
(item) => item.kind !== "auth-needed" || shouldRenderAuthNeeded(item),
|
|
609
|
+
);
|
|
340
610
|
}, [items, events, shouldRenderAuthNeeded]);
|
|
341
|
-
const
|
|
611
|
+
const searchItem = searchTarget
|
|
612
|
+
? resolvedItems.find((item) => isTimelineSearchTarget(item, searchTarget))
|
|
613
|
+
: undefined;
|
|
614
|
+
const searchRevealKey =
|
|
615
|
+
searchItem && searchTarget
|
|
616
|
+
? JSON.stringify([
|
|
617
|
+
searchItem.id,
|
|
618
|
+
searchTarget.query,
|
|
619
|
+
searchTarget.occurrence ?? 0,
|
|
620
|
+
searchTarget.offset ?? null,
|
|
621
|
+
])
|
|
622
|
+
: null;
|
|
623
|
+
const searchTargetRef = useRef(searchTarget);
|
|
624
|
+
searchTargetRef.current = searchTarget;
|
|
625
|
+
const renderSearchableMessageText = useMemo(
|
|
626
|
+
() =>
|
|
627
|
+
renderMessageText
|
|
628
|
+
? (text: string, item: AgentMessageItem | UserMessageItem) =>
|
|
629
|
+
renderMessageText(text, item, {
|
|
630
|
+
searchTarget: searchItem?.id === item.id ? (searchTarget ?? null) : null,
|
|
631
|
+
})
|
|
632
|
+
: (text: string, item: AgentMessageItem | UserMessageItem) => {
|
|
633
|
+
const body = (
|
|
634
|
+
<Markdown
|
|
635
|
+
searchTarget={searchItem?.id === item.id ? searchTarget : null}
|
|
636
|
+
streaming={item.kind === "agent-message" && item.streaming}
|
|
637
|
+
renderInteractiveBlock={
|
|
638
|
+
item.kind === "agent-message" ? renderInteractiveBlock : undefined
|
|
639
|
+
}
|
|
640
|
+
>
|
|
641
|
+
{text}
|
|
642
|
+
</Markdown>
|
|
643
|
+
);
|
|
644
|
+
return item.kind === "user-message" ? (
|
|
645
|
+
<UserMessageBody messageId={item.id} text={text}>
|
|
646
|
+
{body}
|
|
647
|
+
</UserMessageBody>
|
|
648
|
+
) : (
|
|
649
|
+
body
|
|
650
|
+
);
|
|
651
|
+
},
|
|
652
|
+
[renderInteractiveBlock, renderMessageText, searchItem?.id, searchTarget],
|
|
653
|
+
);
|
|
654
|
+
// Event-window identity is independent of projected rows (partial messages
|
|
655
|
+
// can acquire a different first-delta id when older text arrives).
|
|
656
|
+
const sourceItems = events ?? items;
|
|
657
|
+
const olderBoundaryKey = sourceItems?.[0]?.id;
|
|
658
|
+
const newerBoundaryKey = `${events?.[0]?.sessionId ?? ""}:${olderBoundaryKey ?? ""}`;
|
|
659
|
+
const newerScopeRef = useRef(newerBoundaryKey);
|
|
660
|
+
newerScopeRef.current = newerBoundaryKey;
|
|
661
|
+
const newerAttemptRef = useRef<{ pending: boolean } | null>(null);
|
|
662
|
+
const newerRetryButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
663
|
+
const [newerRetryPending, setNewerRetryPending] = useState(false);
|
|
664
|
+
const [newerFailure, setNewerFailure] = useState<{ key: string; message: string } | null>(null);
|
|
665
|
+
useEffect(() => {
|
|
666
|
+
newerScopeRef.current = newerBoundaryKey;
|
|
667
|
+
newerAttemptRef.current = null;
|
|
668
|
+
setNewerFailure(null);
|
|
669
|
+
setNewerRetryPending(false);
|
|
670
|
+
return () => {
|
|
671
|
+
newerScopeRef.current = "";
|
|
672
|
+
newerAttemptRef.current = null;
|
|
673
|
+
};
|
|
674
|
+
}, [newerBoundaryKey]);
|
|
675
|
+
const requestNewer = useCallback(
|
|
676
|
+
(explicitRetry = false) => {
|
|
677
|
+
if (
|
|
678
|
+
newerScopeRef.current !== newerBoundaryKey ||
|
|
679
|
+
!onLoadNewer ||
|
|
680
|
+
loadingNewer ||
|
|
681
|
+
(newerAttemptRef.current && (!explicitRetry || newerAttemptRef.current.pending))
|
|
682
|
+
) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const attempt = { pending: true };
|
|
686
|
+
newerAttemptRef.current = attempt;
|
|
687
|
+
if (explicitRetry) setNewerRetryPending(true);
|
|
688
|
+
const isCurrent = () =>
|
|
689
|
+
newerAttemptRef.current === attempt && newerScopeRef.current === newerBoundaryKey;
|
|
690
|
+
// Both synchronous host errors and rejected promises belong to this
|
|
691
|
+
// boundary. Retain the failed attempt so observers cannot hot-retry it.
|
|
692
|
+
void Promise.resolve()
|
|
693
|
+
.then(() => (isCurrent() ? onLoadNewer() : undefined))
|
|
694
|
+
.then(
|
|
695
|
+
() => {
|
|
696
|
+
if (!isCurrent()) return;
|
|
697
|
+
newerAttemptRef.current = null;
|
|
698
|
+
// The successful page removes the recovery control. Return focus
|
|
699
|
+
// to the reading surface without moving the reader's viewport.
|
|
700
|
+
if (document.activeElement === newerRetryButtonRef.current) {
|
|
701
|
+
scrollRef.current?.focus({ preventScroll: true });
|
|
702
|
+
}
|
|
703
|
+
setNewerFailure(null);
|
|
704
|
+
setNewerRetryPending(false);
|
|
705
|
+
},
|
|
706
|
+
(reason: unknown) => {
|
|
707
|
+
if (!isCurrent()) return;
|
|
708
|
+
attempt.pending = false;
|
|
709
|
+
setNewerRetryPending(false);
|
|
710
|
+
setNewerFailure({
|
|
711
|
+
key: newerBoundaryKey,
|
|
712
|
+
message: formatError(reason),
|
|
713
|
+
});
|
|
714
|
+
},
|
|
715
|
+
);
|
|
716
|
+
},
|
|
717
|
+
[onLoadNewer, loadingNewer, newerBoundaryKey, formatError],
|
|
718
|
+
);
|
|
719
|
+
const previousSourceIdsRef = useRef(new Set<string>());
|
|
720
|
+
const previousSourceBoundaryRef = useRef<string | undefined>(undefined);
|
|
721
|
+
const readingAnchorRef = useRef<TimelineAnchor | null>(null);
|
|
722
|
+
const settlementRef = useRef<TimelineSettlement | null>(null);
|
|
723
|
+
const settlementAnimationRef = useRef<Animation | null>(null);
|
|
724
|
+
const stopSettlement = useCallback(() => {
|
|
725
|
+
settlementAnimationRef.current?.cancel();
|
|
726
|
+
settlementAnimationRef.current = null;
|
|
727
|
+
}, []);
|
|
728
|
+
const olderPageBudgetRef = useRef(0);
|
|
729
|
+
const [olderDemand, setOlderDemand] = useState(0);
|
|
730
|
+
const readableTurns = turnSummary?.rolling === true;
|
|
731
|
+
const projectedGroups = useMemo(
|
|
732
|
+
() => groupTimeline(resolvedItems, { readableTurns }),
|
|
733
|
+
[resolvedItems, readableTurns],
|
|
734
|
+
);
|
|
735
|
+
// A new timeline revision (including session/turn replacement) owns layout.
|
|
736
|
+
// Never leave an old decorative transform running on its retained rows.
|
|
737
|
+
useLayoutEffect(() => stopSettlement, [projectedGroups, stopSettlement]);
|
|
342
738
|
const annotationSources = useMemo(() => {
|
|
343
739
|
const sources = new Map<string, TimelineAnnotationSourceDescriptor>();
|
|
344
740
|
for (const item of resolvedItems) {
|
|
@@ -353,29 +749,56 @@ export function MessageTimeline({
|
|
|
353
749
|
}
|
|
354
750
|
return sources;
|
|
355
751
|
}, [resolvedItems]);
|
|
356
|
-
const groups = useStableTimelineGroupKeys(allGroups);
|
|
357
752
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
358
753
|
const topSentinelRef = useRef<HTMLDivElement | null>(null);
|
|
359
754
|
const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
|
|
360
755
|
const previousBulkFirstKeyRef = useRef<string | null | undefined>(undefined);
|
|
361
756
|
const [pinned, setPinned] = useState(true);
|
|
757
|
+
const { groups: allGroups, release: releaseProgress } = useReadingProgress(
|
|
758
|
+
projectedGroups,
|
|
759
|
+
autoFollow && pinned && !hasNewer,
|
|
760
|
+
scrollRef.current,
|
|
761
|
+
);
|
|
762
|
+
const [canSkipTipCatchup, setCanSkipTipCatchup] = useState(false);
|
|
763
|
+
const canSkipTipCatchupRef = useRef(false);
|
|
362
764
|
const [bulkActive, setBulkActive] = useState(true);
|
|
363
765
|
// Older history prefetch is user-driven: a window shorter than the viewport
|
|
364
766
|
// + rootMargin would otherwise keep the top sentinel intersecting and fetch
|
|
365
767
|
// history forever while the reader sits at the tip. Arm on first scroll-up.
|
|
366
768
|
const olderPrefetchArmedRef = useRef(false);
|
|
367
769
|
const [olderPrefetchArmed, setOlderPrefetchArmed] = useState(false);
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
|
|
770
|
+
// A collapsed history tail can be shorter than the viewport. In that state
|
|
771
|
+
// there is no upward scroll range, so reader intent can never arm the top
|
|
772
|
+
// sentinel. Request one older page per loaded window until history either
|
|
773
|
+
// fills the viewport or the host reports that no older rows remain. Each
|
|
774
|
+
// attempt owns the oldest loaded item boundary across BOTH the automatic
|
|
775
|
+
// underfill and reader-driven sentinel paths. Live-tail appends do not
|
|
776
|
+
// advance older pagination or release that exact owner.
|
|
777
|
+
const olderLoadAttemptRef = useRef<OlderLoadAttempt | null>(null);
|
|
778
|
+
const [underfillSettledAttempt, setUnderfillSettledAttempt] = useState<OlderLoadAttempt | null>(
|
|
779
|
+
null,
|
|
780
|
+
);
|
|
781
|
+
const underfillRetryReadyRef = useRef(false);
|
|
372
782
|
const resizeFollowRafRef = useRef<number | null>(null);
|
|
783
|
+
const questionNavFrameRef = useRef<number | null>(null);
|
|
784
|
+
const [questionNav, setQuestionNav] = useState<QuestionNav | null>(null);
|
|
785
|
+
const questionNavMinViewportRef = useRef(questionNavMinViewportHeight);
|
|
786
|
+
questionNavMinViewportRef.current = questionNavMinViewportHeight;
|
|
787
|
+
const scheduleQuestionNavRef = useRef<() => void>(() => {});
|
|
788
|
+
// Unpinned is not the same as away from the tip: focusing a control in the
|
|
789
|
+
// conversation (Copy, a connection card) hands the view to the reader while
|
|
790
|
+
// they are still at the bottom. Offer the jump only once there is something
|
|
791
|
+
// below them, or a newer window / catch-up to skip.
|
|
792
|
+
const [awayFromTip, setAwayFromTip] = useState(false);
|
|
793
|
+
const jumpPillShown = autoFollow && ((!pinned && awayFromTip) || hasNewer || canSkipTipCatchup);
|
|
373
794
|
const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
|
|
374
795
|
// Content stays invisible until the tip is hard-parked across a short
|
|
375
796
|
// post-commit settle (two rAFs). That absorbs sync late layout while hidden
|
|
376
797
|
// so load/remount does not ease into the tip — live tip-follow is unchanged
|
|
377
798
|
// once revealed. A flash of the window's TOP is still structurally impossible.
|
|
378
|
-
|
|
799
|
+
// The accepted-create handoff uses the reserved local id "c" so its known
|
|
800
|
+
// first message is visible immediately; loaded histories still park first.
|
|
801
|
+
const [revealed, setRevealed] = useState(resolvedItems[0]?.id === "c");
|
|
379
802
|
// Mirror `pinned` into a ref, written ONLY by applyPinned, so the
|
|
380
803
|
// ResizeObserver rAF (a stable closure) reads the live value and a snap can
|
|
381
804
|
// never race a just-unpinned reader across a pending React commit.
|
|
@@ -414,6 +837,8 @@ export function MessageTimeline({
|
|
|
414
837
|
* jumps (Vimium) settle via scrollend while the camera is idle.
|
|
415
838
|
*/
|
|
416
839
|
const readerIntentArmRef = useRef(false);
|
|
840
|
+
/** Gesture-start geometry survives commits before the native scroll event. */
|
|
841
|
+
const readerIntentStartRef = useRef<{ scrollTop: number; maxScroll: number } | null>(null);
|
|
417
842
|
/**
|
|
418
843
|
* Count of camera/snap scrollTop writes whose scroll echoes are not yet
|
|
419
844
|
* consumed. A boolean was wrong when the browser coalesced two writes into
|
|
@@ -438,25 +863,273 @@ export function MessageTimeline({
|
|
|
438
863
|
// deliberate chip remounts (activity→turn wrap, nested key flips) so a fold
|
|
439
864
|
// that already settled closed — or that the reader closed — never reopens.
|
|
440
865
|
const foldMemoryRef = useRef<Map<string, FoldRestingState>>(new Map());
|
|
866
|
+
// Live work disclosures that are open. While open, that turn's progress
|
|
867
|
+
// notes read inside the disclosure, so their copies above it fold away.
|
|
868
|
+
const [openLiveFolds, setOpenLiveFolds] = useState<ReadonlySet<string>>(() => new Set());
|
|
869
|
+
const openLiveFoldsRef = useRef<ReadonlySet<string>>(openLiveFolds);
|
|
870
|
+
const liveNoteIdsByWorkRef = useRef<ReadonlyMap<string, readonly string[]>>(new Map());
|
|
871
|
+
const liveFoldMotionRef = useRef<{ frame: number | null; finish: () => void } | null>(null);
|
|
872
|
+
// The bottom-space loan currently on the scroller, by owner.
|
|
873
|
+
const liveFoldReserveRef = useRef<object | null>(null);
|
|
874
|
+
// writeScrollTop is declared further down; the fold reaches it through this.
|
|
875
|
+
const writeScrollTopRef = useRef<(node: HTMLElement, top: number) => void>(() => {});
|
|
876
|
+
const reportLiveFold = useCallback((workId: string, open: boolean) => {
|
|
877
|
+
const current = openLiveFoldsRef.current;
|
|
878
|
+
if (current.has(workId) === open) return;
|
|
879
|
+
const next = new Set(current);
|
|
880
|
+
if (open) next.add(workId);
|
|
881
|
+
else next.delete(workId);
|
|
882
|
+
openLiveFoldsRef.current = next;
|
|
883
|
+
liveFoldMotionRef.current?.finish();
|
|
884
|
+
const scroller = scrollRef.current;
|
|
885
|
+
const rows = (liveNoteIdsByWorkRef.current.get(workId) ?? []).flatMap((id) => {
|
|
886
|
+
const row = scroller?.querySelector<HTMLElement>(
|
|
887
|
+
`[data-og-live-note-id="${cssEscapeAttribute(id)}"]`,
|
|
888
|
+
);
|
|
889
|
+
return row ? [row] : [];
|
|
890
|
+
});
|
|
891
|
+
if (!scroller || rows.length === 0) {
|
|
892
|
+
setOpenLiveFolds(next);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const header = scroller.querySelector<HTMLElement>(
|
|
896
|
+
`[data-og-work-id="${cssEscapeAttribute(workId)}"] [data-og-work-header="outer"]`,
|
|
897
|
+
);
|
|
898
|
+
const viewTop = scroller.getBoundingClientRect().top;
|
|
899
|
+
const headerTop = header?.getBoundingClientRect().top;
|
|
900
|
+
if (
|
|
901
|
+
!open &&
|
|
902
|
+
header &&
|
|
903
|
+
headerTop !== undefined &&
|
|
904
|
+
(headerTop < viewTop || headerTop > viewTop + scroller.clientHeight)
|
|
905
|
+
) {
|
|
906
|
+
// Closed from the row's sticky header after reading down the list: the
|
|
907
|
+
// row is no longer where the reader is. Like any big collapse, land on
|
|
908
|
+
// it in one step — the notes return above and the closed row rests at
|
|
909
|
+
// the bottom edge — rather than animating content the reader can't see.
|
|
910
|
+
setOpenLiveFolds(next);
|
|
911
|
+
requestFrame(() =>
|
|
912
|
+
requestFrame(() => {
|
|
913
|
+
const row = header.closest<HTMLElement>("[data-og-timeline-group-anchor]");
|
|
914
|
+
if (!row?.isConnected) return;
|
|
915
|
+
const box = row.getBoundingClientRect();
|
|
916
|
+
const top = scroller.getBoundingClientRect().top;
|
|
917
|
+
const bottom = top + scroller.clientHeight;
|
|
918
|
+
// + the scroller's pb-6, as at the true end.
|
|
919
|
+
if (box.bottom > bottom || box.top < top)
|
|
920
|
+
writeScrollTopRef.current(scroller, scroller.scrollTop + box.bottom - bottom + 24);
|
|
921
|
+
scheduleQuestionNavRef.current();
|
|
922
|
+
}),
|
|
923
|
+
);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
// Opening a live row near the bottom drops its one-line preview while its
|
|
927
|
+
// list grows from nothing; closing shrinks the list before the notes have
|
|
928
|
+
// grown back. For that moment the page is shorter than where the reader
|
|
929
|
+
// is, so the browser would clamp the scroll and everything would drop.
|
|
930
|
+
// This runs before that frame paints: keep the reader's position and lend
|
|
931
|
+
// one screen of space at the bottom (the shortfall keeps changing through
|
|
932
|
+
// the commit) until the content has grown back into it.
|
|
933
|
+
const readerTop = lastScrollTopRef.current;
|
|
934
|
+
const reserve = readerTop > 0.5 ? scroller.clientHeight : 0;
|
|
935
|
+
const loan = {};
|
|
936
|
+
if (reserve > 0) {
|
|
937
|
+
liveFoldReserveRef.current = loan;
|
|
938
|
+
scroller.style.paddingBottom = `calc(1.5rem + ${reserve}px)`;
|
|
939
|
+
if (Math.abs(scroller.scrollTop - readerTop) > 0.5)
|
|
940
|
+
writeScrollTopRef.current(scroller, readerTop);
|
|
941
|
+
}
|
|
942
|
+
const releaseReserve = (deadline: number) => {
|
|
943
|
+
// Only this motion's loan; a newer toggle may have replaced it.
|
|
944
|
+
if (reserve === 0 || liveFoldReserveRef.current !== loan) return;
|
|
945
|
+
const needed = scroller.scrollTop + scroller.clientHeight - (scroller.scrollHeight - reserve);
|
|
946
|
+
if (needed > 0.5 && performance.now() < deadline && scroller.isConnected) {
|
|
947
|
+
requestFrame(() => releaseReserve(deadline));
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
liveFoldReserveRef.current = null;
|
|
951
|
+
scroller.style.removeProperty("padding-bottom");
|
|
952
|
+
};
|
|
953
|
+
// The notes fold into (or out of) the work row in one short motion. The
|
|
954
|
+
// row glides to where it should rest: opening, into the first note's place
|
|
955
|
+
// (or the top edge, when that note was above view) so its list starts just
|
|
956
|
+
// beneath; closing, down below its returning notes but never past the
|
|
957
|
+
// bottom edge. When everything is in view that is exactly what happens on
|
|
958
|
+
// its own; otherwise the scroll is written in the same frame as the
|
|
959
|
+
// heights, so it cannot lag. Native scroll anchoring is suspended for the
|
|
960
|
+
// motion; inline styles pin the current look across the state commit, end
|
|
961
|
+
// exactly at the classes' resting geometry, and are then removed.
|
|
962
|
+
const workRow = header?.closest<HTMLElement>("[data-og-timeline-group-anchor]") ?? null;
|
|
963
|
+
const rowStart = workRow?.getBoundingClientRect().top;
|
|
964
|
+
const viewBottom = viewTop + scroller.clientHeight;
|
|
965
|
+
let landing =
|
|
966
|
+
open && rowStart !== undefined
|
|
967
|
+
? Math.max(rows[0]!.getBoundingClientRect().top, viewTop)
|
|
968
|
+
: null;
|
|
969
|
+
const heights = rows.map((row) => (open ? row.offsetHeight : 0));
|
|
970
|
+
const apply = (progress: number) => {
|
|
971
|
+
const shown = open ? 1 - progress : progress;
|
|
972
|
+
rows.forEach((note, index) => {
|
|
973
|
+
// The list gap travels inside the row as padding (margin cancels it),
|
|
974
|
+
// so a shrinking row never overlaps the one above it mid-motion.
|
|
975
|
+
note.style.marginTop = `${-LIVE_NOTE_GAP_PX}px`;
|
|
976
|
+
note.style.paddingTop = `${LIVE_NOTE_GAP_PX * shown}px`;
|
|
977
|
+
note.style.height = `${(heights[index]! + LIVE_NOTE_GAP_PX) * shown}px`;
|
|
978
|
+
note.style.opacity = String(shown);
|
|
979
|
+
});
|
|
980
|
+
if (workRow && rowStart !== undefined && landing !== null) {
|
|
981
|
+
const drift =
|
|
982
|
+
workRow.getBoundingClientRect().top - (rowStart + (landing - rowStart) * progress);
|
|
983
|
+
if (Math.abs(drift) > 0.5) writeScrollTopRef.current(scroller, scroller.scrollTop + drift);
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
for (const row of rows) {
|
|
987
|
+
row.style.boxSizing = "border-box";
|
|
988
|
+
row.style.overflow = "hidden";
|
|
989
|
+
row.style.overflowAnchor = "none";
|
|
990
|
+
}
|
|
991
|
+
scroller.style.overflowAnchor = "none";
|
|
992
|
+
apply(0);
|
|
993
|
+
const fold: { frame: number | null; finish: () => void } = {
|
|
994
|
+
frame: null,
|
|
995
|
+
finish: () => {
|
|
996
|
+
if (fold.frame != null) cancelFrame(fold.frame);
|
|
997
|
+
for (const row of rows) {
|
|
998
|
+
for (const property of [
|
|
999
|
+
"height",
|
|
1000
|
+
"margin-top",
|
|
1001
|
+
"padding-top",
|
|
1002
|
+
"opacity",
|
|
1003
|
+
"overflow",
|
|
1004
|
+
"overflow-anchor",
|
|
1005
|
+
"box-sizing",
|
|
1006
|
+
])
|
|
1007
|
+
row.style.removeProperty(property);
|
|
1008
|
+
}
|
|
1009
|
+
scroller.style.removeProperty("overflow-anchor");
|
|
1010
|
+
releaseReserve(performance.now() + LIVE_NOTE_RESERVE_MS);
|
|
1011
|
+
// The row may now be pinned to the top without any scroll: re-place
|
|
1012
|
+
// the up arrow (it sits below a pinned work header).
|
|
1013
|
+
scheduleQuestionNavRef.current();
|
|
1014
|
+
if (liveFoldMotionRef.current === fold) liveFoldMotionRef.current = null;
|
|
1015
|
+
},
|
|
1016
|
+
};
|
|
1017
|
+
liveFoldMotionRef.current = fold;
|
|
1018
|
+
const duration = prefersReducedMotion() ? 0 : LIVE_NOTE_FOLD_MS;
|
|
1019
|
+
let began: number | null = null;
|
|
1020
|
+
const step = (now: number) => {
|
|
1021
|
+
fold.frame = null;
|
|
1022
|
+
if (!rows.every((row) => row.isConnected)) return fold.finish();
|
|
1023
|
+
if (!open && began === null) {
|
|
1024
|
+
// Unfolding: the natural heights are known only once the folded
|
|
1025
|
+
// class is gone; the inline styles still hold them at zero.
|
|
1026
|
+
rows.forEach((note, index) => (heights[index] = note.scrollHeight));
|
|
1027
|
+
if (workRow && rowStart !== undefined) {
|
|
1028
|
+
const returning = heights.reduce((sum, height) => sum + height + LIVE_NOTE_GAP_PX, 0);
|
|
1029
|
+
// The closed row is its header plus one preview line (its list is
|
|
1030
|
+
// still collapsing now, so the row's own height is not final yet).
|
|
1031
|
+
const closedRow = 2 * (header?.getBoundingClientRect().height ?? 0);
|
|
1032
|
+
const lowest = viewBottom - closedRow - 24;
|
|
1033
|
+
landing = Math.min(rowStart + returning, Math.max(lowest, rowStart));
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
began ??= now;
|
|
1037
|
+
const linear = duration === 0 ? 1 : Math.min(1, (now - began) / duration);
|
|
1038
|
+
apply(1 - (1 - linear) ** 3);
|
|
1039
|
+
if (linear < 1) fold.frame = requestFrame(step);
|
|
1040
|
+
else fold.finish();
|
|
1041
|
+
};
|
|
1042
|
+
fold.frame = requestFrame(step);
|
|
1043
|
+
setOpenLiveFolds(next);
|
|
1044
|
+
}, []);
|
|
1045
|
+
useEffect(
|
|
1046
|
+
() => () => {
|
|
1047
|
+
liveFoldMotionRef.current?.finish();
|
|
1048
|
+
},
|
|
1049
|
+
[],
|
|
1050
|
+
);
|
|
441
1051
|
const userMessageDisclosureMemoryRef = useRef<Map<string, boolean>>(new Map());
|
|
442
1052
|
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
|
443
|
-
const
|
|
444
|
-
const
|
|
1053
|
+
const firstItemGroupKeyRef = useRef<string | null>(null);
|
|
1054
|
+
const firstItemGroupOffsetTopRef = useRef<number | null>(null);
|
|
1055
|
+
const firstItemContentTopRef = useRef<number | null>(null);
|
|
445
1056
|
const firstItemId = resolvedItems[0]?.id ?? null;
|
|
1057
|
+
// Pagination ownership follows the oldest committed input, before host
|
|
1058
|
+
// filtering and grouping. A page containing only suppressed auth notices
|
|
1059
|
+
// still advances this receipt, while live-tail appends leave it unchanged.
|
|
1060
|
+
// Promise fulfillment alone does neither, so delayed prepends stay fenced.
|
|
1061
|
+
const underfillRetryReady = (underfillRetryReadyRef.current = !!(
|
|
1062
|
+
underfillSettledAttempt &&
|
|
1063
|
+
underfillSettledAttempt === olderLoadAttemptRef.current &&
|
|
1064
|
+
hasOlder &&
|
|
1065
|
+
onLoadOlder &&
|
|
1066
|
+
!loadingOlder
|
|
1067
|
+
));
|
|
446
1068
|
// Bulk paints (the initial tail window, a prepended older window — detected
|
|
447
1069
|
// by the first group key changing) must not run per-row entrance animations.
|
|
448
1070
|
const firstKeyChangedForBulk =
|
|
449
1071
|
previousBulkFirstKeyRef.current !== undefined &&
|
|
450
1072
|
previousBulkFirstKeyRef.current !== firstGroupKey;
|
|
451
1073
|
const bulkRender = allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk);
|
|
1074
|
+
const groups = useStableTimelineGroupKeys(allGroups, !bulkRender);
|
|
1075
|
+
// Each live progress note → whether its turn's work disclosure is open.
|
|
1076
|
+
const [liveNoteFolds, liveNoteIdsByWork] = useMemo(() => {
|
|
1077
|
+
const folds = new Map<string, boolean>();
|
|
1078
|
+
const byWork = new Map<string, readonly string[]>();
|
|
1079
|
+
for (const { group } of groups) {
|
|
1080
|
+
if (group.kind !== "activity" || !group.work?.liveNoteIds) continue;
|
|
1081
|
+
byWork.set(group.id, group.work.liveNoteIds);
|
|
1082
|
+
for (const id of group.work.liveNoteIds) folds.set(id, openLiveFolds.has(group.id));
|
|
1083
|
+
}
|
|
1084
|
+
return [folds, byWork] as const;
|
|
1085
|
+
}, [groups, openLiveFolds]);
|
|
1086
|
+
useLayoutEffect(() => {
|
|
1087
|
+
liveNoteIdsByWorkRef.current = liveNoteIdsByWork;
|
|
1088
|
+
}, [liveNoteIdsByWork]);
|
|
1089
|
+
const turnsWithOutput = useMemo(
|
|
1090
|
+
() =>
|
|
1091
|
+
new Set(
|
|
1092
|
+
resolvedItems.flatMap((item) =>
|
|
1093
|
+
"turnId" in item &&
|
|
1094
|
+
item.turnId &&
|
|
1095
|
+
(item.kind === "tool-call" ||
|
|
1096
|
+
((item.kind === "agent-message" || item.kind === "reasoning") && item.text.trim()))
|
|
1097
|
+
? [item.turnId]
|
|
1098
|
+
: [],
|
|
1099
|
+
),
|
|
1100
|
+
),
|
|
1101
|
+
[resolvedItems],
|
|
1102
|
+
);
|
|
1103
|
+
|
|
1104
|
+
const applyCanSkipTipCatchup = useCallback((value: boolean) => {
|
|
1105
|
+
if (canSkipTipCatchupRef.current !== value) {
|
|
1106
|
+
canSkipTipCatchupRef.current = value;
|
|
1107
|
+
setCanSkipTipCatchup(value);
|
|
1108
|
+
}
|
|
1109
|
+
}, []);
|
|
452
1110
|
|
|
453
1111
|
// The ONLY writer of the pinned flag. Ref and state move together, so
|
|
454
1112
|
// behavior (refs read by rAF callbacks) and rendering (the anchor class,
|
|
455
1113
|
// the Jump-to-latest button) can never desync.
|
|
456
|
-
const applyPinned = useCallback(
|
|
457
|
-
|
|
458
|
-
pinnedRef.current
|
|
459
|
-
|
|
1114
|
+
const applyPinned = useCallback(
|
|
1115
|
+
(value: boolean) => {
|
|
1116
|
+
if (pinnedRef.current !== value) {
|
|
1117
|
+
pinnedRef.current = value;
|
|
1118
|
+
setPinned(value);
|
|
1119
|
+
}
|
|
1120
|
+
if (!value) {
|
|
1121
|
+
applyCanSkipTipCatchup(false);
|
|
1122
|
+
}
|
|
1123
|
+
},
|
|
1124
|
+
[applyCanSkipTipCatchup],
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
const rearmOlderPrefetchAfterLeavingTop = useCallback((node: HTMLElement) => {
|
|
1128
|
+
if (node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
1129
|
+
const state = olderLoadAttemptRef.current?.[1];
|
|
1130
|
+
if (state === 2 || state === 3) {
|
|
1131
|
+
olderLoadAttemptRef.current = null;
|
|
1132
|
+
}
|
|
460
1133
|
}
|
|
461
1134
|
}, []);
|
|
462
1135
|
|
|
@@ -466,6 +1139,7 @@ export function MessageTimeline({
|
|
|
466
1139
|
// Pure tip-follow camera. Pin intent uses clamp conservation, not timers.
|
|
467
1140
|
const followRef = useRef<TipFollowState>(createTipFollowState());
|
|
468
1141
|
const followFrameRef = useRef<number | null>(null);
|
|
1142
|
+
const contentShrinkBaselineRef = useRef<TipFollowContentShrinkBaseline | null>(null);
|
|
469
1143
|
|
|
470
1144
|
const syncScrollBaseline = useCallback((node: HTMLElement) => {
|
|
471
1145
|
lastScrollTopRef.current = node.scrollTop;
|
|
@@ -480,6 +1154,9 @@ export function MessageTimeline({
|
|
|
480
1154
|
if (before === next) {
|
|
481
1155
|
return;
|
|
482
1156
|
}
|
|
1157
|
+
// Unpinned camera/anchor writes invalidate return intent. A pinned
|
|
1158
|
+
// pointer gesture still needs its cumulative leave budget across writes.
|
|
1159
|
+
if (!pinnedRef.current || !readerIntentArmRef.current) readerIntentStartRef.current = null;
|
|
483
1160
|
programmaticScrollRef.current += 1;
|
|
484
1161
|
node.scrollTop = next;
|
|
485
1162
|
if (node.scrollTop === before) {
|
|
@@ -489,9 +1166,10 @@ export function MessageTimeline({
|
|
|
489
1166
|
programmaticScrollRef.current -= 1;
|
|
490
1167
|
}
|
|
491
1168
|
}, []);
|
|
1169
|
+
writeScrollTopRef.current = writeScrollTop;
|
|
492
1170
|
|
|
493
1171
|
const cancelLeaveFallback = useCallback(() => {
|
|
494
|
-
if (leaveFallbackRafRef.current
|
|
1172
|
+
if (leaveFallbackRafRef.current != null) {
|
|
495
1173
|
cancelFrame(leaveFallbackRafRef.current);
|
|
496
1174
|
leaveFallbackRafRef.current = null;
|
|
497
1175
|
}
|
|
@@ -502,38 +1180,58 @@ export function MessageTimeline({
|
|
|
502
1180
|
cancelLeaveFallback();
|
|
503
1181
|
}, [cancelLeaveFallback]);
|
|
504
1182
|
|
|
1183
|
+
const clearReaderIntent = useCallback(() => {
|
|
1184
|
+
readerIntentArmRef.current = false;
|
|
1185
|
+
readerIntentStartRef.current = null;
|
|
1186
|
+
}, []);
|
|
1187
|
+
|
|
505
1188
|
const stopFollow = useCallback(() => {
|
|
1189
|
+
contentShrinkBaselineRef.current = null;
|
|
506
1190
|
followRef.current = tipFollowCancel(followRef.current);
|
|
507
|
-
if (followFrameRef.current
|
|
1191
|
+
if (followFrameRef.current != null) {
|
|
508
1192
|
cancelFrame(followFrameRef.current);
|
|
509
1193
|
followFrameRef.current = null;
|
|
510
1194
|
}
|
|
511
1195
|
}, []);
|
|
512
1196
|
|
|
513
1197
|
/** Reader left the tip — wheel, keyboard, pointer-armed scroll-up, or scrollend. */
|
|
514
|
-
const releasePinFromReader = useCallback(
|
|
515
|
-
(
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
1198
|
+
const releasePinFromReader = useCallback(() => {
|
|
1199
|
+
if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
clearReaderIntent();
|
|
1203
|
+
clearPendingReaderLeave();
|
|
1204
|
+
stopFollow();
|
|
1205
|
+
applyPinned(false);
|
|
1206
|
+
if (wantPinRef.current) {
|
|
1207
|
+
wantPinRef.current = false;
|
|
1208
|
+
}
|
|
1209
|
+
if (!olderPrefetchArmedRef.current) {
|
|
1210
|
+
olderPrefetchArmedRef.current = true;
|
|
1211
|
+
setOlderPrefetchArmed(true);
|
|
1212
|
+
}
|
|
1213
|
+
}, [autoFollow, applyPinned, clearPendingReaderLeave, clearReaderIntent, stopFollow]);
|
|
1214
|
+
|
|
1215
|
+
useEffect(() => {
|
|
1216
|
+
if (!readableTurns) return;
|
|
1217
|
+
const node = scrollRef.current;
|
|
1218
|
+
if (!node) return;
|
|
1219
|
+
const onSelection = () => {
|
|
1220
|
+
const selection = node.ownerDocument.getSelection();
|
|
1221
|
+
if (
|
|
1222
|
+
selection &&
|
|
1223
|
+
!selection.isCollapsed &&
|
|
1224
|
+
node.contains(selection.anchorNode) &&
|
|
1225
|
+
node.contains(selection.focusNode)
|
|
1226
|
+
) {
|
|
1227
|
+
stopSettlement();
|
|
1228
|
+
releasePinFromReader();
|
|
1229
|
+
disclosureKeepsUnpinnedRef.current = true;
|
|
533
1230
|
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
|
|
1231
|
+
};
|
|
1232
|
+
node.ownerDocument.addEventListener("selectionchange", onSelection);
|
|
1233
|
+
return () => node.ownerDocument.removeEventListener("selectionchange", onSelection);
|
|
1234
|
+
}, [readableTurns, releasePinFromReader, stopSettlement]);
|
|
537
1235
|
|
|
538
1236
|
/**
|
|
539
1237
|
* Settled away from the tip while the camera is idle — Vimium / unfocused
|
|
@@ -547,19 +1245,17 @@ export function MessageTimeline({
|
|
|
547
1245
|
if (programmaticScrollRef.current > 0) {
|
|
548
1246
|
return;
|
|
549
1247
|
}
|
|
550
|
-
if (followRef.current.running || followFrameRef.current
|
|
1248
|
+
if (followRef.current.running || followFrameRef.current != null) {
|
|
551
1249
|
return;
|
|
552
1250
|
}
|
|
553
1251
|
if (isNearBottom(node) || maxScrollOf(node) <= 1) {
|
|
554
1252
|
clearPendingReaderLeave();
|
|
555
1253
|
return;
|
|
556
1254
|
}
|
|
557
|
-
releasePinFromReader(
|
|
558
|
-
|
|
559
|
-
olderLoadGateRef.current = "armed";
|
|
560
|
-
}
|
|
1255
|
+
releasePinFromReader();
|
|
1256
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
561
1257
|
},
|
|
562
|
-
[autoFollow, clearPendingReaderLeave, releasePinFromReader],
|
|
1258
|
+
[autoFollow, clearPendingReaderLeave, rearmOlderPrefetchAfterLeavingTop, releasePinFromReader],
|
|
563
1259
|
);
|
|
564
1260
|
|
|
565
1261
|
const scheduleLeaveFallback = useCallback(() => {
|
|
@@ -577,6 +1273,22 @@ export function MessageTimeline({
|
|
|
577
1273
|
});
|
|
578
1274
|
}, [cancelLeaveFallback, releasePinAfterScrollSettled]);
|
|
579
1275
|
|
|
1276
|
+
const requestEarlierFromReader = () => {
|
|
1277
|
+
// Upward navigation replaces any earlier downward gesture, even when
|
|
1278
|
+
// already unpinned and the engine never delivered scrollend.
|
|
1279
|
+
clearReaderIntent();
|
|
1280
|
+
releasePinFromReader();
|
|
1281
|
+
wantPinRef.current = false;
|
|
1282
|
+
// A stationary upward gesture is still demand. Successful short/folded
|
|
1283
|
+
// pages may never create enough range to leave the prefetch band.
|
|
1284
|
+
olderPageBudgetRef.current = 8;
|
|
1285
|
+
if (olderLoadAttemptRef.current?.[1] === 2) {
|
|
1286
|
+
olderLoadAttemptRef.current = null;
|
|
1287
|
+
setOlderDemand((value) => value + 1);
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
const touchPositionRef = useRef<{ x: number; y: number } | null>(null);
|
|
1291
|
+
|
|
580
1292
|
const onWheel = (event: {
|
|
581
1293
|
deltaY: number;
|
|
582
1294
|
deltaX: number;
|
|
@@ -593,13 +1305,17 @@ export function MessageTimeline({
|
|
|
593
1305
|
if (wheelConsumedByNestedScrollable(event)) {
|
|
594
1306
|
return;
|
|
595
1307
|
}
|
|
1308
|
+
stopSettlement();
|
|
596
1309
|
disclosureKeepsUnpinnedRef.current = false;
|
|
1310
|
+
programmaticScrollRef.current = 0;
|
|
597
1311
|
if (event.deltaY >= 0) {
|
|
1312
|
+
const node = scrollRef.current;
|
|
1313
|
+
readerIntentStartRef.current = node
|
|
1314
|
+
? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
|
|
1315
|
+
: null;
|
|
598
1316
|
return;
|
|
599
1317
|
}
|
|
600
|
-
|
|
601
|
-
event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
|
|
602
|
-
releasePinFromReader(node);
|
|
1318
|
+
requestEarlierFromReader();
|
|
603
1319
|
};
|
|
604
1320
|
|
|
605
1321
|
/** Touch / stylus / mouse drag on the scroller — explicit leave (not layout). */
|
|
@@ -607,11 +1323,13 @@ export function MessageTimeline({
|
|
|
607
1323
|
button: number;
|
|
608
1324
|
pointerType: string;
|
|
609
1325
|
target: EventTarget | null;
|
|
1326
|
+
currentTarget: EventTarget | null;
|
|
610
1327
|
}) => {
|
|
611
1328
|
// Primary button / touch / pen only. Ignore right-click etc.
|
|
612
|
-
if (event.button
|
|
1329
|
+
if (event.button && event.pointerType === "mouse") {
|
|
613
1330
|
return;
|
|
614
1331
|
}
|
|
1332
|
+
stopSettlement();
|
|
615
1333
|
// Clicks on chips/buttons/links must not arm — their settle collapse
|
|
616
1334
|
// also drops scrollTop and would false-unpin. Drag on prose/scroller may.
|
|
617
1335
|
if (
|
|
@@ -621,7 +1339,13 @@ export function MessageTimeline({
|
|
|
621
1339
|
return;
|
|
622
1340
|
}
|
|
623
1341
|
disclosureKeepsUnpinnedRef.current = false;
|
|
1342
|
+
programmaticScrollRef.current = 0;
|
|
1343
|
+
const node =
|
|
1344
|
+
event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
|
|
624
1345
|
readerIntentArmRef.current = true;
|
|
1346
|
+
readerIntentStartRef.current = node
|
|
1347
|
+
? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
|
|
1348
|
+
: null;
|
|
625
1349
|
};
|
|
626
1350
|
|
|
627
1351
|
const onKeyDown = (event: { key: string; currentTarget: EventTarget | null }) => {
|
|
@@ -633,18 +1357,25 @@ export function MessageTimeline({
|
|
|
633
1357
|
event.key === "Home" ||
|
|
634
1358
|
event.key === "End"
|
|
635
1359
|
) {
|
|
1360
|
+
stopSettlement();
|
|
636
1361
|
disclosureKeepsUnpinnedRef.current = false;
|
|
1362
|
+
// Reader navigation supersedes a pending camera/anchor scroll echo,
|
|
1363
|
+
// including downward keys that do not explicitly release the pin.
|
|
1364
|
+
programmaticScrollRef.current = 0;
|
|
1365
|
+
const node = scrollRef.current;
|
|
1366
|
+
readerIntentStartRef.current = node
|
|
1367
|
+
? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
|
|
1368
|
+
: null;
|
|
637
1369
|
}
|
|
638
1370
|
if (event.key !== "ArrowUp" && event.key !== "PageUp" && event.key !== "Home") {
|
|
639
1371
|
return;
|
|
640
1372
|
}
|
|
641
|
-
|
|
642
|
-
event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
|
|
643
|
-
releasePinFromReader(node);
|
|
1373
|
+
requestEarlierFromReader();
|
|
644
1374
|
};
|
|
645
1375
|
|
|
646
1376
|
const snapToBottom = useCallback(
|
|
647
1377
|
(node: HTMLElement) => {
|
|
1378
|
+
clearReaderIntent();
|
|
648
1379
|
stopFollow();
|
|
649
1380
|
cancelLeaveFallback();
|
|
650
1381
|
writeScrollTop(node, Math.max(0, node.scrollHeight - node.clientHeight));
|
|
@@ -655,8 +1386,16 @@ export function MessageTimeline({
|
|
|
655
1386
|
lastClientHeight: node.clientHeight,
|
|
656
1387
|
cameraTop: null,
|
|
657
1388
|
};
|
|
1389
|
+
applyCanSkipTipCatchup(false);
|
|
658
1390
|
},
|
|
659
|
-
[
|
|
1391
|
+
[
|
|
1392
|
+
applyCanSkipTipCatchup,
|
|
1393
|
+
cancelLeaveFallback,
|
|
1394
|
+
clearReaderIntent,
|
|
1395
|
+
stopFollow,
|
|
1396
|
+
syncScrollBaseline,
|
|
1397
|
+
writeScrollTop,
|
|
1398
|
+
],
|
|
660
1399
|
);
|
|
661
1400
|
|
|
662
1401
|
const beginUserMessageDisclosureChange = useCallback(
|
|
@@ -714,11 +1453,171 @@ export function MessageTimeline({
|
|
|
714
1453
|
() => ({
|
|
715
1454
|
expandedByMessageId: userMessageDisclosureMemoryRef.current,
|
|
716
1455
|
beginChange: beginUserMessageDisclosureChange,
|
|
1456
|
+
labels: {
|
|
1457
|
+
showMore: userMessageDisclosureLabels?.showMore,
|
|
1458
|
+
showLess: userMessageDisclosureLabels?.showLess,
|
|
1459
|
+
},
|
|
1460
|
+
}),
|
|
1461
|
+
[
|
|
1462
|
+
beginUserMessageDisclosureChange,
|
|
1463
|
+
userMessageDisclosureLabels?.showMore,
|
|
1464
|
+
userMessageDisclosureLabels?.showLess,
|
|
1465
|
+
],
|
|
1466
|
+
);
|
|
1467
|
+
const timelineGroupEntryContext = useMemo<TimelineGroupEntryContext>(
|
|
1468
|
+
() => ({
|
|
1469
|
+
userMessageDisclosureContext,
|
|
1470
|
+
behavior: {
|
|
1471
|
+
renderMessageActions,
|
|
1472
|
+
renderMessageText: renderSearchableMessageText,
|
|
1473
|
+
onOpenSession,
|
|
1474
|
+
onMemoryClick,
|
|
1475
|
+
onReconnect,
|
|
1476
|
+
renderAuthNeeded,
|
|
1477
|
+
resolveProviderLogo,
|
|
1478
|
+
toolRegistry,
|
|
1479
|
+
loadRetainedScreenshot,
|
|
1480
|
+
loadRetainedArtifact,
|
|
1481
|
+
loadVideoArtifactPlayback,
|
|
1482
|
+
genieLoading,
|
|
1483
|
+
turnSummary,
|
|
1484
|
+
},
|
|
717
1485
|
}),
|
|
718
|
-
[
|
|
1486
|
+
[
|
|
1487
|
+
loadRetainedArtifact,
|
|
1488
|
+
loadRetainedScreenshot,
|
|
1489
|
+
loadVideoArtifactPlayback,
|
|
1490
|
+
onMemoryClick,
|
|
1491
|
+
onOpenSession,
|
|
1492
|
+
onReconnect,
|
|
1493
|
+
renderAuthNeeded,
|
|
1494
|
+
renderMessageActions,
|
|
1495
|
+
renderSearchableMessageText,
|
|
1496
|
+
resolveProviderLogo,
|
|
1497
|
+
toolRegistry,
|
|
1498
|
+
genieLoading,
|
|
1499
|
+
turnSummary,
|
|
1500
|
+
userMessageDisclosureContext,
|
|
1501
|
+
],
|
|
1502
|
+
);
|
|
1503
|
+
|
|
1504
|
+
const releaseSearchPin = useCallback(() => {
|
|
1505
|
+
applyPinned(false);
|
|
1506
|
+
stopFollow();
|
|
1507
|
+
setRevealed(true);
|
|
1508
|
+
}, [applyPinned, stopFollow]);
|
|
1509
|
+
useTimelineSearchNavigation(
|
|
1510
|
+
scrollRef,
|
|
1511
|
+
searchItem?.id,
|
|
1512
|
+
searchTarget,
|
|
1513
|
+
releaseSearchPin,
|
|
1514
|
+
resolvedItems,
|
|
1515
|
+
);
|
|
1516
|
+
|
|
1517
|
+
const requestOlderIfUnderfilled = useCallback(
|
|
1518
|
+
(node: HTMLElement, retry?: OlderLoadAttempt) => {
|
|
1519
|
+
let currentAttempt = olderLoadAttemptRef.current;
|
|
1520
|
+
if (retry && (currentAttempt !== retry || retry[0] !== olderBoundaryKey)) {
|
|
1521
|
+
// AnimatePresence may retain the exiting button briefly. Its stale
|
|
1522
|
+
// handler cannot replace a newer exact owner.
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
const underfilled = maxScrollOf(node) <= 1;
|
|
1526
|
+
if (!retry && underfilled && currentAttempt?.[1] === 3) {
|
|
1527
|
+
// A receipted prefetch already declined or failed while the viewport
|
|
1528
|
+
// was still scrollable. Collapse may later remove the scroll range;
|
|
1529
|
+
// promote that exact owner to Retry without issuing another request.
|
|
1530
|
+
currentAttempt[1] = 0;
|
|
1531
|
+
setUnderfillSettledAttempt(currentAttempt);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
if (!retry && underfilled && currentAttempt?.[1] === 2) {
|
|
1535
|
+
// A settled ordinary prefetch owns only this visit to the top band.
|
|
1536
|
+
// If its resulting window cannot scroll, yield to automatic underfill
|
|
1537
|
+
// so the reader is not stranded behind a cooldown they cannot exit.
|
|
1538
|
+
currentAttempt = olderLoadAttemptRef.current = null;
|
|
1539
|
+
}
|
|
1540
|
+
// clientHeight=0 is pre-layout/headless, not evidence that the rendered
|
|
1541
|
+
// history underfills a real viewport.
|
|
1542
|
+
if (
|
|
1543
|
+
node.clientHeight <= 1 ||
|
|
1544
|
+
(!retry && !underfilled) ||
|
|
1545
|
+
!hasOlder ||
|
|
1546
|
+
loadingOlder ||
|
|
1547
|
+
!onLoadOlder ||
|
|
1548
|
+
(currentAttempt && !retry)
|
|
1549
|
+
) {
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
const attempt: OlderLoadAttempt = (olderLoadAttemptRef.current = [olderBoundaryKey]);
|
|
1553
|
+
setUnderfillSettledAttempt(null);
|
|
1554
|
+
// Every underfill request owns the ordinary sentinel too, even if reader
|
|
1555
|
+
// intent had not armed it yet when this short-window request began.
|
|
1556
|
+
const noProgress = () => {
|
|
1557
|
+
if (scrollRef.current && olderLoadAttemptRef.current === attempt) {
|
|
1558
|
+
setUnderfillSettledAttempt(attempt);
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
// Legacy fire-and-forget callbacks keep the one-shot behavior. Hosts
|
|
1562
|
+
// that return the real promise opt into safe rejection/no-progress retry;
|
|
1563
|
+
// exact `false` is the first-party request-not-accepted receipt.
|
|
1564
|
+
// All other fulfillment retains this exact owner until its prepend
|
|
1565
|
+
// boundary commits; promise settlement alone cannot prove progress.
|
|
1566
|
+
invokeOlderLoad(onLoadOlder, noProgress, attempt, !retry);
|
|
1567
|
+
},
|
|
1568
|
+
[hasOlder, loadingOlder, olderBoundaryKey, onLoadOlder],
|
|
1569
|
+
);
|
|
1570
|
+
const scheduleQuestionNav = useCallback(() => {
|
|
1571
|
+
if (!readableTurns || questionNavFrameRef.current != null) {
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
questionNavFrameRef.current = requestFrame(() => {
|
|
1575
|
+
questionNavFrameRef.current = null;
|
|
1576
|
+
const node = scrollRef.current;
|
|
1577
|
+
// A short viewport (a narrow embed above a decision card) has no room
|
|
1578
|
+
// for a floating pill that would cover the very rows being read.
|
|
1579
|
+
// clientHeight <= 1 is pre-layout/headless, not a short viewport.
|
|
1580
|
+
const roomy =
|
|
1581
|
+
node !== null &&
|
|
1582
|
+
(node.clientHeight <= 1 || node.clientHeight >= questionNavMinViewportRef.current);
|
|
1583
|
+
const next = node && roomy ? readQuestionNav(node) : null;
|
|
1584
|
+
setQuestionNav((current) => (sameQuestionNav(current, next) ? current : next));
|
|
1585
|
+
});
|
|
1586
|
+
}, [readableTurns]);
|
|
1587
|
+
scheduleQuestionNavRef.current = scheduleQuestionNav;
|
|
1588
|
+
useEffect(
|
|
1589
|
+
() => () => {
|
|
1590
|
+
if (questionNavFrameRef.current != null) {
|
|
1591
|
+
cancelFrame(questionNavFrameRef.current);
|
|
1592
|
+
questionNavFrameRef.current = null;
|
|
1593
|
+
}
|
|
1594
|
+
},
|
|
1595
|
+
[],
|
|
719
1596
|
);
|
|
1597
|
+
const jumpToQuestion = (key: string) => {
|
|
1598
|
+
const node = scrollRef.current;
|
|
1599
|
+
const top = node ? contentTopOf(node, key) : null;
|
|
1600
|
+
if (!node || top === null) {
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
// Explicit reader navigation: leave the tip and park the question on top.
|
|
1604
|
+
releasePinFromReader();
|
|
1605
|
+
wantPinRef.current = false;
|
|
1606
|
+
disclosureKeepsUnpinnedRef.current = false;
|
|
1607
|
+
writeScrollTop(node, Math.max(0, top - QUESTION_NAV_MARGIN_PX));
|
|
1608
|
+
// Hand keyboard/accessibility focus to the actual destination. This also
|
|
1609
|
+
// gives native scroll anchoring the new question rather than an old work
|
|
1610
|
+
// row when the bounded context subsequently gains a later history page.
|
|
1611
|
+
node
|
|
1612
|
+
.querySelector<HTMLElement>(`[data-og-group-key="${cssEscapeAttribute(key)}"]`)
|
|
1613
|
+
?.focus({ preventScroll: true });
|
|
1614
|
+
syncScrollBaseline(node);
|
|
1615
|
+
scheduleQuestionNav();
|
|
1616
|
+
};
|
|
720
1617
|
|
|
721
|
-
const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(
|
|
1618
|
+
const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(
|
|
1619
|
+
requestOlderIfUnderfilled as (node: HTMLElement, now?: number) => void,
|
|
1620
|
+
);
|
|
722
1621
|
const driveFollow = useCallback(
|
|
723
1622
|
(node: HTMLElement, nowMs?: number) => {
|
|
724
1623
|
if (!pinnedRef.current || hasNewerRef.current) {
|
|
@@ -739,19 +1638,41 @@ export function MessageTimeline({
|
|
|
739
1638
|
: typeof performance !== "undefined"
|
|
740
1639
|
? performance.now()
|
|
741
1640
|
: Date.now();
|
|
742
|
-
|
|
1641
|
+
let previousHeight = followRef.current.lastHeight;
|
|
1642
|
+
const previousObservedHeight = lastScrollHeightRef.current;
|
|
1643
|
+
if (
|
|
1644
|
+
contentShrinkBaselineRef.current &&
|
|
1645
|
+
previousObservedHeight > 0 &&
|
|
1646
|
+
node.scrollHeight > previousObservedHeight
|
|
1647
|
+
) {
|
|
1648
|
+
// A reversal ends the collapse sequence. If it remains below the held
|
|
1649
|
+
// baseline, adopt the recovered height; if it grew beyond the baseline,
|
|
1650
|
+
// leave that baseline for tipFollowStep to observe as real growth.
|
|
1651
|
+
contentShrinkBaselineRef.current = null;
|
|
1652
|
+
if (node.scrollHeight < previousHeight) {
|
|
1653
|
+
followRef.current = {
|
|
1654
|
+
...followRef.current,
|
|
1655
|
+
lastHeight: node.scrollHeight,
|
|
1656
|
+
cameraTop: null,
|
|
1657
|
+
};
|
|
1658
|
+
previousHeight = node.scrollHeight;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
const shrinkObservation = tipFollowObserveContentShrink(
|
|
1662
|
+
contentShrinkBaselineRef.current,
|
|
1663
|
+
previousHeight,
|
|
1664
|
+
lastScrollTopRef.current,
|
|
1665
|
+
node.scrollHeight,
|
|
1666
|
+
node.clientHeight,
|
|
1667
|
+
);
|
|
1668
|
+
contentShrinkBaselineRef.current = shrinkObservation.baseline;
|
|
743
1669
|
// Settle-collapse: compensate Δh from the pre-shrink baseline (browser
|
|
744
1670
|
// may already have clamped — don't double-subtract). Keep the follow rAF
|
|
745
1671
|
// alive so when collapse ends (or stream resumes) we ease instead of a
|
|
746
1672
|
// hard stop → flick. Do NOT tip-ease on the same frame as a real shrink
|
|
747
1673
|
// (that fight was the top-of-viewport flicker).
|
|
748
|
-
if (
|
|
749
|
-
let nextTop =
|
|
750
|
-
lastScrollTopRef.current,
|
|
751
|
-
previousHeight,
|
|
752
|
-
node.scrollHeight,
|
|
753
|
-
node.clientHeight,
|
|
754
|
-
);
|
|
1674
|
+
if (shrinkObservation.compensatedScrollTop !== null) {
|
|
1675
|
+
let nextTop = shrinkObservation.compensatedScrollTop;
|
|
755
1676
|
// A chrome/composer dock can land on the same frame as a settle-fold
|
|
756
1677
|
// (the "turn blocked" moment). Compensate BOTH in one write — adopting
|
|
757
1678
|
// the shrunk clientHeight below without gluing left the chrome height
|
|
@@ -776,7 +1697,7 @@ export function MessageTimeline({
|
|
|
776
1697
|
// A direct glue write re-based the camera — drop any stale fraction.
|
|
777
1698
|
cameraTop: null,
|
|
778
1699
|
};
|
|
779
|
-
if (followFrameRef.current
|
|
1700
|
+
if (followFrameRef.current == null) {
|
|
780
1701
|
followFrameRef.current = requestFrame((frameNow) => {
|
|
781
1702
|
followFrameRef.current = null;
|
|
782
1703
|
const current = scrollRef.current;
|
|
@@ -787,15 +1708,6 @@ export function MessageTimeline({
|
|
|
787
1708
|
}
|
|
788
1709
|
return;
|
|
789
1710
|
}
|
|
790
|
-
// Sub-eps height noise: adopt height without moving the camera. Do NOT
|
|
791
|
-
// adopt clientHeight here — tipFollowStep (next line) owns that baseline
|
|
792
|
-
// and must still see a same-frame viewport shrink to glue it.
|
|
793
|
-
if (previousHeight > 0 && node.scrollHeight < previousHeight) {
|
|
794
|
-
followRef.current = {
|
|
795
|
-
...followRef.current,
|
|
796
|
-
lastHeight: node.scrollHeight,
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
1711
|
const result = tipFollowStep(followRef.current, {
|
|
800
1712
|
scrollTop: node.scrollTop,
|
|
801
1713
|
scrollHeight: node.scrollHeight,
|
|
@@ -808,9 +1720,13 @@ export function MessageTimeline({
|
|
|
808
1720
|
followRef.current = result.state;
|
|
809
1721
|
writeScrollTop(node, result.scrollTop);
|
|
810
1722
|
syncScrollBaseline(node);
|
|
1723
|
+
applyCanSkipTipCatchup(
|
|
1724
|
+
result.state.running &&
|
|
1725
|
+
maxScrollOf(node) - node.scrollTop >= JUMP_TO_LATEST_CATCHUP_DEBT_PX,
|
|
1726
|
+
);
|
|
811
1727
|
if (result.state.running) {
|
|
812
1728
|
cancelLeaveFallback();
|
|
813
|
-
if (followFrameRef.current
|
|
1729
|
+
if (followFrameRef.current == null) {
|
|
814
1730
|
followFrameRef.current = requestFrame((frameNow) => {
|
|
815
1731
|
followFrameRef.current = null;
|
|
816
1732
|
const current = scrollRef.current;
|
|
@@ -819,12 +1735,12 @@ export function MessageTimeline({
|
|
|
819
1735
|
}
|
|
820
1736
|
});
|
|
821
1737
|
}
|
|
822
|
-
} else if (followFrameRef.current
|
|
1738
|
+
} else if (followFrameRef.current != null) {
|
|
823
1739
|
cancelFrame(followFrameRef.current);
|
|
824
1740
|
followFrameRef.current = null;
|
|
825
1741
|
}
|
|
826
1742
|
},
|
|
827
|
-
[cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
|
|
1743
|
+
[applyCanSkipTipCatchup, cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
|
|
828
1744
|
);
|
|
829
1745
|
driveFollowRef.current = driveFollow;
|
|
830
1746
|
|
|
@@ -842,12 +1758,80 @@ export function MessageTimeline({
|
|
|
842
1758
|
return;
|
|
843
1759
|
}
|
|
844
1760
|
const previousFirstItemId = previousFirstItemIdRef.current;
|
|
845
|
-
const
|
|
846
|
-
const
|
|
1761
|
+
const previousFirstItemGroupKey = firstItemGroupKeyRef.current;
|
|
1762
|
+
const previousFirstItemGroupOffsetTop = firstItemGroupOffsetTopRef.current;
|
|
1763
|
+
const previousItemContentTop = firstItemContentTopRef.current;
|
|
847
1764
|
const previousScrollTop = lastScrollTopRef.current;
|
|
848
|
-
const
|
|
849
|
-
const
|
|
850
|
-
|
|
1765
|
+
const previousMaxScroll = lastMaxScrollRef.current;
|
|
1766
|
+
const wasAtLiveTailBeforeCommit =
|
|
1767
|
+
previousMaxScroll <= 1 ||
|
|
1768
|
+
previousMaxScroll - previousScrollTop < Math.min(PIN_THRESHOLD_PX, previousMaxScroll);
|
|
1769
|
+
const firstItemChanged = !!previousFirstItemId && firstItemId !== previousFirstItemId;
|
|
1770
|
+
const sourceChanged = olderBoundaryKey !== previousSourceBoundaryRef.current;
|
|
1771
|
+
const retainedSource =
|
|
1772
|
+
sourceItems?.some((item) => previousSourceIdsRef.current.has(item.id)) ?? false;
|
|
1773
|
+
const prepended = sourceChanged && retainedSource;
|
|
1774
|
+
previousSourceBoundaryRef.current = olderBoundaryKey;
|
|
1775
|
+
previousSourceIdsRef.current = new Set(sourceItems?.map((item) => item.id));
|
|
1776
|
+
const readingAnchor = readingAnchorRef.current;
|
|
1777
|
+
readingAnchorRef.current = null;
|
|
1778
|
+
const settlement = settlementRef.current;
|
|
1779
|
+
settlementRef.current = null;
|
|
1780
|
+
const attempt = olderLoadAttemptRef.current;
|
|
1781
|
+
const committedZeroOverlapOlderReplacement = !!(
|
|
1782
|
+
attempt?.[2]?.committed &&
|
|
1783
|
+
sourceChanged &&
|
|
1784
|
+
!retainedSource
|
|
1785
|
+
);
|
|
1786
|
+
const restorePrependAnchor = () => {
|
|
1787
|
+
const correction = readingAnchor && timelineAnchorCorrection(node, readingAnchor);
|
|
1788
|
+
if (correction != null) {
|
|
1789
|
+
if (Math.abs(correction) > 1) writeScrollTop(node, node.scrollTop + correction);
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
// Keep the reader on the same retained rows. Prefer the exact first-item
|
|
1793
|
+
// content coordinate (needed when a prepend merges inside one group),
|
|
1794
|
+
// then the retained group offset, then scrollHeight as a final fallback.
|
|
1795
|
+
// If native anchoring already applied the same shift, leave scrollTop.
|
|
1796
|
+
let delta: number | null = null;
|
|
1797
|
+
if (previousFirstItemId && previousItemContentTop != null) {
|
|
1798
|
+
const itemEl = node.querySelector(
|
|
1799
|
+
`[data-og-item="${cssEscapeAttribute(previousFirstItemId)}"]`,
|
|
1800
|
+
);
|
|
1801
|
+
if (itemEl instanceof HTMLElement) {
|
|
1802
|
+
const scrollerTop = node.getBoundingClientRect().top;
|
|
1803
|
+
const currentItemTop = itemEl.getBoundingClientRect().top - scrollerTop + node.scrollTop;
|
|
1804
|
+
delta = Math.round(currentItemTop - previousItemContentTop);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
const anchorKey = previousFirstItemGroupKey;
|
|
1808
|
+
const anchorEl =
|
|
1809
|
+
anchorKey != null
|
|
1810
|
+
? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(anchorKey)}"]`)
|
|
1811
|
+
: null;
|
|
1812
|
+
if (
|
|
1813
|
+
delta == null &&
|
|
1814
|
+
anchorEl instanceof HTMLElement &&
|
|
1815
|
+
previousFirstItemGroupOffsetTop != null
|
|
1816
|
+
) {
|
|
1817
|
+
const moved = Math.round(anchorEl.offsetTop - previousFirstItemGroupOffsetTop);
|
|
1818
|
+
if (moved) {
|
|
1819
|
+
delta = moved;
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
if (delta == null) {
|
|
1823
|
+
const heightDelta = Math.round(node.scrollHeight - previousScrollHeightRef.current);
|
|
1824
|
+
if (heightDelta > 0) {
|
|
1825
|
+
delta = heightDelta;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
if (delta != null) {
|
|
1829
|
+
const expected = previousScrollTop + delta;
|
|
1830
|
+
if (Math.abs(node.scrollTop - expected) > 2) {
|
|
1831
|
+
writeScrollTop(node, expected);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
};
|
|
851
1835
|
if (pendingJumpToStartRef.current && firstItemChanged) {
|
|
852
1836
|
// The oldest window landed — jump against the NEW DOM, and skip the
|
|
853
1837
|
// prepend correction (it would shift the reader away from the top).
|
|
@@ -863,6 +1847,64 @@ export function MessageTimeline({
|
|
|
863
1847
|
applyPinned(true);
|
|
864
1848
|
snapToBottom(node);
|
|
865
1849
|
}
|
|
1850
|
+
} else if (committedZeroOverlapOlderReplacement) {
|
|
1851
|
+
// An oldest-directed bounded page can replace every previously mounted
|
|
1852
|
+
// row. Its receipt is the only causal proof that this is backward
|
|
1853
|
+
// progress rather than live-tail forward eviction. Anchor at the bottom
|
|
1854
|
+
// seam before recording this window's scroll baselines so an unpinned
|
|
1855
|
+
// reader stays adjacent to the history they were reading.
|
|
1856
|
+
clearPendingReaderLeave();
|
|
1857
|
+
stopFollow();
|
|
1858
|
+
writeScrollTop(node, maxScrollOf(node));
|
|
1859
|
+
if (attempt?.[1] === 1 && pinnedRef.current) {
|
|
1860
|
+
applyPinned(false);
|
|
1861
|
+
}
|
|
1862
|
+
} else if (prepended) {
|
|
1863
|
+
const olderAttemptState = olderLoadAttemptRef.current?.[1];
|
|
1864
|
+
const underfillOwned =
|
|
1865
|
+
!!olderLoadAttemptRef.current &&
|
|
1866
|
+
olderAttemptState !== 1 &&
|
|
1867
|
+
olderAttemptState !== 2 &&
|
|
1868
|
+
olderAttemptState !== 3;
|
|
1869
|
+
if (
|
|
1870
|
+
autoFollow &&
|
|
1871
|
+
pinnedRef.current &&
|
|
1872
|
+
!hasNewer &&
|
|
1873
|
+
!pendingReaderLeaveRef.current &&
|
|
1874
|
+
(wasAtLiveTailBeforeCommit || underfillOwned)
|
|
1875
|
+
) {
|
|
1876
|
+
// Still following the live tip: underfill, or a prefetch the reader
|
|
1877
|
+
// started and then returned from. Park at the new tip. A stale pin
|
|
1878
|
+
// while they are actually up in a short window (gap is not inside
|
|
1879
|
+
// PIN_THRESHOLD of maxScroll, so wasAtLiveTail is false) restores.
|
|
1880
|
+
clearPendingReaderLeave();
|
|
1881
|
+
snapToBottom(node);
|
|
1882
|
+
} else {
|
|
1883
|
+
restorePrependAnchor();
|
|
1884
|
+
if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
1885
|
+
// Geometry or a pending extension/programmatic leave proves the
|
|
1886
|
+
// reader was browsing history even if the pin ref has not settled.
|
|
1887
|
+
stopFollow();
|
|
1888
|
+
clearPendingReaderLeave();
|
|
1889
|
+
applyPinned(false);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
} else if (!pinnedRef.current && readingAnchor && readableTurns) {
|
|
1893
|
+
// A live work row moves behind newly streamed prose. Correct only the
|
|
1894
|
+
// residual movement native anchoring did not absorb, using the reader's
|
|
1895
|
+
// retained paragraph/control rather than the moving row's outer box.
|
|
1896
|
+
const correction = timelineAnchorCorrection(node, readingAnchor);
|
|
1897
|
+
if (correction !== null && Math.abs(correction) > 1) {
|
|
1898
|
+
writeScrollTop(node, node.scrollTop + correction);
|
|
1899
|
+
}
|
|
1900
|
+
const focused = readingAnchor.find((anchor) => anchor.focused)?.element;
|
|
1901
|
+
if (
|
|
1902
|
+
focused &&
|
|
1903
|
+
node.contains(focused) &&
|
|
1904
|
+
node.ownerDocument.activeElement === node.ownerDocument.body
|
|
1905
|
+
) {
|
|
1906
|
+
focused.focus({ preventScroll: true });
|
|
1907
|
+
}
|
|
866
1908
|
} else if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
867
1909
|
// Load/remount (still hidden): hard-park. Live tip after reveal: ease.
|
|
868
1910
|
// Pending unarmed leave: tip *growth* must not yank (Vimium during stream).
|
|
@@ -877,83 +1919,94 @@ export function MessageTimeline({
|
|
|
877
1919
|
} else {
|
|
878
1920
|
driveFollow(node);
|
|
879
1921
|
}
|
|
880
|
-
} else if (prepended) {
|
|
881
|
-
// Keep the reader on the same retained rows. Prefer the offsetTop delta
|
|
882
|
-
// of the group that still holds the previous first item — that stays
|
|
883
|
-
// correct when loadOlder also truncates the tip (height delta then lies).
|
|
884
|
-
// If native anchoring already applied the same shift, leave scrollTop.
|
|
885
|
-
const anchorKey =
|
|
886
|
-
previousFirstItemId !== null
|
|
887
|
-
? previousGroupKeyByItemId.get(previousFirstItemId)
|
|
888
|
-
: undefined;
|
|
889
|
-
const previousAnchorTop =
|
|
890
|
-
anchorKey !== undefined ? previousGroupOffsetByKey.get(anchorKey) : undefined;
|
|
891
|
-
const anchorEl =
|
|
892
|
-
anchorKey !== undefined
|
|
893
|
-
? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(anchorKey)}"]`)
|
|
894
|
-
: null;
|
|
895
|
-
let delta: number | null = null;
|
|
896
|
-
if (anchorEl instanceof HTMLElement && previousAnchorTop !== undefined) {
|
|
897
|
-
const moved = Math.round(anchorEl.offsetTop - previousAnchorTop);
|
|
898
|
-
if (moved !== 0) {
|
|
899
|
-
delta = moved;
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
if (delta === null) {
|
|
903
|
-
const heightDelta = Math.round(node.scrollHeight - previousScrollHeightRef.current);
|
|
904
|
-
if (heightDelta > 0) {
|
|
905
|
-
delta = heightDelta;
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
if (delta !== null) {
|
|
909
|
-
const expected = previousScrollTop + delta;
|
|
910
|
-
if (Math.abs(node.scrollTop - expected) > 2) {
|
|
911
|
-
writeScrollTop(node, expected);
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
1922
|
}
|
|
915
1923
|
// After a prepend, if restore left us below the top prefetch band,
|
|
916
1924
|
// re-arm so a later approach can load again. Still cooling while parked
|
|
917
1925
|
// inside the band (short pages) — that stops the y=0 load loop.
|
|
918
|
-
if (
|
|
919
|
-
|
|
920
|
-
!pinnedRef.current &&
|
|
921
|
-
olderLoadGateRef.current === "cooling" &&
|
|
922
|
-
node.scrollTop > OLDER_PREFETCH_MARGIN_PX
|
|
923
|
-
) {
|
|
924
|
-
olderLoadGateRef.current = "armed";
|
|
1926
|
+
if (prepended && !pinnedRef.current && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
1927
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
925
1928
|
}
|
|
1929
|
+
scheduleQuestionNav();
|
|
926
1930
|
previousFirstItemIdRef.current = firstItemId;
|
|
927
1931
|
previousScrollHeightRef.current = node.scrollHeight;
|
|
928
1932
|
syncScrollBaseline(node);
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
groupKeyByItemIdRef.current = nextKeyByItemId;
|
|
939
|
-
const needOffsets = prepended || firstItemChanged || !pinnedRef.current || Boolean(hasNewer);
|
|
1933
|
+
// offsetTop queries are O(groups); skip while pinned at the live tip
|
|
1934
|
+
// (every stream token used to remeasure the whole timeline).
|
|
1935
|
+
firstItemGroupKeyRef.current = groups[0]?.key ?? null;
|
|
1936
|
+
const needOffsets =
|
|
1937
|
+
prepended ||
|
|
1938
|
+
firstItemChanged ||
|
|
1939
|
+
!pinnedRef.current ||
|
|
1940
|
+
hasNewer ||
|
|
1941
|
+
firstItemContentTopRef.current == null;
|
|
940
1942
|
if (needOffsets) {
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1943
|
+
const committedFirstGroupKey = firstItemGroupKeyRef.current;
|
|
1944
|
+
const firstGroupEl = committedFirstGroupKey
|
|
1945
|
+
? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(committedFirstGroupKey)}"]`)
|
|
1946
|
+
: null;
|
|
1947
|
+
firstItemGroupOffsetTopRef.current =
|
|
1948
|
+
firstGroupEl instanceof HTMLElement ? firstGroupEl.offsetTop : null;
|
|
1949
|
+
const firstItemEl = firstItemId
|
|
1950
|
+
? node.querySelector(`[data-og-item="${cssEscapeAttribute(firstItemId)}"]`)
|
|
1951
|
+
: null;
|
|
1952
|
+
firstItemContentTopRef.current =
|
|
1953
|
+
firstItemId && firstItemEl instanceof HTMLElement
|
|
1954
|
+
? firstItemEl.getBoundingClientRect().top -
|
|
1955
|
+
node.getBoundingClientRect().top +
|
|
1956
|
+
node.scrollTop
|
|
1957
|
+
: null;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
if (settlement) settlementAnimationRef.current = animateTimelineSettlement(settlement);
|
|
1961
|
+
|
|
1962
|
+
// Promise settlement is not itself permission to retry. A receipt-marked
|
|
1963
|
+
// accepted page retires its exact owner on this commit even when projection
|
|
1964
|
+
// is empty or merges into the same first item. Without that mark, retain
|
|
1965
|
+
// the compatibility fallback: a retained prior boundary proves prepend,
|
|
1966
|
+
// while a missing prior boundary is forward eviction and merely rebases.
|
|
1967
|
+
if (!attempt) {
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
if (!attempt[2]?.committed && attempt[0] === olderBoundaryKey) {
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
if (
|
|
1974
|
+
!attempt[2]?.committed &&
|
|
1975
|
+
attempt[0] &&
|
|
1976
|
+
!sourceItems?.find((entry) => entry.id === attempt[0])
|
|
1977
|
+
) {
|
|
1978
|
+
attempt[0] = olderBoundaryKey;
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
if (!attempt[1] || !hasOlder) {
|
|
1982
|
+
setUnderfillSettledAttempt((olderLoadAttemptRef.current = null));
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
// Boundary progress retires the request owner. A late settlement from
|
|
1986
|
+
// that completed prefetch cannot mutate the new window's cooldown owner.
|
|
1987
|
+
olderLoadAttemptRef.current = [olderBoundaryKey, 2];
|
|
1988
|
+
// Bound automatic work, but let continued upward input replenish demand.
|
|
1989
|
+
// Only a committed first-party receipt proves that another page is safe.
|
|
1990
|
+
if (
|
|
1991
|
+
attempt[2]?.committed &&
|
|
1992
|
+
!pinnedRef.current &&
|
|
1993
|
+
hasOlder &&
|
|
1994
|
+
node.scrollTop <= OLDER_PREFETCH_MARGIN_PX &&
|
|
1995
|
+
olderPageBudgetRef.current > 0
|
|
1996
|
+
) {
|
|
1997
|
+
olderPageBudgetRef.current -= 1;
|
|
1998
|
+
olderLoadAttemptRef.current = null;
|
|
1999
|
+
setOlderDemand((value) => value + 1);
|
|
949
2000
|
}
|
|
2001
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
2002
|
+
requestOlderIfUnderfilled(node);
|
|
950
2003
|
});
|
|
951
2004
|
|
|
952
2005
|
// First paint / session remount: keep the scroller hidden, snap to tip for
|
|
953
2006
|
// two animation frames (late sync layout), then reveal. Does not change the
|
|
954
|
-
// tip-follow
|
|
2007
|
+
// live tip-follow law used once `revealed` is true.
|
|
955
2008
|
useLayoutEffect(() => {
|
|
956
|
-
if (revealed || allGroups.length
|
|
2009
|
+
if (revealed || !allGroups.length) {
|
|
957
2010
|
return;
|
|
958
2011
|
}
|
|
959
2012
|
let cancelled = false;
|
|
@@ -981,7 +2034,7 @@ export function MessageTimeline({
|
|
|
981
2034
|
return () => {
|
|
982
2035
|
cancelled = true;
|
|
983
2036
|
cancelFrame(frame1);
|
|
984
|
-
if (frame2
|
|
2037
|
+
if (frame2) {
|
|
985
2038
|
cancelFrame(frame2);
|
|
986
2039
|
}
|
|
987
2040
|
};
|
|
@@ -1000,7 +2053,6 @@ export function MessageTimeline({
|
|
|
1000
2053
|
olderPrefetchArmedRef.current = false;
|
|
1001
2054
|
setOlderPrefetchArmed(false);
|
|
1002
2055
|
}
|
|
1003
|
-
olderLoadGateRef.current = "armed";
|
|
1004
2056
|
wantPinRef.current = false;
|
|
1005
2057
|
pendingJumpToStartRef.current = false;
|
|
1006
2058
|
previousFirstItemIdRef.current = null;
|
|
@@ -1009,14 +2061,27 @@ export function MessageTimeline({
|
|
|
1009
2061
|
lastMaxScrollRef.current = 0;
|
|
1010
2062
|
lastScrollHeightRef.current = 0;
|
|
1011
2063
|
lastClientHeightRef.current = 0;
|
|
1012
|
-
|
|
1013
|
-
|
|
2064
|
+
firstItemGroupKeyRef.current = null;
|
|
2065
|
+
firstItemGroupOffsetTopRef.current = null;
|
|
2066
|
+
firstItemContentTopRef.current = null;
|
|
1014
2067
|
foldMemoryRef.current.clear();
|
|
1015
2068
|
userMessageDisclosureMemoryRef.current.clear();
|
|
1016
2069
|
disclosureKeepsUnpinnedRef.current = false;
|
|
2070
|
+
clearReaderIntent();
|
|
2071
|
+
contentShrinkBaselineRef.current = null;
|
|
1017
2072
|
seenActivityIdsRef.current.clear();
|
|
1018
2073
|
applyPinned(true);
|
|
1019
|
-
}, [allGroups.length, revealed, applyPinned]);
|
|
2074
|
+
}, [allGroups.length, revealed, applyPinned, clearReaderIntent]);
|
|
2075
|
+
|
|
2076
|
+
// Parent commits cover the initial/history-loading cases. Disclosure state
|
|
2077
|
+
// changes are child-local, so the ResizeObserver below owns dynamic collapse
|
|
2078
|
+
// and expansion after mount.
|
|
2079
|
+
useEffect(() => {
|
|
2080
|
+
const node = scrollRef.current;
|
|
2081
|
+
if (node) {
|
|
2082
|
+
requestOlderIfUnderfilled(node);
|
|
2083
|
+
}
|
|
2084
|
+
});
|
|
1020
2085
|
|
|
1021
2086
|
// Clear the bulk-paint marker a frame after it renders, so rows appended
|
|
1022
2087
|
// live (streams, new turns) animate exactly as before.
|
|
@@ -1052,21 +2117,61 @@ export function MessageTimeline({
|
|
|
1052
2117
|
(entries) => {
|
|
1053
2118
|
const intersecting = entries.some((entry) => entry.isIntersecting);
|
|
1054
2119
|
if (!intersecting) {
|
|
1055
|
-
// Left the top band
|
|
1056
|
-
|
|
2120
|
+
// Left the top band. A settled ordinary prefetch releases its exact
|
|
2121
|
+
// owner here; a still-pending request remains cooling so a quick
|
|
2122
|
+
// leave/re-enter cannot overlap it.
|
|
2123
|
+
const attempt = olderLoadAttemptRef.current;
|
|
2124
|
+
if (attempt?.[1] === 2 || attempt?.[1] === 3) {
|
|
2125
|
+
olderLoadAttemptRef.current = null;
|
|
2126
|
+
}
|
|
1057
2127
|
return;
|
|
1058
2128
|
}
|
|
1059
|
-
|
|
2129
|
+
// Both automatic underfill and ordinary prefetch use one boundary
|
|
2130
|
+
// owner. In particular, live growth cannot arm a second sentinel load
|
|
2131
|
+
// while the short-window request that preceded it is still pending.
|
|
2132
|
+
if (olderLoadAttemptRef.current) {
|
|
1060
2133
|
return;
|
|
1061
2134
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
2135
|
+
const attempt: OlderLoadAttempt = (olderLoadAttemptRef.current = [olderBoundaryKey, 1]);
|
|
2136
|
+
const noProgress = () => {
|
|
2137
|
+
if (!scrollRef.current || olderLoadAttemptRef.current !== attempt) {
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
attempt[1] = 3;
|
|
2141
|
+
if (root.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
2142
|
+
olderLoadAttemptRef.current = null;
|
|
2143
|
+
} else if (maxScrollOf(root) <= 1) {
|
|
2144
|
+
attempt[1] = 0;
|
|
2145
|
+
setUnderfillSettledAttempt(attempt);
|
|
2146
|
+
}
|
|
2147
|
+
};
|
|
2148
|
+
// Preserve legacy fire-and-forget top-band retries: the callback has
|
|
2149
|
+
// synchronously returned, but this visit remains cooling until exit.
|
|
2150
|
+
// Only rejection/throw/exact `false` is a no-progress receipt. Other
|
|
2151
|
+
// fulfillment retains the pending owner until boundary commit;
|
|
2152
|
+
// otherwise a delayed prepend could overlap a same-boundary request.
|
|
2153
|
+
if (
|
|
2154
|
+
!invokeOlderLoad(onLoadOlder, noProgress, attempt) &&
|
|
2155
|
+
olderLoadAttemptRef.current === attempt
|
|
2156
|
+
) {
|
|
2157
|
+
olderLoadAttemptRef.current[1] = 2;
|
|
2158
|
+
requestOlderIfUnderfilled(root);
|
|
2159
|
+
}
|
|
1064
2160
|
},
|
|
1065
2161
|
{ root, rootMargin: OLDER_PREFETCH_ROOT_MARGIN },
|
|
1066
2162
|
);
|
|
1067
2163
|
observer.observe(target);
|
|
1068
2164
|
return () => observer.disconnect();
|
|
1069
|
-
}, [
|
|
2165
|
+
}, [
|
|
2166
|
+
firstGroupKey,
|
|
2167
|
+
hasOlder,
|
|
2168
|
+
loadingOlder,
|
|
2169
|
+
olderBoundaryKey,
|
|
2170
|
+
olderPrefetchArmed,
|
|
2171
|
+
olderDemand,
|
|
2172
|
+
onLoadOlder,
|
|
2173
|
+
requestOlderIfUnderfilled,
|
|
2174
|
+
]);
|
|
1070
2175
|
|
|
1071
2176
|
// History view: page forward when the reader nears the bottom of the current
|
|
1072
2177
|
// non-tip window. Does not pull the whole gap — one density-bounded page.
|
|
@@ -1085,15 +2190,17 @@ export function MessageTimeline({
|
|
|
1085
2190
|
}
|
|
1086
2191
|
const observer = new IntersectionObserver(
|
|
1087
2192
|
(entries) => {
|
|
1088
|
-
|
|
1089
|
-
|
|
2193
|
+
// An underfilled history window has both sentinels visible. Advancing
|
|
2194
|
+
// it automatically would undo an explicit older-page navigation.
|
|
2195
|
+
if (maxScrollOf(root) > 1 && entries.some((entry) => entry.isIntersecting)) {
|
|
2196
|
+
requestNewer();
|
|
1090
2197
|
}
|
|
1091
2198
|
},
|
|
1092
2199
|
{ root, rootMargin: "0px 0px 1200px 0px" },
|
|
1093
2200
|
);
|
|
1094
2201
|
observer.observe(target);
|
|
1095
2202
|
return () => observer.disconnect();
|
|
1096
|
-
}, [hasNewer, loadingNewer, onLoadNewer, firstGroupKey]);
|
|
2203
|
+
}, [hasNewer, loadingNewer, onLoadNewer, requestNewer, firstGroupKey]);
|
|
1097
2204
|
|
|
1098
2205
|
// Late layout that React commits cannot see (images decoding, fonts, code
|
|
1099
2206
|
// blocks) grows content without a commit. While pinned, soft-follow the tip;
|
|
@@ -1106,7 +2213,7 @@ export function MessageTimeline({
|
|
|
1106
2213
|
return;
|
|
1107
2214
|
}
|
|
1108
2215
|
const observer = new ResizeObserver(() => {
|
|
1109
|
-
if (resizeFollowRafRef.current
|
|
2216
|
+
if (resizeFollowRafRef.current != null) {
|
|
1110
2217
|
return;
|
|
1111
2218
|
}
|
|
1112
2219
|
resizeFollowRafRef.current = requestFrame(() => {
|
|
@@ -1115,6 +2222,10 @@ export function MessageTimeline({
|
|
|
1115
2222
|
if (!current) {
|
|
1116
2223
|
return;
|
|
1117
2224
|
}
|
|
2225
|
+
// A viewport that shrank below the pill's minimum hides it now, not
|
|
2226
|
+
// on the next scroll.
|
|
2227
|
+
if (questionNavMinViewportRef.current > 0) scheduleQuestionNavRef.current();
|
|
2228
|
+
requestOlderIfUnderfilled(current);
|
|
1118
2229
|
if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
|
|
1119
2230
|
return;
|
|
1120
2231
|
}
|
|
@@ -1132,12 +2243,12 @@ export function MessageTimeline({
|
|
|
1132
2243
|
observer.observe(node);
|
|
1133
2244
|
return () => {
|
|
1134
2245
|
observer.disconnect();
|
|
1135
|
-
if (resizeFollowRafRef.current
|
|
2246
|
+
if (resizeFollowRafRef.current != null) {
|
|
1136
2247
|
cancelFrame(resizeFollowRafRef.current);
|
|
1137
2248
|
resizeFollowRafRef.current = null;
|
|
1138
2249
|
}
|
|
1139
2250
|
};
|
|
1140
|
-
}, [autoFollow, driveFollow, snapToBottom]);
|
|
2251
|
+
}, [autoFollow, driveFollow, requestOlderIfUnderfilled, snapToBottom]);
|
|
1141
2252
|
|
|
1142
2253
|
// Entering a non-tip history window: drop any live pin so the page bottom
|
|
1143
2254
|
// cannot re-stick follow across loadNewer. Leaving it (the tip window
|
|
@@ -1145,6 +2256,8 @@ export function MessageTimeline({
|
|
|
1145
2256
|
// at what just became the live bottom — paging forward to the tip must not
|
|
1146
2257
|
// strand them unpinned watching new content grow below.
|
|
1147
2258
|
useEffect(() => {
|
|
2259
|
+
// Closing find is paint-only; it must not trigger this history-to-tip rule.
|
|
2260
|
+
if (searchTargetRef.current) return;
|
|
1148
2261
|
if (hasNewer) {
|
|
1149
2262
|
stopFollow();
|
|
1150
2263
|
applyPinned(false);
|
|
@@ -1167,6 +2280,15 @@ export function MessageTimeline({
|
|
|
1167
2280
|
}
|
|
1168
2281
|
}, [hasNewer, autoFollow, applyPinned, snapToBottom, stopFollow]);
|
|
1169
2282
|
|
|
2283
|
+
// After the scroll authority above has settled each commit, record whether
|
|
2284
|
+
// the reader is away from the tip (content can land below an unpinned
|
|
2285
|
+
// reader without any scroll event).
|
|
2286
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Deliberately runs after every commit.
|
|
2287
|
+
useLayoutEffect(() => {
|
|
2288
|
+
const node = scrollRef.current;
|
|
2289
|
+
if (node && !pinnedRef.current) setAwayFromTip(!isNearBottom(node));
|
|
2290
|
+
});
|
|
2291
|
+
|
|
1170
2292
|
// Pinned: layout/camera recover tip debt; wheel/keys/pointer-arm unpin
|
|
1171
2293
|
// immediately; extension jumps settle via scrollend (or one-rAF fallback).
|
|
1172
2294
|
// Do not tip-follow-yank an in-flight unarmed scroll-away — that ate Vimium.
|
|
@@ -1175,6 +2297,9 @@ export function MessageTimeline({
|
|
|
1175
2297
|
if (!node) {
|
|
1176
2298
|
return;
|
|
1177
2299
|
}
|
|
2300
|
+
// Only an unpinned reader's distance matters for the jump.
|
|
2301
|
+
if (!pinnedRef.current) setAwayFromTip(!isNearBottom(node));
|
|
2302
|
+
scheduleQuestionNav();
|
|
1178
2303
|
const previousTop = lastScrollTopRef.current;
|
|
1179
2304
|
const previousMaxScroll = lastMaxScrollRef.current;
|
|
1180
2305
|
const nextTop = node.scrollTop;
|
|
@@ -1183,6 +2308,16 @@ export function MessageTimeline({
|
|
|
1183
2308
|
const readerUp = readerScrollUpPx(previousTop, nextTop, previousMaxScroll, nextMaxScroll);
|
|
1184
2309
|
const maxFell = nextMaxScroll < previousMaxScroll - 1;
|
|
1185
2310
|
const readerArmed = readerIntentArmRef.current;
|
|
2311
|
+
const readerIntentStart = readerIntentStartRef.current;
|
|
2312
|
+
const cumulativeReaderUp =
|
|
2313
|
+
readerArmed && readerIntentStart
|
|
2314
|
+
? readerScrollUpPx(
|
|
2315
|
+
readerIntentStart.scrollTop,
|
|
2316
|
+
nextTop,
|
|
2317
|
+
readerIntentStart.maxScroll,
|
|
2318
|
+
nextMaxScroll,
|
|
2319
|
+
)
|
|
2320
|
+
: readerUp;
|
|
1186
2321
|
const heightShrunk =
|
|
1187
2322
|
followRef.current.lastHeight > 0 &&
|
|
1188
2323
|
nextHeight < followRef.current.lastHeight - TIP_FOLLOW_SHRINK_EPS_PX;
|
|
@@ -1198,6 +2333,19 @@ export function MessageTimeline({
|
|
|
1198
2333
|
return;
|
|
1199
2334
|
}
|
|
1200
2335
|
|
|
2336
|
+
if (programmatic && !pinnedRef.current) {
|
|
2337
|
+
// Restore/camera writes while reading history are not a return to the tip.
|
|
2338
|
+
// Still expire a pending Jump-to-latest latch: the in-window jump itself
|
|
2339
|
+
// is a programmatic snap, and a later reader scroll-away can arrive
|
|
2340
|
+
// before that echo is consumed. Skipping this left the latch armed and
|
|
2341
|
+
// snapped the reader when hasNewer later flipped false.
|
|
2342
|
+
syncScrollBaseline(node);
|
|
2343
|
+
if (wantPinRef.current && !isNearBottom(node)) {
|
|
2344
|
+
wantPinRef.current = false;
|
|
2345
|
+
}
|
|
2346
|
+
return;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
1201
2349
|
if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
1202
2350
|
// Fold / composer / SessionChrome: viewport shrink raises maxScroll without
|
|
1203
2351
|
// growing content. Must hit tipFollow before we adopt the new clientHeight
|
|
@@ -1208,7 +2356,7 @@ export function MessageTimeline({
|
|
|
1208
2356
|
// Fold / composer content shrink: compensate before baseline sync so
|
|
1209
2357
|
// driveFollow still sees the pre-shrink scrollTop (avoid double-subtract).
|
|
1210
2358
|
if (heightShrunk || maxFell || viewportShrunk) {
|
|
1211
|
-
|
|
2359
|
+
clearReaderIntent();
|
|
1212
2360
|
clearPendingReaderLeave();
|
|
1213
2361
|
driveFollow(node);
|
|
1214
2362
|
return;
|
|
@@ -1230,15 +2378,14 @@ export function MessageTimeline({
|
|
|
1230
2378
|
clearPendingReaderLeave();
|
|
1231
2379
|
}
|
|
1232
2380
|
// Pointer-dragged scroll-up away from tip. Layout churn never arms this.
|
|
1233
|
-
if (readerArmed &&
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
olderLoadGateRef.current = "armed";
|
|
1238
|
-
}
|
|
2381
|
+
if (readerArmed && cumulativeReaderUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
|
|
2382
|
+
clearReaderIntent();
|
|
2383
|
+
requestEarlierFromReader();
|
|
2384
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
1239
2385
|
return;
|
|
1240
2386
|
}
|
|
1241
|
-
// Tip grew under a still viewport (no reader-up):
|
|
2387
|
+
// Tip grew under a still viewport (no reader-up): track the new growth
|
|
2388
|
+
// immediately and ease only any debt that already existed.
|
|
1242
2389
|
// Reader/extension scroll-up in progress: do not yank — scrollend decides.
|
|
1243
2390
|
// Near-bottom with tipDebt≈0 stays the quiet path (do not broaden follow
|
|
1244
2391
|
// inside PIN_THRESHOLD — that fights small intentional scroll-ups).
|
|
@@ -1258,15 +2405,46 @@ export function MessageTimeline({
|
|
|
1258
2405
|
}
|
|
1259
2406
|
|
|
1260
2407
|
syncScrollBaseline(node);
|
|
2408
|
+
if (programmatic) {
|
|
2409
|
+
// Anchor restoration is never permission to resume tip-follow.
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
1261
2412
|
const nearBottom = isNearBottom(node);
|
|
1262
2413
|
|
|
1263
|
-
// Re-pin only when the reader moved toward/at the tip
|
|
1264
|
-
//
|
|
2414
|
+
// Re-pin only when the reader moved toward/at the tip. Prepend restore
|
|
2415
|
+
// and overflow-anchor raise scrollTop by roughly the same amount as
|
|
2416
|
+
// maxScroll; treating that as a scroll-down
|
|
2417
|
+
// re-pinned a compact-tail history reader (their preserved gap falls
|
|
2418
|
+
// inside PIN_THRESHOLD once the window is tall) and snapped them back.
|
|
2419
|
+
// A commit may already have adopted this scroll position into the layout
|
|
2420
|
+
// baseline. Explicit wheel/key/pointer intent retains its own start so
|
|
2421
|
+
// returning to the tip still re-pins after that interleaving.
|
|
2422
|
+
// Passive wheel handlers may run after compositor scrolling; retain the
|
|
2423
|
+
// earlier layout baseline when it still contains that reader movement.
|
|
2424
|
+
const returnStart =
|
|
2425
|
+
readerIntentStart && readerIntentStart.scrollTop <= previousTop ? readerIntentStart : null;
|
|
2426
|
+
const inserted = Math.max(0, nextMaxScroll - (returnStart?.maxScroll ?? previousMaxScroll));
|
|
2427
|
+
const towardTip = nextTop - (returnStart?.scrollTop ?? previousTop) - inserted;
|
|
2428
|
+
// Explicit intent may reach the exact live bottom despite intervening
|
|
2429
|
+
// streaming growth. Keep subtracting that growth: native anchoring alone
|
|
2430
|
+
// conserves the gap and must never qualify as reader movement.
|
|
2431
|
+
const explicitReturnToBottom = returnStart !== null && nextMaxScroll - nextTop <= 1;
|
|
1265
2432
|
const nextPinned =
|
|
1266
|
-
!hasNewer && nearBottom &&
|
|
2433
|
+
!hasNewer && nearBottom && towardTip > 0.5 && (inserted <= 1 || explicitReturnToBottom);
|
|
1267
2434
|
if (!nextPinned) {
|
|
1268
2435
|
stopFollow();
|
|
1269
2436
|
}
|
|
2437
|
+
const focused = node.ownerDocument.activeElement;
|
|
2438
|
+
if (
|
|
2439
|
+
nextPinned &&
|
|
2440
|
+
focused instanceof HTMLElement &&
|
|
2441
|
+
focused.hasAttribute("data-og-prompt") &&
|
|
2442
|
+
node.contains(focused)
|
|
2443
|
+
) {
|
|
2444
|
+
// Question navigation focuses its destination. Returning to the live
|
|
2445
|
+
// tip releases that stale reader ownership before the commit snapshot.
|
|
2446
|
+
node.focus({ preventScroll: true });
|
|
2447
|
+
}
|
|
1270
2448
|
applyPinned(nextPinned);
|
|
1271
2449
|
// A far-from-bottom scroll while a Jump-to-latest is pending is the reader
|
|
1272
2450
|
// changing their mind: drop the latch, or a stale one (host rejected or
|
|
@@ -1277,17 +2455,22 @@ export function MessageTimeline({
|
|
|
1277
2455
|
wantPinRef.current = false;
|
|
1278
2456
|
}
|
|
1279
2457
|
if (nextPinned) {
|
|
2458
|
+
clearReaderIntent();
|
|
1280
2459
|
return;
|
|
1281
2460
|
}
|
|
2461
|
+
if (readerIntentStart) {
|
|
2462
|
+
// This scroll already accounted for the gesture's movement. Retain a
|
|
2463
|
+
// fresh start for continuous input across the next commit, not stale
|
|
2464
|
+
// movement that could turn a later native clamp into a return to tip.
|
|
2465
|
+
readerIntentStartRef.current = { scrollTop: nextTop, maxScroll: nextMaxScroll };
|
|
2466
|
+
}
|
|
1282
2467
|
if (!olderPrefetchArmedRef.current) {
|
|
1283
2468
|
olderPrefetchArmedRef.current = true;
|
|
1284
2469
|
setOlderPrefetchArmed(true);
|
|
1285
2470
|
}
|
|
1286
2471
|
// Re-arm older prefetch only after leaving the top band (scroll down into
|
|
1287
2472
|
// content). Never re-arm/load from continued scroll toward y=0.
|
|
1288
|
-
|
|
1289
|
-
olderLoadGateRef.current = "armed";
|
|
1290
|
-
}
|
|
2473
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
1291
2474
|
};
|
|
1292
2475
|
|
|
1293
2476
|
const onScrollEnd = () => {
|
|
@@ -1296,6 +2479,9 @@ export function MessageTimeline({
|
|
|
1296
2479
|
return;
|
|
1297
2480
|
}
|
|
1298
2481
|
cancelLeaveFallback();
|
|
2482
|
+
// Engines can emit scrollend between tiny pointer steps. Keep the pinned
|
|
2483
|
+
// gesture's cumulative leave budget until it actually leaves the tip.
|
|
2484
|
+
if (!pinnedRef.current || !readerIntentArmRef.current) clearReaderIntent();
|
|
1299
2485
|
if (disclosureKeepsUnpinnedRef.current) {
|
|
1300
2486
|
programmaticScrollRef.current = 0;
|
|
1301
2487
|
stopFollow();
|
|
@@ -1311,298 +2497,484 @@ export function MessageTimeline({
|
|
|
1311
2497
|
releasePinAfterScrollSettled(node);
|
|
1312
2498
|
};
|
|
1313
2499
|
|
|
1314
|
-
|
|
1315
|
-
<
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
2500
|
+
const timeline = (
|
|
2501
|
+
<AllowancePresentationProvider
|
|
2502
|
+
render={renderAllowanceExhausted}
|
|
2503
|
+
labels={allowanceExhaustedLabels}
|
|
2504
|
+
>
|
|
2505
|
+
<LightboxProvider>
|
|
2506
|
+
<FoldMemoryProvider value={foldMemoryRef.current}>
|
|
2507
|
+
<LiveNoteFoldContext.Provider value={reportLiveFold}>
|
|
2508
|
+
<SeenActivityIdsProvider value={seenActivityIdsRef.current}>
|
|
2509
|
+
<TimelineComputeLabelProvider value={computeLabel ?? null}>
|
|
2510
|
+
<EntranceAnimationProvider value={false}>
|
|
2511
|
+
<TooltipProvider delayDuration={400}>
|
|
2512
|
+
<TimelineAnnotationSourceRootContext.Provider value={scrollRef}>
|
|
2513
|
+
<div
|
|
2514
|
+
className={cn("og-root relative flex min-h-0 flex-col", className)}
|
|
2515
|
+
// Sticky insets start at the scroller's padded content edge
|
|
2516
|
+
// (pt-16). Subtract exactly that padding so an expanded work
|
|
2517
|
+
// header pins flush to the scrollport. Pinning it lower left a
|
|
2518
|
+
// band of scrolling rows visible above the header, which then
|
|
2519
|
+
// looked like it floated over the middle of the timeline.
|
|
2520
|
+
style={{ "--og-work-header-top": "-4rem" } as CSSProperties}
|
|
2521
|
+
>
|
|
2522
|
+
{onAnnotate ? (
|
|
2523
|
+
<Suspense fallback={null}>
|
|
2524
|
+
<TimelineAnnotationSelection
|
|
2525
|
+
rootRef={scrollRef}
|
|
2526
|
+
sources={annotationSources}
|
|
2527
|
+
onAnnotate={onAnnotate}
|
|
2528
|
+
/>
|
|
2529
|
+
</Suspense>
|
|
2530
|
+
) : null}
|
|
2531
|
+
{/* Pinned: anchoring off so the tip-follow camera owns the motion.
|
|
1332
2532
|
Unpinned: native scroll anchoring holds the reader's place. */}
|
|
1333
|
-
<div
|
|
1334
|
-
ref={scrollRef}
|
|
1335
|
-
data-og-timeline-scroller=""
|
|
1336
|
-
data-og-bottom-follow={autoFollow && pinned && !hasNewer ? "true" : "false"}
|
|
1337
|
-
tabIndex={-1}
|
|
1338
|
-
onScroll={onScroll}
|
|
1339
|
-
onScrollEnd={onScrollEnd}
|
|
1340
|
-
onWheel={onWheel}
|
|
1341
|
-
onPointerDown={onPointerDown}
|
|
1342
|
-
onKeyDown={onKeyDown}
|
|
1343
|
-
style={groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined}
|
|
1344
|
-
className={cn(
|
|
1345
|
-
// tabIndex=-1 is programmatic only — never paint a focus ring on
|
|
1346
|
-
// the whole scroller (click + Shift used to flash a blue outline).
|
|
1347
|
-
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 sm:px-6 outline-hidden",
|
|
1348
|
-
autoFollow && pinned && !hasNewer
|
|
1349
|
-
? "[overflow-anchor:none]"
|
|
1350
|
-
: "[overflow-anchor:auto]",
|
|
1351
|
-
)}
|
|
1352
|
-
>
|
|
1353
|
-
<div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
|
|
1354
|
-
{groups.length === 0
|
|
1355
|
-
? (emptyState ?? (
|
|
1356
|
-
<p className="py-10 text-center text-og-menu text-og-fg-subtle">
|
|
1357
|
-
No activity yet.
|
|
1358
|
-
</p>
|
|
1359
|
-
))
|
|
1360
|
-
: null}
|
|
1361
|
-
{hasOlder && olderPrefetchArmed ? (
|
|
1362
|
-
// Overlaid, not a layout row: mounting/unmounting the sentinel
|
|
1363
|
-
// must never shift content (that shift was itself a wobble).
|
|
1364
2533
|
<div
|
|
1365
|
-
ref={
|
|
1366
|
-
data-og-
|
|
1367
|
-
data-og-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
?
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
2534
|
+
ref={scrollRef}
|
|
2535
|
+
data-og-timeline-scroller=""
|
|
2536
|
+
data-og-bottom-follow={
|
|
2537
|
+
autoFollow && pinned && !hasNewer ? "true" : "false"
|
|
2538
|
+
}
|
|
2539
|
+
tabIndex={-1}
|
|
2540
|
+
onScroll={onScroll}
|
|
2541
|
+
onScrollEnd={onScrollEnd}
|
|
2542
|
+
onWheel={onWheel}
|
|
2543
|
+
onTouchStart={(event) => {
|
|
2544
|
+
stopSettlement();
|
|
2545
|
+
const touch = event.touches.length === 1 ? event.touches[0] : undefined;
|
|
2546
|
+
touchPositionRef.current = touch
|
|
2547
|
+
? { x: touch.clientX, y: touch.clientY }
|
|
2548
|
+
: null;
|
|
2549
|
+
}}
|
|
2550
|
+
onTouchMove={(event) => {
|
|
2551
|
+
const touch = event.touches[0];
|
|
2552
|
+
const previous = touchPositionRef.current;
|
|
2553
|
+
if (!touch || !previous || event.touches.length !== 1) {
|
|
2554
|
+
touchPositionRef.current = null;
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
const deltaX = previous.x - touch.clientX;
|
|
2558
|
+
const deltaY = previous.y - touch.clientY;
|
|
2559
|
+
if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 4) return;
|
|
2560
|
+
touchPositionRef.current = { x: touch.clientX, y: touch.clientY };
|
|
2561
|
+
onWheel({
|
|
2562
|
+
deltaX,
|
|
2563
|
+
deltaY,
|
|
2564
|
+
target: event.target,
|
|
2565
|
+
currentTarget: event.currentTarget,
|
|
2566
|
+
});
|
|
2567
|
+
}}
|
|
2568
|
+
onTouchEnd={() => {
|
|
2569
|
+
touchPositionRef.current = null;
|
|
2570
|
+
}}
|
|
2571
|
+
onTouchCancel={() => {
|
|
2572
|
+
touchPositionRef.current = null;
|
|
2573
|
+
}}
|
|
2574
|
+
onClickCapture={(event) => {
|
|
2575
|
+
const target =
|
|
2576
|
+
event.target instanceof Element
|
|
2577
|
+
? event.target.closest("button[aria-expanded]")
|
|
2578
|
+
: null;
|
|
2579
|
+
if (target) {
|
|
2580
|
+
if (target.hasAttribute("data-og-work-header")) {
|
|
2581
|
+
releaseProgress(
|
|
2582
|
+
target
|
|
2583
|
+
.closest("[data-og-group-key]")
|
|
2584
|
+
?.getAttribute("data-og-group-key") ?? null,
|
|
2585
|
+
);
|
|
2586
|
+
}
|
|
2587
|
+
releasePinFromReader();
|
|
2588
|
+
disclosureKeepsUnpinnedRef.current = true;
|
|
2589
|
+
}
|
|
2590
|
+
}}
|
|
2591
|
+
onFocusCapture={(event) => {
|
|
2592
|
+
if (readableTurns && event.target !== event.currentTarget) {
|
|
2593
|
+
stopSettlement();
|
|
2594
|
+
releasePinFromReader();
|
|
2595
|
+
disclosureKeepsUnpinnedRef.current = true;
|
|
2596
|
+
}
|
|
2597
|
+
}}
|
|
2598
|
+
onPointerDown={onPointerDown}
|
|
2599
|
+
onKeyDown={onKeyDown}
|
|
2600
|
+
style={
|
|
2601
|
+
groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined
|
|
2602
|
+
}
|
|
2603
|
+
className={cn(
|
|
2604
|
+
// tabIndex=-1 is programmatic only — never paint a focus ring on
|
|
2605
|
+
// the whole scroller (click + Shift used to flash a blue outline).
|
|
2606
|
+
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pt-16 pb-6 sm:px-6 outline-hidden",
|
|
2607
|
+
autoFollow && pinned && !hasNewer
|
|
2608
|
+
? "[overflow-anchor:none]"
|
|
2609
|
+
: "[overflow-anchor:auto]",
|
|
2610
|
+
)}
|
|
2611
|
+
>
|
|
2612
|
+
<TimelineBeforeLayout
|
|
2613
|
+
capture={() => {
|
|
2614
|
+
if (readableTurns && timelineHasReader(scrollRef.current)) {
|
|
2615
|
+
releasePinFromReader();
|
|
2616
|
+
}
|
|
2617
|
+
readingAnchorRef.current =
|
|
2618
|
+
!pinnedRef.current && scrollRef.current
|
|
2619
|
+
? captureTimelineAnchor(scrollRef.current)
|
|
2620
|
+
: null;
|
|
2621
|
+
settlementRef.current =
|
|
2622
|
+
readableTurns &&
|
|
2623
|
+
autoFollow &&
|
|
2624
|
+
pinnedRef.current &&
|
|
2625
|
+
revealedRef.current &&
|
|
2626
|
+
!hasNewer &&
|
|
2627
|
+
!pendingReaderLeaveRef.current &&
|
|
2628
|
+
!disclosureKeepsUnpinnedRef.current &&
|
|
2629
|
+
!prefersReducedMotion() &&
|
|
2630
|
+
scrollRef.current
|
|
2631
|
+
? captureTimelineSettlement(scrollRef.current, groups)
|
|
2632
|
+
: null;
|
|
2633
|
+
}}
|
|
2634
|
+
>
|
|
2635
|
+
<div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
|
|
2636
|
+
<AnimatePresence>
|
|
2637
|
+
{loadingOlder ||
|
|
2638
|
+
loadingOldest ||
|
|
2639
|
+
(hasOlder && onJumpToStart && olderPrefetchArmed) ||
|
|
2640
|
+
underfillRetryReady ? (
|
|
2641
|
+
// Reserved top gutter: controls scroll with history and cannot
|
|
2642
|
+
// cover a disclosure. Visibility never changes content height.
|
|
2643
|
+
<motion.div
|
|
2644
|
+
initial={{ opacity: 0, y: -6 }}
|
|
2645
|
+
animate={{ opacity: 1, y: 0 }}
|
|
2646
|
+
exit={{ opacity: 0, y: -6 }}
|
|
2647
|
+
transition={{ duration: 0.15, ease: "easeOut" }}
|
|
2648
|
+
data-og-loading-older=""
|
|
2649
|
+
aria-live="polite"
|
|
2650
|
+
className="pointer-events-none absolute inset-x-0 -top-11 z-10 flex justify-center"
|
|
2651
|
+
>
|
|
2652
|
+
{loadingOlder || loadingOldest ? (
|
|
2653
|
+
<span className={LOADING_CHIP_CLASS}>
|
|
2654
|
+
<span className="og-shimmer-text">
|
|
2655
|
+
{loadingOldest
|
|
2656
|
+
? "Jumping to start…"
|
|
2657
|
+
: "Loading earlier activity…"}
|
|
2658
|
+
</span>
|
|
2659
|
+
</span>
|
|
2660
|
+
) : null}
|
|
2661
|
+
{hasOlder &&
|
|
2662
|
+
!loadingOlder &&
|
|
2663
|
+
!loadingOldest &&
|
|
2664
|
+
(underfillRetryReady || onJumpToStart) ? (
|
|
2665
|
+
<button
|
|
2666
|
+
type="button"
|
|
2667
|
+
data-og-retry={underfillRetryReady || undefined}
|
|
2668
|
+
data-og-jump-to-start={!underfillRetryReady || undefined}
|
|
2669
|
+
onClick={() => {
|
|
2670
|
+
const node = scrollRef.current;
|
|
2671
|
+
if (underfillRetryReady) {
|
|
2672
|
+
if (node && underfillRetryReadyRef.current) {
|
|
2673
|
+
// AnimatePresence retains this handler during
|
|
2674
|
+
// exit. Current authorization plus the exact
|
|
2675
|
+
// attempt check prevent stale dispatch.
|
|
2676
|
+
requestOlderIfUnderfilled(
|
|
2677
|
+
node,
|
|
2678
|
+
underfillSettledAttempt!,
|
|
2679
|
+
);
|
|
2680
|
+
}
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
applyPinned(false);
|
|
2684
|
+
pendingJumpToStartRef.current = true;
|
|
2685
|
+
const seq = ++jumpToStartSeqRef.current;
|
|
2686
|
+
void Promise.resolve(onJumpToStart!()).then(
|
|
2687
|
+
() => {
|
|
2688
|
+
// The commit that swaps in the oldest window consumes
|
|
2689
|
+
// the flag against the new DOM; this write covers the
|
|
2690
|
+
// already-committed order and the no-window-change
|
|
2691
|
+
// case (jumping within the current window).
|
|
2692
|
+
const scroller = scrollRef.current ?? node;
|
|
2693
|
+
if (scroller) {
|
|
2694
|
+
scroller.scrollTop = 0;
|
|
2695
|
+
}
|
|
2696
|
+
// A host may resolve without ever changing the
|
|
2697
|
+
// window (already on the oldest page). Any swap
|
|
2698
|
+
// commit runs its layout effect before the next
|
|
2699
|
+
// frame, so a flag still armed by then is the
|
|
2700
|
+
// no-change case — clear it, or a LATER prepend
|
|
2701
|
+
// would spuriously jump the reader to the top.
|
|
2702
|
+
requestFrame(() => {
|
|
2703
|
+
if (jumpToStartSeqRef.current === seq) {
|
|
2704
|
+
pendingJumpToStartRef.current = false;
|
|
2705
|
+
}
|
|
2706
|
+
});
|
|
2707
|
+
},
|
|
2708
|
+
() => {
|
|
2709
|
+
if (jumpToStartSeqRef.current === seq) {
|
|
2710
|
+
pendingJumpToStartRef.current = false;
|
|
2711
|
+
}
|
|
2712
|
+
},
|
|
2713
|
+
);
|
|
2714
|
+
}}
|
|
2715
|
+
className="pointer-events-auto rounded-full border border-og-border px-3 py-1.5 text-og-control"
|
|
2716
|
+
>
|
|
2717
|
+
{underfillRetryReady
|
|
2718
|
+
? underfillSettledAttempt?.[2]?.tailPreserved
|
|
2719
|
+
? "Load earlier activity"
|
|
2720
|
+
: "Retry earlier activity"
|
|
2721
|
+
: "Jump to start"}
|
|
2722
|
+
</button>
|
|
2723
|
+
) : null}
|
|
2724
|
+
</motion.div>
|
|
2725
|
+
) : null}
|
|
2726
|
+
</AnimatePresence>
|
|
2727
|
+
{!groups.length
|
|
2728
|
+
? (emptyState ?? (
|
|
2729
|
+
<p className="py-10 text-center text-og-menu text-og-fg-subtle">
|
|
2730
|
+
No activity yet.
|
|
2731
|
+
</p>
|
|
2732
|
+
))
|
|
2733
|
+
: null}
|
|
2734
|
+
{hasOlder && olderPrefetchArmed ? (
|
|
2735
|
+
// Overlaid, not a layout row: mounting/unmounting the sentinel
|
|
2736
|
+
// must never shift content (that shift was itself a wobble).
|
|
2737
|
+
// End at the scroll origin above the pt-16 gutter so the
|
|
2738
|
+
// observer and scrollTop cooldown share the same 400px band.
|
|
2739
|
+
<div
|
|
2740
|
+
ref={topSentinelRef}
|
|
2741
|
+
data-og-top-sentinel=""
|
|
2742
|
+
data-og-timeline-chrome=""
|
|
2743
|
+
aria-hidden="true"
|
|
2744
|
+
className="pointer-events-none absolute inset-x-0 -top-16 h-px -translate-y-full"
|
|
1418
2745
|
/>
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
2746
|
+
) : null}
|
|
2747
|
+
{groups.map(({ group, key, entranceEnabled }, index) => {
|
|
2748
|
+
return (
|
|
2749
|
+
<TimelineSearchRevealContext.Provider
|
|
2750
|
+
key={key}
|
|
2751
|
+
value={
|
|
2752
|
+
searchItem &&
|
|
2753
|
+
timelineGroupItemIds(group).includes(searchItem.id)
|
|
2754
|
+
? searchRevealKey
|
|
2755
|
+
: null
|
|
2756
|
+
}
|
|
2757
|
+
>
|
|
2758
|
+
<TimelineGroupEntry
|
|
2759
|
+
groupKey={key}
|
|
2760
|
+
group={group}
|
|
2761
|
+
nextGroup={groups[index + 1]?.group}
|
|
2762
|
+
startupDismissed={
|
|
2763
|
+
group.kind === "activity" &&
|
|
2764
|
+
group.items.some(
|
|
2765
|
+
(item) => item.turnId && turnsWithOutput.has(item.turnId),
|
|
2766
|
+
)
|
|
2767
|
+
}
|
|
2768
|
+
entranceEnabled={entranceEnabled}
|
|
2769
|
+
liveEntranceEnabled={
|
|
2770
|
+
group.kind === "activity" ? !bulkRender : undefined
|
|
2771
|
+
}
|
|
2772
|
+
noteFolded={
|
|
2773
|
+
group.kind === "item"
|
|
2774
|
+
? liveNoteFolds.get(group.item.id)
|
|
2775
|
+
: undefined
|
|
2776
|
+
}
|
|
2777
|
+
context={timelineGroupEntryContext}
|
|
2778
|
+
/>
|
|
2779
|
+
</TimelineSearchRevealContext.Provider>
|
|
2780
|
+
);
|
|
2781
|
+
})}
|
|
2782
|
+
{groups.length > 0 && trailingState ? (
|
|
2783
|
+
<div data-og-timeline-trailing-state="">{trailingState}</div>
|
|
2784
|
+
) : null}
|
|
2785
|
+
{hasNewer && newerFailure?.key === newerBoundaryKey ? (
|
|
2786
|
+
<div
|
|
2787
|
+
data-og-newer-error=""
|
|
2788
|
+
className="flex flex-col items-center gap-2 px-4 py-3 text-center text-og-menu text-og-fg-muted"
|
|
2789
|
+
>
|
|
2790
|
+
<p role="status" className="max-w-prose [overflow-wrap:anywhere]">
|
|
2791
|
+
Couldn’t load later activity. {newerFailure.message}
|
|
2792
|
+
</p>
|
|
2793
|
+
<button
|
|
2794
|
+
ref={newerRetryButtonRef}
|
|
2795
|
+
type="button"
|
|
2796
|
+
data-og-retry-newer=""
|
|
2797
|
+
className="min-h-11 rounded-og-md border border-og-border px-3 py-2 text-og-fg hover:bg-og-surface-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-og-accent"
|
|
2798
|
+
aria-disabled={loadingNewer || newerRetryPending}
|
|
2799
|
+
aria-busy={newerRetryPending}
|
|
2800
|
+
onClick={() => requestNewer(true)}
|
|
2801
|
+
>
|
|
2802
|
+
{newerRetryPending
|
|
2803
|
+
? "Retrying later activity…"
|
|
2804
|
+
: "Retry later activity"}
|
|
2805
|
+
</button>
|
|
2806
|
+
</div>
|
|
2807
|
+
) : null}
|
|
2808
|
+
{hasNewer ? (
|
|
2809
|
+
<div
|
|
2810
|
+
ref={bottomSentinelRef}
|
|
2811
|
+
data-og-bottom-sentinel=""
|
|
2812
|
+
data-og-timeline-chrome=""
|
|
2813
|
+
aria-hidden="true"
|
|
2814
|
+
className="h-px w-full"
|
|
2815
|
+
/>
|
|
2816
|
+
) : null}
|
|
2817
|
+
</div>
|
|
2818
|
+
</TimelineBeforeLayout>
|
|
2819
|
+
</div>
|
|
2820
|
+
{draftAnnotations && draftAnnotations.length > 0 ? (
|
|
2821
|
+
<Suspense fallback={null}>
|
|
2822
|
+
<TimelineAnnotationMarkers
|
|
2823
|
+
rootRef={scrollRef}
|
|
2824
|
+
annotations={draftAnnotations}
|
|
2825
|
+
onSelect={onDraftAnnotationSelect}
|
|
2826
|
+
/>
|
|
2827
|
+
</Suspense>
|
|
1459
2828
|
) : null}
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
2829
|
+
|
|
2830
|
+
<div
|
|
2831
|
+
data-og-timeline-nav="top"
|
|
2832
|
+
className={cn(
|
|
2833
|
+
"pointer-events-none absolute inset-x-0 z-10 flex justify-center",
|
|
2834
|
+
questionNav?.belowHeader ? "top-10 pointer-coarse:top-13" : "top-3",
|
|
2835
|
+
)}
|
|
2836
|
+
>
|
|
2837
|
+
<AnimatePresence>
|
|
2838
|
+
{questionNav ? (
|
|
2839
|
+
<motion.button
|
|
2840
|
+
key="question-nav"
|
|
2841
|
+
{...NAV_FADE}
|
|
2842
|
+
type="button"
|
|
2843
|
+
data-og-question-nav=""
|
|
2844
|
+
data-og-jump-to-question=""
|
|
2845
|
+
title="Back to your message"
|
|
2846
|
+
onClick={() => jumpToQuestion(questionNav.key)}
|
|
2847
|
+
className={NAV_BUTTON_CLASS}
|
|
2848
|
+
>
|
|
2849
|
+
<ArrowUpIcon aria-hidden className="size-4" />
|
|
2850
|
+
<span className="sr-only">Back to your message</span>
|
|
2851
|
+
</motion.button>
|
|
2852
|
+
) : null}
|
|
2853
|
+
</AnimatePresence>
|
|
2854
|
+
</div>
|
|
2855
|
+
<div
|
|
2856
|
+
data-og-timeline-nav="bottom"
|
|
2857
|
+
className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex flex-col items-center gap-2"
|
|
2858
|
+
>
|
|
2859
|
+
<AnimatePresence>
|
|
2860
|
+
{loadingNewer ? (
|
|
2861
|
+
<motion.div
|
|
2862
|
+
key="loading-newer"
|
|
2863
|
+
{...NAV_FADE}
|
|
2864
|
+
data-og-loading-newer=""
|
|
2865
|
+
aria-live="polite"
|
|
2866
|
+
className="flex max-w-full justify-center"
|
|
2867
|
+
>
|
|
2868
|
+
<span className={LOADING_CHIP_CLASS}>
|
|
2869
|
+
<span className="og-shimmer-text">Loading later activity…</span>
|
|
2870
|
+
</span>
|
|
2871
|
+
</motion.div>
|
|
2872
|
+
) : null}
|
|
2873
|
+
{jumpPillShown ? (
|
|
2874
|
+
<motion.button
|
|
2875
|
+
key="jump-to-latest"
|
|
2876
|
+
{...NAV_FADE}
|
|
2877
|
+
type="button"
|
|
2878
|
+
data-og-jump-to-latest=""
|
|
2879
|
+
title="Jump to latest"
|
|
2880
|
+
onClick={() => {
|
|
2881
|
+
// Returning to the tip explicitly releases reader-owned
|
|
2882
|
+
// prose. Clear only this timeline's selection before the
|
|
2883
|
+
// synchronous ownership check on the next commit.
|
|
2884
|
+
const viewport = scrollRef.current;
|
|
2885
|
+
const selection = viewport?.ownerDocument.getSelection();
|
|
2886
|
+
if (
|
|
2887
|
+
selection &&
|
|
2888
|
+
!selection.isCollapsed &&
|
|
2889
|
+
(viewport?.contains(selection.anchorNode) ||
|
|
2890
|
+
viewport?.contains(selection.focusNode))
|
|
2891
|
+
)
|
|
2892
|
+
selection.removeAllRanges();
|
|
2893
|
+
disclosureKeepsUnpinnedRef.current = false;
|
|
2894
|
+
if (hasNewer) {
|
|
2895
|
+
// Do not pin against the current history page — its bottom
|
|
2896
|
+
// is not the tip. The pin + snap run when the tip window
|
|
2897
|
+
// actually lands (`hasNewer` flips false).
|
|
2898
|
+
wantPinRef.current = true;
|
|
2899
|
+
const node = scrollRef.current;
|
|
2900
|
+
if (onJumpToLatest) {
|
|
2901
|
+
void Promise.resolve(onJumpToLatest()).then(
|
|
2902
|
+
() => {
|
|
2903
|
+
// Covers a host that flipped hasNewer before
|
|
2904
|
+
// resolving; otherwise the tip-window commit
|
|
2905
|
+
// consumes the flag.
|
|
2906
|
+
const current = scrollRef.current;
|
|
2907
|
+
if (
|
|
2908
|
+
current &&
|
|
2909
|
+
wantPinRef.current &&
|
|
2910
|
+
!hasNewerRef.current
|
|
2911
|
+
) {
|
|
2912
|
+
wantPinRef.current = false;
|
|
2913
|
+
applyPinned(true);
|
|
2914
|
+
snapToBottom(current);
|
|
2915
|
+
}
|
|
2916
|
+
},
|
|
2917
|
+
() => {
|
|
2918
|
+
// The tip reload failed (ordinary network error):
|
|
2919
|
+
// an armed latch would fire a surprise snap when
|
|
2920
|
+
// the reader later pages to the tip themselves.
|
|
2921
|
+
wantPinRef.current = false;
|
|
2922
|
+
},
|
|
2923
|
+
);
|
|
2924
|
+
} else if (node) {
|
|
2925
|
+
// No tip reload available: jump within the in-memory
|
|
2926
|
+
// window so the newer sentinel can page forward; the
|
|
2927
|
+
// latch pins if the tip window eventually lands.
|
|
2928
|
+
snapToBottom(node);
|
|
1489
2929
|
}
|
|
1490
|
-
|
|
1491
|
-
},
|
|
1492
|
-
() => {
|
|
1493
|
-
if (jumpToStartSeqRef.current === seq) {
|
|
1494
|
-
pendingJumpToStartRef.current = false;
|
|
2930
|
+
return;
|
|
1495
2931
|
}
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
}}
|
|
1499
|
-
className={cn(
|
|
1500
|
-
"inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
|
|
1501
|
-
"text-og-control font-medium text-og-fg shadow-og-md backdrop-blur",
|
|
1502
|
-
"hover:border-og-border-strong disabled:opacity-60",
|
|
1503
|
-
)}
|
|
1504
|
-
>
|
|
1505
|
-
<ArrowUpToLineIcon className="size-3.5" />
|
|
1506
|
-
Jump to start
|
|
1507
|
-
</button>
|
|
1508
|
-
) : null}
|
|
1509
|
-
</motion.div>
|
|
1510
|
-
) : null}
|
|
1511
|
-
</AnimatePresence>
|
|
1512
|
-
<AnimatePresence>
|
|
1513
|
-
{loadingNewer ? (
|
|
1514
|
-
<motion.div
|
|
1515
|
-
initial={{ opacity: 0, y: 6 }}
|
|
1516
|
-
animate={{ opacity: 1, y: 0 }}
|
|
1517
|
-
exit={{ opacity: 0, y: 6 }}
|
|
1518
|
-
transition={{ duration: 0.15, ease: "easeOut" }}
|
|
1519
|
-
data-og-loading-newer=""
|
|
1520
|
-
aria-live="polite"
|
|
1521
|
-
className="pointer-events-none absolute inset-x-0 bottom-14 z-10 flex justify-center"
|
|
1522
|
-
>
|
|
1523
|
-
<span className="inline-flex items-center rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1 text-og-control font-medium shadow-og-md backdrop-blur">
|
|
1524
|
-
<span className="og-shimmer-text">Loading later activity…</span>
|
|
1525
|
-
</span>
|
|
1526
|
-
</motion.div>
|
|
1527
|
-
) : null}
|
|
1528
|
-
</AnimatePresence>
|
|
1529
|
-
<AnimatePresence>
|
|
1530
|
-
{((!pinned && autoFollow) || hasNewer) && autoFollow ? (
|
|
1531
|
-
<motion.button
|
|
1532
|
-
type="button"
|
|
1533
|
-
initial={{ opacity: 0, y: 8 }}
|
|
1534
|
-
animate={{ opacity: 1, y: 0 }}
|
|
1535
|
-
exit={{ opacity: 0, y: 8 }}
|
|
1536
|
-
transition={{ duration: 0.15, ease: "easeOut" }}
|
|
1537
|
-
onClick={() => {
|
|
1538
|
-
disclosureKeepsUnpinnedRef.current = false;
|
|
1539
|
-
if (hasNewer) {
|
|
1540
|
-
// Do not pin against the current history page — its bottom
|
|
1541
|
-
// is not the tip. The pin + snap run when the tip window
|
|
1542
|
-
// actually lands (`hasNewer` flips false).
|
|
1543
|
-
wantPinRef.current = true;
|
|
1544
|
-
const node = scrollRef.current;
|
|
1545
|
-
if (onJumpToLatest) {
|
|
1546
|
-
void Promise.resolve(onJumpToLatest()).then(
|
|
1547
|
-
() => {
|
|
1548
|
-
// Covers a host that flipped hasNewer before
|
|
1549
|
-
// resolving; otherwise the tip-window commit
|
|
1550
|
-
// consumes the flag.
|
|
1551
|
-
const current = scrollRef.current;
|
|
1552
|
-
if (current && wantPinRef.current && !hasNewerRef.current) {
|
|
1553
|
-
wantPinRef.current = false;
|
|
2932
|
+
const node = scrollRef.current;
|
|
2933
|
+
if (node) {
|
|
1554
2934
|
applyPinned(true);
|
|
1555
|
-
snapToBottom(
|
|
2935
|
+
snapToBottom(node);
|
|
1556
2936
|
}
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
snapToBottom(node);
|
|
1577
|
-
}
|
|
1578
|
-
}}
|
|
1579
|
-
className={cn(
|
|
1580
|
-
"absolute inset-x-0 bottom-4 mx-auto w-fit",
|
|
1581
|
-
"inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
|
|
1582
|
-
"text-og-control font-medium text-og-fg shadow-og-md backdrop-blur",
|
|
1583
|
-
"hover:border-og-border-strong",
|
|
1584
|
-
)}
|
|
1585
|
-
>
|
|
1586
|
-
<ArrowDownIcon className="size-3.5" />
|
|
1587
|
-
Jump to latest
|
|
1588
|
-
</motion.button>
|
|
1589
|
-
) : null}
|
|
1590
|
-
</AnimatePresence>
|
|
1591
|
-
</div>
|
|
1592
|
-
</TooltipProvider>
|
|
1593
|
-
</EntranceAnimationProvider>
|
|
1594
|
-
</TimelineComputeLabelProvider>
|
|
1595
|
-
</SeenActivityIdsProvider>
|
|
1596
|
-
</FoldMemoryProvider>
|
|
1597
|
-
</LightboxProvider>
|
|
2937
|
+
}}
|
|
2938
|
+
className={NAV_BUTTON_CLASS}
|
|
2939
|
+
>
|
|
2940
|
+
<ArrowDownIcon aria-hidden className="size-4" />
|
|
2941
|
+
<span className="sr-only">Jump to latest</span>
|
|
2942
|
+
</motion.button>
|
|
2943
|
+
) : null}
|
|
2944
|
+
</AnimatePresence>
|
|
2945
|
+
</div>
|
|
2946
|
+
</div>
|
|
2947
|
+
</TimelineAnnotationSourceRootContext.Provider>
|
|
2948
|
+
</TooltipProvider>
|
|
2949
|
+
</EntranceAnimationProvider>
|
|
2950
|
+
</TimelineComputeLabelProvider>
|
|
2951
|
+
</SeenActivityIdsProvider>
|
|
2952
|
+
</LiveNoteFoldContext.Provider>
|
|
2953
|
+
</FoldMemoryProvider>
|
|
2954
|
+
</LightboxProvider>
|
|
2955
|
+
</AllowancePresentationProvider>
|
|
1598
2956
|
);
|
|
2957
|
+
// Agent-authored object links resolve through the host, never the console.
|
|
2958
|
+
return <OpenGeniLinkProvider resolveLink={resolveLink}>{timeline}</OpenGeniLinkProvider>;
|
|
1599
2959
|
}
|
|
1600
2960
|
|
|
1601
2961
|
type KeyedTimelineGroup = {
|
|
1602
2962
|
group: TimelineGroup;
|
|
1603
2963
|
key: string;
|
|
2964
|
+
entranceEnabled: boolean;
|
|
1604
2965
|
};
|
|
1605
2966
|
|
|
2967
|
+
function timelineGroupsRenderEqual(previous: TimelineGroup, next: TimelineGroup): boolean {
|
|
2968
|
+
try {
|
|
2969
|
+
return dequal(previous, next);
|
|
2970
|
+
} catch {
|
|
2971
|
+
// Consumer-owned `unknown` payloads may be proxies/getters. Equality is an
|
|
2972
|
+
// optimization only; a hostile comparator surface must fall back to the
|
|
2973
|
+
// authoritative new group rather than taking down the timeline render.
|
|
2974
|
+
return false;
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
|
|
1606
2978
|
/**
|
|
1607
2979
|
* Projection can legitimately change a group's content-derived key while
|
|
1608
2980
|
* retaining its existing rows. The common pagination case is an older activity
|
|
@@ -1611,42 +2983,73 @@ type KeyedTimelineGroup = {
|
|
|
1611
2983
|
* committed groups by their durable item IDs so both the React key and the
|
|
1612
2984
|
* progressive-window anchor survive either change.
|
|
1613
2985
|
*/
|
|
1614
|
-
function useStableTimelineGroupKeys(
|
|
2986
|
+
function useStableTimelineGroupKeys(
|
|
2987
|
+
allGroups: TimelineGroup[],
|
|
2988
|
+
entranceEnabled: boolean,
|
|
2989
|
+
): KeyedTimelineGroup[] {
|
|
1615
2990
|
const previousRef = useRef<KeyedTimelineGroup[]>([]);
|
|
1616
2991
|
const keyedGroups = useMemo(() => {
|
|
1617
2992
|
const previousByItemId = new Map<string, KeyedTimelineGroup>();
|
|
1618
2993
|
for (const previous of previousRef.current) {
|
|
1619
2994
|
for (const itemId of timelineGroupItemIds(previous.group)) {
|
|
1620
|
-
|
|
2995
|
+
// A live progress note is both its own row and listed in its work
|
|
2996
|
+
// history. Its own row owns the id, so the note keeps its identity.
|
|
2997
|
+
if (previous.group.kind === "item" || !previousByItemId.has(itemId)) {
|
|
2998
|
+
previousByItemId.set(itemId, previous);
|
|
2999
|
+
}
|
|
1621
3000
|
}
|
|
1622
3001
|
}
|
|
1623
3002
|
|
|
1624
3003
|
const usedKeys = new Set<string>();
|
|
1625
3004
|
return allGroups.map((group, index) => {
|
|
1626
3005
|
const itemIds = timelineGroupItemIds(group);
|
|
1627
|
-
let
|
|
3006
|
+
let retainedGroup: KeyedTimelineGroup | undefined;
|
|
1628
3007
|
for (const itemId of itemIds) {
|
|
1629
3008
|
const previous = previousByItemId.get(itemId);
|
|
1630
3009
|
// Retain only same-kind matches. Activity → turn wrap must NOT keep the
|
|
1631
3010
|
// activity chip's React key: that reused a collapsed TurnSummary and
|
|
1632
3011
|
// skipped the settle beat (insta-collapse / content flash).
|
|
1633
|
-
|
|
1634
|
-
|
|
3012
|
+
const startupCompletion =
|
|
3013
|
+
previous?.group.kind === "activity" &&
|
|
3014
|
+
previous.group.items.every(
|
|
3015
|
+
(item) =>
|
|
3016
|
+
item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
|
|
3017
|
+
) &&
|
|
3018
|
+
group.kind === "turn" &&
|
|
3019
|
+
group.outcome === "complete" &&
|
|
3020
|
+
group.groups.every(
|
|
3021
|
+
(child) =>
|
|
3022
|
+
child.kind === "activity" &&
|
|
3023
|
+
child.items.every((item) => item.kind === "startup-phase"),
|
|
3024
|
+
);
|
|
3025
|
+
if (
|
|
3026
|
+
previous &&
|
|
3027
|
+
(previous.group.kind === group.kind || startupCompletion) &&
|
|
3028
|
+
!usedKeys.has(previous.key)
|
|
3029
|
+
) {
|
|
3030
|
+
retainedGroup = previous;
|
|
1635
3031
|
break;
|
|
1636
3032
|
}
|
|
1637
3033
|
}
|
|
1638
3034
|
|
|
1639
3035
|
const canonicalKey = timelineGroupKey(group);
|
|
1640
|
-
let key =
|
|
3036
|
+
let key = retainedGroup?.key ?? canonicalKey;
|
|
1641
3037
|
let collision = 0;
|
|
1642
3038
|
while (usedKeys.has(key)) {
|
|
1643
3039
|
key = `${canonicalKey}:${index}:${collision}`;
|
|
1644
3040
|
collision += 1;
|
|
1645
3041
|
}
|
|
1646
3042
|
usedKeys.add(key);
|
|
1647
|
-
return {
|
|
3043
|
+
return {
|
|
3044
|
+
group:
|
|
3045
|
+
retainedGroup && timelineGroupsRenderEqual(retainedGroup.group, group)
|
|
3046
|
+
? retainedGroup.group
|
|
3047
|
+
: group,
|
|
3048
|
+
key,
|
|
3049
|
+
entranceEnabled: retainedGroup?.entranceEnabled ?? entranceEnabled,
|
|
3050
|
+
};
|
|
1648
3051
|
});
|
|
1649
|
-
}, [allGroups]);
|
|
3052
|
+
}, [allGroups, entranceEnabled]);
|
|
1650
3053
|
|
|
1651
3054
|
useLayoutEffect(() => {
|
|
1652
3055
|
previousRef.current = keyedGroups;
|
|
@@ -1739,17 +3142,155 @@ class TimelineGroupRenderBoundary extends Component<
|
|
|
1739
3142
|
}
|
|
1740
3143
|
}
|
|
1741
3144
|
|
|
3145
|
+
type TimelineGroupEntryProps = {
|
|
3146
|
+
groupKey: string;
|
|
3147
|
+
group: TimelineGroup;
|
|
3148
|
+
nextGroup?: TimelineGroup | undefined;
|
|
3149
|
+
startupDismissed: boolean;
|
|
3150
|
+
entranceEnabled: boolean;
|
|
3151
|
+
liveEntranceEnabled?: boolean | undefined;
|
|
3152
|
+
/**
|
|
3153
|
+
* Present for a live progress note: true while its turn's work disclosure is
|
|
3154
|
+
* open (the note then reads inside it), false while it reads here.
|
|
3155
|
+
*/
|
|
3156
|
+
noteFolded?: boolean | undefined;
|
|
3157
|
+
context: TimelineGroupEntryContext;
|
|
3158
|
+
};
|
|
3159
|
+
|
|
3160
|
+
type TimelineGroupEntryContext = {
|
|
3161
|
+
userMessageDisclosureContext: UserMessageDisclosureContextValue;
|
|
3162
|
+
behavior: TimelineGroupBehaviorProps;
|
|
3163
|
+
};
|
|
3164
|
+
|
|
3165
|
+
type TimelineGroupBehaviorProps = {
|
|
3166
|
+
renderMessageActions: MessageTimelineProps["renderMessageActions"];
|
|
3167
|
+
renderMessageText:
|
|
3168
|
+
| ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
|
|
3169
|
+
| undefined;
|
|
3170
|
+
onOpenSession: MessageTimelineProps["onOpenSession"];
|
|
3171
|
+
onMemoryClick: MessageTimelineProps["onMemoryClick"];
|
|
3172
|
+
onReconnect: MessageTimelineProps["onReconnect"];
|
|
3173
|
+
renderAuthNeeded: MessageTimelineProps["renderAuthNeeded"];
|
|
3174
|
+
resolveProviderLogo: MessageTimelineProps["resolveProviderLogo"];
|
|
3175
|
+
toolRegistry: ToolRegistry;
|
|
3176
|
+
loadRetainedScreenshot: MessageTimelineProps["loadRetainedScreenshot"];
|
|
3177
|
+
loadRetainedArtifact: MessageTimelineProps["loadRetainedArtifact"];
|
|
3178
|
+
loadVideoArtifactPlayback: MessageTimelineProps["loadVideoArtifactPlayback"];
|
|
3179
|
+
genieLoading: MessageTimelineProps["genieLoading"];
|
|
3180
|
+
turnSummary: MessageTimelineProps["turnSummary"];
|
|
3181
|
+
};
|
|
3182
|
+
|
|
3183
|
+
/**
|
|
3184
|
+
* Keep the complete settled-row shell behind one shallow memo boundary. A
|
|
3185
|
+
* prepend still reconciles the keyed list, but render-equivalent suffix groups
|
|
3186
|
+
* skip their providers, error boundaries, disclosure wrappers, and row trees.
|
|
3187
|
+
*/
|
|
3188
|
+
const TimelineGroupEntry = memo(function TimelineGroupEntry({
|
|
3189
|
+
groupKey,
|
|
3190
|
+
group,
|
|
3191
|
+
nextGroup,
|
|
3192
|
+
startupDismissed,
|
|
3193
|
+
entranceEnabled,
|
|
3194
|
+
liveEntranceEnabled,
|
|
3195
|
+
noteFolded,
|
|
3196
|
+
context,
|
|
3197
|
+
}: TimelineGroupEntryProps) {
|
|
3198
|
+
const reducedMotion = useReducedMotion();
|
|
3199
|
+
const { behavior, userMessageDisclosureContext } = context;
|
|
3200
|
+
const contextCompactionCount =
|
|
3201
|
+
group.kind === "turn"
|
|
3202
|
+
? (group.contextCompactionCount ?? 0)
|
|
3203
|
+
: group.kind === "activity" &&
|
|
3204
|
+
nextGroup?.kind === "item" &&
|
|
3205
|
+
nextGroup.item.kind === "context-compaction" &&
|
|
3206
|
+
nextGroup.item.phase === "compacted"
|
|
3207
|
+
? 1
|
|
3208
|
+
: 0;
|
|
3209
|
+
const content = (
|
|
3210
|
+
<ActivityNoteTextContext.Provider value={behavior.renderMessageText ?? null}>
|
|
3211
|
+
<TimelineGroupView
|
|
3212
|
+
{...behavior}
|
|
3213
|
+
group={group}
|
|
3214
|
+
foldLiveCluster={isAgentProgress(nextGroup)}
|
|
3215
|
+
startupDismissed={startupDismissed}
|
|
3216
|
+
trailingAgentText={trailingAgentTextAfterTurn(group, nextGroup)}
|
|
3217
|
+
contextCompactionCount={contextCompactionCount > 0 ? contextCompactionCount : undefined}
|
|
3218
|
+
/>
|
|
3219
|
+
</ActivityNoteTextContext.Provider>
|
|
3220
|
+
);
|
|
3221
|
+
return (
|
|
3222
|
+
<GenieLoadingOptionsContext.Provider value={behavior.genieLoading}>
|
|
3223
|
+
<div
|
|
3224
|
+
data-og-timeline-group-anchor=""
|
|
3225
|
+
data-og-group-key={groupKey}
|
|
3226
|
+
data-og-prompt={
|
|
3227
|
+
group.kind === "item" && group.item.kind === "user-message" ? "" : undefined
|
|
3228
|
+
}
|
|
3229
|
+
tabIndex={group.kind === "item" && group.item.kind === "user-message" ? -1 : undefined}
|
|
3230
|
+
// A live progress note reads inside its open work row instead; folded,
|
|
3231
|
+
// it takes no space and also gives back the list's gap-5 above it.
|
|
3232
|
+
data-og-live-note={noteFolded === undefined ? undefined : noteFolded ? "folded" : "shown"}
|
|
3233
|
+
data-og-live-note-id={
|
|
3234
|
+
noteFolded !== undefined && group.kind === "item" ? group.item.id : undefined
|
|
3235
|
+
}
|
|
3236
|
+
aria-hidden={noteFolded || undefined}
|
|
3237
|
+
inert={noteFolded || undefined}
|
|
3238
|
+
className={
|
|
3239
|
+
noteFolded ? "-mt-5 h-0 overflow-hidden opacity-0 [overflow-anchor:none]" : undefined
|
|
3240
|
+
}
|
|
3241
|
+
>
|
|
3242
|
+
<EntranceAnimationProvider value={entranceEnabled} liveValue={liveEntranceEnabled}>
|
|
3243
|
+
<TimelineGroupRenderBoundary resetKeys={[group, behavior]}>
|
|
3244
|
+
<UserMessageDisclosureProvider value={userMessageDisclosureContext}>
|
|
3245
|
+
{/* Item groups never switch the preparation/content key. Keep their
|
|
3246
|
+
DOM shell without mounting inert presence and motion lifecycles
|
|
3247
|
+
for every historical message in a prepend. */}
|
|
3248
|
+
{group.kind === "item" ? (
|
|
3249
|
+
<div>{content}</div>
|
|
3250
|
+
) : (
|
|
3251
|
+
<AnimatePresence initial={false}>
|
|
3252
|
+
<motion.div
|
|
3253
|
+
key={
|
|
3254
|
+
!startupDismissed &&
|
|
3255
|
+
group.kind === "activity" &&
|
|
3256
|
+
!group.work &&
|
|
3257
|
+
group.items.every(
|
|
3258
|
+
(item) =>
|
|
3259
|
+
item.kind === "startup-phase" ||
|
|
3260
|
+
(item.kind === "reasoning" && !item.text.trim()),
|
|
3261
|
+
)
|
|
3262
|
+
? "preparation"
|
|
3263
|
+
: "content"
|
|
3264
|
+
}
|
|
3265
|
+
initial={false}
|
|
3266
|
+
exit={{ opacity: 0, height: 0 }}
|
|
3267
|
+
transition={{ duration: reducedMotion ? 0 : 0.2 }}
|
|
3268
|
+
>
|
|
3269
|
+
{content}
|
|
3270
|
+
</motion.div>
|
|
3271
|
+
</AnimatePresence>
|
|
3272
|
+
)}
|
|
3273
|
+
</UserMessageDisclosureProvider>
|
|
3274
|
+
</TimelineGroupRenderBoundary>
|
|
3275
|
+
</EntranceAnimationProvider>
|
|
3276
|
+
</div>
|
|
3277
|
+
</GenieLoadingOptionsContext.Provider>
|
|
3278
|
+
);
|
|
3279
|
+
});
|
|
3280
|
+
|
|
1742
3281
|
// The full loaded window stays mounted, so settled history rows must be cheap
|
|
1743
|
-
// on every commit: projection reuses group
|
|
1744
|
-
// memo skips them.
|
|
1745
|
-
// behavior/callback changes are separate props, so ordinary
|
|
1746
|
-
// updates still invalidate immediately.
|
|
3282
|
+
// on every commit: the stable-key projection reuses render-equivalent group
|
|
3283
|
+
// objects, so memo skips them. Changed projection content receives a new group
|
|
3284
|
+
// object, and behavior/callback changes are separate props, so ordinary
|
|
3285
|
+
// streaming and host updates still invalidate immediately.
|
|
1747
3286
|
const TimelineGroupView = memo(function TimelineGroupView({
|
|
1748
3287
|
group,
|
|
3288
|
+
renderMessageActions,
|
|
1749
3289
|
renderMessageText,
|
|
1750
3290
|
onOpenSession,
|
|
1751
3291
|
onMemoryClick,
|
|
1752
3292
|
onReconnect,
|
|
3293
|
+
renderAuthNeeded,
|
|
1753
3294
|
resolveProviderLogo,
|
|
1754
3295
|
toolRegistry,
|
|
1755
3296
|
loadRetainedScreenshot,
|
|
@@ -1759,16 +3300,20 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1759
3300
|
insideTurn = false,
|
|
1760
3301
|
nestClusterChips = false,
|
|
1761
3302
|
foldLiveCluster = false,
|
|
3303
|
+
startupDismissed = false,
|
|
1762
3304
|
trailingAgentText,
|
|
1763
3305
|
contextCompactionCount,
|
|
1764
3306
|
}: {
|
|
1765
3307
|
group: TimelineGroup;
|
|
3308
|
+
renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
1766
3309
|
renderMessageText?:
|
|
1767
3310
|
| ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
|
|
1768
3311
|
| undefined;
|
|
1769
3312
|
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
1770
3313
|
onMemoryClick?: ((memoryId: string) => void) | undefined;
|
|
1771
3314
|
onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
|
|
3315
|
+
/** Host-owned inline connection setup. Return undefined to use the default recovery card. */
|
|
3316
|
+
renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
|
|
1772
3317
|
resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
|
|
1773
3318
|
toolRegistry: ToolRegistry;
|
|
1774
3319
|
loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
|
|
@@ -1779,6 +3324,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1779
3324
|
behind a neutral chip — the one place activity without an outcome still
|
|
1780
3325
|
folds, bounding the DOM of days-long autonomous turns. */
|
|
1781
3326
|
foldLiveCluster?: boolean;
|
|
3327
|
+
startupDismissed?: boolean;
|
|
1782
3328
|
/** Rendering inside an expanded turn group: the outer chip already owns the
|
|
1783
3329
|
failure surface, so nested chips stay tinted but quiet (no repeated
|
|
1784
3330
|
failure text, no auto-open) — one loud error, N calm sub-expands. */
|
|
@@ -1799,16 +3345,29 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1799
3345
|
/** Secondary chip facet when this fold sits next to a compaction landmark. */
|
|
1800
3346
|
contextCompactionCount?: number | undefined;
|
|
1801
3347
|
}) {
|
|
3348
|
+
const startupDetails = useStartupDetails();
|
|
1802
3349
|
const enter = useEntranceAnimation();
|
|
1803
3350
|
const settleChrome = useTurnSettleOpen();
|
|
3351
|
+
const previousSingleActivity = useRef<ActivityItem | undefined>(undefined);
|
|
3352
|
+
useLayoutEffect(() => {
|
|
3353
|
+
const work =
|
|
3354
|
+
group.kind === "activity" ? group.items.filter((item) => item.kind !== "startup-phase") : [];
|
|
3355
|
+
previousSingleActivity.current = work.length === 1 ? work[0] : undefined;
|
|
3356
|
+
}, [group]);
|
|
1804
3357
|
const foldMemory = useFoldMemory();
|
|
3358
|
+
const reportLiveFold = useContext(LiveNoteFoldContext);
|
|
1805
3359
|
// Settled (or live-fold) activity clusters get a chip. Inside an expanded
|
|
1806
3360
|
// turn that is the second layer — quiet nested chips under the outer turn
|
|
1807
3361
|
// summary when contiguous activity naturally clusters (≥2 only).
|
|
3362
|
+
const containsPresentedImage = timelineGroupContainsPresentedImage(group);
|
|
3363
|
+
const hasRememberedImageFold =
|
|
3364
|
+
group.kind === "activity" && containsPresentedImage && foldMemory?.has(group.id);
|
|
3365
|
+
// Primary images stay visible through live narration and the turn wrap.
|
|
3366
|
+
// Manual collapse still belongs to TurnSummary's existing fold memory.
|
|
1808
3367
|
const activityShouldFold =
|
|
1809
3368
|
group.kind === "activity" &&
|
|
1810
|
-
|
|
1811
|
-
|
|
3369
|
+
!containsPresentedImage &&
|
|
3370
|
+
!!(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
|
|
1812
3371
|
// Latch live→folded so a top-level shell that was already mounted open can
|
|
1813
3372
|
// start the settle beat without remounting bare rail → wrapper.
|
|
1814
3373
|
const liveActivitySettle = useLiveSettleFold(activityShouldFold && !insideTurn);
|
|
@@ -1817,7 +3376,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1817
3376
|
group.kind === "turn" &&
|
|
1818
3377
|
(group.outcome === "failed" ||
|
|
1819
3378
|
timelineGroupContainsAuthNeeded(group) ||
|
|
1820
|
-
|
|
3379
|
+
containsPresentedImage);
|
|
1821
3380
|
// activity-* → turn-* remount: carry resting state so settleFold does not
|
|
1822
3381
|
// re-open a chip the reader already watched collapse.
|
|
1823
3382
|
if (group.kind === "turn" && foldMemory && !insideTurn) {
|
|
@@ -1828,9 +3387,149 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1828
3387
|
);
|
|
1829
3388
|
}
|
|
1830
3389
|
const settleFold =
|
|
1831
|
-
|
|
3390
|
+
!turnSummary?.rolling &&
|
|
3391
|
+
(group.kind === "turn" ? !!(enter && !insideTurn && !turnDefaultOpen) : liveActivitySettle);
|
|
1832
3392
|
switch (group.kind) {
|
|
1833
3393
|
case "activity":
|
|
3394
|
+
if (group.work) {
|
|
3395
|
+
const phases = group.items.filter((item) => item.kind === "startup-phase");
|
|
3396
|
+
const preparing =
|
|
3397
|
+
!startupDetails &&
|
|
3398
|
+
!startupDismissed &&
|
|
3399
|
+
!group.work.endedAt &&
|
|
3400
|
+
!group.work.waiting &&
|
|
3401
|
+
phases.length > 0 &&
|
|
3402
|
+
group.items.every(
|
|
3403
|
+
(item) =>
|
|
3404
|
+
item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
|
|
3405
|
+
) &&
|
|
3406
|
+
!phases.some((item) => item.status === "failed" || item.status === "cancelled");
|
|
3407
|
+
if (preparing) {
|
|
3408
|
+
// Preparation is primary, never hidden in an activity disclosure.
|
|
3409
|
+
// Keep work.startedAt unchanged for the handoff and settled duration.
|
|
3410
|
+
return (
|
|
3411
|
+
<ActivityRail
|
|
3412
|
+
items={group.items}
|
|
3413
|
+
startupActive
|
|
3414
|
+
bare
|
|
3415
|
+
toolRegistry={toolRegistry}
|
|
3416
|
+
onOpenSession={onOpenSession}
|
|
3417
|
+
onMemoryClick={onMemoryClick}
|
|
3418
|
+
loadRetainedScreenshot={loadRetainedScreenshot}
|
|
3419
|
+
loadRetainedArtifact={loadRetainedArtifact}
|
|
3420
|
+
/>
|
|
3421
|
+
);
|
|
3422
|
+
}
|
|
3423
|
+
const end = group.work.endedAt;
|
|
3424
|
+
const status: TurnSummaryStatus = end
|
|
3425
|
+
? { kind: "worked", durationMs: durationBetween(group.work.startedAt, end) }
|
|
3426
|
+
: group.work.waiting
|
|
3427
|
+
? { kind: "waiting", ...group.work.waiting }
|
|
3428
|
+
: {
|
|
3429
|
+
kind: "working",
|
|
3430
|
+
since: group.work.startedAt,
|
|
3431
|
+
preview: containsPresentedImage ? undefined : (
|
|
3432
|
+
<RollingActivity
|
|
3433
|
+
items={group.items}
|
|
3434
|
+
toolRegistry={toolRegistry}
|
|
3435
|
+
showCount={false}
|
|
3436
|
+
/>
|
|
3437
|
+
),
|
|
3438
|
+
};
|
|
3439
|
+
return (
|
|
3440
|
+
// Marks this work row for the live-note fold (list keys can be carried
|
|
3441
|
+
// over from an earlier row, so they are not the work id).
|
|
3442
|
+
<div className="contents" data-og-work-id={group.id}>
|
|
3443
|
+
<TurnSummary
|
|
3444
|
+
key="work"
|
|
3445
|
+
items={group.items}
|
|
3446
|
+
status={status}
|
|
3447
|
+
outcome={group.outcome}
|
|
3448
|
+
failureText={group.failureText}
|
|
3449
|
+
foldKey={group.id}
|
|
3450
|
+
defaultOpen={
|
|
3451
|
+
containsPresentedImage ||
|
|
3452
|
+
group.outcome === "failed" ||
|
|
3453
|
+
phases.some((item) => item.status === "failed" || item.status === "cancelled")
|
|
3454
|
+
? true
|
|
3455
|
+
: undefined
|
|
3456
|
+
}
|
|
3457
|
+
facets={turnSummary?.facets}
|
|
3458
|
+
contextCompactionCount={compactedLandmarkCount(group.work.details)}
|
|
3459
|
+
onOpenStateChange={
|
|
3460
|
+
group.work.liveNoteIds?.length && reportLiveFold
|
|
3461
|
+
? (open) => reportLiveFold(group.id, open)
|
|
3462
|
+
: undefined
|
|
3463
|
+
}
|
|
3464
|
+
>
|
|
3465
|
+
<TurnRailFrame compact>
|
|
3466
|
+
{renderFoldedGroups(
|
|
3467
|
+
group.work.details,
|
|
3468
|
+
{
|
|
3469
|
+
renderMessageActions,
|
|
3470
|
+
renderMessageText,
|
|
3471
|
+
onOpenSession,
|
|
3472
|
+
onMemoryClick,
|
|
3473
|
+
onReconnect,
|
|
3474
|
+
renderAuthNeeded,
|
|
3475
|
+
resolveProviderLogo,
|
|
3476
|
+
toolRegistry,
|
|
3477
|
+
loadRetainedScreenshot,
|
|
3478
|
+
loadRetainedArtifact,
|
|
3479
|
+
loadVideoArtifactPlayback,
|
|
3480
|
+
turnSummary,
|
|
3481
|
+
},
|
|
3482
|
+
true,
|
|
3483
|
+
)}
|
|
3484
|
+
</TurnRailFrame>
|
|
3485
|
+
</TurnSummary>
|
|
3486
|
+
</div>
|
|
3487
|
+
);
|
|
3488
|
+
}
|
|
3489
|
+
// Preparation is one quiet surface, not a fold with eight technical steps.
|
|
3490
|
+
// A later turn of the same exchange keeps its status row instead.
|
|
3491
|
+
if (
|
|
3492
|
+
!startupDetails &&
|
|
3493
|
+
!insideTurn &&
|
|
3494
|
+
!group.outcome &&
|
|
3495
|
+
group.items.every(
|
|
3496
|
+
(item) =>
|
|
3497
|
+
item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
|
|
3498
|
+
)
|
|
3499
|
+
) {
|
|
3500
|
+
return (
|
|
3501
|
+
<ActivityRail
|
|
3502
|
+
items={group.items}
|
|
3503
|
+
startupActive={!startupDismissed && !foldLiveCluster}
|
|
3504
|
+
bare
|
|
3505
|
+
toolRegistry={toolRegistry}
|
|
3506
|
+
onOpenSession={onOpenSession}
|
|
3507
|
+
onMemoryClick={onMemoryClick}
|
|
3508
|
+
loadRetainedScreenshot={loadRetainedScreenshot}
|
|
3509
|
+
loadRetainedArtifact={loadRetainedArtifact}
|
|
3510
|
+
/>
|
|
3511
|
+
);
|
|
3512
|
+
}
|
|
3513
|
+
if (
|
|
3514
|
+
turnSummary?.rolling &&
|
|
3515
|
+
insideTurn &&
|
|
3516
|
+
!startupDetails &&
|
|
3517
|
+
!hasRememberedImageFold &&
|
|
3518
|
+
group.items.filter((item) => item.kind !== "startup-phase").length === 1
|
|
3519
|
+
) {
|
|
3520
|
+
return (
|
|
3521
|
+
<ActivityRail
|
|
3522
|
+
items={group.items}
|
|
3523
|
+
startupActive={false}
|
|
3524
|
+
bare
|
|
3525
|
+
toolRegistry={toolRegistry}
|
|
3526
|
+
onOpenSession={onOpenSession}
|
|
3527
|
+
onMemoryClick={onMemoryClick}
|
|
3528
|
+
loadRetainedScreenshot={loadRetainedScreenshot}
|
|
3529
|
+
loadRetainedArtifact={loadRetainedArtifact}
|
|
3530
|
+
/>
|
|
3531
|
+
);
|
|
3532
|
+
}
|
|
1834
3533
|
if (insideTurn) {
|
|
1835
3534
|
// Nested chips whenever the parent has ≥2 clusters. During outer settle
|
|
1836
3535
|
// chrome they stay force-open so structure is visible and height stays
|
|
@@ -1842,11 +3541,22 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1842
3541
|
// settled closed pre-wrap was showing as a CHIP, so mounting it closed
|
|
1843
3542
|
// is both the stable height and the honest state — force-opening it
|
|
1844
3543
|
// was the "already-collapsed cluster auto-expands at the end" reopen.
|
|
1845
|
-
const
|
|
3544
|
+
const visibleActivity = group.items.filter((item) => item.kind !== "startup-phase");
|
|
3545
|
+
const singleThought =
|
|
3546
|
+
!startupDetails &&
|
|
3547
|
+
visibleActivity.length === 1 &&
|
|
3548
|
+
visibleActivity[0]?.kind === "reasoning";
|
|
3549
|
+
// Untouched images stay primary output, but a reader-owned image fold
|
|
3550
|
+
// must keep its shell across a multi-cluster wrap. A bare rail would
|
|
3551
|
+
// bypass the remembered choice; retaining either state also lets the
|
|
3552
|
+
// reader reopen and close the same chip after settlement.
|
|
3553
|
+
const useNestedChip =
|
|
3554
|
+
nestClusterChips && (activityShouldFold || hasRememberedImageFold) && !singleThought;
|
|
1846
3555
|
if (!useNestedChip) {
|
|
1847
3556
|
return (
|
|
1848
3557
|
<ActivityRail
|
|
1849
3558
|
items={group.items}
|
|
3559
|
+
{...(startupDismissed ? { startupActive: false } : {})}
|
|
1850
3560
|
onOpenSession={onOpenSession}
|
|
1851
3561
|
onMemoryClick={onMemoryClick}
|
|
1852
3562
|
toolRegistry={toolRegistry}
|
|
@@ -1884,10 +3594,31 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1884
3594
|
// flips settleFold (collapse) instead of remounting bare rail → wrapper.
|
|
1885
3595
|
return (
|
|
1886
3596
|
<TurnSummary
|
|
3597
|
+
// Apply the primary-output default when a rolling activity first
|
|
3598
|
+
// produces an image; the stable foldKey still preserves user choices.
|
|
3599
|
+
key={containsPresentedImage ? "primary-image" : "activity"}
|
|
1887
3600
|
items={group.items}
|
|
1888
3601
|
outcome={group.outcome}
|
|
1889
3602
|
failureText={group.failureText}
|
|
1890
|
-
defaultOpen={
|
|
3603
|
+
defaultOpen={
|
|
3604
|
+
group.outcome === "failed" ||
|
|
3605
|
+
containsPresentedImage ||
|
|
3606
|
+
(!turnSummary?.rolling && !activityShouldFold)
|
|
3607
|
+
? true
|
|
3608
|
+
: undefined
|
|
3609
|
+
}
|
|
3610
|
+
liveHeader={
|
|
3611
|
+
turnSummary?.rolling &&
|
|
3612
|
+
!containsPresentedImage &&
|
|
3613
|
+
!group.outcome &&
|
|
3614
|
+
!foldLiveCluster ? (
|
|
3615
|
+
<RollingActivity
|
|
3616
|
+
items={group.items}
|
|
3617
|
+
toolRegistry={toolRegistry}
|
|
3618
|
+
previousItem={previousSingleActivity.current}
|
|
3619
|
+
/>
|
|
3620
|
+
) : undefined
|
|
3621
|
+
}
|
|
1891
3622
|
foldKey={group.id}
|
|
1892
3623
|
facets={turnSummary?.facets}
|
|
1893
3624
|
settleFold={settleFold}
|
|
@@ -1910,54 +3641,40 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1910
3641
|
);
|
|
1911
3642
|
case "turn": {
|
|
1912
3643
|
const activityItems = flattenActivityItems(group.groups);
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
3644
|
+
if (
|
|
3645
|
+
!startupDetails &&
|
|
3646
|
+
group.outcome === "complete" &&
|
|
3647
|
+
group.groups.every(
|
|
3648
|
+
(child) =>
|
|
3649
|
+
child.kind === "activity" &&
|
|
3650
|
+
child.items.every(
|
|
3651
|
+
(item) => item.kind === "startup-phase" && item.status === "complete",
|
|
3652
|
+
),
|
|
3653
|
+
)
|
|
3654
|
+
)
|
|
3655
|
+
return <ActivityRail items={activityItems} startupActive={false} bare />;
|
|
1917
3656
|
const turnCopyText = collectTurnCopyText(group.groups, trailingAgentText);
|
|
1918
|
-
const body = group.groups
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
loadRetainedScreenshot,
|
|
1932
|
-
loadRetainedArtifact,
|
|
1933
|
-
loadVideoArtifactPlayback,
|
|
1934
|
-
turnSummary,
|
|
1935
|
-
]}
|
|
1936
|
-
>
|
|
1937
|
-
<TimelineGroupView
|
|
1938
|
-
group={child}
|
|
1939
|
-
renderMessageText={renderMessageText}
|
|
1940
|
-
onOpenSession={onOpenSession}
|
|
1941
|
-
onMemoryClick={onMemoryClick}
|
|
1942
|
-
onReconnect={onReconnect}
|
|
1943
|
-
resolveProviderLogo={resolveProviderLogo}
|
|
1944
|
-
toolRegistry={toolRegistry}
|
|
1945
|
-
loadRetainedScreenshot={loadRetainedScreenshot}
|
|
1946
|
-
loadRetainedArtifact={loadRetainedArtifact}
|
|
1947
|
-
loadVideoArtifactPlayback={loadVideoArtifactPlayback}
|
|
1948
|
-
turnSummary={turnSummary}
|
|
1949
|
-
insideTurn
|
|
1950
|
-
nestClusterChips={nestClusters}
|
|
1951
|
-
/>
|
|
1952
|
-
</TimelineGroupRenderBoundary>
|
|
1953
|
-
);
|
|
3657
|
+
const body = renderFoldedGroups(group.groups, {
|
|
3658
|
+
renderMessageActions,
|
|
3659
|
+
renderMessageText,
|
|
3660
|
+
onOpenSession,
|
|
3661
|
+
onMemoryClick,
|
|
3662
|
+
onReconnect,
|
|
3663
|
+
renderAuthNeeded,
|
|
3664
|
+
resolveProviderLogo,
|
|
3665
|
+
toolRegistry,
|
|
3666
|
+
loadRetainedScreenshot,
|
|
3667
|
+
loadRetainedArtifact,
|
|
3668
|
+
loadVideoArtifactPlayback,
|
|
3669
|
+
turnSummary,
|
|
1954
3670
|
});
|
|
3671
|
+
const durationMs = durationBetween(group.startedAt, group.endedAt);
|
|
1955
3672
|
return (
|
|
1956
3673
|
<TurnSummary
|
|
1957
3674
|
items={activityItems}
|
|
1958
3675
|
outcome={group.outcome}
|
|
1959
3676
|
failureText={insideTurn ? undefined : group.failureText}
|
|
1960
|
-
durationMs={
|
|
3677
|
+
durationMs={durationMs}
|
|
1961
3678
|
defaultOpen={turnDefaultOpen ? true : undefined}
|
|
1962
3679
|
bare={insideTurn}
|
|
1963
3680
|
foldKey={group.id}
|
|
@@ -1982,8 +3699,10 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1982
3699
|
return (
|
|
1983
3700
|
<TimelineRow
|
|
1984
3701
|
item={group.item}
|
|
3702
|
+
renderMessageActions={renderMessageActions}
|
|
1985
3703
|
renderMessageText={renderMessageText}
|
|
1986
3704
|
onReconnect={onReconnect}
|
|
3705
|
+
renderAuthNeeded={renderAuthNeeded}
|
|
1987
3706
|
resolveProviderLogo={resolveProviderLogo}
|
|
1988
3707
|
onOpenSession={onOpenSession}
|
|
1989
3708
|
loadVideoArtifactPlayback={loadVideoArtifactPlayback}
|
|
@@ -1992,6 +3711,78 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1992
3711
|
}
|
|
1993
3712
|
});
|
|
1994
3713
|
|
|
3714
|
+
type FoldedGroupBehavior = {
|
|
3715
|
+
renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
3716
|
+
renderMessageText?:
|
|
3717
|
+
| ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
|
|
3718
|
+
| undefined;
|
|
3719
|
+
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
3720
|
+
onMemoryClick?: ((memoryId: string) => void) | undefined;
|
|
3721
|
+
onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
|
|
3722
|
+
renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
|
|
3723
|
+
resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
|
|
3724
|
+
toolRegistry: ToolRegistry;
|
|
3725
|
+
loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
|
|
3726
|
+
loadRetainedArtifact?: RetainedArtifactLoader | undefined;
|
|
3727
|
+
loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
|
|
3728
|
+
turnSummary?: TurnSummaryOptions | undefined;
|
|
3729
|
+
};
|
|
3730
|
+
|
|
3731
|
+
/** Children of a folded turn or exchange, each on the shared rail. */
|
|
3732
|
+
function renderFoldedGroups(
|
|
3733
|
+
groups: readonly TimelineGroup[],
|
|
3734
|
+
behavior: FoldedGroupBehavior,
|
|
3735
|
+
readableWork = false,
|
|
3736
|
+
) {
|
|
3737
|
+
// Second-layer chips only when there are natural multi-cluster seams:
|
|
3738
|
+
// otherwise the outer turn chip alone is enough ("N steps" wrapping one
|
|
3739
|
+
// more "N steps" was the redundant double fold).
|
|
3740
|
+
const nestClusters = foldableActivityClusterCount(groups) >= 2;
|
|
3741
|
+
return groups.map((child, index) => (
|
|
3742
|
+
<TimelineGroupRenderBoundary
|
|
3743
|
+
key={timelineGroupKey(child)}
|
|
3744
|
+
resetKeys={[
|
|
3745
|
+
child,
|
|
3746
|
+
behavior.renderMessageActions,
|
|
3747
|
+
behavior.renderMessageText,
|
|
3748
|
+
behavior.onOpenSession,
|
|
3749
|
+
behavior.onMemoryClick,
|
|
3750
|
+
behavior.onReconnect,
|
|
3751
|
+
behavior.renderAuthNeeded,
|
|
3752
|
+
behavior.resolveProviderLogo,
|
|
3753
|
+
behavior.toolRegistry,
|
|
3754
|
+
behavior.loadRetainedScreenshot,
|
|
3755
|
+
behavior.loadRetainedArtifact,
|
|
3756
|
+
behavior.loadVideoArtifactPlayback,
|
|
3757
|
+
behavior.turnSummary,
|
|
3758
|
+
]}
|
|
3759
|
+
>
|
|
3760
|
+
{readableWork ? (
|
|
3761
|
+
<div
|
|
3762
|
+
className={
|
|
3763
|
+
index > 0 && (child.kind !== "activity" || groups[index - 1]?.kind !== "activity")
|
|
3764
|
+
? "mt-3"
|
|
3765
|
+
: undefined
|
|
3766
|
+
}
|
|
3767
|
+
>
|
|
3768
|
+
<TimelineGroupView {...behavior} group={child} insideTurn startupDismissed />
|
|
3769
|
+
</div>
|
|
3770
|
+
) : (
|
|
3771
|
+
<TimelineGroupView {...behavior} group={child} insideTurn nestClusterChips={nestClusters} />
|
|
3772
|
+
)}
|
|
3773
|
+
</TimelineGroupRenderBoundary>
|
|
3774
|
+
));
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
function compactedLandmarkCount(groups: readonly TimelineGroup[]): number {
|
|
3778
|
+
return groups.filter(
|
|
3779
|
+
(group) =>
|
|
3780
|
+
group.kind === "item" &&
|
|
3781
|
+
group.item.kind === "context-compaction" &&
|
|
3782
|
+
group.item.phase === "compacted",
|
|
3783
|
+
).length;
|
|
3784
|
+
}
|
|
3785
|
+
|
|
1995
3786
|
/**
|
|
1996
3787
|
* Body under a turn/activity chip. Remount flashes are gated by the timeline
|
|
1997
3788
|
* seen-activity-id map (not by killing entrance): FoldBody used to force
|
|
@@ -2003,9 +3794,16 @@ function FoldBody({ children }: { children: ReactNode }) {
|
|
|
2003
3794
|
|
|
2004
3795
|
/** Stable left rule for turn/activity bodies — always present so settle wrap
|
|
2005
3796
|
never inserts or removes the rail chrome. */
|
|
2006
|
-
function TurnRailFrame({ children }: { children: ReactNode }) {
|
|
3797
|
+
function TurnRailFrame({ children, compact = false }: { children: ReactNode; compact?: boolean }) {
|
|
2007
3798
|
return (
|
|
2008
|
-
<div
|
|
3799
|
+
<div
|
|
3800
|
+
className={cn(
|
|
3801
|
+
"flex flex-col border-l-2 border-og-border pl-3 sm:pl-4",
|
|
3802
|
+
compact ? "gap-0.5" : "gap-4",
|
|
3803
|
+
)}
|
|
3804
|
+
>
|
|
3805
|
+
{children}
|
|
3806
|
+
</div>
|
|
2009
3807
|
);
|
|
2010
3808
|
}
|
|
2011
3809
|
|
|
@@ -2031,7 +3829,9 @@ function useLiveSettleFold(folded: boolean): boolean {
|
|
|
2031
3829
|
function timelineGroupKey(group: TimelineGroup): string {
|
|
2032
3830
|
switch (group.kind) {
|
|
2033
3831
|
case "item":
|
|
2034
|
-
return group.item.
|
|
3832
|
+
return group.item.kind === "user-message" && group.item.reconciliationKey
|
|
3833
|
+
? group.item.reconciliationKey
|
|
3834
|
+
: group.item.id;
|
|
2035
3835
|
case "activity":
|
|
2036
3836
|
return group.id;
|
|
2037
3837
|
case "turn":
|
|
@@ -2050,26 +3850,15 @@ function timelineGroupContainsAuthNeeded(group: TimelineGroup): boolean {
|
|
|
2050
3850
|
}
|
|
2051
3851
|
}
|
|
2052
3852
|
|
|
2053
|
-
/** Generated images are primary user-visible output, not incidental activity. */
|
|
2054
|
-
function timelineGroupContainsGeneratedImage(group: TimelineGroup): boolean {
|
|
2055
|
-
switch (group.kind) {
|
|
2056
|
-
case "item":
|
|
2057
|
-
return false;
|
|
2058
|
-
case "activity":
|
|
2059
|
-
return group.items.some(
|
|
2060
|
-
(item) => item.kind === "tool-call" && item.name === "generate_image",
|
|
2061
|
-
);
|
|
2062
|
-
case "turn":
|
|
2063
|
-
return group.groups.some(timelineGroupContainsGeneratedImage);
|
|
2064
|
-
}
|
|
2065
|
-
}
|
|
2066
|
-
|
|
2067
3853
|
function timelineGroupItemIds(group: TimelineGroup): string[] {
|
|
2068
3854
|
switch (group.kind) {
|
|
2069
3855
|
case "item":
|
|
2070
3856
|
return [group.item.id];
|
|
2071
3857
|
case "activity":
|
|
2072
|
-
return
|
|
3858
|
+
return [
|
|
3859
|
+
...(group.work?.details ?? []).flatMap(timelineGroupItemIds),
|
|
3860
|
+
...group.items.map((item) => item.id),
|
|
3861
|
+
];
|
|
2073
3862
|
case "turn":
|
|
2074
3863
|
return group.groups.flatMap(timelineGroupItemIds);
|
|
2075
3864
|
}
|
|
@@ -2087,7 +3876,7 @@ function isAgentProgress(next: TimelineGroup | undefined): boolean {
|
|
|
2087
3876
|
return (
|
|
2088
3877
|
next.kind === "activity" ||
|
|
2089
3878
|
next.kind === "turn" ||
|
|
2090
|
-
(next.kind === "item" && next.item.kind === "agent-message")
|
|
3879
|
+
(next.kind === "item" && next.item.kind === "agent-message" && next.item.text.trim().length > 0)
|
|
2091
3880
|
);
|
|
2092
3881
|
}
|
|
2093
3882
|
|
|
@@ -2096,7 +3885,7 @@ function isAgentProgress(next: TimelineGroup | undefined): boolean {
|
|
|
2096
3885
|
item) can sit after the ACTIVE cluster, which must never fold mid-work. */
|
|
2097
3886
|
function clusterIsSettled(group: Extract<TimelineGroup, { kind: "activity" }>): boolean {
|
|
2098
3887
|
return group.items.every((item) => {
|
|
2099
|
-
if (item.kind === "reasoning") {
|
|
3888
|
+
if (item.kind === "reasoning" || item.kind === "agent-message") {
|
|
2100
3889
|
return !item.streaming;
|
|
2101
3890
|
}
|
|
2102
3891
|
// Memory writes and fleet observations are discrete, already-settled events.
|
|
@@ -2118,11 +3907,11 @@ function foldableActivityClusterCount(groups: readonly TimelineGroup[]): number
|
|
|
2118
3907
|
return count;
|
|
2119
3908
|
}
|
|
2120
3909
|
|
|
2121
|
-
function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
|
|
3910
|
+
function flattenActivityItems(groups: readonly TimelineGroup[]): ActivityItem[] {
|
|
2122
3911
|
const items: ActivityItem[] = [];
|
|
2123
3912
|
for (const group of groups) {
|
|
2124
3913
|
if (group.kind === "activity") {
|
|
2125
|
-
items.push(...group.items);
|
|
3914
|
+
items.push(...flattenActivityItems(group.work?.details ?? []), ...group.items);
|
|
2126
3915
|
} else if (group.kind === "turn") {
|
|
2127
3916
|
items.push(...flattenActivityItems(group.groups));
|
|
2128
3917
|
}
|
|
@@ -2139,6 +3928,18 @@ function collectAgentMessageText(groups: readonly TimelineGroup[]): string {
|
|
|
2139
3928
|
if (text.length > 0) {
|
|
2140
3929
|
parts.push(text);
|
|
2141
3930
|
}
|
|
3931
|
+
} else if (group.kind === "activity") {
|
|
3932
|
+
// Commentary folded into clusters is still the assistant's own prose.
|
|
3933
|
+
const nested = collectAgentMessageText(group.work?.details ?? []);
|
|
3934
|
+
if (nested.length > 0) {
|
|
3935
|
+
parts.push(nested);
|
|
3936
|
+
}
|
|
3937
|
+
for (const item of group.items) {
|
|
3938
|
+
const text = item.kind === "agent-message" ? item.text.trim() : "";
|
|
3939
|
+
if (text.length > 0) {
|
|
3940
|
+
parts.push(text);
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
2142
3943
|
} else if (group.kind === "turn") {
|
|
2143
3944
|
const nested = collectAgentMessageText(group.groups);
|
|
2144
3945
|
if (nested.length > 0) {
|
|
@@ -2159,6 +3960,9 @@ function collectTurnIdsFromGroups(groups: readonly TimelineGroup[]): Set<string>
|
|
|
2159
3960
|
ids.add(turnId);
|
|
2160
3961
|
}
|
|
2161
3962
|
} else if (group.kind === "activity") {
|
|
3963
|
+
for (const nested of collectTurnIdsFromGroups(group.work?.details ?? [])) {
|
|
3964
|
+
ids.add(nested);
|
|
3965
|
+
}
|
|
2162
3966
|
for (const item of group.items) {
|
|
2163
3967
|
if (item.turnId) {
|
|
2164
3968
|
ids.add(item.turnId);
|
|
@@ -2188,7 +3992,7 @@ export function trailingAgentTextAfterTurn(
|
|
|
2188
3992
|
if (next?.kind === "item" && next.item.kind === "agent-message") {
|
|
2189
3993
|
const turnIds = collectTurnIdsFromGroups(group.groups);
|
|
2190
3994
|
// Degenerate body with no turnIds: do not guess — safer than lifting wrong.
|
|
2191
|
-
if (turnIds.size
|
|
3995
|
+
if (!turnIds.size) {
|
|
2192
3996
|
return undefined;
|
|
2193
3997
|
}
|
|
2194
3998
|
if (!next.item.turnId || !turnIds.has(next.item.turnId)) {
|
|
@@ -2207,7 +4011,7 @@ function collectTurnCopyText(
|
|
|
2207
4011
|
const parts = [collectAgentMessageText(groups), trailingAgentText?.trim() ?? ""].filter(
|
|
2208
4012
|
(part) => part.length > 0,
|
|
2209
4013
|
);
|
|
2210
|
-
if (parts.length
|
|
4014
|
+
if (!parts.length) {
|
|
2211
4015
|
return undefined;
|
|
2212
4016
|
}
|
|
2213
4017
|
return parts.join("\n\n");
|
|
@@ -2231,26 +4035,45 @@ function durationBetween(startedAt: string, endedAt: string): number | undefined
|
|
|
2231
4035
|
*/
|
|
2232
4036
|
export function TimelineRow({
|
|
2233
4037
|
item,
|
|
4038
|
+
renderMessageActions,
|
|
2234
4039
|
renderMessageText,
|
|
2235
4040
|
onReconnect,
|
|
4041
|
+
renderAuthNeeded,
|
|
2236
4042
|
resolveProviderLogo,
|
|
2237
4043
|
onOpenSession,
|
|
2238
4044
|
loadVideoArtifactPlayback,
|
|
2239
4045
|
}: {
|
|
2240
4046
|
item: TimelineItem;
|
|
4047
|
+
renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
2241
4048
|
renderMessageText?:
|
|
2242
4049
|
| ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
|
|
2243
4050
|
| undefined;
|
|
2244
4051
|
onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
|
|
4052
|
+
/** Host-owned inline connection setup. Return undefined to use the default recovery card. */
|
|
4053
|
+
renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
|
|
2245
4054
|
resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
|
|
2246
4055
|
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
2247
4056
|
loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
|
|
2248
4057
|
}) {
|
|
2249
4058
|
switch (item.kind) {
|
|
2250
4059
|
case "user-message":
|
|
2251
|
-
return
|
|
4060
|
+
return (
|
|
4061
|
+
<UserMessageRow
|
|
4062
|
+
item={item}
|
|
4063
|
+
renderMessageActions={renderMessageActions}
|
|
4064
|
+
renderMessageText={renderMessageText}
|
|
4065
|
+
/>
|
|
4066
|
+
);
|
|
4067
|
+
case "human-input":
|
|
4068
|
+
return <HumanInputConversationRow item={item} />;
|
|
2252
4069
|
case "agent-message":
|
|
2253
|
-
return
|
|
4070
|
+
return (
|
|
4071
|
+
<AgentMessageRow
|
|
4072
|
+
item={item}
|
|
4073
|
+
renderMessageActions={renderMessageActions}
|
|
4074
|
+
renderMessageText={renderMessageText}
|
|
4075
|
+
/>
|
|
4076
|
+
);
|
|
2254
4077
|
case "worker-completion":
|
|
2255
4078
|
return <WorkerCompletionRow item={item} onOpenSession={onOpenSession} />;
|
|
2256
4079
|
case "session-status":
|
|
@@ -2259,7 +4082,11 @@ export function TimelineRow({
|
|
|
2259
4082
|
return <GoalRow item={item} />;
|
|
2260
4083
|
case "machine-input-batch":
|
|
2261
4084
|
return (
|
|
2262
|
-
<MachineInputBatchRow
|
|
4085
|
+
<MachineInputBatchRow
|
|
4086
|
+
item={item}
|
|
4087
|
+
onOpenSession={onOpenSession}
|
|
4088
|
+
loadVideoArtifactPlayback={loadVideoArtifactPlayback}
|
|
4089
|
+
/>
|
|
2263
4090
|
);
|
|
2264
4091
|
case "notice":
|
|
2265
4092
|
return <NoticeRow item={item} />;
|
|
@@ -2267,11 +4094,13 @@ export function TimelineRow({
|
|
|
2267
4094
|
return <CompactionRow item={item} />;
|
|
2268
4095
|
case "auth-needed":
|
|
2269
4096
|
return (
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
4097
|
+
renderAuthNeeded?.(item) ?? (
|
|
4098
|
+
<AuthNeededRow
|
|
4099
|
+
item={item}
|
|
4100
|
+
onReconnect={onReconnect}
|
|
4101
|
+
resolveProviderLogo={resolveProviderLogo}
|
|
4102
|
+
/>
|
|
4103
|
+
)
|
|
2275
4104
|
);
|
|
2276
4105
|
default:
|
|
2277
4106
|
return null;
|
|
@@ -2290,11 +4119,11 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
|
|
|
2290
4119
|
const trigger =
|
|
2291
4120
|
item.trigger && item.phase !== "started" ? COMPACTION_TRIGGER_LABEL[item.trigger] : null;
|
|
2292
4121
|
const before =
|
|
2293
|
-
item.estimatedTokensBefore
|
|
4122
|
+
item.estimatedTokensBefore != null
|
|
2294
4123
|
? Math.round(item.estimatedTokensBefore).toLocaleString("en-US")
|
|
2295
4124
|
: null;
|
|
2296
4125
|
const after =
|
|
2297
|
-
item.estimatedTokensAfter
|
|
4126
|
+
item.estimatedTokensAfter != null
|
|
2298
4127
|
? Math.round(item.estimatedTokensAfter).toLocaleString("en-US")
|
|
2299
4128
|
: null;
|
|
2300
4129
|
const title =
|
|
@@ -2309,14 +4138,14 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
|
|
|
2309
4138
|
item.phase === "compacted"
|
|
2310
4139
|
? "Chat history above is unchanged"
|
|
2311
4140
|
: item.phase === "skipped"
|
|
2312
|
-
? compactionSkipSubtitle(item.skipReason)
|
|
4141
|
+
? compactionSkipSubtitle(item.skipReason, item.providerRejection)
|
|
2313
4142
|
: null;
|
|
2314
4143
|
const pill =
|
|
2315
4144
|
item.phase === "skipped" && item.skipReason === "summarization_failed"
|
|
2316
4145
|
? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
|
|
2317
4146
|
: item.phase === "started"
|
|
2318
|
-
?
|
|
2319
|
-
:
|
|
4147
|
+
? WAITING_PILL_CLASS
|
|
4148
|
+
: NEUTRAL_PILL;
|
|
2320
4149
|
return (
|
|
2321
4150
|
<div className={cn(enter && "animate-og-enter", "flex justify-center")}>
|
|
2322
4151
|
<div
|
|
@@ -2339,21 +4168,6 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
|
|
|
2339
4168
|
);
|
|
2340
4169
|
}
|
|
2341
4170
|
|
|
2342
|
-
function compactionSkipSubtitle(reason: string | null): string {
|
|
2343
|
-
switch (reason) {
|
|
2344
|
-
case "no_history":
|
|
2345
|
-
return "No active history to compact";
|
|
2346
|
-
case "replacement_not_smaller":
|
|
2347
|
-
return "Checkpoint would not reduce memory size";
|
|
2348
|
-
case "replacement_unchanged":
|
|
2349
|
-
return "Checkpoint made no progress";
|
|
2350
|
-
case "summarization_failed":
|
|
2351
|
-
return "Request it again to retry. Chat history is unchanged.";
|
|
2352
|
-
default:
|
|
2353
|
-
return "Compaction was not needed. Chat history is unchanged.";
|
|
2354
|
-
}
|
|
2355
|
-
}
|
|
2356
|
-
|
|
2357
4171
|
/** Hover-reveal clock beside the copy control (sent / finished). */
|
|
2358
4172
|
function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
|
|
2359
4173
|
return (
|
|
@@ -2372,22 +4186,17 @@ function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
|
|
|
2372
4186
|
|
|
2373
4187
|
function UserMessageRow({
|
|
2374
4188
|
item,
|
|
4189
|
+
renderMessageActions,
|
|
2375
4190
|
renderMessageText,
|
|
2376
4191
|
}: {
|
|
2377
4192
|
item: UserMessageItem;
|
|
4193
|
+
renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
2378
4194
|
renderMessageText?:
|
|
2379
4195
|
| ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
|
|
2380
4196
|
| undefined;
|
|
2381
4197
|
}) {
|
|
2382
4198
|
const enter = useEntranceAnimation();
|
|
2383
|
-
const
|
|
2384
|
-
item.delivery?.state === "sending"
|
|
2385
|
-
? "Sending…"
|
|
2386
|
-
: item.delivery?.state === "queued"
|
|
2387
|
-
? "Queued"
|
|
2388
|
-
: item.delivery?.state === "failed"
|
|
2389
|
-
? "Not sent"
|
|
2390
|
-
: null;
|
|
4199
|
+
const deliveryFailed = item.delivery?.state === "failed";
|
|
2391
4200
|
return (
|
|
2392
4201
|
<div className={cn(enter && "animate-og-enter", "flex justify-end")}>
|
|
2393
4202
|
<div className="flex max-w-[85%] min-w-0 flex-col items-end gap-1">
|
|
@@ -2400,11 +4209,19 @@ function UserMessageRow({
|
|
|
2400
4209
|
}
|
|
2401
4210
|
label="Copy message"
|
|
2402
4211
|
className="w-fit max-w-full min-w-0"
|
|
2403
|
-
trailing={
|
|
4212
|
+
trailing={
|
|
4213
|
+
<>
|
|
4214
|
+
{renderMessageActions?.(item)}
|
|
4215
|
+
<MessageFooterTime occurredAt={item.occurredAt} />
|
|
4216
|
+
</>
|
|
4217
|
+
}
|
|
2404
4218
|
>
|
|
2405
|
-
<div className=
|
|
4219
|
+
<div className={MESSAGE_BUBBLE_CLASS}>
|
|
2406
4220
|
{item.text ? (
|
|
2407
|
-
<div
|
|
4221
|
+
<div
|
|
4222
|
+
data-og-search-item={item.id}
|
|
4223
|
+
data-og-annotation-source-key={item.annotationSource?.eventId}
|
|
4224
|
+
>
|
|
2408
4225
|
{renderMessageText ? (
|
|
2409
4226
|
renderMessageText(item.text, item)
|
|
2410
4227
|
) : (
|
|
@@ -2415,34 +4232,53 @@ function UserMessageRow({
|
|
|
2415
4232
|
</div>
|
|
2416
4233
|
) : null}
|
|
2417
4234
|
{(item.annotations?.length ?? 0) > 0 ? (
|
|
2418
|
-
<
|
|
4235
|
+
<TimelineAnnotationCards
|
|
2419
4236
|
annotations={item.annotations ?? []}
|
|
2420
4237
|
className={item.text ? "mt-2" : undefined}
|
|
2421
4238
|
/>
|
|
2422
4239
|
) : null}
|
|
2423
4240
|
</div>
|
|
2424
4241
|
</CopyHoverFrame>
|
|
2425
|
-
{
|
|
2426
|
-
<div
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
4242
|
+
{deliveryFailed ? (
|
|
4243
|
+
<div
|
|
4244
|
+
role="status"
|
|
4245
|
+
className="flex max-w-full flex-col items-start gap-1 px-1 text-og-xs text-og-status-failed"
|
|
4246
|
+
>
|
|
4247
|
+
<span className="flex min-w-0 items-start gap-1">
|
|
4248
|
+
<TriangleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
|
4249
|
+
<span className="min-w-0 break-words">
|
|
4250
|
+
{item.delivery?.error || "Message not sent"}
|
|
4251
|
+
</span>
|
|
4252
|
+
</span>
|
|
4253
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
4254
|
+
{item.delivery?.onEdit ? (
|
|
4255
|
+
<button
|
|
4256
|
+
type="button"
|
|
4257
|
+
className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
|
|
4258
|
+
onClick={item.delivery.onEdit}
|
|
4259
|
+
>
|
|
4260
|
+
Edit message
|
|
4261
|
+
</button>
|
|
4262
|
+
) : null}
|
|
4263
|
+
{item.delivery?.onRetry ? (
|
|
4264
|
+
<button
|
|
4265
|
+
type="button"
|
|
4266
|
+
className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
|
|
4267
|
+
onClick={item.delivery.onRetry}
|
|
4268
|
+
>
|
|
4269
|
+
Retry
|
|
4270
|
+
</button>
|
|
4271
|
+
) : null}
|
|
4272
|
+
{item.delivery?.onRemove ? (
|
|
4273
|
+
<button
|
|
4274
|
+
type="button"
|
|
4275
|
+
className="font-medium text-og-fg-subtle underline decoration-og-border underline-offset-2 hover:text-og-fg"
|
|
4276
|
+
onClick={item.delivery.onRemove}
|
|
4277
|
+
>
|
|
4278
|
+
Remove
|
|
4279
|
+
</button>
|
|
4280
|
+
) : null}
|
|
4281
|
+
</div>
|
|
2446
4282
|
</div>
|
|
2447
4283
|
) : null}
|
|
2448
4284
|
</div>
|
|
@@ -2450,11 +4286,146 @@ function UserMessageRow({
|
|
|
2450
4286
|
);
|
|
2451
4287
|
}
|
|
2452
4288
|
|
|
4289
|
+
function HumanInputConversationRow({ item }: { item: HumanInputItem }) {
|
|
4290
|
+
const enter = useEntranceAnimation();
|
|
4291
|
+
const multipleQuestions = Math.max(item.questions.length, item.answers.length) > 1;
|
|
4292
|
+
const questionNumberById = new Map(
|
|
4293
|
+
item.questions.map((question, index) => [question.id, index + 1]),
|
|
4294
|
+
);
|
|
4295
|
+
const settledLabel =
|
|
4296
|
+
item.response.outcome === "answered"
|
|
4297
|
+
? "You answered"
|
|
4298
|
+
: item.response.outcome === "skipped"
|
|
4299
|
+
? "Skipped"
|
|
4300
|
+
: item.response.outcome === "expired"
|
|
4301
|
+
? "Expired"
|
|
4302
|
+
: "Cancelled";
|
|
4303
|
+
const copyText = humanInputConversationCopyText(item, settledLabel);
|
|
4304
|
+
|
|
4305
|
+
return (
|
|
4306
|
+
<div
|
|
4307
|
+
className={cn(enter && "animate-og-enter", "flex w-full flex-col gap-2.5")}
|
|
4308
|
+
data-human-input-history={item.requestId}
|
|
4309
|
+
>
|
|
4310
|
+
<div className="flex max-w-[90%] items-start gap-3 rounded-og-lg rounded-bl-og-xs border border-og-border bg-og-surface-1 px-3.5 py-3 sm:max-w-[82%]">
|
|
4311
|
+
<span className="mt-0.5 inline-flex size-8 shrink-0 items-center justify-center rounded-og-md bg-og-status-waiting/10 text-og-status-waiting">
|
|
4312
|
+
<MessageCircleQuestionIcon aria-hidden="true" className="size-4" />
|
|
4313
|
+
</span>
|
|
4314
|
+
<div className="min-w-0 flex-1">
|
|
4315
|
+
<p className="text-og-xs font-medium text-og-fg-subtle">Agent asked</p>
|
|
4316
|
+
<div className="mt-1.5 space-y-3">
|
|
4317
|
+
{item.questions.length > 0 ? (
|
|
4318
|
+
item.questions.map((question, index) => (
|
|
4319
|
+
<div key={question.id}>
|
|
4320
|
+
{question.label ? (
|
|
4321
|
+
<p className="text-og-sm font-semibold text-og-fg">
|
|
4322
|
+
{multipleQuestions ? (
|
|
4323
|
+
<span className="mr-1.5 tabular-nums text-og-fg-muted">{index + 1}.</span>
|
|
4324
|
+
) : null}
|
|
4325
|
+
{multipleQuestions ? " " : null}
|
|
4326
|
+
{question.label}
|
|
4327
|
+
</p>
|
|
4328
|
+
) : null}
|
|
4329
|
+
<p
|
|
4330
|
+
className={cn(
|
|
4331
|
+
"text-og-md leading-6 text-og-fg",
|
|
4332
|
+
question.label && "mt-0.5 text-og-sm text-og-fg-muted",
|
|
4333
|
+
)}
|
|
4334
|
+
>
|
|
4335
|
+
{!question.label && multipleQuestions ? (
|
|
4336
|
+
<span className="mr-1.5 tabular-nums text-og-fg-muted">{index + 1}.</span>
|
|
4337
|
+
) : null}
|
|
4338
|
+
{!question.label && multipleQuestions ? " " : null}
|
|
4339
|
+
{question.prompt}
|
|
4340
|
+
</p>
|
|
4341
|
+
</div>
|
|
4342
|
+
))
|
|
4343
|
+
) : (
|
|
4344
|
+
<p className="text-og-sm text-og-fg-muted">The agent requested structured input.</p>
|
|
4345
|
+
)}
|
|
4346
|
+
</div>
|
|
4347
|
+
</div>
|
|
4348
|
+
</div>
|
|
4349
|
+
|
|
4350
|
+
<div className="flex justify-end">
|
|
4351
|
+
<CopyHoverFrame
|
|
4352
|
+
copyText={copyText}
|
|
4353
|
+
label="Copy answer"
|
|
4354
|
+
className="w-fit max-w-[90%] min-w-0 sm:max-w-[82%]"
|
|
4355
|
+
trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
|
|
4356
|
+
>
|
|
4357
|
+
<div className={MESSAGE_BUBBLE_CLASS}>
|
|
4358
|
+
<p className="text-og-xs font-medium text-og-fg-subtle">{settledLabel}</p>
|
|
4359
|
+
{item.response.outcome === "answered" ? (
|
|
4360
|
+
<div className="mt-1.5 space-y-3">
|
|
4361
|
+
{item.answers.length > 0 ? (
|
|
4362
|
+
item.answers.map((answer, answerIndex) => (
|
|
4363
|
+
<div key={answer.questionId}>
|
|
4364
|
+
{multipleQuestions ? (
|
|
4365
|
+
<p className="text-og-sm font-semibold text-og-fg">
|
|
4366
|
+
<span className="mr-1.5 tabular-nums text-og-fg-muted">
|
|
4367
|
+
{questionNumberById.get(answer.questionId) ?? answerIndex + 1}.
|
|
4368
|
+
</span>{" "}
|
|
4369
|
+
{answer.label}
|
|
4370
|
+
</p>
|
|
4371
|
+
) : null}
|
|
4372
|
+
<div className={cn("text-og-md text-og-fg", multipleQuestions && "mt-0.5")}>
|
|
4373
|
+
{answer.values.length > 1 ? (
|
|
4374
|
+
<ul className="list-disc space-y-0.5 pl-5">
|
|
4375
|
+
{answer.values.map((value) => (
|
|
4376
|
+
<li key={`${answer.questionId}-${value}`}>{value}</li>
|
|
4377
|
+
))}
|
|
4378
|
+
</ul>
|
|
4379
|
+
) : (
|
|
4380
|
+
<p>{answer.values[0] || "Answered"}</p>
|
|
4381
|
+
)}
|
|
4382
|
+
</div>
|
|
4383
|
+
</div>
|
|
4384
|
+
))
|
|
4385
|
+
) : (
|
|
4386
|
+
<p>Answered</p>
|
|
4387
|
+
)}
|
|
4388
|
+
</div>
|
|
4389
|
+
) : null}
|
|
4390
|
+
</div>
|
|
4391
|
+
</CopyHoverFrame>
|
|
4392
|
+
</div>
|
|
4393
|
+
</div>
|
|
4394
|
+
);
|
|
4395
|
+
}
|
|
4396
|
+
|
|
4397
|
+
function humanInputConversationCopyText(item: HumanInputItem, settledLabel: string): string {
|
|
4398
|
+
const questions = item.questions
|
|
4399
|
+
.map(
|
|
4400
|
+
(question, index) =>
|
|
4401
|
+
`${item.questions.length > 1 ? `${index + 1}. ` : ""}${question.label || "Question"}: ${question.prompt}`,
|
|
4402
|
+
)
|
|
4403
|
+
.join("\n\n");
|
|
4404
|
+
const questionNumberById = new Map(
|
|
4405
|
+
item.questions.map((question, index) => [question.id, index + 1]),
|
|
4406
|
+
);
|
|
4407
|
+
const answers = item.answers
|
|
4408
|
+
.map(
|
|
4409
|
+
(answer, index) =>
|
|
4410
|
+
`${
|
|
4411
|
+
item.questions.length > 1
|
|
4412
|
+
? `${questionNumberById.get(answer.questionId) ?? index + 1}. `
|
|
4413
|
+
: ""
|
|
4414
|
+
}${answer.label}: ${answer.values.join(", ") || "Answered"}`,
|
|
4415
|
+
)
|
|
4416
|
+
.join("\n\n");
|
|
4417
|
+
return [questions, `${settledLabel}${answers ? `\n\n${answers}` : ""}`]
|
|
4418
|
+
.filter(Boolean)
|
|
4419
|
+
.join("\n\n");
|
|
4420
|
+
}
|
|
4421
|
+
|
|
2453
4422
|
function AgentMessageRow({
|
|
2454
4423
|
item,
|
|
4424
|
+
renderMessageActions,
|
|
2455
4425
|
renderMessageText,
|
|
2456
4426
|
}: {
|
|
2457
4427
|
item: AgentMessageItem;
|
|
4428
|
+
renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
2458
4429
|
renderMessageText?:
|
|
2459
4430
|
| ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
|
|
2460
4431
|
| undefined;
|
|
@@ -2470,17 +4441,28 @@ function AgentMessageRow({
|
|
|
2470
4441
|
// While streaming, copy is still useful (current text) but keep chrome calm —
|
|
2471
4442
|
// stamp only after the message finishes (occurredAt tracks completion).
|
|
2472
4443
|
return (
|
|
2473
|
-
<
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
4444
|
+
<CopyHoverFrame
|
|
4445
|
+
copyText={item.text}
|
|
4446
|
+
label="Copy message"
|
|
4447
|
+
align="start"
|
|
4448
|
+
className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}
|
|
4449
|
+
trailing={
|
|
4450
|
+
item.streaming ? null : (
|
|
4451
|
+
<>
|
|
4452
|
+
{renderMessageActions?.(item)}
|
|
4453
|
+
<MessageFooterTime occurredAt={item.occurredAt} />
|
|
4454
|
+
</>
|
|
4455
|
+
)
|
|
4456
|
+
}
|
|
4457
|
+
>
|
|
4458
|
+
<div
|
|
4459
|
+
data-og-wide-table-message=""
|
|
4460
|
+
data-og-search-item={item.id}
|
|
4461
|
+
data-og-annotation-source-key={item.annotationSource?.eventId}
|
|
2480
4462
|
>
|
|
2481
4463
|
{body}
|
|
2482
|
-
</
|
|
2483
|
-
</
|
|
4464
|
+
</div>
|
|
4465
|
+
</CopyHoverFrame>
|
|
2484
4466
|
);
|
|
2485
4467
|
}
|
|
2486
4468
|
|
|
@@ -2555,9 +4537,7 @@ function WorkerCompletionRow({
|
|
|
2555
4537
|
// payloads can carry a leftover pausedReason/rationale from earlier in the
|
|
2556
4538
|
// worker's life, and a "Worker completed" card must not show a pause section.
|
|
2557
4539
|
const showPausedReason =
|
|
2558
|
-
item.childStatus !== "failed" &&
|
|
2559
|
-
item.goalStatus === "paused" &&
|
|
2560
|
-
Boolean(item.pausedReason?.trim());
|
|
4540
|
+
item.childStatus !== "failed" && item.goalStatus === "paused" && !!item.pausedReason?.trim();
|
|
2561
4541
|
const details: { label: string; value: string; muted?: boolean }[] = [
|
|
2562
4542
|
...(item.text.trim() ? [{ label: "Report", value: item.text.trim() }] : []),
|
|
2563
4543
|
...(item.evidence?.trim()
|
|
@@ -2640,7 +4620,11 @@ function WorkerCompletionRow({
|
|
|
2640
4620
|
);
|
|
2641
4621
|
}
|
|
2642
4622
|
|
|
2643
|
-
function SessionStatusRow({
|
|
4623
|
+
function SessionStatusRow({
|
|
4624
|
+
item,
|
|
4625
|
+
}: {
|
|
4626
|
+
item: { status: SessionStatus; occurredAt: string; resolvedAt?: string };
|
|
4627
|
+
}) {
|
|
2644
4628
|
const enter = useEntranceAnimation();
|
|
2645
4629
|
const meta = SESSION_STATUS_META[item.status];
|
|
2646
4630
|
return (
|
|
@@ -2653,8 +4637,9 @@ function SessionStatusRow({ item }: { item: { status: SessionStatus; occurredAt:
|
|
|
2653
4637
|
>
|
|
2654
4638
|
<span className="h-px flex-1 bg-og-border" />
|
|
2655
4639
|
<span className="inline-flex items-center gap-1.5">
|
|
2656
|
-
<StatusDot status={item.status} className="size-1" />
|
|
2657
|
-
{meta.label.toLowerCase()} ·
|
|
4640
|
+
{item.resolvedAt ? null : <StatusDot status={item.status} className="size-1" />}
|
|
4641
|
+
{item.resolvedAt ? "work resumed" : meta.label.toLowerCase()} ·{" "}
|
|
4642
|
+
{formatRelativeTime(item.resolvedAt ?? item.occurredAt)}
|
|
2658
4643
|
</span>
|
|
2659
4644
|
<span className="h-px flex-1 bg-og-border" />
|
|
2660
4645
|
</div>
|
|
@@ -2682,36 +4667,46 @@ type GoalMeta = { label: string; pill: string; icon: ComponentType<{ className?:
|
|
|
2682
4667
|
|
|
2683
4668
|
const NEUTRAL_PILL = "border-og-border bg-og-surface-1 text-og-fg-muted";
|
|
2684
4669
|
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
}
|
|
4670
|
+
// Shared production chunks can be cyclic. Resolve icon bindings during render,
|
|
4671
|
+
// after their modules initialize, rather than permanently capturing undefined.
|
|
4672
|
+
function goalMeta(action: GoalItem["action"]): GoalMeta {
|
|
4673
|
+
const metadata: Record<GoalItem["action"], GoalMeta> = {
|
|
4674
|
+
set: {
|
|
4675
|
+
label: "Goal set",
|
|
4676
|
+
pill: "border-og-accent/30 bg-og-accent/10 text-og-accent",
|
|
4677
|
+
icon: TargetIcon,
|
|
4678
|
+
},
|
|
4679
|
+
updated: { label: "Goal updated", pill: NEUTRAL_PILL, icon: PencilLineIcon },
|
|
4680
|
+
completed: {
|
|
4681
|
+
label: "Goal completed",
|
|
4682
|
+
pill: "border-og-status-idle/30 bg-og-status-idle/10 text-og-status-idle",
|
|
4683
|
+
icon: CheckIcon,
|
|
4684
|
+
},
|
|
4685
|
+
paused: {
|
|
4686
|
+
label: "Goal paused",
|
|
4687
|
+
pill: WAITING_PILL_CLASS,
|
|
4688
|
+
icon: PauseIcon,
|
|
4689
|
+
},
|
|
4690
|
+
resumed: { label: "Goal resumed", pill: NEUTRAL_PILL, icon: PlayIcon },
|
|
4691
|
+
cleared: { label: "Goal cleared", pill: NEUTRAL_PILL, icon: Trash2Icon },
|
|
4692
|
+
held: {
|
|
4693
|
+
label: "Goal held",
|
|
4694
|
+
pill: WAITING_PILL_CLASS,
|
|
4695
|
+
icon: PauseCircleIcon,
|
|
4696
|
+
},
|
|
4697
|
+
continuation: { label: "Continuing toward the goal", pill: NEUTRAL_PILL, icon: ArrowRightIcon },
|
|
4698
|
+
};
|
|
4699
|
+
return metadata[action];
|
|
4700
|
+
}
|
|
2706
4701
|
|
|
2707
4702
|
/**
|
|
2708
4703
|
* A goal landmark pill. Resolves its label, accent/tone, and glyph from
|
|
2709
|
-
* {@link
|
|
4704
|
+
* {@link goalMeta} so all six actions are visually distinguishable while the
|
|
2710
4705
|
* palette stays restrained — see that table for the per-action rationale.
|
|
2711
4706
|
*/
|
|
2712
4707
|
function GoalRow({ item }: { item: GoalItem }) {
|
|
2713
4708
|
const enter = useEntranceAnimation();
|
|
2714
|
-
const { label, pill, icon: Icon } =
|
|
4709
|
+
const { label, pill, icon: Icon } = goalMeta(item.action);
|
|
2715
4710
|
return (
|
|
2716
4711
|
<div className={cn(enter && "animate-og-enter", "flex justify-center")}>
|
|
2717
4712
|
<span
|
|
@@ -2732,9 +4727,11 @@ function GoalRow({ item }: { item: GoalItem }) {
|
|
|
2732
4727
|
|
|
2733
4728
|
function MachineInputBatchRow({
|
|
2734
4729
|
item,
|
|
4730
|
+
onOpenSession,
|
|
2735
4731
|
loadVideoArtifactPlayback,
|
|
2736
4732
|
}: {
|
|
2737
4733
|
item: MachineInputBatchItem;
|
|
4734
|
+
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
2738
4735
|
loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
|
|
2739
4736
|
}) {
|
|
2740
4737
|
const enter = useEntranceAnimation();
|
|
@@ -2750,11 +4747,15 @@ function MachineInputBatchRow({
|
|
|
2750
4747
|
}
|
|
2751
4748
|
const singleSummary = single ? cleanMachineInputSummary(single.summary) : "";
|
|
2752
4749
|
const showCollapsedSummary =
|
|
2753
|
-
single != null && machineInputSummaryIsUseful(single.kind, singleSummary);
|
|
4750
|
+
!item.compact && single != null && machineInputSummaryIsUseful(single.kind, singleSummary);
|
|
2754
4751
|
|
|
2755
4752
|
return (
|
|
2756
4753
|
<div className={cn(enter && "animate-og-enter", "flex flex-col items-center gap-1.5")}>
|
|
2757
|
-
<details
|
|
4754
|
+
<details
|
|
4755
|
+
className="group w-full max-w-full"
|
|
4756
|
+
data-og-machine-input-batch=""
|
|
4757
|
+
title={`Received ${formatClockTime(item.occurredAt)}`}
|
|
4758
|
+
>
|
|
2758
4759
|
<summary className="flex cursor-pointer list-none justify-center [&::-webkit-details-marker]:hidden">
|
|
2759
4760
|
<span
|
|
2760
4761
|
className={cn(
|
|
@@ -2770,10 +4771,14 @@ function MachineInputBatchRow({
|
|
|
2770
4771
|
</span>
|
|
2771
4772
|
</summary>
|
|
2772
4773
|
<div className="mx-auto mt-2 w-full max-w-lg space-y-2 border-t border-og-border/50 pt-2">
|
|
4774
|
+
<p className="text-og-xs text-og-fg-subtle">
|
|
4775
|
+
Received <time dateTime={item.occurredAt}>{formatClockTime(item.occurredAt)}</time>
|
|
4776
|
+
</p>
|
|
2773
4777
|
{item.members.map((member) => (
|
|
2774
4778
|
<MachineInputRow
|
|
2775
4779
|
key={member.id}
|
|
2776
4780
|
member={member}
|
|
4781
|
+
onOpenSession={onOpenSession}
|
|
2777
4782
|
loadVideoArtifactPlayback={loadVideoArtifactPlayback}
|
|
2778
4783
|
/>
|
|
2779
4784
|
))}
|
|
@@ -2790,9 +4795,11 @@ function MachineInputBatchRow({
|
|
|
2790
4795
|
|
|
2791
4796
|
function MachineInputRow({
|
|
2792
4797
|
member,
|
|
4798
|
+
onOpenSession,
|
|
2793
4799
|
loadVideoArtifactPlayback,
|
|
2794
4800
|
}: {
|
|
2795
4801
|
member: MachineInputBatchItem["members"][number];
|
|
4802
|
+
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
2796
4803
|
loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
|
|
2797
4804
|
}) {
|
|
2798
4805
|
if (member.kind === "media_generation_result" && member.result) {
|
|
@@ -2819,6 +4826,11 @@ function MachineInputRow({
|
|
|
2819
4826
|
{truncate(summary, 320)}
|
|
2820
4827
|
</p>
|
|
2821
4828
|
) : null}
|
|
4829
|
+
<ChildSessionLink
|
|
4830
|
+
kind={member.kind}
|
|
4831
|
+
sourceId={member.sourceId}
|
|
4832
|
+
onOpenSession={onOpenSession}
|
|
4833
|
+
/>
|
|
2822
4834
|
</div>
|
|
2823
4835
|
</div>
|
|
2824
4836
|
);
|
|
@@ -2892,12 +4904,34 @@ function formatVideoDuration(seconds: number): string {
|
|
|
2892
4904
|
|
|
2893
4905
|
function NoticeRow({ item }: { item: NoticeItem }) {
|
|
2894
4906
|
const enter = useEntranceAnimation();
|
|
4907
|
+
if (item.allowance) return <AllowanceExhaustedRow refusal={item.allowance} />;
|
|
4908
|
+
if (item.recordedOutcome) {
|
|
4909
|
+
return (
|
|
4910
|
+
<details
|
|
4911
|
+
className="group text-og-sm text-og-fg-muted"
|
|
4912
|
+
role="note"
|
|
4913
|
+
data-og-recorded-outcome="wait"
|
|
4914
|
+
>
|
|
4915
|
+
<summary className="flex cursor-pointer list-none items-center gap-2 py-1 [&::-webkit-details-marker]:hidden">
|
|
4916
|
+
<ChevronRightIcon
|
|
4917
|
+
aria-hidden
|
|
4918
|
+
className="size-3.5 transition-transform group-open:rotate-90"
|
|
4919
|
+
/>
|
|
4920
|
+
<span title={new Date(item.occurredAt).toLocaleString()}>
|
|
4921
|
+
{recordedWaitSummary(item)}
|
|
4922
|
+
</span>
|
|
4923
|
+
</summary>
|
|
4924
|
+
<p className="mt-1 whitespace-pre-wrap break-words pl-5 text-og-fg-muted">{item.text}</p>
|
|
4925
|
+
</details>
|
|
4926
|
+
);
|
|
4927
|
+
}
|
|
4928
|
+
const resolvedApproval = Boolean(item.resolvedAt && item.text.startsWith("Approval needed"));
|
|
2895
4929
|
const tone =
|
|
2896
4930
|
item.tone === "failed"
|
|
2897
4931
|
? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
|
|
2898
|
-
: item.tone === "waiting"
|
|
2899
|
-
?
|
|
2900
|
-
:
|
|
4932
|
+
: item.tone === "waiting" && !item.resolvedAt
|
|
4933
|
+
? WAITING_PILL_CLASS
|
|
4934
|
+
: NEUTRAL_PILL;
|
|
2901
4935
|
return (
|
|
2902
4936
|
<div
|
|
2903
4937
|
className={cn(
|
|
@@ -2907,15 +4941,21 @@ function NoticeRow({ item }: { item: NoticeItem }) {
|
|
|
2907
4941
|
)}
|
|
2908
4942
|
role="status"
|
|
2909
4943
|
>
|
|
2910
|
-
|
|
2911
|
-
className=
|
|
2912
|
-
|
|
4944
|
+
{resolvedApproval ? (
|
|
4945
|
+
<CheckIcon className="mt-0.5 size-4 shrink-0 opacity-70" aria-hidden="true" />
|
|
4946
|
+
) : (
|
|
4947
|
+
<TriangleAlertIcon
|
|
4948
|
+
className={cn("mt-0.5 size-4 shrink-0", item.tone === "cancelled" && "opacity-60")}
|
|
4949
|
+
/>
|
|
4950
|
+
)}
|
|
2913
4951
|
<div className="min-w-0 flex-1">
|
|
2914
|
-
<span className="whitespace-pre-wrap break-words">
|
|
4952
|
+
<span className="whitespace-pre-wrap break-words">
|
|
4953
|
+
{resolvedApproval ? "You responded to this approval." : item.text}
|
|
4954
|
+
</span>
|
|
2915
4955
|
{item.details ? (
|
|
2916
4956
|
<details className="mt-2 text-og-control">
|
|
2917
4957
|
<summary className="cursor-pointer font-medium">{item.details.label}</summary>
|
|
2918
|
-
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-og-sm bg-
|
|
4958
|
+
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-og-sm bg-og-fg/5 p-2 font-mono">
|
|
2919
4959
|
{JSON.stringify(item.details.value, null, 2)}
|
|
2920
4960
|
</pre>
|
|
2921
4961
|
</details>
|
|
@@ -2935,6 +4975,41 @@ function NoticeRow({ item }: { item: NoticeItem }) {
|
|
|
2935
4975
|
);
|
|
2936
4976
|
}
|
|
2937
4977
|
|
|
4978
|
+
/**
|
|
4979
|
+
* "Waited for 2 agents · 3m 5s" once later input ended the wait, otherwise
|
|
4980
|
+
* "Waiting for 2 agents · since 10:32" (with the date on a later day). The
|
|
4981
|
+
* reason stays behind the disclosure.
|
|
4982
|
+
*/
|
|
4983
|
+
function recordedWaitSummary(item: NoticeItem): ReactNode {
|
|
4984
|
+
const agents = item.waitingAgents ?? 0;
|
|
4985
|
+
const target = agents > 0 ? ` for ${agents} ${agents === 1 ? "agent" : "agents"}` : "";
|
|
4986
|
+
const waitedMs = item.waitEndedAt
|
|
4987
|
+
? durationBetween(item.occurredAt, item.waitEndedAt)
|
|
4988
|
+
: undefined;
|
|
4989
|
+
if (waitedMs !== undefined) {
|
|
4990
|
+
return (
|
|
4991
|
+
<>
|
|
4992
|
+
Waited{target} ·{" "}
|
|
4993
|
+
<time dateTime={`PT${Math.floor(waitedMs / 1000)}S`}>{formatElapsed(waitedMs)}</time>
|
|
4994
|
+
</>
|
|
4995
|
+
);
|
|
4996
|
+
}
|
|
4997
|
+
// An open wait is historical once its day has passed; keep its date visible.
|
|
4998
|
+
const started = new Date(item.occurredAt);
|
|
4999
|
+
const sameDay = started.toDateString() === new Date().toDateString();
|
|
5000
|
+
return (
|
|
5001
|
+
<>
|
|
5002
|
+
{item.waitEndedAt ? "Waited" : "Waiting"}
|
|
5003
|
+
{target} · since{" "}
|
|
5004
|
+
<time dateTime={item.occurredAt}>
|
|
5005
|
+
{sameDay
|
|
5006
|
+
? formatClockTime(item.occurredAt)
|
|
5007
|
+
: started.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}
|
|
5008
|
+
</time>
|
|
5009
|
+
</>
|
|
5010
|
+
);
|
|
5011
|
+
}
|
|
5012
|
+
|
|
2938
5013
|
/**
|
|
2939
5014
|
* The inline connection-recovery card: missing or lapsed access surfaces as a
|
|
2940
5015
|
* calm, tappable affordance instead of a raw provider-domain error. The `reason`
|
|
@@ -2981,6 +5056,7 @@ function AuthNeededRow({
|
|
|
2981
5056
|
? `${provider} tools unavailable`
|
|
2982
5057
|
: `${actionLabel} ${provider}`;
|
|
2983
5058
|
const reasonLine = recommendation?.rationale ?? authReasonLine(item.reason);
|
|
5059
|
+
const hostAuthorizationUrl = item.authoritySource === "host" ? item.authorizationUrl : null;
|
|
2984
5060
|
|
|
2985
5061
|
const start = async () => {
|
|
2986
5062
|
if (!onReconnect || busy) {
|
|
@@ -3023,27 +5099,40 @@ function AuthNeededRow({
|
|
|
3023
5099
|
) : null}
|
|
3024
5100
|
</div>
|
|
3025
5101
|
</div>
|
|
3026
|
-
{
|
|
5102
|
+
{hostAuthorizationUrl ? (
|
|
5103
|
+
<a
|
|
5104
|
+
href={hostAuthorizationUrl}
|
|
5105
|
+
rel="noreferrer"
|
|
5106
|
+
target="_blank"
|
|
5107
|
+
className={cn(
|
|
5108
|
+
PRIMARY_ACTION_CLASS,
|
|
5109
|
+
"transition-colors hover:bg-og-primary-hover pointer-coarse:min-h-9",
|
|
5110
|
+
)}
|
|
5111
|
+
>
|
|
5112
|
+
<RefreshCwIcon className="size-3.5" aria-hidden />
|
|
5113
|
+
{actionLabel}
|
|
5114
|
+
</a>
|
|
5115
|
+
) : item.authoritySource !== "host" && !unavailable && onReconnect ? (
|
|
3027
5116
|
<button
|
|
3028
5117
|
type="button"
|
|
3029
5118
|
onClick={() => void start()}
|
|
3030
5119
|
disabled={busy}
|
|
3031
5120
|
className={cn(
|
|
3032
|
-
|
|
3033
|
-
"transition-colors hover:bg-og-
|
|
5121
|
+
PRIMARY_ACTION_CLASS,
|
|
5122
|
+
"transition-colors hover:bg-og-primary-hover disabled:opacity-50 pointer-coarse:min-h-9",
|
|
3034
5123
|
)}
|
|
3035
5124
|
>
|
|
3036
5125
|
<RefreshCwIcon className={cn("size-3.5", busy && "animate-og-spin")} aria-hidden />
|
|
3037
5126
|
{busy ? "Opening…" : actionLabel}
|
|
3038
5127
|
</button>
|
|
3039
|
-
) : !unavailable && item.authorizationUrl ? (
|
|
5128
|
+
) : item.authoritySource !== "host" && !unavailable && item.authorizationUrl ? (
|
|
3040
5129
|
<a
|
|
3041
5130
|
href={item.authorizationUrl}
|
|
3042
5131
|
rel="noreferrer"
|
|
3043
5132
|
target="_blank"
|
|
3044
5133
|
className={cn(
|
|
3045
|
-
|
|
3046
|
-
"transition-colors hover:bg-og-
|
|
5134
|
+
PRIMARY_ACTION_CLASS,
|
|
5135
|
+
"transition-colors hover:bg-og-primary-hover pointer-coarse:min-h-9",
|
|
3047
5136
|
)}
|
|
3048
5137
|
>
|
|
3049
5138
|
<RefreshCwIcon className="size-3.5" aria-hidden />
|
|
@@ -3105,7 +5194,7 @@ function AuthProviderLogo({ src, label }: { src: string | null; label: string })
|
|
|
3105
5194
|
`capabilityMonogram` so the reconnect tile reads like every other logo tile. */
|
|
3106
5195
|
function monogram(label: string): string {
|
|
3107
5196
|
const words = label.trim().split(/\s+/).filter(Boolean);
|
|
3108
|
-
if (words.length
|
|
5197
|
+
if (!words.length) {
|
|
3109
5198
|
return "?";
|
|
3110
5199
|
}
|
|
3111
5200
|
if (words.length === 1) {
|