From dcefadd87957af05ce8dd34f5ff20e9f945dded3 Mon Sep 17 00:00:00 2001 From: Andrew Mikofalvy <5668128+amikofalvy@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:53:12 -0700 Subject: [PATCH] feat(open-knowledge): worktree selector for per-branch windows (#2322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(open-knowledge): worktree selector for isolated per-branch windows Adds a worktree selector to the desktop app so you can work on several branches of a project at once, each in its own window with its own editor and server. Surfaced in two places (PRD-7231): - The sidebar project switcher gains a Worktrees section listing local branches and their worktrees, with the current window and main worktree flagged. Picking a branch opens its worktree in a new window, creating one on demand under .ok/worktrees/ if it does not exist yet. A "New worktree" item opens a dialog to start a fresh branch off the current one. - The File menu gains "New worktree" and "Switch worktree" items that drive the same renderer surface via menu actions. Desktop main enumerates branches and worktrees and runs git worktree add, auto-locating each worktree inside the project and excluding .ok/worktrees/ from the parent repo git status through .git/info/exclude so the nested worktree never shows up as untracked. Opening a worktree reuses the existing project-open path with a new 'worktree' entry point; because the worktree carries the committed .ok/config.yml it resolves as a managed project and opens with no consent dialog. There is no in-place git checkout, so the live editor and auto-committing shadow repo are never disturbed. list and create ride one consolidated IPC channel (ok:worktree:dispatch), following the ok:sharing:dispatch precedent to respect the hand-rolled channel cap. The pure branch/worktree merge and path logic live in core with unit tests; the git IO and selector UI are covered by integration and DOM tests. * fix(open-knowledge): worktree IPC no-git logging + stderr classify types CI caught two issues on the worktree selector: - worktree-service classifyAddError reached err.stderr through optional chaining (a biome --unsafe rewrite of the original non-null assertion), making raw possibly-undefined and failing desktop typecheck (TS18048). Rewritten with a stable local + Buffer.isBuffer guard so no assertion is needed and the unsafe fix cannot reintroduce it. - The ok:worktree:dispatch handler returned { ok: false, reason: no-git } without a paired logIpcError call, failing the ipc-log-coverage meta-test. Added the logIpcError call in the guard block. * fix(open-knowledge): guard project switcher against Electron open-click fall-through On the Electron host Chromium delivers no real pointerdown to the renderer, so the click that opens the project-switcher dropdown can fall through onto a menu row and immediately fire its onSelect — a project opens and the menu flickers shut. Swallow any row selection landing within SELECT_GUARD_MS of the menu opening (preventDefault keeps the menu open); a deliberate later click still works. Applies to recents, the project actions, and the new worktree rows. Gated on the Electron host so browsers keep normal behavior. * feat(open-knowledge): enrich recents with worktree relationship + keep project as launch default Backend for first-class nested worktrees (PRD-7231): ok:project:list-recent now annotates each present recent with { gitCommonDir, mainRoot, isLinkedWorktree, branch } via a memoized single git rev-parse per path, so the renderer can group linked worktrees under their main project. Opening a worktree still records it in recents but sets lastOpenedProject to the main repo root, so the launch default stays the project rather than a specific branch's window. * feat(open-knowledge): cached non-blocking worktree store for the current project Module-level store (mirrors current-branch-store) holding the current window's WorktreeSelectorModel, fetched once via bridge.worktree.list() and shared across the switcher, command palette, and search via useSyncExternalStore. Repeat reads are cache-served (instant, non-blocking); refresh() re-fetches after a create; a failed/null fetch keeps the prior cache. PRD-7231 FR7. * feat(open-knowledge): pure repo-grouping of recents for nested worktrees * feat(open-knowledge): nested worktree submenu, switcher search, Cmd-K worktrees Make worktrees first-class in the project switcher. Recents group by repo: a project with opened worktrees exposes them in a submenu (project row + each worktree), a project with none stays a flat row. Typing in the switcher searches across recent projects, opened worktrees, and the current project's un-opened branches (create-on-demand via the cached worktree store). The Cmd-K palette gains a Worktrees group over the same cached model. Replaces the flat WorktreeSwitcherSection with RecentProjectsMenu; the top-level "New worktree" item stays for git projects. One git spawn total, shared across surfaces; async arrival fills a stable region without reflow. * feat(open-knowledge): dual-mode New Worktree dialog (create or check out a branch) The New Worktree dialog now disambiguates the typed branch against the project's local branches: an unknown name creates a fresh branch off the current one, a known name checks that existing branch out as a worktree (createBranch false, no base). Existing branches surface in a datalist so selection is discoverable, the caption + confirm label adapt to the mode, and the current window's cached worktree model refreshes after a create. * docs(open-knowledge): align worktree-selector SPEC with shipped submenu/search/dialog * fix(open-knowledge): use not interpolated t`` in worktree submenu The React Compiler cannot lower a tagged template with interpolations (`t`Open ${name}``), and it only fails in the vite build's Babel pass — not tsc or bun tests — so it slipped local checks and broke the app build (and every downstream OK Validation job). Switch the "Open " label to the component, which handles interpolation compiler-safely. * style(open-knowledge): biome-format worktree recents test files * docs(open-knowledge): drop FR7 tag from worktree-store comment (comment-discipline) * fix(open-knowledge): coalesce mid-flight worktree-store refresh + cover create error path Addresses PR review: refresh() (fired right after worktree.create) was dropped if it landed while the bootstrap load was still in-flight, so a just-created worktree could miss the current window's cache until remount. Queue one follow-up load instead. Also add the flagged error-path test: a failed create-on-demand toasts and opens no window. GitOrigin-RevId: 093eba067e2497015f4800bb3b17fa3995d4e6c3 --- .changeset/worktree-selector.md | 5 + .../components/CommandPalette.dom.test.tsx | 76 ++++ .../app/src/components/CommandPalette.tsx | 63 +++- .../components/NewWorktreeDialog.dom.test.tsx | 145 ++++++++ .../app/src/components/NewWorktreeDialog.tsx | 219 ++++++++++++ .../components/ProjectSwitcher.dom.test.tsx | 84 ++++- .../app/src/components/ProjectSwitcher.tsx | 149 +++++--- .../RecentProjectsMenu.dom.test.tsx | 274 +++++++++++++++ .../app/src/components/RecentProjectsMenu.tsx | 329 ++++++++++++++++++ .../project-switcher-recents.test.ts | 95 +++++ .../components/project-switcher-recents.ts | 52 +++ packages/app/src/hooks/use-worktrees.ts | 6 + packages/app/src/lib/desktop-bridge-types.ts | 15 +- packages/app/src/lib/worktree-store.test.ts | 102 ++++++ packages/app/src/lib/worktree-store.ts | 79 +++++ packages/app/src/locales/en/messages.json | 41 ++- packages/app/src/locales/en/messages.po | 115 +++++- packages/app/src/locales/pseudo/messages.json | 41 ++- packages/app/src/locales/pseudo/messages.po | 111 +++++- .../tests/stress/fixtures/handoff-mocks.ts | 4 + packages/core/src/desktop-bridge.ts | 17 +- .../core/src/git/branch-list-parser.test.ts | 29 ++ packages/core/src/git/branch-list-parser.ts | 13 + packages/core/src/git/worktree-path.test.ts | 35 ++ packages/core/src/git/worktree-path.ts | 13 + .../src/git/worktree-selector-model.test.ts | 107 ++++++ .../core/src/git/worktree-selector-model.ts | 108 ++++++ packages/core/src/index.ts | 11 + packages/core/src/sharing/receive-flow.ts | 4 + packages/desktop/src/main/index.ts | 52 ++- packages/desktop/src/main/menu.ts | 13 + .../desktop/src/main/worktree-recents.test.ts | 97 ++++++ packages/desktop/src/main/worktree-recents.ts | 72 ++++ .../desktop/src/main/worktree-service.test.ts | 153 ++++++++ packages/desktop/src/main/worktree-service.ts | 144 ++++++++ packages/desktop/src/preload/index.ts | 14 + .../desktop/src/shared/bridge-contract.ts | 12 +- packages/desktop/src/shared/entry-point.ts | 4 +- packages/desktop/src/shared/ipc-channels.ts | 11 + .../ipc-channel-count-ratchet.test.ts | 2 +- .../tests/integration/m1-smoke.test.ts | 6 +- packages/desktop/tests/main/menu.test.ts | 26 ++ 42 files changed, 2840 insertions(+), 108 deletions(-) create mode 100644 .changeset/worktree-selector.md create mode 100644 packages/app/src/components/NewWorktreeDialog.dom.test.tsx create mode 100644 packages/app/src/components/NewWorktreeDialog.tsx create mode 100644 packages/app/src/components/RecentProjectsMenu.dom.test.tsx create mode 100644 packages/app/src/components/RecentProjectsMenu.tsx create mode 100644 packages/app/src/components/project-switcher-recents.test.ts create mode 100644 packages/app/src/components/project-switcher-recents.ts create mode 100644 packages/app/src/hooks/use-worktrees.ts create mode 100644 packages/app/src/lib/worktree-store.test.ts create mode 100644 packages/app/src/lib/worktree-store.ts create mode 100644 packages/core/src/git/branch-list-parser.test.ts create mode 100644 packages/core/src/git/branch-list-parser.ts create mode 100644 packages/core/src/git/worktree-path.test.ts create mode 100644 packages/core/src/git/worktree-path.ts create mode 100644 packages/core/src/git/worktree-selector-model.test.ts create mode 100644 packages/core/src/git/worktree-selector-model.ts create mode 100644 packages/desktop/src/main/worktree-recents.test.ts create mode 100644 packages/desktop/src/main/worktree-recents.ts create mode 100644 packages/desktop/src/main/worktree-service.test.ts create mode 100644 packages/desktop/src/main/worktree-service.ts diff --git a/.changeset/worktree-selector.md b/.changeset/worktree-selector.md new file mode 100644 index 000000000..3873d141d --- /dev/null +++ b/.changeset/worktree-selector.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": patch +--- + +You can now work on several branches of a project at once, each in its own window. The project switcher in the sidebar footer (and the File menu) has a new Worktrees section: pick a branch to open its worktree in a new window — if it doesn't have one yet, OpenKnowledge creates it on demand — or choose "New worktree…" to start a fresh branch. Worktrees are stored inside the project under `.ok/worktrees/` and kept out of git status automatically, so each window stays fully isolated (its own editor and server) without touching your working copy. diff --git a/packages/app/src/components/CommandPalette.dom.test.tsx b/packages/app/src/components/CommandPalette.dom.test.tsx index 3bf433a15..d2992e635 100644 --- a/packages/app/src/components/CommandPalette.dom.test.tsx +++ b/packages/app/src/components/CommandPalette.dom.test.tsx @@ -183,6 +183,13 @@ mock.module('@/components/command-palette-tag-search', () => ({ fetchDocsForTag: mock(() => Promise.resolve([])), })); +let worktreeModelMock: import('@inkeep/open-knowledge-core').WorktreeSelectorModel | null = null; +mock.module('@/hooks/use-worktrees', () => ({ + useWorktrees: () => worktreeModelMock, +})); +const refreshWorktreesMock = mock(() => {}); +mock.module('@/lib/worktree-store', () => ({ refreshWorktrees: refreshWorktreesMock })); + function recent(name: string, path = `/projects/${name.toLowerCase()}`) { return { name, path: path.replaceAll(' ', '-') }; } @@ -209,6 +216,15 @@ function createBridge() { navigator: { open: mock(() => Promise.resolve()), }, + worktree: { + create: mock(() => + Promise.resolve({ + ok: true as const, + path: '/projects/current/.ok/worktrees/feature-x', + created: true, + }), + ), + }, }; } @@ -247,6 +263,8 @@ describe('CommandPalette DOM behavior', () => { createProjectDialogProps = []; commandDialogProps = []; refreshInstallStatesCalls = 0; + worktreeModelMock = null; + refreshWorktreesMock.mockClear(); window.location.hash = ''; globalThis.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({ results: [] }), { status: 200 })), @@ -541,6 +559,64 @@ describe('CommandPalette DOM behavior', () => { expect(screen.queryByTestId('command-palette-search-preparing')).toBeNull(), ); }); + + test('surfaces worktrees of the current project — opens an existing one and creates one on demand', async () => { + worktreeModelMock = { + mainRoot: '/projects/current', + currentBranch: 'main', + entries: [ + { + branch: 'main', + worktreePath: '/projects/current', + isCurrent: true, + isMain: true, + locked: false, + }, + { + branch: 'dev', + worktreePath: '/projects/current/.ok/worktrees/dev', + isCurrent: false, + isMain: false, + locked: false, + }, + { + branch: 'feature-x', + worktreePath: null, + isCurrent: false, + isMain: false, + locked: false, + }, + ], + }; + const { bridge } = await renderPalette(); + + expect(screen.queryByTestId('command-palette-worktree-main')).toBeNull(); + + fireEvent.click(screen.getByTestId('command-palette-worktree-dev')); + await waitFor(() => { + expect(bridge?.project.open).toHaveBeenCalledWith({ + path: '/projects/current/.ok/worktrees/dev', + target: 'new-window', + entryPoint: 'worktree', + }); + }); + + fireEvent.click(screen.getByTestId('command-palette-worktree-feature-x')); + await waitFor(() => { + expect(bridge?.worktree.create).toHaveBeenCalledWith({ + branch: 'feature-x', + createBranch: false, + }); + }); + await waitFor(() => { + expect(bridge?.project.open).toHaveBeenCalledWith({ + path: '/projects/current/.ok/worktrees/feature-x', + target: 'new-window', + entryPoint: 'worktree', + }); + }); + expect(refreshWorktreesMock).toHaveBeenCalled(); + }); }); describe('NavigationItem path subtitle', () => { diff --git a/packages/app/src/components/CommandPalette.tsx b/packages/app/src/components/CommandPalette.tsx index b2b1b8cee..add9bc080 100644 --- a/packages/app/src/components/CommandPalette.tsx +++ b/packages/app/src/components/CommandPalette.tsx @@ -1,6 +1,6 @@ // biome-ignore-all lint/plugin/no-raw-html-interactive-element: pre-rule backlog — file uses raw