diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts index fcd18086abce..f4ca8424d2f2 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts @@ -494,6 +494,29 @@ describe('liveSessionProjectId', () => { expect(id).toBe('p_app') }) + + it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => { + // The bug: a fresh Windows session drops into the overlay before the next + // backend refresh; case-sensitive matching missed its project until then. + const id = liveSessionProjectId(makeSession('c:/work/notes/SUB'), [makeProject('p_notes', ['C:\\Work\\Notes'])]) + + expect(id).toBe('p_notes') + }) + + it('matches a root-relative WSL cwd (single backslash) case-insensitively', () => { + const id = liveSessionProjectId(makeSession('//wsl.localhost/Ubuntu/home/alice/PROJ'), [ + makeProject('p_proj', ['\\wsl.localhost\\Ubuntu\\home\\alice\\proj']) + ]) + + expect(id).toBe('p_proj') + }) + + it('keeps POSIX cwd matching case-sensitive (no false project match)', () => { + // Distinct case on POSIX is a distinct path → falls back to its own auto id. + expect(liveSessionProjectId(makeSession('/work/notes'), [makeProject('p_notes', ['/Work/Notes'])])).toBe( + '/work/notes' + ) + }) }) describe('overlayLiveLanes', () => { diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts index 899a59e69793..04e18f7c7b95 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -73,6 +73,27 @@ const segments = (path: string): string[] => /** A path with trailing separators stripped, for stable equality checks. */ const normalizePath = (path: null | string | undefined): string => (path ?? '').replace(/[/\\]+$/, '') +// Windows spellings: drive-letter (`C:\…`), UNC (`\\srv`, `//srv`), or any +// backslash-rooted path (`\wsl.localhost\…`). A single leading `/` stays POSIX. +// Mirrors the backend `_is_windows_path` so the live overlay places rows into +// the same project the backend tree would. +const isWindowsPath = (path: string): boolean => + /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\') || path.startsWith('//') + +/** + * Segments for identity comparison: Windows paths fold case (and separators, via + * {@link segments}) so `C:\Work` and `c:/work` are one lane; POSIX stays + * case-sensitive. Comparison-only — emitted ids/labels keep their spelling. + */ +const comparisonSegments = (path: string): string[] => { + const segs = segments(path) + + return isWindowsPath(path) ? segs.map(seg => seg.toLowerCase()) : segs +} + +/** Canonical per-host comparison key (separator/case/trailing-slash agnostic). */ +const pathKey = (path: null | string | undefined): string => comparisonSegments(path ?? '').join('/') + /** Last path segment. */ export const baseName = (path: string): string | undefined => segments(path).pop() @@ -317,8 +338,8 @@ export function mergeRepoWorktreeGroups( /** True when `target` equals `folder` or is nested under it (segment-wise). */ function isPathUnder(folder: string, target: string): boolean { - const f = segments(folder) - const t = segments(target) + const f = comparisonSegments(folder) + const t = comparisonSegments(target) if (!f.length || f.length > t.length) { return false @@ -347,9 +368,8 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro // No persisted repo root yet (brand-new session) → the cwd is the root. const repoRoot = (session.git_repo_root || '').trim() || cwd - const underRepo = cwd === repoRoot || cwd.startsWith(`${repoRoot}/`) || cwd.startsWith(`${repoRoot}\\`) - if (!underRepo) { + if (!isPathUnder(repoRoot, cwd)) { return null } @@ -423,7 +443,7 @@ export function overlayRepoLanes( live: SessionInfo[], removed: ReadonlySet = NO_REMOVED ): SidebarWorkspaceTree { - const repoRoot = normalizePath(repo.path) + const repoRootKey = pathKey(repo.path) let changed = false // Snapshot lanes minus anything the user just deleted/archived. @@ -457,7 +477,7 @@ export function overlayRepoLanes( for (const g of lanes) { const lanePath = normalizePath(g.path) - if (!lanePath || lanePath === repoRoot || !isPathUnder(lanePath, cwd)) { + if (!lanePath || pathKey(lanePath) === repoRootKey || !isPathUnder(lanePath, cwd)) { continue } @@ -480,14 +500,14 @@ export function overlayRepoLanes( continue } - const placedPath = normalizePath(placed.path) + const placedKey = pathKey(placed.path) lane = lanes.find(g => g.id === placed.id) ?? (placed.isMain ? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase()) : undefined) ?? - (!placed.isMain && placedPath ? lanes.find(g => normalizePath(g.path) === placedPath) : undefined) + (!placed.isMain && placedKey ? lanes.find(g => pathKey(g.path) === placedKey) : undefined) if (!lane) { lane = { ...placed, sessions: [] } diff --git a/tests/tui_gateway/test_project_tree.py b/tests/tui_gateway/test_project_tree.py index 5608f2c26259..cd0424c5d5a5 100644 --- a/tests/tui_gateway/test_project_tree.py +++ b/tests/tui_gateway/test_project_tree.py @@ -237,6 +237,31 @@ def test_windows_path_identity_preserves_explicit_project_priority(): assert tree["scoped_session_ids"] == [session["id"]] +def test_wsl_localhost_cwds_collapse_into_one_auto_project(): + # Root-relative WSL spellings (single leading backslash) are Windows paths, + # so case/separator variants collapse instead of spawning duplicate autos. + sessions = [ + _session(r"\wsl.localhost\Ubuntu\home\alice\proj"), + _session("//wsl.localhost/Ubuntu/home/alice/PROJ"), + ] + + tree = pt.build_tree([], sessions, [], resolve=lambda _cwd: None, hydrate=True) + + assert len(tree["projects"]) == 1 + assert tree["projects"][0]["sessionCount"] == 2 + + +def test_wsl_localhost_path_cannot_bypass_explicit_project(): + explicit = _project("p_proj", "Proj", [r"\wsl.localhost\Ubuntu\home\alice\proj"]) + session = _session("//wsl.localhost/Ubuntu/home/alice/PROJ/") + + tree = pt.build_tree([explicit], [session], [], resolve=lambda _cwd: None, hydrate=True) + + assert [p["id"] for p in tree["projects"]] == ["p_proj"] + assert tree["projects"][0]["sessionCount"] == 1 + assert tree["scoped_session_ids"] == [session["id"]] + + def test_posix_path_identity_remains_case_sensitive(): explicit = _project("p_notes", "Notes", ["/Work/Notes"]) session = _session("/work/notes") diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index bb18473dc651..3f243153964c 100644 --- a/tui_gateway/project_tree.py +++ b/tui_gateway/project_tree.py @@ -62,7 +62,10 @@ def _segments(path: str) -> list[str]: def _is_windows_path(path: str) -> bool: value = (path or "").strip() - return bool(re.match(r"^[A-Za-z]:[/\\]", value)) or value.startswith(("\\\\", "//")) + # Drive-letter (`C:\…`), UNC (`\\srv`, `//srv`), or any backslash-rooted path + # — the root-relative `\wsl.localhost\…` / `\Users\…` spellings included. A + # single leading `/` stays POSIX (case-sensitive). + return bool(re.match(r"^[A-Za-z]:[/\\]", value)) or value.startswith(("\\", "//")) def _comparison_segments(path: str) -> list[str]: