From 7901def30163c18dab378cdc19247be91f09c75a Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 15:28:28 -0500 Subject: [PATCH 1/5] feat: add checkoutPath to sessions for worktree-aware grouping Switch Claude provider back to resolveGitRepoRoot (matching Codex and Dan's original intent) so worktree sessions group under the parent repo. Compute checkoutPath separately via resolveGitCheckoutRoot and include it on sessions when it differs from projectPath. This provides both paths: projectPath for repo-level grouping, and checkoutPath for worktree-level grouping. A follow-up commit will add the client-side setting to choose between them. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/coding-cli/providers/claude.ts | 4 ++-- server/coding-cli/session-indexer.ts | 5 +++++ server/coding-cli/types.ts | 1 + server/session-directory/projection.ts | 2 ++ shared/read-models.ts | 1 + src/lib/api.ts | 1 + src/store/types.ts | 1 + 7 files changed, 13 insertions(+), 2 deletions(-) diff --git a/server/coding-cli/providers/claude.ts b/server/coding-cli/providers/claude.ts index 157aee3e..96d76f3b 100644 --- a/server/coding-cli/providers/claude.ts +++ b/server/coding-cli/providers/claude.ts @@ -7,7 +7,7 @@ import { getClaudeHome } from '../../claude-home.js' import type { CodingCliProvider } from '../provider.js' import { normalizeFirstUserMessage, type NormalizedEvent, type ParsedSessionMeta, type TokenSummary } from '../types.js' import { parseClaudeEvent, isMessageEvent, isResultEvent, isToolResultContent, isToolUseContent, isTextContent } from '../../claude-stream-types.js' -import { looksLikePath, isSystemContext, extractFromIdeContext, resolveGitCheckoutRoot } from '../utils.js' +import { looksLikePath, isSystemContext, extractFromIdeContext, resolveGitRepoRoot } from '../utils.js' export type JsonlMeta = { sessionId?: string @@ -579,7 +579,7 @@ export const claudeProvider: CodingCliProvider = { async resolveProjectPath(_filePath: string, meta: ParsedSessionMeta): Promise { if (!meta.cwd) return 'unknown' - return resolveGitCheckoutRoot(meta.cwd) + return resolveGitRepoRoot(meta.cwd) }, extractSessionId(filePath: string): string { diff --git a/server/coding-cli/session-indexer.ts b/server/coding-cli/session-indexer.ts index 981a25e3..c83be728 100644 --- a/server/coding-cli/session-indexer.ts +++ b/server/coding-cli/session-indexer.ts @@ -9,6 +9,7 @@ import { configStore, SessionOverride } from '../config-store.js' import type { CodingCliProvider } from './provider.js' import { makeSessionKey, type CodingCliSession, type CodingCliProviderName, type ProjectGroup } from './types.js' import { sanitizeCodexTaskEventsForTruncatedSnippet } from './providers/codex.js' +import { resolveGitCheckoutRoot } from './utils.js' import { diffProjects } from '../sessions-sync/diff.js' import type { SessionMetadataStore, SessionMetadataEntry } from '../session-metadata-store.js' @@ -530,10 +531,14 @@ export class CodingCliSessionIndexer { ? (meta.lastActivityAt ?? previous?.lastActivityAt ?? createdAt ?? 0) : (meta.lastActivityAt ?? createdAt ?? 0)) + const checkoutRoot = meta.cwd ? await resolveGitCheckoutRoot(meta.cwd) : undefined + const checkoutPath = checkoutRoot && checkoutRoot !== projectPath ? checkoutRoot : undefined + const baseSession: CodingCliSession = { provider: provider.name, sessionId, projectPath, + ...(checkoutPath ? { checkoutPath } : {}), lastActivityAt, createdAt, messageCount: meta.messageCount, diff --git a/server/coding-cli/types.ts b/server/coding-cli/types.ts index 7f586708..4435b293 100644 --- a/server/coding-cli/types.ts +++ b/server/coding-cli/types.ts @@ -166,6 +166,7 @@ export interface CodingCliSession { provider: CodingCliProviderName sessionId: string projectPath: string + checkoutPath?: string lastActivityAt: number createdAt?: number archived?: boolean diff --git a/server/session-directory/projection.ts b/server/session-directory/projection.ts index 0ae24d81..b8ec7d81 100644 --- a/server/session-directory/projection.ts +++ b/server/session-directory/projection.ts @@ -15,6 +15,7 @@ function comparableItemsEqual(a: SessionDirectoryComparableItem, b: SessionDirec a.provider === b.provider && a.sessionId === b.sessionId && a.projectPath === b.projectPath && + a.checkoutPath === b.checkoutPath && a.title === b.title && a.summary === b.summary && a.lastActivityAt === b.lastActivityAt && @@ -33,6 +34,7 @@ export function toSessionDirectoryComparableItem(session: CodingCliSession): Ses provider: session.provider, sessionId: session.sessionId, projectPath: session.projectPath, + checkoutPath: session.checkoutPath, title: session.title, summary: session.summary, lastActivityAt: session.lastActivityAt, diff --git a/shared/read-models.ts b/shared/read-models.ts index b4b9c7f6..e58322aa 100644 --- a/shared/read-models.ts +++ b/shared/read-models.ts @@ -41,6 +41,7 @@ export const SessionDirectoryItemSchema = z.object({ sessionId: z.string().min(1), provider: z.string().min(1), projectPath: z.string().min(1), + checkoutPath: z.string().optional(), title: z.string().optional(), summary: z.string().optional(), snippet: z.string().optional(), diff --git a/src/lib/api.ts b/src/lib/api.ts index 6f42c68a..f8d90469 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -329,6 +329,7 @@ function groupDirectoryItemsAsProjects(items: ReadModelSessionDirectoryItem[]) { provider: item.provider, sessionId: item.sessionId, projectPath: item.projectPath, + ...(item.checkoutPath ? { checkoutPath: item.checkoutPath } : {}), lastActivityAt: item.lastActivityAt, createdAt: item.createdAt, archived: item.archived, diff --git a/src/store/types.ts b/src/store/types.ts index 5f69ea0f..9b37b170 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -81,6 +81,7 @@ export interface CodingCliSession { sessionType?: string sessionId: string projectPath: string + checkoutPath?: string createdAt?: number lastActivityAt: number messageCount?: number From 7591fd690ae2e4cccb25ed653cec0a8abfa4b5b8 Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 15:31:08 -0500 Subject: [PATCH 2/5] feat: add worktreeGrouping setting to sidebar settings Add 'repo' | 'worktree' setting (default 'repo') to LocalSettings.sidebar. Include normalizer, default value, merge/resolve support, and a dropdown in WorkspaceSettings UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- shared/settings.ts | 16 ++++++++++++++++ src/components/settings/WorkspaceSettings.tsx | 15 +++++++++++++++ src/store/types.ts | 2 ++ 3 files changed, 33 insertions(+) diff --git a/shared/settings.ts b/shared/settings.ts index bd0b49a6..40dfd917 100644 --- a/shared/settings.ts +++ b/shared/settings.ts @@ -21,6 +21,7 @@ const TAB_ATTENTION_STYLE_VALUES = ['highlight', 'pulse', 'darken', 'none'] as c const ATTENTION_DISMISS_VALUES = ['click', 'type'] as const const SESSION_OPEN_MODE_VALUES = ['tab', 'split'] as const const SIDEBAR_SORT_MODE_VALUES = ['recency', 'recency-pinned', 'activity', 'project'] as const +const WORKTREE_GROUPING_VALUES = ['repo', 'worktree'] as const export const CODEX_SANDBOX_VALUES = ['read-only', 'workspace-write', 'danger-full-access'] as const export const CLAUDE_PERMISSION_MODE_VALUES = ['default', 'plan', 'acceptEdits', 'bypassPermissions'] as const const EXTERNAL_EDITOR_VALUES = ['auto', 'cursor', 'code', 'custom'] as const @@ -50,6 +51,7 @@ const TERMINAL_LOCAL_KEYS = [ const PANES_LOCAL_KEYS = ['snapThreshold', 'iconsOnTabs', 'tabAttentionStyle', 'attentionDismiss', 'sessionOpenMode'] as const const SIDEBAR_LOCAL_KEYS = [ 'sortMode', + 'worktreeGrouping', 'showProjectBadges', 'showSubagents', 'ignoreCodexSubagents', @@ -68,6 +70,7 @@ export type TabAttentionStyle = (typeof TAB_ATTENTION_STYLE_VALUES)[number] export type AttentionDismiss = (typeof ATTENTION_DISMISS_VALUES)[number] export type SessionOpenMode = (typeof SESSION_OPEN_MODE_VALUES)[number] export type SidebarSortMode = (typeof SIDEBAR_SORT_MODE_VALUES)[number] +export type WorktreeGrouping = (typeof WORKTREE_GROUPING_VALUES)[number] export type CodexSandboxMode = (typeof CODEX_SANDBOX_VALUES)[number] export type ClaudePermissionMode = (typeof CLAUDE_PERMISSION_MODE_VALUES)[number] export type ExternalEditor = (typeof EXTERNAL_EDITOR_VALUES)[number] @@ -168,6 +171,7 @@ export type LocalSettings = { } sidebar: { sortMode: SidebarSortMode + worktreeGrouping: WorktreeGrouping showProjectBadges: boolean showSubagents: boolean ignoreCodexSubagents: boolean @@ -257,6 +261,10 @@ function mergeRecordOfObjects>( return merged } +function normalizeWorktreeGrouping(value: unknown): WorktreeGrouping { + return WORKTREE_GROUPING_VALUES.includes(value as WorktreeGrouping) ? (value as WorktreeGrouping) : 'repo' +} + function normalizeLocalSortMode(mode: unknown): SidebarSortMode { if (mode === 'hybrid') { return 'activity' @@ -423,6 +431,9 @@ function normalizeExtractedLocalSeed(patch: Record): LocalSetti if (hasOwn(patch.sidebar, 'sortMode')) { sidebar.sortMode = normalizeLocalSortMode(patch.sidebar.sortMode) } + if (hasOwn(patch.sidebar, 'worktreeGrouping')) { + sidebar.worktreeGrouping = normalizeWorktreeGrouping(patch.sidebar.worktreeGrouping) + } if (typeof patch.sidebar.showProjectBadges === 'boolean') { sidebar.showProjectBadges = patch.sidebar.showProjectBadges as boolean } @@ -675,6 +686,7 @@ export const defaultLocalSettings: LocalSettings = { }, sidebar: { sortMode: 'activity', + worktreeGrouping: 'repo', showProjectBadges: true, showSubagents: false, ignoreCodexSubagents: true, @@ -964,6 +976,7 @@ export function resolveLocalSettings(patch?: LocalSettingsPatch): LocalSettings sidebar: { ...mergeDefined(defaultLocalSettings.sidebar, patch?.sidebar), sortMode: normalizeLocalSortMode(patch?.sidebar?.sortMode), + worktreeGrouping: normalizeWorktreeGrouping(patch?.sidebar?.worktreeGrouping), }, notifications: mergeDefined(defaultLocalSettings.notifications, patch?.notifications), } @@ -998,6 +1011,9 @@ export function mergeLocalSettings(base: LocalSettingsPatch | undefined, patch: if (hasOwn(sidebar, 'sortMode')) { sidebar.sortMode = normalizeLocalSortMode(sidebar.sortMode) } + if (hasOwn(sidebar, 'worktreeGrouping')) { + sidebar.worktreeGrouping = normalizeWorktreeGrouping(sidebar.worktreeGrouping) + } if (Object.keys(sidebar).length > 0) { next.sidebar = sidebar as LocalSettingsPatch['sidebar'] } diff --git a/src/components/settings/WorkspaceSettings.tsx b/src/components/settings/WorkspaceSettings.tsx index 0ecce170..87b5da79 100644 --- a/src/components/settings/WorkspaceSettings.tsx +++ b/src/components/settings/WorkspaceSettings.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from 'react' import { KEYBOARD_SHORTCUTS } from '@/lib/keyboard-shortcuts' import type { SidebarSortMode, + WorktreeGrouping, SessionOpenMode, TabAttentionStyle, AttentionDismiss, @@ -59,6 +60,20 @@ export default function WorkspaceSettings({ + + + + Date: Mon, 30 Mar 2026 15:32:30 -0500 Subject: [PATCH 3/5] feat: client-side grouping by checkoutPath when worktreeGrouping is 'worktree' buildSessionItems now accepts worktreeGrouping parameter and uses session.checkoutPath as the effective project path for subtitle and grouping when the setting is 'worktree'. Default 'repo' mode uses projectPath as before. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/store/selectors/sidebarSelectors.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/store/selectors/sidebarSelectors.ts b/src/store/selectors/sidebarSelectors.ts index cc92dd5d..71a71676 100644 --- a/src/store/selectors/sidebarSelectors.ts +++ b/src/store/selectors/sidebarSelectors.ts @@ -1,6 +1,6 @@ import { createSelector } from '@reduxjs/toolkit' import type { RootState } from '../store' -import type { BackgroundTerminal, CodingCliProviderName } from '../types' +import type { BackgroundTerminal, CodingCliProviderName, WorktreeGrouping } from '../types' import { isValidClaudeSessionId } from '@/lib/claude-session-id' import { collectSessionRefsFromNode, collectSessionRefsFromTabs } from '@/lib/session-utils' import { getAgentChatProviderConfig } from '@/lib/agent-chat-utils' @@ -42,6 +42,7 @@ const selectSessionActivityForSort = (state: RootState) => { if (sortMode !== 'activity') return EMPTY_ACTIVITY return state.sessionActivity?.sessions || EMPTY_ACTIVITY } +const selectWorktreeGrouping = (state: RootState): WorktreeGrouping => state.settings.settings.sidebar?.worktreeGrouping || 'repo' const selectShowSubagents = (state: RootState) => state.settings.settings.sidebar?.showSubagents ?? false const selectIgnoreCodexSubagents = (state: RootState) => state.settings.settings.sidebar?.ignoreCodexSubagents ?? true const selectShowNoninteractiveSessions = (state: RootState) => state.settings.settings.sidebar?.showNoninteractiveSessions ?? false @@ -61,7 +62,8 @@ export function buildSessionItems( tabs: RootState['tabs']['tabs'], panes: RootState['panes'], terminals: BackgroundTerminal[], - sessionActivity: Record + sessionActivity: Record, + worktreeGrouping: WorktreeGrouping = 'repo', ): SidebarSessionItem[] { const items: SidebarSessionItem[] = [] const runningSessionMap = new Map() @@ -100,6 +102,9 @@ export function buildSessionItems( const tabInfo = tabSessionMap.get(key) const ratchetedActivity = sessionActivity[key] const hasTitle = !!session.title + const effectivePath = worktreeGrouping === 'worktree' + ? (session.checkoutPath || project.projectPath) + : project.projectPath items.push({ id: `session-${provider}-${session.sessionId}`, sessionId: session.sessionId, @@ -107,8 +112,8 @@ export function buildSessionItems( sessionType: session.sessionType || provider, title: session.title || session.sessionId.slice(0, 8), hasTitle, - subtitle: getProjectName(project.projectPath), - projectPath: project.projectPath, + subtitle: getProjectName(effectivePath), + projectPath: effectivePath, projectColor: project.color, archived: session.archived, timestamp: session.lastActivityAt, @@ -366,6 +371,7 @@ export const makeSelectSortedSessionItems = () => selectPanes, selectSessionActivityForSort, selectSortMode, + selectWorktreeGrouping, selectShowSubagents, selectIgnoreCodexSubagents, selectShowNoninteractiveSessions, @@ -381,6 +387,7 @@ export const makeSelectSortedSessionItems = () => panes, sessionActivity, sortMode, + worktreeGrouping, showSubagents, ignoreCodexSubagents, showNoninteractiveSessions, @@ -390,7 +397,7 @@ export const makeSelectSortedSessionItems = () => terminals, filter ) => { - const items = buildSessionItems(projects, tabs, panes, terminals, sessionActivity) + const items = buildSessionItems(projects, tabs, panes, terminals, sessionActivity, worktreeGrouping) const visible = filterSessionItemsByVisibility(items, { showSubagents, ignoreCodexSubagents, From 661528d1bd815b0b5a84d3a172f6aaf7420aa0cf Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 15:33:44 -0500 Subject: [PATCH 4/5] test: add worktree grouping tests for sidebar selectors Three tests covering: - Default 'repo' mode uses projectPath for subtitle - 'worktree' mode uses checkoutPath when available - Falls back to projectPath when no checkoutPath in worktree mode Co-Authored-By: Claude Opus 4.6 (1M context) --- .../store/selectors/sidebarSelectors.test.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/test/unit/client/store/selectors/sidebarSelectors.test.ts b/test/unit/client/store/selectors/sidebarSelectors.test.ts index cf2a7da7..b9ae7900 100644 --- a/test/unit/client/store/selectors/sidebarSelectors.test.ts +++ b/test/unit/client/store/selectors/sidebarSelectors.test.ts @@ -358,6 +358,70 @@ describe('sidebarSelectors', () => { }) }) + describe('worktree grouping', () => { + const emptyTabs: [] = [] + const emptyPanes = { layouts: {} } as any + const emptyTerminals: [] = [] + const emptyActivity: Record = {} + + function makeProject(sessions: Partial[], projectPath = '/test/repo', color?: string): ProjectGroup { + return { + projectPath, + color, + sessions: sessions.map((s) => ({ + provider: 'claude' as const, + sessionId: 'sess-1', + projectPath, + lastActivityAt: 1000, + ...s, + })), + } + } + + it('uses projectPath for subtitle in repo mode (default)', () => { + const projects = [ + makeProject([ + { sessionId: 'wt-1', checkoutPath: '/test/repo/.worktrees/feature-a' }, + { sessionId: 'wt-2' }, + ], '/test/repo'), + ] + + const items = buildSessionItems(projects, emptyTabs, emptyPanes, emptyTerminals, emptyActivity) + + expect(items[0].subtitle).toBe('repo') + expect(items[0].projectPath).toBe('/test/repo') + expect(items[1].subtitle).toBe('repo') + expect(items[1].projectPath).toBe('/test/repo') + }) + + it('uses checkoutPath for subtitle in worktree mode', () => { + const projects = [ + makeProject([ + { sessionId: 'wt-1', checkoutPath: '/test/repo/.worktrees/feature-a' }, + { sessionId: 'wt-2' }, + ], '/test/repo'), + ] + + const items = buildSessionItems(projects, emptyTabs, emptyPanes, emptyTerminals, emptyActivity, 'worktree') + + expect(items[0].subtitle).toBe('feature-a') + expect(items[0].projectPath).toBe('/test/repo/.worktrees/feature-a') + expect(items[1].subtitle).toBe('repo') + expect(items[1].projectPath).toBe('/test/repo') + }) + + it('falls back to projectPath when session has no checkoutPath in worktree mode', () => { + const projects = [ + makeProject([{ sessionId: 'no-wt' }], '/test/repo'), + ] + + const items = buildSessionItems(projects, emptyTabs, emptyPanes, emptyTerminals, emptyActivity, 'worktree') + + expect(items[0].subtitle).toBe('repo') + expect(items[0].projectPath).toBe('/test/repo') + }) + }) + describe('sortSessionItems', () => { describe('recency mode', () => { it('sorts by timestamp descending', () => { From 38e3343d4485fe152dfbb5938fd495758763191d Mon Sep 17 00:00:00 2001 From: Matt Leaverton Date: Mon, 30 Mar 2026 15:43:43 -0500 Subject: [PATCH 5/5] fix: thread checkoutPath through search results for worktree grouping Add checkoutPath to SearchResult type and searchResultsToProjects mapping so worktree grouping works consistently when a sidebar search is active. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/api.ts | 1 + src/store/sessionsThunks.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/lib/api.ts b/src/lib/api.ts index f8d90469..ed5393d6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -275,6 +275,7 @@ export type SearchResult = { sessionId: string provider: CodingCliProviderName projectPath: string + checkoutPath?: string title?: string summary?: string sessionType?: string diff --git a/src/store/sessionsThunks.ts b/src/store/sessionsThunks.ts index 101be949..b1194e78 100644 --- a/src/store/sessionsThunks.ts +++ b/src/store/sessionsThunks.ts @@ -68,6 +68,7 @@ function searchResultsToProjects(results: Awaited