mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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.
This commit is contained in:
parent
87aaf87748
commit
66747f154c
5 changed files with 102 additions and 26 deletions
|
|
@ -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({
|
|||
<WorkspaceHeader
|
||||
action={
|
||||
onNewSession && (
|
||||
<WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
|
||||
<WorkspaceAddButton
|
||||
label={s.newSessionIn(repo.label)}
|
||||
onClick={() => {
|
||||
// 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}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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:<path>`).
|
||||
// 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<Record<string, boolean>>(
|
||||
SIDEBAR_WORKSPACE_NODE_OPEN_STORAGE_KEY,
|
||||
migrateWorkspaceCollapsedIds(),
|
||||
Codecs.json<Record<string, boolean>>(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<string, boolean> {
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue