fix(desktop): a layout says what is on screen
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.
This commit is contained in:
@@ -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: () => <LocalizedTabTitle select={t => 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')
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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' }
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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<string, Pick<LayoutPresetSpec, 'resting' | 'tier'>>()
|
||||
|
||||
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<LayoutPresetSpec, 'resting' | 'tier'>) {
|
||||
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<string> = new Set()
|
||||
|
||||
export const layoutPresetResting = (id: string): ReadonlySet<string> => {
|
||||
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<string, StoredPreset> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
42
apps/desktop/src/store/layout-flipped-sides.test.ts
Normal file
42
apps/desktop/src/store/layout-flipped-sides.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<boolean> = computed(
|
||||
states => states[CHAT_SIDEBAR_PANE_ID]?.open ?? true
|
||||
)
|
||||
|
||||
export const $fileBrowserOpen: ReadableAtom<boolean> = 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<boolean> = 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<RightRailTabId | null>(RIGHT_RAIL_ACTIVE_TAB_STORAGE_KEY, null, {
|
||||
@@ -316,12 +325,16 @@ const $sidebarSortKey = persistentAtom<SidebarSortKey>(
|
||||
oneOf(SIDEBAR_SORT_KEYS, 'updated')
|
||||
)
|
||||
|
||||
export const $sidebarRowMeta = persistentAtom<SidebarRowMeta[]>(
|
||||
// Simple mode rests the rows on what was said and when, without touching
|
||||
// this preference.
|
||||
const $sidebarRowMetaPref = persistentAtom<SidebarRowMeta[]>(
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user