@@ -268,6 +268,119 @@ function getProjectPathFromSlug(slugDir: string): string | null {
268268 return result ;
269269}
270270
271+ // ── Child slug directory discovery ───────────────────────────
272+
273+ /**
274+ * Find child session directories (subfolders) for a parent project.
275+ * Uses slug prefix as a fast filter, then verifies via path comparison.
276+ * Relies on slugToPathCache being warm (populated by discoverProjects).
277+ */
278+ export function findChildSlugDirs ( parentPath : string , excludePaths ?: Set < string > ) : Array < {
279+ slugDir : string ; childPath : string ; relativePath : string ;
280+ } > {
281+ const claudeDir = getClaudeProjectsDir ( ) ;
282+ if ( ! fs . existsSync ( claudeDir ) ) return [ ] ;
283+
284+ const parentSlug = getProjectSlug ( parentPath ) ;
285+ const parentNorm = normalizePathForCompare ( parentPath ) ;
286+ const sep = path . sep . toLowerCase ( ) ;
287+ const results : Array < { slugDir : string ; childPath : string ; relativePath : string } > = [ ] ;
288+
289+ let entries : fs . Dirent [ ] ;
290+ try {
291+ entries = fs . readdirSync ( claudeDir , { withFileTypes : true } ) ;
292+ } catch {
293+ return [ ] ;
294+ }
295+
296+ for ( const entry of entries ) {
297+ if ( ! entry . isDirectory ( ) ) continue ;
298+ // Fast filter: slug must start with parentSlug + '-'
299+ if ( ! entry . name . startsWith ( parentSlug + '-' ) ) continue ;
300+ // Skip the parent itself
301+ if ( entry . name === parentSlug ) continue ;
302+
303+ const slugDir = path . join ( claudeDir , entry . name ) ;
304+ const childPath = getProjectPathFromSlug ( slugDir ) ;
305+ if ( ! childPath ) continue ;
306+
307+ // Verify child is actually under parent (prevents false positives like CageMetrics-v2)
308+ const childNorm = normalizePathForCompare ( childPath ) ;
309+ if ( ! childNorm . startsWith ( parentNorm + sep ) ) continue ;
310+
311+ // Skip pinned subfolders
312+ if ( excludePaths ) {
313+ const childKey = normalizePathForCompare ( childPath ) ;
314+ if ( excludePaths . has ( childKey ) ) continue ;
315+ }
316+
317+ const relativePath = path . relative ( parentPath , childPath ) ;
318+ results . push ( { slugDir, childPath, relativePath } ) ;
319+ }
320+
321+ results . sort ( ( a , b ) => a . relativePath . localeCompare ( b . relativePath ) ) ;
322+ return results ;
323+ }
324+
325+ // ── Name disambiguation ─────────────────────────────────────
326+
327+ /**
328+ * When multiple projects share the same display name, append the
329+ * distinguishing parent folder so they're visually distinct.
330+ * e.g. two "CageMetrics" → "CageMetrics" and "CageMetrics/data"
331+ */
332+ function disambiguateNames ( projects : Project [ ] ) : void {
333+ // Group by lowercase name to find collisions
334+ const byName = new Map < string , Project [ ] > ( ) ;
335+ for ( const p of projects ) {
336+ const key = p . name . toLowerCase ( ) ;
337+ const group = byName . get ( key ) ;
338+ if ( group ) group . push ( p ) ;
339+ else byName . set ( key , [ p ] ) ;
340+ }
341+
342+ for ( const group of byName . values ( ) ) {
343+ if ( group . length < 2 ) continue ;
344+ // Append the last unique path segment to each duplicate
345+ for ( const p of group ) {
346+ const parts = p . path . replace ( / \\ / g, '/' ) . split ( '/' ) . filter ( Boolean ) ;
347+ // Use last 2 segments: "parent/child" (or just folder name if only 1 segment)
348+ const suffix = parts . length >= 2
349+ ? `${ parts [ parts . length - 2 ] } /${ parts [ parts . length - 1 ] } `
350+ : parts [ parts . length - 1 ] || p . path ;
351+ p . name = suffix ;
352+ }
353+ }
354+ }
355+
356+ // ── Nested project filtering ────────────────────────────────
357+
358+ /**
359+ * Remove non-pinned projects whose path is a strict subdirectory of another
360+ * project in the list. Mutates the array in place.
361+ *
362+ * Projects can arrive from three sources (pinned, discovered, indexed) in any
363+ * order, so per-source checks miss cross-source nesting (e.g., a discovered
364+ * child session dir + an indexed parent repo). This single final pass handles
365+ * all combinations.
366+ */
367+ function filterNestedProjects ( projects : Project [ ] ) : void {
368+ const sep = path . sep . toLowerCase ( ) ;
369+ const allPaths = new Set ( projects . map ( p => normalizePathForCompare ( p . path ) ) ) ;
370+
371+ for ( let i = projects . length - 1 ; i >= 0 ; i -- ) {
372+ if ( projects [ i ] . pinned ) continue ; // pinned subfolders stay standalone
373+ const key = normalizePathForCompare ( projects [ i ] . path ) ;
374+ for ( const other of allPaths ) {
375+ if ( other !== key && key . startsWith ( other + sep ) ) {
376+ projects . splice ( i , 1 ) ;
377+ allPaths . delete ( key ) ;
378+ break ;
379+ }
380+ }
381+ }
382+ }
383+
271384// ── List building ───────────────────────────────────────────
272385
273386/**
@@ -322,18 +435,11 @@ export function buildProjectList(config: Config): Project[] {
322435 }
323436
324437 // Add indexed projects (from previous filesystem scans) — these may not have Claude sessions yet
325- // Skip entries that are subdirectories of already-seen projects (stale index data)
326438 const indexed = readProjectIndex ( ) ;
327439 for ( const entry of indexed ) {
328440 const key = normalizePathForCompare ( entry . path ) ;
329441 if ( seenPaths . has ( key ) ) continue ;
330442 if ( hiddenSet . has ( key ) ) continue ;
331- // Skip if this path is a child of an already-seen project
332- let isSubdir = false ;
333- for ( const seen of seenPaths ) {
334- if ( key . startsWith ( seen + path . sep . toLowerCase ( ) ) ) { isSubdir = true ; break ; }
335- }
336- if ( isSubdir ) continue ;
337443 seenPaths . add ( key ) ;
338444
339445 let displayName : string ;
@@ -349,6 +455,14 @@ export function buildProjectList(config: Config): Project[] {
349455 } ) ;
350456 }
351457
458+ // Final pass: remove non-pinned projects whose path is a subdirectory of another project.
459+ // This catches cross-source nesting (e.g., discovered child + indexed parent) regardless
460+ // of which source added each project or in what order.
461+ filterNestedProjects ( projects ) ;
462+
463+ // Disambiguate duplicate display names by appending parent folder
464+ disambiguateNames ( projects ) ;
465+
352466 // Persist name cache for fast mini TUI lookups
353467 try { writeProjectNameCache ( nameCache ) ; } catch { }
354468
@@ -409,17 +523,11 @@ export function buildProjectListFast(config: Config): Project[] {
409523 }
410524
411525 // Add indexed projects (from previous filesystem scans)
412- // Skip entries that are subdirectories of already-seen projects
413526 const indexed = readProjectIndex ( ) ;
414527 for ( const entry of indexed ) {
415528 const key = normalizePathForCompare ( entry . path ) ;
416529 if ( seenPaths . has ( key ) ) continue ;
417530 if ( hiddenSet . has ( key ) ) continue ;
418- let isSubdir = false ;
419- for ( const seen of seenPaths ) {
420- if ( key . startsWith ( seen + path . sep . toLowerCase ( ) ) ) { isSubdir = true ; break ; }
421- }
422- if ( isSubdir ) continue ;
423531 seenPaths . add ( key ) ;
424532
425533 const displayName = nameCache [ entry . path ] ?? entry . name ;
@@ -437,6 +545,12 @@ export function buildProjectListFast(config: Config): Project[] {
437545 } ) ;
438546 }
439547
548+ // Final pass: remove non-pinned projects whose path is a subdirectory of another project.
549+ filterNestedProjects ( projects ) ;
550+
551+ // Disambiguate duplicate display names by appending parent folder
552+ disambiguateNames ( projects ) ;
553+
440554 if ( cacheUpdated ) {
441555 try { writeProjectNameCache ( nameCache ) ; } catch { }
442556 }
0 commit comments