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/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({ + + + + { 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 @@ -64,7 +65,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() @@ -103,6 +105,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, @@ -110,8 +115,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, @@ -414,6 +419,7 @@ export const makeSelectSortedSessionItems = () => selectPanes, selectSessionActivityForSort, selectSortMode, + selectWorktreeGrouping, selectShowSubagents, selectIgnoreCodexSubagents, selectShowNoninteractiveSessions, @@ -431,6 +437,7 @@ export const makeSelectSortedSessionItems = () => panes, sessionActivity, sortMode, + worktreeGrouping, showSubagents, ignoreCodexSubagents, showNoninteractiveSessions, @@ -442,7 +449,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, 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 { }) }) + 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('makeSelectSortedSessionItems', () => { it('uses the applied title query to keep only matching fallback rows and rejects ancestor-only matches', () => { const matchingFallback = createFallbackTab('tab-match', 'fallback-match', 'Matching Fallback', '/tmp/local/trycycle')