@kabmo-labs/kabmo 0.7.0 → 0.9.0
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 +7 -0
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +10 -1
- package/src/client/components/common/code-surface/documents.test.ts +120 -0
- package/src/client/components/common/code-surface/documents.ts +201 -0
- package/src/client/components/common/code-surface/extensions.test.ts +110 -0
- package/src/client/components/common/code-surface/extensions.ts +162 -0
- package/src/client/components/common/code-surface/handle.ts +102 -0
- package/src/client/components/common/code-surface/index.tsx +253 -314
- package/src/client/components/common/code-surface/keymap.test.ts +133 -0
- package/src/client/components/common/code-surface/keymap.ts +267 -0
- package/src/client/components/common/code-surface/marks.ts +74 -0
- package/src/client/components/common/code-surface/shiki.ts +234 -0
- package/src/client/components/common/code-surface/sync.test.ts +185 -0
- package/src/client/components/common/code-surface/sync.ts +58 -0
- package/src/client/components/common/code-surface/theme.test.ts +202 -0
- package/src/client/components/common/code-surface/theme.ts +111 -0
- package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
- package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
- package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
- package/src/client/components/common/shell-update-button/index.tsx +125 -0
- package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
- package/src/client/components/common/toolbar-row/index.tsx +189 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +55 -69
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/Modal.tsx +8 -0
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
- package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
- package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
- package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
- package/src/client/components/workspace/diff-panel/index.tsx +76 -145
- package/src/client/components/workspace/diff-panel/merge.ts +315 -0
- package/src/client/components/workspace/editor/Body.tsx +4 -7
- package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
- package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
- package/src/client/components/workspace/editor/FilePane.tsx +4 -7
- package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
- package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
- package/src/client/components/workspace/editor/index.tsx +7 -9
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
- package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/search-panel/Body.tsx +1 -1
- package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
- package/src/client/hooks/chat/omp/reconnect.ts +187 -0
- package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
- package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
- package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
- package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
- package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
- package/src/client/hooks/chat/omp/session-list.tsx +69 -9
- package/src/client/hooks/chat/timeline/generating.ts +19 -2
- package/src/client/hooks/chat/timeline/index.ts +27 -28
- package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
- package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
- package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
- package/src/client/hooks/chat/timeline/session-load.ts +69 -37
- package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
- package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
- package/src/client/hooks/code/deferred-highlight.ts +129 -0
- package/src/client/hooks/editor/find-state.ts +1 -1
- package/src/client/hooks/editor/theme.test.ts +206 -0
- package/src/client/hooks/editor/theme.ts +82 -0
- package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
- package/src/client/hooks/editor/use-editor-drop.ts +67 -14
- package/src/client/hooks/editor/use-editor-find.ts +7 -7
- package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
- package/src/client/hooks/editor/use-outline-view.ts +94 -7
- package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
- package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
- package/src/client/hooks/editor/use-search-reveal.ts +1 -1
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
- package/src/client/hooks/ui/updates/shell-download.ts +153 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
- package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
- package/src/client/hooks/ui/updates/shell-update.ts +119 -0
- package/src/client/hooks/workspace/diff-file.ts +29 -10
- package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
- package/src/client/hooks/workspace/editor-view.ts +61 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +165 -0
- package/src/client/lib/desktop/bridge.ts +45 -0
- package/src/client/lib/search-reveal.ts +1 -1
- package/src/client/main.tsx +5 -1
- package/src/client/tailwind.css +56 -8
- package/src/server/index.ts +7 -2
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +262 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +142 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
- package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
- package/src/server/lib/lifecycle/port-guard.ts +39 -7
- package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
- package/src/server/lib/lifecycle/probe.test.ts +77 -164
- package/src/server/lib/lifecycle/probe.ts +121 -13
- package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
- package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
- package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
- package/src/server/lib/omp/session/telemetry.test.ts +33 -0
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/realtime/signals.server.ts +10 -0
- package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
- package/src/server/lib/realtime/topics.server.ts +16 -0
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +200 -0
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +187 -0
- package/src/server/lib/updates/shell-pages.ts +157 -0
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +59 -71
- package/src/server/lib/updates/shell-release.ts +178 -198
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/fs/git-merge.test.ts +11 -1
- package/src/server/routes/fs/read.test.ts +24 -7
- package/src/server/routes/fs/scope.test.ts +15 -10
- package/src/server/routes/fs/scope.ts +3 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +59 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +134 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +326 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
- package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
- package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
- package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
- package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
- package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
- package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
- package/src/shared/lib/code/editor/find-scan.ts +177 -0
- package/src/shared/lib/code/editor/keymap.ts +3 -3
- package/src/shared/lib/code/editor/lines.test.ts +7 -55
- package/src/shared/lib/code/editor/lines.ts +8 -48
- package/src/shared/lib/code/editor/marks.test.ts +4 -5
- package/src/shared/lib/code/editor/marks.ts +13 -25
- package/src/shared/lib/code/editor/theme-loader.ts +97 -0
- package/src/shared/lib/code/editor/themes.generated.ts +156 -0
- package/src/shared/lib/code/editor/themes.test.ts +165 -0
- package/src/shared/lib/code/editor/themes.ts +165 -0
- package/src/shared/lib/code/highlight-memo.test.ts +103 -0
- package/src/shared/lib/code/highlight-memo.ts +149 -0
- package/src/shared/lib/code/highlighter-warmup.ts +112 -0
- package/src/shared/lib/code/highlighter.ts +3 -1
- package/src/shared/lib/code/idle.ts +37 -0
- package/src/shared/lib/code/outline/rows.test.ts +96 -0
- package/src/shared/lib/code/outline/rows.ts +94 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
- package/src/shared/lib/code/syntax-highlight.ts +2 -3
- package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
- package/src/shared/lib/fs/file-kind.ts +47 -13
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/file-url.test.ts +52 -0
- package/src/shared/lib/fs/file-url.ts +37 -0
- package/src/shared/lib/fs/patch-sides.test.ts +114 -0
- package/src/shared/lib/fs/patch-sides.ts +139 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/realtime/protocol.ts +16 -0
- package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
- package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
- package/src/shared/lib/updates/shell-arch.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
- package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
- package/src/shared/lib/workspace/panel-widths.ts +25 -5
- package/src/shared/lib/workspace/session-state/cache.ts +159 -0
- package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
- package/src/shared/lib/workspace/session-state/merge.ts +100 -0
- package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
- package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
- package/src/shared/lib/workspace/session-state/store.ts +92 -130
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-nzyjf92m.css +0 -1
- package/src/client/components/common/code-editor/handler.test.ts +0 -274
- package/src/client/components/common/code-editor/handler.ts +0 -175
- package/src/client/components/common/code-editor/index.tsx +0 -314
- package/src/client/components/common/code-surface/measure.ts +0 -148
- package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
- package/src/client/hooks/editor/scroll-container.test.ts +0 -102
- package/src/client/hooks/editor/scroll-container.ts +0 -26
- package/src/client/hooks/editor/use-code-window.ts +0 -100
- package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
- package/src/client/hooks/editor/use-editor-history.ts +0 -134
- package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
- package/src/client/hooks/editor/use-occurrences.ts +0 -98
- package/src/shared/lib/code/editor/commands.test.ts +0 -155
- package/src/shared/lib/code/editor/commands.ts +0 -260
- package/src/shared/lib/code/editor/edits.test.ts +0 -100
- package/src/shared/lib/code/editor/edits.ts +0 -122
- package/src/shared/lib/code/editor/history.test.ts +0 -118
- package/src/shared/lib/code/editor/history.ts +0 -101
- package/src/shared/lib/code/editor/layers.test.ts +0 -77
- package/src/shared/lib/code/editor/layers.ts +0 -67
- package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
- package/src/shared/lib/code/editor/line-edits.ts +0 -159
- package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
- package/src/shared/lib/code/editor/occurrences.ts +0 -111
- package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
- package/src/shared/lib/code/editor/reset-css.ts +0 -32
- package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
- package/src/shared/lib/code/editor/text-commands.ts +0 -154
- package/src/shared/lib/code/lazy-window.test.ts +0 -161
- package/src/shared/lib/code/lazy-window.ts +0 -156
- package/src/shared/lib/code/windowed-highlight.ts +0 -114
- package/src/shared/lib/fs/diff-parser.test.ts +0 -116
- package/src/shared/lib/fs/diff-parser.ts +0 -213
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The lost-run trigger: a channel that comes back after frames were missed.
|
|
8
|
+
*
|
|
9
|
+
* The reported failure is a run whose `message_end`/`agent_end` were emitted
|
|
10
|
+
* into a socket nobody was reading (a backgrounded tab, a frozen phone), so the
|
|
11
|
+
* timeline kept the stream's last partial segment and its generating flag never
|
|
12
|
+
* released. What decides whether this hook acts is the CHANNEL, and the two
|
|
13
|
+
* conditions are pinned here because getting either wrong is destructive:
|
|
14
|
+
*
|
|
15
|
+
* - a dial is not a reconnect. A send whose `connect()` opens a closed channel
|
|
16
|
+
* passes through `connecting`/`open` too, and settling there would delete the
|
|
17
|
+
* optimistic turn of a prompt that is not even on the wire yet.
|
|
18
|
+
* - the server's verdict must be asked, and only `runIsOver` settles. An
|
|
19
|
+
* unanswered probe, a `busy` session and a live run all leave the timeline
|
|
20
|
+
* alone.
|
|
21
|
+
*
|
|
22
|
+
* The status publisher driven here is the one the connection layer calls
|
|
23
|
+
* (`setStatus`), so the trigger path is the real one; the socket itself is not
|
|
24
|
+
* what these cases are about.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
28
|
+
import { Window } from 'happy-dom';
|
|
29
|
+
import { h, render } from 'preact';
|
|
30
|
+
import { useRef } from 'preact/hooks';
|
|
31
|
+
import { act } from 'preact/test-utils';
|
|
32
|
+
|
|
33
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
34
|
+
import { useReconnectSettle } from '@/client/hooks/chat/omp/reconnect';
|
|
35
|
+
import { resetConnection } from '@/shared/lib/realtime/connection';
|
|
36
|
+
import { setStatus } from '@/shared/lib/realtime/state';
|
|
37
|
+
|
|
38
|
+
let container: HTMLElement | undefined;
|
|
39
|
+
const originalFetch = Bun.fetch;
|
|
40
|
+
|
|
41
|
+
beforeAll(() => {
|
|
42
|
+
installDomGlobals(new Window({ url: 'http://localhost' }));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterAll(() => {
|
|
46
|
+
restoreDomGlobals();
|
|
47
|
+
globalThis.fetch = originalFetch;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
if (container) render(null, container);
|
|
52
|
+
container?.remove();
|
|
53
|
+
container = undefined;
|
|
54
|
+
resetConnection();
|
|
55
|
+
globalThis.fetch = originalFetch;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
interface Harness {
|
|
59
|
+
settles: () => number;
|
|
60
|
+
losses: () => number;
|
|
61
|
+
/** Re-render under another session id (a spawn adoption). */
|
|
62
|
+
adopt: (nextId: string) => Promise<void>;
|
|
63
|
+
generating: { current: boolean };
|
|
64
|
+
streamRun: { current: boolean };
|
|
65
|
+
serverRun: { current: boolean };
|
|
66
|
+
/** Drive the connection status the way the socket lifecycle does. */
|
|
67
|
+
setStatus: (status: 'idle' | 'connecting' | 'open' | 'closed') => Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Answer the attach probe with `answer` (or fail when it is null). */
|
|
71
|
+
function stubProbe(answer: unknown, onCall?: () => void): void {
|
|
72
|
+
globalThis.fetch = (async () => {
|
|
73
|
+
onCall?.();
|
|
74
|
+
if (answer === null) throw new Error('offline');
|
|
75
|
+
return new Response(JSON.stringify(answer), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
76
|
+
}) as unknown as typeof fetch;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function mount(generating: boolean, sessionId = 'omp-1'): Promise<Harness> {
|
|
80
|
+
const seen = {
|
|
81
|
+
settles: 0,
|
|
82
|
+
losses: 0,
|
|
83
|
+
generating: { current: generating },
|
|
84
|
+
streamRun: { current: generating },
|
|
85
|
+
serverRun: { current: generating },
|
|
86
|
+
};
|
|
87
|
+
function Probe({ id = sessionId }: { id?: string }) {
|
|
88
|
+
const generatingRef = useRef(seen.generating.current);
|
|
89
|
+
// The stream has announced the run, which is the state a lost run is in.
|
|
90
|
+
const streamRunRef = useRef(seen.streamRun.current);
|
|
91
|
+
const serverRunRef = useRef(seen.serverRun.current);
|
|
92
|
+
seen.generating = generatingRef;
|
|
93
|
+
seen.streamRun = streamRunRef;
|
|
94
|
+
seen.serverRun = serverRunRef;
|
|
95
|
+
useReconnectSettle({
|
|
96
|
+
sessionId: id,
|
|
97
|
+
isGeneratingRef: generatingRef,
|
|
98
|
+
streamRunRef,
|
|
99
|
+
serverRunRef,
|
|
100
|
+
onSettled: () => { seen.settles += 1; },
|
|
101
|
+
onFramesLost: () => { seen.losses += 1; },
|
|
102
|
+
});
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
container ??= document.body.appendChild(document.createElement('div'));
|
|
106
|
+
await act(async () => { render(h(Probe, null), container as HTMLElement); });
|
|
107
|
+
return {
|
|
108
|
+
settles: () => seen.settles,
|
|
109
|
+
losses: () => seen.losses,
|
|
110
|
+
generating: seen.generating,
|
|
111
|
+
streamRun: seen.streamRun,
|
|
112
|
+
serverRun: seen.serverRun,
|
|
113
|
+
/** Re-render with another session id — the spawn adoption that re-mounts
|
|
114
|
+
* the effect mid-run. */
|
|
115
|
+
adopt: async (nextId: string) => {
|
|
116
|
+
await act(async () => {
|
|
117
|
+
render(h(Probe, { id: nextId }), container as HTMLElement);
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
setStatus: async (status) => {
|
|
121
|
+
await act(async () => {
|
|
122
|
+
setStatus(status);
|
|
123
|
+
// Let the probe's fetch/then chain (and the re-check at the answer) run.
|
|
124
|
+
for (let i = 0; i < 8; i += 1) await Promise.resolve();
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe('a channel that comes back after frames were missed', () => {
|
|
131
|
+
test('settles the run the server reports as finished', async () => {
|
|
132
|
+
let probes = 0;
|
|
133
|
+
stubProbe({ running: true, state: { isStreaming: false, isPromptRunning: false, isSettled: true } }, () => { probes += 1; });
|
|
134
|
+
const harness = await mount(true);
|
|
135
|
+
|
|
136
|
+
await harness.setStatus('open');
|
|
137
|
+
await harness.setStatus('closed');
|
|
138
|
+
await harness.setStatus('connecting');
|
|
139
|
+
await harness.setStatus('open');
|
|
140
|
+
|
|
141
|
+
expect(probes).toBe(1);
|
|
142
|
+
expect(harness.settles()).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('a session the server no longer runs is settled too', async () => {
|
|
146
|
+
stubProbe({ running: false });
|
|
147
|
+
const harness = await mount(true);
|
|
148
|
+
|
|
149
|
+
await harness.setStatus('open');
|
|
150
|
+
await harness.setStatus('closed');
|
|
151
|
+
await harness.setStatus('open');
|
|
152
|
+
|
|
153
|
+
expect(harness.settles()).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('a run still in flight is left alone', async () => {
|
|
157
|
+
stubProbe({ running: true, state: { isStreaming: true, isPromptRunning: true } });
|
|
158
|
+
const harness = await mount(true);
|
|
159
|
+
|
|
160
|
+
await harness.setStatus('open');
|
|
161
|
+
await harness.setStatus('closed');
|
|
162
|
+
await harness.setStatus('open');
|
|
163
|
+
|
|
164
|
+
expect(harness.settles()).toBe(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('a busy session with no run is left alone', async () => {
|
|
168
|
+
// A live subagent whose parent turn ended: `busy` is not run evidence, and
|
|
169
|
+
// settling it would abandon the work still going.
|
|
170
|
+
stubProbe({ running: true, busy: true, state: { isStreaming: false, isPromptRunning: false } });
|
|
171
|
+
const harness = await mount(true);
|
|
172
|
+
|
|
173
|
+
await harness.setStatus('open');
|
|
174
|
+
await harness.setStatus('closed');
|
|
175
|
+
await harness.setStatus('open');
|
|
176
|
+
|
|
177
|
+
expect(harness.settles()).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('an unanswered probe settles nothing', async () => {
|
|
181
|
+
stubProbe(null);
|
|
182
|
+
const harness = await mount(true);
|
|
183
|
+
|
|
184
|
+
await harness.setStatus('open');
|
|
185
|
+
await harness.setStatus('closed');
|
|
186
|
+
await harness.setStatus('open');
|
|
187
|
+
|
|
188
|
+
expect(harness.settles()).toBe(0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('a FIRST dial is not a reconnect', async () => {
|
|
192
|
+
// The send path opens a closed channel for a prompt it is about to send:
|
|
193
|
+
// no frames can have been missed, so the optimistic turn must survive.
|
|
194
|
+
stubProbe({ running: true, state: { isStreaming: false, isPromptRunning: false, isSettled: true } });
|
|
195
|
+
const harness = await mount(true);
|
|
196
|
+
|
|
197
|
+
await harness.setStatus('connecting');
|
|
198
|
+
await harness.setStatus('open');
|
|
199
|
+
|
|
200
|
+
expect(harness.settles()).toBe(0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('a session with NO run at all is never probed', async () => {
|
|
204
|
+
// Nothing has started, so nothing can have been lost: a reconnect on an
|
|
205
|
+
// idle session must not even ask.
|
|
206
|
+
let probes = 0;
|
|
207
|
+
stubProbe({ running: false }, () => { probes += 1; });
|
|
208
|
+
const harness = await mount(false);
|
|
209
|
+
|
|
210
|
+
await harness.setStatus('open');
|
|
211
|
+
await harness.setStatus('closed');
|
|
212
|
+
await harness.setStatus('open');
|
|
213
|
+
|
|
214
|
+
expect(probes).toBe(0);
|
|
215
|
+
expect(harness.settles()).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('a run that FLICKERED off before the drop still counts as lost', async () => {
|
|
219
|
+
// Measured on a real run: the drop landed in a beat where the generating
|
|
220
|
+
// flag and both run flags read false (the spawn-adoption window, a
|
|
221
|
+
// `prompt_result` folded as settled), so the loss was read as "nothing was
|
|
222
|
+
// running" — and the transcript was never re-read. The evidence is
|
|
223
|
+
// LATCHED, so a flicker cannot erase it.
|
|
224
|
+
let probes = 0;
|
|
225
|
+
stubProbe({ running: false }, () => { probes += 1; });
|
|
226
|
+
const harness = await mount(true);
|
|
227
|
+
|
|
228
|
+
await harness.setStatus('open');
|
|
229
|
+
// Every live signal goes quiet before the channel does.
|
|
230
|
+
harness.generating.current = false;
|
|
231
|
+
harness.streamRun.current = false;
|
|
232
|
+
harness.serverRun.current = false;
|
|
233
|
+
await harness.setStatus('closed');
|
|
234
|
+
await harness.setStatus('open');
|
|
235
|
+
|
|
236
|
+
expect(probes).toBe(1);
|
|
237
|
+
expect(harness.settles()).toBe(1);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('the loss is recorded at the DROP, before any probe answers', async () => {
|
|
241
|
+
// The probe is a request, and a run can settle while it is in flight —
|
|
242
|
+
// measured as a flaky e2e where `agent_end` arrived first, read a flag that
|
|
243
|
+
// was still false, and never re-read the transcript. What was lost is a
|
|
244
|
+
// fact about the drop, so it is written then.
|
|
245
|
+
let probes = 0;
|
|
246
|
+
stubProbe({ running: true, state: { isStreaming: true } }, () => { probes += 1; });
|
|
247
|
+
const harness = await mount(true);
|
|
248
|
+
|
|
249
|
+
await harness.setStatus('open');
|
|
250
|
+
await harness.setStatus('closed');
|
|
251
|
+
|
|
252
|
+
// No `open` yet: the probe has not even been issued.
|
|
253
|
+
expect(probes).toBe(0);
|
|
254
|
+
expect(harness.losses()).toBe(1);
|
|
255
|
+
expect(harness.settles()).toBe(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('a spawn adoption DURING the outage does not lose the fact', async () => {
|
|
259
|
+
// The effect is keyed on the session id, and that id moves mid-run: a spawn
|
|
260
|
+
// answers with omp's UUID a beat after the first prompt. A tab that went
|
|
261
|
+
// away in that window re-mounted the effect INSIDE the outage, and local
|
|
262
|
+
// variables came back with `wasOpen:false` — so the drop read as a dial
|
|
263
|
+
// that never opened, nothing was recorded, and the transcript was never
|
|
264
|
+
// re-read (measured on a real run; the e2e spec caught it as a flake).
|
|
265
|
+
let probes = 0;
|
|
266
|
+
stubProbe({ running: true, state: { isStreaming: true } }, () => { probes += 1; });
|
|
267
|
+
const harness = await mount(true, 'new-1');
|
|
268
|
+
|
|
269
|
+
await harness.setStatus('open');
|
|
270
|
+
await harness.setStatus('closed');
|
|
271
|
+
// The adoption lands while the channel is down.
|
|
272
|
+
await harness.adopt('omp-1');
|
|
273
|
+
await harness.setStatus('connecting');
|
|
274
|
+
await harness.setStatus('open');
|
|
275
|
+
|
|
276
|
+
expect(harness.losses()).toBe(1);
|
|
277
|
+
expect(probes).toBe(1);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('a probe that answers "still live" settles nothing', async () => {
|
|
281
|
+
stubProbe({ running: true, state: { isStreaming: true } });
|
|
282
|
+
const harness = await mount(true);
|
|
283
|
+
|
|
284
|
+
await harness.setStatus('open');
|
|
285
|
+
await harness.setStatus('closed');
|
|
286
|
+
await harness.setStatus('open');
|
|
287
|
+
|
|
288
|
+
expect(harness.settles()).toBe(0);
|
|
289
|
+
expect(harness.losses()).toBe(1);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('the SERVER\'s stream row alone is enough evidence', async () => {
|
|
293
|
+
// A run whose very FIRST frame (`agent_start`) was the one lost has no
|
|
294
|
+
// stream-side mark on this client, but the server wrote its `stream` row at
|
|
295
|
+
// dispatch — and that row is what proves frames could have been missed.
|
|
296
|
+
// Without this the first-frame loss was unrecoverable: the timeline kept
|
|
297
|
+
// the empty placeholder while the answer streamed into a socket that was
|
|
298
|
+
// gone.
|
|
299
|
+
stubProbe({ running: false });
|
|
300
|
+
const harness = await mount(true);
|
|
301
|
+
harness.streamRun.current = false;
|
|
302
|
+
harness.serverRun.current = true;
|
|
303
|
+
|
|
304
|
+
await harness.setStatus('open');
|
|
305
|
+
await harness.setStatus('closed');
|
|
306
|
+
await harness.setStatus('open');
|
|
307
|
+
|
|
308
|
+
expect(harness.settles()).toBe(1);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('a drop with no run believed live settles nothing', async () => {
|
|
312
|
+
stubProbe({ running: true, state: { isStreaming: false, isSettled: true } });
|
|
313
|
+
const harness = await mount(false);
|
|
314
|
+
|
|
315
|
+
await harness.setStatus('open');
|
|
316
|
+
await harness.setStatus('closed');
|
|
317
|
+
await harness.setStatus('open');
|
|
318
|
+
|
|
319
|
+
expect(harness.settles()).toBe(0);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('a run that finished during the drop is settled once, not on every reconnect', async () => {
|
|
323
|
+
stubProbe({ running: false });
|
|
324
|
+
const harness = await mount(true);
|
|
325
|
+
|
|
326
|
+
await harness.setStatus('open');
|
|
327
|
+
await harness.setStatus('closed');
|
|
328
|
+
await harness.setStatus('open');
|
|
329
|
+
expect(harness.settles()).toBe(1);
|
|
330
|
+
|
|
331
|
+
// The settle path releases the generating flag; a second flap must not
|
|
332
|
+
// re-settle (and must not re-read the transcript again).
|
|
333
|
+
harness.generating.current = false;
|
|
334
|
+
await harness.setStatus('closed');
|
|
335
|
+
await harness.setStatus('open');
|
|
336
|
+
expect(harness.settles()).toBe(1);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Recover a run whose terminal frames were lost with the realtime channel.
|
|
8
|
+
*
|
|
9
|
+
* The reported failure: a streaming chat was left in a background tab, the
|
|
10
|
+
* socket was torn down, the turn finished while nothing was reading, and the
|
|
11
|
+
* user came back to an answer cut off mid-sentence with the docked indicator
|
|
12
|
+
* already gone. Both halves of that state come from the same missing frames —
|
|
13
|
+
* `message_end` never replaced the streaming row, and `agent_end` never
|
|
14
|
+
* released the local generating flag — while the SERVER's `stream` row settled
|
|
15
|
+
* normally, which is why the indicator stopped but the transcript did not.
|
|
16
|
+
*
|
|
17
|
+
* Two facts decide when this applies, and both are about the CHANNEL, not about
|
|
18
|
+
* a run's bookkeeping:
|
|
19
|
+
*
|
|
20
|
+
* - frames can only have been missed if the socket was OPEN while a run was
|
|
21
|
+
* believed live and then went away. A send whose `connect()` dials a closed
|
|
22
|
+
* channel never meets that condition, so the optimistic turn it just mounted
|
|
23
|
+
* can never be settled by this path (the prompt is not even on the wire yet).
|
|
24
|
+
* - the run must be over on the SERVER, asked through the same attach probe the
|
|
25
|
+
* mount path uses (`runIsOver`). `busy` is not a verdict there, and an
|
|
26
|
+
* unanswered probe settles nothing.
|
|
27
|
+
*
|
|
28
|
+
* Recovery is two steps and both are needed: settle the local run (release the
|
|
29
|
+
* generating flag, drop the ids only a live stream would consume), then re-read
|
|
30
|
+
* the committed transcript — the rows on screen are the stream's last PARTIAL
|
|
31
|
+
* segment, and the session JSONL is the only record of the rest.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { useEffect, useRef } from 'preact/hooks';
|
|
35
|
+
|
|
36
|
+
import { realtimeClient } from '@/shared/lib/realtime/client';
|
|
37
|
+
import { runIsOver, type AgentRunProbe } from '@/client/hooks/chat/omp/run-liveness';
|
|
38
|
+
|
|
39
|
+
export interface ReconnectSettleDeps {
|
|
40
|
+
/** The session on screen; null for a pending `new-…` chat (no child yet). */
|
|
41
|
+
sessionId: string | null;
|
|
42
|
+
/** Live "this page is showing a run" flag — owned or adopted. */
|
|
43
|
+
isGeneratingRef: { current: boolean };
|
|
44
|
+
/** The stream has spoken for that run (see `streamRunRef`). */
|
|
45
|
+
streamRunRef: { current: boolean };
|
|
46
|
+
/** The SERVER reports a run for this session (`session_stream_state`'s
|
|
47
|
+
* `stream` row, the same source the sidebar spinner reads). This is the
|
|
48
|
+
* evidence that a prompt was DISPATCHED, which is what makes frame loss
|
|
49
|
+
* possible — and it is the only signal available for a run whose own
|
|
50
|
+
* `agent_start` was the first frame lost. A prompt that has not reached the
|
|
51
|
+
* server yet has no row, so it is still excluded. */
|
|
52
|
+
serverRunRef: { current: boolean };
|
|
53
|
+
/** The run is over server-side: settle the local UI and re-read the
|
|
54
|
+
* committed transcript. */
|
|
55
|
+
onSettled: () => void;
|
|
56
|
+
/** Frames were LOST — called SYNCHRONOUSLY at the drop, not at the probe's
|
|
57
|
+
* answer. A run in flight when an open channel goes away has already lost
|
|
58
|
+
* whatever finished during the outage: its `message_end` went into the dead
|
|
59
|
+
* socket, and `agent_end` only materializes turns that stopped abnormally.
|
|
60
|
+
* The caller records it so the transcript is re-read when the run settles.
|
|
61
|
+
*
|
|
62
|
+
* At the DROP, deliberately: the attach probe is a request, so a run can
|
|
63
|
+
* settle while it is in flight — measured as a flaky spec where `agent_end`
|
|
64
|
+
* arrived first, read a flag that was still false, and never reloaded. What
|
|
65
|
+
* is lost is a fact about the drop, not about the probe's answer. */
|
|
66
|
+
onFramesLost: () => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The channel facts this hook needs, held OUTSIDE the effect.
|
|
71
|
+
*
|
|
72
|
+
* The effect is keyed on the session id, and that id MOVES mid-run: a spawn
|
|
73
|
+
* answers with omp's UUID a beat after the first prompt, so the URL — and this
|
|
74
|
+
* effect — re-mounts. Local variables therefore lose the only two facts that
|
|
75
|
+
* make the repair possible, measured on a real run: the tab went away during
|
|
76
|
+
* the spawn adoption, the effect re-mounted inside the outage, `wasOpen` came
|
|
77
|
+
* back `false`, and the drop was read as "a dial that never opened" — so
|
|
78
|
+
* nothing was recorded and the transcript was never re-read.
|
|
79
|
+
*/
|
|
80
|
+
interface ChannelFacts {
|
|
81
|
+
/** The channel was OPEN at the last transition we saw. */
|
|
82
|
+
wasOpen: boolean;
|
|
83
|
+
/** A run was in flight when it went away, so frames were lost. */
|
|
84
|
+
missedFrames: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* A run has been seen live for this session since it was last reconciled.
|
|
87
|
+
*
|
|
88
|
+
* A LATCH, not a live read, and that is the whole point: the client's
|
|
89
|
+
* generating flag flickers false during a live run (measured: a `prompt_result`
|
|
90
|
+
* folded as "settled", and the spawn-adoption window), and a drop that landed
|
|
91
|
+
* in one of those beats was read as "no run in flight" — so nothing was
|
|
92
|
+
* recorded and the transcript was never re-read, which is exactly the
|
|
93
|
+
* cut-off answer this hook exists to repair.
|
|
94
|
+
*/
|
|
95
|
+
sawRun: boolean;
|
|
96
|
+
/** The session id `sawRun` belongs to, so a real switch resets it while a
|
|
97
|
+
* spawn ADOPTION (same conversation, new id) keeps it. */
|
|
98
|
+
sessionId: string | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function useReconnectSettle(deps: ReconnectSettleDeps): void {
|
|
102
|
+
const { sessionId, isGeneratingRef, streamRunRef, serverRunRef, onSettled, onFramesLost } = deps;
|
|
103
|
+
// Held in a ref: the caller rebuilds `onSettled` on every render (it closes
|
|
104
|
+
// over the timeline's own callbacks), and an effect keyed on its identity
|
|
105
|
+
// would re-subscribe constantly.
|
|
106
|
+
const onSettledRef = useRef(onSettled);
|
|
107
|
+
onSettledRef.current = onSettled;
|
|
108
|
+
const onFramesLostRef = useRef(onFramesLost);
|
|
109
|
+
onFramesLostRef.current = onFramesLost;
|
|
110
|
+
// Survives a re-mount, which is the whole point (see `ChannelFacts`).
|
|
111
|
+
const factsRef = useRef<ChannelFacts | null>(null);
|
|
112
|
+
|
|
113
|
+
// Latch the run evidence on EVERY render, before any effect runs: the flags
|
|
114
|
+
// are refs, so a beat that flickers false would otherwise erase the only
|
|
115
|
+
// proof a run existed when the channel went away.
|
|
116
|
+
const facts = (factsRef.current ??= {
|
|
117
|
+
wasOpen: realtimeClient.status() === 'open',
|
|
118
|
+
missedFrames: false,
|
|
119
|
+
sawRun: false,
|
|
120
|
+
sessionId,
|
|
121
|
+
});
|
|
122
|
+
// Re-read the CHANNEL on every render, because this hook re-mounts and the
|
|
123
|
+
// channel does not. Measured: the effect was re-created while the socket was
|
|
124
|
+
// OPEN (a spawn adoption, a session-state re-render), the latch still said
|
|
125
|
+
// `wasOpen:false` from before that, and the following drop was read as a dial
|
|
126
|
+
// that never opened — so the repair never ran. Only the TRANSITION matters,
|
|
127
|
+
// so reconciling here cannot invent a loss: `missedFrames` is still armed by
|
|
128
|
+
// the subscriber alone.
|
|
129
|
+
facts.wasOpen = realtimeClient.status() === 'open';
|
|
130
|
+
if (isGeneratingRef.current || streamRunRef.current || serverRunRef.current) facts.sawRun = true;
|
|
131
|
+
// A genuine switch (nothing live) starts over; an adoption keeps the latch.
|
|
132
|
+
if (facts.sessionId !== sessionId) {
|
|
133
|
+
if (!isGeneratingRef.current && !serverRunRef.current) facts.sawRun = false;
|
|
134
|
+
facts.sessionId = sessionId;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!sessionId) return;
|
|
139
|
+
// Lit when an OPEN channel goes away while a run is in flight: the only
|
|
140
|
+
// state in which frames can have been missed. `wasOpen` is what separates
|
|
141
|
+
// that from a dial: a send whose `connect()` opens a closed channel also
|
|
142
|
+
// passes through `connecting`, and settling on that would delete the
|
|
143
|
+
// optimistic turn of a prompt that is not even on the wire yet.
|
|
144
|
+
let cancelled = false;
|
|
145
|
+
|
|
146
|
+
const unsubscribe = realtimeClient.onStatus(() => {
|
|
147
|
+
if (cancelled) return;
|
|
148
|
+
if (realtimeClient.status() !== 'open') {
|
|
149
|
+
// Either piece of evidence that a run is in flight: the stream named it,
|
|
150
|
+
// or the server's `stream` row exists (which also covers a run whose
|
|
151
|
+
// very first frame — `agent_start` — was the one lost).
|
|
152
|
+
if (facts.wasOpen && facts.sawRun) {
|
|
153
|
+
facts.missedFrames = true;
|
|
154
|
+
// Recorded NOW: the probe below is a request, and a run that settles
|
|
155
|
+
// while it is in flight would otherwise settle against a flag that
|
|
156
|
+
// has not been written yet.
|
|
157
|
+
onFramesLostRef.current();
|
|
158
|
+
}
|
|
159
|
+
facts.wasOpen = false;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
facts.wasOpen = true;
|
|
163
|
+
if (!facts.missedFrames) return;
|
|
164
|
+
facts.missedFrames = false;
|
|
165
|
+
// Deliberately NOT gated on `isGeneratingRef`: the flag can be false for a
|
|
166
|
+
// run that is still live (or one that ended while the tab was away), and
|
|
167
|
+
// the probe below is the authority on which it is.
|
|
168
|
+
void (async () => {
|
|
169
|
+
const probe = await fetch(`/api/agent/${encodeURIComponent(sessionId)}`)
|
|
170
|
+
.then((res) => (res.ok ? (res.json() as Promise<AgentRunProbe>) : null))
|
|
171
|
+
.catch(() => null);
|
|
172
|
+
// Re-checked at the answer, not only at the trigger: the user may have
|
|
173
|
+
// switched sessions while the probe was in flight.
|
|
174
|
+
if (cancelled) return;
|
|
175
|
+
// Still live: nothing to settle, and the loss is already recorded.
|
|
176
|
+
if (!runIsOver(probe)) return;
|
|
177
|
+
facts.sawRun = false;
|
|
178
|
+
onSettledRef.current();
|
|
179
|
+
})();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return () => {
|
|
183
|
+
cancelled = true;
|
|
184
|
+
unsubscribe();
|
|
185
|
+
};
|
|
186
|
+
}, [sessionId, isGeneratingRef, streamRunRef, serverRunRef]);
|
|
187
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `runIsOver` — the verdict a reattached client asks before settling a run it
|
|
8
|
+
* was following. Every case here is a shape the server really produces:
|
|
9
|
+
*
|
|
10
|
+
* - `running:false` is the answer for a session this process does not manage
|
|
11
|
+
* (and for one whose child is gone): the run is over, whatever the local
|
|
12
|
+
* generating flag still believes;
|
|
13
|
+
* - `busy` with no run is NOT over — the server sets it for work a probe would
|
|
14
|
+
* queue behind (a live subagent whose parent turn ended, a parked dialog);
|
|
15
|
+
* - a live `isStreaming`/`isPromptRunning` is NOT over;
|
|
16
|
+
* - no answer at all (a failed request) is NOT a verdict.
|
|
17
|
+
*
|
|
18
|
+
* The asymmetry is deliberate: settling a run that is still going throws away
|
|
19
|
+
* the streaming answer, while missing a settle costs a reload.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, expect, test } from 'bun:test';
|
|
23
|
+
import { runIsOver } from '@/client/hooks/chat/omp/run-liveness';
|
|
24
|
+
|
|
25
|
+
describe('runIsOver', () => {
|
|
26
|
+
test('a session this process does not run is over', () => {
|
|
27
|
+
expect(runIsOver({ running: false })).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('no answer is not a verdict', () => {
|
|
31
|
+
expect(runIsOver(null)).toBe(false);
|
|
32
|
+
expect(runIsOver(undefined)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a busy session with no run in flight is NOT over', () => {
|
|
36
|
+
// A live subagent whose parent turn ended, or a dialog a reloaded client
|
|
37
|
+
// has not answered: settling here would abandon the work still running.
|
|
38
|
+
expect(runIsOver({ running: true, busy: true, state: { isStreaming: false, isPromptRunning: false } })).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a streaming or prompting run is NOT over', () => {
|
|
42
|
+
expect(runIsOver({ running: true, state: { isStreaming: true, isPromptRunning: false } })).toBe(false);
|
|
43
|
+
expect(runIsOver({ running: true, state: { isStreaming: false, isPromptRunning: true } })).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('omp\'s verdict is REQUIRED: a child with nothing streaming is not enough', () => {
|
|
47
|
+
// A turn can be admitted but not yet streaming, and a build that does not
|
|
48
|
+
// report `isSettled` cannot tell us which. The server refuses to settle on
|
|
49
|
+
// anything but `isSettled === true` (`RunSettle`), so this must too —
|
|
50
|
+
// otherwise the client reloads a transcript the server still considers live.
|
|
51
|
+
expect(runIsOver({ running: true, state: { isStreaming: false, isPromptRunning: false } })).toBe(false);
|
|
52
|
+
expect(runIsOver({ running: true, state: { isStreaming: false, isPromptRunning: false, isSettled: true } })).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('omp\'s own quiescence verdict decides when it is present', () => {
|
|
56
|
+
expect(runIsOver({ running: true, state: { isSettled: true } })).toBe(true);
|
|
57
|
+
// omp says something is still admitted/scheduled/queued: a retry wait, a
|
|
58
|
+
// background job. Not over.
|
|
59
|
+
expect(runIsOver({ running: true, state: { isSettled: false } })).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a payload with no state is not read as settled', () => {
|
|
63
|
+
expect(runIsOver({ running: true })).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a queued follow-up keeps the session open even when it reads settled', () => {
|
|
67
|
+
// omp will wake again, so this is not a finished run: settling would reload
|
|
68
|
+
// the transcript while the next turn is about to open.
|
|
69
|
+
expect(runIsOver({ running: true, state: { isSettled: true, queuedMessageCount: 1 } })).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether the run a REATTACHED client was following is over.
|
|
8
|
+
*
|
|
9
|
+
* A socket that comes back after a run's terminal frames were lost with it
|
|
10
|
+
* leaves the timeline holding the stream's last `message_update` — a partial
|
|
11
|
+
* answer — and nothing else will ever finalize it: `message_end` and
|
|
12
|
+
* `agent_end` were emitted into a socket nobody was reading. The snapshot that
|
|
13
|
+
* repairs the attachment says only whether a run is live NOW, and that answer
|
|
14
|
+
* has to be read precisely, because the SAME shape ("child alive, nothing
|
|
15
|
+
* running") appears in the beat between a send's `connect()` and its prompt —
|
|
16
|
+
* acting on it there would settle a run that is about to start.
|
|
17
|
+
*
|
|
18
|
+
* `busy` is deliberately NOT evidence either way: the server sets it for work a
|
|
19
|
+
* probe would queue behind (a live subagent whose parent turn ended, a parked
|
|
20
|
+
* dialog), and a session with something else live is left alone rather than
|
|
21
|
+
* settled.
|
|
22
|
+
*
|
|
23
|
+
* The final word is omp's OWN verdict, `state.isSettled`, and it must be
|
|
24
|
+
* literally `true`. The server's `RunSettle` applies exactly that rule
|
|
25
|
+
* (`isSettled !== true` → do not settle), and two answers to "is this run over"
|
|
26
|
+
* is how the indicator and the transcript end up disagreeing. A build that does
|
|
27
|
+
* not report the field therefore settles nothing here — a reload costs one
|
|
28
|
+
* reconnect, while settling a live run throws the streaming answer away.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The `GET /api/agent/:id` / topic-snapshot payload, narrowed to what this
|
|
32
|
+
* decision reads. */
|
|
33
|
+
export interface AgentRunProbe {
|
|
34
|
+
running?: boolean;
|
|
35
|
+
busy?: boolean;
|
|
36
|
+
state?: {
|
|
37
|
+
isStreaming?: boolean;
|
|
38
|
+
isPromptRunning?: boolean;
|
|
39
|
+
/** omp's own quiescence verdict. Absent on builds that do not report it. */
|
|
40
|
+
isSettled?: boolean;
|
|
41
|
+
/** Follow-ups and steers omp has accepted but not run yet. A non-zero
|
|
42
|
+
* count means the session WILL wake again, so it is not finished — and it
|
|
43
|
+
* is what keeps this from settling a session whose queued prompt was
|
|
44
|
+
* delivered server-side while the tab was away. */
|
|
45
|
+
queuedMessageCount?: number;
|
|
46
|
+
} | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function runIsOver(probe: AgentRunProbe | null | undefined): boolean {
|
|
50
|
+
// No answer (a failed request, a refused payload) is not a verdict: leave the
|
|
51
|
+
// timeline alone and let the next reconnect ask again.
|
|
52
|
+
if (!probe) return false;
|
|
53
|
+
if (probe.running === false) return true;
|
|
54
|
+
if (probe.busy) return false;
|
|
55
|
+
const state = probe.state;
|
|
56
|
+
if (!state) return false;
|
|
57
|
+
if (state.isStreaming || state.isPromptRunning) return false;
|
|
58
|
+
if (typeof state.queuedMessageCount === 'number' && state.queuedMessageCount > 0) return false;
|
|
59
|
+
// omp's own quiescence verdict, and the ONLY thing that may settle a run —
|
|
60
|
+
// the same rule the server applies (`RunSettle`: `isSettled !== true` → do
|
|
61
|
+
// not settle). Absent means the build does not report it, which is NOT
|
|
62
|
+
// evidence of quiescence: a turn can be admitted but not yet streaming, and
|
|
63
|
+
// settling there would reload the transcript mid-dispatch.
|
|
64
|
+
return state.isSettled === true;
|
|
65
|
+
}
|