@opengeni/react 1.0.1 → 1.0.2-canary.37191796234001
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 +651 -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-GTR5XQ7X.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-5UQPNLIO.js → chunk-23J2ZNXT.js} +4117 -3896
- package/dist/chunk-23J2ZNXT.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-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-CR7U7HAR.js +453 -0
- package/dist/chunk-CR7U7HAR.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-E3G7SAYZ.js +3124 -0
- package/dist/chunk-E3G7SAYZ.js.map +1 -0
- package/dist/chunk-ELCE5SXV.js +359 -0
- package/dist/chunk-ELCE5SXV.js.map +1 -0
- package/dist/chunk-ERGYJIBB.js +21 -0
- package/dist/chunk-ERGYJIBB.js.map +1 -0
- package/dist/chunk-FFA2OVOV.js +319 -0
- package/dist/chunk-FFA2OVOV.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-6KLX2RH5.js → chunk-H2NJQORO.js} +1069 -79
- package/dist/chunk-H2NJQORO.js.map +1 -0
- package/dist/chunk-I7IWRQLN.js +1608 -0
- package/dist/chunk-I7IWRQLN.js.map +1 -0
- package/dist/{chunk-HA46CYQK.js → chunk-IQUCRAT7.js} +792 -537
- package/dist/chunk-IQUCRAT7.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-JFNEV4LU.js +2733 -0
- package/dist/chunk-JFNEV4LU.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-C5ZPSSS5.js → chunk-NEXLLTP4.js} +2 -1
- package/dist/{chunk-C5ZPSSS5.js.map → chunk-NEXLLTP4.js.map} +1 -1
- package/dist/chunk-NZUI76EM.js +371 -0
- package/dist/chunk-NZUI76EM.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-IGC6A2ZI.js → chunk-Q5QBEZIQ.js} +777 -246
- package/dist/chunk-Q5QBEZIQ.js.map +1 -0
- package/dist/chunk-RHREZ7BO.js +11 -0
- package/dist/chunk-RHREZ7BO.js.map +1 -0
- package/dist/chunk-SCOTPE2I.js +589 -0
- package/dist/chunk-SCOTPE2I.js.map +1 -0
- package/dist/{chunk-B6WPZSTT.js → chunk-SJZDKVW2.js} +1331 -1163
- package/dist/chunk-SJZDKVW2.js.map +1 -0
- package/dist/chunk-TMJRG4YC.js +12803 -0
- package/dist/chunk-TMJRG4YC.js.map +1 -0
- 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-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-XOAMRDDK.js +481 -0
- package/dist/chunk-XOAMRDDK.js.map +1 -0
- package/dist/chunk-XUW2UWQM.js +299 -0
- package/dist/chunk-XUW2UWQM.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-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-LLOLUHVG.js → chunk-YWFG7ZX7.js} +1358 -2746
- package/dist/chunk-YWFG7ZX7.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 +55 -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 +86 -0
- package/dist/components/session-list.d.ts +36 -0
- package/dist/components/session-mcp-capability-card.d.ts +41 -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-ENHVAZ2Q.js +19 -0
- package/dist/computer-viewer-ENHVAZ2Q.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 +2237 -6200
- 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-755OW5DS.js +117 -0
- package/dist/latest-question-755OW5DS.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-PWRI7R2X.js +14 -0
- package/dist/model-policy-picker-menu-PWRI7R2X.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-BBC47SLD.js +20 -0
- package/dist/sandbox-files-BBC47SLD.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 +32 -3
- package/dist/timeline/types.d.ts +141 -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 +2498 -663
- 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 +319 -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 +512 -0
- package/src/components/session-list.tsx +250 -0
- package/src/components/session-mcp-capability-card.tsx +680 -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-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 +1 -0
- 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 +1600 -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 +208 -44
- package/src/timeline/types.ts +186 -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-GTR5XQ7X.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,8 +40,8 @@ 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,
|
|
@@ -42,22 +59,43 @@ import {
|
|
|
42
59
|
import { cn } from "../lib/cn";
|
|
43
60
|
import { formatClockTime, formatRelativeTime, truncate } from "../lib/format";
|
|
44
61
|
import { prefersReducedMotion } from "../lib/motion";
|
|
45
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
invokeOlderHistoryLoaderWithReceiptCapture,
|
|
64
|
+
type OlderHistoryLoadReceipt,
|
|
65
|
+
type OlderHistoryLoader,
|
|
66
|
+
} from "../older-history";
|
|
67
|
+
import { Markdown, type MarkdownProps } from "./markdown";
|
|
68
|
+
import { OpenGeniLinkProvider, type OpenGeniLinkResolver } from "./open-geni-links";
|
|
69
|
+
import {
|
|
70
|
+
TimelineBeforeLayout,
|
|
71
|
+
captureTimelineAnchor,
|
|
72
|
+
timelineAnchorCorrection,
|
|
73
|
+
timelineHasReader,
|
|
74
|
+
type TimelineAnchor,
|
|
75
|
+
} from "./timeline-anchor";
|
|
76
|
+
import { useReadingProgress } from "./timeline-reading-progress";
|
|
77
|
+
import {
|
|
78
|
+
animateTimelineSettlement,
|
|
79
|
+
captureTimelineSettlement,
|
|
80
|
+
type TimelineSettlement,
|
|
81
|
+
} from "./timeline-settlement";
|
|
46
82
|
import {
|
|
47
83
|
UserMessageBody,
|
|
48
84
|
UserMessageDisclosureProvider,
|
|
49
85
|
type UserMessageDisclosureContextValue,
|
|
86
|
+
type UserMessageDisclosureLabels,
|
|
50
87
|
} from "./user-message-body";
|
|
51
88
|
import {
|
|
52
89
|
createTipFollowState,
|
|
53
90
|
readerScrollUpPx,
|
|
54
91
|
tipFollowCancel,
|
|
55
|
-
tipFollowCompensateShrink,
|
|
56
92
|
tipFollowCompensateViewportShrink,
|
|
93
|
+
tipFollowObserveContentShrink,
|
|
57
94
|
tipFollowStep,
|
|
58
95
|
supportsScrollEndEvent,
|
|
59
96
|
TIP_FOLLOW_READER_UP_EPS_PX,
|
|
60
97
|
TIP_FOLLOW_SHRINK_EPS_PX,
|
|
98
|
+
type TipFollowContentShrinkBaseline,
|
|
61
99
|
type TipFollowState,
|
|
62
100
|
} from "./tip-follow";
|
|
63
101
|
import {
|
|
@@ -71,6 +109,7 @@ import {
|
|
|
71
109
|
type AuthNeededItem,
|
|
72
110
|
type ContextCompactionItem,
|
|
73
111
|
type GoalItem,
|
|
112
|
+
type HumanInputItem,
|
|
74
113
|
type MachineInputBatchItem,
|
|
75
114
|
type NoticeItem,
|
|
76
115
|
type TimelineGroup,
|
|
@@ -81,6 +120,7 @@ import {
|
|
|
81
120
|
type VideoArtifactPlaybackLoader,
|
|
82
121
|
type ToolRegistry,
|
|
83
122
|
type TurnSummaryOptions,
|
|
123
|
+
type TurnSummaryStatus,
|
|
84
124
|
type UserMessageItem,
|
|
85
125
|
type FoldRestingState,
|
|
86
126
|
type WorkerCompletionItem,
|
|
@@ -91,6 +131,7 @@ import {
|
|
|
91
131
|
useTurnSettleOpen,
|
|
92
132
|
} from "../timeline";
|
|
93
133
|
import { CopyHoverFrame } from "./copy-button";
|
|
134
|
+
import { TimelineAnnotationSourceRootContext } from "./timeline-annotation-reveal-context";
|
|
94
135
|
import { GeneratedVideoPlayer } from "./generated-video-player";
|
|
95
136
|
import {
|
|
96
137
|
MACHINE_INPUT_META,
|
|
@@ -102,23 +143,45 @@ import {
|
|
|
102
143
|
import { SESSION_STATUS_META, StatusDot } from "./session-status";
|
|
103
144
|
import { TimelineComputeLabelProvider } from "../timeline/compute-label";
|
|
104
145
|
import { EntranceAnimationProvider, useEntranceAnimation } from "../timeline/entrance";
|
|
146
|
+
import {
|
|
147
|
+
AllowanceExhaustedRow,
|
|
148
|
+
AllowancePresentationProvider,
|
|
149
|
+
type RenderAllowanceExhausted,
|
|
150
|
+
} from "../timeline/allowance-exhausted-row";
|
|
151
|
+
import type { AllowanceLabels } from "../usage/allowance-copy";
|
|
105
152
|
import { SeenActivityIdsProvider } from "../timeline/seen-activity-ids";
|
|
106
|
-
import {
|
|
153
|
+
import { TimelineAnnotationCards } from "./timeline-annotations";
|
|
107
154
|
import { TooltipProvider } from "./tooltip";
|
|
108
155
|
|
|
109
156
|
const TimelineAnnotationSelection = lazy(() => import("./timeline-annotation-selection"));
|
|
157
|
+
const TimelineAnnotationMarkers = lazy(() => import("./timeline-annotation-markers"));
|
|
110
158
|
|
|
111
159
|
export type MessageTimelineProps = {
|
|
160
|
+
/** Exact durable search hit; clearing it removes highlighting without moving the reader. */
|
|
161
|
+
searchTarget?: TimelineSearchTarget | null | undefined;
|
|
162
|
+
/** Localized user-message disclosure actions, including custom UserMessageBody renderers. */
|
|
163
|
+
userMessageDisclosureLabels?: UserMessageDisclosureLabels | undefined;
|
|
112
164
|
/** Raw session events (projected internally) … */
|
|
113
165
|
events?: SessionEvent[] | undefined;
|
|
114
166
|
/** … or pre-projected items (e.g. from `useSessionEvents().timeline`). */
|
|
115
167
|
items?: TimelineItem[] | undefined;
|
|
116
168
|
/** Current session status (reserved; tip "Working…" chrome removed for now). */
|
|
117
169
|
status?: SessionStatus | null | undefined;
|
|
170
|
+
/** Host-owned controls beside Copy and the timestamp on settled message rows. */
|
|
171
|
+
renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
118
172
|
/** Plug a markdown renderer for message bodies (e.g. streamdown). */
|
|
119
173
|
renderMessageText?:
|
|
120
|
-
| ((
|
|
174
|
+
| ((
|
|
175
|
+
text: string,
|
|
176
|
+
item: AgentMessageItem | UserMessageItem,
|
|
177
|
+
context: { searchTarget: TimelineSearchTarget | null },
|
|
178
|
+
) => ReactNode)
|
|
121
179
|
| undefined;
|
|
180
|
+
/**
|
|
181
|
+
* Render assistant `opengeni-html` / `opengeni-site` fences in the default
|
|
182
|
+
* message renderer (`SessionConversation` supplies the inline Site preview).
|
|
183
|
+
*/
|
|
184
|
+
renderInteractiveBlock?: MarkdownProps["renderInteractiveBlock"];
|
|
122
185
|
/** Drill into a spawned worker session. */
|
|
123
186
|
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
124
187
|
/**
|
|
@@ -138,6 +201,17 @@ export type MessageTimelineProps = {
|
|
|
138
201
|
* a Reconnect button without a handler to run it.
|
|
139
202
|
*/
|
|
140
203
|
onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
|
|
204
|
+
/** Host-owned inline connection setup. Return undefined to use the default recovery card. */
|
|
205
|
+
renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
|
|
206
|
+
/**
|
|
207
|
+
* Replace the "usage limit reached" row shown when a workspace or member
|
|
208
|
+
* usage allowance stops work (an `allowance_exhausted` refusal). Receives the
|
|
209
|
+
* typed refusal (scope, reset time) and the default row; return undefined to
|
|
210
|
+
* keep the default. Use it to point people at your own plan or admin page.
|
|
211
|
+
*/
|
|
212
|
+
renderAllowanceExhausted?: RenderAllowanceExhausted | undefined;
|
|
213
|
+
/** Replace the words of the default "usage limit reached" row. */
|
|
214
|
+
allowanceExhaustedLabels?: Partial<AllowanceLabels> | undefined;
|
|
141
215
|
/**
|
|
142
216
|
* Decide which durable authentication notices this timeline presents.
|
|
143
217
|
* Defaults to showing every notice. Embedded hosts can suppress notices for
|
|
@@ -158,9 +232,19 @@ export type MessageTimelineProps = {
|
|
|
158
232
|
* `createDefaultToolRegistry({ entries })` to add custom tool renderers.
|
|
159
233
|
*/
|
|
160
234
|
toolRegistry?: ToolRegistry | undefined;
|
|
235
|
+
/**
|
|
236
|
+
* Open OpenGeni object links the agent writes in replies and progress notes:
|
|
237
|
+
* `artifact:<file>`, `sandbox:<path>`, editable artifacts, and Sites. Return
|
|
238
|
+
* a host URL (`{ href }`) or action (`{ open }`); unhandled targets render as
|
|
239
|
+
* unavailable text instead of a console link that 404s inside the host.
|
|
240
|
+
* Applies to the default message renderer and to any `Markdown` a custom
|
|
241
|
+
* `renderMessageText` renders. `SessionConversation` supplies file and
|
|
242
|
+
* sandbox downloads by default.
|
|
243
|
+
*/
|
|
244
|
+
resolveLink?: OpenGeniLinkResolver | undefined;
|
|
161
245
|
/** Resolve opaque retained screenshot receipts through the authenticated host SDK. */
|
|
162
246
|
loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
|
|
163
|
-
/** Resolve permanent
|
|
247
|
+
/** Resolve permanent workspace image/file receipts through the authenticated host SDK. */
|
|
164
248
|
loadRetainedArtifact?: RetainedArtifactLoader | undefined;
|
|
165
249
|
/** Mint short-lived native playback sources for retained generated videos. */
|
|
166
250
|
loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
|
|
@@ -170,17 +254,32 @@ export type MessageTimelineProps = {
|
|
|
170
254
|
*/
|
|
171
255
|
computeLabel?: string | null | undefined;
|
|
172
256
|
/** Customize collapsed turn facets for this timeline instance. */
|
|
257
|
+
genieLoading?: GenieLoadingOptions | undefined;
|
|
173
258
|
turnSummary?: TurnSummaryOptions | undefined;
|
|
174
259
|
/** Follow new events when pinned to the bottom. Defaults to true. */
|
|
175
260
|
autoFollow?: boolean | undefined;
|
|
176
261
|
/** Capture a same-row text selection into the host's canonical composer draft. */
|
|
177
262
|
onAnnotate?: ((annotation: DraftTimelineAnnotation) => void) | undefined;
|
|
263
|
+
/**
|
|
264
|
+
* Hide the floating "Back to your message" pill while the timeline viewport
|
|
265
|
+
* is shorter than this (px), e.g. a narrow embed above a decision card.
|
|
266
|
+
* Defaults to 0 (always available).
|
|
267
|
+
*/
|
|
268
|
+
questionNavMinViewportHeight?: number | undefined;
|
|
269
|
+
/** Composer draft quotes currently attached to the next send. */
|
|
270
|
+
draftAnnotations?: readonly DraftTimelineAnnotation[] | undefined;
|
|
271
|
+
/** Open the composer review list for one numbered draft badge. */
|
|
272
|
+
onDraftAnnotationSelect?: ((id: string) => void) | undefined;
|
|
178
273
|
/** Older durable history exists above the current window (see useSessionEvents). */
|
|
179
274
|
hasOlder?: boolean | undefined;
|
|
180
275
|
/** An older window is being fetched; shows the quiet top shimmer. */
|
|
181
276
|
loadingOlder?: boolean | undefined;
|
|
182
|
-
/**
|
|
183
|
-
|
|
277
|
+
/**
|
|
278
|
+
* Called when older history should backfill. Existing void, synchronous-value,
|
|
279
|
+
* and arbitrary-promise callbacks remain supported. Receipt-aware loaders
|
|
280
|
+
* preserve committed-page direction through wrappers and bounded windows.
|
|
281
|
+
*/
|
|
282
|
+
onLoadOlder?: OlderHistoryLoader | undefined;
|
|
184
283
|
/** Jump to the durable session start (bounded oldest window, no middle). */
|
|
185
284
|
onJumpToStart?: (() => void | Promise<void>) | undefined;
|
|
186
285
|
/** True while the oldest window is loading. */
|
|
@@ -189,13 +288,16 @@ export type MessageTimelineProps = {
|
|
|
189
288
|
hasNewer?: boolean | undefined;
|
|
190
289
|
/** A newer history page is being fetched. */
|
|
191
290
|
loadingNewer?: boolean | undefined;
|
|
192
|
-
/** Page forward through history
|
|
193
|
-
onLoadNewer?: (() =>
|
|
291
|
+
/** Page forward through history. Return the request promise to enable inline error/retry. */
|
|
292
|
+
onLoadNewer?: (() => unknown) | undefined;
|
|
194
293
|
/**
|
|
195
294
|
* Reload the live tip window. When omitted, Jump to latest only re-pins and
|
|
196
295
|
* scrolls the in-memory window.
|
|
197
296
|
*/
|
|
198
297
|
onJumpToLatest?: (() => void | Promise<void>) | undefined;
|
|
298
|
+
/** @deprecated Retained for source compatibility; contextual prompt navigation
|
|
299
|
+
* uses only mounted prompts and never invokes this global resolver. */
|
|
300
|
+
onJumpToLatestQuestion?: (() => Promise<number | null>) | undefined;
|
|
199
301
|
/** Host-owned content appended after timeline groups, such as startup progress. */
|
|
200
302
|
trailingState?: ReactNode | undefined;
|
|
201
303
|
emptyState?: ReactNode | undefined;
|
|
@@ -211,8 +313,9 @@ export type MessageTimelineProps = {
|
|
|
211
313
|
*
|
|
212
314
|
* Scroll invariant (tip-follow camera — see `./tip-follow.ts`):
|
|
213
315
|
* - Load/remount: hidden until tip is hard-snapped across a short settle; then
|
|
214
|
-
* reveal. Live tip: DOM
|
|
215
|
-
*
|
|
316
|
+
* reveal. Live tip: DOM growth advances the pinned viewport by the same
|
|
317
|
+
* amount, so rendered content is visible immediately. Only debt that already
|
|
318
|
+
* existed before the growth goes through the camera ease.
|
|
216
319
|
* - One continuous follow while hot (faster τ when behind); sleeps when cold.
|
|
217
320
|
* - While pinned, tip-debt from growth/collapse must NEVER unpin — only
|
|
218
321
|
* wheel/keys/pointer-armed scroll-up, or a settled scrollend away from the
|
|
@@ -226,6 +329,33 @@ export type MessageTimelineProps = {
|
|
|
226
329
|
* alone is wrong. Late layout while unpinned stays browser-owned.
|
|
227
330
|
*/
|
|
228
331
|
const PIN_THRESHOLD_PX = 48;
|
|
332
|
+
/**
|
|
333
|
+
* A pinned timeline can still have meaningful pre-existing debt while the
|
|
334
|
+
* tip-follow camera catches up. Surface the existing explicit jump once that
|
|
335
|
+
* debt is large enough to be useful, without flashing the control for ordinary
|
|
336
|
+
* line-sized streaming movement.
|
|
337
|
+
*/
|
|
338
|
+
const JUMP_TO_LATEST_CATCHUP_DEBT_PX = 240;
|
|
339
|
+
/** Breathing room above the question when following stops at an answer. */
|
|
340
|
+
const QUESTION_NAV_MARGIN_PX = 12;
|
|
341
|
+
/**
|
|
342
|
+
* Floating timeline navigation: two identical small round arrow buttons at fixed
|
|
343
|
+
* spots, centered on the conversation. Back to your message floats 12px below
|
|
344
|
+
* the top edge, or just below an expanded work-header strip while that strip is
|
|
345
|
+
* pinned (32px, 44px on coarse pointers) so the strip stays a full-width
|
|
346
|
+
* target. Jump to latest floats 12px above the bottom edge. They never dodge
|
|
347
|
+
* the content beneath them (floating over a sliver of text is fine), so they
|
|
348
|
+
* cannot wander as rows stream in or the host resizes. Coarse pointers get a
|
|
349
|
+
* larger invisible hit area instead of a larger button.
|
|
350
|
+
*/
|
|
351
|
+
const NAV_BUTTON_CLASS =
|
|
352
|
+
"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-['']";
|
|
353
|
+
const NAV_FADE = {
|
|
354
|
+
initial: { opacity: 0 },
|
|
355
|
+
animate: { opacity: 1 },
|
|
356
|
+
exit: { opacity: 0 },
|
|
357
|
+
transition: { duration: 0.15, ease: "easeOut" },
|
|
358
|
+
} as const;
|
|
229
359
|
/**
|
|
230
360
|
* Prefetch older history when the top sentinel is this far from the viewport.
|
|
231
361
|
* After a page loads we stay cool until the reader leaves this band (scrolls
|
|
@@ -233,6 +363,68 @@ const PIN_THRESHOLD_PX = 48;
|
|
|
233
363
|
*/
|
|
234
364
|
const OLDER_PREFETCH_MARGIN_PX = 400;
|
|
235
365
|
const OLDER_PREFETCH_ROOT_MARGIN = `${OLDER_PREFETCH_MARGIN_PX}px 0px 0px 0px`;
|
|
366
|
+
const PRIMARY_ACTION_CLASS =
|
|
367
|
+
"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";
|
|
368
|
+
const MESSAGE_BUBBLE_CLASS =
|
|
369
|
+
"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";
|
|
370
|
+
const WAITING_PILL_CLASS =
|
|
371
|
+
"border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting";
|
|
372
|
+
const LOADING_CHIP_CLASS =
|
|
373
|
+
"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";
|
|
374
|
+
|
|
375
|
+
// State: 0 underfill retry, 1 prefetch pending, 2 compatibility-settled,
|
|
376
|
+
// 3 explicit no-progress waiting to become an underfill retry.
|
|
377
|
+
// The tuple identity is the exact request ownership fence. The boundary may rebase
|
|
378
|
+
// forward while that request is pending when a bounded live-tail append evicts
|
|
379
|
+
// its former oldest row. First-party loaders mark the exact owner when their
|
|
380
|
+
// older page commits; retained direction is the fallback for other hosts.
|
|
381
|
+
type OlderLoadAttempt = [
|
|
382
|
+
boundary: string | undefined,
|
|
383
|
+
state?: number,
|
|
384
|
+
receipt?: OlderHistoryLoadReceipt,
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
function invokeOlderLoad(
|
|
388
|
+
load: OlderHistoryLoader,
|
|
389
|
+
noProgress: () => void,
|
|
390
|
+
attempt: OlderLoadAttempt,
|
|
391
|
+
preserveTail = false,
|
|
392
|
+
): 1 | undefined {
|
|
393
|
+
try {
|
|
394
|
+
// Receipt creation is captured synchronously through legacy wrappers such
|
|
395
|
+
// as `() => void loadOlder()`, even when the wrapper discards the return.
|
|
396
|
+
const result = invokeOlderHistoryLoaderWithReceiptCapture(
|
|
397
|
+
load,
|
|
398
|
+
(receipt) => {
|
|
399
|
+
attempt[2] = receipt;
|
|
400
|
+
},
|
|
401
|
+
preserveTail,
|
|
402
|
+
) as OlderHistoryLoadReceipt | PromiseLike<unknown> | unknown;
|
|
403
|
+
const receipt =
|
|
404
|
+
attempt[2] ??
|
|
405
|
+
(typeof (result as { committed?: unknown } | undefined)?.committed === "boolean"
|
|
406
|
+
? (result as OlderHistoryLoadReceipt)
|
|
407
|
+
: undefined);
|
|
408
|
+
if (receipt) {
|
|
409
|
+
attempt[2] = receipt;
|
|
410
|
+
void receipt.then(
|
|
411
|
+
(value) => value === false && !receipt.committed && noProgress(),
|
|
412
|
+
noProgress,
|
|
413
|
+
);
|
|
414
|
+
return 1;
|
|
415
|
+
}
|
|
416
|
+
if (typeof (result as PromiseLike<unknown> | undefined)?.then != "function") {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
void (result as PromiseLike<unknown>).then(
|
|
420
|
+
(value) => value === false && noProgress(),
|
|
421
|
+
noProgress,
|
|
422
|
+
);
|
|
423
|
+
} catch {
|
|
424
|
+
noProgress();
|
|
425
|
+
}
|
|
426
|
+
return 1;
|
|
427
|
+
}
|
|
236
428
|
|
|
237
429
|
/**
|
|
238
430
|
* Pinned = the viewport bottom is within PIN_THRESHOLD_PX of the content
|
|
@@ -243,7 +435,8 @@ const OLDER_PREFETCH_ROOT_MARGIN = `${OLDER_PREFETCH_MARGIN_PX}px 0px 0px 0px`;
|
|
|
243
435
|
* cannot scroll at all is always pinned.
|
|
244
436
|
*/
|
|
245
437
|
function maxScrollOf(node: HTMLElement): number {
|
|
246
|
-
|
|
438
|
+
// Browser layout guarantees scrollHeight is at least clientHeight.
|
|
439
|
+
return node.scrollHeight - node.clientHeight;
|
|
247
440
|
}
|
|
248
441
|
|
|
249
442
|
/**
|
|
@@ -286,6 +479,58 @@ function isNearBottom(node: HTMLElement): boolean {
|
|
|
286
479
|
return gap < Math.min(PIN_THRESHOLD_PX, maxScroll);
|
|
287
480
|
}
|
|
288
481
|
|
|
482
|
+
/** Content-space top of a timeline group, or null when it is not mounted. */
|
|
483
|
+
function contentTopOf(node: HTMLElement, groupKey: string): number | null {
|
|
484
|
+
const element = node.querySelector(`[data-og-group-key="${cssEscapeAttribute(groupKey)}"]`);
|
|
485
|
+
if (!(element instanceof HTMLElement)) {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
return element.getBoundingClientRect().top - node.getBoundingClientRect().top + node.scrollTop;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** The question the reader is inside of, once its start has scrolled out of view. */
|
|
492
|
+
/**
|
|
493
|
+
* `belowHeader`: an expanded work header is pinned to the top of the scrollport,
|
|
494
|
+
* so the up arrow sits just below that strip instead of covering it.
|
|
495
|
+
*/
|
|
496
|
+
type QuestionNav = { key: string; belowHeader: boolean };
|
|
497
|
+
|
|
498
|
+
/** How far a question's start must be above the viewport before navigation shows. */
|
|
499
|
+
const QUESTION_NAV_HIDDEN_PX = 24;
|
|
500
|
+
|
|
501
|
+
function readQuestionNav(node: HTMLElement): QuestionNav | null {
|
|
502
|
+
const view = node.getBoundingClientRect();
|
|
503
|
+
const prompts = [...node.querySelectorAll<HTMLElement>("[data-og-prompt]")];
|
|
504
|
+
// The last prompt before the reading position owns the response/work in view.
|
|
505
|
+
// A newer prompt farther down the conversation is not a navigation target.
|
|
506
|
+
const middle = view.top + view.height / 2;
|
|
507
|
+
const prompt = prompts
|
|
508
|
+
.reverse()
|
|
509
|
+
.find((candidate) => candidate.getBoundingClientRect().top <= middle);
|
|
510
|
+
const key = prompt?.dataset.ogGroupKey;
|
|
511
|
+
if (!prompt || !key) return null;
|
|
512
|
+
const bounds = prompt.getBoundingClientRect();
|
|
513
|
+
if (bounds.top >= view.top - QUESTION_NAV_HIDDEN_PX || bounds.bottom > view.top) {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
return { key, belowHeader: workHeaderPinned(node, view) };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** True while an expanded outer work header is stuck to the scrollport's top edge. */
|
|
520
|
+
function workHeaderPinned(node: HTMLElement, view: DOMRect): boolean {
|
|
521
|
+
for (const header of node.querySelectorAll<HTMLElement>(
|
|
522
|
+
'[data-og-work-header="outer"][data-state="open"]',
|
|
523
|
+
)) {
|
|
524
|
+
const box = header.getBoundingClientRect();
|
|
525
|
+
if (Math.abs(box.top - view.top) <= 1 && box.bottom > view.top) return true;
|
|
526
|
+
}
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function sameQuestionNav(a: QuestionNav | null, b: QuestionNav | null): boolean {
|
|
531
|
+
return a === b || (!!a && !!b && a.key === b.key && a.belowHeader === b.belowHeader);
|
|
532
|
+
}
|
|
533
|
+
|
|
289
534
|
/** Escape a value for use inside a CSS attribute selector. */
|
|
290
535
|
function cssEscapeAttribute(value: string): string {
|
|
291
536
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
@@ -301,13 +546,21 @@ function cssEscapeAttribute(value: string): string {
|
|
|
301
546
|
* with a "jump to latest" affordance when the reader scrolls back.
|
|
302
547
|
*/
|
|
303
548
|
export function MessageTimeline({
|
|
549
|
+
resolveLink,
|
|
550
|
+
renderInteractiveBlock,
|
|
551
|
+
userMessageDisclosureLabels,
|
|
552
|
+
searchTarget,
|
|
304
553
|
events,
|
|
305
554
|
items,
|
|
306
555
|
status: _status,
|
|
556
|
+
renderMessageActions,
|
|
307
557
|
renderMessageText,
|
|
308
558
|
onOpenSession,
|
|
309
559
|
onMemoryClick,
|
|
310
560
|
onReconnect,
|
|
561
|
+
renderAuthNeeded,
|
|
562
|
+
renderAllowanceExhausted,
|
|
563
|
+
allowanceExhaustedLabels,
|
|
311
564
|
shouldRenderAuthNeeded,
|
|
312
565
|
resolveProviderLogo,
|
|
313
566
|
toolRegistry = defaultToolRegistry,
|
|
@@ -315,9 +568,13 @@ export function MessageTimeline({
|
|
|
315
568
|
loadRetainedArtifact,
|
|
316
569
|
loadVideoArtifactPlayback,
|
|
317
570
|
computeLabel = null,
|
|
571
|
+
genieLoading,
|
|
318
572
|
turnSummary,
|
|
319
573
|
autoFollow = true,
|
|
320
574
|
onAnnotate,
|
|
575
|
+
questionNavMinViewportHeight = 0,
|
|
576
|
+
draftAnnotations,
|
|
577
|
+
onDraftAnnotationSelect,
|
|
321
578
|
hasOlder = false,
|
|
322
579
|
loadingOlder = false,
|
|
323
580
|
onLoadOlder,
|
|
@@ -331,14 +588,143 @@ export function MessageTimeline({
|
|
|
331
588
|
emptyState,
|
|
332
589
|
className,
|
|
333
590
|
}: MessageTimelineProps) {
|
|
591
|
+
const formatError = useErrorMessage();
|
|
334
592
|
const resolvedItems = useMemo(() => {
|
|
335
|
-
const
|
|
593
|
+
const projectedItems = items ?? buildTimeline(events ?? []);
|
|
336
594
|
if (!shouldRenderAuthNeeded) {
|
|
337
|
-
return
|
|
595
|
+
return projectedItems;
|
|
338
596
|
}
|
|
339
|
-
return
|
|
597
|
+
return projectedItems.filter(
|
|
598
|
+
(item) => item.kind !== "auth-needed" || shouldRenderAuthNeeded(item),
|
|
599
|
+
);
|
|
340
600
|
}, [items, events, shouldRenderAuthNeeded]);
|
|
341
|
-
const
|
|
601
|
+
const searchItem = searchTarget
|
|
602
|
+
? resolvedItems.find((item) => isTimelineSearchTarget(item, searchTarget))
|
|
603
|
+
: undefined;
|
|
604
|
+
const searchRevealKey =
|
|
605
|
+
searchItem && searchTarget
|
|
606
|
+
? JSON.stringify([
|
|
607
|
+
searchItem.id,
|
|
608
|
+
searchTarget.query,
|
|
609
|
+
searchTarget.occurrence ?? 0,
|
|
610
|
+
searchTarget.offset ?? null,
|
|
611
|
+
])
|
|
612
|
+
: null;
|
|
613
|
+
const searchTargetRef = useRef(searchTarget);
|
|
614
|
+
searchTargetRef.current = searchTarget;
|
|
615
|
+
const renderSearchableMessageText = useMemo(
|
|
616
|
+
() =>
|
|
617
|
+
renderMessageText
|
|
618
|
+
? (text: string, item: AgentMessageItem | UserMessageItem) =>
|
|
619
|
+
renderMessageText(text, item, {
|
|
620
|
+
searchTarget: searchItem?.id === item.id ? (searchTarget ?? null) : null,
|
|
621
|
+
})
|
|
622
|
+
: (text: string, item: AgentMessageItem | UserMessageItem) => {
|
|
623
|
+
const body = (
|
|
624
|
+
<Markdown
|
|
625
|
+
searchTarget={searchItem?.id === item.id ? searchTarget : null}
|
|
626
|
+
streaming={item.kind === "agent-message" && item.streaming}
|
|
627
|
+
renderInteractiveBlock={
|
|
628
|
+
item.kind === "agent-message" ? renderInteractiveBlock : undefined
|
|
629
|
+
}
|
|
630
|
+
>
|
|
631
|
+
{text}
|
|
632
|
+
</Markdown>
|
|
633
|
+
);
|
|
634
|
+
return item.kind === "user-message" ? (
|
|
635
|
+
<UserMessageBody messageId={item.id} text={text}>
|
|
636
|
+
{body}
|
|
637
|
+
</UserMessageBody>
|
|
638
|
+
) : (
|
|
639
|
+
body
|
|
640
|
+
);
|
|
641
|
+
},
|
|
642
|
+
[renderInteractiveBlock, renderMessageText, searchItem?.id, searchTarget],
|
|
643
|
+
);
|
|
644
|
+
// Event-window identity is independent of projected rows (partial messages
|
|
645
|
+
// can acquire a different first-delta id when older text arrives).
|
|
646
|
+
const sourceItems = events ?? items;
|
|
647
|
+
const olderBoundaryKey = sourceItems?.[0]?.id;
|
|
648
|
+
const newerBoundaryKey = `${events?.[0]?.sessionId ?? ""}:${olderBoundaryKey ?? ""}`;
|
|
649
|
+
const newerScopeRef = useRef(newerBoundaryKey);
|
|
650
|
+
newerScopeRef.current = newerBoundaryKey;
|
|
651
|
+
const newerAttemptRef = useRef<{ pending: boolean } | null>(null);
|
|
652
|
+
const newerRetryButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
653
|
+
const [newerRetryPending, setNewerRetryPending] = useState(false);
|
|
654
|
+
const [newerFailure, setNewerFailure] = useState<{ key: string; message: string } | null>(null);
|
|
655
|
+
useEffect(() => {
|
|
656
|
+
newerScopeRef.current = newerBoundaryKey;
|
|
657
|
+
newerAttemptRef.current = null;
|
|
658
|
+
setNewerFailure(null);
|
|
659
|
+
setNewerRetryPending(false);
|
|
660
|
+
return () => {
|
|
661
|
+
newerScopeRef.current = "";
|
|
662
|
+
newerAttemptRef.current = null;
|
|
663
|
+
};
|
|
664
|
+
}, [newerBoundaryKey]);
|
|
665
|
+
const requestNewer = useCallback(
|
|
666
|
+
(explicitRetry = false) => {
|
|
667
|
+
if (
|
|
668
|
+
newerScopeRef.current !== newerBoundaryKey ||
|
|
669
|
+
!onLoadNewer ||
|
|
670
|
+
loadingNewer ||
|
|
671
|
+
(newerAttemptRef.current && (!explicitRetry || newerAttemptRef.current.pending))
|
|
672
|
+
) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const attempt = { pending: true };
|
|
676
|
+
newerAttemptRef.current = attempt;
|
|
677
|
+
if (explicitRetry) setNewerRetryPending(true);
|
|
678
|
+
const isCurrent = () =>
|
|
679
|
+
newerAttemptRef.current === attempt && newerScopeRef.current === newerBoundaryKey;
|
|
680
|
+
// Both synchronous host errors and rejected promises belong to this
|
|
681
|
+
// boundary. Retain the failed attempt so observers cannot hot-retry it.
|
|
682
|
+
void Promise.resolve()
|
|
683
|
+
.then(() => (isCurrent() ? onLoadNewer() : undefined))
|
|
684
|
+
.then(
|
|
685
|
+
() => {
|
|
686
|
+
if (!isCurrent()) return;
|
|
687
|
+
newerAttemptRef.current = null;
|
|
688
|
+
// The successful page removes the recovery control. Return focus
|
|
689
|
+
// to the reading surface without moving the reader's viewport.
|
|
690
|
+
if (document.activeElement === newerRetryButtonRef.current) {
|
|
691
|
+
scrollRef.current?.focus({ preventScroll: true });
|
|
692
|
+
}
|
|
693
|
+
setNewerFailure(null);
|
|
694
|
+
setNewerRetryPending(false);
|
|
695
|
+
},
|
|
696
|
+
(reason: unknown) => {
|
|
697
|
+
if (!isCurrent()) return;
|
|
698
|
+
attempt.pending = false;
|
|
699
|
+
setNewerRetryPending(false);
|
|
700
|
+
setNewerFailure({
|
|
701
|
+
key: newerBoundaryKey,
|
|
702
|
+
message: formatError(reason),
|
|
703
|
+
});
|
|
704
|
+
},
|
|
705
|
+
);
|
|
706
|
+
},
|
|
707
|
+
[onLoadNewer, loadingNewer, newerBoundaryKey, formatError],
|
|
708
|
+
);
|
|
709
|
+
const previousSourceIdsRef = useRef(new Set<string>());
|
|
710
|
+
const previousSourceBoundaryRef = useRef<string | undefined>(undefined);
|
|
711
|
+
const readingAnchorRef = useRef<TimelineAnchor | null>(null);
|
|
712
|
+
const settlementRef = useRef<TimelineSettlement | null>(null);
|
|
713
|
+
const settlementAnimationRef = useRef<Animation | null>(null);
|
|
714
|
+
const stopSettlement = useCallback(() => {
|
|
715
|
+
settlementAnimationRef.current?.cancel();
|
|
716
|
+
settlementAnimationRef.current = null;
|
|
717
|
+
}, []);
|
|
718
|
+
const olderPageBudgetRef = useRef(0);
|
|
719
|
+
const [olderDemand, setOlderDemand] = useState(0);
|
|
720
|
+
const readableTurns = turnSummary?.rolling === true;
|
|
721
|
+
const projectedGroups = useMemo(
|
|
722
|
+
() => groupTimeline(resolvedItems, { readableTurns }),
|
|
723
|
+
[resolvedItems, readableTurns],
|
|
724
|
+
);
|
|
725
|
+
// A new timeline revision (including session/turn replacement) owns layout.
|
|
726
|
+
// Never leave an old decorative transform running on its retained rows.
|
|
727
|
+
useLayoutEffect(() => stopSettlement, [projectedGroups, stopSettlement]);
|
|
342
728
|
const annotationSources = useMemo(() => {
|
|
343
729
|
const sources = new Map<string, TimelineAnnotationSourceDescriptor>();
|
|
344
730
|
for (const item of resolvedItems) {
|
|
@@ -353,29 +739,56 @@ export function MessageTimeline({
|
|
|
353
739
|
}
|
|
354
740
|
return sources;
|
|
355
741
|
}, [resolvedItems]);
|
|
356
|
-
const groups = useStableTimelineGroupKeys(allGroups);
|
|
357
742
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
358
743
|
const topSentinelRef = useRef<HTMLDivElement | null>(null);
|
|
359
744
|
const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
|
|
360
745
|
const previousBulkFirstKeyRef = useRef<string | null | undefined>(undefined);
|
|
361
746
|
const [pinned, setPinned] = useState(true);
|
|
747
|
+
const { groups: allGroups, release: releaseProgress } = useReadingProgress(
|
|
748
|
+
projectedGroups,
|
|
749
|
+
autoFollow && pinned && !hasNewer,
|
|
750
|
+
scrollRef.current,
|
|
751
|
+
);
|
|
752
|
+
const [canSkipTipCatchup, setCanSkipTipCatchup] = useState(false);
|
|
753
|
+
const canSkipTipCatchupRef = useRef(false);
|
|
362
754
|
const [bulkActive, setBulkActive] = useState(true);
|
|
363
755
|
// Older history prefetch is user-driven: a window shorter than the viewport
|
|
364
756
|
// + rootMargin would otherwise keep the top sentinel intersecting and fetch
|
|
365
757
|
// history forever while the reader sits at the tip. Arm on first scroll-up.
|
|
366
758
|
const olderPrefetchArmedRef = useRef(false);
|
|
367
759
|
const [olderPrefetchArmed, setOlderPrefetchArmed] = useState(false);
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
|
|
760
|
+
// A collapsed history tail can be shorter than the viewport. In that state
|
|
761
|
+
// there is no upward scroll range, so reader intent can never arm the top
|
|
762
|
+
// sentinel. Request one older page per loaded window until history either
|
|
763
|
+
// fills the viewport or the host reports that no older rows remain. Each
|
|
764
|
+
// attempt owns the oldest loaded item boundary across BOTH the automatic
|
|
765
|
+
// underfill and reader-driven sentinel paths. Live-tail appends do not
|
|
766
|
+
// advance older pagination or release that exact owner.
|
|
767
|
+
const olderLoadAttemptRef = useRef<OlderLoadAttempt | null>(null);
|
|
768
|
+
const [underfillSettledAttempt, setUnderfillSettledAttempt] = useState<OlderLoadAttempt | null>(
|
|
769
|
+
null,
|
|
770
|
+
);
|
|
771
|
+
const underfillRetryReadyRef = useRef(false);
|
|
372
772
|
const resizeFollowRafRef = useRef<number | null>(null);
|
|
773
|
+
const questionNavFrameRef = useRef<number | null>(null);
|
|
774
|
+
const [questionNav, setQuestionNav] = useState<QuestionNav | null>(null);
|
|
775
|
+
const questionNavMinViewportRef = useRef(questionNavMinViewportHeight);
|
|
776
|
+
questionNavMinViewportRef.current = questionNavMinViewportHeight;
|
|
777
|
+
const scheduleQuestionNavRef = useRef<() => void>(() => {});
|
|
778
|
+
// Unpinned is not the same as away from the tip: focusing a control in the
|
|
779
|
+
// conversation (Copy, a connection card) hands the view to the reader while
|
|
780
|
+
// they are still at the bottom. Offer the jump only once there is something
|
|
781
|
+
// below them, or a newer window / catch-up to skip.
|
|
782
|
+
const [awayFromTip, setAwayFromTip] = useState(false);
|
|
783
|
+
const jumpPillShown = autoFollow && ((!pinned && awayFromTip) || hasNewer || canSkipTipCatchup);
|
|
373
784
|
const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
|
|
374
785
|
// Content stays invisible until the tip is hard-parked across a short
|
|
375
786
|
// post-commit settle (two rAFs). That absorbs sync late layout while hidden
|
|
376
787
|
// so load/remount does not ease into the tip — live tip-follow is unchanged
|
|
377
788
|
// once revealed. A flash of the window's TOP is still structurally impossible.
|
|
378
|
-
|
|
789
|
+
// The accepted-create handoff uses the reserved local id "c" so its known
|
|
790
|
+
// first message is visible immediately; loaded histories still park first.
|
|
791
|
+
const [revealed, setRevealed] = useState(resolvedItems[0]?.id === "c");
|
|
379
792
|
// Mirror `pinned` into a ref, written ONLY by applyPinned, so the
|
|
380
793
|
// ResizeObserver rAF (a stable closure) reads the live value and a snap can
|
|
381
794
|
// never race a just-unpinned reader across a pending React commit.
|
|
@@ -414,6 +827,8 @@ export function MessageTimeline({
|
|
|
414
827
|
* jumps (Vimium) settle via scrollend while the camera is idle.
|
|
415
828
|
*/
|
|
416
829
|
const readerIntentArmRef = useRef(false);
|
|
830
|
+
/** Gesture-start geometry survives commits before the native scroll event. */
|
|
831
|
+
const readerIntentStartRef = useRef<{ scrollTop: number; maxScroll: number } | null>(null);
|
|
417
832
|
/**
|
|
418
833
|
* Count of camera/snap scrollTop writes whose scroll echoes are not yet
|
|
419
834
|
* consumed. A boolean was wrong when the browser coalesced two writes into
|
|
@@ -440,23 +855,72 @@ export function MessageTimeline({
|
|
|
440
855
|
const foldMemoryRef = useRef<Map<string, FoldRestingState>>(new Map());
|
|
441
856
|
const userMessageDisclosureMemoryRef = useRef<Map<string, boolean>>(new Map());
|
|
442
857
|
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
|
443
|
-
const
|
|
444
|
-
const
|
|
858
|
+
const firstItemGroupKeyRef = useRef<string | null>(null);
|
|
859
|
+
const firstItemGroupOffsetTopRef = useRef<number | null>(null);
|
|
860
|
+
const firstItemContentTopRef = useRef<number | null>(null);
|
|
445
861
|
const firstItemId = resolvedItems[0]?.id ?? null;
|
|
862
|
+
// Pagination ownership follows the oldest committed input, before host
|
|
863
|
+
// filtering and grouping. A page containing only suppressed auth notices
|
|
864
|
+
// still advances this receipt, while live-tail appends leave it unchanged.
|
|
865
|
+
// Promise fulfillment alone does neither, so delayed prepends stay fenced.
|
|
866
|
+
const underfillRetryReady = (underfillRetryReadyRef.current = !!(
|
|
867
|
+
underfillSettledAttempt &&
|
|
868
|
+
underfillSettledAttempt === olderLoadAttemptRef.current &&
|
|
869
|
+
hasOlder &&
|
|
870
|
+
onLoadOlder &&
|
|
871
|
+
!loadingOlder
|
|
872
|
+
));
|
|
446
873
|
// Bulk paints (the initial tail window, a prepended older window — detected
|
|
447
874
|
// by the first group key changing) must not run per-row entrance animations.
|
|
448
875
|
const firstKeyChangedForBulk =
|
|
449
876
|
previousBulkFirstKeyRef.current !== undefined &&
|
|
450
877
|
previousBulkFirstKeyRef.current !== firstGroupKey;
|
|
451
878
|
const bulkRender = allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk);
|
|
879
|
+
const groups = useStableTimelineGroupKeys(allGroups, !bulkRender);
|
|
880
|
+
const turnsWithOutput = useMemo(
|
|
881
|
+
() =>
|
|
882
|
+
new Set(
|
|
883
|
+
resolvedItems.flatMap((item) =>
|
|
884
|
+
"turnId" in item &&
|
|
885
|
+
item.turnId &&
|
|
886
|
+
(item.kind === "tool-call" ||
|
|
887
|
+
((item.kind === "agent-message" || item.kind === "reasoning") && item.text.trim()))
|
|
888
|
+
? [item.turnId]
|
|
889
|
+
: [],
|
|
890
|
+
),
|
|
891
|
+
),
|
|
892
|
+
[resolvedItems],
|
|
893
|
+
);
|
|
894
|
+
|
|
895
|
+
const applyCanSkipTipCatchup = useCallback((value: boolean) => {
|
|
896
|
+
if (canSkipTipCatchupRef.current !== value) {
|
|
897
|
+
canSkipTipCatchupRef.current = value;
|
|
898
|
+
setCanSkipTipCatchup(value);
|
|
899
|
+
}
|
|
900
|
+
}, []);
|
|
452
901
|
|
|
453
902
|
// The ONLY writer of the pinned flag. Ref and state move together, so
|
|
454
903
|
// behavior (refs read by rAF callbacks) and rendering (the anchor class,
|
|
455
904
|
// the Jump-to-latest button) can never desync.
|
|
456
|
-
const applyPinned = useCallback(
|
|
457
|
-
|
|
458
|
-
pinnedRef.current
|
|
459
|
-
|
|
905
|
+
const applyPinned = useCallback(
|
|
906
|
+
(value: boolean) => {
|
|
907
|
+
if (pinnedRef.current !== value) {
|
|
908
|
+
pinnedRef.current = value;
|
|
909
|
+
setPinned(value);
|
|
910
|
+
}
|
|
911
|
+
if (!value) {
|
|
912
|
+
applyCanSkipTipCatchup(false);
|
|
913
|
+
}
|
|
914
|
+
},
|
|
915
|
+
[applyCanSkipTipCatchup],
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
const rearmOlderPrefetchAfterLeavingTop = useCallback((node: HTMLElement) => {
|
|
919
|
+
if (node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
920
|
+
const state = olderLoadAttemptRef.current?.[1];
|
|
921
|
+
if (state === 2 || state === 3) {
|
|
922
|
+
olderLoadAttemptRef.current = null;
|
|
923
|
+
}
|
|
460
924
|
}
|
|
461
925
|
}, []);
|
|
462
926
|
|
|
@@ -466,6 +930,7 @@ export function MessageTimeline({
|
|
|
466
930
|
// Pure tip-follow camera. Pin intent uses clamp conservation, not timers.
|
|
467
931
|
const followRef = useRef<TipFollowState>(createTipFollowState());
|
|
468
932
|
const followFrameRef = useRef<number | null>(null);
|
|
933
|
+
const contentShrinkBaselineRef = useRef<TipFollowContentShrinkBaseline | null>(null);
|
|
469
934
|
|
|
470
935
|
const syncScrollBaseline = useCallback((node: HTMLElement) => {
|
|
471
936
|
lastScrollTopRef.current = node.scrollTop;
|
|
@@ -480,6 +945,9 @@ export function MessageTimeline({
|
|
|
480
945
|
if (before === next) {
|
|
481
946
|
return;
|
|
482
947
|
}
|
|
948
|
+
// Unpinned camera/anchor writes invalidate return intent. A pinned
|
|
949
|
+
// pointer gesture still needs its cumulative leave budget across writes.
|
|
950
|
+
if (!pinnedRef.current || !readerIntentArmRef.current) readerIntentStartRef.current = null;
|
|
483
951
|
programmaticScrollRef.current += 1;
|
|
484
952
|
node.scrollTop = next;
|
|
485
953
|
if (node.scrollTop === before) {
|
|
@@ -491,7 +959,7 @@ export function MessageTimeline({
|
|
|
491
959
|
}, []);
|
|
492
960
|
|
|
493
961
|
const cancelLeaveFallback = useCallback(() => {
|
|
494
|
-
if (leaveFallbackRafRef.current
|
|
962
|
+
if (leaveFallbackRafRef.current != null) {
|
|
495
963
|
cancelFrame(leaveFallbackRafRef.current);
|
|
496
964
|
leaveFallbackRafRef.current = null;
|
|
497
965
|
}
|
|
@@ -502,38 +970,58 @@ export function MessageTimeline({
|
|
|
502
970
|
cancelLeaveFallback();
|
|
503
971
|
}, [cancelLeaveFallback]);
|
|
504
972
|
|
|
973
|
+
const clearReaderIntent = useCallback(() => {
|
|
974
|
+
readerIntentArmRef.current = false;
|
|
975
|
+
readerIntentStartRef.current = null;
|
|
976
|
+
}, []);
|
|
977
|
+
|
|
505
978
|
const stopFollow = useCallback(() => {
|
|
979
|
+
contentShrinkBaselineRef.current = null;
|
|
506
980
|
followRef.current = tipFollowCancel(followRef.current);
|
|
507
|
-
if (followFrameRef.current
|
|
981
|
+
if (followFrameRef.current != null) {
|
|
508
982
|
cancelFrame(followFrameRef.current);
|
|
509
983
|
followFrameRef.current = null;
|
|
510
984
|
}
|
|
511
985
|
}, []);
|
|
512
986
|
|
|
513
987
|
/** 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
|
-
|
|
988
|
+
const releasePinFromReader = useCallback(() => {
|
|
989
|
+
if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
clearReaderIntent();
|
|
993
|
+
clearPendingReaderLeave();
|
|
994
|
+
stopFollow();
|
|
995
|
+
applyPinned(false);
|
|
996
|
+
if (wantPinRef.current) {
|
|
997
|
+
wantPinRef.current = false;
|
|
998
|
+
}
|
|
999
|
+
if (!olderPrefetchArmedRef.current) {
|
|
1000
|
+
olderPrefetchArmedRef.current = true;
|
|
1001
|
+
setOlderPrefetchArmed(true);
|
|
1002
|
+
}
|
|
1003
|
+
}, [autoFollow, applyPinned, clearPendingReaderLeave, clearReaderIntent, stopFollow]);
|
|
1004
|
+
|
|
1005
|
+
useEffect(() => {
|
|
1006
|
+
if (!readableTurns) return;
|
|
1007
|
+
const node = scrollRef.current;
|
|
1008
|
+
if (!node) return;
|
|
1009
|
+
const onSelection = () => {
|
|
1010
|
+
const selection = node.ownerDocument.getSelection();
|
|
1011
|
+
if (
|
|
1012
|
+
selection &&
|
|
1013
|
+
!selection.isCollapsed &&
|
|
1014
|
+
node.contains(selection.anchorNode) &&
|
|
1015
|
+
node.contains(selection.focusNode)
|
|
1016
|
+
) {
|
|
1017
|
+
stopSettlement();
|
|
1018
|
+
releasePinFromReader();
|
|
1019
|
+
disclosureKeepsUnpinnedRef.current = true;
|
|
533
1020
|
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
|
|
1021
|
+
};
|
|
1022
|
+
node.ownerDocument.addEventListener("selectionchange", onSelection);
|
|
1023
|
+
return () => node.ownerDocument.removeEventListener("selectionchange", onSelection);
|
|
1024
|
+
}, [readableTurns, releasePinFromReader, stopSettlement]);
|
|
537
1025
|
|
|
538
1026
|
/**
|
|
539
1027
|
* Settled away from the tip while the camera is idle — Vimium / unfocused
|
|
@@ -547,19 +1035,17 @@ export function MessageTimeline({
|
|
|
547
1035
|
if (programmaticScrollRef.current > 0) {
|
|
548
1036
|
return;
|
|
549
1037
|
}
|
|
550
|
-
if (followRef.current.running || followFrameRef.current
|
|
1038
|
+
if (followRef.current.running || followFrameRef.current != null) {
|
|
551
1039
|
return;
|
|
552
1040
|
}
|
|
553
1041
|
if (isNearBottom(node) || maxScrollOf(node) <= 1) {
|
|
554
1042
|
clearPendingReaderLeave();
|
|
555
1043
|
return;
|
|
556
1044
|
}
|
|
557
|
-
releasePinFromReader(
|
|
558
|
-
|
|
559
|
-
olderLoadGateRef.current = "armed";
|
|
560
|
-
}
|
|
1045
|
+
releasePinFromReader();
|
|
1046
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
561
1047
|
},
|
|
562
|
-
[autoFollow, clearPendingReaderLeave, releasePinFromReader],
|
|
1048
|
+
[autoFollow, clearPendingReaderLeave, rearmOlderPrefetchAfterLeavingTop, releasePinFromReader],
|
|
563
1049
|
);
|
|
564
1050
|
|
|
565
1051
|
const scheduleLeaveFallback = useCallback(() => {
|
|
@@ -577,6 +1063,22 @@ export function MessageTimeline({
|
|
|
577
1063
|
});
|
|
578
1064
|
}, [cancelLeaveFallback, releasePinAfterScrollSettled]);
|
|
579
1065
|
|
|
1066
|
+
const requestEarlierFromReader = () => {
|
|
1067
|
+
// Upward navigation replaces any earlier downward gesture, even when
|
|
1068
|
+
// already unpinned and the engine never delivered scrollend.
|
|
1069
|
+
clearReaderIntent();
|
|
1070
|
+
releasePinFromReader();
|
|
1071
|
+
wantPinRef.current = false;
|
|
1072
|
+
// A stationary upward gesture is still demand. Successful short/folded
|
|
1073
|
+
// pages may never create enough range to leave the prefetch band.
|
|
1074
|
+
olderPageBudgetRef.current = 8;
|
|
1075
|
+
if (olderLoadAttemptRef.current?.[1] === 2) {
|
|
1076
|
+
olderLoadAttemptRef.current = null;
|
|
1077
|
+
setOlderDemand((value) => value + 1);
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
const touchPositionRef = useRef<{ x: number; y: number } | null>(null);
|
|
1081
|
+
|
|
580
1082
|
const onWheel = (event: {
|
|
581
1083
|
deltaY: number;
|
|
582
1084
|
deltaX: number;
|
|
@@ -593,13 +1095,17 @@ export function MessageTimeline({
|
|
|
593
1095
|
if (wheelConsumedByNestedScrollable(event)) {
|
|
594
1096
|
return;
|
|
595
1097
|
}
|
|
1098
|
+
stopSettlement();
|
|
596
1099
|
disclosureKeepsUnpinnedRef.current = false;
|
|
1100
|
+
programmaticScrollRef.current = 0;
|
|
597
1101
|
if (event.deltaY >= 0) {
|
|
1102
|
+
const node = scrollRef.current;
|
|
1103
|
+
readerIntentStartRef.current = node
|
|
1104
|
+
? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
|
|
1105
|
+
: null;
|
|
598
1106
|
return;
|
|
599
1107
|
}
|
|
600
|
-
|
|
601
|
-
event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
|
|
602
|
-
releasePinFromReader(node);
|
|
1108
|
+
requestEarlierFromReader();
|
|
603
1109
|
};
|
|
604
1110
|
|
|
605
1111
|
/** Touch / stylus / mouse drag on the scroller — explicit leave (not layout). */
|
|
@@ -607,11 +1113,13 @@ export function MessageTimeline({
|
|
|
607
1113
|
button: number;
|
|
608
1114
|
pointerType: string;
|
|
609
1115
|
target: EventTarget | null;
|
|
1116
|
+
currentTarget: EventTarget | null;
|
|
610
1117
|
}) => {
|
|
611
1118
|
// Primary button / touch / pen only. Ignore right-click etc.
|
|
612
|
-
if (event.button
|
|
1119
|
+
if (event.button && event.pointerType === "mouse") {
|
|
613
1120
|
return;
|
|
614
1121
|
}
|
|
1122
|
+
stopSettlement();
|
|
615
1123
|
// Clicks on chips/buttons/links must not arm — their settle collapse
|
|
616
1124
|
// also drops scrollTop and would false-unpin. Drag on prose/scroller may.
|
|
617
1125
|
if (
|
|
@@ -621,7 +1129,13 @@ export function MessageTimeline({
|
|
|
621
1129
|
return;
|
|
622
1130
|
}
|
|
623
1131
|
disclosureKeepsUnpinnedRef.current = false;
|
|
1132
|
+
programmaticScrollRef.current = 0;
|
|
1133
|
+
const node =
|
|
1134
|
+
event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
|
|
624
1135
|
readerIntentArmRef.current = true;
|
|
1136
|
+
readerIntentStartRef.current = node
|
|
1137
|
+
? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
|
|
1138
|
+
: null;
|
|
625
1139
|
};
|
|
626
1140
|
|
|
627
1141
|
const onKeyDown = (event: { key: string; currentTarget: EventTarget | null }) => {
|
|
@@ -633,18 +1147,25 @@ export function MessageTimeline({
|
|
|
633
1147
|
event.key === "Home" ||
|
|
634
1148
|
event.key === "End"
|
|
635
1149
|
) {
|
|
1150
|
+
stopSettlement();
|
|
636
1151
|
disclosureKeepsUnpinnedRef.current = false;
|
|
1152
|
+
// Reader navigation supersedes a pending camera/anchor scroll echo,
|
|
1153
|
+
// including downward keys that do not explicitly release the pin.
|
|
1154
|
+
programmaticScrollRef.current = 0;
|
|
1155
|
+
const node = scrollRef.current;
|
|
1156
|
+
readerIntentStartRef.current = node
|
|
1157
|
+
? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
|
|
1158
|
+
: null;
|
|
637
1159
|
}
|
|
638
1160
|
if (event.key !== "ArrowUp" && event.key !== "PageUp" && event.key !== "Home") {
|
|
639
1161
|
return;
|
|
640
1162
|
}
|
|
641
|
-
|
|
642
|
-
event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
|
|
643
|
-
releasePinFromReader(node);
|
|
1163
|
+
requestEarlierFromReader();
|
|
644
1164
|
};
|
|
645
1165
|
|
|
646
1166
|
const snapToBottom = useCallback(
|
|
647
1167
|
(node: HTMLElement) => {
|
|
1168
|
+
clearReaderIntent();
|
|
648
1169
|
stopFollow();
|
|
649
1170
|
cancelLeaveFallback();
|
|
650
1171
|
writeScrollTop(node, Math.max(0, node.scrollHeight - node.clientHeight));
|
|
@@ -655,8 +1176,16 @@ export function MessageTimeline({
|
|
|
655
1176
|
lastClientHeight: node.clientHeight,
|
|
656
1177
|
cameraTop: null,
|
|
657
1178
|
};
|
|
1179
|
+
applyCanSkipTipCatchup(false);
|
|
658
1180
|
},
|
|
659
|
-
[
|
|
1181
|
+
[
|
|
1182
|
+
applyCanSkipTipCatchup,
|
|
1183
|
+
cancelLeaveFallback,
|
|
1184
|
+
clearReaderIntent,
|
|
1185
|
+
stopFollow,
|
|
1186
|
+
syncScrollBaseline,
|
|
1187
|
+
writeScrollTop,
|
|
1188
|
+
],
|
|
660
1189
|
);
|
|
661
1190
|
|
|
662
1191
|
const beginUserMessageDisclosureChange = useCallback(
|
|
@@ -714,11 +1243,171 @@ export function MessageTimeline({
|
|
|
714
1243
|
() => ({
|
|
715
1244
|
expandedByMessageId: userMessageDisclosureMemoryRef.current,
|
|
716
1245
|
beginChange: beginUserMessageDisclosureChange,
|
|
1246
|
+
labels: {
|
|
1247
|
+
showMore: userMessageDisclosureLabels?.showMore,
|
|
1248
|
+
showLess: userMessageDisclosureLabels?.showLess,
|
|
1249
|
+
},
|
|
717
1250
|
}),
|
|
718
|
-
[
|
|
1251
|
+
[
|
|
1252
|
+
beginUserMessageDisclosureChange,
|
|
1253
|
+
userMessageDisclosureLabels?.showMore,
|
|
1254
|
+
userMessageDisclosureLabels?.showLess,
|
|
1255
|
+
],
|
|
1256
|
+
);
|
|
1257
|
+
const timelineGroupEntryContext = useMemo<TimelineGroupEntryContext>(
|
|
1258
|
+
() => ({
|
|
1259
|
+
userMessageDisclosureContext,
|
|
1260
|
+
behavior: {
|
|
1261
|
+
renderMessageActions,
|
|
1262
|
+
renderMessageText: renderSearchableMessageText,
|
|
1263
|
+
onOpenSession,
|
|
1264
|
+
onMemoryClick,
|
|
1265
|
+
onReconnect,
|
|
1266
|
+
renderAuthNeeded,
|
|
1267
|
+
resolveProviderLogo,
|
|
1268
|
+
toolRegistry,
|
|
1269
|
+
loadRetainedScreenshot,
|
|
1270
|
+
loadRetainedArtifact,
|
|
1271
|
+
loadVideoArtifactPlayback,
|
|
1272
|
+
genieLoading,
|
|
1273
|
+
turnSummary,
|
|
1274
|
+
},
|
|
1275
|
+
}),
|
|
1276
|
+
[
|
|
1277
|
+
loadRetainedArtifact,
|
|
1278
|
+
loadRetainedScreenshot,
|
|
1279
|
+
loadVideoArtifactPlayback,
|
|
1280
|
+
onMemoryClick,
|
|
1281
|
+
onOpenSession,
|
|
1282
|
+
onReconnect,
|
|
1283
|
+
renderAuthNeeded,
|
|
1284
|
+
renderMessageActions,
|
|
1285
|
+
renderSearchableMessageText,
|
|
1286
|
+
resolveProviderLogo,
|
|
1287
|
+
toolRegistry,
|
|
1288
|
+
genieLoading,
|
|
1289
|
+
turnSummary,
|
|
1290
|
+
userMessageDisclosureContext,
|
|
1291
|
+
],
|
|
1292
|
+
);
|
|
1293
|
+
|
|
1294
|
+
const releaseSearchPin = useCallback(() => {
|
|
1295
|
+
applyPinned(false);
|
|
1296
|
+
stopFollow();
|
|
1297
|
+
setRevealed(true);
|
|
1298
|
+
}, [applyPinned, stopFollow]);
|
|
1299
|
+
useTimelineSearchNavigation(
|
|
1300
|
+
scrollRef,
|
|
1301
|
+
searchItem?.id,
|
|
1302
|
+
searchTarget,
|
|
1303
|
+
releaseSearchPin,
|
|
1304
|
+
resolvedItems,
|
|
1305
|
+
);
|
|
1306
|
+
|
|
1307
|
+
const requestOlderIfUnderfilled = useCallback(
|
|
1308
|
+
(node: HTMLElement, retry?: OlderLoadAttempt) => {
|
|
1309
|
+
let currentAttempt = olderLoadAttemptRef.current;
|
|
1310
|
+
if (retry && (currentAttempt !== retry || retry[0] !== olderBoundaryKey)) {
|
|
1311
|
+
// AnimatePresence may retain the exiting button briefly. Its stale
|
|
1312
|
+
// handler cannot replace a newer exact owner.
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
const underfilled = maxScrollOf(node) <= 1;
|
|
1316
|
+
if (!retry && underfilled && currentAttempt?.[1] === 3) {
|
|
1317
|
+
// A receipted prefetch already declined or failed while the viewport
|
|
1318
|
+
// was still scrollable. Collapse may later remove the scroll range;
|
|
1319
|
+
// promote that exact owner to Retry without issuing another request.
|
|
1320
|
+
currentAttempt[1] = 0;
|
|
1321
|
+
setUnderfillSettledAttempt(currentAttempt);
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
if (!retry && underfilled && currentAttempt?.[1] === 2) {
|
|
1325
|
+
// A settled ordinary prefetch owns only this visit to the top band.
|
|
1326
|
+
// If its resulting window cannot scroll, yield to automatic underfill
|
|
1327
|
+
// so the reader is not stranded behind a cooldown they cannot exit.
|
|
1328
|
+
currentAttempt = olderLoadAttemptRef.current = null;
|
|
1329
|
+
}
|
|
1330
|
+
// clientHeight=0 is pre-layout/headless, not evidence that the rendered
|
|
1331
|
+
// history underfills a real viewport.
|
|
1332
|
+
if (
|
|
1333
|
+
node.clientHeight <= 1 ||
|
|
1334
|
+
(!retry && !underfilled) ||
|
|
1335
|
+
!hasOlder ||
|
|
1336
|
+
loadingOlder ||
|
|
1337
|
+
!onLoadOlder ||
|
|
1338
|
+
(currentAttempt && !retry)
|
|
1339
|
+
) {
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
const attempt: OlderLoadAttempt = (olderLoadAttemptRef.current = [olderBoundaryKey]);
|
|
1343
|
+
setUnderfillSettledAttempt(null);
|
|
1344
|
+
// Every underfill request owns the ordinary sentinel too, even if reader
|
|
1345
|
+
// intent had not armed it yet when this short-window request began.
|
|
1346
|
+
const noProgress = () => {
|
|
1347
|
+
if (scrollRef.current && olderLoadAttemptRef.current === attempt) {
|
|
1348
|
+
setUnderfillSettledAttempt(attempt);
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
// Legacy fire-and-forget callbacks keep the one-shot behavior. Hosts
|
|
1352
|
+
// that return the real promise opt into safe rejection/no-progress retry;
|
|
1353
|
+
// exact `false` is the first-party request-not-accepted receipt.
|
|
1354
|
+
// All other fulfillment retains this exact owner until its prepend
|
|
1355
|
+
// boundary commits; promise settlement alone cannot prove progress.
|
|
1356
|
+
invokeOlderLoad(onLoadOlder, noProgress, attempt, !retry);
|
|
1357
|
+
},
|
|
1358
|
+
[hasOlder, loadingOlder, olderBoundaryKey, onLoadOlder],
|
|
719
1359
|
);
|
|
1360
|
+
const scheduleQuestionNav = useCallback(() => {
|
|
1361
|
+
if (!readableTurns || questionNavFrameRef.current != null) {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
questionNavFrameRef.current = requestFrame(() => {
|
|
1365
|
+
questionNavFrameRef.current = null;
|
|
1366
|
+
const node = scrollRef.current;
|
|
1367
|
+
// A short viewport (a narrow embed above a decision card) has no room
|
|
1368
|
+
// for a floating pill that would cover the very rows being read.
|
|
1369
|
+
// clientHeight <= 1 is pre-layout/headless, not a short viewport.
|
|
1370
|
+
const roomy =
|
|
1371
|
+
node !== null &&
|
|
1372
|
+
(node.clientHeight <= 1 || node.clientHeight >= questionNavMinViewportRef.current);
|
|
1373
|
+
const next = node && roomy ? readQuestionNav(node) : null;
|
|
1374
|
+
setQuestionNav((current) => (sameQuestionNav(current, next) ? current : next));
|
|
1375
|
+
});
|
|
1376
|
+
}, [readableTurns]);
|
|
1377
|
+
scheduleQuestionNavRef.current = scheduleQuestionNav;
|
|
1378
|
+
useEffect(
|
|
1379
|
+
() => () => {
|
|
1380
|
+
if (questionNavFrameRef.current != null) {
|
|
1381
|
+
cancelFrame(questionNavFrameRef.current);
|
|
1382
|
+
questionNavFrameRef.current = null;
|
|
1383
|
+
}
|
|
1384
|
+
},
|
|
1385
|
+
[],
|
|
1386
|
+
);
|
|
1387
|
+
const jumpToQuestion = (key: string) => {
|
|
1388
|
+
const node = scrollRef.current;
|
|
1389
|
+
const top = node ? contentTopOf(node, key) : null;
|
|
1390
|
+
if (!node || top === null) {
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
// Explicit reader navigation: leave the tip and park the question on top.
|
|
1394
|
+
releasePinFromReader();
|
|
1395
|
+
wantPinRef.current = false;
|
|
1396
|
+
disclosureKeepsUnpinnedRef.current = false;
|
|
1397
|
+
writeScrollTop(node, Math.max(0, top - QUESTION_NAV_MARGIN_PX));
|
|
1398
|
+
// Hand keyboard/accessibility focus to the actual destination. This also
|
|
1399
|
+
// gives native scroll anchoring the new question rather than an old work
|
|
1400
|
+
// row when the bounded context subsequently gains a later history page.
|
|
1401
|
+
node
|
|
1402
|
+
.querySelector<HTMLElement>(`[data-og-group-key="${cssEscapeAttribute(key)}"]`)
|
|
1403
|
+
?.focus({ preventScroll: true });
|
|
1404
|
+
syncScrollBaseline(node);
|
|
1405
|
+
scheduleQuestionNav();
|
|
1406
|
+
};
|
|
720
1407
|
|
|
721
|
-
const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(
|
|
1408
|
+
const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(
|
|
1409
|
+
requestOlderIfUnderfilled as (node: HTMLElement, now?: number) => void,
|
|
1410
|
+
);
|
|
722
1411
|
const driveFollow = useCallback(
|
|
723
1412
|
(node: HTMLElement, nowMs?: number) => {
|
|
724
1413
|
if (!pinnedRef.current || hasNewerRef.current) {
|
|
@@ -739,19 +1428,41 @@ export function MessageTimeline({
|
|
|
739
1428
|
: typeof performance !== "undefined"
|
|
740
1429
|
? performance.now()
|
|
741
1430
|
: Date.now();
|
|
742
|
-
|
|
1431
|
+
let previousHeight = followRef.current.lastHeight;
|
|
1432
|
+
const previousObservedHeight = lastScrollHeightRef.current;
|
|
1433
|
+
if (
|
|
1434
|
+
contentShrinkBaselineRef.current &&
|
|
1435
|
+
previousObservedHeight > 0 &&
|
|
1436
|
+
node.scrollHeight > previousObservedHeight
|
|
1437
|
+
) {
|
|
1438
|
+
// A reversal ends the collapse sequence. If it remains below the held
|
|
1439
|
+
// baseline, adopt the recovered height; if it grew beyond the baseline,
|
|
1440
|
+
// leave that baseline for tipFollowStep to observe as real growth.
|
|
1441
|
+
contentShrinkBaselineRef.current = null;
|
|
1442
|
+
if (node.scrollHeight < previousHeight) {
|
|
1443
|
+
followRef.current = {
|
|
1444
|
+
...followRef.current,
|
|
1445
|
+
lastHeight: node.scrollHeight,
|
|
1446
|
+
cameraTop: null,
|
|
1447
|
+
};
|
|
1448
|
+
previousHeight = node.scrollHeight;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const shrinkObservation = tipFollowObserveContentShrink(
|
|
1452
|
+
contentShrinkBaselineRef.current,
|
|
1453
|
+
previousHeight,
|
|
1454
|
+
lastScrollTopRef.current,
|
|
1455
|
+
node.scrollHeight,
|
|
1456
|
+
node.clientHeight,
|
|
1457
|
+
);
|
|
1458
|
+
contentShrinkBaselineRef.current = shrinkObservation.baseline;
|
|
743
1459
|
// Settle-collapse: compensate Δh from the pre-shrink baseline (browser
|
|
744
1460
|
// may already have clamped — don't double-subtract). Keep the follow rAF
|
|
745
1461
|
// alive so when collapse ends (or stream resumes) we ease instead of a
|
|
746
1462
|
// hard stop → flick. Do NOT tip-ease on the same frame as a real shrink
|
|
747
1463
|
// (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
|
-
);
|
|
1464
|
+
if (shrinkObservation.compensatedScrollTop !== null) {
|
|
1465
|
+
let nextTop = shrinkObservation.compensatedScrollTop;
|
|
755
1466
|
// A chrome/composer dock can land on the same frame as a settle-fold
|
|
756
1467
|
// (the "turn blocked" moment). Compensate BOTH in one write — adopting
|
|
757
1468
|
// the shrunk clientHeight below without gluing left the chrome height
|
|
@@ -776,7 +1487,7 @@ export function MessageTimeline({
|
|
|
776
1487
|
// A direct glue write re-based the camera — drop any stale fraction.
|
|
777
1488
|
cameraTop: null,
|
|
778
1489
|
};
|
|
779
|
-
if (followFrameRef.current
|
|
1490
|
+
if (followFrameRef.current == null) {
|
|
780
1491
|
followFrameRef.current = requestFrame((frameNow) => {
|
|
781
1492
|
followFrameRef.current = null;
|
|
782
1493
|
const current = scrollRef.current;
|
|
@@ -787,15 +1498,6 @@ export function MessageTimeline({
|
|
|
787
1498
|
}
|
|
788
1499
|
return;
|
|
789
1500
|
}
|
|
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
1501
|
const result = tipFollowStep(followRef.current, {
|
|
800
1502
|
scrollTop: node.scrollTop,
|
|
801
1503
|
scrollHeight: node.scrollHeight,
|
|
@@ -808,9 +1510,13 @@ export function MessageTimeline({
|
|
|
808
1510
|
followRef.current = result.state;
|
|
809
1511
|
writeScrollTop(node, result.scrollTop);
|
|
810
1512
|
syncScrollBaseline(node);
|
|
1513
|
+
applyCanSkipTipCatchup(
|
|
1514
|
+
result.state.running &&
|
|
1515
|
+
maxScrollOf(node) - node.scrollTop >= JUMP_TO_LATEST_CATCHUP_DEBT_PX,
|
|
1516
|
+
);
|
|
811
1517
|
if (result.state.running) {
|
|
812
1518
|
cancelLeaveFallback();
|
|
813
|
-
if (followFrameRef.current
|
|
1519
|
+
if (followFrameRef.current == null) {
|
|
814
1520
|
followFrameRef.current = requestFrame((frameNow) => {
|
|
815
1521
|
followFrameRef.current = null;
|
|
816
1522
|
const current = scrollRef.current;
|
|
@@ -819,12 +1525,12 @@ export function MessageTimeline({
|
|
|
819
1525
|
}
|
|
820
1526
|
});
|
|
821
1527
|
}
|
|
822
|
-
} else if (followFrameRef.current
|
|
1528
|
+
} else if (followFrameRef.current != null) {
|
|
823
1529
|
cancelFrame(followFrameRef.current);
|
|
824
1530
|
followFrameRef.current = null;
|
|
825
1531
|
}
|
|
826
1532
|
},
|
|
827
|
-
[cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
|
|
1533
|
+
[applyCanSkipTipCatchup, cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
|
|
828
1534
|
);
|
|
829
1535
|
driveFollowRef.current = driveFollow;
|
|
830
1536
|
|
|
@@ -842,12 +1548,80 @@ export function MessageTimeline({
|
|
|
842
1548
|
return;
|
|
843
1549
|
}
|
|
844
1550
|
const previousFirstItemId = previousFirstItemIdRef.current;
|
|
845
|
-
const
|
|
846
|
-
const
|
|
1551
|
+
const previousFirstItemGroupKey = firstItemGroupKeyRef.current;
|
|
1552
|
+
const previousFirstItemGroupOffsetTop = firstItemGroupOffsetTopRef.current;
|
|
1553
|
+
const previousItemContentTop = firstItemContentTopRef.current;
|
|
847
1554
|
const previousScrollTop = lastScrollTopRef.current;
|
|
848
|
-
const
|
|
849
|
-
const
|
|
850
|
-
|
|
1555
|
+
const previousMaxScroll = lastMaxScrollRef.current;
|
|
1556
|
+
const wasAtLiveTailBeforeCommit =
|
|
1557
|
+
previousMaxScroll <= 1 ||
|
|
1558
|
+
previousMaxScroll - previousScrollTop < Math.min(PIN_THRESHOLD_PX, previousMaxScroll);
|
|
1559
|
+
const firstItemChanged = !!previousFirstItemId && firstItemId !== previousFirstItemId;
|
|
1560
|
+
const sourceChanged = olderBoundaryKey !== previousSourceBoundaryRef.current;
|
|
1561
|
+
const retainedSource =
|
|
1562
|
+
sourceItems?.some((item) => previousSourceIdsRef.current.has(item.id)) ?? false;
|
|
1563
|
+
const prepended = sourceChanged && retainedSource;
|
|
1564
|
+
previousSourceBoundaryRef.current = olderBoundaryKey;
|
|
1565
|
+
previousSourceIdsRef.current = new Set(sourceItems?.map((item) => item.id));
|
|
1566
|
+
const readingAnchor = readingAnchorRef.current;
|
|
1567
|
+
readingAnchorRef.current = null;
|
|
1568
|
+
const settlement = settlementRef.current;
|
|
1569
|
+
settlementRef.current = null;
|
|
1570
|
+
const attempt = olderLoadAttemptRef.current;
|
|
1571
|
+
const committedZeroOverlapOlderReplacement = !!(
|
|
1572
|
+
attempt?.[2]?.committed &&
|
|
1573
|
+
sourceChanged &&
|
|
1574
|
+
!retainedSource
|
|
1575
|
+
);
|
|
1576
|
+
const restorePrependAnchor = () => {
|
|
1577
|
+
const correction = readingAnchor && timelineAnchorCorrection(node, readingAnchor);
|
|
1578
|
+
if (correction != null) {
|
|
1579
|
+
if (Math.abs(correction) > 1) writeScrollTop(node, node.scrollTop + correction);
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
// Keep the reader on the same retained rows. Prefer the exact first-item
|
|
1583
|
+
// content coordinate (needed when a prepend merges inside one group),
|
|
1584
|
+
// then the retained group offset, then scrollHeight as a final fallback.
|
|
1585
|
+
// If native anchoring already applied the same shift, leave scrollTop.
|
|
1586
|
+
let delta: number | null = null;
|
|
1587
|
+
if (previousFirstItemId && previousItemContentTop != null) {
|
|
1588
|
+
const itemEl = node.querySelector(
|
|
1589
|
+
`[data-og-item="${cssEscapeAttribute(previousFirstItemId)}"]`,
|
|
1590
|
+
);
|
|
1591
|
+
if (itemEl instanceof HTMLElement) {
|
|
1592
|
+
const scrollerTop = node.getBoundingClientRect().top;
|
|
1593
|
+
const currentItemTop = itemEl.getBoundingClientRect().top - scrollerTop + node.scrollTop;
|
|
1594
|
+
delta = Math.round(currentItemTop - previousItemContentTop);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
const anchorKey = previousFirstItemGroupKey;
|
|
1598
|
+
const anchorEl =
|
|
1599
|
+
anchorKey != null
|
|
1600
|
+
? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(anchorKey)}"]`)
|
|
1601
|
+
: null;
|
|
1602
|
+
if (
|
|
1603
|
+
delta == null &&
|
|
1604
|
+
anchorEl instanceof HTMLElement &&
|
|
1605
|
+
previousFirstItemGroupOffsetTop != null
|
|
1606
|
+
) {
|
|
1607
|
+
const moved = Math.round(anchorEl.offsetTop - previousFirstItemGroupOffsetTop);
|
|
1608
|
+
if (moved) {
|
|
1609
|
+
delta = moved;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
if (delta == null) {
|
|
1613
|
+
const heightDelta = Math.round(node.scrollHeight - previousScrollHeightRef.current);
|
|
1614
|
+
if (heightDelta > 0) {
|
|
1615
|
+
delta = heightDelta;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
if (delta != null) {
|
|
1619
|
+
const expected = previousScrollTop + delta;
|
|
1620
|
+
if (Math.abs(node.scrollTop - expected) > 2) {
|
|
1621
|
+
writeScrollTop(node, expected);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
};
|
|
851
1625
|
if (pendingJumpToStartRef.current && firstItemChanged) {
|
|
852
1626
|
// The oldest window landed — jump against the NEW DOM, and skip the
|
|
853
1627
|
// prepend correction (it would shift the reader away from the top).
|
|
@@ -863,6 +1637,64 @@ export function MessageTimeline({
|
|
|
863
1637
|
applyPinned(true);
|
|
864
1638
|
snapToBottom(node);
|
|
865
1639
|
}
|
|
1640
|
+
} else if (committedZeroOverlapOlderReplacement) {
|
|
1641
|
+
// An oldest-directed bounded page can replace every previously mounted
|
|
1642
|
+
// row. Its receipt is the only causal proof that this is backward
|
|
1643
|
+
// progress rather than live-tail forward eviction. Anchor at the bottom
|
|
1644
|
+
// seam before recording this window's scroll baselines so an unpinned
|
|
1645
|
+
// reader stays adjacent to the history they were reading.
|
|
1646
|
+
clearPendingReaderLeave();
|
|
1647
|
+
stopFollow();
|
|
1648
|
+
writeScrollTop(node, maxScrollOf(node));
|
|
1649
|
+
if (attempt?.[1] === 1 && pinnedRef.current) {
|
|
1650
|
+
applyPinned(false);
|
|
1651
|
+
}
|
|
1652
|
+
} else if (prepended) {
|
|
1653
|
+
const olderAttemptState = olderLoadAttemptRef.current?.[1];
|
|
1654
|
+
const underfillOwned =
|
|
1655
|
+
!!olderLoadAttemptRef.current &&
|
|
1656
|
+
olderAttemptState !== 1 &&
|
|
1657
|
+
olderAttemptState !== 2 &&
|
|
1658
|
+
olderAttemptState !== 3;
|
|
1659
|
+
if (
|
|
1660
|
+
autoFollow &&
|
|
1661
|
+
pinnedRef.current &&
|
|
1662
|
+
!hasNewer &&
|
|
1663
|
+
!pendingReaderLeaveRef.current &&
|
|
1664
|
+
(wasAtLiveTailBeforeCommit || underfillOwned)
|
|
1665
|
+
) {
|
|
1666
|
+
// Still following the live tip: underfill, or a prefetch the reader
|
|
1667
|
+
// started and then returned from. Park at the new tip. A stale pin
|
|
1668
|
+
// while they are actually up in a short window (gap is not inside
|
|
1669
|
+
// PIN_THRESHOLD of maxScroll, so wasAtLiveTail is false) restores.
|
|
1670
|
+
clearPendingReaderLeave();
|
|
1671
|
+
snapToBottom(node);
|
|
1672
|
+
} else {
|
|
1673
|
+
restorePrependAnchor();
|
|
1674
|
+
if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
1675
|
+
// Geometry or a pending extension/programmatic leave proves the
|
|
1676
|
+
// reader was browsing history even if the pin ref has not settled.
|
|
1677
|
+
stopFollow();
|
|
1678
|
+
clearPendingReaderLeave();
|
|
1679
|
+
applyPinned(false);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
} else if (!pinnedRef.current && readingAnchor && readableTurns) {
|
|
1683
|
+
// A live work row moves behind newly streamed prose. Correct only the
|
|
1684
|
+
// residual movement native anchoring did not absorb, using the reader's
|
|
1685
|
+
// retained paragraph/control rather than the moving row's outer box.
|
|
1686
|
+
const correction = timelineAnchorCorrection(node, readingAnchor);
|
|
1687
|
+
if (correction !== null && Math.abs(correction) > 1) {
|
|
1688
|
+
writeScrollTop(node, node.scrollTop + correction);
|
|
1689
|
+
}
|
|
1690
|
+
const focused = readingAnchor.find((anchor) => anchor.focused)?.element;
|
|
1691
|
+
if (
|
|
1692
|
+
focused &&
|
|
1693
|
+
node.contains(focused) &&
|
|
1694
|
+
node.ownerDocument.activeElement === node.ownerDocument.body
|
|
1695
|
+
) {
|
|
1696
|
+
focused.focus({ preventScroll: true });
|
|
1697
|
+
}
|
|
866
1698
|
} else if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
867
1699
|
// Load/remount (still hidden): hard-park. Live tip after reveal: ease.
|
|
868
1700
|
// Pending unarmed leave: tip *growth* must not yank (Vimium during stream).
|
|
@@ -877,83 +1709,94 @@ export function MessageTimeline({
|
|
|
877
1709
|
} else {
|
|
878
1710
|
driveFollow(node);
|
|
879
1711
|
}
|
|
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
1712
|
}
|
|
915
1713
|
// After a prepend, if restore left us below the top prefetch band,
|
|
916
1714
|
// re-arm so a later approach can load again. Still cooling while parked
|
|
917
1715
|
// 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";
|
|
1716
|
+
if (prepended && !pinnedRef.current && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
1717
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
925
1718
|
}
|
|
1719
|
+
scheduleQuestionNav();
|
|
926
1720
|
previousFirstItemIdRef.current = firstItemId;
|
|
927
1721
|
previousScrollHeightRef.current = node.scrollHeight;
|
|
928
1722
|
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);
|
|
1723
|
+
// offsetTop queries are O(groups); skip while pinned at the live tip
|
|
1724
|
+
// (every stream token used to remeasure the whole timeline).
|
|
1725
|
+
firstItemGroupKeyRef.current = groups[0]?.key ?? null;
|
|
1726
|
+
const needOffsets =
|
|
1727
|
+
prepended ||
|
|
1728
|
+
firstItemChanged ||
|
|
1729
|
+
!pinnedRef.current ||
|
|
1730
|
+
hasNewer ||
|
|
1731
|
+
firstItemContentTopRef.current == null;
|
|
940
1732
|
if (needOffsets) {
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1733
|
+
const committedFirstGroupKey = firstItemGroupKeyRef.current;
|
|
1734
|
+
const firstGroupEl = committedFirstGroupKey
|
|
1735
|
+
? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(committedFirstGroupKey)}"]`)
|
|
1736
|
+
: null;
|
|
1737
|
+
firstItemGroupOffsetTopRef.current =
|
|
1738
|
+
firstGroupEl instanceof HTMLElement ? firstGroupEl.offsetTop : null;
|
|
1739
|
+
const firstItemEl = firstItemId
|
|
1740
|
+
? node.querySelector(`[data-og-item="${cssEscapeAttribute(firstItemId)}"]`)
|
|
1741
|
+
: null;
|
|
1742
|
+
firstItemContentTopRef.current =
|
|
1743
|
+
firstItemId && firstItemEl instanceof HTMLElement
|
|
1744
|
+
? firstItemEl.getBoundingClientRect().top -
|
|
1745
|
+
node.getBoundingClientRect().top +
|
|
1746
|
+
node.scrollTop
|
|
1747
|
+
: null;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
if (settlement) settlementAnimationRef.current = animateTimelineSettlement(settlement);
|
|
1751
|
+
|
|
1752
|
+
// Promise settlement is not itself permission to retry. A receipt-marked
|
|
1753
|
+
// accepted page retires its exact owner on this commit even when projection
|
|
1754
|
+
// is empty or merges into the same first item. Without that mark, retain
|
|
1755
|
+
// the compatibility fallback: a retained prior boundary proves prepend,
|
|
1756
|
+
// while a missing prior boundary is forward eviction and merely rebases.
|
|
1757
|
+
if (!attempt) {
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
if (!attempt[2]?.committed && attempt[0] === olderBoundaryKey) {
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
if (
|
|
1764
|
+
!attempt[2]?.committed &&
|
|
1765
|
+
attempt[0] &&
|
|
1766
|
+
!sourceItems?.find((entry) => entry.id === attempt[0])
|
|
1767
|
+
) {
|
|
1768
|
+
attempt[0] = olderBoundaryKey;
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
if (!attempt[1] || !hasOlder) {
|
|
1772
|
+
setUnderfillSettledAttempt((olderLoadAttemptRef.current = null));
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
// Boundary progress retires the request owner. A late settlement from
|
|
1776
|
+
// that completed prefetch cannot mutate the new window's cooldown owner.
|
|
1777
|
+
olderLoadAttemptRef.current = [olderBoundaryKey, 2];
|
|
1778
|
+
// Bound automatic work, but let continued upward input replenish demand.
|
|
1779
|
+
// Only a committed first-party receipt proves that another page is safe.
|
|
1780
|
+
if (
|
|
1781
|
+
attempt[2]?.committed &&
|
|
1782
|
+
!pinnedRef.current &&
|
|
1783
|
+
hasOlder &&
|
|
1784
|
+
node.scrollTop <= OLDER_PREFETCH_MARGIN_PX &&
|
|
1785
|
+
olderPageBudgetRef.current > 0
|
|
1786
|
+
) {
|
|
1787
|
+
olderPageBudgetRef.current -= 1;
|
|
1788
|
+
olderLoadAttemptRef.current = null;
|
|
1789
|
+
setOlderDemand((value) => value + 1);
|
|
949
1790
|
}
|
|
1791
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
1792
|
+
requestOlderIfUnderfilled(node);
|
|
950
1793
|
});
|
|
951
1794
|
|
|
952
1795
|
// First paint / session remount: keep the scroller hidden, snap to tip for
|
|
953
1796
|
// two animation frames (late sync layout), then reveal. Does not change the
|
|
954
|
-
// tip-follow
|
|
1797
|
+
// live tip-follow law used once `revealed` is true.
|
|
955
1798
|
useLayoutEffect(() => {
|
|
956
|
-
if (revealed || allGroups.length
|
|
1799
|
+
if (revealed || !allGroups.length) {
|
|
957
1800
|
return;
|
|
958
1801
|
}
|
|
959
1802
|
let cancelled = false;
|
|
@@ -981,7 +1824,7 @@ export function MessageTimeline({
|
|
|
981
1824
|
return () => {
|
|
982
1825
|
cancelled = true;
|
|
983
1826
|
cancelFrame(frame1);
|
|
984
|
-
if (frame2
|
|
1827
|
+
if (frame2) {
|
|
985
1828
|
cancelFrame(frame2);
|
|
986
1829
|
}
|
|
987
1830
|
};
|
|
@@ -1000,7 +1843,6 @@ export function MessageTimeline({
|
|
|
1000
1843
|
olderPrefetchArmedRef.current = false;
|
|
1001
1844
|
setOlderPrefetchArmed(false);
|
|
1002
1845
|
}
|
|
1003
|
-
olderLoadGateRef.current = "armed";
|
|
1004
1846
|
wantPinRef.current = false;
|
|
1005
1847
|
pendingJumpToStartRef.current = false;
|
|
1006
1848
|
previousFirstItemIdRef.current = null;
|
|
@@ -1009,14 +1851,27 @@ export function MessageTimeline({
|
|
|
1009
1851
|
lastMaxScrollRef.current = 0;
|
|
1010
1852
|
lastScrollHeightRef.current = 0;
|
|
1011
1853
|
lastClientHeightRef.current = 0;
|
|
1012
|
-
|
|
1013
|
-
|
|
1854
|
+
firstItemGroupKeyRef.current = null;
|
|
1855
|
+
firstItemGroupOffsetTopRef.current = null;
|
|
1856
|
+
firstItemContentTopRef.current = null;
|
|
1014
1857
|
foldMemoryRef.current.clear();
|
|
1015
1858
|
userMessageDisclosureMemoryRef.current.clear();
|
|
1016
1859
|
disclosureKeepsUnpinnedRef.current = false;
|
|
1860
|
+
clearReaderIntent();
|
|
1861
|
+
contentShrinkBaselineRef.current = null;
|
|
1017
1862
|
seenActivityIdsRef.current.clear();
|
|
1018
1863
|
applyPinned(true);
|
|
1019
|
-
}, [allGroups.length, revealed, applyPinned]);
|
|
1864
|
+
}, [allGroups.length, revealed, applyPinned, clearReaderIntent]);
|
|
1865
|
+
|
|
1866
|
+
// Parent commits cover the initial/history-loading cases. Disclosure state
|
|
1867
|
+
// changes are child-local, so the ResizeObserver below owns dynamic collapse
|
|
1868
|
+
// and expansion after mount.
|
|
1869
|
+
useEffect(() => {
|
|
1870
|
+
const node = scrollRef.current;
|
|
1871
|
+
if (node) {
|
|
1872
|
+
requestOlderIfUnderfilled(node);
|
|
1873
|
+
}
|
|
1874
|
+
});
|
|
1020
1875
|
|
|
1021
1876
|
// Clear the bulk-paint marker a frame after it renders, so rows appended
|
|
1022
1877
|
// live (streams, new turns) animate exactly as before.
|
|
@@ -1052,21 +1907,61 @@ export function MessageTimeline({
|
|
|
1052
1907
|
(entries) => {
|
|
1053
1908
|
const intersecting = entries.some((entry) => entry.isIntersecting);
|
|
1054
1909
|
if (!intersecting) {
|
|
1055
|
-
// Left the top band
|
|
1056
|
-
|
|
1910
|
+
// Left the top band. A settled ordinary prefetch releases its exact
|
|
1911
|
+
// owner here; a still-pending request remains cooling so a quick
|
|
1912
|
+
// leave/re-enter cannot overlap it.
|
|
1913
|
+
const attempt = olderLoadAttemptRef.current;
|
|
1914
|
+
if (attempt?.[1] === 2 || attempt?.[1] === 3) {
|
|
1915
|
+
olderLoadAttemptRef.current = null;
|
|
1916
|
+
}
|
|
1057
1917
|
return;
|
|
1058
1918
|
}
|
|
1059
|
-
|
|
1919
|
+
// Both automatic underfill and ordinary prefetch use one boundary
|
|
1920
|
+
// owner. In particular, live growth cannot arm a second sentinel load
|
|
1921
|
+
// while the short-window request that preceded it is still pending.
|
|
1922
|
+
if (olderLoadAttemptRef.current) {
|
|
1060
1923
|
return;
|
|
1061
1924
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1925
|
+
const attempt: OlderLoadAttempt = (olderLoadAttemptRef.current = [olderBoundaryKey, 1]);
|
|
1926
|
+
const noProgress = () => {
|
|
1927
|
+
if (!scrollRef.current || olderLoadAttemptRef.current !== attempt) {
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
attempt[1] = 3;
|
|
1931
|
+
if (root.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
|
|
1932
|
+
olderLoadAttemptRef.current = null;
|
|
1933
|
+
} else if (maxScrollOf(root) <= 1) {
|
|
1934
|
+
attempt[1] = 0;
|
|
1935
|
+
setUnderfillSettledAttempt(attempt);
|
|
1936
|
+
}
|
|
1937
|
+
};
|
|
1938
|
+
// Preserve legacy fire-and-forget top-band retries: the callback has
|
|
1939
|
+
// synchronously returned, but this visit remains cooling until exit.
|
|
1940
|
+
// Only rejection/throw/exact `false` is a no-progress receipt. Other
|
|
1941
|
+
// fulfillment retains the pending owner until boundary commit;
|
|
1942
|
+
// otherwise a delayed prepend could overlap a same-boundary request.
|
|
1943
|
+
if (
|
|
1944
|
+
!invokeOlderLoad(onLoadOlder, noProgress, attempt) &&
|
|
1945
|
+
olderLoadAttemptRef.current === attempt
|
|
1946
|
+
) {
|
|
1947
|
+
olderLoadAttemptRef.current[1] = 2;
|
|
1948
|
+
requestOlderIfUnderfilled(root);
|
|
1949
|
+
}
|
|
1064
1950
|
},
|
|
1065
1951
|
{ root, rootMargin: OLDER_PREFETCH_ROOT_MARGIN },
|
|
1066
1952
|
);
|
|
1067
1953
|
observer.observe(target);
|
|
1068
1954
|
return () => observer.disconnect();
|
|
1069
|
-
}, [
|
|
1955
|
+
}, [
|
|
1956
|
+
firstGroupKey,
|
|
1957
|
+
hasOlder,
|
|
1958
|
+
loadingOlder,
|
|
1959
|
+
olderBoundaryKey,
|
|
1960
|
+
olderPrefetchArmed,
|
|
1961
|
+
olderDemand,
|
|
1962
|
+
onLoadOlder,
|
|
1963
|
+
requestOlderIfUnderfilled,
|
|
1964
|
+
]);
|
|
1070
1965
|
|
|
1071
1966
|
// History view: page forward when the reader nears the bottom of the current
|
|
1072
1967
|
// non-tip window. Does not pull the whole gap — one density-bounded page.
|
|
@@ -1085,15 +1980,17 @@ export function MessageTimeline({
|
|
|
1085
1980
|
}
|
|
1086
1981
|
const observer = new IntersectionObserver(
|
|
1087
1982
|
(entries) => {
|
|
1088
|
-
|
|
1089
|
-
|
|
1983
|
+
// An underfilled history window has both sentinels visible. Advancing
|
|
1984
|
+
// it automatically would undo an explicit older-page navigation.
|
|
1985
|
+
if (maxScrollOf(root) > 1 && entries.some((entry) => entry.isIntersecting)) {
|
|
1986
|
+
requestNewer();
|
|
1090
1987
|
}
|
|
1091
1988
|
},
|
|
1092
1989
|
{ root, rootMargin: "0px 0px 1200px 0px" },
|
|
1093
1990
|
);
|
|
1094
1991
|
observer.observe(target);
|
|
1095
1992
|
return () => observer.disconnect();
|
|
1096
|
-
}, [hasNewer, loadingNewer, onLoadNewer, firstGroupKey]);
|
|
1993
|
+
}, [hasNewer, loadingNewer, onLoadNewer, requestNewer, firstGroupKey]);
|
|
1097
1994
|
|
|
1098
1995
|
// Late layout that React commits cannot see (images decoding, fonts, code
|
|
1099
1996
|
// blocks) grows content without a commit. While pinned, soft-follow the tip;
|
|
@@ -1106,7 +2003,7 @@ export function MessageTimeline({
|
|
|
1106
2003
|
return;
|
|
1107
2004
|
}
|
|
1108
2005
|
const observer = new ResizeObserver(() => {
|
|
1109
|
-
if (resizeFollowRafRef.current
|
|
2006
|
+
if (resizeFollowRafRef.current != null) {
|
|
1110
2007
|
return;
|
|
1111
2008
|
}
|
|
1112
2009
|
resizeFollowRafRef.current = requestFrame(() => {
|
|
@@ -1115,6 +2012,10 @@ export function MessageTimeline({
|
|
|
1115
2012
|
if (!current) {
|
|
1116
2013
|
return;
|
|
1117
2014
|
}
|
|
2015
|
+
// A viewport that shrank below the pill's minimum hides it now, not
|
|
2016
|
+
// on the next scroll.
|
|
2017
|
+
if (questionNavMinViewportRef.current > 0) scheduleQuestionNavRef.current();
|
|
2018
|
+
requestOlderIfUnderfilled(current);
|
|
1118
2019
|
if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
|
|
1119
2020
|
return;
|
|
1120
2021
|
}
|
|
@@ -1132,12 +2033,12 @@ export function MessageTimeline({
|
|
|
1132
2033
|
observer.observe(node);
|
|
1133
2034
|
return () => {
|
|
1134
2035
|
observer.disconnect();
|
|
1135
|
-
if (resizeFollowRafRef.current
|
|
2036
|
+
if (resizeFollowRafRef.current != null) {
|
|
1136
2037
|
cancelFrame(resizeFollowRafRef.current);
|
|
1137
2038
|
resizeFollowRafRef.current = null;
|
|
1138
2039
|
}
|
|
1139
2040
|
};
|
|
1140
|
-
}, [autoFollow, driveFollow, snapToBottom]);
|
|
2041
|
+
}, [autoFollow, driveFollow, requestOlderIfUnderfilled, snapToBottom]);
|
|
1141
2042
|
|
|
1142
2043
|
// Entering a non-tip history window: drop any live pin so the page bottom
|
|
1143
2044
|
// cannot re-stick follow across loadNewer. Leaving it (the tip window
|
|
@@ -1145,6 +2046,8 @@ export function MessageTimeline({
|
|
|
1145
2046
|
// at what just became the live bottom — paging forward to the tip must not
|
|
1146
2047
|
// strand them unpinned watching new content grow below.
|
|
1147
2048
|
useEffect(() => {
|
|
2049
|
+
// Closing find is paint-only; it must not trigger this history-to-tip rule.
|
|
2050
|
+
if (searchTargetRef.current) return;
|
|
1148
2051
|
if (hasNewer) {
|
|
1149
2052
|
stopFollow();
|
|
1150
2053
|
applyPinned(false);
|
|
@@ -1167,6 +2070,15 @@ export function MessageTimeline({
|
|
|
1167
2070
|
}
|
|
1168
2071
|
}, [hasNewer, autoFollow, applyPinned, snapToBottom, stopFollow]);
|
|
1169
2072
|
|
|
2073
|
+
// After the scroll authority above has settled each commit, record whether
|
|
2074
|
+
// the reader is away from the tip (content can land below an unpinned
|
|
2075
|
+
// reader without any scroll event).
|
|
2076
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Deliberately runs after every commit.
|
|
2077
|
+
useLayoutEffect(() => {
|
|
2078
|
+
const node = scrollRef.current;
|
|
2079
|
+
if (node && !pinnedRef.current) setAwayFromTip(!isNearBottom(node));
|
|
2080
|
+
});
|
|
2081
|
+
|
|
1170
2082
|
// Pinned: layout/camera recover tip debt; wheel/keys/pointer-arm unpin
|
|
1171
2083
|
// immediately; extension jumps settle via scrollend (or one-rAF fallback).
|
|
1172
2084
|
// Do not tip-follow-yank an in-flight unarmed scroll-away — that ate Vimium.
|
|
@@ -1175,6 +2087,9 @@ export function MessageTimeline({
|
|
|
1175
2087
|
if (!node) {
|
|
1176
2088
|
return;
|
|
1177
2089
|
}
|
|
2090
|
+
// Only an unpinned reader's distance matters for the jump.
|
|
2091
|
+
if (!pinnedRef.current) setAwayFromTip(!isNearBottom(node));
|
|
2092
|
+
scheduleQuestionNav();
|
|
1178
2093
|
const previousTop = lastScrollTopRef.current;
|
|
1179
2094
|
const previousMaxScroll = lastMaxScrollRef.current;
|
|
1180
2095
|
const nextTop = node.scrollTop;
|
|
@@ -1183,6 +2098,16 @@ export function MessageTimeline({
|
|
|
1183
2098
|
const readerUp = readerScrollUpPx(previousTop, nextTop, previousMaxScroll, nextMaxScroll);
|
|
1184
2099
|
const maxFell = nextMaxScroll < previousMaxScroll - 1;
|
|
1185
2100
|
const readerArmed = readerIntentArmRef.current;
|
|
2101
|
+
const readerIntentStart = readerIntentStartRef.current;
|
|
2102
|
+
const cumulativeReaderUp =
|
|
2103
|
+
readerArmed && readerIntentStart
|
|
2104
|
+
? readerScrollUpPx(
|
|
2105
|
+
readerIntentStart.scrollTop,
|
|
2106
|
+
nextTop,
|
|
2107
|
+
readerIntentStart.maxScroll,
|
|
2108
|
+
nextMaxScroll,
|
|
2109
|
+
)
|
|
2110
|
+
: readerUp;
|
|
1186
2111
|
const heightShrunk =
|
|
1187
2112
|
followRef.current.lastHeight > 0 &&
|
|
1188
2113
|
nextHeight < followRef.current.lastHeight - TIP_FOLLOW_SHRINK_EPS_PX;
|
|
@@ -1198,6 +2123,19 @@ export function MessageTimeline({
|
|
|
1198
2123
|
return;
|
|
1199
2124
|
}
|
|
1200
2125
|
|
|
2126
|
+
if (programmatic && !pinnedRef.current) {
|
|
2127
|
+
// Restore/camera writes while reading history are not a return to the tip.
|
|
2128
|
+
// Still expire a pending Jump-to-latest latch: the in-window jump itself
|
|
2129
|
+
// is a programmatic snap, and a later reader scroll-away can arrive
|
|
2130
|
+
// before that echo is consumed. Skipping this left the latch armed and
|
|
2131
|
+
// snapped the reader when hasNewer later flipped false.
|
|
2132
|
+
syncScrollBaseline(node);
|
|
2133
|
+
if (wantPinRef.current && !isNearBottom(node)) {
|
|
2134
|
+
wantPinRef.current = false;
|
|
2135
|
+
}
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
1201
2139
|
if (autoFollow && pinnedRef.current && !hasNewer) {
|
|
1202
2140
|
// Fold / composer / SessionChrome: viewport shrink raises maxScroll without
|
|
1203
2141
|
// growing content. Must hit tipFollow before we adopt the new clientHeight
|
|
@@ -1208,7 +2146,7 @@ export function MessageTimeline({
|
|
|
1208
2146
|
// Fold / composer content shrink: compensate before baseline sync so
|
|
1209
2147
|
// driveFollow still sees the pre-shrink scrollTop (avoid double-subtract).
|
|
1210
2148
|
if (heightShrunk || maxFell || viewportShrunk) {
|
|
1211
|
-
|
|
2149
|
+
clearReaderIntent();
|
|
1212
2150
|
clearPendingReaderLeave();
|
|
1213
2151
|
driveFollow(node);
|
|
1214
2152
|
return;
|
|
@@ -1230,15 +2168,14 @@ export function MessageTimeline({
|
|
|
1230
2168
|
clearPendingReaderLeave();
|
|
1231
2169
|
}
|
|
1232
2170
|
// Pointer-dragged scroll-up away from tip. Layout churn never arms this.
|
|
1233
|
-
if (readerArmed &&
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
olderLoadGateRef.current = "armed";
|
|
1238
|
-
}
|
|
2171
|
+
if (readerArmed && cumulativeReaderUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
|
|
2172
|
+
clearReaderIntent();
|
|
2173
|
+
requestEarlierFromReader();
|
|
2174
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
1239
2175
|
return;
|
|
1240
2176
|
}
|
|
1241
|
-
// Tip grew under a still viewport (no reader-up):
|
|
2177
|
+
// Tip grew under a still viewport (no reader-up): track the new growth
|
|
2178
|
+
// immediately and ease only any debt that already existed.
|
|
1242
2179
|
// Reader/extension scroll-up in progress: do not yank — scrollend decides.
|
|
1243
2180
|
// Near-bottom with tipDebt≈0 stays the quiet path (do not broaden follow
|
|
1244
2181
|
// inside PIN_THRESHOLD — that fights small intentional scroll-ups).
|
|
@@ -1258,15 +2195,46 @@ export function MessageTimeline({
|
|
|
1258
2195
|
}
|
|
1259
2196
|
|
|
1260
2197
|
syncScrollBaseline(node);
|
|
2198
|
+
if (programmatic) {
|
|
2199
|
+
// Anchor restoration is never permission to resume tip-follow.
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
1261
2202
|
const nearBottom = isNearBottom(node);
|
|
1262
2203
|
|
|
1263
|
-
// Re-pin only when the reader moved toward/at the tip
|
|
1264
|
-
//
|
|
2204
|
+
// Re-pin only when the reader moved toward/at the tip. Prepend restore
|
|
2205
|
+
// and overflow-anchor raise scrollTop by roughly the same amount as
|
|
2206
|
+
// maxScroll; treating that as a scroll-down
|
|
2207
|
+
// re-pinned a compact-tail history reader (their preserved gap falls
|
|
2208
|
+
// inside PIN_THRESHOLD once the window is tall) and snapped them back.
|
|
2209
|
+
// A commit may already have adopted this scroll position into the layout
|
|
2210
|
+
// baseline. Explicit wheel/key/pointer intent retains its own start so
|
|
2211
|
+
// returning to the tip still re-pins after that interleaving.
|
|
2212
|
+
// Passive wheel handlers may run after compositor scrolling; retain the
|
|
2213
|
+
// earlier layout baseline when it still contains that reader movement.
|
|
2214
|
+
const returnStart =
|
|
2215
|
+
readerIntentStart && readerIntentStart.scrollTop <= previousTop ? readerIntentStart : null;
|
|
2216
|
+
const inserted = Math.max(0, nextMaxScroll - (returnStart?.maxScroll ?? previousMaxScroll));
|
|
2217
|
+
const towardTip = nextTop - (returnStart?.scrollTop ?? previousTop) - inserted;
|
|
2218
|
+
// Explicit intent may reach the exact live bottom despite intervening
|
|
2219
|
+
// streaming growth. Keep subtracting that growth: native anchoring alone
|
|
2220
|
+
// conserves the gap and must never qualify as reader movement.
|
|
2221
|
+
const explicitReturnToBottom = returnStart !== null && nextMaxScroll - nextTop <= 1;
|
|
1265
2222
|
const nextPinned =
|
|
1266
|
-
!hasNewer && nearBottom &&
|
|
2223
|
+
!hasNewer && nearBottom && towardTip > 0.5 && (inserted <= 1 || explicitReturnToBottom);
|
|
1267
2224
|
if (!nextPinned) {
|
|
1268
2225
|
stopFollow();
|
|
1269
2226
|
}
|
|
2227
|
+
const focused = node.ownerDocument.activeElement;
|
|
2228
|
+
if (
|
|
2229
|
+
nextPinned &&
|
|
2230
|
+
focused instanceof HTMLElement &&
|
|
2231
|
+
focused.hasAttribute("data-og-prompt") &&
|
|
2232
|
+
node.contains(focused)
|
|
2233
|
+
) {
|
|
2234
|
+
// Question navigation focuses its destination. Returning to the live
|
|
2235
|
+
// tip releases that stale reader ownership before the commit snapshot.
|
|
2236
|
+
node.focus({ preventScroll: true });
|
|
2237
|
+
}
|
|
1270
2238
|
applyPinned(nextPinned);
|
|
1271
2239
|
// A far-from-bottom scroll while a Jump-to-latest is pending is the reader
|
|
1272
2240
|
// changing their mind: drop the latch, or a stale one (host rejected or
|
|
@@ -1277,17 +2245,22 @@ export function MessageTimeline({
|
|
|
1277
2245
|
wantPinRef.current = false;
|
|
1278
2246
|
}
|
|
1279
2247
|
if (nextPinned) {
|
|
2248
|
+
clearReaderIntent();
|
|
1280
2249
|
return;
|
|
1281
2250
|
}
|
|
2251
|
+
if (readerIntentStart) {
|
|
2252
|
+
// This scroll already accounted for the gesture's movement. Retain a
|
|
2253
|
+
// fresh start for continuous input across the next commit, not stale
|
|
2254
|
+
// movement that could turn a later native clamp into a return to tip.
|
|
2255
|
+
readerIntentStartRef.current = { scrollTop: nextTop, maxScroll: nextMaxScroll };
|
|
2256
|
+
}
|
|
1282
2257
|
if (!olderPrefetchArmedRef.current) {
|
|
1283
2258
|
olderPrefetchArmedRef.current = true;
|
|
1284
2259
|
setOlderPrefetchArmed(true);
|
|
1285
2260
|
}
|
|
1286
2261
|
// Re-arm older prefetch only after leaving the top band (scroll down into
|
|
1287
2262
|
// content). Never re-arm/load from continued scroll toward y=0.
|
|
1288
|
-
|
|
1289
|
-
olderLoadGateRef.current = "armed";
|
|
1290
|
-
}
|
|
2263
|
+
rearmOlderPrefetchAfterLeavingTop(node);
|
|
1291
2264
|
};
|
|
1292
2265
|
|
|
1293
2266
|
const onScrollEnd = () => {
|
|
@@ -1296,6 +2269,9 @@ export function MessageTimeline({
|
|
|
1296
2269
|
return;
|
|
1297
2270
|
}
|
|
1298
2271
|
cancelLeaveFallback();
|
|
2272
|
+
// Engines can emit scrollend between tiny pointer steps. Keep the pinned
|
|
2273
|
+
// gesture's cumulative leave budget until it actually leaves the tip.
|
|
2274
|
+
if (!pinnedRef.current || !readerIntentArmRef.current) clearReaderIntent();
|
|
1299
2275
|
if (disclosureKeepsUnpinnedRef.current) {
|
|
1300
2276
|
programmaticScrollRef.current = 0;
|
|
1301
2277
|
stopFollow();
|
|
@@ -1311,298 +2287,471 @@ export function MessageTimeline({
|
|
|
1311
2287
|
releasePinAfterScrollSettled(node);
|
|
1312
2288
|
};
|
|
1313
2289
|
|
|
1314
|
-
|
|
1315
|
-
<
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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
|
-
<div
|
|
1365
|
-
ref={topSentinelRef}
|
|
1366
|
-
data-og-top-sentinel=""
|
|
1367
|
-
data-og-timeline-chrome=""
|
|
1368
|
-
aria-hidden="true"
|
|
1369
|
-
className="pointer-events-none absolute inset-x-0 top-0 h-px"
|
|
1370
|
-
/>
|
|
1371
|
-
) : null}
|
|
1372
|
-
{groups.map(({ group, key }, index) => {
|
|
1373
|
-
const next = groups[index + 1]?.group;
|
|
1374
|
-
const contextCompactionCount =
|
|
1375
|
-
group.kind === "turn"
|
|
1376
|
-
? (group.contextCompactionCount ?? 0)
|
|
1377
|
-
: group.kind === "activity" &&
|
|
1378
|
-
next?.kind === "item" &&
|
|
1379
|
-
next.item.kind === "context-compaction" &&
|
|
1380
|
-
next.item.phase === "compacted"
|
|
1381
|
-
? 1
|
|
1382
|
-
: 0;
|
|
1383
|
-
return (
|
|
1384
|
-
<div key={key} data-og-timeline-group-anchor="" data-og-group-key={key}>
|
|
1385
|
-
<TimelineGroupRenderBoundary
|
|
1386
|
-
resetKeys={[
|
|
1387
|
-
group,
|
|
1388
|
-
renderMessageText,
|
|
1389
|
-
onOpenSession,
|
|
1390
|
-
onMemoryClick,
|
|
1391
|
-
onReconnect,
|
|
1392
|
-
resolveProviderLogo,
|
|
1393
|
-
toolRegistry,
|
|
1394
|
-
loadRetainedScreenshot,
|
|
1395
|
-
loadRetainedArtifact,
|
|
1396
|
-
loadVideoArtifactPlayback,
|
|
1397
|
-
turnSummary,
|
|
1398
|
-
]}
|
|
1399
|
-
>
|
|
1400
|
-
<UserMessageDisclosureProvider value={userMessageDisclosureContext}>
|
|
1401
|
-
<TimelineGroupView
|
|
1402
|
-
group={group}
|
|
1403
|
-
renderMessageText={renderMessageText}
|
|
1404
|
-
onOpenSession={onOpenSession}
|
|
1405
|
-
onMemoryClick={onMemoryClick}
|
|
1406
|
-
onReconnect={onReconnect}
|
|
1407
|
-
resolveProviderLogo={resolveProviderLogo}
|
|
1408
|
-
toolRegistry={toolRegistry}
|
|
1409
|
-
loadRetainedScreenshot={loadRetainedScreenshot}
|
|
1410
|
-
loadRetainedArtifact={loadRetainedArtifact}
|
|
1411
|
-
loadVideoArtifactPlayback={loadVideoArtifactPlayback}
|
|
1412
|
-
turnSummary={turnSummary}
|
|
1413
|
-
foldLiveCluster={isAgentProgress(next)}
|
|
1414
|
-
trailingAgentText={trailingAgentTextAfterTurn(group, next)}
|
|
1415
|
-
contextCompactionCount={
|
|
1416
|
-
contextCompactionCount > 0 ? contextCompactionCount : undefined
|
|
1417
|
-
}
|
|
1418
|
-
/>
|
|
1419
|
-
</UserMessageDisclosureProvider>
|
|
1420
|
-
</TimelineGroupRenderBoundary>
|
|
1421
|
-
</div>
|
|
1422
|
-
);
|
|
1423
|
-
})}
|
|
1424
|
-
{groups.length > 0 && trailingState ? (
|
|
1425
|
-
<div data-og-timeline-trailing-state="">{trailingState}</div>
|
|
1426
|
-
) : null}
|
|
1427
|
-
{hasNewer ? (
|
|
1428
|
-
<div
|
|
1429
|
-
ref={bottomSentinelRef}
|
|
1430
|
-
data-og-bottom-sentinel=""
|
|
1431
|
-
data-og-timeline-chrome=""
|
|
1432
|
-
aria-hidden="true"
|
|
1433
|
-
className="h-px w-full"
|
|
1434
|
-
/>
|
|
2290
|
+
const timeline = (
|
|
2291
|
+
<AllowancePresentationProvider
|
|
2292
|
+
render={renderAllowanceExhausted}
|
|
2293
|
+
labels={allowanceExhaustedLabels}
|
|
2294
|
+
>
|
|
2295
|
+
<LightboxProvider>
|
|
2296
|
+
<FoldMemoryProvider value={foldMemoryRef.current}>
|
|
2297
|
+
<SeenActivityIdsProvider value={seenActivityIdsRef.current}>
|
|
2298
|
+
<TimelineComputeLabelProvider value={computeLabel ?? null}>
|
|
2299
|
+
<EntranceAnimationProvider value={false}>
|
|
2300
|
+
<TooltipProvider delayDuration={400}>
|
|
2301
|
+
<TimelineAnnotationSourceRootContext.Provider value={scrollRef}>
|
|
2302
|
+
<div
|
|
2303
|
+
className={cn("og-root relative flex min-h-0 flex-col", className)}
|
|
2304
|
+
// Sticky insets start at the scroller's padded content edge
|
|
2305
|
+
// (pt-16). Subtract exactly that padding so an expanded work
|
|
2306
|
+
// header pins flush to the scrollport. Pinning it lower left a
|
|
2307
|
+
// band of scrolling rows visible above the header, which then
|
|
2308
|
+
// looked like it floated over the middle of the timeline.
|
|
2309
|
+
style={{ "--og-work-header-top": "-4rem" } as CSSProperties}
|
|
2310
|
+
>
|
|
2311
|
+
{onAnnotate ? (
|
|
2312
|
+
<Suspense fallback={null}>
|
|
2313
|
+
<TimelineAnnotationSelection
|
|
2314
|
+
rootRef={scrollRef}
|
|
2315
|
+
sources={annotationSources}
|
|
2316
|
+
onAnnotate={onAnnotate}
|
|
2317
|
+
/>
|
|
2318
|
+
</Suspense>
|
|
1435
2319
|
) : null}
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
{
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
-
pendingJumpToStartRef.current = false;
|
|
1489
|
-
}
|
|
1490
|
-
});
|
|
1491
|
-
},
|
|
1492
|
-
() => {
|
|
1493
|
-
if (jumpToStartSeqRef.current === seq) {
|
|
1494
|
-
pendingJumpToStartRef.current = false;
|
|
1495
|
-
}
|
|
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;
|
|
1554
|
-
applyPinned(true);
|
|
1555
|
-
snapToBottom(current);
|
|
1556
|
-
}
|
|
1557
|
-
},
|
|
1558
|
-
() => {
|
|
1559
|
-
// The tip reload failed (ordinary network error):
|
|
1560
|
-
// an armed latch would fire a surprise snap when
|
|
1561
|
-
// the reader later pages to the tip themselves.
|
|
1562
|
-
wantPinRef.current = false;
|
|
1563
|
-
},
|
|
2320
|
+
{/* Pinned: anchoring off so the tip-follow camera owns the motion.
|
|
2321
|
+
Unpinned: native scroll anchoring holds the reader's place. */}
|
|
2322
|
+
<div
|
|
2323
|
+
ref={scrollRef}
|
|
2324
|
+
data-og-timeline-scroller=""
|
|
2325
|
+
data-og-bottom-follow={autoFollow && pinned && !hasNewer ? "true" : "false"}
|
|
2326
|
+
tabIndex={-1}
|
|
2327
|
+
onScroll={onScroll}
|
|
2328
|
+
onScrollEnd={onScrollEnd}
|
|
2329
|
+
onWheel={onWheel}
|
|
2330
|
+
onTouchStart={(event) => {
|
|
2331
|
+
stopSettlement();
|
|
2332
|
+
const touch = event.touches.length === 1 ? event.touches[0] : undefined;
|
|
2333
|
+
touchPositionRef.current = touch
|
|
2334
|
+
? { x: touch.clientX, y: touch.clientY }
|
|
2335
|
+
: null;
|
|
2336
|
+
}}
|
|
2337
|
+
onTouchMove={(event) => {
|
|
2338
|
+
const touch = event.touches[0];
|
|
2339
|
+
const previous = touchPositionRef.current;
|
|
2340
|
+
if (!touch || !previous || event.touches.length !== 1) {
|
|
2341
|
+
touchPositionRef.current = null;
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
const deltaX = previous.x - touch.clientX;
|
|
2345
|
+
const deltaY = previous.y - touch.clientY;
|
|
2346
|
+
if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 4) return;
|
|
2347
|
+
touchPositionRef.current = { x: touch.clientX, y: touch.clientY };
|
|
2348
|
+
onWheel({
|
|
2349
|
+
deltaX,
|
|
2350
|
+
deltaY,
|
|
2351
|
+
target: event.target,
|
|
2352
|
+
currentTarget: event.currentTarget,
|
|
2353
|
+
});
|
|
2354
|
+
}}
|
|
2355
|
+
onTouchEnd={() => {
|
|
2356
|
+
touchPositionRef.current = null;
|
|
2357
|
+
}}
|
|
2358
|
+
onTouchCancel={() => {
|
|
2359
|
+
touchPositionRef.current = null;
|
|
2360
|
+
}}
|
|
2361
|
+
onClickCapture={(event) => {
|
|
2362
|
+
const target =
|
|
2363
|
+
event.target instanceof Element
|
|
2364
|
+
? event.target.closest("button[aria-expanded]")
|
|
2365
|
+
: null;
|
|
2366
|
+
if (target) {
|
|
2367
|
+
if (target.hasAttribute("data-og-work-header")) {
|
|
2368
|
+
releaseProgress(
|
|
2369
|
+
target
|
|
2370
|
+
.closest("[data-og-group-key]")
|
|
2371
|
+
?.getAttribute("data-og-group-key") ?? null,
|
|
1564
2372
|
);
|
|
1565
|
-
} else if (node) {
|
|
1566
|
-
// No tip reload available: jump within the in-memory
|
|
1567
|
-
// window so the newer sentinel can page forward; the
|
|
1568
|
-
// latch pins if the tip window eventually lands.
|
|
1569
|
-
snapToBottom(node);
|
|
1570
2373
|
}
|
|
1571
|
-
|
|
2374
|
+
releasePinFromReader();
|
|
2375
|
+
disclosureKeepsUnpinnedRef.current = true;
|
|
1572
2376
|
}
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
2377
|
+
}}
|
|
2378
|
+
onFocusCapture={(event) => {
|
|
2379
|
+
if (readableTurns && event.target !== event.currentTarget) {
|
|
2380
|
+
stopSettlement();
|
|
2381
|
+
releasePinFromReader();
|
|
2382
|
+
disclosureKeepsUnpinnedRef.current = true;
|
|
1577
2383
|
}
|
|
1578
2384
|
}}
|
|
2385
|
+
onPointerDown={onPointerDown}
|
|
2386
|
+
onKeyDown={onKeyDown}
|
|
2387
|
+
style={
|
|
2388
|
+
groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined
|
|
2389
|
+
}
|
|
1579
2390
|
className={cn(
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
"
|
|
1583
|
-
|
|
2391
|
+
// tabIndex=-1 is programmatic only — never paint a focus ring on
|
|
2392
|
+
// the whole scroller (click + Shift used to flash a blue outline).
|
|
2393
|
+
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pt-16 pb-6 sm:px-6 outline-hidden",
|
|
2394
|
+
autoFollow && pinned && !hasNewer
|
|
2395
|
+
? "[overflow-anchor:none]"
|
|
2396
|
+
: "[overflow-anchor:auto]",
|
|
1584
2397
|
)}
|
|
1585
2398
|
>
|
|
1586
|
-
<
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
2399
|
+
<TimelineBeforeLayout
|
|
2400
|
+
capture={() => {
|
|
2401
|
+
if (readableTurns && timelineHasReader(scrollRef.current)) {
|
|
2402
|
+
releasePinFromReader();
|
|
2403
|
+
}
|
|
2404
|
+
readingAnchorRef.current =
|
|
2405
|
+
!pinnedRef.current && scrollRef.current
|
|
2406
|
+
? captureTimelineAnchor(scrollRef.current)
|
|
2407
|
+
: null;
|
|
2408
|
+
settlementRef.current =
|
|
2409
|
+
readableTurns &&
|
|
2410
|
+
autoFollow &&
|
|
2411
|
+
pinnedRef.current &&
|
|
2412
|
+
revealedRef.current &&
|
|
2413
|
+
!hasNewer &&
|
|
2414
|
+
!pendingReaderLeaveRef.current &&
|
|
2415
|
+
!disclosureKeepsUnpinnedRef.current &&
|
|
2416
|
+
!prefersReducedMotion() &&
|
|
2417
|
+
scrollRef.current
|
|
2418
|
+
? captureTimelineSettlement(scrollRef.current, groups)
|
|
2419
|
+
: null;
|
|
2420
|
+
}}
|
|
2421
|
+
>
|
|
2422
|
+
<div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
|
|
2423
|
+
<AnimatePresence>
|
|
2424
|
+
{loadingOlder ||
|
|
2425
|
+
loadingOldest ||
|
|
2426
|
+
(hasOlder && onJumpToStart && olderPrefetchArmed) ||
|
|
2427
|
+
underfillRetryReady ? (
|
|
2428
|
+
// Reserved top gutter: controls scroll with history and cannot
|
|
2429
|
+
// cover a disclosure. Visibility never changes content height.
|
|
2430
|
+
<motion.div
|
|
2431
|
+
initial={{ opacity: 0, y: -6 }}
|
|
2432
|
+
animate={{ opacity: 1, y: 0 }}
|
|
2433
|
+
exit={{ opacity: 0, y: -6 }}
|
|
2434
|
+
transition={{ duration: 0.15, ease: "easeOut" }}
|
|
2435
|
+
data-og-loading-older=""
|
|
2436
|
+
aria-live="polite"
|
|
2437
|
+
className="pointer-events-none absolute inset-x-0 -top-11 z-10 flex justify-center"
|
|
2438
|
+
>
|
|
2439
|
+
{loadingOlder || loadingOldest ? (
|
|
2440
|
+
<span className={LOADING_CHIP_CLASS}>
|
|
2441
|
+
<span className="og-shimmer-text">
|
|
2442
|
+
{loadingOldest
|
|
2443
|
+
? "Jumping to start…"
|
|
2444
|
+
: "Loading earlier activity…"}
|
|
2445
|
+
</span>
|
|
2446
|
+
</span>
|
|
2447
|
+
) : null}
|
|
2448
|
+
{hasOlder &&
|
|
2449
|
+
!loadingOlder &&
|
|
2450
|
+
!loadingOldest &&
|
|
2451
|
+
(underfillRetryReady || onJumpToStart) ? (
|
|
2452
|
+
<button
|
|
2453
|
+
type="button"
|
|
2454
|
+
data-og-retry={underfillRetryReady || undefined}
|
|
2455
|
+
data-og-jump-to-start={!underfillRetryReady || undefined}
|
|
2456
|
+
onClick={() => {
|
|
2457
|
+
const node = scrollRef.current;
|
|
2458
|
+
if (underfillRetryReady) {
|
|
2459
|
+
if (node && underfillRetryReadyRef.current) {
|
|
2460
|
+
// AnimatePresence retains this handler during
|
|
2461
|
+
// exit. Current authorization plus the exact
|
|
2462
|
+
// attempt check prevent stale dispatch.
|
|
2463
|
+
requestOlderIfUnderfilled(
|
|
2464
|
+
node,
|
|
2465
|
+
underfillSettledAttempt!,
|
|
2466
|
+
);
|
|
2467
|
+
}
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
applyPinned(false);
|
|
2471
|
+
pendingJumpToStartRef.current = true;
|
|
2472
|
+
const seq = ++jumpToStartSeqRef.current;
|
|
2473
|
+
void Promise.resolve(onJumpToStart!()).then(
|
|
2474
|
+
() => {
|
|
2475
|
+
// The commit that swaps in the oldest window consumes
|
|
2476
|
+
// the flag against the new DOM; this write covers the
|
|
2477
|
+
// already-committed order and the no-window-change
|
|
2478
|
+
// case (jumping within the current window).
|
|
2479
|
+
const scroller = scrollRef.current ?? node;
|
|
2480
|
+
if (scroller) {
|
|
2481
|
+
scroller.scrollTop = 0;
|
|
2482
|
+
}
|
|
2483
|
+
// A host may resolve without ever changing the
|
|
2484
|
+
// window (already on the oldest page). Any swap
|
|
2485
|
+
// commit runs its layout effect before the next
|
|
2486
|
+
// frame, so a flag still armed by then is the
|
|
2487
|
+
// no-change case — clear it, or a LATER prepend
|
|
2488
|
+
// would spuriously jump the reader to the top.
|
|
2489
|
+
requestFrame(() => {
|
|
2490
|
+
if (jumpToStartSeqRef.current === seq) {
|
|
2491
|
+
pendingJumpToStartRef.current = false;
|
|
2492
|
+
}
|
|
2493
|
+
});
|
|
2494
|
+
},
|
|
2495
|
+
() => {
|
|
2496
|
+
if (jumpToStartSeqRef.current === seq) {
|
|
2497
|
+
pendingJumpToStartRef.current = false;
|
|
2498
|
+
}
|
|
2499
|
+
},
|
|
2500
|
+
);
|
|
2501
|
+
}}
|
|
2502
|
+
className="pointer-events-auto rounded-full border border-og-border px-3 py-1.5 text-og-control"
|
|
2503
|
+
>
|
|
2504
|
+
{underfillRetryReady
|
|
2505
|
+
? underfillSettledAttempt?.[2]?.tailPreserved
|
|
2506
|
+
? "Load earlier activity"
|
|
2507
|
+
: "Retry earlier activity"
|
|
2508
|
+
: "Jump to start"}
|
|
2509
|
+
</button>
|
|
2510
|
+
) : null}
|
|
2511
|
+
</motion.div>
|
|
2512
|
+
) : null}
|
|
2513
|
+
</AnimatePresence>
|
|
2514
|
+
{!groups.length
|
|
2515
|
+
? (emptyState ?? (
|
|
2516
|
+
<p className="py-10 text-center text-og-menu text-og-fg-subtle">
|
|
2517
|
+
No activity yet.
|
|
2518
|
+
</p>
|
|
2519
|
+
))
|
|
2520
|
+
: null}
|
|
2521
|
+
{hasOlder && olderPrefetchArmed ? (
|
|
2522
|
+
// Overlaid, not a layout row: mounting/unmounting the sentinel
|
|
2523
|
+
// must never shift content (that shift was itself a wobble).
|
|
2524
|
+
// End at the scroll origin above the pt-16 gutter so the
|
|
2525
|
+
// observer and scrollTop cooldown share the same 400px band.
|
|
2526
|
+
<div
|
|
2527
|
+
ref={topSentinelRef}
|
|
2528
|
+
data-og-top-sentinel=""
|
|
2529
|
+
data-og-timeline-chrome=""
|
|
2530
|
+
aria-hidden="true"
|
|
2531
|
+
className="pointer-events-none absolute inset-x-0 -top-16 h-px -translate-y-full"
|
|
2532
|
+
/>
|
|
2533
|
+
) : null}
|
|
2534
|
+
{groups.map(({ group, key, entranceEnabled }, index) => {
|
|
2535
|
+
return (
|
|
2536
|
+
<TimelineSearchRevealContext.Provider
|
|
2537
|
+
key={key}
|
|
2538
|
+
value={
|
|
2539
|
+
searchItem &&
|
|
2540
|
+
timelineGroupItemIds(group).includes(searchItem.id)
|
|
2541
|
+
? searchRevealKey
|
|
2542
|
+
: null
|
|
2543
|
+
}
|
|
2544
|
+
>
|
|
2545
|
+
<TimelineGroupEntry
|
|
2546
|
+
groupKey={key}
|
|
2547
|
+
group={group}
|
|
2548
|
+
nextGroup={groups[index + 1]?.group}
|
|
2549
|
+
startupDismissed={
|
|
2550
|
+
group.kind === "activity" &&
|
|
2551
|
+
group.items.some(
|
|
2552
|
+
(item) => item.turnId && turnsWithOutput.has(item.turnId),
|
|
2553
|
+
)
|
|
2554
|
+
}
|
|
2555
|
+
entranceEnabled={entranceEnabled}
|
|
2556
|
+
liveEntranceEnabled={
|
|
2557
|
+
group.kind === "activity" ? !bulkRender : undefined
|
|
2558
|
+
}
|
|
2559
|
+
context={timelineGroupEntryContext}
|
|
2560
|
+
/>
|
|
2561
|
+
</TimelineSearchRevealContext.Provider>
|
|
2562
|
+
);
|
|
2563
|
+
})}
|
|
2564
|
+
{groups.length > 0 && trailingState ? (
|
|
2565
|
+
<div data-og-timeline-trailing-state="">{trailingState}</div>
|
|
2566
|
+
) : null}
|
|
2567
|
+
{hasNewer && newerFailure?.key === newerBoundaryKey ? (
|
|
2568
|
+
<div
|
|
2569
|
+
data-og-newer-error=""
|
|
2570
|
+
className="flex flex-col items-center gap-2 px-4 py-3 text-center text-og-menu text-og-fg-muted"
|
|
2571
|
+
>
|
|
2572
|
+
<p role="status" className="max-w-prose [overflow-wrap:anywhere]">
|
|
2573
|
+
Couldn’t load later activity. {newerFailure.message}
|
|
2574
|
+
</p>
|
|
2575
|
+
<button
|
|
2576
|
+
ref={newerRetryButtonRef}
|
|
2577
|
+
type="button"
|
|
2578
|
+
data-og-retry-newer=""
|
|
2579
|
+
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"
|
|
2580
|
+
aria-disabled={loadingNewer || newerRetryPending}
|
|
2581
|
+
aria-busy={newerRetryPending}
|
|
2582
|
+
onClick={() => requestNewer(true)}
|
|
2583
|
+
>
|
|
2584
|
+
{newerRetryPending
|
|
2585
|
+
? "Retrying later activity…"
|
|
2586
|
+
: "Retry later activity"}
|
|
2587
|
+
</button>
|
|
2588
|
+
</div>
|
|
2589
|
+
) : null}
|
|
2590
|
+
{hasNewer ? (
|
|
2591
|
+
<div
|
|
2592
|
+
ref={bottomSentinelRef}
|
|
2593
|
+
data-og-bottom-sentinel=""
|
|
2594
|
+
data-og-timeline-chrome=""
|
|
2595
|
+
aria-hidden="true"
|
|
2596
|
+
className="h-px w-full"
|
|
2597
|
+
/>
|
|
2598
|
+
) : null}
|
|
2599
|
+
</div>
|
|
2600
|
+
</TimelineBeforeLayout>
|
|
2601
|
+
</div>
|
|
2602
|
+
{draftAnnotations && draftAnnotations.length > 0 ? (
|
|
2603
|
+
<Suspense fallback={null}>
|
|
2604
|
+
<TimelineAnnotationMarkers
|
|
2605
|
+
rootRef={scrollRef}
|
|
2606
|
+
annotations={draftAnnotations}
|
|
2607
|
+
onSelect={onDraftAnnotationSelect}
|
|
2608
|
+
/>
|
|
2609
|
+
</Suspense>
|
|
2610
|
+
) : null}
|
|
2611
|
+
|
|
2612
|
+
<div
|
|
2613
|
+
data-og-timeline-nav="top"
|
|
2614
|
+
className={cn(
|
|
2615
|
+
"pointer-events-none absolute inset-x-0 z-10 flex justify-center",
|
|
2616
|
+
questionNav?.belowHeader ? "top-10 pointer-coarse:top-13" : "top-3",
|
|
2617
|
+
)}
|
|
2618
|
+
>
|
|
2619
|
+
<AnimatePresence>
|
|
2620
|
+
{questionNav ? (
|
|
2621
|
+
<motion.button
|
|
2622
|
+
key="question-nav"
|
|
2623
|
+
{...NAV_FADE}
|
|
2624
|
+
type="button"
|
|
2625
|
+
data-og-question-nav=""
|
|
2626
|
+
data-og-jump-to-question=""
|
|
2627
|
+
title="Back to your message"
|
|
2628
|
+
onClick={() => jumpToQuestion(questionNav.key)}
|
|
2629
|
+
className={NAV_BUTTON_CLASS}
|
|
2630
|
+
>
|
|
2631
|
+
<ArrowUpIcon aria-hidden className="size-4" />
|
|
2632
|
+
<span className="sr-only">Back to your message</span>
|
|
2633
|
+
</motion.button>
|
|
2634
|
+
) : null}
|
|
2635
|
+
</AnimatePresence>
|
|
2636
|
+
</div>
|
|
2637
|
+
<div
|
|
2638
|
+
data-og-timeline-nav="bottom"
|
|
2639
|
+
className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex flex-col items-center gap-2"
|
|
2640
|
+
>
|
|
2641
|
+
<AnimatePresence>
|
|
2642
|
+
{loadingNewer ? (
|
|
2643
|
+
<motion.div
|
|
2644
|
+
key="loading-newer"
|
|
2645
|
+
{...NAV_FADE}
|
|
2646
|
+
data-og-loading-newer=""
|
|
2647
|
+
aria-live="polite"
|
|
2648
|
+
className="flex max-w-full justify-center"
|
|
2649
|
+
>
|
|
2650
|
+
<span className={LOADING_CHIP_CLASS}>
|
|
2651
|
+
<span className="og-shimmer-text">Loading later activity…</span>
|
|
2652
|
+
</span>
|
|
2653
|
+
</motion.div>
|
|
2654
|
+
) : null}
|
|
2655
|
+
{jumpPillShown ? (
|
|
2656
|
+
<motion.button
|
|
2657
|
+
key="jump-to-latest"
|
|
2658
|
+
{...NAV_FADE}
|
|
2659
|
+
type="button"
|
|
2660
|
+
data-og-jump-to-latest=""
|
|
2661
|
+
title="Jump to latest"
|
|
2662
|
+
onClick={() => {
|
|
2663
|
+
// Returning to the tip explicitly releases reader-owned
|
|
2664
|
+
// prose. Clear only this timeline's selection before the
|
|
2665
|
+
// synchronous ownership check on the next commit.
|
|
2666
|
+
const viewport = scrollRef.current;
|
|
2667
|
+
const selection = viewport?.ownerDocument.getSelection();
|
|
2668
|
+
if (
|
|
2669
|
+
selection &&
|
|
2670
|
+
!selection.isCollapsed &&
|
|
2671
|
+
(viewport?.contains(selection.anchorNode) ||
|
|
2672
|
+
viewport?.contains(selection.focusNode))
|
|
2673
|
+
)
|
|
2674
|
+
selection.removeAllRanges();
|
|
2675
|
+
disclosureKeepsUnpinnedRef.current = false;
|
|
2676
|
+
if (hasNewer) {
|
|
2677
|
+
// Do not pin against the current history page — its bottom
|
|
2678
|
+
// is not the tip. The pin + snap run when the tip window
|
|
2679
|
+
// actually lands (`hasNewer` flips false).
|
|
2680
|
+
wantPinRef.current = true;
|
|
2681
|
+
const node = scrollRef.current;
|
|
2682
|
+
if (onJumpToLatest) {
|
|
2683
|
+
void Promise.resolve(onJumpToLatest()).then(
|
|
2684
|
+
() => {
|
|
2685
|
+
// Covers a host that flipped hasNewer before
|
|
2686
|
+
// resolving; otherwise the tip-window commit
|
|
2687
|
+
// consumes the flag.
|
|
2688
|
+
const current = scrollRef.current;
|
|
2689
|
+
if (current && wantPinRef.current && !hasNewerRef.current) {
|
|
2690
|
+
wantPinRef.current = false;
|
|
2691
|
+
applyPinned(true);
|
|
2692
|
+
snapToBottom(current);
|
|
2693
|
+
}
|
|
2694
|
+
},
|
|
2695
|
+
() => {
|
|
2696
|
+
// The tip reload failed (ordinary network error):
|
|
2697
|
+
// an armed latch would fire a surprise snap when
|
|
2698
|
+
// the reader later pages to the tip themselves.
|
|
2699
|
+
wantPinRef.current = false;
|
|
2700
|
+
},
|
|
2701
|
+
);
|
|
2702
|
+
} else if (node) {
|
|
2703
|
+
// No tip reload available: jump within the in-memory
|
|
2704
|
+
// window so the newer sentinel can page forward; the
|
|
2705
|
+
// latch pins if the tip window eventually lands.
|
|
2706
|
+
snapToBottom(node);
|
|
2707
|
+
}
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
const node = scrollRef.current;
|
|
2711
|
+
if (node) {
|
|
2712
|
+
applyPinned(true);
|
|
2713
|
+
snapToBottom(node);
|
|
2714
|
+
}
|
|
2715
|
+
}}
|
|
2716
|
+
className={NAV_BUTTON_CLASS}
|
|
2717
|
+
>
|
|
2718
|
+
<ArrowDownIcon aria-hidden className="size-4" />
|
|
2719
|
+
<span className="sr-only">Jump to latest</span>
|
|
2720
|
+
</motion.button>
|
|
2721
|
+
) : null}
|
|
2722
|
+
</AnimatePresence>
|
|
2723
|
+
</div>
|
|
2724
|
+
</div>
|
|
2725
|
+
</TimelineAnnotationSourceRootContext.Provider>
|
|
2726
|
+
</TooltipProvider>
|
|
2727
|
+
</EntranceAnimationProvider>
|
|
2728
|
+
</TimelineComputeLabelProvider>
|
|
2729
|
+
</SeenActivityIdsProvider>
|
|
2730
|
+
</FoldMemoryProvider>
|
|
2731
|
+
</LightboxProvider>
|
|
2732
|
+
</AllowancePresentationProvider>
|
|
1598
2733
|
);
|
|
2734
|
+
// Agent-authored object links resolve through the host, never the console.
|
|
2735
|
+
return <OpenGeniLinkProvider resolveLink={resolveLink}>{timeline}</OpenGeniLinkProvider>;
|
|
1599
2736
|
}
|
|
1600
2737
|
|
|
1601
2738
|
type KeyedTimelineGroup = {
|
|
1602
2739
|
group: TimelineGroup;
|
|
1603
2740
|
key: string;
|
|
2741
|
+
entranceEnabled: boolean;
|
|
1604
2742
|
};
|
|
1605
2743
|
|
|
2744
|
+
function timelineGroupsRenderEqual(previous: TimelineGroup, next: TimelineGroup): boolean {
|
|
2745
|
+
try {
|
|
2746
|
+
return dequal(previous, next);
|
|
2747
|
+
} catch {
|
|
2748
|
+
// Consumer-owned `unknown` payloads may be proxies/getters. Equality is an
|
|
2749
|
+
// optimization only; a hostile comparator surface must fall back to the
|
|
2750
|
+
// authoritative new group rather than taking down the timeline render.
|
|
2751
|
+
return false;
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
|
|
1606
2755
|
/**
|
|
1607
2756
|
* Projection can legitimately change a group's content-derived key while
|
|
1608
2757
|
* retaining its existing rows. The common pagination case is an older activity
|
|
@@ -1611,7 +2760,10 @@ type KeyedTimelineGroup = {
|
|
|
1611
2760
|
* committed groups by their durable item IDs so both the React key and the
|
|
1612
2761
|
* progressive-window anchor survive either change.
|
|
1613
2762
|
*/
|
|
1614
|
-
function useStableTimelineGroupKeys(
|
|
2763
|
+
function useStableTimelineGroupKeys(
|
|
2764
|
+
allGroups: TimelineGroup[],
|
|
2765
|
+
entranceEnabled: boolean,
|
|
2766
|
+
): KeyedTimelineGroup[] {
|
|
1615
2767
|
const previousRef = useRef<KeyedTimelineGroup[]>([]);
|
|
1616
2768
|
const keyedGroups = useMemo(() => {
|
|
1617
2769
|
const previousByItemId = new Map<string, KeyedTimelineGroup>();
|
|
@@ -1624,29 +2776,53 @@ function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGr
|
|
|
1624
2776
|
const usedKeys = new Set<string>();
|
|
1625
2777
|
return allGroups.map((group, index) => {
|
|
1626
2778
|
const itemIds = timelineGroupItemIds(group);
|
|
1627
|
-
let
|
|
2779
|
+
let retainedGroup: KeyedTimelineGroup | undefined;
|
|
1628
2780
|
for (const itemId of itemIds) {
|
|
1629
2781
|
const previous = previousByItemId.get(itemId);
|
|
1630
2782
|
// Retain only same-kind matches. Activity → turn wrap must NOT keep the
|
|
1631
2783
|
// activity chip's React key: that reused a collapsed TurnSummary and
|
|
1632
2784
|
// skipped the settle beat (insta-collapse / content flash).
|
|
1633
|
-
|
|
1634
|
-
|
|
2785
|
+
const startupCompletion =
|
|
2786
|
+
previous?.group.kind === "activity" &&
|
|
2787
|
+
previous.group.items.every(
|
|
2788
|
+
(item) =>
|
|
2789
|
+
item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
|
|
2790
|
+
) &&
|
|
2791
|
+
group.kind === "turn" &&
|
|
2792
|
+
group.outcome === "complete" &&
|
|
2793
|
+
group.groups.every(
|
|
2794
|
+
(child) =>
|
|
2795
|
+
child.kind === "activity" &&
|
|
2796
|
+
child.items.every((item) => item.kind === "startup-phase"),
|
|
2797
|
+
);
|
|
2798
|
+
if (
|
|
2799
|
+
previous &&
|
|
2800
|
+
(previous.group.kind === group.kind || startupCompletion) &&
|
|
2801
|
+
!usedKeys.has(previous.key)
|
|
2802
|
+
) {
|
|
2803
|
+
retainedGroup = previous;
|
|
1635
2804
|
break;
|
|
1636
2805
|
}
|
|
1637
2806
|
}
|
|
1638
2807
|
|
|
1639
2808
|
const canonicalKey = timelineGroupKey(group);
|
|
1640
|
-
let key =
|
|
2809
|
+
let key = retainedGroup?.key ?? canonicalKey;
|
|
1641
2810
|
let collision = 0;
|
|
1642
2811
|
while (usedKeys.has(key)) {
|
|
1643
2812
|
key = `${canonicalKey}:${index}:${collision}`;
|
|
1644
2813
|
collision += 1;
|
|
1645
2814
|
}
|
|
1646
2815
|
usedKeys.add(key);
|
|
1647
|
-
return {
|
|
2816
|
+
return {
|
|
2817
|
+
group:
|
|
2818
|
+
retainedGroup && timelineGroupsRenderEqual(retainedGroup.group, group)
|
|
2819
|
+
? retainedGroup.group
|
|
2820
|
+
: group,
|
|
2821
|
+
key,
|
|
2822
|
+
entranceEnabled: retainedGroup?.entranceEnabled ?? entranceEnabled,
|
|
2823
|
+
};
|
|
1648
2824
|
});
|
|
1649
|
-
}, [allGroups]);
|
|
2825
|
+
}, [allGroups, entranceEnabled]);
|
|
1650
2826
|
|
|
1651
2827
|
useLayoutEffect(() => {
|
|
1652
2828
|
previousRef.current = keyedGroups;
|
|
@@ -1739,17 +2915,138 @@ class TimelineGroupRenderBoundary extends Component<
|
|
|
1739
2915
|
}
|
|
1740
2916
|
}
|
|
1741
2917
|
|
|
2918
|
+
type TimelineGroupEntryProps = {
|
|
2919
|
+
groupKey: string;
|
|
2920
|
+
group: TimelineGroup;
|
|
2921
|
+
nextGroup?: TimelineGroup | undefined;
|
|
2922
|
+
startupDismissed: boolean;
|
|
2923
|
+
entranceEnabled: boolean;
|
|
2924
|
+
liveEntranceEnabled?: boolean | undefined;
|
|
2925
|
+
context: TimelineGroupEntryContext;
|
|
2926
|
+
};
|
|
2927
|
+
|
|
2928
|
+
type TimelineGroupEntryContext = {
|
|
2929
|
+
userMessageDisclosureContext: UserMessageDisclosureContextValue;
|
|
2930
|
+
behavior: TimelineGroupBehaviorProps;
|
|
2931
|
+
};
|
|
2932
|
+
|
|
2933
|
+
type TimelineGroupBehaviorProps = {
|
|
2934
|
+
renderMessageActions: MessageTimelineProps["renderMessageActions"];
|
|
2935
|
+
renderMessageText:
|
|
2936
|
+
| ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
|
|
2937
|
+
| undefined;
|
|
2938
|
+
onOpenSession: MessageTimelineProps["onOpenSession"];
|
|
2939
|
+
onMemoryClick: MessageTimelineProps["onMemoryClick"];
|
|
2940
|
+
onReconnect: MessageTimelineProps["onReconnect"];
|
|
2941
|
+
renderAuthNeeded: MessageTimelineProps["renderAuthNeeded"];
|
|
2942
|
+
resolveProviderLogo: MessageTimelineProps["resolveProviderLogo"];
|
|
2943
|
+
toolRegistry: ToolRegistry;
|
|
2944
|
+
loadRetainedScreenshot: MessageTimelineProps["loadRetainedScreenshot"];
|
|
2945
|
+
loadRetainedArtifact: MessageTimelineProps["loadRetainedArtifact"];
|
|
2946
|
+
loadVideoArtifactPlayback: MessageTimelineProps["loadVideoArtifactPlayback"];
|
|
2947
|
+
genieLoading: MessageTimelineProps["genieLoading"];
|
|
2948
|
+
turnSummary: MessageTimelineProps["turnSummary"];
|
|
2949
|
+
};
|
|
2950
|
+
|
|
2951
|
+
/**
|
|
2952
|
+
* Keep the complete settled-row shell behind one shallow memo boundary. A
|
|
2953
|
+
* prepend still reconciles the keyed list, but render-equivalent suffix groups
|
|
2954
|
+
* skip their providers, error boundaries, disclosure wrappers, and row trees.
|
|
2955
|
+
*/
|
|
2956
|
+
const TimelineGroupEntry = memo(function TimelineGroupEntry({
|
|
2957
|
+
groupKey,
|
|
2958
|
+
group,
|
|
2959
|
+
nextGroup,
|
|
2960
|
+
startupDismissed,
|
|
2961
|
+
entranceEnabled,
|
|
2962
|
+
liveEntranceEnabled,
|
|
2963
|
+
context,
|
|
2964
|
+
}: TimelineGroupEntryProps) {
|
|
2965
|
+
const reducedMotion = useReducedMotion();
|
|
2966
|
+
const { behavior, userMessageDisclosureContext } = context;
|
|
2967
|
+
const contextCompactionCount =
|
|
2968
|
+
group.kind === "turn"
|
|
2969
|
+
? (group.contextCompactionCount ?? 0)
|
|
2970
|
+
: group.kind === "activity" &&
|
|
2971
|
+
nextGroup?.kind === "item" &&
|
|
2972
|
+
nextGroup.item.kind === "context-compaction" &&
|
|
2973
|
+
nextGroup.item.phase === "compacted"
|
|
2974
|
+
? 1
|
|
2975
|
+
: 0;
|
|
2976
|
+
const content = (
|
|
2977
|
+
<ActivityNoteTextContext.Provider value={behavior.renderMessageText ?? null}>
|
|
2978
|
+
<TimelineGroupView
|
|
2979
|
+
{...behavior}
|
|
2980
|
+
group={group}
|
|
2981
|
+
foldLiveCluster={isAgentProgress(nextGroup)}
|
|
2982
|
+
startupDismissed={startupDismissed}
|
|
2983
|
+
trailingAgentText={trailingAgentTextAfterTurn(group, nextGroup)}
|
|
2984
|
+
contextCompactionCount={contextCompactionCount > 0 ? contextCompactionCount : undefined}
|
|
2985
|
+
/>
|
|
2986
|
+
</ActivityNoteTextContext.Provider>
|
|
2987
|
+
);
|
|
2988
|
+
return (
|
|
2989
|
+
<GenieLoadingOptionsContext.Provider value={behavior.genieLoading}>
|
|
2990
|
+
<div
|
|
2991
|
+
data-og-timeline-group-anchor=""
|
|
2992
|
+
data-og-group-key={groupKey}
|
|
2993
|
+
data-og-prompt={
|
|
2994
|
+
group.kind === "item" && group.item.kind === "user-message" ? "" : undefined
|
|
2995
|
+
}
|
|
2996
|
+
tabIndex={group.kind === "item" && group.item.kind === "user-message" ? -1 : undefined}
|
|
2997
|
+
>
|
|
2998
|
+
<EntranceAnimationProvider value={entranceEnabled} liveValue={liveEntranceEnabled}>
|
|
2999
|
+
<TimelineGroupRenderBoundary resetKeys={[group, behavior]}>
|
|
3000
|
+
<UserMessageDisclosureProvider value={userMessageDisclosureContext}>
|
|
3001
|
+
{/* Item groups never switch the preparation/content key. Keep their
|
|
3002
|
+
DOM shell without mounting inert presence and motion lifecycles
|
|
3003
|
+
for every historical message in a prepend. */}
|
|
3004
|
+
{group.kind === "item" ? (
|
|
3005
|
+
<div>{content}</div>
|
|
3006
|
+
) : (
|
|
3007
|
+
<AnimatePresence initial={false}>
|
|
3008
|
+
<motion.div
|
|
3009
|
+
key={
|
|
3010
|
+
!startupDismissed &&
|
|
3011
|
+
group.kind === "activity" &&
|
|
3012
|
+
!group.work &&
|
|
3013
|
+
group.items.every(
|
|
3014
|
+
(item) =>
|
|
3015
|
+
item.kind === "startup-phase" ||
|
|
3016
|
+
(item.kind === "reasoning" && !item.text.trim()),
|
|
3017
|
+
)
|
|
3018
|
+
? "preparation"
|
|
3019
|
+
: "content"
|
|
3020
|
+
}
|
|
3021
|
+
initial={false}
|
|
3022
|
+
exit={{ opacity: 0, height: 0 }}
|
|
3023
|
+
transition={{ duration: reducedMotion ? 0 : 0.2 }}
|
|
3024
|
+
>
|
|
3025
|
+
{content}
|
|
3026
|
+
</motion.div>
|
|
3027
|
+
</AnimatePresence>
|
|
3028
|
+
)}
|
|
3029
|
+
</UserMessageDisclosureProvider>
|
|
3030
|
+
</TimelineGroupRenderBoundary>
|
|
3031
|
+
</EntranceAnimationProvider>
|
|
3032
|
+
</div>
|
|
3033
|
+
</GenieLoadingOptionsContext.Provider>
|
|
3034
|
+
);
|
|
3035
|
+
});
|
|
3036
|
+
|
|
1742
3037
|
// 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.
|
|
3038
|
+
// on every commit: the stable-key projection reuses render-equivalent group
|
|
3039
|
+
// objects, so memo skips them. Changed projection content receives a new group
|
|
3040
|
+
// object, and behavior/callback changes are separate props, so ordinary
|
|
3041
|
+
// streaming and host updates still invalidate immediately.
|
|
1747
3042
|
const TimelineGroupView = memo(function TimelineGroupView({
|
|
1748
3043
|
group,
|
|
3044
|
+
renderMessageActions,
|
|
1749
3045
|
renderMessageText,
|
|
1750
3046
|
onOpenSession,
|
|
1751
3047
|
onMemoryClick,
|
|
1752
3048
|
onReconnect,
|
|
3049
|
+
renderAuthNeeded,
|
|
1753
3050
|
resolveProviderLogo,
|
|
1754
3051
|
toolRegistry,
|
|
1755
3052
|
loadRetainedScreenshot,
|
|
@@ -1759,16 +3056,20 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1759
3056
|
insideTurn = false,
|
|
1760
3057
|
nestClusterChips = false,
|
|
1761
3058
|
foldLiveCluster = false,
|
|
3059
|
+
startupDismissed = false,
|
|
1762
3060
|
trailingAgentText,
|
|
1763
3061
|
contextCompactionCount,
|
|
1764
3062
|
}: {
|
|
1765
3063
|
group: TimelineGroup;
|
|
3064
|
+
renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
1766
3065
|
renderMessageText?:
|
|
1767
3066
|
| ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
|
|
1768
3067
|
| undefined;
|
|
1769
3068
|
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
1770
3069
|
onMemoryClick?: ((memoryId: string) => void) | undefined;
|
|
1771
3070
|
onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
|
|
3071
|
+
/** Host-owned inline connection setup. Return undefined to use the default recovery card. */
|
|
3072
|
+
renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
|
|
1772
3073
|
resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
|
|
1773
3074
|
toolRegistry: ToolRegistry;
|
|
1774
3075
|
loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
|
|
@@ -1779,6 +3080,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1779
3080
|
behind a neutral chip — the one place activity without an outcome still
|
|
1780
3081
|
folds, bounding the DOM of days-long autonomous turns. */
|
|
1781
3082
|
foldLiveCluster?: boolean;
|
|
3083
|
+
startupDismissed?: boolean;
|
|
1782
3084
|
/** Rendering inside an expanded turn group: the outer chip already owns the
|
|
1783
3085
|
failure surface, so nested chips stay tinted but quiet (no repeated
|
|
1784
3086
|
failure text, no auto-open) — one loud error, N calm sub-expands. */
|
|
@@ -1799,16 +3101,28 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1799
3101
|
/** Secondary chip facet when this fold sits next to a compaction landmark. */
|
|
1800
3102
|
contextCompactionCount?: number | undefined;
|
|
1801
3103
|
}) {
|
|
3104
|
+
const startupDetails = useStartupDetails();
|
|
1802
3105
|
const enter = useEntranceAnimation();
|
|
1803
3106
|
const settleChrome = useTurnSettleOpen();
|
|
3107
|
+
const previousSingleActivity = useRef<ActivityItem | undefined>(undefined);
|
|
3108
|
+
useLayoutEffect(() => {
|
|
3109
|
+
const work =
|
|
3110
|
+
group.kind === "activity" ? group.items.filter((item) => item.kind !== "startup-phase") : [];
|
|
3111
|
+
previousSingleActivity.current = work.length === 1 ? work[0] : undefined;
|
|
3112
|
+
}, [group]);
|
|
1804
3113
|
const foldMemory = useFoldMemory();
|
|
1805
3114
|
// Settled (or live-fold) activity clusters get a chip. Inside an expanded
|
|
1806
3115
|
// turn that is the second layer — quiet nested chips under the outer turn
|
|
1807
3116
|
// summary when contiguous activity naturally clusters (≥2 only).
|
|
3117
|
+
const containsPresentedImage = timelineGroupContainsPresentedImage(group);
|
|
3118
|
+
const hasRememberedImageFold =
|
|
3119
|
+
group.kind === "activity" && containsPresentedImage && foldMemory?.has(group.id);
|
|
3120
|
+
// Primary images stay visible through live narration and the turn wrap.
|
|
3121
|
+
// Manual collapse still belongs to TurnSummary's existing fold memory.
|
|
1808
3122
|
const activityShouldFold =
|
|
1809
3123
|
group.kind === "activity" &&
|
|
1810
|
-
|
|
1811
|
-
|
|
3124
|
+
!containsPresentedImage &&
|
|
3125
|
+
!!(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
|
|
1812
3126
|
// Latch live→folded so a top-level shell that was already mounted open can
|
|
1813
3127
|
// start the settle beat without remounting bare rail → wrapper.
|
|
1814
3128
|
const liveActivitySettle = useLiveSettleFold(activityShouldFold && !insideTurn);
|
|
@@ -1817,7 +3131,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1817
3131
|
group.kind === "turn" &&
|
|
1818
3132
|
(group.outcome === "failed" ||
|
|
1819
3133
|
timelineGroupContainsAuthNeeded(group) ||
|
|
1820
|
-
|
|
3134
|
+
containsPresentedImage);
|
|
1821
3135
|
// activity-* → turn-* remount: carry resting state so settleFold does not
|
|
1822
3136
|
// re-open a chip the reader already watched collapse.
|
|
1823
3137
|
if (group.kind === "turn" && foldMemory && !insideTurn) {
|
|
@@ -1828,9 +3142,140 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1828
3142
|
);
|
|
1829
3143
|
}
|
|
1830
3144
|
const settleFold =
|
|
1831
|
-
|
|
3145
|
+
!turnSummary?.rolling &&
|
|
3146
|
+
(group.kind === "turn" ? !!(enter && !insideTurn && !turnDefaultOpen) : liveActivitySettle);
|
|
1832
3147
|
switch (group.kind) {
|
|
1833
3148
|
case "activity":
|
|
3149
|
+
if (group.work) {
|
|
3150
|
+
const phases = group.items.filter((item) => item.kind === "startup-phase");
|
|
3151
|
+
const preparing =
|
|
3152
|
+
!startupDetails &&
|
|
3153
|
+
!startupDismissed &&
|
|
3154
|
+
!group.work.endedAt &&
|
|
3155
|
+
!group.work.waiting &&
|
|
3156
|
+
phases.length > 0 &&
|
|
3157
|
+
group.items.every(
|
|
3158
|
+
(item) =>
|
|
3159
|
+
item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
|
|
3160
|
+
) &&
|
|
3161
|
+
!phases.some((item) => item.status === "failed" || item.status === "cancelled");
|
|
3162
|
+
if (preparing) {
|
|
3163
|
+
// Preparation is primary, never hidden in an activity disclosure.
|
|
3164
|
+
// Keep work.startedAt unchanged for the handoff and settled duration.
|
|
3165
|
+
return (
|
|
3166
|
+
<ActivityRail
|
|
3167
|
+
items={group.items}
|
|
3168
|
+
startupActive
|
|
3169
|
+
bare
|
|
3170
|
+
toolRegistry={toolRegistry}
|
|
3171
|
+
onOpenSession={onOpenSession}
|
|
3172
|
+
onMemoryClick={onMemoryClick}
|
|
3173
|
+
loadRetainedScreenshot={loadRetainedScreenshot}
|
|
3174
|
+
loadRetainedArtifact={loadRetainedArtifact}
|
|
3175
|
+
/>
|
|
3176
|
+
);
|
|
3177
|
+
}
|
|
3178
|
+
const end = group.work.endedAt;
|
|
3179
|
+
const status: TurnSummaryStatus = end
|
|
3180
|
+
? { kind: "worked", durationMs: durationBetween(group.work.startedAt, end) }
|
|
3181
|
+
: group.work.waiting
|
|
3182
|
+
? { kind: "waiting", ...group.work.waiting }
|
|
3183
|
+
: {
|
|
3184
|
+
kind: "working",
|
|
3185
|
+
since: group.work.startedAt,
|
|
3186
|
+
preview: containsPresentedImage ? undefined : (
|
|
3187
|
+
<RollingActivity
|
|
3188
|
+
items={group.items}
|
|
3189
|
+
toolRegistry={toolRegistry}
|
|
3190
|
+
showCount={false}
|
|
3191
|
+
/>
|
|
3192
|
+
),
|
|
3193
|
+
};
|
|
3194
|
+
return (
|
|
3195
|
+
<TurnSummary
|
|
3196
|
+
key="work"
|
|
3197
|
+
items={group.items}
|
|
3198
|
+
status={status}
|
|
3199
|
+
outcome={group.outcome}
|
|
3200
|
+
failureText={group.failureText}
|
|
3201
|
+
foldKey={group.id}
|
|
3202
|
+
defaultOpen={
|
|
3203
|
+
containsPresentedImage ||
|
|
3204
|
+
group.outcome === "failed" ||
|
|
3205
|
+
phases.some((item) => item.status === "failed" || item.status === "cancelled")
|
|
3206
|
+
? true
|
|
3207
|
+
: undefined
|
|
3208
|
+
}
|
|
3209
|
+
facets={turnSummary?.facets}
|
|
3210
|
+
contextCompactionCount={compactedLandmarkCount(group.work.details)}
|
|
3211
|
+
>
|
|
3212
|
+
<TurnRailFrame compact>
|
|
3213
|
+
{renderFoldedGroups(
|
|
3214
|
+
group.work.details,
|
|
3215
|
+
{
|
|
3216
|
+
renderMessageActions,
|
|
3217
|
+
renderMessageText,
|
|
3218
|
+
onOpenSession,
|
|
3219
|
+
onMemoryClick,
|
|
3220
|
+
onReconnect,
|
|
3221
|
+
renderAuthNeeded,
|
|
3222
|
+
resolveProviderLogo,
|
|
3223
|
+
toolRegistry,
|
|
3224
|
+
loadRetainedScreenshot,
|
|
3225
|
+
loadRetainedArtifact,
|
|
3226
|
+
loadVideoArtifactPlayback,
|
|
3227
|
+
turnSummary,
|
|
3228
|
+
},
|
|
3229
|
+
true,
|
|
3230
|
+
)}
|
|
3231
|
+
</TurnRailFrame>
|
|
3232
|
+
</TurnSummary>
|
|
3233
|
+
);
|
|
3234
|
+
}
|
|
3235
|
+
// Preparation is one quiet surface, not a fold with eight technical steps.
|
|
3236
|
+
// A later turn of the same exchange keeps its status row instead.
|
|
3237
|
+
if (
|
|
3238
|
+
!startupDetails &&
|
|
3239
|
+
!insideTurn &&
|
|
3240
|
+
!group.outcome &&
|
|
3241
|
+
group.items.every(
|
|
3242
|
+
(item) =>
|
|
3243
|
+
item.kind === "startup-phase" || (item.kind === "reasoning" && !item.text.trim()),
|
|
3244
|
+
)
|
|
3245
|
+
) {
|
|
3246
|
+
return (
|
|
3247
|
+
<ActivityRail
|
|
3248
|
+
items={group.items}
|
|
3249
|
+
startupActive={!startupDismissed && !foldLiveCluster}
|
|
3250
|
+
bare
|
|
3251
|
+
toolRegistry={toolRegistry}
|
|
3252
|
+
onOpenSession={onOpenSession}
|
|
3253
|
+
onMemoryClick={onMemoryClick}
|
|
3254
|
+
loadRetainedScreenshot={loadRetainedScreenshot}
|
|
3255
|
+
loadRetainedArtifact={loadRetainedArtifact}
|
|
3256
|
+
/>
|
|
3257
|
+
);
|
|
3258
|
+
}
|
|
3259
|
+
if (
|
|
3260
|
+
turnSummary?.rolling &&
|
|
3261
|
+
insideTurn &&
|
|
3262
|
+
!startupDetails &&
|
|
3263
|
+
!hasRememberedImageFold &&
|
|
3264
|
+
group.items.filter((item) => item.kind !== "startup-phase").length === 1
|
|
3265
|
+
) {
|
|
3266
|
+
return (
|
|
3267
|
+
<ActivityRail
|
|
3268
|
+
items={group.items}
|
|
3269
|
+
startupActive={false}
|
|
3270
|
+
bare
|
|
3271
|
+
toolRegistry={toolRegistry}
|
|
3272
|
+
onOpenSession={onOpenSession}
|
|
3273
|
+
onMemoryClick={onMemoryClick}
|
|
3274
|
+
loadRetainedScreenshot={loadRetainedScreenshot}
|
|
3275
|
+
loadRetainedArtifact={loadRetainedArtifact}
|
|
3276
|
+
/>
|
|
3277
|
+
);
|
|
3278
|
+
}
|
|
1834
3279
|
if (insideTurn) {
|
|
1835
3280
|
// Nested chips whenever the parent has ≥2 clusters. During outer settle
|
|
1836
3281
|
// chrome they stay force-open so structure is visible and height stays
|
|
@@ -1842,11 +3287,22 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1842
3287
|
// settled closed pre-wrap was showing as a CHIP, so mounting it closed
|
|
1843
3288
|
// is both the stable height and the honest state — force-opening it
|
|
1844
3289
|
// was the "already-collapsed cluster auto-expands at the end" reopen.
|
|
1845
|
-
const
|
|
3290
|
+
const visibleActivity = group.items.filter((item) => item.kind !== "startup-phase");
|
|
3291
|
+
const singleThought =
|
|
3292
|
+
!startupDetails &&
|
|
3293
|
+
visibleActivity.length === 1 &&
|
|
3294
|
+
visibleActivity[0]?.kind === "reasoning";
|
|
3295
|
+
// Untouched images stay primary output, but a reader-owned image fold
|
|
3296
|
+
// must keep its shell across a multi-cluster wrap. A bare rail would
|
|
3297
|
+
// bypass the remembered choice; retaining either state also lets the
|
|
3298
|
+
// reader reopen and close the same chip after settlement.
|
|
3299
|
+
const useNestedChip =
|
|
3300
|
+
nestClusterChips && (activityShouldFold || hasRememberedImageFold) && !singleThought;
|
|
1846
3301
|
if (!useNestedChip) {
|
|
1847
3302
|
return (
|
|
1848
3303
|
<ActivityRail
|
|
1849
3304
|
items={group.items}
|
|
3305
|
+
{...(startupDismissed ? { startupActive: false } : {})}
|
|
1850
3306
|
onOpenSession={onOpenSession}
|
|
1851
3307
|
onMemoryClick={onMemoryClick}
|
|
1852
3308
|
toolRegistry={toolRegistry}
|
|
@@ -1884,10 +3340,31 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1884
3340
|
// flips settleFold (collapse) instead of remounting bare rail → wrapper.
|
|
1885
3341
|
return (
|
|
1886
3342
|
<TurnSummary
|
|
3343
|
+
// Apply the primary-output default when a rolling activity first
|
|
3344
|
+
// produces an image; the stable foldKey still preserves user choices.
|
|
3345
|
+
key={containsPresentedImage ? "primary-image" : "activity"}
|
|
1887
3346
|
items={group.items}
|
|
1888
3347
|
outcome={group.outcome}
|
|
1889
3348
|
failureText={group.failureText}
|
|
1890
|
-
defaultOpen={
|
|
3349
|
+
defaultOpen={
|
|
3350
|
+
group.outcome === "failed" ||
|
|
3351
|
+
containsPresentedImage ||
|
|
3352
|
+
(!turnSummary?.rolling && !activityShouldFold)
|
|
3353
|
+
? true
|
|
3354
|
+
: undefined
|
|
3355
|
+
}
|
|
3356
|
+
liveHeader={
|
|
3357
|
+
turnSummary?.rolling &&
|
|
3358
|
+
!containsPresentedImage &&
|
|
3359
|
+
!group.outcome &&
|
|
3360
|
+
!foldLiveCluster ? (
|
|
3361
|
+
<RollingActivity
|
|
3362
|
+
items={group.items}
|
|
3363
|
+
toolRegistry={toolRegistry}
|
|
3364
|
+
previousItem={previousSingleActivity.current}
|
|
3365
|
+
/>
|
|
3366
|
+
) : undefined
|
|
3367
|
+
}
|
|
1891
3368
|
foldKey={group.id}
|
|
1892
3369
|
facets={turnSummary?.facets}
|
|
1893
3370
|
settleFold={settleFold}
|
|
@@ -1910,54 +3387,40 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1910
3387
|
);
|
|
1911
3388
|
case "turn": {
|
|
1912
3389
|
const activityItems = flattenActivityItems(group.groups);
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
3390
|
+
if (
|
|
3391
|
+
!startupDetails &&
|
|
3392
|
+
group.outcome === "complete" &&
|
|
3393
|
+
group.groups.every(
|
|
3394
|
+
(child) =>
|
|
3395
|
+
child.kind === "activity" &&
|
|
3396
|
+
child.items.every(
|
|
3397
|
+
(item) => item.kind === "startup-phase" && item.status === "complete",
|
|
3398
|
+
),
|
|
3399
|
+
)
|
|
3400
|
+
)
|
|
3401
|
+
return <ActivityRail items={activityItems} startupActive={false} bare />;
|
|
1917
3402
|
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
|
-
);
|
|
3403
|
+
const body = renderFoldedGroups(group.groups, {
|
|
3404
|
+
renderMessageActions,
|
|
3405
|
+
renderMessageText,
|
|
3406
|
+
onOpenSession,
|
|
3407
|
+
onMemoryClick,
|
|
3408
|
+
onReconnect,
|
|
3409
|
+
renderAuthNeeded,
|
|
3410
|
+
resolveProviderLogo,
|
|
3411
|
+
toolRegistry,
|
|
3412
|
+
loadRetainedScreenshot,
|
|
3413
|
+
loadRetainedArtifact,
|
|
3414
|
+
loadVideoArtifactPlayback,
|
|
3415
|
+
turnSummary,
|
|
1954
3416
|
});
|
|
3417
|
+
const durationMs = durationBetween(group.startedAt, group.endedAt);
|
|
1955
3418
|
return (
|
|
1956
3419
|
<TurnSummary
|
|
1957
3420
|
items={activityItems}
|
|
1958
3421
|
outcome={group.outcome}
|
|
1959
3422
|
failureText={insideTurn ? undefined : group.failureText}
|
|
1960
|
-
durationMs={
|
|
3423
|
+
durationMs={durationMs}
|
|
1961
3424
|
defaultOpen={turnDefaultOpen ? true : undefined}
|
|
1962
3425
|
bare={insideTurn}
|
|
1963
3426
|
foldKey={group.id}
|
|
@@ -1982,8 +3445,10 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1982
3445
|
return (
|
|
1983
3446
|
<TimelineRow
|
|
1984
3447
|
item={group.item}
|
|
3448
|
+
renderMessageActions={renderMessageActions}
|
|
1985
3449
|
renderMessageText={renderMessageText}
|
|
1986
3450
|
onReconnect={onReconnect}
|
|
3451
|
+
renderAuthNeeded={renderAuthNeeded}
|
|
1987
3452
|
resolveProviderLogo={resolveProviderLogo}
|
|
1988
3453
|
onOpenSession={onOpenSession}
|
|
1989
3454
|
loadVideoArtifactPlayback={loadVideoArtifactPlayback}
|
|
@@ -1992,6 +3457,78 @@ const TimelineGroupView = memo(function TimelineGroupView({
|
|
|
1992
3457
|
}
|
|
1993
3458
|
});
|
|
1994
3459
|
|
|
3460
|
+
type FoldedGroupBehavior = {
|
|
3461
|
+
renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
3462
|
+
renderMessageText?:
|
|
3463
|
+
| ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
|
|
3464
|
+
| undefined;
|
|
3465
|
+
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
3466
|
+
onMemoryClick?: ((memoryId: string) => void) | undefined;
|
|
3467
|
+
onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
|
|
3468
|
+
renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
|
|
3469
|
+
resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
|
|
3470
|
+
toolRegistry: ToolRegistry;
|
|
3471
|
+
loadRetainedScreenshot?: RetainedScreenshotLoader | undefined;
|
|
3472
|
+
loadRetainedArtifact?: RetainedArtifactLoader | undefined;
|
|
3473
|
+
loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
|
|
3474
|
+
turnSummary?: TurnSummaryOptions | undefined;
|
|
3475
|
+
};
|
|
3476
|
+
|
|
3477
|
+
/** Children of a folded turn or exchange, each on the shared rail. */
|
|
3478
|
+
function renderFoldedGroups(
|
|
3479
|
+
groups: readonly TimelineGroup[],
|
|
3480
|
+
behavior: FoldedGroupBehavior,
|
|
3481
|
+
readableWork = false,
|
|
3482
|
+
) {
|
|
3483
|
+
// Second-layer chips only when there are natural multi-cluster seams:
|
|
3484
|
+
// otherwise the outer turn chip alone is enough ("N steps" wrapping one
|
|
3485
|
+
// more "N steps" was the redundant double fold).
|
|
3486
|
+
const nestClusters = foldableActivityClusterCount(groups) >= 2;
|
|
3487
|
+
return groups.map((child, index) => (
|
|
3488
|
+
<TimelineGroupRenderBoundary
|
|
3489
|
+
key={timelineGroupKey(child)}
|
|
3490
|
+
resetKeys={[
|
|
3491
|
+
child,
|
|
3492
|
+
behavior.renderMessageActions,
|
|
3493
|
+
behavior.renderMessageText,
|
|
3494
|
+
behavior.onOpenSession,
|
|
3495
|
+
behavior.onMemoryClick,
|
|
3496
|
+
behavior.onReconnect,
|
|
3497
|
+
behavior.renderAuthNeeded,
|
|
3498
|
+
behavior.resolveProviderLogo,
|
|
3499
|
+
behavior.toolRegistry,
|
|
3500
|
+
behavior.loadRetainedScreenshot,
|
|
3501
|
+
behavior.loadRetainedArtifact,
|
|
3502
|
+
behavior.loadVideoArtifactPlayback,
|
|
3503
|
+
behavior.turnSummary,
|
|
3504
|
+
]}
|
|
3505
|
+
>
|
|
3506
|
+
{readableWork ? (
|
|
3507
|
+
<div
|
|
3508
|
+
className={
|
|
3509
|
+
index > 0 && (child.kind !== "activity" || groups[index - 1]?.kind !== "activity")
|
|
3510
|
+
? "mt-3"
|
|
3511
|
+
: undefined
|
|
3512
|
+
}
|
|
3513
|
+
>
|
|
3514
|
+
<TimelineGroupView {...behavior} group={child} insideTurn startupDismissed />
|
|
3515
|
+
</div>
|
|
3516
|
+
) : (
|
|
3517
|
+
<TimelineGroupView {...behavior} group={child} insideTurn nestClusterChips={nestClusters} />
|
|
3518
|
+
)}
|
|
3519
|
+
</TimelineGroupRenderBoundary>
|
|
3520
|
+
));
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
function compactedLandmarkCount(groups: readonly TimelineGroup[]): number {
|
|
3524
|
+
return groups.filter(
|
|
3525
|
+
(group) =>
|
|
3526
|
+
group.kind === "item" &&
|
|
3527
|
+
group.item.kind === "context-compaction" &&
|
|
3528
|
+
group.item.phase === "compacted",
|
|
3529
|
+
).length;
|
|
3530
|
+
}
|
|
3531
|
+
|
|
1995
3532
|
/**
|
|
1996
3533
|
* Body under a turn/activity chip. Remount flashes are gated by the timeline
|
|
1997
3534
|
* seen-activity-id map (not by killing entrance): FoldBody used to force
|
|
@@ -2003,9 +3540,16 @@ function FoldBody({ children }: { children: ReactNode }) {
|
|
|
2003
3540
|
|
|
2004
3541
|
/** Stable left rule for turn/activity bodies — always present so settle wrap
|
|
2005
3542
|
never inserts or removes the rail chrome. */
|
|
2006
|
-
function TurnRailFrame({ children }: { children: ReactNode }) {
|
|
3543
|
+
function TurnRailFrame({ children, compact = false }: { children: ReactNode; compact?: boolean }) {
|
|
2007
3544
|
return (
|
|
2008
|
-
<div
|
|
3545
|
+
<div
|
|
3546
|
+
className={cn(
|
|
3547
|
+
"flex flex-col border-l-2 border-og-border pl-3 sm:pl-4",
|
|
3548
|
+
compact ? "gap-0.5" : "gap-4",
|
|
3549
|
+
)}
|
|
3550
|
+
>
|
|
3551
|
+
{children}
|
|
3552
|
+
</div>
|
|
2009
3553
|
);
|
|
2010
3554
|
}
|
|
2011
3555
|
|
|
@@ -2031,7 +3575,9 @@ function useLiveSettleFold(folded: boolean): boolean {
|
|
|
2031
3575
|
function timelineGroupKey(group: TimelineGroup): string {
|
|
2032
3576
|
switch (group.kind) {
|
|
2033
3577
|
case "item":
|
|
2034
|
-
return group.item.
|
|
3578
|
+
return group.item.kind === "user-message" && group.item.reconciliationKey
|
|
3579
|
+
? group.item.reconciliationKey
|
|
3580
|
+
: group.item.id;
|
|
2035
3581
|
case "activity":
|
|
2036
3582
|
return group.id;
|
|
2037
3583
|
case "turn":
|
|
@@ -2050,26 +3596,15 @@ function timelineGroupContainsAuthNeeded(group: TimelineGroup): boolean {
|
|
|
2050
3596
|
}
|
|
2051
3597
|
}
|
|
2052
3598
|
|
|
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
3599
|
function timelineGroupItemIds(group: TimelineGroup): string[] {
|
|
2068
3600
|
switch (group.kind) {
|
|
2069
3601
|
case "item":
|
|
2070
3602
|
return [group.item.id];
|
|
2071
3603
|
case "activity":
|
|
2072
|
-
return
|
|
3604
|
+
return [
|
|
3605
|
+
...(group.work?.details ?? []).flatMap(timelineGroupItemIds),
|
|
3606
|
+
...group.items.map((item) => item.id),
|
|
3607
|
+
];
|
|
2073
3608
|
case "turn":
|
|
2074
3609
|
return group.groups.flatMap(timelineGroupItemIds);
|
|
2075
3610
|
}
|
|
@@ -2087,7 +3622,7 @@ function isAgentProgress(next: TimelineGroup | undefined): boolean {
|
|
|
2087
3622
|
return (
|
|
2088
3623
|
next.kind === "activity" ||
|
|
2089
3624
|
next.kind === "turn" ||
|
|
2090
|
-
(next.kind === "item" && next.item.kind === "agent-message")
|
|
3625
|
+
(next.kind === "item" && next.item.kind === "agent-message" && next.item.text.trim().length > 0)
|
|
2091
3626
|
);
|
|
2092
3627
|
}
|
|
2093
3628
|
|
|
@@ -2096,7 +3631,7 @@ function isAgentProgress(next: TimelineGroup | undefined): boolean {
|
|
|
2096
3631
|
item) can sit after the ACTIVE cluster, which must never fold mid-work. */
|
|
2097
3632
|
function clusterIsSettled(group: Extract<TimelineGroup, { kind: "activity" }>): boolean {
|
|
2098
3633
|
return group.items.every((item) => {
|
|
2099
|
-
if (item.kind === "reasoning") {
|
|
3634
|
+
if (item.kind === "reasoning" || item.kind === "agent-message") {
|
|
2100
3635
|
return !item.streaming;
|
|
2101
3636
|
}
|
|
2102
3637
|
// Memory writes and fleet observations are discrete, already-settled events.
|
|
@@ -2118,11 +3653,11 @@ function foldableActivityClusterCount(groups: readonly TimelineGroup[]): number
|
|
|
2118
3653
|
return count;
|
|
2119
3654
|
}
|
|
2120
3655
|
|
|
2121
|
-
function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
|
|
3656
|
+
function flattenActivityItems(groups: readonly TimelineGroup[]): ActivityItem[] {
|
|
2122
3657
|
const items: ActivityItem[] = [];
|
|
2123
3658
|
for (const group of groups) {
|
|
2124
3659
|
if (group.kind === "activity") {
|
|
2125
|
-
items.push(...group.items);
|
|
3660
|
+
items.push(...flattenActivityItems(group.work?.details ?? []), ...group.items);
|
|
2126
3661
|
} else if (group.kind === "turn") {
|
|
2127
3662
|
items.push(...flattenActivityItems(group.groups));
|
|
2128
3663
|
}
|
|
@@ -2139,6 +3674,18 @@ function collectAgentMessageText(groups: readonly TimelineGroup[]): string {
|
|
|
2139
3674
|
if (text.length > 0) {
|
|
2140
3675
|
parts.push(text);
|
|
2141
3676
|
}
|
|
3677
|
+
} else if (group.kind === "activity") {
|
|
3678
|
+
// Commentary folded into clusters is still the assistant's own prose.
|
|
3679
|
+
const nested = collectAgentMessageText(group.work?.details ?? []);
|
|
3680
|
+
if (nested.length > 0) {
|
|
3681
|
+
parts.push(nested);
|
|
3682
|
+
}
|
|
3683
|
+
for (const item of group.items) {
|
|
3684
|
+
const text = item.kind === "agent-message" ? item.text.trim() : "";
|
|
3685
|
+
if (text.length > 0) {
|
|
3686
|
+
parts.push(text);
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
2142
3689
|
} else if (group.kind === "turn") {
|
|
2143
3690
|
const nested = collectAgentMessageText(group.groups);
|
|
2144
3691
|
if (nested.length > 0) {
|
|
@@ -2159,6 +3706,9 @@ function collectTurnIdsFromGroups(groups: readonly TimelineGroup[]): Set<string>
|
|
|
2159
3706
|
ids.add(turnId);
|
|
2160
3707
|
}
|
|
2161
3708
|
} else if (group.kind === "activity") {
|
|
3709
|
+
for (const nested of collectTurnIdsFromGroups(group.work?.details ?? [])) {
|
|
3710
|
+
ids.add(nested);
|
|
3711
|
+
}
|
|
2162
3712
|
for (const item of group.items) {
|
|
2163
3713
|
if (item.turnId) {
|
|
2164
3714
|
ids.add(item.turnId);
|
|
@@ -2188,7 +3738,7 @@ export function trailingAgentTextAfterTurn(
|
|
|
2188
3738
|
if (next?.kind === "item" && next.item.kind === "agent-message") {
|
|
2189
3739
|
const turnIds = collectTurnIdsFromGroups(group.groups);
|
|
2190
3740
|
// Degenerate body with no turnIds: do not guess — safer than lifting wrong.
|
|
2191
|
-
if (turnIds.size
|
|
3741
|
+
if (!turnIds.size) {
|
|
2192
3742
|
return undefined;
|
|
2193
3743
|
}
|
|
2194
3744
|
if (!next.item.turnId || !turnIds.has(next.item.turnId)) {
|
|
@@ -2207,7 +3757,7 @@ function collectTurnCopyText(
|
|
|
2207
3757
|
const parts = [collectAgentMessageText(groups), trailingAgentText?.trim() ?? ""].filter(
|
|
2208
3758
|
(part) => part.length > 0,
|
|
2209
3759
|
);
|
|
2210
|
-
if (parts.length
|
|
3760
|
+
if (!parts.length) {
|
|
2211
3761
|
return undefined;
|
|
2212
3762
|
}
|
|
2213
3763
|
return parts.join("\n\n");
|
|
@@ -2231,26 +3781,45 @@ function durationBetween(startedAt: string, endedAt: string): number | undefined
|
|
|
2231
3781
|
*/
|
|
2232
3782
|
export function TimelineRow({
|
|
2233
3783
|
item,
|
|
3784
|
+
renderMessageActions,
|
|
2234
3785
|
renderMessageText,
|
|
2235
3786
|
onReconnect,
|
|
3787
|
+
renderAuthNeeded,
|
|
2236
3788
|
resolveProviderLogo,
|
|
2237
3789
|
onOpenSession,
|
|
2238
3790
|
loadVideoArtifactPlayback,
|
|
2239
3791
|
}: {
|
|
2240
3792
|
item: TimelineItem;
|
|
3793
|
+
renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
2241
3794
|
renderMessageText?:
|
|
2242
3795
|
| ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
|
|
2243
3796
|
| undefined;
|
|
2244
3797
|
onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
|
|
3798
|
+
/** Host-owned inline connection setup. Return undefined to use the default recovery card. */
|
|
3799
|
+
renderAuthNeeded?: ((item: AuthNeededItem) => ReactNode | undefined) | undefined;
|
|
2245
3800
|
resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
|
|
2246
3801
|
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
2247
3802
|
loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
|
|
2248
3803
|
}) {
|
|
2249
3804
|
switch (item.kind) {
|
|
2250
3805
|
case "user-message":
|
|
2251
|
-
return
|
|
3806
|
+
return (
|
|
3807
|
+
<UserMessageRow
|
|
3808
|
+
item={item}
|
|
3809
|
+
renderMessageActions={renderMessageActions}
|
|
3810
|
+
renderMessageText={renderMessageText}
|
|
3811
|
+
/>
|
|
3812
|
+
);
|
|
3813
|
+
case "human-input":
|
|
3814
|
+
return <HumanInputConversationRow item={item} />;
|
|
2252
3815
|
case "agent-message":
|
|
2253
|
-
return
|
|
3816
|
+
return (
|
|
3817
|
+
<AgentMessageRow
|
|
3818
|
+
item={item}
|
|
3819
|
+
renderMessageActions={renderMessageActions}
|
|
3820
|
+
renderMessageText={renderMessageText}
|
|
3821
|
+
/>
|
|
3822
|
+
);
|
|
2254
3823
|
case "worker-completion":
|
|
2255
3824
|
return <WorkerCompletionRow item={item} onOpenSession={onOpenSession} />;
|
|
2256
3825
|
case "session-status":
|
|
@@ -2259,7 +3828,11 @@ export function TimelineRow({
|
|
|
2259
3828
|
return <GoalRow item={item} />;
|
|
2260
3829
|
case "machine-input-batch":
|
|
2261
3830
|
return (
|
|
2262
|
-
<MachineInputBatchRow
|
|
3831
|
+
<MachineInputBatchRow
|
|
3832
|
+
item={item}
|
|
3833
|
+
onOpenSession={onOpenSession}
|
|
3834
|
+
loadVideoArtifactPlayback={loadVideoArtifactPlayback}
|
|
3835
|
+
/>
|
|
2263
3836
|
);
|
|
2264
3837
|
case "notice":
|
|
2265
3838
|
return <NoticeRow item={item} />;
|
|
@@ -2267,11 +3840,13 @@ export function TimelineRow({
|
|
|
2267
3840
|
return <CompactionRow item={item} />;
|
|
2268
3841
|
case "auth-needed":
|
|
2269
3842
|
return (
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
3843
|
+
renderAuthNeeded?.(item) ?? (
|
|
3844
|
+
<AuthNeededRow
|
|
3845
|
+
item={item}
|
|
3846
|
+
onReconnect={onReconnect}
|
|
3847
|
+
resolveProviderLogo={resolveProviderLogo}
|
|
3848
|
+
/>
|
|
3849
|
+
)
|
|
2275
3850
|
);
|
|
2276
3851
|
default:
|
|
2277
3852
|
return null;
|
|
@@ -2290,11 +3865,11 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
|
|
|
2290
3865
|
const trigger =
|
|
2291
3866
|
item.trigger && item.phase !== "started" ? COMPACTION_TRIGGER_LABEL[item.trigger] : null;
|
|
2292
3867
|
const before =
|
|
2293
|
-
item.estimatedTokensBefore
|
|
3868
|
+
item.estimatedTokensBefore != null
|
|
2294
3869
|
? Math.round(item.estimatedTokensBefore).toLocaleString("en-US")
|
|
2295
3870
|
: null;
|
|
2296
3871
|
const after =
|
|
2297
|
-
item.estimatedTokensAfter
|
|
3872
|
+
item.estimatedTokensAfter != null
|
|
2298
3873
|
? Math.round(item.estimatedTokensAfter).toLocaleString("en-US")
|
|
2299
3874
|
: null;
|
|
2300
3875
|
const title =
|
|
@@ -2309,14 +3884,14 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
|
|
|
2309
3884
|
item.phase === "compacted"
|
|
2310
3885
|
? "Chat history above is unchanged"
|
|
2311
3886
|
: item.phase === "skipped"
|
|
2312
|
-
? compactionSkipSubtitle(item.skipReason)
|
|
3887
|
+
? compactionSkipSubtitle(item.skipReason, item.providerRejection)
|
|
2313
3888
|
: null;
|
|
2314
3889
|
const pill =
|
|
2315
3890
|
item.phase === "skipped" && item.skipReason === "summarization_failed"
|
|
2316
3891
|
? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
|
|
2317
3892
|
: item.phase === "started"
|
|
2318
|
-
?
|
|
2319
|
-
:
|
|
3893
|
+
? WAITING_PILL_CLASS
|
|
3894
|
+
: NEUTRAL_PILL;
|
|
2320
3895
|
return (
|
|
2321
3896
|
<div className={cn(enter && "animate-og-enter", "flex justify-center")}>
|
|
2322
3897
|
<div
|
|
@@ -2339,21 +3914,6 @@ function CompactionRow({ item }: { item: ContextCompactionItem }) {
|
|
|
2339
3914
|
);
|
|
2340
3915
|
}
|
|
2341
3916
|
|
|
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
3917
|
/** Hover-reveal clock beside the copy control (sent / finished). */
|
|
2358
3918
|
function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
|
|
2359
3919
|
return (
|
|
@@ -2372,22 +3932,17 @@ function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
|
|
|
2372
3932
|
|
|
2373
3933
|
function UserMessageRow({
|
|
2374
3934
|
item,
|
|
3935
|
+
renderMessageActions,
|
|
2375
3936
|
renderMessageText,
|
|
2376
3937
|
}: {
|
|
2377
3938
|
item: UserMessageItem;
|
|
3939
|
+
renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
2378
3940
|
renderMessageText?:
|
|
2379
3941
|
| ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
|
|
2380
3942
|
| undefined;
|
|
2381
3943
|
}) {
|
|
2382
3944
|
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;
|
|
3945
|
+
const deliveryFailed = item.delivery?.state === "failed";
|
|
2391
3946
|
return (
|
|
2392
3947
|
<div className={cn(enter && "animate-og-enter", "flex justify-end")}>
|
|
2393
3948
|
<div className="flex max-w-[85%] min-w-0 flex-col items-end gap-1">
|
|
@@ -2400,11 +3955,19 @@ function UserMessageRow({
|
|
|
2400
3955
|
}
|
|
2401
3956
|
label="Copy message"
|
|
2402
3957
|
className="w-fit max-w-full min-w-0"
|
|
2403
|
-
trailing={
|
|
3958
|
+
trailing={
|
|
3959
|
+
<>
|
|
3960
|
+
{renderMessageActions?.(item)}
|
|
3961
|
+
<MessageFooterTime occurredAt={item.occurredAt} />
|
|
3962
|
+
</>
|
|
3963
|
+
}
|
|
2404
3964
|
>
|
|
2405
|
-
<div className=
|
|
3965
|
+
<div className={MESSAGE_BUBBLE_CLASS}>
|
|
2406
3966
|
{item.text ? (
|
|
2407
|
-
<div
|
|
3967
|
+
<div
|
|
3968
|
+
data-og-search-item={item.id}
|
|
3969
|
+
data-og-annotation-source-key={item.annotationSource?.eventId}
|
|
3970
|
+
>
|
|
2408
3971
|
{renderMessageText ? (
|
|
2409
3972
|
renderMessageText(item.text, item)
|
|
2410
3973
|
) : (
|
|
@@ -2415,34 +3978,53 @@ function UserMessageRow({
|
|
|
2415
3978
|
</div>
|
|
2416
3979
|
) : null}
|
|
2417
3980
|
{(item.annotations?.length ?? 0) > 0 ? (
|
|
2418
|
-
<
|
|
3981
|
+
<TimelineAnnotationCards
|
|
2419
3982
|
annotations={item.annotations ?? []}
|
|
2420
3983
|
className={item.text ? "mt-2" : undefined}
|
|
2421
3984
|
/>
|
|
2422
3985
|
) : null}
|
|
2423
3986
|
</div>
|
|
2424
3987
|
</CopyHoverFrame>
|
|
2425
|
-
{
|
|
2426
|
-
<div
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
3988
|
+
{deliveryFailed ? (
|
|
3989
|
+
<div
|
|
3990
|
+
role="status"
|
|
3991
|
+
className="flex max-w-full flex-col items-start gap-1 px-1 text-og-xs text-og-status-failed"
|
|
3992
|
+
>
|
|
3993
|
+
<span className="flex min-w-0 items-start gap-1">
|
|
3994
|
+
<TriangleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
|
3995
|
+
<span className="min-w-0 break-words">
|
|
3996
|
+
{item.delivery?.error || "Message not sent"}
|
|
3997
|
+
</span>
|
|
3998
|
+
</span>
|
|
3999
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
4000
|
+
{item.delivery?.onEdit ? (
|
|
4001
|
+
<button
|
|
4002
|
+
type="button"
|
|
4003
|
+
className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
|
|
4004
|
+
onClick={item.delivery.onEdit}
|
|
4005
|
+
>
|
|
4006
|
+
Edit message
|
|
4007
|
+
</button>
|
|
4008
|
+
) : null}
|
|
4009
|
+
{item.delivery?.onRetry ? (
|
|
4010
|
+
<button
|
|
4011
|
+
type="button"
|
|
4012
|
+
className="font-medium text-og-status-failed underline decoration-og-status-failed/50 underline-offset-2 hover:text-og-fg"
|
|
4013
|
+
onClick={item.delivery.onRetry}
|
|
4014
|
+
>
|
|
4015
|
+
Retry
|
|
4016
|
+
</button>
|
|
4017
|
+
) : null}
|
|
4018
|
+
{item.delivery?.onRemove ? (
|
|
4019
|
+
<button
|
|
4020
|
+
type="button"
|
|
4021
|
+
className="font-medium text-og-fg-subtle underline decoration-og-border underline-offset-2 hover:text-og-fg"
|
|
4022
|
+
onClick={item.delivery.onRemove}
|
|
4023
|
+
>
|
|
4024
|
+
Remove
|
|
4025
|
+
</button>
|
|
4026
|
+
) : null}
|
|
4027
|
+
</div>
|
|
2446
4028
|
</div>
|
|
2447
4029
|
) : null}
|
|
2448
4030
|
</div>
|
|
@@ -2450,11 +4032,146 @@ function UserMessageRow({
|
|
|
2450
4032
|
);
|
|
2451
4033
|
}
|
|
2452
4034
|
|
|
4035
|
+
function HumanInputConversationRow({ item }: { item: HumanInputItem }) {
|
|
4036
|
+
const enter = useEntranceAnimation();
|
|
4037
|
+
const multipleQuestions = Math.max(item.questions.length, item.answers.length) > 1;
|
|
4038
|
+
const questionNumberById = new Map(
|
|
4039
|
+
item.questions.map((question, index) => [question.id, index + 1]),
|
|
4040
|
+
);
|
|
4041
|
+
const settledLabel =
|
|
4042
|
+
item.response.outcome === "answered"
|
|
4043
|
+
? "You answered"
|
|
4044
|
+
: item.response.outcome === "skipped"
|
|
4045
|
+
? "Skipped"
|
|
4046
|
+
: item.response.outcome === "expired"
|
|
4047
|
+
? "Expired"
|
|
4048
|
+
: "Cancelled";
|
|
4049
|
+
const copyText = humanInputConversationCopyText(item, settledLabel);
|
|
4050
|
+
|
|
4051
|
+
return (
|
|
4052
|
+
<div
|
|
4053
|
+
className={cn(enter && "animate-og-enter", "flex w-full flex-col gap-2.5")}
|
|
4054
|
+
data-human-input-history={item.requestId}
|
|
4055
|
+
>
|
|
4056
|
+
<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%]">
|
|
4057
|
+
<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">
|
|
4058
|
+
<MessageCircleQuestionIcon aria-hidden="true" className="size-4" />
|
|
4059
|
+
</span>
|
|
4060
|
+
<div className="min-w-0 flex-1">
|
|
4061
|
+
<p className="text-og-xs font-medium text-og-fg-subtle">Agent asked</p>
|
|
4062
|
+
<div className="mt-1.5 space-y-3">
|
|
4063
|
+
{item.questions.length > 0 ? (
|
|
4064
|
+
item.questions.map((question, index) => (
|
|
4065
|
+
<div key={question.id}>
|
|
4066
|
+
{question.label ? (
|
|
4067
|
+
<p className="text-og-sm font-semibold text-og-fg">
|
|
4068
|
+
{multipleQuestions ? (
|
|
4069
|
+
<span className="mr-1.5 tabular-nums text-og-fg-muted">{index + 1}.</span>
|
|
4070
|
+
) : null}
|
|
4071
|
+
{multipleQuestions ? " " : null}
|
|
4072
|
+
{question.label}
|
|
4073
|
+
</p>
|
|
4074
|
+
) : null}
|
|
4075
|
+
<p
|
|
4076
|
+
className={cn(
|
|
4077
|
+
"text-og-md leading-6 text-og-fg",
|
|
4078
|
+
question.label && "mt-0.5 text-og-sm text-og-fg-muted",
|
|
4079
|
+
)}
|
|
4080
|
+
>
|
|
4081
|
+
{!question.label && multipleQuestions ? (
|
|
4082
|
+
<span className="mr-1.5 tabular-nums text-og-fg-muted">{index + 1}.</span>
|
|
4083
|
+
) : null}
|
|
4084
|
+
{!question.label && multipleQuestions ? " " : null}
|
|
4085
|
+
{question.prompt}
|
|
4086
|
+
</p>
|
|
4087
|
+
</div>
|
|
4088
|
+
))
|
|
4089
|
+
) : (
|
|
4090
|
+
<p className="text-og-sm text-og-fg-muted">The agent requested structured input.</p>
|
|
4091
|
+
)}
|
|
4092
|
+
</div>
|
|
4093
|
+
</div>
|
|
4094
|
+
</div>
|
|
4095
|
+
|
|
4096
|
+
<div className="flex justify-end">
|
|
4097
|
+
<CopyHoverFrame
|
|
4098
|
+
copyText={copyText}
|
|
4099
|
+
label="Copy answer"
|
|
4100
|
+
className="w-fit max-w-[90%] min-w-0 sm:max-w-[82%]"
|
|
4101
|
+
trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
|
|
4102
|
+
>
|
|
4103
|
+
<div className={MESSAGE_BUBBLE_CLASS}>
|
|
4104
|
+
<p className="text-og-xs font-medium text-og-fg-subtle">{settledLabel}</p>
|
|
4105
|
+
{item.response.outcome === "answered" ? (
|
|
4106
|
+
<div className="mt-1.5 space-y-3">
|
|
4107
|
+
{item.answers.length > 0 ? (
|
|
4108
|
+
item.answers.map((answer, answerIndex) => (
|
|
4109
|
+
<div key={answer.questionId}>
|
|
4110
|
+
{multipleQuestions ? (
|
|
4111
|
+
<p className="text-og-sm font-semibold text-og-fg">
|
|
4112
|
+
<span className="mr-1.5 tabular-nums text-og-fg-muted">
|
|
4113
|
+
{questionNumberById.get(answer.questionId) ?? answerIndex + 1}.
|
|
4114
|
+
</span>{" "}
|
|
4115
|
+
{answer.label}
|
|
4116
|
+
</p>
|
|
4117
|
+
) : null}
|
|
4118
|
+
<div className={cn("text-og-md text-og-fg", multipleQuestions && "mt-0.5")}>
|
|
4119
|
+
{answer.values.length > 1 ? (
|
|
4120
|
+
<ul className="list-disc space-y-0.5 pl-5">
|
|
4121
|
+
{answer.values.map((value) => (
|
|
4122
|
+
<li key={`${answer.questionId}-${value}`}>{value}</li>
|
|
4123
|
+
))}
|
|
4124
|
+
</ul>
|
|
4125
|
+
) : (
|
|
4126
|
+
<p>{answer.values[0] || "Answered"}</p>
|
|
4127
|
+
)}
|
|
4128
|
+
</div>
|
|
4129
|
+
</div>
|
|
4130
|
+
))
|
|
4131
|
+
) : (
|
|
4132
|
+
<p>Answered</p>
|
|
4133
|
+
)}
|
|
4134
|
+
</div>
|
|
4135
|
+
) : null}
|
|
4136
|
+
</div>
|
|
4137
|
+
</CopyHoverFrame>
|
|
4138
|
+
</div>
|
|
4139
|
+
</div>
|
|
4140
|
+
);
|
|
4141
|
+
}
|
|
4142
|
+
|
|
4143
|
+
function humanInputConversationCopyText(item: HumanInputItem, settledLabel: string): string {
|
|
4144
|
+
const questions = item.questions
|
|
4145
|
+
.map(
|
|
4146
|
+
(question, index) =>
|
|
4147
|
+
`${item.questions.length > 1 ? `${index + 1}. ` : ""}${question.label || "Question"}: ${question.prompt}`,
|
|
4148
|
+
)
|
|
4149
|
+
.join("\n\n");
|
|
4150
|
+
const questionNumberById = new Map(
|
|
4151
|
+
item.questions.map((question, index) => [question.id, index + 1]),
|
|
4152
|
+
);
|
|
4153
|
+
const answers = item.answers
|
|
4154
|
+
.map(
|
|
4155
|
+
(answer, index) =>
|
|
4156
|
+
`${
|
|
4157
|
+
item.questions.length > 1
|
|
4158
|
+
? `${questionNumberById.get(answer.questionId) ?? index + 1}. `
|
|
4159
|
+
: ""
|
|
4160
|
+
}${answer.label}: ${answer.values.join(", ") || "Answered"}`,
|
|
4161
|
+
)
|
|
4162
|
+
.join("\n\n");
|
|
4163
|
+
return [questions, `${settledLabel}${answers ? `\n\n${answers}` : ""}`]
|
|
4164
|
+
.filter(Boolean)
|
|
4165
|
+
.join("\n\n");
|
|
4166
|
+
}
|
|
4167
|
+
|
|
2453
4168
|
function AgentMessageRow({
|
|
2454
4169
|
item,
|
|
4170
|
+
renderMessageActions,
|
|
2455
4171
|
renderMessageText,
|
|
2456
4172
|
}: {
|
|
2457
4173
|
item: AgentMessageItem;
|
|
4174
|
+
renderMessageActions?: ((item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
2458
4175
|
renderMessageText?:
|
|
2459
4176
|
| ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
|
|
2460
4177
|
| undefined;
|
|
@@ -2470,17 +4187,28 @@ function AgentMessageRow({
|
|
|
2470
4187
|
// While streaming, copy is still useful (current text) but keep chrome calm —
|
|
2471
4188
|
// stamp only after the message finishes (occurredAt tracks completion).
|
|
2472
4189
|
return (
|
|
2473
|
-
<
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
4190
|
+
<CopyHoverFrame
|
|
4191
|
+
copyText={item.text}
|
|
4192
|
+
label="Copy message"
|
|
4193
|
+
align="start"
|
|
4194
|
+
className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}
|
|
4195
|
+
trailing={
|
|
4196
|
+
item.streaming ? null : (
|
|
4197
|
+
<>
|
|
4198
|
+
{renderMessageActions?.(item)}
|
|
4199
|
+
<MessageFooterTime occurredAt={item.occurredAt} />
|
|
4200
|
+
</>
|
|
4201
|
+
)
|
|
4202
|
+
}
|
|
4203
|
+
>
|
|
4204
|
+
<div
|
|
4205
|
+
data-og-wide-table-message=""
|
|
4206
|
+
data-og-search-item={item.id}
|
|
4207
|
+
data-og-annotation-source-key={item.annotationSource?.eventId}
|
|
2480
4208
|
>
|
|
2481
4209
|
{body}
|
|
2482
|
-
</
|
|
2483
|
-
</
|
|
4210
|
+
</div>
|
|
4211
|
+
</CopyHoverFrame>
|
|
2484
4212
|
);
|
|
2485
4213
|
}
|
|
2486
4214
|
|
|
@@ -2555,9 +4283,7 @@ function WorkerCompletionRow({
|
|
|
2555
4283
|
// payloads can carry a leftover pausedReason/rationale from earlier in the
|
|
2556
4284
|
// worker's life, and a "Worker completed" card must not show a pause section.
|
|
2557
4285
|
const showPausedReason =
|
|
2558
|
-
item.childStatus !== "failed" &&
|
|
2559
|
-
item.goalStatus === "paused" &&
|
|
2560
|
-
Boolean(item.pausedReason?.trim());
|
|
4286
|
+
item.childStatus !== "failed" && item.goalStatus === "paused" && !!item.pausedReason?.trim();
|
|
2561
4287
|
const details: { label: string; value: string; muted?: boolean }[] = [
|
|
2562
4288
|
...(item.text.trim() ? [{ label: "Report", value: item.text.trim() }] : []),
|
|
2563
4289
|
...(item.evidence?.trim()
|
|
@@ -2640,7 +4366,11 @@ function WorkerCompletionRow({
|
|
|
2640
4366
|
);
|
|
2641
4367
|
}
|
|
2642
4368
|
|
|
2643
|
-
function SessionStatusRow({
|
|
4369
|
+
function SessionStatusRow({
|
|
4370
|
+
item,
|
|
4371
|
+
}: {
|
|
4372
|
+
item: { status: SessionStatus; occurredAt: string; resolvedAt?: string };
|
|
4373
|
+
}) {
|
|
2644
4374
|
const enter = useEntranceAnimation();
|
|
2645
4375
|
const meta = SESSION_STATUS_META[item.status];
|
|
2646
4376
|
return (
|
|
@@ -2653,8 +4383,9 @@ function SessionStatusRow({ item }: { item: { status: SessionStatus; occurredAt:
|
|
|
2653
4383
|
>
|
|
2654
4384
|
<span className="h-px flex-1 bg-og-border" />
|
|
2655
4385
|
<span className="inline-flex items-center gap-1.5">
|
|
2656
|
-
<StatusDot status={item.status} className="size-1" />
|
|
2657
|
-
{meta.label.toLowerCase()} ·
|
|
4386
|
+
{item.resolvedAt ? null : <StatusDot status={item.status} className="size-1" />}
|
|
4387
|
+
{item.resolvedAt ? "work resumed" : meta.label.toLowerCase()} ·{" "}
|
|
4388
|
+
{formatRelativeTime(item.resolvedAt ?? item.occurredAt)}
|
|
2658
4389
|
</span>
|
|
2659
4390
|
<span className="h-px flex-1 bg-og-border" />
|
|
2660
4391
|
</div>
|
|
@@ -2682,36 +4413,46 @@ type GoalMeta = { label: string; pill: string; icon: ComponentType<{ className?:
|
|
|
2682
4413
|
|
|
2683
4414
|
const NEUTRAL_PILL = "border-og-border bg-og-surface-1 text-og-fg-muted";
|
|
2684
4415
|
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
}
|
|
4416
|
+
// Shared production chunks can be cyclic. Resolve icon bindings during render,
|
|
4417
|
+
// after their modules initialize, rather than permanently capturing undefined.
|
|
4418
|
+
function goalMeta(action: GoalItem["action"]): GoalMeta {
|
|
4419
|
+
const metadata: Record<GoalItem["action"], GoalMeta> = {
|
|
4420
|
+
set: {
|
|
4421
|
+
label: "Goal set",
|
|
4422
|
+
pill: "border-og-accent/30 bg-og-accent/10 text-og-accent",
|
|
4423
|
+
icon: TargetIcon,
|
|
4424
|
+
},
|
|
4425
|
+
updated: { label: "Goal updated", pill: NEUTRAL_PILL, icon: PencilLineIcon },
|
|
4426
|
+
completed: {
|
|
4427
|
+
label: "Goal completed",
|
|
4428
|
+
pill: "border-og-status-idle/30 bg-og-status-idle/10 text-og-status-idle",
|
|
4429
|
+
icon: CheckIcon,
|
|
4430
|
+
},
|
|
4431
|
+
paused: {
|
|
4432
|
+
label: "Goal paused",
|
|
4433
|
+
pill: WAITING_PILL_CLASS,
|
|
4434
|
+
icon: PauseIcon,
|
|
4435
|
+
},
|
|
4436
|
+
resumed: { label: "Goal resumed", pill: NEUTRAL_PILL, icon: PlayIcon },
|
|
4437
|
+
cleared: { label: "Goal cleared", pill: NEUTRAL_PILL, icon: Trash2Icon },
|
|
4438
|
+
held: {
|
|
4439
|
+
label: "Goal held",
|
|
4440
|
+
pill: WAITING_PILL_CLASS,
|
|
4441
|
+
icon: PauseCircleIcon,
|
|
4442
|
+
},
|
|
4443
|
+
continuation: { label: "Continuing toward the goal", pill: NEUTRAL_PILL, icon: ArrowRightIcon },
|
|
4444
|
+
};
|
|
4445
|
+
return metadata[action];
|
|
4446
|
+
}
|
|
2706
4447
|
|
|
2707
4448
|
/**
|
|
2708
4449
|
* A goal landmark pill. Resolves its label, accent/tone, and glyph from
|
|
2709
|
-
* {@link
|
|
4450
|
+
* {@link goalMeta} so all six actions are visually distinguishable while the
|
|
2710
4451
|
* palette stays restrained — see that table for the per-action rationale.
|
|
2711
4452
|
*/
|
|
2712
4453
|
function GoalRow({ item }: { item: GoalItem }) {
|
|
2713
4454
|
const enter = useEntranceAnimation();
|
|
2714
|
-
const { label, pill, icon: Icon } =
|
|
4455
|
+
const { label, pill, icon: Icon } = goalMeta(item.action);
|
|
2715
4456
|
return (
|
|
2716
4457
|
<div className={cn(enter && "animate-og-enter", "flex justify-center")}>
|
|
2717
4458
|
<span
|
|
@@ -2732,9 +4473,11 @@ function GoalRow({ item }: { item: GoalItem }) {
|
|
|
2732
4473
|
|
|
2733
4474
|
function MachineInputBatchRow({
|
|
2734
4475
|
item,
|
|
4476
|
+
onOpenSession,
|
|
2735
4477
|
loadVideoArtifactPlayback,
|
|
2736
4478
|
}: {
|
|
2737
4479
|
item: MachineInputBatchItem;
|
|
4480
|
+
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
2738
4481
|
loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
|
|
2739
4482
|
}) {
|
|
2740
4483
|
const enter = useEntranceAnimation();
|
|
@@ -2750,11 +4493,15 @@ function MachineInputBatchRow({
|
|
|
2750
4493
|
}
|
|
2751
4494
|
const singleSummary = single ? cleanMachineInputSummary(single.summary) : "";
|
|
2752
4495
|
const showCollapsedSummary =
|
|
2753
|
-
single != null && machineInputSummaryIsUseful(single.kind, singleSummary);
|
|
4496
|
+
!item.compact && single != null && machineInputSummaryIsUseful(single.kind, singleSummary);
|
|
2754
4497
|
|
|
2755
4498
|
return (
|
|
2756
4499
|
<div className={cn(enter && "animate-og-enter", "flex flex-col items-center gap-1.5")}>
|
|
2757
|
-
<details
|
|
4500
|
+
<details
|
|
4501
|
+
className="group w-full max-w-full"
|
|
4502
|
+
data-og-machine-input-batch=""
|
|
4503
|
+
title={`Received ${formatClockTime(item.occurredAt)}`}
|
|
4504
|
+
>
|
|
2758
4505
|
<summary className="flex cursor-pointer list-none justify-center [&::-webkit-details-marker]:hidden">
|
|
2759
4506
|
<span
|
|
2760
4507
|
className={cn(
|
|
@@ -2770,10 +4517,14 @@ function MachineInputBatchRow({
|
|
|
2770
4517
|
</span>
|
|
2771
4518
|
</summary>
|
|
2772
4519
|
<div className="mx-auto mt-2 w-full max-w-lg space-y-2 border-t border-og-border/50 pt-2">
|
|
4520
|
+
<p className="text-og-xs text-og-fg-subtle">
|
|
4521
|
+
Received <time dateTime={item.occurredAt}>{formatClockTime(item.occurredAt)}</time>
|
|
4522
|
+
</p>
|
|
2773
4523
|
{item.members.map((member) => (
|
|
2774
4524
|
<MachineInputRow
|
|
2775
4525
|
key={member.id}
|
|
2776
4526
|
member={member}
|
|
4527
|
+
onOpenSession={onOpenSession}
|
|
2777
4528
|
loadVideoArtifactPlayback={loadVideoArtifactPlayback}
|
|
2778
4529
|
/>
|
|
2779
4530
|
))}
|
|
@@ -2790,9 +4541,11 @@ function MachineInputBatchRow({
|
|
|
2790
4541
|
|
|
2791
4542
|
function MachineInputRow({
|
|
2792
4543
|
member,
|
|
4544
|
+
onOpenSession,
|
|
2793
4545
|
loadVideoArtifactPlayback,
|
|
2794
4546
|
}: {
|
|
2795
4547
|
member: MachineInputBatchItem["members"][number];
|
|
4548
|
+
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
2796
4549
|
loadVideoArtifactPlayback?: VideoArtifactPlaybackLoader | undefined;
|
|
2797
4550
|
}) {
|
|
2798
4551
|
if (member.kind === "media_generation_result" && member.result) {
|
|
@@ -2819,6 +4572,11 @@ function MachineInputRow({
|
|
|
2819
4572
|
{truncate(summary, 320)}
|
|
2820
4573
|
</p>
|
|
2821
4574
|
) : null}
|
|
4575
|
+
<ChildSessionLink
|
|
4576
|
+
kind={member.kind}
|
|
4577
|
+
sourceId={member.sourceId}
|
|
4578
|
+
onOpenSession={onOpenSession}
|
|
4579
|
+
/>
|
|
2822
4580
|
</div>
|
|
2823
4581
|
</div>
|
|
2824
4582
|
);
|
|
@@ -2892,12 +4650,34 @@ function formatVideoDuration(seconds: number): string {
|
|
|
2892
4650
|
|
|
2893
4651
|
function NoticeRow({ item }: { item: NoticeItem }) {
|
|
2894
4652
|
const enter = useEntranceAnimation();
|
|
4653
|
+
if (item.allowance) return <AllowanceExhaustedRow refusal={item.allowance} />;
|
|
4654
|
+
if (item.recordedOutcome) {
|
|
4655
|
+
return (
|
|
4656
|
+
<details
|
|
4657
|
+
className="group text-og-sm text-og-fg-muted"
|
|
4658
|
+
role="note"
|
|
4659
|
+
data-og-recorded-outcome="wait"
|
|
4660
|
+
>
|
|
4661
|
+
<summary className="flex cursor-pointer list-none items-center gap-2 py-1 [&::-webkit-details-marker]:hidden">
|
|
4662
|
+
<ChevronRightIcon
|
|
4663
|
+
aria-hidden
|
|
4664
|
+
className="size-3.5 transition-transform group-open:rotate-90"
|
|
4665
|
+
/>
|
|
4666
|
+
<span title={new Date(item.occurredAt).toLocaleString()}>
|
|
4667
|
+
{recordedWaitSummary(item)}
|
|
4668
|
+
</span>
|
|
4669
|
+
</summary>
|
|
4670
|
+
<p className="mt-1 whitespace-pre-wrap break-words pl-5 text-og-fg-muted">{item.text}</p>
|
|
4671
|
+
</details>
|
|
4672
|
+
);
|
|
4673
|
+
}
|
|
4674
|
+
const resolvedApproval = Boolean(item.resolvedAt && item.text.startsWith("Approval needed"));
|
|
2895
4675
|
const tone =
|
|
2896
4676
|
item.tone === "failed"
|
|
2897
4677
|
? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
|
|
2898
|
-
: item.tone === "waiting"
|
|
2899
|
-
?
|
|
2900
|
-
:
|
|
4678
|
+
: item.tone === "waiting" && !item.resolvedAt
|
|
4679
|
+
? WAITING_PILL_CLASS
|
|
4680
|
+
: NEUTRAL_PILL;
|
|
2901
4681
|
return (
|
|
2902
4682
|
<div
|
|
2903
4683
|
className={cn(
|
|
@@ -2907,15 +4687,21 @@ function NoticeRow({ item }: { item: NoticeItem }) {
|
|
|
2907
4687
|
)}
|
|
2908
4688
|
role="status"
|
|
2909
4689
|
>
|
|
2910
|
-
|
|
2911
|
-
className=
|
|
2912
|
-
|
|
4690
|
+
{resolvedApproval ? (
|
|
4691
|
+
<CheckIcon className="mt-0.5 size-4 shrink-0 opacity-70" aria-hidden="true" />
|
|
4692
|
+
) : (
|
|
4693
|
+
<TriangleAlertIcon
|
|
4694
|
+
className={cn("mt-0.5 size-4 shrink-0", item.tone === "cancelled" && "opacity-60")}
|
|
4695
|
+
/>
|
|
4696
|
+
)}
|
|
2913
4697
|
<div className="min-w-0 flex-1">
|
|
2914
|
-
<span className="whitespace-pre-wrap break-words">
|
|
4698
|
+
<span className="whitespace-pre-wrap break-words">
|
|
4699
|
+
{resolvedApproval ? "You responded to this approval." : item.text}
|
|
4700
|
+
</span>
|
|
2915
4701
|
{item.details ? (
|
|
2916
4702
|
<details className="mt-2 text-og-control">
|
|
2917
4703
|
<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-
|
|
4704
|
+
<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
4705
|
{JSON.stringify(item.details.value, null, 2)}
|
|
2920
4706
|
</pre>
|
|
2921
4707
|
</details>
|
|
@@ -2935,6 +4721,41 @@ function NoticeRow({ item }: { item: NoticeItem }) {
|
|
|
2935
4721
|
);
|
|
2936
4722
|
}
|
|
2937
4723
|
|
|
4724
|
+
/**
|
|
4725
|
+
* "Waited for 2 agents · 3m 5s" once later input ended the wait, otherwise
|
|
4726
|
+
* "Waiting for 2 agents · since 10:32" (with the date on a later day). The
|
|
4727
|
+
* reason stays behind the disclosure.
|
|
4728
|
+
*/
|
|
4729
|
+
function recordedWaitSummary(item: NoticeItem): ReactNode {
|
|
4730
|
+
const agents = item.waitingAgents ?? 0;
|
|
4731
|
+
const target = agents > 0 ? ` for ${agents} ${agents === 1 ? "agent" : "agents"}` : "";
|
|
4732
|
+
const waitedMs = item.waitEndedAt
|
|
4733
|
+
? durationBetween(item.occurredAt, item.waitEndedAt)
|
|
4734
|
+
: undefined;
|
|
4735
|
+
if (waitedMs !== undefined) {
|
|
4736
|
+
return (
|
|
4737
|
+
<>
|
|
4738
|
+
Waited{target} ·{" "}
|
|
4739
|
+
<time dateTime={`PT${Math.floor(waitedMs / 1000)}S`}>{formatElapsed(waitedMs)}</time>
|
|
4740
|
+
</>
|
|
4741
|
+
);
|
|
4742
|
+
}
|
|
4743
|
+
// An open wait is historical once its day has passed; keep its date visible.
|
|
4744
|
+
const started = new Date(item.occurredAt);
|
|
4745
|
+
const sameDay = started.toDateString() === new Date().toDateString();
|
|
4746
|
+
return (
|
|
4747
|
+
<>
|
|
4748
|
+
{item.waitEndedAt ? "Waited" : "Waiting"}
|
|
4749
|
+
{target} · since{" "}
|
|
4750
|
+
<time dateTime={item.occurredAt}>
|
|
4751
|
+
{sameDay
|
|
4752
|
+
? formatClockTime(item.occurredAt)
|
|
4753
|
+
: started.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}
|
|
4754
|
+
</time>
|
|
4755
|
+
</>
|
|
4756
|
+
);
|
|
4757
|
+
}
|
|
4758
|
+
|
|
2938
4759
|
/**
|
|
2939
4760
|
* The inline connection-recovery card: missing or lapsed access surfaces as a
|
|
2940
4761
|
* calm, tappable affordance instead of a raw provider-domain error. The `reason`
|
|
@@ -2981,6 +4802,7 @@ function AuthNeededRow({
|
|
|
2981
4802
|
? `${provider} tools unavailable`
|
|
2982
4803
|
: `${actionLabel} ${provider}`;
|
|
2983
4804
|
const reasonLine = recommendation?.rationale ?? authReasonLine(item.reason);
|
|
4805
|
+
const hostAuthorizationUrl = item.authoritySource === "host" ? item.authorizationUrl : null;
|
|
2984
4806
|
|
|
2985
4807
|
const start = async () => {
|
|
2986
4808
|
if (!onReconnect || busy) {
|
|
@@ -3023,27 +4845,40 @@ function AuthNeededRow({
|
|
|
3023
4845
|
) : null}
|
|
3024
4846
|
</div>
|
|
3025
4847
|
</div>
|
|
3026
|
-
{
|
|
4848
|
+
{hostAuthorizationUrl ? (
|
|
4849
|
+
<a
|
|
4850
|
+
href={hostAuthorizationUrl}
|
|
4851
|
+
rel="noreferrer"
|
|
4852
|
+
target="_blank"
|
|
4853
|
+
className={cn(
|
|
4854
|
+
PRIMARY_ACTION_CLASS,
|
|
4855
|
+
"transition-colors hover:bg-og-primary-hover pointer-coarse:min-h-9",
|
|
4856
|
+
)}
|
|
4857
|
+
>
|
|
4858
|
+
<RefreshCwIcon className="size-3.5" aria-hidden />
|
|
4859
|
+
{actionLabel}
|
|
4860
|
+
</a>
|
|
4861
|
+
) : item.authoritySource !== "host" && !unavailable && onReconnect ? (
|
|
3027
4862
|
<button
|
|
3028
4863
|
type="button"
|
|
3029
4864
|
onClick={() => void start()}
|
|
3030
4865
|
disabled={busy}
|
|
3031
4866
|
className={cn(
|
|
3032
|
-
|
|
3033
|
-
"transition-colors hover:bg-og-
|
|
4867
|
+
PRIMARY_ACTION_CLASS,
|
|
4868
|
+
"transition-colors hover:bg-og-primary-hover disabled:opacity-50 pointer-coarse:min-h-9",
|
|
3034
4869
|
)}
|
|
3035
4870
|
>
|
|
3036
4871
|
<RefreshCwIcon className={cn("size-3.5", busy && "animate-og-spin")} aria-hidden />
|
|
3037
4872
|
{busy ? "Opening…" : actionLabel}
|
|
3038
4873
|
</button>
|
|
3039
|
-
) : !unavailable && item.authorizationUrl ? (
|
|
4874
|
+
) : item.authoritySource !== "host" && !unavailable && item.authorizationUrl ? (
|
|
3040
4875
|
<a
|
|
3041
4876
|
href={item.authorizationUrl}
|
|
3042
4877
|
rel="noreferrer"
|
|
3043
4878
|
target="_blank"
|
|
3044
4879
|
className={cn(
|
|
3045
|
-
|
|
3046
|
-
"transition-colors hover:bg-og-
|
|
4880
|
+
PRIMARY_ACTION_CLASS,
|
|
4881
|
+
"transition-colors hover:bg-og-primary-hover pointer-coarse:min-h-9",
|
|
3047
4882
|
)}
|
|
3048
4883
|
>
|
|
3049
4884
|
<RefreshCwIcon className="size-3.5" aria-hidden />
|
|
@@ -3105,7 +4940,7 @@ function AuthProviderLogo({ src, label }: { src: string | null; label: string })
|
|
|
3105
4940
|
`capabilityMonogram` so the reconnect tile reads like every other logo tile. */
|
|
3106
4941
|
function monogram(label: string): string {
|
|
3107
4942
|
const words = label.trim().split(/\s+/).filter(Boolean);
|
|
3108
|
-
if (words.length
|
|
4943
|
+
if (!words.length) {
|
|
3109
4944
|
return "?";
|
|
3110
4945
|
}
|
|
3111
4946
|
if (words.length === 1) {
|