Skip to content

Project resolution: project → worktree → session model, history, and UI#822

Merged
backnotprop merged 13 commits into
feat/single-server-runtimefrom
feat/project-resolution
May 30, 2026
Merged

Project resolution: project → worktree → session model, history, and UI#822
backnotprop merged 13 commits into
feat/single-server-runtimefrom
feat/project-resolution

Conversation

@backnotprop
Copy link
Copy Markdown
Owner

Project resolution: project → worktree → session model, history, and UI

Layered on top of #733 (feat/single-server-runtime). Squash-merges into it.

Establishes a single, correct ownership model — a session always belongs to one project, optionally a worktree — and surfaces it end to end. Decision facts in goals/architecture/decisions/.

Backend

  • Resolver (project-resolver.ts): resolveProject(cwd) → owning project + optional worktree. Rules: nearest declared root → git toplevel → cwd. Worktrees roll up to the main repo (tagged with branch); declared non-git workspaces (mygroup/ of sibling repos) become the project. Git-injected → pure & testable.
  • Registry: declared-root support (sticky), worktree/sub-repo child rows, registerResolvedProject.
  • Session attribution: sessions carry projectCwd + worktree; operational cwd preserved for git ops.
  • History → resolver (Phase 1): history now keys on the resolved owning project, nested history/{project}/{worktree?}/{slug}/; reader == writer.
  • Global history (Phase 3): listAllHistory() + GET /daemon/history.

Frontend

  • Session-tree builder (Phase 2): pure buildSessionTree(projects, sessions)project → worktree → session, orphan-safe.
  • Sidebar (Phase 4): regrouped from by-type to project → worktree → session, live-only, active project auto-expanded, worktrees collapsible.
  • Sessions + History view (Phase 4): conjoined Active⇄All + project filter under the project selector, a full-screen mode, and click a history entry to open the saved plan (via annotate-a-file).

Tests

~40 resolver/registry tests (real git/fs integration incl. worktree × declared crossings) + history, tree-builder, history-store, and client tests. Full suite green (1527 pass); typecheck + single-file build clean. Backend verified live through the compiled daemon (worktree rollup, history layout, /daemon/history) and via a real Claude hook → CLI → daemon probe.

Not in this PR / follow-ups

  • The Sessions+History view has passed typecheck/build/tests + adversarial review, but not yet a manual browser click-through — good first review target.
  • Deferred (in project-resolution-followups.md): session-snapshot meta (projectCwd/worktree), the "Add project → declared workspace" UI verification, CLAUDE.md matchKey/history-layout doc updates, and test-hygiene (a couple of tests write to the real history dir).
  • long-running-session-costs.md documents the perf tail of the never-die session model (eviction/virtualization recommended) — informational, not addressed here.

… roots

Resolve a session's owning project from its launch cwd instead of trusting the
transient cwd (agents cd around; launches happen from subdirs/worktrees):

- project-resolver.ts: pure resolveProjectCore(cwd, declaredRoots, gitProbe) —
  (1) nearest declared root at/above cwd, (2) git toplevel, (3) cwd. Worktrees
  roll up to the main repo, tagged with {cwd, branch}. 12 unit tests cover the
  hierarchy + cwd contract VCs (subdir→root, worktree rollup, mygroup/ workspace).
- project-registry.ts: declared-root support (sticky flag), getDeclaredRoots(),
  registerResolvedProject() (owning project + worktree/sub-repo child row).
- session-factory: attribute sessions to the resolved project; keep cwd
  operational; matchKey keyed on operational scope to keep worktrees distinct.
- session record/summary carry projectCwd + worktree; manual /daemon/projects add
  marks the entry declared (supersedes git boundary, enables non-git workspaces).

Backend only — UI tree (project → repo|worktree → session) is the next layer.
23 integration tests that build real git repos, linked + detached worktrees,
nested repos, non-git workspaces of sibling repos, and declared roots on disk,
run the real resolver + registry through every angle, and tear down all temp
environments. Covers: subdir→root normalization, worktree rollup + tags, two
worktrees stay distinct, non-git fallback, nested-repo innermost wins, declared
workspace rollup (mygroup/), nearest-declared-wins, non-ancestor ignored, and
registry persistence (parent+child rows, sticky declared flag, idempotency).
5 more integration cases pinning the context intersections: deep cwd inside a
sub-repo of a declared workspace, deep cwd in a nested repo, a worktree located
inside a declared workspace (owns the workspace, sub-scope = worktree dir), a
deep cwd within that worktree, and a worktree located outside the declared
workspace (ignores the declaration, rolls up to its own main repo).
…odel, history map, followups

- hierarchy rule 4/5: 'worktree' tier generalized to sub-repo-under-declared-root
- core-model-and-project-ux.md: agent→cli(resolveProject)→daemon + project UX goals
- plan-history-usage-map.md: full audit of history readers/writers
- project-resolution-followups.md: open items (history migration, global view, UI tree)
…ayout

History now keys on resolveProject's owning project (threaded into the plan/annotate
servers) and nests worktree history under a sanitized worktree segment:
  ~/.plannotator/history/{project}/{worktreeSeg?}/{slug}/NNN.md
Writer and reader use the identical {project, worktreeSeg} captured per session, so a
version written under a worktree is read back from the same place. The 6 storage fns
gain an optional trailing worktreeSeg (backward-compatible). detectProjectName kept as
the standalone fallback. +8 storage tests; 739 pass, typecheck clean.

Verified live: a plan in a worktree writes history/{repo}/{branch-seg}/{slug}/ and the
version browser reads it back via /api/plan/versions. Closes the history divergence
(core VC1/VC3, cwd VC8 history path).
buildSessionTree(projects, sessions) in packages/ui/utils/sessionTree.ts groups the
live session list by owning project (projectCwd ?? cwd) then by worktree.cwd, seeds
worktrees from registry rows (so zero-session worktrees still show), synthesizes
worktree/project nodes for sessions with no matching row (orphan-safe, never drops a
session), and sorts deterministically (name then cwd; sessions by createdAt then id).
20 unit tests incl. counts-reconcile + duplicate-name + equal-createdAt determinism.
Pure: no React, no I/O. No UI yet.
listAllHistory() in packages/shared/storage.ts walks ~/.plannotator/history and
disambiguates the optional worktree level (a dir with NNN.md files = slug dir; else its
children are slug dirs and it is a worktreeSeg), returning {project, worktree?, slug,
versionCount, latest} per plan. New auth-gated GET /daemon/history (optional ?project=)
returns the index. Integration test covers flat + worktree-nested layouts.

Verified live: enumerates 4132 real entries (114 worktree-nested) without error; 401
without a token. Closes core VC4 (cross-project history / the old archive's browse job).
…) + state plan

7 facts: launcher unchanged; sidebar regroups by project->worktree->session (live-only,
all projects, active expanded/others collapsed-expandable); active project = current
session's owning project; history browsable + filterable by project; active+history
conjoined (Active<->All filter) with a full-page history view (Git Dashboard pattern).
State plan: reuse sessions/projects/activeSessionId; derive tree + active project; add
appStore expand-state, a history store, and daemonApiClient.getHistory().
Rewrite AppSidebar from by-mode grouping to a depth-indented, live-only tree built from
buildSessionTree(projects, sessions): projects (default collapsed, active auto-expanded),
worktrees (collapsible, default expanded), sessions (leaf, mode icon, active highlight).
appStore gains expandedProjects (projects) + collapsedWorktrees (worktrees) Sets with
immer; useActiveProjectCwd derives the active project from the current session. Tight
26px rows, depth-based indent (fixes worktree left-drift), subtle chevrons/icons.
Closes hierarchy VC6/VC7. Launcher untouched.
Replace the landing's flat 'Active sessions' list with a conjoined view under the
project selector: Active<->All toggle (default Active) + project filter, plus a
full-screen mode (FullSessionsHistoryView, Git Dashboard carousel pattern). 'All'
lists history (past plans); clicking a history entry opens the saved plan via a new
createAnnotateSession on its file path. Adds daemonApiClient.getHistory()/
createAnnotateSession + HistoryListResponse type/guards, a history-store (clone of the
git-dashboard store), and listAllHistory now returns each entry's latestVersionPath.
Extracts the shared ROW/pad row-style from the sidebar. Review fix: history rows for
untracked/custom-named projects open via the absolute file path (no cwd dependency).
Typecheck + build clean; 1527 tests pass. Closes Phase 4 (with the sidebar).
Audit of the persistent-mount / never-die session model: what stays mounted, the
per-session pollers/subscriptions that keep running while hidden, list re-render churn,
and where it fails at scale (~fine 1-5, degraded 5-10, breaks 10+). Reconciles with
performance/findings.md; recommends eviction + visibility-gating + virtualization +
code-splitting as the survivability path.
Addresses #822 review finding: the resolver's ancestor check (isAtOrUnder)
appends a forward slash, so on Windows a declared root C:\\work\\group never
prefix-matched a session at C:\\work\\group\\repo — declared workspace grouping
silently did nothing and repos fell back to their individual git toplevels.

git --show-toplevel already emits forward slashes on every platform, so normalize
backslashes to forward slashes in norm(); the input cwd and declared roots then
agree with it and the whole comparison chain is correct on Windows. One-function
change. Adds Windows-path regression tests (fail without the fix).
Addresses #822 review finding. The worktree history segment was
sanitizeTag(branch || basename(cwd)) ?? undefined — lossy as a key:
 - a 1-char/unsanitizable branch coalesced to undefined and dropped history into
   the project's FLAT path, mixing with the main checkout and other such worktrees;
 - distinct branches that normalize identically (feat_x and feat-x both -> feat-x)
   merged two worktrees' histories into one folder.

Extract a pure worktreeSegment() that appends a short hash of the worktree's
absolute path (its stable identity) to a readable label, falling back to wt-<hash>
when the label is empty. Always non-empty, never collides. Adds unit tests.

Note: #822 review finding #6 (same-basename projects collide in the landing
filter) is intentionally NOT addressed here — it stems from history being keyed by
project name at the storage layer (same name -> same folder on disk), so it is not
a clean UI-only fix and is out of scope for this change.
@backnotprop backnotprop merged commit 2cb7629 into feat/single-server-runtime May 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant