@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { useTimelineScope } from '@/client/hooks/chat/timeline/scope';
|
|
3
|
+
import { useSpawnTarget } from '@/client/hooks/chat/timeline/spawn-target';
|
|
3
4
|
import type { ChatMessageData } from '@/shared/types';
|
|
4
5
|
import { useOmpAgent } from '@/client/hooks/chat/omp';
|
|
5
6
|
import { useChatTimelineQueue } from '@/client/hooks/chat/timeline/queue';
|
|
@@ -17,7 +18,7 @@ import { cancelStreamingCoalescer } from '@/shared/lib/chat/timeline/stream-coal
|
|
|
17
18
|
import { useChatTimelineAccessMode } from '@/client/hooks/chat/timeline/access-mode';
|
|
18
19
|
import { useComposerModes } from '@/client/hooks/chat/timeline/composer-modes';
|
|
19
20
|
import { useComposerDraft } from '@/client/hooks/chat/composer/use-draft';
|
|
20
|
-
import { isChatRunning } from '@/client/hooks/chat/omp/session-statuses';
|
|
21
|
+
import { isChatRunning, isSessionStreaming } from '@/client/hooks/chat/omp/session-statuses';
|
|
21
22
|
import { useWaitingDependencyChoice } from '@/client/hooks/chat/waiting/choice';
|
|
22
23
|
import { useComposerRefs } from '@/client/hooks/chat/timeline/composer-refs';
|
|
23
24
|
|
|
@@ -40,10 +41,8 @@ export function useChatTimeline({
|
|
|
40
41
|
}: UseChatTimelineOptions = {}) {
|
|
41
42
|
const { sessionId, folderId, selectedFolderId, selectContextFolder, setSearchParams } = useTimelineScope();
|
|
42
43
|
// Plan/goal toggles plus the plan-review surface. PER SESSION, unlike the
|
|
43
|
-
// access mode: they describe what this conversation is doing, and
|
|
44
|
-
//
|
|
45
|
-
// returns both halves, because a mode command can produce a proposal and
|
|
46
|
-
// answering that proposal is itself a mode command.
|
|
44
|
+
// access mode: they describe what this conversation is doing, and one hook
|
|
45
|
+
// returns both halves because a mode command can produce a proposal.
|
|
47
46
|
const { modes, planReview } = useComposerModes(sessionId);
|
|
48
47
|
|
|
49
48
|
// Ref mirror of loadOlder so the scroll handler can call the latest one
|
|
@@ -57,8 +56,7 @@ export function useChatTimeline({
|
|
|
57
56
|
const { inputValue, setInputValue, inputAttachments, setInputAttachments } = useComposerDraft();
|
|
58
57
|
const [localMessages, setLocalMessages] = useState<ChatMessageData[]>([]);
|
|
59
58
|
|
|
60
|
-
// A pending "new-…" session renders the workspace picker
|
|
61
|
-
// timeline and scroll handling.
|
|
59
|
+
// A pending "new-…" session renders the workspace picker with its own timeline.
|
|
62
60
|
const isPendingSession = Boolean(sessionId?.startsWith('new-'));
|
|
63
61
|
// Committed history lands asynchronously (session-load.ts), so the container
|
|
64
62
|
// paints at the top first; jump to the tail once per session when it does.
|
|
@@ -76,6 +74,7 @@ export function useChatTimeline({
|
|
|
76
74
|
isGeneratingRef,
|
|
77
75
|
ownRun,
|
|
78
76
|
ownRunRef,
|
|
77
|
+
streamRunRef,
|
|
79
78
|
setGenerating,
|
|
80
79
|
generatingVerb,
|
|
81
80
|
activityRef,
|
|
@@ -125,11 +124,16 @@ export function useChatTimeline({
|
|
|
125
124
|
// not start is still reported by the server row and never by client state.
|
|
126
125
|
const timelineRunning = isChatRunning(streamStatusMap, sessionId, ownRun);
|
|
127
126
|
|
|
127
|
+
// SERVER evidence a prompt was dispatched (the `stream` row), mirrored for
|
|
128
|
+
// the reconnect guard: a run whose FIRST frame was lost has no `agent_start`.
|
|
129
|
+
const serverRunRef = useRef(isSessionStreaming(streamStatusMap, sessionId));
|
|
130
|
+
serverRunRef.current = isSessionStreaming(streamStatusMap, sessionId);
|
|
131
|
+
|
|
128
132
|
const aiPlaceholderIdRef = useRef<string | null>(null);
|
|
129
133
|
|
|
130
134
|
const {
|
|
131
135
|
sessionData,
|
|
132
|
-
adoptedSessionIdRef, refreshSessionMeta, seedSession,
|
|
136
|
+
adoptedSessionIdRef, refreshSessionMeta, seedSession, reloadMessages, needsReloadRef,
|
|
133
137
|
hasMore,
|
|
134
138
|
loadingOlder, loadOlderError, sessionLoading, loadOlder, jumpToTurn,
|
|
135
139
|
} = useSessionLoad({
|
|
@@ -138,8 +142,11 @@ export function useChatTimeline({
|
|
|
138
142
|
setGenerating,
|
|
139
143
|
isGeneratingRef,
|
|
140
144
|
ownRunRef,
|
|
145
|
+
streamRunRef,
|
|
146
|
+
serverRunRef,
|
|
141
147
|
aiPlaceholderIdRef,
|
|
142
148
|
optimisticUserIdRef,
|
|
149
|
+
pendingUserDisplaysRef,
|
|
143
150
|
cancelStreamingCoalescer,
|
|
144
151
|
metaRefreshedRef,
|
|
145
152
|
scrollRef,
|
|
@@ -147,15 +154,10 @@ export function useChatTimeline({
|
|
|
147
154
|
jumpCountRef,
|
|
148
155
|
});
|
|
149
156
|
loadOlderRef.current = loadOlder;
|
|
150
|
-
|
|
151
157
|
// Jump rail: the full-history turn index plus the paging walk that reaches a
|
|
152
158
|
// turn outside the mounted window. The walk is wrapped so follow mode stands
|
|
153
159
|
// down until the jump lands (see rail-jump.ts).
|
|
154
|
-
const { turns: userTurns, jumping: jumpingTurn, jumpToTurn: pagedJumpToTurn } = useUserTurns({
|
|
155
|
-
sessionId,
|
|
156
|
-
messages: localMessages,
|
|
157
|
-
jumpToTurn,
|
|
158
|
-
});
|
|
160
|
+
const { turns: userTurns, jumping: jumpingTurn, jumpToTurn: pagedJumpToTurn } = useUserTurns({ sessionId, messages: localMessages, jumpToTurn });
|
|
159
161
|
const jumpToUserTurn = useRailJump(pagedJumpToTurn, { followRef, scrollRef });
|
|
160
162
|
|
|
161
163
|
// The composer's model/thinking mirrors and the mid-run stash (see the hook).
|
|
@@ -210,7 +212,11 @@ export function useChatTimeline({
|
|
|
210
212
|
appSettings,
|
|
211
213
|
enqueueExtensionDialog,
|
|
212
214
|
withdrawExtensionDialog,
|
|
215
|
+
reloadMessages,
|
|
216
|
+
needsReloadRef,
|
|
213
217
|
}), activityRef);
|
|
218
|
+
const spawn = useSpawnTarget(sessionId);
|
|
219
|
+
|
|
214
220
|
const { steerOmpAgent, executeSend } = useChatTimelineSend({
|
|
215
221
|
folders,
|
|
216
222
|
selectedFolderId,
|
|
@@ -234,27 +240,18 @@ export function useChatTimeline({
|
|
|
234
240
|
deferredComposerPickRef,
|
|
235
241
|
accessModeRef,
|
|
236
242
|
spawnSelectionRef: modes.spawnSelectionRef,
|
|
243
|
+
spawnWorktreeRef: spawn.worktreePathRef,
|
|
237
244
|
abortControllerRef,
|
|
238
245
|
setInputValue,
|
|
239
246
|
setSearchParams,
|
|
240
247
|
});
|
|
241
248
|
|
|
242
249
|
// Queue auto-delivery moved server-side: the wrapper's terminal `agent_end`
|
|
243
|
-
// schedules the next queued prompt
|
|
244
|
-
//
|
|
245
|
-
// panel is a view; `stopHoldRef` remains only for the mock path below.
|
|
250
|
+
// schedules the next queued prompt with a transactional head claim, so no
|
|
251
|
+
// reload/tab race can double-send. `stopHoldRef` serves only the mock path.
|
|
246
252
|
|
|
247
|
-
const {
|
|
248
|
-
|
|
249
|
-
handleEditQueueItem,
|
|
250
|
-
handleSendNowQueueItem,
|
|
251
|
-
handleUndo,
|
|
252
|
-
handleRetry,
|
|
253
|
-
submitNewChat,
|
|
254
|
-
stopGenerating,
|
|
255
|
-
handleThinkingLevelChange,
|
|
256
|
-
handleModelChange,
|
|
257
|
-
} = useChatTimelineActions({
|
|
253
|
+
const { handleSend, handleEditQueueItem, handleSendNowQueueItem, handleUndo, handleRetry,
|
|
254
|
+
submitNewChat, stopGenerating, handleThinkingLevelChange, handleModelChange } = useChatTimelineActions({
|
|
258
255
|
inputValue,
|
|
259
256
|
setInputValue,
|
|
260
257
|
setInputAttachments,
|
|
@@ -339,6 +336,8 @@ export function useChatTimeline({
|
|
|
339
336
|
composerModelRef,
|
|
340
337
|
deferredComposerPickRef,
|
|
341
338
|
waitingDependency,
|
|
339
|
+
spawnTarget: spawn.target,
|
|
340
|
+
setSpawnTarget: spawn.setTarget,
|
|
342
341
|
extensionDialogs,
|
|
343
342
|
resolveExtensionDialog,
|
|
344
343
|
respondToExtensionUi: ompAgent.respondToExtensionUi,
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Recovery of a run whose terminal frames were lost with the channel.
|
|
8
|
+
*
|
|
9
|
+
* The reported failure, end to end: a streaming chat left in a background tab,
|
|
10
|
+
* the socket torn down, the turn finished with nobody reading. The timeline
|
|
11
|
+
* kept the stream's last PARTIAL segment and its generating flag never
|
|
12
|
+
* released, while the server's `stream` row had already settled — so the
|
|
13
|
+
* indicator stopped and the answer stayed cut off mid-sentence.
|
|
14
|
+
*
|
|
15
|
+
* Mounted through the REAL `useSessionLoad`, because the repair is a reload of
|
|
16
|
+
* that hook's own state: asserting on the recovery hook's calls would pass
|
|
17
|
+
* while the timeline kept the partial row.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
21
|
+
import { Window } from 'happy-dom';
|
|
22
|
+
import { h, render } from 'preact';
|
|
23
|
+
import { act } from 'preact/test-utils';
|
|
24
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
25
|
+
|
|
26
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
27
|
+
import { useSessionLoad } from '@/client/hooks/chat/timeline/session-load';
|
|
28
|
+
import { resetConnection } from '@/shared/lib/realtime/connection';
|
|
29
|
+
import { setStatus } from '@/shared/lib/realtime/state';
|
|
30
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
31
|
+
|
|
32
|
+
const originalFetch = Bun.fetch;
|
|
33
|
+
const PARTIAL: ChatMessageData = { id: 'omp-ai', role: 'ai', content: 'The answer stops mid-sent' };
|
|
34
|
+
const COMMITTED: ChatMessageData[] = [
|
|
35
|
+
{ id: 'omp-user', role: 'user', content: 'tolong jelaskan' },
|
|
36
|
+
{ id: 'omp-ai', role: 'ai', content: 'The answer, in full.' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
let container: HTMLElement | undefined;
|
|
40
|
+
/** Render-triggered waiters, so a case can await the state it asserts on. */
|
|
41
|
+
const messageWaiters: { match: (rows: ChatMessageData[]) => boolean; resolve: () => void }[] = [];
|
|
42
|
+
|
|
43
|
+
beforeAll(() => {
|
|
44
|
+
installDomGlobals(new Window({ url: 'http://localhost' }));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
restoreDomGlobals();
|
|
49
|
+
globalThis.fetch = originalFetch;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
if (container) render(null, container);
|
|
54
|
+
container?.remove();
|
|
55
|
+
container = undefined;
|
|
56
|
+
resetConnection();
|
|
57
|
+
globalThis.fetch = originalFetch;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
interface Api {
|
|
61
|
+
messages: ChatMessageData[];
|
|
62
|
+
generating: boolean;
|
|
63
|
+
setMessages: (next: ChatMessageData[]) => void;
|
|
64
|
+
/** Arm the state a dropped run leaves behind. */
|
|
65
|
+
loseRun: () => void;
|
|
66
|
+
/** The recovery's own flag: set when a still-live run lost segments, which
|
|
67
|
+
* is what schedules the read at its settle. A ref, not a render value — the
|
|
68
|
+
* flag is written without a state change, and no consumer renders for it. */
|
|
69
|
+
needsReloadRef: { current: boolean };
|
|
70
|
+
/** Run the reconnect: open → closed → open. */
|
|
71
|
+
reconnect: () => Promise<void>;
|
|
72
|
+
/** Resolves on the render where the timeline holds rows matching `match`:
|
|
73
|
+
* the retry is timer-driven, so this is the signal to wait on. */
|
|
74
|
+
awaitMessages: (match: (rows: ChatMessageData[]) => boolean) => Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ProbeProps {
|
|
78
|
+
api: Api;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function Probe({ api }: ProbeProps) {
|
|
82
|
+
const [messages, setMessages] = useState<ChatMessageData[]>([]);
|
|
83
|
+
const isGeneratingRef = useRef(false);
|
|
84
|
+
const ownRunRef = useRef(false);
|
|
85
|
+
const streamRunRef = useRef(false);
|
|
86
|
+
const serverRunRef = useRef(false);
|
|
87
|
+
const aiPlaceholderIdRef = useRef<string | null>(null);
|
|
88
|
+
const optimisticUserIdRef = useRef<string | null>(null);
|
|
89
|
+
const pendingUserDisplaysRef = useRef<{ sent: string; display: string }[]>([]);
|
|
90
|
+
const metaRefreshedRef = useRef<string | null>(null);
|
|
91
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
92
|
+
const jumpActiveRef = useRef(false);
|
|
93
|
+
const jumpCountRef = useRef(0);
|
|
94
|
+
const [generating, setGeneratingState] = useState(false);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
for (const waiter of [...messageWaiters]) {
|
|
97
|
+
if (!waiter.match(messages)) continue;
|
|
98
|
+
messageWaiters.splice(messageWaiters.indexOf(waiter), 1);
|
|
99
|
+
waiter.resolve();
|
|
100
|
+
}
|
|
101
|
+
}, [messages]);
|
|
102
|
+
const setGenerating = useCallback((v: boolean) => {
|
|
103
|
+
isGeneratingRef.current = v;
|
|
104
|
+
setGeneratingState(v);
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
api.messages = messages;
|
|
108
|
+
api.awaitMessages = (match) => {
|
|
109
|
+
if (match(messages)) return Promise.resolve();
|
|
110
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
111
|
+
messageWaiters.push({ match, resolve });
|
|
112
|
+
return promise;
|
|
113
|
+
};
|
|
114
|
+
api.generating = generating;
|
|
115
|
+
api.setMessages = setMessages;
|
|
116
|
+
api.loseRun = () => {
|
|
117
|
+
isGeneratingRef.current = true;
|
|
118
|
+
// The stream had announced this run — that is the state a lost run is in
|
|
119
|
+
// (a dispatch whose first frame never arrived is a different case, and the
|
|
120
|
+
// recovery deliberately refuses it).
|
|
121
|
+
streamRunRef.current = true;
|
|
122
|
+
serverRunRef.current = true;
|
|
123
|
+
setGeneratingState(true);
|
|
124
|
+
aiPlaceholderIdRef.current = 'omp-ai';
|
|
125
|
+
setMessages([PARTIAL]);
|
|
126
|
+
};
|
|
127
|
+
api.reconnect = async () => {
|
|
128
|
+
await act(async () => {
|
|
129
|
+
setStatus('open');
|
|
130
|
+
setStatus('closed');
|
|
131
|
+
setStatus('connecting');
|
|
132
|
+
setStatus('open');
|
|
133
|
+
for (let i = 0; i < 10; i += 1) await Promise.resolve();
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const load = useSessionLoad({
|
|
138
|
+
sessionId: 'omp-1',
|
|
139
|
+
setLocalMessages: setMessages,
|
|
140
|
+
setGenerating,
|
|
141
|
+
isGeneratingRef,
|
|
142
|
+
ownRunRef,
|
|
143
|
+
streamRunRef,
|
|
144
|
+
serverRunRef,
|
|
145
|
+
aiPlaceholderIdRef,
|
|
146
|
+
optimisticUserIdRef,
|
|
147
|
+
pendingUserDisplaysRef,
|
|
148
|
+
cancelStreamingCoalescer: () => {},
|
|
149
|
+
metaRefreshedRef,
|
|
150
|
+
scrollRef,
|
|
151
|
+
jumpActiveRef,
|
|
152
|
+
jumpCountRef,
|
|
153
|
+
});
|
|
154
|
+
api.needsReloadRef = load.needsReloadRef;
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Route the two reads this path makes: the transcript, and the attach probe.
|
|
159
|
+
* The transcript is EMPTY until `arm()` — a run still in flight has not
|
|
160
|
+
* committed the turn it is streaming, and arming only after the drop is what
|
|
161
|
+
* makes a reload observable (an always-populated route would let a broken
|
|
162
|
+
* path pass by having the initial load paint the same rows). */
|
|
163
|
+
interface StubRoutes {
|
|
164
|
+
transcriptReads: number;
|
|
165
|
+
arm: () => void;
|
|
166
|
+
/** Answer the next N transcript reads with an EMPTY list — omp buffers its
|
|
167
|
+
* writes, so the turn that just finished is not on disk yet. */
|
|
168
|
+
emptyReads: (count: number) => void;
|
|
169
|
+
/** Resolves once the transcript has been read `count` times: the retry is
|
|
170
|
+
* timer-driven, so this is the signal to wait on rather than a duration. */
|
|
171
|
+
readsReached: (count: number) => Promise<void>;
|
|
172
|
+
/** Resolves on the FIRST transcript read, so the harness can wait for the
|
|
173
|
+
* initial load to land instead of guessing a delay. */
|
|
174
|
+
firstRead: Promise<void>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function stubRoutes(probeAnswer: unknown): StubRoutes {
|
|
178
|
+
const seen = { transcriptReads: 0, armed: false, empty: 0 };
|
|
179
|
+
const waiters: { count: number; resolve: () => void }[] = [];
|
|
180
|
+
const settleWaiters = (): void => {
|
|
181
|
+
for (const waiter of [...waiters]) {
|
|
182
|
+
if (seen.transcriptReads < waiter.count) continue;
|
|
183
|
+
waiters.splice(waiters.indexOf(waiter), 1);
|
|
184
|
+
waiter.resolve();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const firstRead = Promise.withResolvers<void>();
|
|
188
|
+
globalThis.fetch = (async (input: unknown) => {
|
|
189
|
+
const url = String(input);
|
|
190
|
+
if (url.includes('/api/agent/')) {
|
|
191
|
+
return new Response(JSON.stringify(probeAnswer), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
192
|
+
}
|
|
193
|
+
seen.transcriptReads += 1;
|
|
194
|
+
firstRead.resolve();
|
|
195
|
+
settleWaiters();
|
|
196
|
+
const empty = !seen.armed || seen.empty > 0;
|
|
197
|
+
if (seen.empty > 0) seen.empty -= 1;
|
|
198
|
+
return new Response(JSON.stringify({
|
|
199
|
+
session: { id: 'omp-1', messages: empty ? [] : COMMITTED },
|
|
200
|
+
hasMore: false,
|
|
201
|
+
oldestIndex: 0,
|
|
202
|
+
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
203
|
+
}) as unknown as typeof fetch;
|
|
204
|
+
return {
|
|
205
|
+
get transcriptReads() { return seen.transcriptReads; },
|
|
206
|
+
arm: () => { seen.armed = true; },
|
|
207
|
+
emptyReads: (count) => { seen.empty = count; },
|
|
208
|
+
readsReached: (count) => {
|
|
209
|
+
if (seen.transcriptReads >= count) return Promise.resolve();
|
|
210
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
211
|
+
waiters.push({ count, resolve });
|
|
212
|
+
return promise;
|
|
213
|
+
},
|
|
214
|
+
firstRead: firstRead.promise,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function mount(probeAnswer: unknown): Promise<{ api: Api; routes: StubRoutes }> {
|
|
219
|
+
const routes = stubRoutes(probeAnswer);
|
|
220
|
+
const api = {} as Api;
|
|
221
|
+
container ??= document.body.appendChild(document.createElement('div'));
|
|
222
|
+
await act(async () => {
|
|
223
|
+
render(h(Probe, { api }), container as HTMLElement);
|
|
224
|
+
});
|
|
225
|
+
// The initial history read is a real promise chain (`json()` included), so a
|
|
226
|
+
// bare microtask flush is not enough — and a load landing AFTER the test arms
|
|
227
|
+
// its partial row would clear it, which is the harness racing the behaviour
|
|
228
|
+
// under test. Waiting on the read itself is deterministic. It is awaited
|
|
229
|
+
// OUTSIDE the render's `act`: effects flush when that callback returns, so a
|
|
230
|
+
// fetch issued by one cannot settle inside it.
|
|
231
|
+
await act(async () => {
|
|
232
|
+
await routes.firstRead;
|
|
233
|
+
for (let i = 0; i < 8; i += 1) await Promise.resolve();
|
|
234
|
+
});
|
|
235
|
+
return { api, routes };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
describe('a run whose terminal frames were lost with the channel', () => {
|
|
239
|
+
test('settles the local run and replaces the partial answer with the transcript', async () => {
|
|
240
|
+
const { api, routes } = await mount({ running: false });
|
|
241
|
+
await act(async () => { api.loseRun(); routes.arm(); });
|
|
242
|
+
expect(api.messages).toEqual([PARTIAL]);
|
|
243
|
+
expect(api.generating).toBe(true);
|
|
244
|
+
|
|
245
|
+
await api.reconnect();
|
|
246
|
+
|
|
247
|
+
// The rows on screen were the stream's last PARTIAL segment; the session
|
|
248
|
+
// JSONL is the only record of the rest.
|
|
249
|
+
expect(api.messages).toEqual(COMMITTED);
|
|
250
|
+
expect(api.generating).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('leaves a run the server still reports as live alone — but remembers it', async () => {
|
|
254
|
+
// Reloading mid-turn would replace the stream's own rows with a lagging
|
|
255
|
+
// snapshot, so nothing is applied here. What must NOT be lost is the fact
|
|
256
|
+
// that segments finished during the outage: their `message_end` went into
|
|
257
|
+
// the dead socket, and only a full read once the run is over recovers them.
|
|
258
|
+
const { api, routes } = await mount({ running: true, state: { isStreaming: true, isPromptRunning: true } });
|
|
259
|
+
await act(async () => { api.loseRun(); routes.arm(); });
|
|
260
|
+
|
|
261
|
+
await api.reconnect();
|
|
262
|
+
|
|
263
|
+
expect(api.messages).toEqual([PARTIAL]);
|
|
264
|
+
expect(api.generating).toBe(true);
|
|
265
|
+
expect(api.needsReloadRef.current).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('a first dial is not a reconnect and does not settle the run', async () => {
|
|
269
|
+
const { api, routes } = await mount({ running: false });
|
|
270
|
+
await act(async () => { api.loseRun(); routes.arm(); });
|
|
271
|
+
|
|
272
|
+
await act(async () => {
|
|
273
|
+
setStatus('connecting');
|
|
274
|
+
setStatus('open');
|
|
275
|
+
for (let i = 0; i < 10; i += 1) await Promise.resolve();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(api.messages).toEqual([PARTIAL]);
|
|
279
|
+
expect(api.generating).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('a transcript omp has not flushed yet is retried until the turn lands', async () => {
|
|
283
|
+
// omp buffers its transcript writes: the turn that just finished can be
|
|
284
|
+
// absent from the file for a beat after the channel returns. One read and
|
|
285
|
+
// stop would leave the partial answer on screen — the state this recovery
|
|
286
|
+
// exists to clear — so an EMPTY answer is retried on the same front-loaded
|
|
287
|
+
// schedule the metadata refresh uses, and stops at the first real one.
|
|
288
|
+
//
|
|
289
|
+
// Fake timers, not a sleep: the retry is a real `setTimeout` chain, and the
|
|
290
|
+
// schedule is what this case is about.
|
|
291
|
+
const { api, routes } = await mount({ running: false });
|
|
292
|
+
await act(async () => {
|
|
293
|
+
api.loseRun();
|
|
294
|
+
routes.arm();
|
|
295
|
+
routes.emptyReads(2);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await api.reconnect();
|
|
299
|
+
// The first read came back empty, so the rows are still the partial answer.
|
|
300
|
+
expect(api.messages).toEqual([PARTIAL]);
|
|
301
|
+
|
|
302
|
+
// The retry is a real timer chain; wait on the RENDER that carries the
|
|
303
|
+
// committed rows, not on a guessed delay. Two empty answers, so the third
|
|
304
|
+
// read is the one with the turn in it.
|
|
305
|
+
// The retry is a real timer chain, and the rows land from a render it
|
|
306
|
+
// triggers. Awaited OUTSIDE `act`: preact defers effects until the act
|
|
307
|
+
// callback returns, so waiting for a render-driven signal inside one
|
|
308
|
+
// deadlocks.
|
|
309
|
+
await api.awaitMessages((rows) => rows.length === COMMITTED.length);
|
|
310
|
+
|
|
311
|
+
expect(api.messages).toEqual(COMMITTED);
|
|
312
|
+
expect(routes.transcriptReads).toBeGreaterThanOrEqual(4);
|
|
313
|
+
|
|
314
|
+
expect(api.messages).toEqual(COMMITTED);
|
|
315
|
+
expect(routes.transcriptReads).toBeGreaterThanOrEqual(3);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('a lost run on a session this server does not own still settles', async () => {
|
|
319
|
+
// `running:false` is also the answer for a session owned by another
|
|
320
|
+
// instance; the frames are gone either way and the transcript is readable.
|
|
321
|
+
const { api, routes } = await mount({ running: false });
|
|
322
|
+
await act(async () => { api.loseRun(); routes.arm(); });
|
|
323
|
+
|
|
324
|
+
await api.reconnect();
|
|
325
|
+
|
|
326
|
+
expect(api.generating).toBe(false);
|
|
327
|
+
expect(api.messages).toEqual(COMMITTED);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
* The trigger is the channel reopening while this page still believes a run is
|
|
18
|
+
* live (`useReconnectSettle`, which also asks the server whether the run is
|
|
19
|
+
* really over). Recovery is two steps and both are needed:
|
|
20
|
+
*
|
|
21
|
+
* 1. settle the local run — release the generating flag and drop the ids that
|
|
22
|
+
* only a live stream would consume (the AI placeholder, the optimistic user
|
|
23
|
+
* mark, pending steer displays);
|
|
24
|
+
* 2. re-read the committed transcript, because the rows on screen are the
|
|
25
|
+
* stream's last PARTIAL segment and the session JSONL is the only record of
|
|
26
|
+
* the rest.
|
|
27
|
+
*
|
|
28
|
+
* The reload is deliberately unconditional about ownership: a run this page
|
|
29
|
+
* dispatched is over by the time this fires, so the guard in `session-load.ts`
|
|
30
|
+
* (which exists to keep a LAGGING snapshot away from a live run) does not
|
|
31
|
+
* apply here.
|
|
32
|
+
*
|
|
33
|
+
* A run that is STILL LIVE at reconnect is a different case and is NOT
|
|
34
|
+
* reloaded here — its rows are the stream's, and replacing them mid-turn is the
|
|
35
|
+
* lagging-snapshot bug the guard exists for. But its earlier segments are gone:
|
|
36
|
+
* their `message_end` went into the dead socket, and `agent_end` only
|
|
37
|
+
* materializes turns that stopped abnormally (`materializeTerminalMessages`).
|
|
38
|
+
* So the loss is REMEMBERED (`needsReloadRef`) and the transcript is re-read
|
|
39
|
+
* when the run settles, which is the only point where a full read is safe.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
43
|
+
import { useCallback, useRef } from 'preact/hooks';
|
|
44
|
+
|
|
45
|
+
import { SESSION_META_RETRY_SCHEDULE_MS } from '@/shared/lib/workspace/refresh-cadence';
|
|
46
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
47
|
+
import { useReconnectSettle } from '@/client/hooks/chat/omp/reconnect';
|
|
48
|
+
|
|
49
|
+
export interface LostRunRecoveryDeps {
|
|
50
|
+
sessionId: string | null;
|
|
51
|
+
sessionIdRef: { current: string | null };
|
|
52
|
+
/** The id a spawn adopted, while the URL still carries the pending one. */
|
|
53
|
+
adoptedSessionIdRef: { current: string | null };
|
|
54
|
+
isGeneratingRef: { current: boolean };
|
|
55
|
+
/** The stream has announced the run in flight — see `useReconnectSettle`. */
|
|
56
|
+
streamRunRef: { current: boolean };
|
|
57
|
+
/** The server reports a run for this session (its `stream` row). */
|
|
58
|
+
serverRunRef: { current: boolean };
|
|
59
|
+
setGenerating: (v: boolean) => void;
|
|
60
|
+
setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
|
|
61
|
+
/** Pagination cursor for the window the reload applies. */
|
|
62
|
+
applyWindow: (hasMore: unknown, oldestIndex: unknown) => void;
|
|
63
|
+
aiPlaceholderIdRef: { current: string | null };
|
|
64
|
+
optimisticUserIdRef: { current: string | null };
|
|
65
|
+
pendingUserDisplaysRef: { current: { sent: string; display: string }[] };
|
|
66
|
+
cancelStreamingCoalescer: () => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface LostRunRecovery {
|
|
70
|
+
/** Re-read the committed transcript now (used by the run's own settle path
|
|
71
|
+
* when `needsReloadRef` is set — see `omp-callbacks.ts`). */
|
|
72
|
+
reloadMessages: () => void;
|
|
73
|
+
/** Set when a run that lost frames is still streaming: its earlier segments
|
|
74
|
+
* can only be recovered by a full read once the run is over. */
|
|
75
|
+
needsReloadRef: { current: boolean };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function useLostRunRecovery(deps: LostRunRecoveryDeps): LostRunRecovery {
|
|
79
|
+
const {
|
|
80
|
+
sessionId,
|
|
81
|
+
sessionIdRef,
|
|
82
|
+
adoptedSessionIdRef,
|
|
83
|
+
isGeneratingRef,
|
|
84
|
+
streamRunRef,
|
|
85
|
+
serverRunRef,
|
|
86
|
+
setGenerating,
|
|
87
|
+
setLocalMessages,
|
|
88
|
+
applyWindow,
|
|
89
|
+
aiPlaceholderIdRef,
|
|
90
|
+
optimisticUserIdRef,
|
|
91
|
+
pendingUserDisplaysRef,
|
|
92
|
+
cancelStreamingCoalescer,
|
|
93
|
+
} = deps;
|
|
94
|
+
|
|
95
|
+
/** Replace the timeline with the session's committed transcript.
|
|
96
|
+
*
|
|
97
|
+
* A FAILED read leaves the rows alone. An EMPTY one is retried on the same
|
|
98
|
+
* front-loaded schedule `refreshSessionMeta` uses, and for the same reason:
|
|
99
|
+
* omp buffers its transcript writes, so the turn that just finished may not
|
|
100
|
+
* be on disk yet when the channel comes back — reading once and stopping
|
|
101
|
+
* would leave the partial answer in place, which is the very state this
|
|
102
|
+
* recovery exists to clear. The retry is bounded and stops at the first
|
|
103
|
+
* non-empty answer. */
|
|
104
|
+
const reloadMessages = useCallback(() => {
|
|
105
|
+
const current = sessionIdRef.current;
|
|
106
|
+
// A spawn answers with omp's id a beat before the URL carries it, so the
|
|
107
|
+
// adopted id is the one with a transcript when the pending id is still in
|
|
108
|
+
// the URL.
|
|
109
|
+
const target = current && !current.startsWith('new-')
|
|
110
|
+
? current
|
|
111
|
+
: adoptedSessionIdRef.current;
|
|
112
|
+
if (!target) return;
|
|
113
|
+
let attempt = 0;
|
|
114
|
+
const tryRead = (): void => {
|
|
115
|
+
fetch(`/api/chat/${encodeURIComponent(target)}`)
|
|
116
|
+
.then(res => res.json())
|
|
117
|
+
.then(data => {
|
|
118
|
+
// The session may have moved while the read was in flight; applying
|
|
119
|
+
// it would paint one conversation's rows into another.
|
|
120
|
+
const now = sessionIdRef.current;
|
|
121
|
+
if (now !== target && adoptedSessionIdRef.current !== target) return;
|
|
122
|
+
if (!data?.session) return;
|
|
123
|
+
const fetched: ChatMessageData[] = data.session.messages || [];
|
|
124
|
+
if (fetched.length > 0) {
|
|
125
|
+
setLocalMessages(fetched);
|
|
126
|
+
applyWindow(data.hasMore, data.oldestIndex);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const delay = SESSION_META_RETRY_SCHEDULE_MS[attempt];
|
|
130
|
+
attempt += 1;
|
|
131
|
+
if (delay !== undefined) setTimeout(tryRead, delay);
|
|
132
|
+
})
|
|
133
|
+
.catch(() => {});
|
|
134
|
+
};
|
|
135
|
+
tryRead();
|
|
136
|
+
}, [sessionIdRef, adoptedSessionIdRef, setLocalMessages, applyWindow]);
|
|
137
|
+
|
|
138
|
+
const needsReloadRef = useRef(false);
|
|
139
|
+
|
|
140
|
+
useReconnectSettle({
|
|
141
|
+
// A pending `new-…` chat has no omp child and no committed transcript: its
|
|
142
|
+
// run is covered by the pending view, and settling it here would drop the
|
|
143
|
+
// optimistic rows of a spawn still in flight.
|
|
144
|
+
sessionId: sessionId?.startsWith('new-') ? null : sessionId,
|
|
145
|
+
isGeneratingRef,
|
|
146
|
+
streamRunRef,
|
|
147
|
+
serverRunRef,
|
|
148
|
+
// Recorded at the DROP: the segments that finished during the outage have
|
|
149
|
+
// no other way back, and the read that recovers them is only safe once the
|
|
150
|
+
// run is over — so the flag is consumed by `onAgentEnd` (`omp-callbacks.ts`)
|
|
151
|
+
// rather than read here.
|
|
152
|
+
onFramesLost: () => { needsReloadRef.current = true; },
|
|
153
|
+
onSettled: () => {
|
|
154
|
+
setGenerating(false);
|
|
155
|
+
// Queued updates are the same lost segment's tail: the reload is
|
|
156
|
+
// authoritative, and applying them would paint a partial answer over the
|
|
157
|
+
// committed one.
|
|
158
|
+
cancelStreamingCoalescer();
|
|
159
|
+
optimisticUserIdRef.current = null;
|
|
160
|
+
pendingUserDisplaysRef.current = [];
|
|
161
|
+
// The placeholder mark is released, but its ROW is not deleted here: the
|
|
162
|
+
// committed read below is what replaces the rows, and dropping the only
|
|
163
|
+
// row first would blank the transcript for the retry window — and leave
|
|
164
|
+
// it blank for good if omp never flushed the turn. A row the stream left
|
|
165
|
+
// mid-flight also carries `thinking.isGenerating`, which is what pulses
|
|
166
|
+
// the collapsed accordion; the run is over, so nothing is generating.
|
|
167
|
+
aiPlaceholderIdRef.current = null;
|
|
168
|
+
setLocalMessages(prev => prev.map(m => (typeof m.thinking === 'object' && m.thinking?.isGenerating
|
|
169
|
+
? { ...m, thinking: { ...m.thinking, isGenerating: false } }
|
|
170
|
+
: m)));
|
|
171
|
+
needsReloadRef.current = false;
|
|
172
|
+
reloadMessages();
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return { reloadMessages, needsReloadRef };
|
|
177
|
+
}
|
|
@@ -160,6 +160,7 @@ function makeSendHarness(options: {
|
|
|
160
160
|
const params: string[] = [];
|
|
161
161
|
const seeds: SessionSeed[] = [];
|
|
162
162
|
const spawnSelectionRef = { current: { plan: true, goal: false } };
|
|
163
|
+
const spawnWorktreeRef = { current: null as string | null };
|
|
163
164
|
const adoptedSessionIdRef = { current: null };
|
|
164
165
|
const deps: ChatTimelineSendDeps = {
|
|
165
166
|
folders: options.folders ?? [],
|
|
@@ -190,6 +191,7 @@ function makeSendHarness(options: {
|
|
|
190
191
|
deferredComposerPickRef: options.deferredPick ?? { current: null },
|
|
191
192
|
accessModeRef: { current: 'always-ask' },
|
|
192
193
|
spawnSelectionRef,
|
|
194
|
+
spawnWorktreeRef,
|
|
193
195
|
abortControllerRef: { current: null },
|
|
194
196
|
setInputValue: (v) => {
|
|
195
197
|
drafts.push(v);
|
|
@@ -151,6 +151,7 @@ function makeSendHarness(options: {
|
|
|
151
151
|
const params: string[] = [];
|
|
152
152
|
const seeds: SessionSeed[] = [];
|
|
153
153
|
const spawnSelectionRef = { current: { plan: true, goal: false } };
|
|
154
|
+
const spawnWorktreeRef = { current: null as string | null };
|
|
154
155
|
const adoptedSessionIdRef = { current: null };
|
|
155
156
|
const deps: ChatTimelineSendDeps = {
|
|
156
157
|
folders: options.folders ?? [],
|
|
@@ -181,6 +182,7 @@ function makeSendHarness(options: {
|
|
|
181
182
|
deferredComposerPickRef: options.deferredPick ?? { current: null },
|
|
182
183
|
accessModeRef: { current: 'always-ask' },
|
|
183
184
|
spawnSelectionRef,
|
|
185
|
+
spawnWorktreeRef,
|
|
184
186
|
abortControllerRef: { current: null },
|
|
185
187
|
setInputValue: (v) => {
|
|
186
188
|
drafts.push(v);
|