From 66747f154ccfd36b6df63a3906d47ebefdba3f24 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 22:43:55 -0500 Subject: [PATCH] fix(desktop): auto-expand a lane when starting a session in it, and stabilize sidebar collapse state Clicking '+' on a collapsed worktree/branch lane (or repo) created a session in a folder the user couldn't see. It now force-expands the target node. The root cause was that collapse state was stored as an XOR override of defaultOpen. defaultOpen flips for a worktree lane (collapsed while empty, open once it holds a session), so an explicit expand of an empty lane silently re-read as a 'collapse' the moment the lane gained its first row - collapsing the very lane you'd just opened to work in. Store the resolved open/collapsed boolean per node instead ($sidebarWorkspaceNodeOpen), which survives the default flip; one-time migration off the old set. The review file-tree shares this store and is updated to match. --- .../chat/sidebar/projects/entered-content.tsx | 12 ++- .../src/app/chat/sidebar/projects/model.ts | 17 ++-- .../chat/sidebar/projects/workspace-group.tsx | 6 ++ .../app/right-sidebar/review/file-tree.tsx | 6 +- apps/desktop/src/store/layout.ts | 87 ++++++++++++++++--- 5 files changed, 102 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx b/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx index 561074d623c1..9851e12e33b6 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx @@ -15,7 +15,7 @@ import { import type { HermesGitWorktree } from '@/global' import type { SessionInfo } from '@/hermes' import { useI18n } from '@/i18n' -import { $dismissedWorktreeIds, dismissWorktree } from '@/store/layout' +import { $dismissedWorktreeIds, dismissWorktree, setWorkspaceNodeOpen } from '@/store/layout' import { notifyError } from '@/store/notifications' import { removeWorktreePath } from '@/store/projects' @@ -257,7 +257,15 @@ function RepoFlatSection({ onNewSession(repo.path)} /> + { + // Reveal the repo the new session targets if the user had it + // collapsed — the session lands in one of its lanes. + setWorkspaceNodeOpen(repo.id, true) + onNewSession(repo.path) + }} + /> ) } count={repoCount} diff --git a/apps/desktop/src/app/chat/sidebar/projects/model.ts b/apps/desktop/src/app/chat/sidebar/projects/model.ts index 1c17c676e0c6..1e55db2fe41e 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/model.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/model.ts @@ -5,7 +5,7 @@ import type { HermesGitWorktree } from '@/global' import type { SessionInfo } from '@/hermes' import { desktopGit } from '@/lib/desktop-git' import { mapPool } from '@/lib/pool' -import { $sidebarWorkspaceCollapsedIds, toggleWorkspaceNodeCollapsed } from '@/store/layout' +import { $sidebarWorkspaceNodeOpen, toggleWorkspaceNodeCollapsed } from '@/store/layout' import { $worktreeRefreshToken } from '@/store/projects' import { sessionRecency, type SidebarProjectTree } from './workspace-groups' @@ -123,14 +123,13 @@ export function useRepoWorktreeMap( // Persisted open/collapse for a repo/worktree node. Lets a project's folder // layout auto-restore when you enter it, and survive reloads. // -// The persisted set is an OVERRIDE of `defaultOpen`, not an absolute "collapsed" -// list: XOR lets one store serve both polarities. A default-open node (repo, -// populated lane) lists collapses; a default-collapsed node (an EMPTY lane — no -// sessions yet) instead records an explicit expand. So empty worktree/branch -// lanes start collapsed and only open when the user clicks in. +// State is stored as the RESOLVED boolean per node (see `$sidebarWorkspaceNodeOpen`), +// so a node whose `defaultOpen` flips — an empty worktree/branch lane defaults +// collapsed, then defaults open once it holds a session — keeps whatever the +// user explicitly chose instead of having it silently reinterpreted. An absent +// id follows `defaultOpen`, so empty lanes still start collapsed until opened. export function useWorkspaceNodeOpen(id: string, defaultOpen = true): [boolean, () => void] { - const collapsed = useStore($sidebarWorkspaceCollapsedIds) - const overridden = collapsed.includes(id) + const state = useStore($sidebarWorkspaceNodeOpen) - return [defaultOpen ? !overridden : overridden, () => toggleWorkspaceNodeCollapsed(id)] + return [state[id] ?? defaultOpen, () => toggleWorkspaceNodeCollapsed(id, defaultOpen)] } diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx index 911aaaeecb2a..bcbefafa672d 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { Codicon } from '@/components/ui/codicon' import type { SessionInfo } from '@/hermes' import { useI18n } from '@/i18n' +import { setWorkspaceNodeOpen } from '@/store/layout' import { notifyError } from '@/store/notifications' import { newSessionInProfile } from '@/store/profile' import { switchBranchInRepo } from '@/store/projects' @@ -68,6 +69,11 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov } const handleNewSession = async () => { + // Reveal the lane the new session targets — an empty worktree/branch lane + // starts collapsed, so without this the session lands in a folder the user + // can't see. Stable across the lane's default flipping open once populated. + setWorkspaceNodeOpen(group.id, true) + if (isProfileGroup) { newSessionInProfile(group.id) diff --git a/apps/desktop/src/app/right-sidebar/review/file-tree.tsx b/apps/desktop/src/app/right-sidebar/review/file-tree.tsx index 5eb66c0fc696..6c2dccb4bb7e 100644 --- a/apps/desktop/src/app/right-sidebar/review/file-tree.tsx +++ b/apps/desktop/src/app/right-sidebar/review/file-tree.tsx @@ -19,7 +19,7 @@ import { isDesktopFsRemoteMode } from '@/lib/desktop-fs' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { cn } from '@/lib/utils' import { $renamingPath, copyFilePath, revealFile, toRelativePath } from '@/store/file-actions' -import { $sidebarWorkspaceCollapsedIds, revealFileInTree, toggleWorkspaceNodeCollapsed } from '@/store/layout' +import { $sidebarWorkspaceNodeOpen, revealFileInTree, toggleWorkspaceNodeCollapsed } from '@/store/layout' import { notifyError } from '@/store/notifications' import { setCurrentSessionPreviewTarget } from '@/store/preview' import { @@ -198,9 +198,9 @@ function ReviewDirRow({ motion: boolean node: ReviewTreeNode }) { - const collapsed = useStore($sidebarWorkspaceCollapsedIds) + const nodeOpen = useStore($sidebarWorkspaceNodeOpen) const id = `review:${node.id}` - const open = !collapsed.includes(id) + const open = nodeOpen[id] ?? true const toggle = () => toggleWorkspaceNodeCollapsed(id) return ( diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index 25cbab5c7b60..80d9343954a8 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -4,7 +4,7 @@ import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants' import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell' import { matchesQuery } from '@/hooks/use-media-query' import { Codecs, persistentAtom } from '@/lib/persisted' -import { arraysEqual, insertUniqueId } from '@/lib/storage' +import { arraysEqual, insertUniqueId, readKey } from '@/lib/storage' import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes' @@ -29,6 +29,7 @@ const SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceOrder' const SIDEBAR_WORKSPACE_PARENT_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceParentOrder' const SIDEBAR_PROJECT_ORDER_STORAGE_KEY = 'hermes.desktop.projectOrder' const SIDEBAR_WORKSPACE_COLLAPSED_STORAGE_KEY = 'hermes.desktop.workspaceCollapsed' +const SIDEBAR_WORKSPACE_NODE_OPEN_STORAGE_KEY = 'hermes.desktop.workspaceNodeOpen' const SIDEBAR_DISMISSED_AUTO_PROJECTS_STORAGE_KEY = 'hermes.desktop.dismissedAutoProjects' const SIDEBAR_DISMISSED_WORKTREES_STORAGE_KEY = 'hermes.desktop.dismissedWorktrees' const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped' @@ -96,14 +97,61 @@ export const $sidebarProjectOrderIds = persistentAtom( [] as string[], Codecs.stringArray ) -// Repo/worktree nodes that the user has explicitly COLLAPSED. Absent = open, so -// a project's folders auto-open when you enter it (and persist your collapses -// across reloads). Keyed by stable node id (repo root / worktree path). -export const $sidebarWorkspaceCollapsedIds = persistentAtom( - SIDEBAR_WORKSPACE_COLLAPSED_STORAGE_KEY, - [] as string[], - Codecs.stringArray +// Explicit open/collapse state for sidebar workspace nodes AND review file-tree +// folders, keyed by stable node id (repo root / worktree path / `review:`). +// A stored value is the user's EXPLICIT choice (true = open, false = collapsed); +// an absent id falls back to the caller's `defaultOpen`. +// +// We store the RESOLVED boolean, NOT an XOR against the default (the old +// `workspaceCollapsed` set did the latter). The XOR was buggy for any node +// whose default *flips*: a worktree lane defaults collapsed while empty and +// open once it holds a session, so an explicit expand of an empty lane silently +// re-read as a "collapse" the moment the lane gained a row — collapsing the very +// lane the user had just opened to work in. An absolute value survives that flip. +export const $sidebarWorkspaceNodeOpen = persistentAtom>( + SIDEBAR_WORKSPACE_NODE_OPEN_STORAGE_KEY, + migrateWorkspaceCollapsedIds(), + Codecs.json>(raw => { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return {} + } + + return Object.fromEntries( + Object.entries(raw).filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean') + ) + }) ) + +// One-time migration off the old XOR `workspaceCollapsed` string[]. Every id in +// it was a deviation from a DEFAULT-OPEN node (repos + file-tree folders, whose +// default never flips), so it maps cleanly to `collapsed` (false). The rare +// empty-worktree-lane "expand" record maps to `false` too, which just returns +// that lane to its default-collapsed state — self-healing, not a regression. +function migrateWorkspaceCollapsedIds(): Record { + if (readKey(SIDEBAR_WORKSPACE_NODE_OPEN_STORAGE_KEY) !== null) { + return {} + } + + const raw = readKey(SIDEBAR_WORKSPACE_COLLAPSED_STORAGE_KEY) + + if (raw === null) { + return {} + } + + try { + const ids = JSON.parse(raw) as unknown + + if (!Array.isArray(ids)) { + return {} + } + + return Object.fromEntries( + ids.filter((id): id is string => typeof id === 'string' && id.length > 0).map(id => [id, false]) + ) + } catch { + return {} + } +} // Auto-derived (git-repo) projects the user has dismissed ("deleted") from the // overview. Keyed by repo-root path; persisted so they stay hidden. Explicit // projects are deleted for real instead — this only declutters the auto tier. @@ -141,11 +189,26 @@ export const $panesFlipped = persistentAtom(PANES_FLIPPED_STORAGE_KEY, false, Co export const $isSidebarResizing = atom(false) export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE) -// Toggle a repo/worktree node's persisted collapse state (absent = open). -export function toggleWorkspaceNodeCollapsed(id: string): void { - const current = $sidebarWorkspaceCollapsedIds.get() +// Resolve a node's open state against its default (absent = follow default). +export function workspaceNodeOpen(id: string, defaultOpen = true): boolean { + return $sidebarWorkspaceNodeOpen.get()[id] ?? defaultOpen +} - $sidebarWorkspaceCollapsedIds.set(current.includes(id) ? current.filter(nodeId => nodeId !== id) : [...current, id]) +// Force a node open/collapsed. Stable across a default flip — used by "+ new +// session" to reveal the lane it targets and keep it open once it's populated. +export function setWorkspaceNodeOpen(id: string, open: boolean): void { + const current = $sidebarWorkspaceNodeOpen.get() + + if (current[id] === open) { + return + } + + $sidebarWorkspaceNodeOpen.set({ ...current, [id]: open }) +} + +// Toggle a repo/worktree/file-tree node relative to its current resolved state. +export function toggleWorkspaceNodeCollapsed(id: string, defaultOpen = true): void { + setWorkspaceNodeOpen(id, !workspaceNodeOpen(id, defaultOpen)) } // Dismiss ("delete") an auto-derived project from the overview.