From bf21ba08e330ae995f57ae20fa5ef57733662d14 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 21:46:15 -0500 Subject: [PATCH] fix(desktop): resolve tab title and project color for sessions outside the recents page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a session in a new tab left the tab titled 'Session' with no project-color dot until new activity landed the row in the paginated recents list. Two gaps: 1. tileTitle/tileAccent/tileDragPayload only looked the stored row up in $sessions (the recents page). Sessions opened from a project group are often older than that page, so the lookup missed entirely. Resolve through the project tree as a fallback (tileStoredRow) and re-sync pane titles/accents when $projectTree loads. 2. Even with the row present, liveSessionProjectId returned null for a session whose cwd sits outside its recorded git_repo_root (mid-session relocation / sibling worktree), because the cwd-under-root guard ran before the explicit-project folder match. The backend tree groups such rows under the project; the client now agrees — an explicit folder match is authoritative, only the auto-project (repo root) fallback still needs cwd-under-root confidence. sessionColorFor also computes directly (overrides -> project color) when the row isn't in the $sessionColorById map, which is keyed over $sessions only. --- apps/desktop/src/app/chat/session-tile.tsx | 50 ++++++++++++++++--- .../sidebar/projects/workspace-groups.test.ts | 26 ++++++++++ .../chat/sidebar/projects/workspace-groups.ts | 27 ++++++---- apps/desktop/src/store/session-color.ts | 16 +++++- 4 files changed, 102 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index e9057ab16963..a2de9a3781a3 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -36,6 +36,7 @@ import { sessionTitle } from '@/lib/chat-runtime' import { createComposerAttachmentScope } from '@/store/composer' import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout' import { $activeGatewayProfile } from '@/store/profile' +import { $projectTree } from '@/store/projects' import { sessionAwaitingInput } from '@/store/prompts' import { $gatewayState, @@ -54,6 +55,7 @@ import { type SessionTile, sessionTileDelegate } from '@/store/session-states' +import type { SessionInfo } from '@/types/hermes' import type { SessionDragPayload } from './composer/inline-refs' import { type ComposerScope, ComposerScopeProvider } from './composer/scope' @@ -278,8 +280,41 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string } // Tile -> pane contribution sync (call once from the app root). // --------------------------------------------------------------------------- +/** Resolve a tile's stored row: the recents list first, then the project + * tree. A session opened as a tab from a project group is often older than + * the paginated recents page, so it has no `$sessions` row at all until new + * activity lands it there — resolving through the tree keeps its tab titled + * and tinted instead of a grey "Session" placeholder. */ +export function tileStoredRow(storedSessionId: string): SessionInfo | undefined { + const fromList = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) + + if (fromList) { + return fromList + } + + for (const project of $projectTree.get()) { + for (const repo of project.repos) { + for (const group of repo.groups) { + const row = group.sessions.find(s => sessionMatchesStoredId(s, storedSessionId)) + + if (row) { + return row + } + } + } + + const preview = project.previewSessions?.find(s => sessionMatchesStoredId(s, storedSessionId)) + + if (preview) { + return preview + } + } + + return undefined +} + function tileTitle(storedSessionId: string): string { - const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) + const stored = tileStoredRow(storedSessionId) return stored ? sessionTitle(stored) : 'Session' } @@ -287,12 +322,12 @@ function tileTitle(storedSessionId: string): string { /** The tab's lead-dot color — the tile's session resolved through the SAME * shared map the sidebar reads, so a row and its tab always agree. */ function tileAccent(storedSessionId: string): string | undefined { - return sessionColorFor($sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))) + return sessionColorFor(tileStoredRow(storedSessionId)) } /** The `@session` link payload for a tile tab drag — id + owning profile + title. */ function tileDragPayload(storedSessionId: string): SessionDragPayload { - const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) + const stored = tileStoredRow(storedSessionId) return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) } } @@ -381,9 +416,10 @@ export function SessionTabMenu({ /** Layout-tree pane id — powers the Close-others/right/all verbs. */ tabPaneId: string }) { - const sessions = useStore($sessions) + useStore($sessions) + useStore($projectTree) const pinnedSessionIds = useStore($pinnedSessionIds) - const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId)) + const stored = tileStoredRow(storedSessionId) const pinId = stored ? sessionPinId(stored) : storedSessionId const pinned = pinnedSessionIds.includes(pinId) @@ -440,7 +476,9 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) * `$sessions`). Tiles dock against main on the chosen edge, flex width. */ export const watchSessionTiles = paneMirror({ source: $sessionTiles, - also: [$sessions, $sessionColorById], + // $projectTree: a tile whose session is older than the recents page resolves + // its title/accent through the tree, which loads after the tiles register. + also: [$sessions, $sessionColorById, $projectTree], key: t => t.storedSessionId, prefix: 'session-tile', dir: t => t.dir, 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 9c1711b192a8..9f169b7d8ce2 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 @@ -505,6 +505,24 @@ describe('liveSessionProjectId', () => { expect(id).toBe('p_app') }) + it('places a cwd-outside-root session under an explicit project matching either path', () => { + // A mid-session relocation (or a sibling worktree) leaves cwd outside the + // recorded repo root. An explicit folder match is still authoritative — + // only the auto-project (repo root) fallback needs cwd-under-root + // confidence. Match via the repo root... + expect( + liveSessionProjectId(makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [ + makeProject('p_proj', ['/home/u/proj']) + ]) + ).toBe('p_proj') + // ...and via the cwd. + expect( + liveSessionProjectId(makeSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [ + makeProject('p_www', ['/www/elsewhere']) + ]) + ).toBe('p_www') + }) + 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. @@ -553,6 +571,14 @@ describe('sessionProjectColor', () => { expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff') }) + it('colors a cwd-outside-root session when an explicit project folder matches', () => { + // The backend tree groups such a row under the project; the client color + // derivation must agree instead of leaving the row (and its tab) grey. + const session = makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }) + + expect(sessionProjectColor(session, [colored('p_proj', ['/home/u/proj'], '#4a9eff')])).toBe('#4a9eff') + }) + it('returns null for a session that only maps to an auto repo root (no explicit project)', () => { // liveSessionProjectId falls back to the repo root id, which is not a // project row and therefore carries no color. 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 fc57cb1cd28c..17a085113ddb 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -357,7 +357,11 @@ function isPathUnder(folder: string, target: string): boolean { * the overview at once instead of waiting for the next backend refresh. Returns * null only for sessions we genuinely can't place from the row alone: cwd-less, * kanban-task worktrees (they fold into the kanban bucket), or a worktree that - * lives OUTSIDE the repo root (a sibling dir whose project can't be derived). + * lives OUTSIDE the repo root (a sibling dir) AND under no explicit project + * folder. An explicit-project folder match always places the row — even when + * the row's cwd sits outside its recorded repo root (a mid-session relocation, + * or a sibling worktree of a project repo), the folder match is authoritative; + * only the repo-root AUTO-project fallback needs cwd-under-root confidence. */ export function liveSessionProjectId(session: SessionInfo, explicitProjects: ProjectInfo[]): null | string { const cwd = (session.cwd || '').trim() @@ -372,13 +376,6 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro return null } - // With a cwd present it must sit under the repo root (a sibling worktree - // outside the root can't be placed from the row alone); a root-only session - // skips this — the root IS the anchor. - if (cwd && !isPathUnder(repoRoot, cwd)) { - return null - } - let projectId = '' let bestLen = -1 @@ -399,7 +396,19 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro } } - return projectId || repoRoot + if (projectId) { + return projectId + } + + // AUTO-project fallback (the repo root itself): with a cwd present it must + // sit under the repo root (a sibling worktree outside the root can't be + // placed from the row alone); a root-only session skips this — the root IS + // the anchor. + if (cwd && !isPathUnder(repoRoot, cwd)) { + return null + } + + return repoRoot } /** diff --git a/apps/desktop/src/store/session-color.ts b/apps/desktop/src/store/session-color.ts index 08f5f079448c..3607d1b607fa 100644 --- a/apps/desktop/src/store/session-color.ts +++ b/apps/desktop/src/store/session-color.ts @@ -57,7 +57,19 @@ export const $sessionColorById = computed( ) // The color for a single session object (the tabs already hold the SessionInfo -// they render, so they resolve through the same map the sidebar reads). +// they render, so they resolve through the same map the sidebar reads). A row +// that isn't in `$sessions` — e.g. a project-tree session older than the +// paginated recents page, opened as a tab — misses the map, so fall back to +// resolving it directly through the same precedence the map uses. export function sessionColorFor(session: null | SessionInfo | undefined): string | undefined { - return session ? $sessionColorById.get()[session.id] : undefined + if (!session) { + return undefined + } + + return ( + $sessionColorById.get()[session.id] ?? + $sessionColorOverrides.get()[sessionPinId(session)] ?? + sessionProjectColor(session, $projects.get()) ?? + undefined + ) }