fix(desktop): resolve tab title and project color for sessions outside the recents page

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-22 21:46:15 -05:00
parent 35cdc63ca0
commit bf21ba08e3
4 changed files with 102 additions and 17 deletions

View file

@ -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<SessionTile>({
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,

View file

@ -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.

View file

@ -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
}
/**

View file

@ -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
)
}