diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index e9057ab16963..e1e149a29d60 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,25 @@ 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 match = (s: SessionInfo) => sessionMatchesStoredId(s, storedSessionId) + + return ( + $sessions.get().find(match) ?? + $projectTree + .get() + .flatMap(p => [...p.repos.flatMap(r => r.groups.flatMap(g => g.sessions)), ...(p.previewSessions ?? [])]) + .find(match) + ) +} + 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 +306,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 +400,12 @@ export function SessionTabMenu({ /** Layout-tree pane id — powers the Close-others/right/all verbs. */ tabPaneId: string }) { - const sessions = useStore($sessions) + // Subscribe for reactivity; the row is read imperatively via tileStoredRow + // (which spans both sources), so the values themselves are unused here. + 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 +462,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/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 43bde9b516fc..7b0d1a1e649c 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -273,7 +273,10 @@ function useSessionActions({ const workItems: ItemSpec[] = [ spec({ disabled: !onBranch, - icon: 'git-branch', + // Fork glyph to match the inline message action's GitFork icon + // (assistant-message.tsx). NB: this codicon font has no `git-fork` + // glyph (only `git-fork-private`); `repo-forked` is the fork icon. + icon: 'repo-forked', label: r.branchFrom, onSelect: () => { triggerHaptic('selection') diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index f2409dd060a5..5188a11f8d26 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -33,6 +33,7 @@ import { setSelectedStoredSessionId, setSessions } from '@/store/session' +import { $sessionTiles } from '@/store/session-states' import { sessionRoute } from '../../routes' import type { ClientSessionState } from '../../types' @@ -975,9 +976,11 @@ describe('resumeSession failure recovery', () => { }) function BranchHarness({ + navigate = vi.fn(), onReady, requestGateway }: { + navigate?: ReturnType onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise) => void requestGateway: (method: string, params?: Record) => Promise }) { @@ -991,7 +994,7 @@ function BranchHarness({ ensureSessionState: () => ({}) as ClientSessionState, getRouteToken: () => 'token', getRoutedStoredSessionId: () => null, - navigate: vi.fn() as never, + navigate: navigate as never, requestGateway, resetViewSync: vi.fn(), runtimeIdByStoredSessionIdRef: ref(new Map()), @@ -1013,9 +1016,48 @@ describe('branchStoredSession desktop source tagging', () => { afterEach(() => { cleanup() setSessions([]) + $sessionTiles.set([]) + setSelectedStoredSessionId(null) vi.restoreAllMocks() }) + it('opens the branch as a new tab and leaves the parent chat selected', async () => { + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.create') { + return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never + } + + return {} as never + }) + + // Parent is the currently-open (primary) chat. + setSessions([storedSession({ id: 'stored-parent', message_count: 1 })]) + setSelectedStoredSessionId('stored-parent') + vi.mocked(getSessionMessages).mockResolvedValue({ + messages: [{ content: 'branch me', role: 'user', timestamp: 1 }], + session_id: 'stored-parent' + } as never) + + const navigate = vi.fn() + let branchStoredSession: ((storedSessionId: string) => Promise) | null = null + render( + (branchStoredSession = branch)} + requestGateway={requestGateway} + /> + ) + await waitFor(() => expect(branchStoredSession).not.toBeNull()) + + await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) + + // The branch opened as its own tab... + expect($sessionTiles.get().some(tile => tile.storedSessionId === 'branch-stored')).toBe(true) + // ...without stealing the primary selection or navigating away from the parent. + expect($selectedStoredSessionId.get()).toBe('stored-parent') + expect(navigate).not.toHaveBeenCalledWith(sessionRoute('branch-stored')) + }) + it('tags desktop branch sessions as desktop sessions', async () => { let createParams: Record | undefined diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 9adb6f7e41a0..e0ca3084cd08 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -1030,7 +1030,8 @@ export function useSessionActions({ ) // Shared fork: create a child session seeded with `branchMessages`, linked to - // `parentStoredId` so it nests under its parent, then make it the active chat. + // `parentStoredId` so it nests under its parent, then open it as its own tab + // and switch to it — the parent chat stays put (mirrors openNewSessionTile). const forkBranch = useCallback( async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise => { creatingSessionRef.current = true @@ -1067,8 +1068,6 @@ export function useSessionActions({ parent ? parent.last_active || parent.started_at : undefined ) ensureSessionState(branched.session_id, routedSessionId) - setActiveSessionId(branched.session_id) - activeSessionIdRef.current = branched.session_id updateSessionState( branched.session_id, state => ({ @@ -1079,9 +1078,6 @@ export function useSessionActions({ }), routedSessionId ) - setSelectedStoredSessionId(routedSessionId) - selectedStoredSessionIdRef.current = routedSessionId - navigate(sessionRoute(routedSessionId)) const runtimeInfo = applyRuntimeInfo(branched.info) patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd) @@ -1090,6 +1086,15 @@ export function useSessionActions({ updateSessionState(branched.session_id, state => ({ ...state, ...runtimeInfo }), routedSessionId) } + // Open the branch as its own tab and switch to it, leaving the parent + // chat exactly where it is. Prime the tile with the create runtime so it + // skips a redundant resume. Do NOT select it as the primary session + // first — openSessionTile no-ops when the id is already primary. + openSessionTile(routedSessionId, 'center') + patchSessionTile(routedSessionId, { runtimeId: branched.session_id }) + revealTreePane(`session-tile:${routedSessionId}`) + broadcastSessionsChanged() + return true } catch (err) { notifyError(err, copy.branchFailed) @@ -1101,16 +1106,7 @@ export function useSessionActions({ }, 0) } }, - [ - activeSessionIdRef, - copy, - creatingSessionRef, - ensureSessionState, - navigate, - requestGateway, - selectedStoredSessionIdRef, - updateSessionState - ] + [copy, creatingSessionRef, ensureSessionState, requestGateway, updateSessionState] ) // Branch the open chat — optionally from a specific message — off its live transcript. diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 8ba225a4bad8..37bd9a8f01c6 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -10,7 +10,7 @@ */ import { useStore } from '@nanostores/react' -import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react' +import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react' import { Codicon } from '@/components/ui/codicon' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu' @@ -178,6 +178,30 @@ export function TreeGroup({ const active = paneFor(activeId) const isEmpty = node.panes.length === 0 + // KEEP-ALIVE: every pane that has been ACTIVE in this zone stays mounted — + // an inactive tab merely hides (visibility), it does not unmount. Remounting + // on every tab switch re-measured and re-scrolled the content from scratch + // (the thread visibly layout-shifted each time a session tab was revisited). + // Lazy on purpose: a pane first mounts when first activated, so a + // boot-restored tab stack doesn't resume every session up front. + const everActivePanesRef = useRef>(new Set()) + + useEffect(() => { + if (!node.minimized && !isEmpty) { + everActivePanesRef.current.add(activeId) + } + + // Prune panes that left the zone (closed / moved to another group), so a + // long-lived zone doesn't pin stale ids forever. + for (const id of everActivePanesRef.current) { + if (!node.panes.includes(id)) { + everActivePanesRef.current.delete(id) + } + } + }) + + const keptPanes = shown.filter(id => id === activeId || everActivePanesRef.current.has(id)) + // ONE header style: the app's compact pane-header. DEFAULT is contextual — // a single pane isn't a "tab", so its header auto-hides; a stack shows its // chips. EXCEPTIONS force a lone pane to keep its header (tab + close X): @@ -478,18 +502,40 @@ export function TreeGroup({ )} - {/* Body: the active pane's contributed content, or the empty zone. */} + {/* Body: the zone's pane content — every kept (ever-active) pane stays + mounted in an absolute layer; only the active one is visible. + `visibility` (not display) keeps the hidden pane's layout box, so + scroll positions and measurements survive the round-trip. */} {!node.minimized && ( -
+
{isEmpty ? (
{/* Same decode primitive as the CONNECTING boot overlay. */}
- ) : active?.render ? ( - {active.render()} ) : ( -
{t.zones.missingPane(activeId)}
+ keptPanes.map(paneId => { + const pane = paneFor(paneId) + const isActive = paneId === activeId + + return ( +
+ {pane?.render ? ( + {pane.render()} + ) : ( + isActive && ( +
+ {t.zones.missingPane(paneId)} +
+ ) + )} +
+ ) + }) )}
)} diff --git a/apps/desktop/src/store/session-color.ts b/apps/desktop/src/store/session-color.ts index 08f5f079448c..9c0871c63916 100644 --- a/apps/desktop/src/store/session-color.ts +++ b/apps/desktop/src/store/session-color.ts @@ -4,7 +4,7 @@ import { sessionProjectColor } from '@/app/chat/sidebar/projects/workspace-group import { Codecs, persistentAtom } from '@/lib/persisted' import { $projects } from '@/store/projects' import { $sessions, sessionPinId } from '@/store/session' -import type { SessionInfo } from '@/types/hermes' +import type { ProjectInfo, SessionInfo } from '@/types/hermes' // Per-session color OVERRIDES — a user-picked color that wins over the inherited // project color (#66565 layer 2). Desktop-local like pins, keyed by the DURABLE @@ -39,13 +39,21 @@ export function setSessionColorOverride(durableId: string, color: null | string) // // Precedence in one place: an explicit per-session override wins over the // inherited project color. Agent-set color (#66565 layer 3) slots in here too. +function resolveSessionColor( + session: SessionInfo, + projects: ProjectInfo[], + overrides: Record +): string | undefined { + return overrides[sessionPinId(session)] ?? sessionProjectColor(session, projects) ?? undefined +} + export const $sessionColorById = computed( [$sessions, $projects, $sessionColorOverrides], (sessions, projects, overrides) => { const map: Record = {} for (const session of sessions) { - const color = overrides[sessionPinId(session)] ?? sessionProjectColor(session, projects) + const color = resolveSessionColor(session, projects, overrides) if (color) { map[session.id] = color @@ -57,7 +65,14 @@ 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 the +// same resolver the map is built from. 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] ?? resolveSessionColor(session, $projects.get(), $sessionColorOverrides.get()) }