# How the Pi‑Web Project Sidebar Implements Worktree‑Aware Grouping

> Discover how the Pi-Web sidebar uses worktree-aware grouping to unify sessions from main checkouts and linked worktrees into a single project view.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: internals
- Published: 2026-07-26

---

**The Pi‑Web sidebar treats every Git worktree that belongs to the same repository as a single project, grouping sessions from the main checkout and linked worktrees under one unified view.**

In the `agegr/pi-web` repository, the sidebar component ([`components/SessionSidebar.tsx`](https://github.com/agegr/pi-web/blob/main/components/SessionSidebar.tsx)) solves the fragmentation problem that occurs when developers use multiple Git worktrees. Instead of scattering sessions across isolated directory entries, the UI automatically detects when directories belong to the same repository and aggregates them into a single project node. This worktree-aware grouping relies on repository‑root resolution, cached worktree metadata, and hierarchical session trees.

## Resolving the Project Root from Any Directory

The sidebar must determine which project a session belongs to before it can group items. Because a user might open a file inside a linked worktree rather than the main repository, the component uses a resolution strategy that prioritizes Git metadata over simple path matching.

### The `projectRootFor` Helper

At the core of this logic is the `projectRootFor` callback defined around lines 73‑80 in [`components/SessionSidebar.tsx`](https://github.com/agegr/pi-web/blob/main/components/SessionSidebar.tsx). This function accepts a `cwd` (current working directory) and returns the canonical project root:

```typescript
const projectRootFor = useCallback(
  (cwd: string | null) => {
    if (!cwd) return null;
    // Prefer cached worktree data
    if (worktreeState && worktreeState.forCwd === cwd) 
      return worktreeState.projectRoot;
    // Fallback: any session that matches this cwd
    const match = allSessions.find((s) => s.cwd === cwd);
    return match?.projectRoot ?? cwd;
  },
  [worktreeState, allSessions]
);

```

The helper first checks `worktreeState`, a piece of state that stores the resolved `projectRoot` for the currently selected directory. If the cache misses, it scans `allSessions` for a session whose `cwd` matches, returning that session’s `projectRoot` or falling back to the original directory.

### Fallback Session Matching

When no worktree cache exists—such as during initial load—the sidebar iterates through the session list to find any entry located in the target directory. This ensures that even before the async worktree discovery completes, the UI can make an educated guess about project affiliation based on previously recorded session metadata.

## Filtering Sessions by Project Identity

Once the sidebar identifies the active project root, it filters the global session list to show only relevant items. The filtering logic at lines 224‑226 compares each session’s `projectRoot` (or its fallback `cwd`) against the resolved project:

```typescript
const selectedProject = projectRootFor(selectedCwd);
const filteredSessions = selectedProject
  ? allSessions.filter((s) => (s.projectRoot ?? s.cwd) === selectedProject)
  : allSessions;

```

This comparison is strictly equality‑based; because all worktrees of the same repository share an identical `projectRoot` value (the main repository’s top‑level directory), sessions from `/repo/main`, `/repo/feature-a`, and `/repo/hotfix-b` naturally satisfy the same predicate and appear together.

## Building the Hierarchical Session Tree

Raw session lists are flat, but the sidebar renders a collapsible tree. The component transforms the filtered array via `buildSessionTree()`, located approximately lines 93‑135 in [`components/SessionSidebar.tsx`](https://github.com/agegr/pi-web/blob/main/components/SessionSidebar.tsx).

### Parent‑Child Relationships

The function constructs a hierarchy by traversing `parentSessionId` chains. Sessions that spawn child processes (such as terminals created from within an existing editor session) nest underneath their logical parent rather than cluttering the top level.

### Sorting by Modification Date

After tree construction, each level is sorted descending by the most recent modification timestamp. This keeps active sessions visible at the top of the project group while preserving the logical parent‑child structure.

## Discovering Git Worktrees

Worktree awareness requires accurate Git metadata. When the selected directory changes, the sidebar fetches data from the `/api/worktrees?cwd=...` endpoint, implemented in the [`lib/worktree.ts`](https://github.com/agegr/pi-web/blob/main/lib/worktree.ts) module.

### Listing Linked Worktrees

The `listWorktrees` function (lines 32‑66) executes `git worktree list --porcelain` and parses the output into structured `WorktreeInfo` objects:

```typescript
export async function listWorktrees(cwd: string): Promise<WorktreeInfo[]> {
  const out = await git(cwd, ["worktree", "list", "--porcelain"]);
  const worktrees: WorktreeInfo[] = [];
  let current: (Partial<WorktreeInfo> & { prunable?: boolean }) | null = null;
  // …parse porcelain format and push non‑prunable entries…
  return worktrees;
}

```

This list includes paths to linked worktrees, their branch refs, and whether they are prunable (bare checkouts), allowing the UI to present valid switch targets.

### Resolving Repository Metadata

To determine whether the current directory sits inside a worktree versus the main checkout, [`lib/worktree.ts`](https://github.com/agegr/pi-web/blob/main/lib/worktree.ts) exposes `resolveProject`. It runs `git rev-parse --git-common-dir --git-dir --show-toplevel` and compares paths:

```typescript
export async function resolveProject(cwd: string): Promise<ProjectInfo> {
  const out = await git(cwd, [
    "rev-parse", "--path-format=absolute",
    "--git-common-dir", "--git-dir", "--show-toplevel",
    "--abbrev-ref", "HEAD",
  ]);
  const [commonDir, gitDir, toplevel, ref] = out.split("\n");
  const isTopLevel = toplevel === realpathSync(cwd);
  const isWorktreeTopLevel = gitDir !== commonDir && isTopLevel;
  return {
    projectRoot: isWorktreeTopLevel ? dirname(commonDir) : cwd,
    branch: ref && ref !== "HEAD" ? ref : null,
    isWorktree: isWorktreeTopLevel,
    isTopLevel,
  };
}

```

If `gitDir` differs from `commonDir`, Git has placed the directory in a linked worktree, and the function returns the parent of the common Git directory as the canonical `projectRoot`.

## Conditional Worktree Switcher UI

The sidebar renders a dropdown that lets users switch between the main checkout and any linked worktrees, but only when three conditions align. The `showWorktreeSwitcher` boolean (lines 226‑231) guards the render:

```typescript
const showWorktreeSwitcher = Boolean(
  worktreeState?.isGit &&
  worktreeState?.isTopLevel &&
  selectedCwd &&
  selectedProject === worktreeState.projectRoot
);

```

The dropdown appears only when the current directory is a Git repository, sits at the worktree’s top level (not a subdirectory), and matches the resolved project root. This prevents the switcher from cluttering the UI when the user browses subfolders or non‑Git directories.

## Synchronizing State Across Worktree Operations

Creating or removing worktrees requires immediate UI synchronization to maintain the grouping illusion.

### Optimistic Creation

When a user triggers `handleCreateWorktree`, the component optimistically appends the new worktree path to `worktreeState.worktrees` (lines 630‑638). This allows `projectRootFor()` to resolve the new directory instantly without waiting for a network round‑trip, preventing flicker or "unrecognized project" states.

### Removal Handling

If a worktree is deleted, the component increments a refresh key (`setWtRefreshKey`) to re‑fetch the worktree list. Should the removed worktree be the current `cwd`, the sidebar automatically switches `selectedCwd` back to the main repository root (lines 669‑670), ensuring the user never stares at a orphaned directory reference.

## Summary

- **Single Project View:** The sidebar groups sessions by `projectRoot`, ensuring all worktrees of the same repository appear under one heading.
- **Root Resolution:** The `projectRootFor` helper prioritizes cached worktree data from [`lib/worktree.ts`](https://github.com/agegr/pi-web/blob/main/lib/worktree.ts) and falls back to session metadata.
- **Tree Construction:** `buildSessionTree` organizes filtered sessions into parent‑child hierarchies sorted by modification time.
- **Conditional UI:** The worktree switcher renders only at repository top‑levels when `isGit`, `isTopLevel`, and project alignment are satisfied.
- **State Sync:** Optimistic updates and automatic fallback handling keep the sidebar responsive during worktree lifecycle events.

## Frequently Asked Questions

### What is Git worktree grouping in pi-web?

Git worktree grouping refers to the sidebar’s behavior of treating multiple linked worktrees (created via `git worktree add`) as a single project node. According to the `agegr/pi-web` source code, the [`SessionSidebar.tsx`](https://github.com/agegr/pi-web/blob/main/SessionSidebar.tsx) component achieves this by resolving every directory to its canonical `projectRoot` using `git rev-parse --git-common-dir`, then filtering sessions that share that root. This prevents the UI from fragmenting sessions across separate directory entries when they logically belong to the same codebase.

### How does the sidebar determine the correct project root for a session?

The sidebar uses the `projectRootFor` callback in [`components/SessionSidebar.tsx`](https://github.com/agegr/pi-web/blob/main/components/SessionSidebar.tsx). It first checks the cached `worktreeState` object populated by [`lib/worktree.ts`](https://github.com/agegr/pi-web/blob/main/lib/worktree.ts); if the cache contains a match for the current `cwd`, it returns the stored `projectRoot`. Otherwise, it searches `allSessions` for any entry with a matching `cwd` and uses that entry’s `projectRoot` field, falling back to the raw directory path only when no metadata exists.

### When does the worktree switcher dropdown appear?

The switcher appears only when three conditions evaluate to true: the current directory is inside a Git repository (`worktreeState.isGit`), the directory represents the top‑level of a checkout (`worktreeState.isTopLevel`), and the resolved `selectedProject` matches the cached `projectRoot`. This logic, implemented in the `showWorktreeSwitcher` constant around lines 226‑231, ensures the dropdown is contextually relevant and hidden when browsing subdirectories or non‑Git folders.

### How are sessions organized within the project tree?

After filtering sessions to the current project, the sidebar calls `buildSessionTree()` to construct a hierarchy based on `parentSessionId` relationships. Sessions with no parent appear at the top level, while child sessions nest underneath their progenitors. Each level is then sorted by the session’s last modification timestamp, placing the most recently active items first according to the implementation around lines 93‑135 in [`components/SessionSidebar.tsx`](https://github.com/agegr/pi-web/blob/main/components/SessionSidebar.tsx).