From a53b42ddea76b250a3f5fa3f6dffa057f29e6374 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 22 Sep 2026 11:45:29 -0500 Subject: [PATCH] fix(desktop): a layout says what is on screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applying a preset opens every pane it places and closes the ones it lists as `resting`, through the panes' own stores so ⌃`/⌘J/⌘G/⌘B stay truthful. The per-pane `revealOnPreset` flag is gone; a saved deck remembers which of its panes were closed. Basic was `sessions | workspace`, and picking it produced Focus — the apply adopts every omitted pane back in as workspace tabs, so Basic → Focus → Basic converged on one shape (in Advanced too). Basic is now sessions + chat with the terminal resting as a rail under the chat and review/files resting in a right column; Focus keeps files/review as tabs behind the chat with the same rail (a terminal tab covered the conversation, and a reveal on apply fronted it). Simple's two presets are Basic and its mirror. The mirror exposed a positional bug on main: `$fileBrowserOpen` collapsed "the right edge" whatever sat there, so a resting file tree folded the mirrored sidebar away — flip Default with ⌘\ and ⌘J hides the sessions column. An edge now belongs to the pane on it (`sidebarSide()` / `fileBrowserSide()` follow `$panesFlipped`; the tree-side bindings pick their owner the same way). Sessions gains an opener to mirror its closer. In Simple a closed terminal hides, rail and all (`bindToolPaneCollapse(…, $rail)`); ⌃` is the door for the session. --- apps/desktop/src/app/contrib/controller.tsx | 82 ++++++++--- .../desktop/src/app/contrib/layout-presets.ts | 57 ++++++-- .../src/components/pane-shell/tree/presets.ts | 76 +++++++++-- .../pane-shell/tree/resting-presets.test.ts | 128 ++++++++++++++++++ .../src/components/pane-shell/tree/store.ts | 64 +++++++-- .../src/store/layout-flipped-sides.test.ts | 42 ++++++ apps/desktop/src/store/layout.ts | 49 +++++-- 7 files changed, 430 insertions(+), 68 deletions(-) create mode 100644 apps/desktop/src/components/pane-shell/tree/resting-presets.test.ts create mode 100644 apps/desktop/src/store/layout-flipped-sides.test.ts diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index cf70279452..23936436f8 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -44,11 +44,23 @@ import { registry } from '@/contrib/registry' import { discoverRuntimePlugins } from '@/contrib/runtime-loader' import { LocalizedTabTitle, translateNow } from '@/i18n' import { NEW_SESSION_TITLE, sessionTitle as storedSessionTitle } from '@/lib/chat-runtime' -import { Download, FileText, LayoutDashboard, PanelBottom, PanelTop, Terminal, Upload, Users, Zap } from '@/lib/icons' +import { + Download, + FileText, + LayoutDashboard, + PanelBottom, + PanelTop, + SlidersHorizontal, + Terminal, + Upload, + Users, + Zap +} from '@/lib/icons' import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' import { isOnboardingEnabled } from '@/lib/onboarding-enabled' import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives' import { setYoloEnabled } from '@/lib/yolo-session' +import { $interfaceMode, $showsAdvancedChrome, setModeContext, toggleSimpleMode } from '@/store/interface-mode' import { $fileBrowserOpen, $panesFlipped, @@ -56,11 +68,14 @@ import { FILE_BROWSER_DEFAULT_WIDTH, FILE_BROWSER_MAX_WIDTH, FILE_BROWSER_MIN_WIDTH, + fileBrowserSide, setFileBrowserOpen, setSidebarOpen, SIDEBAR_DEFAULT_WIDTH, - SIDEBAR_MAX_WIDTH + SIDEBAR_MAX_WIDTH, + sidebarSide } from '@/store/layout' +import { $profiles } from '@/store/profile' import { $profileRailVisible } from '@/store/profile-rail-prefs' import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share' import { @@ -203,11 +218,9 @@ registry.registerMany([ area: 'panes', // Register-time sample; the tab renders `tabTitle` (see sessions). title: translateNow('sidebar.terminal'), - // revealOnPreset: choosing a layout that places the terminal (e.g. - // "Terminal deck") turns takeover on so the zone actually shows, instead of - // staying collapsed behind the ⌃` toggle. height sizes the fixed track (a - // single-pane zone declaring a height is a fixed track — the preset weight - // is moot): a short deck, not a third of the window. + // height sizes the fixed track (a single-pane zone declaring a height is a + // fixed track — the preset weight is moot): a short deck, not a third of + // the window. // // NO minHeight: a tool panel drags all the way down to its collapsed // header (the sash floors it at COLLAPSED_ZONE_PX and folds the zone to @@ -216,7 +229,6 @@ registry.registerMany([ placement: 'bottom', height: '20vh', maxHeight: '80vh', - revealOnPreset: true, lifecycleKeepAlive: true, tabTitle: () => t.sidebar.terminal} />, tabTitleText: () => translateNow('sidebar.terminal') @@ -373,6 +385,17 @@ registry.registerMany([ get: () => $profileRailVisible.get(), set: enabled => $profileRailVisible.set(enabled) }), + // Simple hides most of the chrome that would offer the way back, so ⌘K is a + // guaranteed door (alongside the layout editor and Settings → Appearance). + paletteToggle({ + id: 'view.simpleMode', + label: 'Simple mode', + action: 'view.toggleSimpleMode', + icon: SlidersHorizontal, + keywords: ['simple', 'advanced', 'mode', 'interface', 'chrome', 'minimal', 'focus', 'distraction'], + get: () => $interfaceMode.get() === 'simple', + set: toggleSimpleMode + }), paletteToggle({ id: 'view.toggleTabStrip', label: 'Toggle tabs', @@ -564,11 +587,22 @@ $panesFlipped.listen(flipped => { } }) -// POSITIONAL side toggles (titlebar buttons, ⌘B / ⌘J): $sidebarOpen ≙ the -// LEFT side of the main zone, $fileBrowserOpen ≙ the RIGHT — everything on -// that side hides together, whatever panes have been rearranged there. -bindTreeSideVisibility('left', $sidebarOpen, setSidebarOpen) -bindTreeSideVisibility('right', $fileBrowserOpen, setFileBrowserOpen) +// Side toggles (titlebar buttons, ⌘B / ⌘J) collapse a whole edge of the main +// zone — everything on it hides together, whatever has been rearranged there. +// Which store owns which edge follows the panes: mirrored, the sidebar sits +// on the right and $sidebarOpen goes with it (see sidebarSide in store/layout). +const $leftEdgeOpen = computed([$panesFlipped, $sidebarOpen, $fileBrowserOpen], (flipped, sidebar, files) => + flipped ? files : sidebar +) + +const $rightEdgeOpen = computed([$panesFlipped, $sidebarOpen, $fileBrowserOpen], (flipped, sidebar, files) => + flipped ? sidebar : files +) + +bindTreeSideVisibility('left', $leftEdgeOpen, open => ($panesFlipped.get() ? setFileBrowserOpen : setSidebarOpen)(open)) +bindTreeSideVisibility('right', $rightEdgeOpen, open => + ($panesFlipped.get() ? setSidebarOpen : setFileBrowserOpen)(open) +) // Workspace-scoped surfaces: the file tree and git diff only mean something // inside a project. A detached chat (no cwd) hides them — their zones @@ -601,13 +635,19 @@ bindPaneVisibility( () => openReview($reviewScopeCwd.get(), $reviewScopeTarget.get()) ) // ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not -// hides; PTYs stay alive while collapsed (see PersistentTerminal). +// hides; PTYs stay alive while collapsed (see PersistentTerminal). Simple has +// no terminal: where chrome is off a closed one hides, rail and all, and ⌃` +// is the door for the session. bindToolPaneCollapse( 'terminal', $terminalTakeover, () => setTerminalTakeover(false), - () => setTerminalTakeover(true) + () => setTerminalTakeover(true), + $showsAdvancedChrome ) +// Policies may consult the install: the profile rail stays in Simple when a +// second profile makes it the only remaining way to switch. +$profiles.subscribe(profiles => setModeContext({ profileCount: profiles.length })) // ⌘K door onto the same pane the keybind and statusbar pill flip — was a // one-way "open" row under Go to, so it never showed on/off and couldn't hide. // Reads the TREE like every other pane toggle: `$terminalTakeover` stays true @@ -774,12 +814,18 @@ registry.register( // flips back) — but only while the pane actually lives in that root side // column. Dragged next to main, a side collapse can't hide it (the collapse // skips main-bearing children), so Close falls back to dismissal there — -// otherwise ⌘W/Close silently no-op. +// otherwise ⌘W/Close silently no-op. The sessions opener is the mirror: a +// preset that places the sidebar shows it, ⌘B truthful. registerPaneCloser('sessions', () => - paneRootSide('sessions') === 'left' ? setSidebarOpen(false) : dismissTreePane('sessions') + paneRootSide('sessions') === sidebarSide() ? setSidebarOpen(false) : dismissTreePane('sessions') ) +registerPaneOpener('sessions', () => { + if (paneRootSide('sessions') === sidebarSide()) { + setSidebarOpen(true) + } +}) registerPaneCloser('files', () => - paneRootSide('files') === 'right' ? setFileBrowserOpen(false) : dismissTreePane('files') + paneRootSide('files') === fileBrowserSide() ? setFileBrowserOpen(false) : dismissTreePane('files') ) // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/app/contrib/layout-presets.ts b/apps/desktop/src/app/contrib/layout-presets.ts index 786d955457..c5aa882c62 100644 --- a/apps/desktop/src/app/contrib/layout-presets.ts +++ b/apps/desktop/src/app/contrib/layout-presets.ts @@ -1,6 +1,5 @@ -import { group, split } from '@/components/pane-shell/tree/model' -import { registry } from '@/contrib/registry' -import { isOnboardingEnabled } from '@/lib/onboarding-enabled' +import { group, mirrorTreeHorizontal, split } from '@/components/pane-shell/tree/model' +import { registerBundledPresets } from '@/components/pane-shell/tree/presets' // --------------------------------------------------------------------------- // Layout presets — CHAT (main) always dominates. @@ -39,11 +38,32 @@ export const DEFAULT_TREE = split( 'spl-root' ) -const FOCUS_TREE = split('row', [group(['sessions']), group(['workspace', 'files', 'review', 'terminal'])], [1, 4.6]) +// Focus is one column of attention: files and review are tabs BEHIND the chat, +// the terminal a collapsed rail under it — opening the terminal must never +// cover the conversation, which a terminal tab did. +const FOCUS_TREE = split( + 'row', + [group(['sessions']), split('column', [group(['workspace', 'files', 'review']), group(['terminal'])], [3, 1])], + [1, 4.6] +) -// Basic starts with sessions and chat so first-run users need not learn -// terminal, files or review panes before using Hermes. -const BASIC_TREE = split('row', [group(['sessions']), group(['workspace'])], [1, 4.6]) +// Basic is sessions and chat with the tooling RESTING in its own slots: the +// terminal a collapsed rail under the chat (its column carries the chat, so +// ⌘J folding the right side can never take the rail with it), review and +// files a right column that ⌘J / ⌘G open. A tree that simply omitted them was +// a lie — applying it adopts every missing pane back in as workspace tabs, +// which is Focus. +const BASIC_TREE = split( + 'row', + [ + group(['sessions']), + split('column', [group(['workspace']), group(['terminal'])], [3, 1]), + split('row', [group(['review']), group(['files'])], [1, 1.2]) + ], + [1, 3.4, 1.25] +) + +const BASIC_RESTING = ['terminal', 'files', 'review'] as const const TERMINAL_TREE = split( 'column', @@ -64,11 +84,22 @@ const QUAD_TREE = split( ) export function registerLayoutPresets() { - return registry.registerMany([ - { id: 'default', area: 'layouts', title: 'Default', order: 0, data: DEFAULT_TREE }, - ...(isOnboardingEnabled() ? [{ id: 'basic', area: 'layouts', title: 'Basic', order: 5, data: BASIC_TREE }] : []), - { id: 'focus', area: 'layouts', title: 'Focus', order: 10, data: FOCUS_TREE }, - { id: 'terminal-deck', area: 'layouts', title: 'Terminal deck', order: 20, data: TERMINAL_TREE }, - { id: 'quad', area: 'layouts', title: 'Quad', order: 30, data: QUAD_TREE } + // Simple is always the Basic arrangement; its one choice is which side the + // sidebar sits. The decks are Advanced — arranging tooling is the point. + return registerBundledPresets([ + { id: 'sidebar-left', title: 'Sidebar left', order: 0, tree: BASIC_TREE, resting: BASIC_RESTING, tier: 'simple' }, + { + id: 'sidebar-right', + title: 'Sidebar right', + order: 1, + tree: mirrorTreeHorizontal(BASIC_TREE), + resting: BASIC_RESTING, + tier: 'simple' + }, + { id: 'default', title: 'Default', order: 0, tree: DEFAULT_TREE, tier: 'advanced' }, + { id: 'basic', title: 'Basic', order: 5, tree: BASIC_TREE, resting: BASIC_RESTING, tier: 'advanced' }, + { id: 'focus', title: 'Focus', order: 10, tree: FOCUS_TREE, resting: ['terminal'], tier: 'advanced' }, + { id: 'terminal-deck', title: 'Terminal deck', order: 20, tree: TERMINAL_TREE, tier: 'advanced' }, + { id: 'quad', title: 'Quad', order: 30, tree: QUAD_TREE, tier: 'advanced' } ]) } diff --git a/apps/desktop/src/components/pane-shell/tree/presets.ts b/apps/desktop/src/components/pane-shell/tree/presets.ts index c0eb27f874..9bfad6d5d4 100644 --- a/apps/desktop/src/components/pane-shell/tree/presets.ts +++ b/apps/desktop/src/components/pane-shell/tree/presets.ts @@ -10,12 +10,55 @@ import { registry } from '@/contrib/registry' import { readJson, writeJson, writeKey } from '@/lib/storage' +import { asLayoutIntent, type Tiered } from '@/store/interface-mode' -import { isLayoutNode, type LayoutNode } from './model' -import { $layoutTree, applyTree, markActivePreset } from './store' +import { allPaneIds, findGroupOfPane, isLayoutNode, type LayoutNode } from './model' +import { $dismissedPanes, $hiddenTreePanes, $layoutTree, applyTree, markActivePreset } from './store' export const LAYOUTS_AREA = 'layouts' +/** + * A preset is the tree plus what the tree cannot say. `resting` names the + * panes it places but leaves CLOSED — every other pane it places opens on + * apply, so a preset states what is on screen. `tier` names the one mode + * whose shelf carries it. Both stay off `data`, which every consumer reads as + * a bare `LayoutNode`. + */ +export interface LayoutPresetSpec extends Tiered { + id: string + order: number + resting?: readonly string[] + title: string + tree: LayoutNode +} + +const specs = new Map>() + +export function registerBundledPresets(bundled: readonly LayoutPresetSpec[]) { + for (const spec of bundled) { + rememberSpec(spec.id, spec) + } + + return registry.registerMany( + bundled.map(({ id, order, title, tree }) => ({ id, area: LAYOUTS_AREA, title, order, data: tree })) + ) +} + +function rememberSpec(id: string, { resting, tier }: Pick) { + specs.set(id, { resting, tier }) +} + +/** User decks have no tier, so every shelf carries them. */ +export const layoutPresetTier = (id: string) => specs.get(id)?.tier + +const NO_RESTING: ReadonlySet = new Set() + +export const layoutPresetResting = (id: string): ReadonlySet => { + const resting = specs.get(id)?.resting + + return resting ? new Set(resting) : NO_RESTING +} + // v2: v1 presets predate semantic placement (see store.ts) — retire them. const USER_KEY = 'hermes.desktop.layoutPresets.v2' @@ -23,6 +66,7 @@ writeKey('hermes.desktop.layoutPresets.v1', null) interface StoredPreset { name: string + resting?: string[] tree: LayoutNode } @@ -35,6 +79,7 @@ function loadUserPresets(): Record { for (const [id, preset] of Object.entries(parsed)) { if (preset && typeof preset.name === 'string' && isLayoutNode(preset.tree)) { out[id] = preset + rememberSpec(id, preset) } } @@ -60,8 +105,10 @@ for (const [id, preset] of Object.entries(userPresets)) { registerUserPreset(id, preset) } -/** Save any tree as a named user preset (and make it active). */ -export function saveLayoutPresetTree(name: string, tree: LayoutNode): string | null { +/** Save any tree as a named user preset (and make it active). A deck saved + * from the live layout remembers which of its panes were closed, so applying + * it later restores what was on screen, not just where things sat. */ +export function saveLayoutPresetTree(name: string, tree: LayoutNode, resting: readonly string[] = []): string | null { const trimmed = name.trim() if (!tree || !trimmed) { @@ -75,20 +122,27 @@ export function saveLayoutPresetTree(name: string, tree: LayoutNode): string | n .replace(/^-+|-+$/g, '') || Date.now().toString(36) }` - userPresets[id] = { name: trimmed, tree } + userPresets[id] = { name: trimmed, tree, resting: [...resting] } persistUserPresets(userPresets) + rememberSpec(id, userPresets[id]) registerUserPreset(id, userPresets[id]) markActivePreset(id) return id } -/** Save the CURRENT tree as a named user preset (and make it active). */ +/** Save the CURRENT tree as a named user preset (and make it active). A pane + * rests when it is closed — hidden, dismissed or folded to its rail — not + * when it merely sits behind a sibling tab. */ export function saveCurrentLayoutAs(name: string) { const tree = $layoutTree.get() if (tree) { - saveLayoutPresetTree(name, tree) + const hidden = $hiddenTreePanes.get() + const dismissed = $dismissedPanes.get() + const rests = (id: string) => hidden.has(id) || dismissed.has(id) || Boolean(findGroupOfPane(tree, id)?.minimized) + + saveLayoutPresetTree(name, tree, allPaneIds(tree).filter(rests)) } } @@ -98,6 +152,7 @@ export function deleteUserPreset(id: string) { } delete userPresets[id] + specs.delete(id) persistUserPresets(userPresets) userDisposers.get(id)?.() userDisposers.delete(id) @@ -105,7 +160,10 @@ export function deleteUserPreset(id: string) { export const isUserPreset = (id: string) => id in userPresets -/** Apply a preset's tree (deep-cloned so live edits never mutate the preset). */ +/** Apply a preset's tree (deep-cloned so live edits never mutate the preset), + * opening what it places and resting what it says to. In Simple the mode + * already decides what rests, so the open/close side of a layout pick yields + * to it instead of surfacing shadowed panes for the session. */ export function applyLayoutPreset(id: string, tree: LayoutNode) { - applyTree(structuredClone(tree), id) + asLayoutIntent(() => applyTree(structuredClone(tree), id, specs.get(id)?.resting)) } diff --git a/apps/desktop/src/components/pane-shell/tree/resting-presets.test.ts b/apps/desktop/src/components/pane-shell/tree/resting-presets.test.ts new file mode 100644 index 0000000000..9a57b7b5fe --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/resting-presets.test.ts @@ -0,0 +1,128 @@ +import { atom } from 'nanostores' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { registry } from '@/contrib/registry' + +import { allPaneIds, findGroupOfPane, group, split } from './model' +import { applyLayoutPreset, deleteUserPreset, layoutPresetTier, registerBundledPresets, saveCurrentLayoutAs } from './presets' +import { $dismissedPanes, $hiddenTreePanes, $layoutTree, bindPaneVisibility, bindToolPaneCollapse } from './store' + +// Basic used to be `sessions | workspace`, and picking it produced Focus: +// `applyTree` adopts every registered pane the tree omits back in as +// workspace tabs. A preset that wants the tooling OFF SCREEN has to place it +// and rest it, so the arrangement survives adoption and only what's open +// differs from the deck that shows it. + +const ARRANGEMENT = split('row', [ + group(['sessions']), + group(['workspace']), + split('column', [group(['review', 'files']), group(['terminal'])]) +]) + +const disposers: (() => void)[] = [] + +beforeEach(() => { + window.localStorage.clear() + $dismissedPanes.set(new Set()) + $hiddenTreePanes.set(new Set()) + + for (const [id, data] of [ + ['sessions', { placement: 'left' }], + ['workspace', { placement: 'main', uncloseable: true }], + ['files', { placement: 'right' }], + ['review', { placement: 'right' }], + ['terminal', { placement: 'bottom', revealOnPreset: true }] + ] as const) { + disposers.push(registry.register({ area: 'panes', data, id, render: () => null, title: id })) + } +}) + +afterEach(() => { + disposers.splice(0).forEach(dispose => dispose()) +}) + +describe('resting presets', () => { + it('keeps the arrangement and closes the resting panes through their stores', () => { + const $terminal = atom(true) + const $files = atom(true) + const $review = atom(true) + + bindToolPaneCollapse('terminal', $terminal, () => $terminal.set(false), () => $terminal.set(true)) + bindPaneVisibility('files', $files, () => $files.set(false), () => $files.set(true)) + bindPaneVisibility('review', $review, () => $review.set(false), () => $review.set(true)) + + disposers.push( + registerBundledPresets([ + { id: 'shows', title: 'Shows', order: 0, tree: ARRANGEMENT, tier: 'advanced' }, + { id: 'rests', title: 'Rests', order: 1, tree: ARRANGEMENT, resting: ['terminal', 'files', 'review'] } + ]) + ) + + applyLayoutPreset('rests', ARRANGEMENT) + + expect(allPaneIds($layoutTree.get()!).sort()).toEqual(allPaneIds(ARRANGEMENT).sort()) + expect([$terminal.get(), $files.get(), $review.get()]).toEqual([false, false, false]) + expect([...$hiddenTreePanes.get()].sort()).toEqual(['files', 'review']) + expect(findGroupOfPane($layoutTree.get()!, 'terminal')?.minimized).toBe(true) + + // The deck that shows the tooling opens EVERYTHING it places. + applyLayoutPreset('shows', ARRANGEMENT) + + expect([$terminal.get(), $files.get(), $review.get()]).toEqual([true, true, true]) + expect(allPaneIds($layoutTree.get()!).sort()).toEqual(allPaneIds(ARRANGEMENT).sort()) + + // Resting again with the store ALREADY closed still collapses the rail: + // a same-value closer is a no-op and the fresh tree carries no flag. + $terminal.set(false) + applyLayoutPreset('rests', ARRANGEMENT) + + expect(findGroupOfPane($layoutTree.get()!, 'terminal')?.minimized).toBe(true) + }) + + it('a deck saved from the live layout remembers what was closed', () => { + const $terminal = atom(false) + const $files = atom(true) + + bindToolPaneCollapse('terminal', $terminal, () => $terminal.set(false), () => $terminal.set(true)) + bindPaneVisibility('files', $files, () => $files.set(false), () => $files.set(true)) + + disposers.push(registerBundledPresets([{ id: 'seed', title: 'Seed', order: 0, tree: ARRANGEMENT, resting: ['terminal'] }])) + applyLayoutPreset('seed', ARRANGEMENT) + saveCurrentLayoutAs('Mine') + + // Open the terminal by hand, then re-apply the saved deck: it rests again. + $terminal.set(true) + applyLayoutPreset('user-mine', $layoutTree.get()!) + + expect($terminal.get()).toBe(false) + expect($files.get()).toBe(true) + expect(layoutPresetTier('user-mine')).toBeUndefined() + + deleteUserPreset('user-mine') + }) + + it('a reveal on apply never steals the tab the preset put first', () => { + const $terminal = atom(false) + + bindToolPaneCollapse('terminal', $terminal, () => $terminal.set(false), () => $terminal.set(true)) + + // Focus: the terminal is a tab BEHIND the chat, and opening it on apply + // used to front it over the chat. + const focus = split('row', [group(['sessions']), group(['workspace', 'files', 'review', 'terminal'])]) + + disposers.push(registerBundledPresets([{ id: 'focus', title: 'Focus', order: 0, tree: focus }])) + applyLayoutPreset('focus', focus) + + expect($terminal.get()).toBe(true) + expect(findGroupOfPane($layoutTree.get()!, 'workspace')?.active).toBe('workspace') + }) + + it('files bundled presets by their declared tier; user decks have none', () => { + disposers.push( + registerBundledPresets([{ id: 'deck', title: 'Deck', order: 0, tree: ARRANGEMENT, tier: 'advanced' }]) + ) + + expect(layoutPresetTier('deck')).toBe('advanced') + expect(layoutPresetTier('user-mine')).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index a37047c321..42d05c42ed 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -376,8 +376,8 @@ export function registerPaneCloser(paneId: string, close?: () => void) { * Route a pane's "show it" intent through the app store that owns its * visibility — the mirror of `registerPaneCloser`, so a preset can reveal a * toggle-gated pane (e.g. the terminal, whose visibility ⌃`/`$terminalTakeover` - * owns) while the toggle stays truthful. Only panes that opt in via - * `data.revealOnPreset` are opened on preset apply. + * owns) while the toggle stays truthful. Applying a preset opens every pane it + * places, except the ones it places resting. */ export function registerPaneOpener(paneId: string, open: () => void) { paneOpeners[paneId] = open @@ -1640,7 +1640,7 @@ export function moveTreePane(paneId: string, target: { groupId: string; pos: Dro * preset) are adopted into the group their current siblings land in, so * applying a preset never loses a pane. */ -export function applyTree(tree: LayoutNode, presetId: string) { +export function applyTree(tree: LayoutNode, presetId: string, resting: readonly string[] = []) { const previous = $layoutTree.get() // A preset defines the layout's SIZES too — stale drag overrides from the @@ -1651,21 +1651,46 @@ export function applyTree(tree: LayoutNode, presetId: string) { commit(previous ? adoptMissingPanes(tree, previous) : tree) markActivePreset(presetId) - // Picking a named layout is an intent to SEE its panes. Toggle-gated panes - // (the terminal, whose visibility a store owns) would otherwise stay - // collapsed after the tree changes — so reveal the ones that opt in through - // their owning store, keeping the ⌃`/toggle state truthful. Iterate the - // preset's DECLARED panes (not the adopted result) so only panes a preset - // explicitly places are turned on. - const panes = registry.getArea('panes') + // A preset says what is ON SCREEN. Every toggle-gated pane it places opens + // through its owning store (so ⌃`/⌘J/⌘G stay truthful), except the ones it + // places RESTING, which close through the same store. Iterate the preset's + // DECLARED panes (not the adopted result) so only panes it explicitly places + // move. + const rests = new Set(resting) for (const paneId of allPaneIds(tree)) { - const data = panes.find(c => c.id === paneId)?.data as { revealOnPreset?: boolean } | undefined + if (rests.has(paneId)) { + paneClosers[paneId]?.() - if (data?.revealOnPreset) { + // A store already reading closed makes that closer a same-value no-op, + // and the fresh tree carries no minimized flag — so a tool pane's rail + // is collapsed explicitly. Hide-style panes need nothing: the hidden + // set outlives the tree. + if (isCollapsePane(paneId)) { + setPaneCollapsed(paneId, true) + } + } else { paneOpeners[paneId]?.() } } + + // Opening fronts the pane in its stack (a reveal is "show me this"), which + // steals the active slot from whatever the preset put first — Focus opened + // with the terminal over the chat. The preset's own tab order is the intent: + // re-assert each declared group's active tab after the reveals. + const applied = $layoutTree.get() + + if (applied) { + for (const groupId of groupLeafIds(tree)) { + const declared = findGroup(tree, groupId) + const want = declared?.active ?? declared?.panes[0] + const live = findGroup(applied, groupId) + + if (want && live && live.active !== want && live.panes.includes(want)) { + activateTreePane(groupId, want) + } + } + } } /** @@ -1906,12 +1931,17 @@ export function bindPaneVisibility( * wasn't the active tab, the shared-zone branch declined, and the key read as * dead until the stack was broken up. The persisted tree already records which * tab was active — leave it alone. + * + * `$rail` says whether a CLOSED pane keeps its rail on screen. Off, the pane + * hides instead (Simple has no terminal) — same store, same toggle, only the + * resting shape differs. Omitted means always. */ export function bindToolPaneCollapse( paneId: string, $open: { get(): boolean; listen(fn: (open: boolean) => void): void }, close: () => void, - open: () => void + open: () => void, + $rail?: { get(): boolean; listen(fn: (rail: boolean) => void): void } ) { markCollapsePane(paneId) @@ -1922,6 +1952,14 @@ export function bindToolPaneCollapse( $open.listen(isOpen => (isOpen ? revealTreePane(paneId) : setPaneCollapsed(paneId, true))) registerPaneCloser(paneId, close) registerPaneOpener(paneId, open) + + if ($rail) { + const sync = () => setTreePaneHidden(paneId, !$open.get() && !$rail.get()) + + sync() + $open.listen(sync) + $rail.listen(sync) + } } /** diff --git a/apps/desktop/src/store/layout-flipped-sides.test.ts b/apps/desktop/src/store/layout-flipped-sides.test.ts new file mode 100644 index 0000000000..e962ce3bd3 --- /dev/null +++ b/apps/desktop/src/store/layout-flipped-sides.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// An edge belongs to the pane that sits on it. Mirrored (⌘\ or the Simple +// "Sidebar right" layout), the sessions sidebar is on the RIGHT — ⌘B must +// still mean the sidebar and ⌘J the file tree, or a resting file tree folds +// the sidebar away with it. Regression for the "Sidebar right" layout +// rendering only the chat. + +describe('side toggles follow the flip', () => { + beforeEach(() => { + window.localStorage.clear() + vi.resetModules() + }) + + afterEach(() => { + vi.resetModules() + }) + + it('collapses the edge the sidebar / file tree actually sit on', async () => { + const tree = await import('@/components/pane-shell/tree/store') + const layout = await import('@/store/layout') + + expect(layout.sidebarSide()).toBe('left') + expect(layout.fileBrowserSide()).toBe('right') + + layout.$panesFlipped.set(true) + + expect(layout.sidebarSide()).toBe('right') + expect(layout.fileBrowserSide()).toBe('left') + + layout.setFileBrowserOpen(false) + expect(tree.$collapsedTreeSides.get().has('left')).toBe(true) + expect(tree.$collapsedTreeSides.get().has('right')).toBe(false) + + layout.setSidebarOpen(false) + expect(tree.$collapsedTreeSides.get().has('right')).toBe(true) + + layout.setFileBrowserOpen(true) + layout.setSidebarOpen(true) + expect(tree.$collapsedTreeSides.get().size).toBe(0) + }) +}) diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index 2f5d86a5f0..a158f35511 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -5,12 +5,14 @@ import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell' import { restoreHiddenTreeSideTabs, restoreMinimizedTreeSide, - setTreeSideCollapsed + setTreeSideCollapsed, + type TreeSide } from '@/components/pane-shell/tree/store' import { matchesQuery } from '@/hooks/use-media-query' import { connectionScopedAtom } from '@/lib/connection-scoped' import { type Codec, Codecs, persistentAtom } from '@/lib/persisted' import { arraysEqual, insertUniqueId, readKey } from '@/lib/storage' +import { modeBound } from '@/store/interface-mode' import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride } from './panes' import { $showAllProfiles, setShowAllProfiles } from './profile' @@ -78,11 +80,18 @@ export const $sidebarOpen: ReadableAtom = computed( states => states[CHAT_SIDEBAR_PANE_ID]?.open ?? true ) -export const $fileBrowserOpen: ReadableAtom = computed( +// The file tree's own toggle (⌘J), which doubles as the RIGHT side's collapse. +// Simple mode rests it closed without touching the pane record; ⌘J still opens +// it for the session. +const $fileBrowserOpenPref: ReadableAtom = computed( $paneStates, states => states[FILE_BROWSER_PANE_ID]?.open ?? false ) +export const $fileBrowserOpen = modeBound('fileBrowserOpen', $fileBrowserOpenPref, open => + setPaneOpen(FILE_BROWSER_PANE_ID, open) +) + // Persisted so a relaunch reopens the same rail tab. Null when the rail has no // tabs; a restored id with no matching tab is reconciled in the preview store. export const $rightRailActiveTabId = persistentAtom(RIGHT_RAIL_ACTIVE_TAB_STORAGE_KEY, null, { @@ -316,12 +325,16 @@ const $sidebarSortKey = persistentAtom( oneOf(SIDEBAR_SORT_KEYS, 'updated') ) -export const $sidebarRowMeta = persistentAtom( +// Simple mode rests the rows on what was said and when, without touching +// this preference. +const $sidebarRowMetaPref = persistentAtom( SIDEBAR_ROW_META_STORAGE_KEY, SIDEBAR_DEFAULT_ROW_META, listOf(ROW_META) ) +export const $sidebarRowMeta = modeBound('sidebarRowMeta', $sidebarRowMetaPref, meta => $sidebarRowMetaPref.set(meta)) + /** Inbox style: render the flat list's session rows as three-line cards * (project · age / title / model · size) instead of the one-line row. A * RENDER variant, deliberately not a grouping — it composes with whichever @@ -534,13 +547,19 @@ function revealNarrowPane(id: string, mode: 'close' | 'open' | 'toggle'): boolea return true } +// An edge belongs to the pane that sits on it: the flip (⌘\ / a mirrored +// layout) puts the sessions sidebar on the right, and ⌘B keeps meaning the +// sidebar, ⌘J the file tree — never "whatever is on the left". +export const sidebarSide = (): TreeSide => ($panesFlipped.get() ? 'right' : 'left') +export const fileBrowserSide = (): TreeSide => ($panesFlipped.get() ? 'left' : 'right') + export function setSidebarOpen(open: boolean) { setPaneOpen(CHAT_SIDEBAR_PANE_ID, open) - setTreeSideCollapsed('left', !open) + setTreeSideCollapsed(sidebarSide(), !open) if (open) { - restoreMinimizedTreeSide('left') - restoreHiddenTreeSideTabs('left') + restoreMinimizedTreeSide(sidebarSide()) + restoreHiddenTreeSideTabs(sidebarSide()) } revealNarrowPane(CHAT_SIDEBAR_PANE_ID, open ? 'open' : 'close') @@ -548,9 +567,9 @@ export function setSidebarOpen(open: boolean) { export function toggleSidebarOpen() { if (!revealNarrowPane(CHAT_SIDEBAR_PANE_ID, 'toggle')) { - const open = restoreMinimizedTreeSide('left') || !$sidebarOpen.get() + const open = restoreMinimizedTreeSide(sidebarSide()) || !$sidebarOpen.get() setPaneOpen(CHAT_SIDEBAR_PANE_ID, open) - setTreeSideCollapsed('left', !open) + setTreeSideCollapsed(sidebarSide(), !open) } } @@ -559,18 +578,18 @@ export function toggleFileBrowserOpen() { return } - const open = restoreMinimizedTreeSide('right') || !$fileBrowserOpen.get() - setPaneOpen(FILE_BROWSER_PANE_ID, open) - setTreeSideCollapsed('right', !open) + const open = restoreMinimizedTreeSide(fileBrowserSide()) || !$fileBrowserOpen.get() + $fileBrowserOpen.set(open) + setTreeSideCollapsed(fileBrowserSide(), !open) } export function setFileBrowserOpen(open: boolean) { - setPaneOpen(FILE_BROWSER_PANE_ID, open) - setTreeSideCollapsed('right', !open) + $fileBrowserOpen.set(open) + setTreeSideCollapsed(fileBrowserSide(), !open) if (open) { - restoreMinimizedTreeSide('right') - restoreHiddenTreeSideTabs('right') + restoreMinimizedTreeSide(fileBrowserSide()) + restoreHiddenTreeSideTabs(fileBrowserSide()) } revealNarrowPane(FILE_BROWSER_PANE_ID, open ? 'open' : 'close')