@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
|
@@ -6,63 +6,37 @@
|
|
|
6
6
|
/**
|
|
7
7
|
* Pins the session-state store's observer contract and cache lifecycle.
|
|
8
8
|
*
|
|
9
|
-
* The store is
|
|
10
|
-
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* The store is the single source of a session's UI state: every panel reads and
|
|
10
|
+
* writes through it, and it persists the whole blob through
|
|
11
|
+
* `GET/POST /api/sessions/:sessionId/state`. Two contracts matter enough to pin
|
|
12
|
+
* here rather than in a component test:
|
|
13
|
+
*
|
|
14
|
+
* - **Notification routing.** A write notifies the slot's followers and the
|
|
15
|
+
* session-wide ones, never another session's, and an unsubscribed listener
|
|
16
|
+
* stops receiving while an in-flight pass still reaches it.
|
|
17
|
+
* - **Cache lifecycle.** The cache is bounded (10 sessions) and TTL'd (10
|
|
18
|
+
* minutes), and eviction never takes a session with unsaved writes.
|
|
19
|
+
*
|
|
20
|
+
* The blob-arrival half — the write-during-read race — is `store-load.test.ts`,
|
|
21
|
+
* and the wholesale-replacement bus is `replacement.test.ts`.
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
|
-
import {
|
|
24
|
+
import { describe, expect, setSystemTime, test } from 'bun:test';
|
|
19
25
|
|
|
20
|
-
import { notifySessionKey
|
|
26
|
+
import { notifySessionKey } from '@/shared/lib/workspace/session-state/listeners';
|
|
21
27
|
import {
|
|
22
28
|
clearSessionKey,
|
|
23
|
-
flushSession,
|
|
24
29
|
forgetSession,
|
|
25
30
|
getLastOpenedAt,
|
|
26
31
|
getSessionValue,
|
|
27
32
|
hydrateSession,
|
|
28
|
-
loadSession,
|
|
29
33
|
migrateSessionState,
|
|
30
34
|
recordSessionOpen,
|
|
31
35
|
setSessionKey,
|
|
32
36
|
} from '@/shared/lib/workspace/session-state/store';
|
|
37
|
+
import { counter, follow, installStoreTeardown, use } from '@/shared/lib/workspace/session-state/harness.test-util';
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
/** The runner's own fetch, reached through `Bun` so a stub leaked onto the global cannot be mistaken for it. */
|
|
36
|
-
const originalFetch = Bun.fetch;
|
|
37
|
-
const touched: string[] = [];
|
|
38
|
-
const unsubs: Array<() => void> = [];
|
|
39
|
-
|
|
40
|
-
/** Register a session id so `afterEach` forgets it. */
|
|
41
|
-
function use(id: string): string {
|
|
42
|
-
touched.push(id);
|
|
43
|
-
return id;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Subscribe and auto-unsubscribe at teardown. */
|
|
47
|
-
function follow(sessionId: string | null, key: string | null, listener: () => void): () => void {
|
|
48
|
-
const unsubscribe = subscribeSessionKey(sessionId, key, listener);
|
|
49
|
-
unsubs.push(unsubscribe);
|
|
50
|
-
return unsubscribe;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Count how many times `notifySessionKey` reaches a listener. */
|
|
54
|
-
function counter(): { calls: number; listener: () => void } {
|
|
55
|
-
const state = { calls: 0, listener: () => { state.calls += 1; } };
|
|
56
|
-
return state;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
afterEach(() => {
|
|
60
|
-
for (const unsubscribe of unsubs.splice(0)) unsubscribe();
|
|
61
|
-
for (const id of touched.splice(0)) forgetSession(id);
|
|
62
|
-
Reflect.set(globalThis, 'window', originalWindow);
|
|
63
|
-
Reflect.set(globalThis, 'fetch', originalFetch);
|
|
64
|
-
setSystemTime();
|
|
65
|
-
});
|
|
39
|
+
installStoreTeardown();
|
|
66
40
|
|
|
67
41
|
describe('session-state store: read/write', () => {
|
|
68
42
|
test('a null session id is a no-op for reads and writes', () => {
|
|
@@ -211,102 +185,6 @@ describe('session-state store: notification routing', () => {
|
|
|
211
185
|
});
|
|
212
186
|
});
|
|
213
187
|
|
|
214
|
-
describe('session-state store: blob arrival (loadSession)', () => {
|
|
215
|
-
function stubWindow(): void {
|
|
216
|
-
Reflect.set(globalThis, 'window', {});
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function stubFetch(response: unknown): void {
|
|
220
|
-
Reflect.set(globalThis, 'fetch', async () => response);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
test('a clean session adopts the stored blob and notifies', async () => {
|
|
224
|
-
const id = use('load-clean');
|
|
225
|
-
stubWindow();
|
|
226
|
-
stubFetch({ ok: true, json: async () => ({ state: { a: 1, b: 2 } }) });
|
|
227
|
-
const probe = counter();
|
|
228
|
-
follow(id, null, probe.listener);
|
|
229
|
-
|
|
230
|
-
await loadSession(id);
|
|
231
|
-
expect<number | undefined>(getSessionValue(id, 'a')).toBe(1);
|
|
232
|
-
expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'b')).toBe(2);
|
|
233
|
-
expect(probe.calls).toBe(1);
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
test('a dirty session keeps its local writes over the blob', async () => {
|
|
237
|
-
const id = use('load-dirty');
|
|
238
|
-
stubWindow();
|
|
239
|
-
setSessionKey(id, 'a', 99);
|
|
240
|
-
stubFetch({ ok: true, json: async () => ({ state: { a: 1, b: 2 } }) });
|
|
241
|
-
|
|
242
|
-
await loadSession(id);
|
|
243
|
-
expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'a')).toBe(99);
|
|
244
|
-
expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'b')).toBe(2);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test('a non-ok response leaves the cache untouched but still notifies', async () => {
|
|
248
|
-
const id = use('load-error');
|
|
249
|
-
stubWindow();
|
|
250
|
-
stubFetch({ ok: false, json: async () => ({}) });
|
|
251
|
-
const probe = counter();
|
|
252
|
-
follow(id, null, probe.listener);
|
|
253
|
-
|
|
254
|
-
await loadSession(id);
|
|
255
|
-
expect(getSessionValue(id, 'a')).toBeUndefined();
|
|
256
|
-
expect(probe.calls).toBe(1);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
test('a rejected fetch is swallowed and notifies', async () => {
|
|
260
|
-
const id = use('load-throw');
|
|
261
|
-
stubWindow();
|
|
262
|
-
Reflect.set(globalThis, 'fetch', async () => {
|
|
263
|
-
throw new Error('offline');
|
|
264
|
-
});
|
|
265
|
-
const probe = counter();
|
|
266
|
-
follow(id, null, probe.listener);
|
|
267
|
-
|
|
268
|
-
// The store logs the failure; keep the expected warning out of the run log.
|
|
269
|
-
const warn = console.warn;
|
|
270
|
-
console.warn = () => {};
|
|
271
|
-
try {
|
|
272
|
-
await loadSession(id);
|
|
273
|
-
} finally {
|
|
274
|
-
console.warn = warn;
|
|
275
|
-
}
|
|
276
|
-
expect(getSessionValue(id, 'a')).toBeUndefined();
|
|
277
|
-
expect(probe.calls).toBe(1);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
test('loadSession without a window returns early: no fetch, no notify', async () => {
|
|
281
|
-
const id = use('load-nowindow');
|
|
282
|
-
// Removed for the case and put back after it: another file's hook can have
|
|
283
|
-
// installed one while this file runs, and leaving it missing would break
|
|
284
|
-
// whichever case follows.
|
|
285
|
-
const hadWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
|
286
|
-
Reflect.deleteProperty(globalThis, 'window');
|
|
287
|
-
let fetched = false;
|
|
288
|
-
Reflect.set(globalThis, 'fetch', async () => {
|
|
289
|
-
fetched = true;
|
|
290
|
-
throw new Error('must not be called');
|
|
291
|
-
});
|
|
292
|
-
try {
|
|
293
|
-
const probe = counter();
|
|
294
|
-
follow(id, null, probe.listener);
|
|
295
|
-
await loadSession(id);
|
|
296
|
-
expect(fetched).toBe(false);
|
|
297
|
-
expect(probe.calls).toBe(0);
|
|
298
|
-
} finally {
|
|
299
|
-
if (hadWindow) Object.defineProperty(globalThis, 'window', hadWindow);
|
|
300
|
-
Reflect.set(globalThis, 'fetch', Bun.fetch);
|
|
301
|
-
}
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
test('flushSession is a resolved no-op without a window or id', async () => {
|
|
305
|
-
await expect(flushSession(null)).resolves.toBeUndefined();
|
|
306
|
-
await expect(flushSession(use('flush'))).resolves.toBeUndefined();
|
|
307
|
-
});
|
|
308
|
-
});
|
|
309
|
-
|
|
310
188
|
describe('session-state store: bounded cache', () => {
|
|
311
189
|
test('the oldest clean session is evicted past the size cap', () => {
|
|
312
190
|
const ids = Array.from({ length: 11 }, (_unused, index) => use(`lru-${index}`));
|
|
@@ -66,91 +66,52 @@
|
|
|
66
66
|
* from another conversation is never restored
|
|
67
67
|
* - chat.draft string
|
|
68
68
|
* - chat.draftAttachments attachment metadata array
|
|
69
|
+
*
|
|
70
|
+
* The module is three files, because one had passed the per-file ceiling: this
|
|
71
|
+
* one owns the PUBLIC API (read, write, load, migrate) and what a write means;
|
|
72
|
+
* `cache.ts` owns the storage (the maps, the LRU order, the idle TTL, the dirty
|
|
73
|
+
* flag); `merge.ts` owns the write counter and the "which copy wins when a blob
|
|
74
|
+
* lands" decision. The maps live only in `cache.ts`, so "is this session dirty"
|
|
75
|
+
* and "drop this session" cannot be answered from two places that disagree.
|
|
69
76
|
*/
|
|
70
77
|
|
|
71
78
|
import { notifySessionKey, notifySessionReplaced } from '@/shared/lib/workspace/session-state/listeners';
|
|
79
|
+
import {
|
|
80
|
+
clearDirty,
|
|
81
|
+
dropEntry,
|
|
82
|
+
dropPersistTimer,
|
|
83
|
+
entryForWrite,
|
|
84
|
+
evictIfNeeded,
|
|
85
|
+
isDirty,
|
|
86
|
+
lastAccess,
|
|
87
|
+
markDirty,
|
|
88
|
+
readEntry,
|
|
89
|
+
setPersistTimer,
|
|
90
|
+
stampAccess,
|
|
91
|
+
touchEntry,
|
|
92
|
+
writeEntry,
|
|
93
|
+
} from '@/shared/lib/workspace/session-state/cache';
|
|
94
|
+
import {
|
|
95
|
+
currentWriteSeq,
|
|
96
|
+
forgetWrites,
|
|
97
|
+
keysWrittenAfter,
|
|
98
|
+
mergeStoredBlob,
|
|
99
|
+
noteWrite,
|
|
100
|
+
} from '@/shared/lib/workspace/session-state/merge';
|
|
72
101
|
|
|
73
102
|
type SessionState = Record<string, unknown>;
|
|
74
103
|
|
|
75
|
-
const cache = new Map<string, SessionState>();
|
|
76
|
-
/** Epoch ms of the last user-driven access per session (get/set/hydrate). */
|
|
77
|
-
const lastTouched = new Map<string, number>();
|
|
78
|
-
const dirtySessions = new Map<string, boolean>();
|
|
79
|
-
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
80
|
-
|
|
81
104
|
const PERSIST_DEBOUNCE_MS = 600;
|
|
82
105
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
/** A session whose last user access is older than this is evicted regardless
|
|
87
|
-
* of the size cap, so stale per-session UI state (drafts, panel layouts)
|
|
88
|
-
* stops occupying memory 10 minutes after the user last opened it. */
|
|
89
|
-
const SESSION_IDLE_TTL_MS = 10 * 60 * 1000;
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Reorder a cache entry to the end of the Map's iteration order (the
|
|
93
|
-
* most-recently-used position) via delete-then-set. Map.set on an existing
|
|
94
|
-
* key updates the value but keeps its original position, so the explicit
|
|
95
|
-
* delete is required for true LRU semantics.
|
|
96
|
-
*/
|
|
97
|
-
function touchCacheEntry(sessionId: string): void {
|
|
98
|
-
const value = cache.get(sessionId);
|
|
99
|
-
if (value !== undefined) {
|
|
100
|
-
cache.delete(sessionId);
|
|
101
|
-
cache.set(sessionId, value);
|
|
102
|
-
}
|
|
103
|
-
lastTouched.set(sessionId, Date.now());
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Evict the oldest clean sessions until the cache is within bounds.
|
|
108
|
-
*
|
|
109
|
-
* Dirty-safety rule: a session with unsaved writes (`dirtySessions.get(id) ===
|
|
110
|
-
* true`) must never be evicted, or the user's drafts and editor state would be
|
|
111
|
-
* silently lost. If every candidate is dirty (or is the protected id currently
|
|
112
|
-
* being written), stop evicting rather than lose data.
|
|
113
|
-
*
|
|
114
|
-
* Entries idle past SESSION_IDLE_TTL_MS are evicted first — even when the
|
|
115
|
-
* cache is under the size cap — so long-unused sessions drop out on schedule.
|
|
116
|
-
*/
|
|
117
|
-
function evictIfNeeded(protectedId: string | null): void {
|
|
118
|
-
const now = Date.now();
|
|
119
|
-
const evict = (id: string): void => {
|
|
120
|
-
cache.delete(id);
|
|
121
|
-
dirtySessions.delete(id);
|
|
122
|
-
lastTouched.delete(id);
|
|
123
|
-
const timer = persistTimers.get(id);
|
|
124
|
-
if (timer) {
|
|
125
|
-
clearTimeout(timer);
|
|
126
|
-
persistTimers.delete(id);
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
// TTL pass: safe to skip only while the timer still holds the unsaved state
|
|
130
|
-
// or the session is the one currently being written.
|
|
131
|
-
for (const [id, touched] of lastTouched) {
|
|
132
|
-
if (now - touched <= SESSION_IDLE_TTL_MS) continue;
|
|
133
|
-
if (id === protectedId) continue;
|
|
134
|
-
if (dirtySessions.get(id)) continue;
|
|
135
|
-
evict(id);
|
|
136
|
-
}
|
|
137
|
-
while (cache.size > MAX_CACHED_SESSIONS) {
|
|
138
|
-
let evicted = false;
|
|
139
|
-
for (const id of cache.keys()) {
|
|
140
|
-
if (id === protectedId) continue;
|
|
141
|
-
if (dirtySessions.get(id)) continue;
|
|
142
|
-
evict(id);
|
|
143
|
-
evicted = true;
|
|
144
|
-
break;
|
|
145
|
-
}
|
|
146
|
-
if (!evicted) break;
|
|
147
|
-
}
|
|
106
|
+
/** Evict past the cap, dropping this session's write history with it. */
|
|
107
|
+
function evictNow(protectedId: string | null): void {
|
|
108
|
+
evictIfNeeded(protectedId, forgetWrites);
|
|
148
109
|
}
|
|
149
110
|
|
|
150
111
|
export function getSessionValue<T>(sessionId: string | null, key: string): T | undefined {
|
|
151
112
|
if (!sessionId) return undefined;
|
|
152
|
-
|
|
153
|
-
return
|
|
113
|
+
touchEntry(sessionId);
|
|
114
|
+
return readEntry(sessionId)?.[key] as T | undefined;
|
|
154
115
|
}
|
|
155
116
|
|
|
156
117
|
/**
|
|
@@ -166,18 +127,20 @@ export function getSessionValue<T>(sessionId: string | null, key: string): T | u
|
|
|
166
127
|
*/
|
|
167
128
|
export function getSessionState(sessionId: string | null): Readonly<Record<string, unknown>> | undefined {
|
|
168
129
|
if (!sessionId) return undefined;
|
|
169
|
-
return
|
|
130
|
+
return readEntry(sessionId);
|
|
170
131
|
}
|
|
171
132
|
|
|
172
133
|
export function setSessionKey(sessionId: string | null, key: string, value: unknown): void {
|
|
173
134
|
if (!sessionId) return;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
135
|
+
// Mutated IN PLACE, never copied: `useSessionState` compares the store's entry
|
|
136
|
+
// against its own copy by IDENTITY to decide whether a fresher write exists,
|
|
137
|
+
// and a copy per write made every reader see a different object for one slot.
|
|
138
|
+
entryForWrite(sessionId)[key] = value;
|
|
139
|
+
touchEntry(sessionId);
|
|
140
|
+
markDirty(sessionId);
|
|
141
|
+
noteWrite(sessionId, key);
|
|
179
142
|
schedulePersist(sessionId);
|
|
180
|
-
|
|
143
|
+
evictNow(sessionId);
|
|
181
144
|
notifySessionKey(sessionId, key);
|
|
182
145
|
}
|
|
183
146
|
|
|
@@ -188,22 +151,25 @@ export function setSessionKey(sessionId: string | null, key: string, value: unkn
|
|
|
188
151
|
*/
|
|
189
152
|
export function clearSessionKey(sessionId: string | null, key: string): void {
|
|
190
153
|
if (!sessionId) return;
|
|
191
|
-
const state =
|
|
154
|
+
const state = readEntry(sessionId);
|
|
192
155
|
if (!state || !(key in state)) return;
|
|
193
|
-
delete state[key];
|
|
194
|
-
|
|
195
|
-
|
|
156
|
+
delete (state as SessionState)[key];
|
|
157
|
+
touchEntry(sessionId);
|
|
158
|
+
markDirty(sessionId);
|
|
159
|
+
// A DELETE is a write: a blob that still carries the key must not bring it
|
|
160
|
+
// back (a retired key cleared on this side would return from the other tab's
|
|
161
|
+
// older snapshot).
|
|
162
|
+
noteWrite(sessionId, key);
|
|
196
163
|
schedulePersist(sessionId);
|
|
197
164
|
notifySessionKey(sessionId, key);
|
|
198
165
|
}
|
|
199
166
|
|
|
200
167
|
function schedulePersist(sessionId: string): void {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
persistTimers.set(
|
|
168
|
+
dropPersistTimer(sessionId);
|
|
169
|
+
setPersistTimer(
|
|
204
170
|
sessionId,
|
|
205
171
|
setTimeout(() => {
|
|
206
|
-
|
|
172
|
+
dropPersistTimer(sessionId);
|
|
207
173
|
void persistSession(sessionId);
|
|
208
174
|
}, PERSIST_DEBOUNCE_MS),
|
|
209
175
|
);
|
|
@@ -211,9 +177,9 @@ function schedulePersist(sessionId: string): void {
|
|
|
211
177
|
|
|
212
178
|
async function persistSession(sessionId: string): Promise<void> {
|
|
213
179
|
if (typeof window === 'undefined' || !sessionId) return;
|
|
214
|
-
if (!
|
|
215
|
-
|
|
216
|
-
const state =
|
|
180
|
+
if (!isDirty(sessionId)) return;
|
|
181
|
+
clearDirty(sessionId);
|
|
182
|
+
const state = readEntry(sessionId) ?? {};
|
|
217
183
|
try {
|
|
218
184
|
await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/state`, {
|
|
219
185
|
method: 'POST',
|
|
@@ -222,18 +188,14 @@ async function persistSession(sessionId: string): Promise<void> {
|
|
|
222
188
|
});
|
|
223
189
|
} catch (err) {
|
|
224
190
|
console.warn('session-state persist failed:', err);
|
|
225
|
-
|
|
191
|
+
markDirty(sessionId);
|
|
226
192
|
}
|
|
227
193
|
}
|
|
228
194
|
|
|
229
195
|
/** Immediately persist any pending writes for a session (used on switch). */
|
|
230
196
|
export function flushSession(sessionId: string | null): Promise<void> {
|
|
231
197
|
if (!sessionId || typeof window === 'undefined') return Promise.resolve();
|
|
232
|
-
|
|
233
|
-
if (timer) {
|
|
234
|
-
clearTimeout(timer);
|
|
235
|
-
persistTimers.delete(sessionId);
|
|
236
|
-
}
|
|
198
|
+
dropPersistTimer(sessionId);
|
|
237
199
|
return persistSession(sessionId);
|
|
238
200
|
}
|
|
239
201
|
|
|
@@ -241,27 +203,34 @@ export function flushSession(sessionId: string | null): Promise<void> {
|
|
|
241
203
|
export async function loadSession(sessionId: string): Promise<void> {
|
|
242
204
|
if (!sessionId) return;
|
|
243
205
|
if (typeof window === 'undefined') return;
|
|
206
|
+
/**
|
|
207
|
+
* The write counter as of the moment the read STARTED.
|
|
208
|
+
*
|
|
209
|
+
* Everything written after this is newer than the response, because the
|
|
210
|
+
* response describes the row as it was when the request arrived. Two
|
|
211
|
+
* mechanisms are needed and neither is sufficient alone: `dirtySessions`
|
|
212
|
+
* covers a write whose persist has not landed yet, while this covers one that
|
|
213
|
+
* already persisted — at which point the dirty flag is clear and a stale
|
|
214
|
+
* response would otherwise be adopted wholesale. Measured: a file opened
|
|
215
|
+
* during the first `/state` read came back with `layout.openedFiles` gone.
|
|
216
|
+
*/
|
|
217
|
+
const startedAt = currentWriteSeq();
|
|
244
218
|
try {
|
|
245
219
|
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/state`);
|
|
246
220
|
if (res.ok) {
|
|
247
221
|
const data = (await res.json()) as { state?: SessionState };
|
|
248
222
|
const incoming = data.state && typeof data.state === 'object' ? data.state : {};
|
|
249
|
-
const existing =
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
// one tab never appeared in the other.
|
|
256
|
-
const dirty = dirtySessions.get(sessionId) === true;
|
|
257
|
-
const merged = dirty ? { ...incoming, ...existing } : incoming;
|
|
258
|
-
cache.set(sessionId, merged);
|
|
259
|
-
touchCacheEntry(sessionId);
|
|
223
|
+
const existing = readEntry(sessionId) ?? {};
|
|
224
|
+
writeEntry(
|
|
225
|
+
sessionId,
|
|
226
|
+
mergeStoredBlob(incoming, existing, keysWrittenAfter(sessionId, startedAt), isDirty(sessionId)),
|
|
227
|
+
);
|
|
228
|
+
touchEntry(sessionId);
|
|
260
229
|
}
|
|
261
230
|
} catch (err) {
|
|
262
231
|
console.warn('session-state load failed:', err);
|
|
263
232
|
}
|
|
264
|
-
|
|
233
|
+
evictNow(sessionId);
|
|
265
234
|
// The blob replaces every slot at once, so a follower that read the store
|
|
266
235
|
// before it arrived (no pick yet) has to re-read — including a private copy
|
|
267
236
|
// held by `useSessionState`, which does not follow per-slot writes.
|
|
@@ -272,9 +241,9 @@ export async function loadSession(sessionId: string): Promise<void> {
|
|
|
272
241
|
/** Seed the cache synchronously (e.g. from SSR-provided data). */
|
|
273
242
|
export function hydrateSession(sessionId: string | null, state: SessionState): void {
|
|
274
243
|
if (!sessionId) return;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
244
|
+
writeEntry(sessionId, { ...(readEntry(sessionId) ?? {}), ...state });
|
|
245
|
+
touchEntry(sessionId);
|
|
246
|
+
evictNow(sessionId);
|
|
278
247
|
notifySessionKey(sessionId);
|
|
279
248
|
notifySessionReplaced(sessionId);
|
|
280
249
|
}
|
|
@@ -285,12 +254,12 @@ export function hydrateSession(sessionId: string | null, state: SessionState): v
|
|
|
285
254
|
* its last open is within SESSION_IDLE_TTL_MS.
|
|
286
255
|
*/
|
|
287
256
|
export function recordSessionOpen(sessionId: string | null): void {
|
|
288
|
-
if (sessionId)
|
|
257
|
+
if (sessionId) stampAccess(sessionId);
|
|
289
258
|
}
|
|
290
259
|
|
|
291
260
|
/** Epoch ms of the user's last open of this session in this tab, if any. */
|
|
292
261
|
export function getLastOpenedAt(sessionId: string): number | undefined {
|
|
293
|
-
return
|
|
262
|
+
return lastAccess(sessionId);
|
|
294
263
|
}
|
|
295
264
|
|
|
296
265
|
/**
|
|
@@ -302,14 +271,8 @@ export function getLastOpenedAt(sessionId: string): number | undefined {
|
|
|
302
271
|
* the delete and resurrect the state.
|
|
303
272
|
*/
|
|
304
273
|
export function forgetSession(sessionId: string): void {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
clearTimeout(timer);
|
|
308
|
-
persistTimers.delete(sessionId);
|
|
309
|
-
}
|
|
310
|
-
cache.delete(sessionId);
|
|
311
|
-
dirtySessions.delete(sessionId);
|
|
312
|
-
lastTouched.delete(sessionId);
|
|
274
|
+
dropEntry(sessionId);
|
|
275
|
+
forgetWrites(sessionId);
|
|
313
276
|
notifySessionKey(sessionId);
|
|
314
277
|
notifySessionReplaced(sessionId);
|
|
315
278
|
}
|
|
@@ -319,15 +282,14 @@ export function forgetSession(sessionId: string): void {
|
|
|
319
282
|
* by a spawn, then discard the transient slot.
|
|
320
283
|
*/
|
|
321
284
|
export function migrateSessionState(fromId: string, toId: string): void {
|
|
322
|
-
const from =
|
|
323
|
-
if (from)
|
|
324
|
-
|
|
325
|
-
|
|
285
|
+
const from = readEntry(fromId);
|
|
286
|
+
if (from) writeEntry(toId, { ...from, ...(readEntry(toId) ?? {}) });
|
|
287
|
+
touchEntry(toId);
|
|
288
|
+
markDirty(toId);
|
|
326
289
|
schedulePersist(toId);
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
evictIfNeeded(toId);
|
|
290
|
+
dropEntry(fromId);
|
|
291
|
+
forgetWrites(fromId);
|
|
292
|
+
evictNow(toId);
|
|
331
293
|
// The adopted slot gained the whole transient state; the transient one lost it.
|
|
332
294
|
notifySessionKey(toId);
|
|
333
295
|
notifySessionKey(fromId);
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
|
|
22
22
|
|
|
23
|
-
import { activeProjectForSession, composerRootFor } from '@/shared/lib/workspace/active-project';
|
|
23
|
+
import { activeProjectForSession, composerRootFor, effectiveRootFor } from '@/shared/lib/workspace/active-project';
|
|
24
24
|
import { diffTabId, diffTabName, fileTabId } from '@/shared/lib/workspace/file-tab-id';
|
|
25
25
|
import {
|
|
26
26
|
DEFAULT_RIGHT_PANEL_FRACTION,
|
|
@@ -173,6 +173,54 @@ describe('active-project', () => {
|
|
|
173
173
|
expect(composerRootFor(folders, 30, 1)).toBe('/work/gamma');
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The root the panels follow. A worktree session runs OUTSIDE its folder's
|
|
178
|
+
* project path, so pointing the panels at `project_path` showed the checkout
|
|
179
|
+
* the agent had left — measured: every panel kept reading the main tree while
|
|
180
|
+
* the run wrote to the worktree.
|
|
181
|
+
*/
|
|
182
|
+
describe('effectiveRootFor', () => {
|
|
183
|
+
const withWorktree = (status: 'ready' | 'missing'): WorkspaceFolderData[] => [
|
|
184
|
+
{
|
|
185
|
+
id: 1,
|
|
186
|
+
name: 'alpha',
|
|
187
|
+
project_path: '/work/alpha',
|
|
188
|
+
isExpanded: true,
|
|
189
|
+
hasMore: false,
|
|
190
|
+
totalSessions: 2,
|
|
191
|
+
sessions: [
|
|
192
|
+
{
|
|
193
|
+
id: 'wt-session',
|
|
194
|
+
folder_id: 1,
|
|
195
|
+
title: 'in a worktree',
|
|
196
|
+
worktree: {
|
|
197
|
+
branch: 'wt/cosmic-dolphin',
|
|
198
|
+
path: '/data/worktrees/abc/cosmic-dolphin',
|
|
199
|
+
primaryRoot: '/work/alpha',
|
|
200
|
+
status,
|
|
201
|
+
detached: false,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
{ id: 'plain', folder_id: 1, title: 'in the checkout' },
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
test('a ready worktree wins over the folder project path', () => {
|
|
210
|
+
expect(effectiveRootFor(withWorktree('ready'), 'wt-session')).toBe('/data/worktrees/abc/cosmic-dolphin');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('a MISSING worktree falls back to the project path — a directory that is gone is not a root', () => {
|
|
214
|
+
expect(effectiveRootFor(withWorktree('missing'), 'wt-session')).toBe('/work/alpha');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('an ordinary session keeps the project path', () => {
|
|
218
|
+
expect(effectiveRootFor(withWorktree('ready'), 'plain')).toBe('/work/alpha');
|
|
219
|
+
expect(effectiveRootFor(folders, 30)).toBe('/work/gamma');
|
|
220
|
+
expect(effectiveRootFor(folders, 999)).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
176
224
|
test('composerRootFor falls back to the selected folder without a session match', () => {
|
|
177
225
|
expect(composerRootFor(folders, null, 3)).toBe('/work/gamma');
|
|
178
226
|
expect(composerRootFor(folders, 999, 3)).toBe('/work/gamma');
|