feat(desktop): tool panels collapse to a persistent rail, ✕ removes them

Terminal and logs now follow the IntelliJ/VS-Code tool-window model: their
toggle (⌃`, ⌘K) COLLAPSES the zone to a rail with the tab still showing instead
of hiding it outright, so "toggle" and "the tab bar" stop fighting. Restore
routes through the pane's store opener (rail click / chevron) so the shortcut and
titlebar toggle stay truthful; the tab's ✕ dismisses the panel (comes back via
its toggle), while a session tile's ✕ still closes the session. New store
primitives (setPaneCollapsed / restoreTreePane / collapseTreePane + a
collapse-pane registry) via a bindPaneCollapse in the controller.
This commit is contained in:
Brooklyn Nicholson 2026-07-14 21:06:40 -04:00
parent ec926ce89e
commit 798e602a8e
3 changed files with 118 additions and 12 deletions

View file

@ -14,6 +14,7 @@ import {
declareDefaultTree,
dismissTreePane,
dockPaneBeside,
markCollapsePane,
mirrorLayoutTree,
paneRootSide,
registerLayoutResetHandler,
@ -21,6 +22,7 @@ import {
registerPaneOpener,
resetLayoutTree,
revealTreePane,
setPaneCollapsed,
setTreePaneHidden,
watchContributedPanes
} from '@/components/pane-shell/tree/store'
@ -412,6 +414,23 @@ function bindPaneVisibility(
}
}
// TOOL PANELS (terminal, logs): like bindPaneVisibility but the toggle COLLAPSES
// the zone to a persistent rail (tab stays) instead of hiding it — the
// IntelliJ/VS-Code tool-window model. Restore routes back through `open` (rail
// click / chevron) so ⌃`/the button stay truthful; the tab's ✕ removes it.
function bindPaneCollapse(
paneId: string,
$open: { get(): boolean; listen(fn: (open: boolean) => void): void },
close: () => void,
open: () => void
) {
markCollapsePane(paneId)
setPaneCollapsed(paneId, !$open.get())
$open.listen(isOpen => setPaneCollapsed(paneId, !isOpen))
registerPaneCloser(paneId, close)
registerPaneOpener(paneId, open)
}
// SIDES have one source of truth: the TREE. The legacy $panesFlipped flag is
// DERIVED from where the sessions zone actually sits (TitlebarControls maps
// its left/right buttons through it), so dragging sessions across — or
@ -467,9 +486,9 @@ bindPaneVisibility(
computed([$reviewOpen, $hasWorkspace], (open, workspace) => open && workspace),
closeReview
)
// ⌃` / statusbar toggle — the terminal zone follows takeover instead of
// being forced on (PTYs stay alive while hidden; see PersistentTerminal).
bindPaneVisibility(
// ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not
// hides; PTYs stay alive while collapsed (see PersistentTerminal).
bindPaneCollapse(
'terminal',
$terminalTakeover,
() => setTerminalTakeover(false),
@ -489,7 +508,7 @@ bindPaneVisibility('preview', $previewVisible, closeRightRail)
// Logs are optional chrome: off by default, toggled from ⌘K, persisted.
const $logsOpen = persistentAtom('hermes.desktop.logsOpen', false, Codecs.bool)
bindPaneVisibility(
bindPaneCollapse(
'logs',
$logsOpen,
() => $logsOpen.set(false),

View file

@ -34,7 +34,11 @@ import {
$treeDragging,
activateTreePane,
closeTreePane,
collapseTreePane,
dismissTreePane,
isCollapsePane,
moveTreePane,
restoreTreePane,
SESSION_TILE_DRAG,
setTreeGroupHeaderHidden,
splitTreeZone,
@ -254,6 +258,15 @@ export function TreeGroup({
// MAIN strands the whole app behind a strip.
const minimizable = !shown.some(id => paneChrome(paneFor(id)).uncloseable)
// Tab ✕: a tool panel (terminal/logs) is REMOVED from the layout (comes back
// via its toggle); everything else routes through its Close (a session tile
// closes the session, a store-bound pane collapses).
const closeTab = (paneId: string) => (isCollapsePane(paneId) ? dismissTreePane(paneId) : closeTreePane(paneId))
// Collapse/restore a tool panel (or plain minimize elsewhere) — the header
// chevron + tap gesture, routed so ⌃`/the titlebar toggle stay truthful.
const toggleCollapse = () => (node.minimized ? restoreTreePane(activeId) : collapseTreePane(activeId))
// Same menu on the header strip and the edit veil — one prop bag.
const zoneMenu = {
closable,
@ -299,7 +312,7 @@ export function TreeGroup({
// Strip line faces the content the zone collapsed away from.
railSide === 'right' ? PANE_TAB_STRIP_LINE_LEFT : PANE_TAB_STRIP_LINE_RIGHT
)}
onClick={() => toggleTreeGroupMinimized(node.id, false)}
onClick={() => restoreTreePane(activeId)}
title={t.zones.restore}
>
<div
@ -319,10 +332,9 @@ export function TreeGroup({
key={paneId}
onClick={event => {
event.stopPropagation()
toggleTreeGroupMinimized(node.id, false)
activateTreePane(node.id, paneId)
restoreTreePane(paneId)
}}
onClose={closeable ? () => closeTreePane(paneId) : undefined}
onClose={closeable ? () => closeTab(paneId) : undefined}
role="tab"
side={railSide}
vertical
@ -359,7 +371,7 @@ export function TreeGroup({
startPaneDrag(
activeId,
e,
() => minimizable && toggleTreeGroupMinimized(node.id, !node.minimized),
() => minimizable && toggleCollapse(),
undefined,
hideHeaderDoubleTap
)
@ -383,7 +395,7 @@ export function TreeGroup({
aria-selected={isActive}
data-tree-tab={paneId}
key={paneId}
onClose={closeable ? () => closeTreePane(paneId) : undefined}
onClose={closeable ? () => closeTab(paneId) : undefined}
onPointerDown={e =>
startPaneDrag(
paneId,
@ -394,7 +406,7 @@ export function TreeGroup({
// — overloading the active tab made double-click a
// minimize/restore/hide lottery.
if (node.minimized) {
toggleTreeGroupMinimized(node.id, false)
restoreTreePane(paneId)
}
activateTreePane(node.id, paneId)
@ -419,7 +431,7 @@ export function TreeGroup({
<button
aria-label={node.minimized ? t.zones.restore : t.zones.minimize}
className="mx-1 grid size-5 shrink-0 place-items-center self-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 group-hover/pane-header:opacity-100"
onClick={() => toggleTreeGroupMinimized(node.id, !node.minimized)}
onClick={toggleCollapse}
onPointerDown={e => e.stopPropagation()}
type="button"
>

View file

@ -171,6 +171,20 @@ export function registerPaneOpener(paneId: string, open: () => void) {
paneOpeners[paneId] = open
}
// TOOL PANELS (terminal, logs, …): their toggle COLLAPSES the zone to a rail
// (tab stays) instead of hiding it, and the tab's ✕ REMOVES it (vs a session
// tile, whose ✕ closes the session). Membership tells the renderer which
// semantics a tab gets. See bindPaneCollapse in the controller.
const collapsePanes = new Set<string>()
export function markCollapsePane(paneId: string) {
collapsePanes.add(paneId)
}
export function isCollapsePane(paneId: string): boolean {
return collapsePanes.has(paneId)
}
const resetHandlers = new Set<() => void>()
/** Run during a layout reset, BEFORE generic adoption lets an owner
@ -954,6 +968,67 @@ export function toggleTreeGroupMinimized(groupId: string, minimized: boolean) {
}
}
/** The group hosting `paneId`, or null. */
function paneGroup(paneId: string) {
const tree = $layoutTree.get()
return tree ? findGroupOfPane(tree, paneId) : null
}
/** Collapse/restore a pane's ZONE to a minimized rail its tab stays visible.
* Store-driven (one-way): a tool panel's $open store mirrors here via
* bindPaneCollapse, so a toggle collapses rather than hides. */
export function setPaneCollapsed(paneId: string, collapsed: boolean) {
const group = paneGroup(paneId)
if (group && Boolean(group.minimized) !== collapsed) {
toggleTreeGroupMinimized(group.id, collapsed)
if (!collapsed) {
revealTreePane(paneId)
}
}
}
/** Restore a minimized tool pane the truthful way through its store opener
* when bound (keeps `/titlebar toggles in sync), else just un-minimize +
* front. Used by the rail (tab / whole-rail click) and the header chevron. */
export function restoreTreePane(paneId: string) {
const open = paneOpeners[paneId]
if (open) {
open()
return
}
const group = paneGroup(paneId)
if (group) {
toggleTreeGroupMinimized(group.id, false)
activateTreePane(group.id, paneId)
}
}
/** Collapse a tool pane through its store closer (truthful), else minimize the
* zone directly. Gated on isCollapsePane so a non-tool pane's closer (a tile's
* REMOVES it) is never mistaken for a collapse. */
export function collapseTreePane(paneId: string) {
const close = paneClosers[paneId]
if (isCollapsePane(paneId) && close) {
close()
return
}
const group = paneGroup(paneId)
if (group) {
toggleTreeGroupMinimized(group.id, true)
}
}
/** Hide/show a zone's header entirely (double-click gesture). */
export function setTreeGroupHeaderHidden(groupId: string, headerHidden: boolean) {
const tree = $layoutTree.get()