Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions server/coding-cli/providers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -579,7 +579,7 @@ export const claudeProvider: CodingCliProvider = {

async resolveProjectPath(_filePath: string, meta: ParsedSessionMeta): Promise<string> {
if (!meta.cwd) return 'unknown'
return resolveGitCheckoutRoot(meta.cwd)
return resolveGitRepoRoot(meta.cwd)
},

extractSessionId(filePath: string): string {
Expand Down
5 changes: 5 additions & 0 deletions server/coding-cli/session-indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions server/coding-cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export interface CodingCliSession {
provider: CodingCliProviderName
sessionId: string
projectPath: string
checkoutPath?: string
lastActivityAt: number
createdAt?: number
archived?: boolean
Expand Down
2 changes: 2 additions & 0 deletions server/session-directory/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions shared/read-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
16 changes: 16 additions & 0 deletions shared/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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]
Expand Down Expand Up @@ -168,6 +171,7 @@ export type LocalSettings = {
}
sidebar: {
sortMode: SidebarSortMode
worktreeGrouping: WorktreeGrouping
showProjectBadges: boolean
showSubagents: boolean
ignoreCodexSubagents: boolean
Expand Down Expand Up @@ -257,6 +261,10 @@ function mergeRecordOfObjects<T extends Record<string, unknown>>(
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'
Expand Down Expand Up @@ -423,6 +431,9 @@ function normalizeExtractedLocalSeed(patch: Record<string, unknown>): 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
}
Expand Down Expand Up @@ -675,6 +686,7 @@ export const defaultLocalSettings: LocalSettings = {
},
sidebar: {
sortMode: 'activity',
worktreeGrouping: 'repo',
showProjectBadges: true,
showSubagents: false,
ignoreCodexSubagents: true,
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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']
}
Expand Down
15 changes: 15 additions & 0 deletions src/components/settings/WorkspaceSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from 'react'
import { KEYBOARD_SHORTCUTS } from '@/lib/keyboard-shortcuts'
import type {
SidebarSortMode,
WorktreeGrouping,
SessionOpenMode,
TabAttentionStyle,
AttentionDismiss,
Expand Down Expand Up @@ -59,6 +60,20 @@ export default function WorkspaceSettings({
</select>
</SettingsRow>

<SettingsRow label="Worktree grouping">
<select
value={settings.sidebar?.worktreeGrouping || 'repo'}
onChange={(e) => {
const v = e.target.value as WorktreeGrouping
applyLocalSetting({ sidebar: { worktreeGrouping: v } })
}}
className="h-10 w-full px-3 text-sm bg-muted border-0 rounded-md focus:outline-none focus:ring-1 focus:ring-border md:h-8 md:w-auto"
>
<option value="repo">Repository</option>
<option value="worktree">Worktree</option>
</select>
</SettingsRow>

<SettingsRow label="Show project badges">
<Toggle
checked={settings.sidebar?.showProjectBadges ?? true}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ export type SearchResult = {
sessionId: string
provider: CodingCliProviderName
projectPath: string
checkoutPath?: string
title?: string
summary?: string
sessionType?: string
Expand Down Expand Up @@ -329,6 +330,7 @@ function groupDirectoryItemsAsProjects(items: ReadModelSessionDirectoryItem[]) {
provider: item.provider,
sessionId: item.sessionId,
projectPath: item.projectPath,
...(item.checkoutPath ? { checkoutPath: item.checkoutPath } : {}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve checkoutPath in sidebar search results

The new worktree grouping depends on checkoutPath, but this change only threads that field through the snapshot grouping path; searchSessions() still maps directory items into SearchResult without checkoutPath, so when a sidebar query is active sessions revert to repo-level subtitles/grouping even if sidebar.worktreeGrouping is set to 'worktree'. Please carry checkoutPath through the search response and searchResultsToProjects so filtered and unfiltered sidebar views behave consistently.

Useful? React with 👍 / 👎.

lastActivityAt: item.lastActivityAt,
createdAt: item.createdAt,
archived: item.archived,
Expand Down
17 changes: 12 additions & 5 deletions src/store/selectors/sidebarSelectors.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -44,6 +44,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
Expand All @@ -64,7 +65,8 @@ export function buildSessionItems(
tabs: RootState['tabs']['tabs'],
panes: RootState['panes'],
terminals: BackgroundTerminal[],
sessionActivity: Record<string, number>
sessionActivity: Record<string, number>,
worktreeGrouping: WorktreeGrouping = 'repo',
): SidebarSessionItem[] {
const items: SidebarSessionItem[] = []
const runningSessionMap = new Map<string, { terminalId: string; createdAt: number; allTerminalIds: string[] }>()
Expand Down Expand Up @@ -103,15 +105,18 @@ 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,
provider,
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,
Expand Down Expand Up @@ -414,6 +419,7 @@ export const makeSelectSortedSessionItems = () =>
selectPanes,
selectSessionActivityForSort,
selectSortMode,
selectWorktreeGrouping,
selectShowSubagents,
selectIgnoreCodexSubagents,
selectShowNoninteractiveSessions,
Expand All @@ -431,6 +437,7 @@ export const makeSelectSortedSessionItems = () =>
panes,
sessionActivity,
sortMode,
worktreeGrouping,
showSubagents,
ignoreCodexSubagents,
showNoninteractiveSessions,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/store/sessionsThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ function searchResultsToProjects(results: Awaited<ReturnType<typeof searchSessio
provider: result.provider,
sessionId: result.sessionId,
projectPath: result.projectPath,
...(result.checkoutPath ? { checkoutPath: result.checkoutPath } : {}),
lastActivityAt: result.lastActivityAt,
createdAt: result.createdAt,
archived: result.archived,
Expand Down
3 changes: 3 additions & 0 deletions src/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
TerminalRendererMode,
TerminalTheme,
TabAttentionStyle,
WorktreeGrouping,
} from '@shared/settings'
import type { CodingCliProviderName, TokenSummary } from '@shared/ws-protocol'
export type { CodingCliProviderName }
Expand Down Expand Up @@ -81,6 +82,7 @@ export interface CodingCliSession {
sessionType?: string
sessionId: string
projectPath: string
checkoutPath?: string
createdAt?: number
lastActivityAt: number
messageCount?: number
Expand Down Expand Up @@ -134,6 +136,7 @@ export type {
TabAttentionStyle,
TerminalRendererMode,
TerminalTheme,
WorktreeGrouping,
}

export type AppSettings = ResolvedSettings
Expand Down
64 changes: 64 additions & 0 deletions test/unit/client/store/selectors/sidebarSelectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,70 @@ describe('sidebarSelectors', () => {
})
})

describe('worktree grouping', () => {
const emptyTabs: [] = []
const emptyPanes = { layouts: {} } as any
const emptyTerminals: [] = []
const emptyActivity: Record<string, number> = {}

function makeProject(sessions: Partial<CodingCliSession>[], 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')
Expand Down
Loading