refactor(desktop): make a zone's tab strip a stated mode, not a flag five paths wrote
`headerHidden` carried two meanings at once. `true` was either "the user hid this" or "a double-tap nobody meant hid this"; `false` was either "the user wants a strip" or "insert / tab-cycling / dock-enforce / adoption pinned one to escape a dead end". Because the layout wrote the same field the user did, a repair silently overwrote a preference and neither could be read back — and since hiding also unmounted the tab, the ✕ and the menu offering "Show header", a zone that got hidden by accident stayed that way across restarts. Replaces it with `tabStrip?: 'always' | 'never'`, where absent is auto and only the user ever writes it, and moves the decision into one resolver that TreeGroup and the store both call, so the strip on screen and the toggle command cannot disagree. Reachability moves into that resolver as an invariant that outranks an explicit `never`: a closeable tile keeps its ✕ and a lone tool panel keeps its chip, because "hide the chrome" is never a request to make a surface unreachable. With that guarantee held centrally, the four repair writes are gone. Persisted `headerHidden` is dropped rather than translated — nothing on disk distinguishes a deliberate hide from an accidental one, and carrying the accidents forward would re-strand exactly the people who reported being stuck. The double-tap hide goes with it, along with the synthesized double-tap detector it was the only consumer of. It fired from ordinary double-clicks on a tab, nothing announced it, and its undo lived behind the chrome it had just removed. `data-zone-no-header` goes too: it marked full-page views for a body double-click toggle that no longer exists, and nothing has read it since. Supersedes the tab-side half of the fix from abundantbeing and yoniebans, whose commits this builds on.
This commit is contained in:
@@ -9,7 +9,6 @@
|
||||
import type { ReadableAtom } from 'nanostores'
|
||||
import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
|
||||
|
||||
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
|
||||
import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import type { TileDock } from '@/store/session-states'
|
||||
@@ -44,12 +43,7 @@ export interface PaneMirror<T> {
|
||||
tabWrap?: (key: string, tab: ReactElement) => ReactNode
|
||||
/** Override the tile's TAB drag (session drop language: stack/split/link).
|
||||
* Returns whether it took the drag (see PaneChrome.tabDrag). */
|
||||
tabDrag?: (
|
||||
key: string,
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
onTap: () => void,
|
||||
double?: DoubleTapContext
|
||||
) => boolean
|
||||
tabDrag?: (key: string, event: ReactPointerEvent<HTMLElement>, onTap: () => void) => boolean
|
||||
/** Wired as the pane's closer (tab Close). */
|
||||
close: (key: string) => void
|
||||
}
|
||||
@@ -89,11 +83,10 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
||||
minWidth: cfg.minWidth,
|
||||
// Every mirrored tile is a full workspace surface docked beside main —
|
||||
// and closeable, which is what keeps its tab when it lands in a zone of
|
||||
// its own (see lone-header.ts).
|
||||
// its own (see strip-visibility.ts).
|
||||
placement: 'main',
|
||||
tabDrag: cfg.tabDrag
|
||||
? (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) =>
|
||||
cfg.tabDrag!(key, event, onTap, double)
|
||||
? (event: ReactPointerEvent<HTMLElement>, onTap: () => void) => cfg.tabDrag!(key, event, onTap)
|
||||
: undefined, // returns boolean (handled) — see PaneChrome.tabDrag
|
||||
tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined
|
||||
},
|
||||
|
||||
@@ -30,7 +30,6 @@ import type { PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { queryAllVisible } from '@/components/pane-shell/pane-visibility'
|
||||
import { findGroup } from '@/components/pane-shell/tree/model'
|
||||
import {
|
||||
type DoubleTapContext,
|
||||
rectContains,
|
||||
slotBefore,
|
||||
snapshotStrips,
|
||||
@@ -96,14 +95,14 @@ function tileZoneHost(groupId: string): { chat: boolean; pane: string } | null {
|
||||
* Begin dragging a session — a sidebar row OR a tile's own tab (same drop
|
||||
* language either way: stack, split, or composer link). Sub-threshold releases
|
||||
* stay ordinary clicks, so `opts.onTap` (activate the tile) rides the tab's
|
||||
* gesture; Esc aborts instantly. A stack/
|
||||
* split commits through `openSessionTile`, which OPENS a new tile from a sidebar
|
||||
* row and MOVES the existing one when its tab is the drag source.
|
||||
* gesture; Esc aborts instantly. A stack/split commits through
|
||||
* `openSessionTile`, which OPENS a new tile from a sidebar row and MOVES the
|
||||
* existing one when its tab is the drag source.
|
||||
*/
|
||||
export function startSessionDrag(
|
||||
payload: SessionDragPayload,
|
||||
e: ReactPointerEvent<HTMLElement>,
|
||||
opts?: { double?: DoubleTapContext; onTap?: () => void }
|
||||
opts?: { onTap?: () => void }
|
||||
) {
|
||||
let zones: EngineZone[] = []
|
||||
let strips: StripSnapshot[] = []
|
||||
@@ -124,7 +123,6 @@ export function startSessionDrag(
|
||||
const restoreOpacity = source?.style.opacity ?? ''
|
||||
|
||||
startDragSession(e, {
|
||||
double: opts?.double,
|
||||
ghost: { label: sessionLabel(payload) },
|
||||
onTap: opts?.onTap,
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
|
||||
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
|
||||
import { $layoutTree, closeTreePane, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
|
||||
import { $layoutTree, closeTreePane, moveTreePane, setTreeGroupTabStrip } from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { transcribeAudio } from '@/hermes'
|
||||
@@ -560,7 +560,7 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
|
||||
const group = tree ? findGroupOfPane(tree, 'workspace') : null
|
||||
|
||||
if (group) {
|
||||
setTreeGroupHeaderHidden(group.id, true)
|
||||
setTreeGroupTabStrip(group.id, 'never')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { IdleMount } from '@/components/idle-mount'
|
||||
import { $layoutEditMode, toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
|
||||
import { allPaneIds, group, groupLeafIds, split } from '@/components/pane-shell/tree/model'
|
||||
import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer'
|
||||
import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session'
|
||||
import {
|
||||
$layoutTree,
|
||||
bindPaneVisibility,
|
||||
@@ -138,14 +137,14 @@ const workspaceDragPayload = (): SessionDragPayload | null => {
|
||||
// The main tab drags like a session tile — drop it on a composer to link the
|
||||
// chat, on a zone/edge to stack/split. Defers (`false`) to the generic pane
|
||||
// move when there's no loaded session to carry.
|
||||
const workspaceTabDrag = (event: ReactPointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) => {
|
||||
const workspaceTabDrag = (event: ReactPointerEvent<HTMLElement>, onTap: () => void) => {
|
||||
const payload = workspaceDragPayload()
|
||||
|
||||
if (!payload) {
|
||||
return false
|
||||
}
|
||||
|
||||
startSessionDrag(payload, event, { double, onTap })
|
||||
startSessionDrag(payload, event, { onTap })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -147,10 +147,13 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
|
||||
/>
|
||||
)
|
||||
|
||||
// FULL-PAGE views (not chat): a page is not a tab-able surface, so the
|
||||
// zone's tab strip stands down while one is showing (paneChrome.headerVeto).
|
||||
// FULL-PAGE views (not chat): a page is not a tab-able surface, so the zone's
|
||||
// tab strip stands down while one is showing. That is `paneChrome.headerVeto`
|
||||
// on the contribution, not a DOM marker — the `data-zone-no-header` attribute
|
||||
// that used to ride this wrapper gated a body double-click toggle that no
|
||||
// longer exists, and nothing has read it since.
|
||||
const page = (view: ReactNode) => (
|
||||
<div className="contents" data-zone-no-header>
|
||||
<div className="contents">
|
||||
<Suspense fallback={null}>{view}</Suspense>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -180,11 +180,12 @@ describe('enforced dock (stacked Bots pane → sessions-zone tab, every boot)',
|
||||
expect(group.panes).toEqual(['sessions', 'hermes-bots:pane'])
|
||||
})
|
||||
|
||||
it('forces the tab strip visible when already co-located but hidden with bots active (community "only Bots shows" regression)', async () => {
|
||||
it('shows the tab strip when already co-located but hidden with bots active (community "only Bots shows" regression)', async () => {
|
||||
// The Aug 2026 field reports: sessions+bots already share one group, the
|
||||
// strip is hidden (headerHidden), and bots holds the active tab — the
|
||||
// sessions pane exists but is unreachable. The re-home path never runs
|
||||
// (nothing to move), so the enforce must repair reachability directly.
|
||||
// legacy strip flag is set, and bots holds the active tab — the sessions
|
||||
// pane exists but is unreachable. The re-home path never runs (nothing to
|
||||
// move), so reachability has to come from somewhere else: the migration
|
||||
// drops the legacy flag, and a two-pane zone on auto shows its strip.
|
||||
const hiddenStackedTree = {
|
||||
type: 'split',
|
||||
id: 'root',
|
||||
@@ -208,10 +209,10 @@ describe('enforced dock (stacked Bots pane → sessions-zone tab, every boot)',
|
||||
|
||||
const group = model.findGroupOfPane(tree.$layoutTree.get()!, 'hermes-bots:pane')!
|
||||
|
||||
// Both panes stay put — but the strip is forced visible so SESSIONS is
|
||||
// reachable again. The active tab is NOT stolen mid-boot.
|
||||
// Both panes stay put — but the strip is visible so SESSIONS is reachable
|
||||
// again. The active tab is NOT stolen mid-boot.
|
||||
expect(group.panes).toEqual(['sessions', 'hermes-bots:pane'])
|
||||
expect(group.headerHidden).not.toBe(true)
|
||||
expect(tree.tabStripVisibleForGroup(group)).toBe(true)
|
||||
})
|
||||
|
||||
it('re-homes an edge-enforced pane stranded in the sessions tab strip', async () => {
|
||||
|
||||
@@ -15,6 +15,20 @@
|
||||
|
||||
export type Orientation = 'row' | 'column'
|
||||
|
||||
/**
|
||||
* A zone's STANDING CHOICE about its tab strip. Absent is the third value and
|
||||
* the default: AUTO, where the strip's presence is a pure function of what the
|
||||
* zone currently holds (see `resolveTabStripVisible`).
|
||||
*
|
||||
* This replaced a `headerHidden?: boolean` that tried to carry both the user's
|
||||
* choice and the layout's own repairs in one field. `false` there meant either
|
||||
* "the user wants the strip" or "some code path pinned it visible to escape a
|
||||
* dead end" — insert, tab cycling, dock enforcement and pane adoption all wrote
|
||||
* it — so a repair permanently overwrote a choice and neither could be read
|
||||
* back. Only the user writes `tabStrip`; everything else asks AUTO.
|
||||
*/
|
||||
export type TabStripMode = 'always' | 'never'
|
||||
|
||||
export interface SplitNode {
|
||||
type: 'split'
|
||||
id: string
|
||||
@@ -33,12 +47,10 @@ export interface GroupNode {
|
||||
active: string
|
||||
/** Collapsed to header strip (chevron restores). */
|
||||
minimized?: boolean
|
||||
/**
|
||||
* Header hidden entirely (double-click the header to hide, double-click the
|
||||
* zone's top edge to bring it back). Minimize always shows the header —
|
||||
* a minimized group IS its header.
|
||||
*/
|
||||
headerHidden?: boolean
|
||||
/** The user's standing choice for this zone's strip; absent = auto. Written
|
||||
* only by the zone menu and the toggle command. Minimize ignores it — a
|
||||
* minimized group IS its strip. */
|
||||
tabStrip?: TabStripMode
|
||||
}
|
||||
|
||||
export type LayoutNode = SplitNode | GroupNode
|
||||
@@ -57,7 +69,7 @@ export const group = (panes: string[], options?: Partial<Omit<GroupNode, 'type'
|
||||
panes,
|
||||
active: options?.active ?? panes[0] ?? '',
|
||||
minimized: options?.minimized,
|
||||
headerHidden: options?.headerHidden
|
||||
tabStrip: options?.tabStrip
|
||||
})
|
||||
|
||||
export const split = (
|
||||
@@ -156,12 +168,10 @@ export function normalize(node: LayoutNode): LayoutNode | null {
|
||||
|
||||
const active = node.panes.includes(node.active) ? node.active : node.panes[0]
|
||||
|
||||
// NOTE: `headerHidden` is deliberately untouched here. A zone down to one
|
||||
// pane is headerless by default anyway, so a stored `true` is visually
|
||||
// redundant *while it's alone* — but normalize used to DROP it, which threw
|
||||
// away the user's standing choice: the bar came back the moment a pane
|
||||
// rejoined (close a stacked tool panel, toggle it back on). `false` is
|
||||
// sticky for the mirror reason — once a zone has had a tab bar, it keeps it.
|
||||
// `tabStrip` is deliberately untouched: it is the user's standing choice
|
||||
// about this zone, not a derived attribute, so no structural edit may
|
||||
// launder it. (Its predecessor `headerHidden` had to be reasoned about here
|
||||
// precisely because the layout wrote to it too.)
|
||||
if (active === node.active) {
|
||||
return node
|
||||
}
|
||||
@@ -262,15 +272,17 @@ export function insertAtGroup(
|
||||
const at = before ? n.panes.indexOf(before) : -1
|
||||
const panes = at >= 0 ? [...n.panes.slice(0, at), paneId, ...n.panes.slice(at)] : [...n.panes, paneId]
|
||||
|
||||
// Gaining a pane pins the header EXPLICITLY shown (not just cleared):
|
||||
// a stack you can't see is a trap, and once a zone has ever stacked
|
||||
// the bar STAYS when it drops back to one tab — the auto-hide flicker
|
||||
// while dragging tabs around felt broken. Hiding is the user's call
|
||||
// (double-click / zone menu). Active moves only on a gesture; an empty
|
||||
// target has no prior tab, so the newcomer takes it regardless.
|
||||
// `tabStrip` is NOT touched. Gaining a pane used to pin the strip
|
||||
// visible so a surprise arrival always had a handle, which is how a
|
||||
// deliberate hide came undone by a background adoption. Reachability
|
||||
// is the resolver's job now, and it answers per-pane: a closeable tile
|
||||
// forces the strip open, a stack of tool panels doesn't need it
|
||||
// because tab cycling already reaches every member.
|
||||
// Active moves only on a gesture; an empty target has no prior tab, so
|
||||
// the newcomer takes it regardless.
|
||||
const active = activate || n.panes.length === 0 ? paneId : n.active
|
||||
|
||||
return { ...n, panes, active, headerHidden: false }
|
||||
return { ...n, panes, active }
|
||||
}
|
||||
|
||||
const orientation: Orientation = pos === 'left' || pos === 'right' ? 'row' : 'column'
|
||||
@@ -530,8 +542,9 @@ export function setGroupMinimized(root: LayoutNode, groupId: string, minimized:
|
||||
return mapGroups(root, g => (g.id === groupId ? { ...g, minimized } : g))
|
||||
}
|
||||
|
||||
export function setGroupHeaderHidden(root: LayoutNode, groupId: string, headerHidden: boolean): LayoutNode {
|
||||
return mapGroups(root, g => (g.id === groupId ? { ...g, headerHidden } : g))
|
||||
/** Write a zone's standing strip choice; `undefined` returns it to auto. */
|
||||
export function setGroupTabStrip(root: LayoutNode, groupId: string, tabStrip: TabStripMode | undefined): LayoutNode {
|
||||
return mapGroups(root, g => (g.id === groupId ? { ...g, tabStrip } : g))
|
||||
}
|
||||
|
||||
function replaceNode(node: LayoutNode, id: string, make: (g: GroupNode) => LayoutNode): LayoutNode {
|
||||
@@ -577,6 +590,33 @@ export function setSplitWeights(root: LayoutNode, splitId: string, weights: numb
|
||||
// Validation (persisted trees are untrusted)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bring a persisted tree onto the current attribute schema.
|
||||
*
|
||||
* Retires `headerHidden` outright rather than translating it. A stored `true`
|
||||
* could have come from a deliberate "Hide header", or from a double-tap the
|
||||
* user never meant (that gesture rode every tab, so an ordinary double-click
|
||||
* on a title hid the strip), and nothing on disk distinguishes them. Since the
|
||||
* hide also unmounted the only surface offering "Show header", every wrongly
|
||||
* hidden zone stayed hidden across restarts — the state people actually
|
||||
* reported being stuck in. Carrying those forward as `tabStrip: 'never'` would
|
||||
* re-strand exactly them, so the flag is dropped and the zone returns to auto;
|
||||
* the strip is now hidden deliberately, from controls that say how to undo it.
|
||||
*
|
||||
* A stored `false` is dropped for the same reason in reverse: most were written
|
||||
* by the layout's own repair paths, not by anyone choosing to see a strip.
|
||||
*/
|
||||
export function migratePersistedTree(node: LayoutNode): LayoutNode {
|
||||
if (node.type === 'group') {
|
||||
const { headerHidden, ...rest } = node as GroupNode & { headerHidden?: unknown }
|
||||
const tabStrip = rest.tabStrip === 'always' || rest.tabStrip === 'never' ? rest.tabStrip : undefined
|
||||
|
||||
return headerHidden === undefined && rest.tabStrip === tabStrip ? node : { ...rest, tabStrip }
|
||||
}
|
||||
|
||||
return { ...node, children: node.children.map(migratePersistedTree) }
|
||||
}
|
||||
|
||||
export function isLayoutNode(value: unknown): value is LayoutNode {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
|
||||
@@ -155,17 +155,10 @@ const sameHint = (a: DropHint | null, b: DropHint | null) =>
|
||||
(a?.groupIds?.length ?? 0) === (b?.groupIds?.length ?? 0) &&
|
||||
(a?.groupIds ?? []).every((id, i) => b?.groupIds?.[i] === id)
|
||||
|
||||
/** Double-tap detection for drag handles. Pane handles preventDefault
|
||||
* pointerdown, which suppresses native `dblclick` — so rapid same-handle
|
||||
* taps are detected here instead. */
|
||||
const DOUBLE_TAP_MS = 400
|
||||
let lastTap: { key: string; time: number } | null = null
|
||||
|
||||
export interface DoubleTapContext {
|
||||
/** Two sub-threshold releases with the same key within DOUBLE_TAP_MS. */
|
||||
key: string
|
||||
onDoubleTap: () => void
|
||||
}
|
||||
// Drag handles carry NO double-tap. Handles preventDefault pointerdown, so a
|
||||
// synthesized one is the only way to get it here — and a gesture this machinery
|
||||
// hands to every handle at once is the wrong home for anything destructive.
|
||||
// Trackpad double-tap is a separate concern: `@/lib/trackpad-gestures`.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The generic drag session (machinery) — resolvers plug in below / elsewhere.
|
||||
@@ -186,7 +179,6 @@ export interface DragSessionSpec {
|
||||
onEnd?(): void
|
||||
/** Sub-threshold release = a click on the handle. */
|
||||
onTap?(): void
|
||||
double?: DoubleTapContext
|
||||
/** Floating chip following the pointer — for drags whose source doesn't
|
||||
* stay visibly "held" (a sidebar row, unlike a dimmed tab). See
|
||||
* `@/lib/drag-ghost`. */
|
||||
@@ -218,10 +210,10 @@ function suppressDragClick(committed: boolean) {
|
||||
|
||||
/**
|
||||
* Begin a drag session from a handle's pointerdown. A sub-threshold release
|
||||
* is a click (`onTap` / `double.onDoubleTap`); past the threshold the spec's
|
||||
* resolver owns targeting and the machinery owns everything else. Esc aborts
|
||||
* instantly: the session registers as the TOP escape layer, tears down
|
||||
* synchronously, and nothing commits.
|
||||
* is a click (`onTap`); past the threshold the spec's resolver owns targeting
|
||||
* and the machinery owns everything else. Esc aborts instantly: the session
|
||||
* registers as the TOP escape layer, tears down synchronously, and nothing
|
||||
* commits.
|
||||
*/
|
||||
export function startDragSession(e: ReactPointerEvent<HTMLElement>, spec: DragSessionSpec) {
|
||||
if (e.button !== 0) {
|
||||
@@ -365,15 +357,7 @@ export function startDragSession(e: ReactPointerEvent<HTMLElement>, spec: DragSe
|
||||
spec.onCommit($dropHint.get())
|
||||
}
|
||||
} else if (commit) {
|
||||
const now = Date.now()
|
||||
|
||||
if (spec.double && lastTap?.key === spec.double.key && now - lastTap.time < DOUBLE_TAP_MS) {
|
||||
lastTap = null
|
||||
spec.double.onDoubleTap()
|
||||
} else {
|
||||
lastTap = spec.double ? { key: spec.double.key, time: now } : null
|
||||
spec.onTap?.()
|
||||
}
|
||||
spec.onTap?.()
|
||||
}
|
||||
|
||||
spec.onEnd?.()
|
||||
@@ -418,14 +402,13 @@ const TEAR_OFF_SLACK_PX = 18
|
||||
|
||||
/**
|
||||
* Begin a pane drag from any handle. A sub-threshold release is a click
|
||||
* (`onTap`, used to activate tabs; rapid repeat fires `double.onDoubleTap`
|
||||
* instead). With a `reorder` context (tab drags), movement inside the strip
|
||||
* targets an insertion slot — the strip renders a divider at it, NOTHING
|
||||
* moves until release (placement-on-release, like every other drop); tearing
|
||||
* away from the strip converts the drag into a zone move. Zone mode: zones
|
||||
* light up, the target's tab strip stacks at its divider slot, Shift extends
|
||||
* the highlight range, release drops into the ClosestCenter primary zone.
|
||||
* Esc aborts either mode.
|
||||
* (`onTap`, used to activate tabs). With a `reorder` context (tab drags),
|
||||
* movement inside the strip targets an insertion slot — the strip renders a
|
||||
* divider at it, NOTHING moves until release (placement-on-release, like every
|
||||
* other drop); tearing away from the strip converts the drag into a zone move.
|
||||
* Zone mode: zones light up, the target's tab strip stacks at its divider slot,
|
||||
* Shift extends the highlight range, release drops into the ClosestCenter
|
||||
* primary zone. Esc aborts either mode.
|
||||
*
|
||||
* `ghostLabel` opts into the pointer-following chip (`@/lib/drag-ghost`) — the
|
||||
* same "what am I holding" affordance sessions use. The in-strip dim only
|
||||
@@ -437,7 +420,6 @@ export function startPaneDrag(
|
||||
e: ReactPointerEvent<HTMLElement>,
|
||||
onTap?: () => void,
|
||||
reorder?: ReorderContext,
|
||||
double?: DoubleTapContext,
|
||||
ghostLabel?: string,
|
||||
/** Multi-tab selection riding this drag (strip order, includes `paneId`).
|
||||
* The whole block moves/reorders together; `paneId` stays the pressed tab
|
||||
@@ -504,7 +486,6 @@ export function startPaneDrag(
|
||||
Boolean(reorder) && rectContains(reorderStrip().rect, x, y, TEAR_OFF_SLACK_PX)
|
||||
|
||||
startDragSession(e, {
|
||||
double,
|
||||
ghost: ghostLabel ? { label: ghostLabel } : undefined,
|
||||
onTap,
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { forceLoneHeaderForPanes } from './lone-header'
|
||||
|
||||
describe('forceLoneHeaderForPanes', () => {
|
||||
const chrome =
|
||||
(placement?: string, uncloseable = false) =>
|
||||
() => ({ placement, uncloseable })
|
||||
|
||||
const noCollapse = () => false
|
||||
|
||||
// Every mirrored tile (session / page / preview) is a closeable `main` pane, so
|
||||
// dragging one into a zone of its own must keep its tab — it used to strand a
|
||||
// preview headerless, with nothing to grab and no ✕.
|
||||
it('forces a header for closeable placement:main panes', () => {
|
||||
expect(forceLoneHeaderForPanes(['preview-tile:url:x'], chrome('main'), noCollapse)).toBe(true)
|
||||
expect(forceLoneHeaderForPanes(['session-tile:abc'], chrome('main'), noCollapse)).toBe(true)
|
||||
})
|
||||
|
||||
it('forces a header for a lone collapse tool pane', () => {
|
||||
expect(
|
||||
forceLoneHeaderForPanes(
|
||||
['terminal'],
|
||||
() => ({}),
|
||||
id => id === 'terminal'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a lone uncloseable workspace headerless', () => {
|
||||
expect(forceLoneHeaderForPanes(['workspace'], chrome('main', true), noCollapse)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves standing side chrome (files / sessions) headerless', () => {
|
||||
expect(forceLoneHeaderForPanes(['files'], chrome('right'), noCollapse)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* When a lone pane must keep its tab strip (name card + close).
|
||||
*
|
||||
* Default: a single pane isn't a "tab", so the header auto-hides. Exceptions
|
||||
* force it on so a closeable surface never becomes an unclosable dead zone:
|
||||
* - a closeable `placement: 'main'` pane — every mirrored TILE (a session, a
|
||||
* page, a preview) is one, so dragging a tile into a zone of its own keeps
|
||||
* its tab and its ✕
|
||||
* - a collapse tool panel dragged into its own zone
|
||||
*/
|
||||
|
||||
export interface LoneHeaderChrome {
|
||||
placement?: string
|
||||
uncloseable?: boolean
|
||||
}
|
||||
|
||||
export function forceLoneHeaderForPanes(
|
||||
shown: readonly string[],
|
||||
chromeOf: (id: string) => LoneHeaderChrome,
|
||||
isCollapsePane: (id: string) => boolean
|
||||
): boolean {
|
||||
// "This pane can be closed, so it must expose the ✕." Only the uncloseable
|
||||
// workspace is exempt; standing side chrome (files / sessions) isn't 'main'.
|
||||
if (
|
||||
shown.some(id => {
|
||||
const chrome = chromeOf(id)
|
||||
|
||||
return !chrome.uncloseable && chrome.placement === 'main'
|
||||
})
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return shown.length === 1 && isCollapsePane(shown[0])
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Contribution } from '@/contrib/types'
|
||||
import { setTabStripDefault } from '@/store/tabstrip-prefs'
|
||||
|
||||
import { resolveTabStripVisible, type StripPane, tabStripVisibleForZone } from './strip-visibility'
|
||||
|
||||
const tile = (): StripPane => ({ collapsePane: false, placement: 'main' })
|
||||
const workspace = (): StripPane => ({ collapsePane: false, placement: 'main', uncloseable: true })
|
||||
const toolPanel = (): StripPane => ({ collapsePane: true, placement: 'bottom' })
|
||||
const sideChrome = (): StripPane => ({ collapsePane: false, placement: 'right' })
|
||||
|
||||
describe('auto (no stored choice)', () => {
|
||||
it('gives a lone workspace no strip and a stack of two a strip', () => {
|
||||
expect(resolveTabStripVisible({ shown: [workspace()] })).toBe(false)
|
||||
expect(resolveTabStripVisible({ shown: [workspace(), sideChrome()] })).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves standing side chrome alone in its own zone', () => {
|
||||
expect(resolveTabStripVisible({ shown: [sideChrome()] })).toBe(false)
|
||||
})
|
||||
|
||||
it('has nothing to draw for an empty zone', () => {
|
||||
expect(resolveTabStripVisible({ shown: [] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the stored choice', () => {
|
||||
it('overrides auto in both directions', () => {
|
||||
expect(resolveTabStripVisible({ mode: 'always', shown: [workspace()] })).toBe(true)
|
||||
expect(resolveTabStripVisible({ mode: 'never', shown: [workspace(), sideChrome()] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// THE invariant the old boolean could not hold. `never` used to sit above the
|
||||
// force-visible rule, so hiding a zone that held only a closeable tile left a
|
||||
// surface with no tab, no ✕ and no menu — the "how do I get it back" reports.
|
||||
describe('no dead zone', () => {
|
||||
it('keeps the strip for a closeable tile even when the zone says never', () => {
|
||||
expect(resolveTabStripVisible({ mode: 'never', shown: [tile()] })).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the strip for a lone tool panel even when the zone says never', () => {
|
||||
expect(resolveTabStripVisible({ mode: 'never', shown: [toolPanel()] })).toBe(true)
|
||||
})
|
||||
|
||||
it('still hides a zone that cannot strand anything', () => {
|
||||
// The workspace is uncloseable, and a stack is reachable by tab cycling —
|
||||
// the invariant protects handles, it does not veto hiding as such.
|
||||
expect(resolveTabStripVisible({ mode: 'never', shown: [workspace()] })).toBe(false)
|
||||
expect(resolveTabStripVisible({ mode: 'never', shown: [toolPanel(), toolPanel()] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// A full-page view is not a tab-able surface, and it lifts itself the moment
|
||||
// the chat comes back — so it outranks even the stranding rule and, unlike
|
||||
// `mode`, is never written to the tree.
|
||||
describe('a full-page view', () => {
|
||||
it('suppresses the strip regardless of what the zone holds or says', () => {
|
||||
expect(resolveTabStripVisible({ headerVeto: true, mode: 'always', shown: [tile()] })).toBe(false)
|
||||
expect(resolveTabStripVisible({ headerVeto: true, shown: [workspace(), tile()] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// The adapter both TreeGroup and the store call. Its job is to read the same
|
||||
// chrome flags and fold in the app-wide default on both paths, so the strip on
|
||||
// screen and the toggle command can never disagree.
|
||||
describe('tabStripVisibleForZone', () => {
|
||||
const contributions: Record<string, Contribution> = {
|
||||
terminal: { area: 'panes', data: { placement: 'bottom' }, id: 'terminal', render: () => null, title: 'terminal' },
|
||||
'tile:a': { area: 'panes', data: { placement: 'main' }, id: 'tile:a', render: () => null, title: 'tile' },
|
||||
workspace: {
|
||||
area: 'panes',
|
||||
data: { placement: 'main', uncloseable: true },
|
||||
id: 'workspace',
|
||||
render: () => null,
|
||||
title: 'chat'
|
||||
}
|
||||
}
|
||||
|
||||
const visible = (shown: string[], mode?: 'always' | 'never') =>
|
||||
tabStripVisibleForZone({
|
||||
active: shown[0],
|
||||
isCollapsePane: id => id === 'terminal',
|
||||
mode,
|
||||
paneFor: id => contributions[id],
|
||||
shown
|
||||
})
|
||||
|
||||
afterEach(() => setTabStripDefault('auto'))
|
||||
|
||||
it('reads placement, uncloseable and collapse off the contributions', () => {
|
||||
expect(visible(['workspace'])).toBe(false)
|
||||
expect(visible(['tile:a'], 'never')).toBe(true)
|
||||
expect(visible(['terminal'], 'never')).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the app default when the zone has no choice', () => {
|
||||
setTabStripDefault('always')
|
||||
expect(visible(['workspace'])).toBe(true)
|
||||
|
||||
setTabStripDefault('never')
|
||||
expect(visible(['workspace', 'terminal'])).toBe(false)
|
||||
})
|
||||
|
||||
it("lets a zone's own choice beat the app default", () => {
|
||||
setTabStripDefault('never')
|
||||
expect(visible(['workspace'], 'always')).toBe(true)
|
||||
|
||||
setTabStripDefault('always')
|
||||
expect(visible(['workspace'], 'never')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Does this zone show its tab strip? One resolver, one precedence order, so
|
||||
* every caller gets the same answer and the rule can be read in one place.
|
||||
*
|
||||
* The decision used to be an inline expression in TreeGroup fed by a flag four
|
||||
* other code paths also wrote to, which is how a zone could end up with no
|
||||
* strip, no tab, no ✕ and no menu to get any of them back. The ladder below is
|
||||
* the whole policy; nothing outside `mode` is persisted, so a zone's chrome is
|
||||
* a function of what it currently holds plus one deliberate choice.
|
||||
*/
|
||||
|
||||
import type { Contribution } from '@/contrib/types'
|
||||
import { effectiveTabStripMode } from '@/store/tabstrip-prefs'
|
||||
|
||||
import type { TabStripMode } from '../model'
|
||||
|
||||
import { paneChrome } from './track-model'
|
||||
|
||||
export interface StripPane {
|
||||
/** A tool panel (terminal / logs) that collapses rather than closes. */
|
||||
collapsePane: boolean
|
||||
/** Contribution placement — `'main'` marks a docked tile (session, page,
|
||||
* preview) as opposed to standing side chrome. */
|
||||
placement?: string
|
||||
/** Panes that never leave the tree (the workspace). */
|
||||
uncloseable?: boolean
|
||||
}
|
||||
|
||||
export interface StripZone {
|
||||
/** The ACTIVE pane declines to be tabbed (a full-page view). */
|
||||
headerVeto?: boolean
|
||||
/** The zone's standing choice; undefined = auto. */
|
||||
mode?: TabStripMode
|
||||
/** Panes currently rendered as chips — chrome-hidden and narrow-collapsed
|
||||
* panes are already filtered out. */
|
||||
shown: readonly StripPane[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A pane is STRANDED without a strip when the strip is the only thing carrying
|
||||
* its handle: a closeable tile needs its ✕, a lone tool panel needs a chip to
|
||||
* grab. The uncloseable workspace is not strandable — it cannot be closed or
|
||||
* lost, so a lone chat is free to be chromeless.
|
||||
*
|
||||
* This outranks an explicit `never` on purpose. "Hide the strip" is a request
|
||||
* about chrome, never a request to make a surface unreachable, and a zone that
|
||||
* answers no gesture at all is not a state any setting should be able to
|
||||
* produce. Hiding still works everywhere it cannot trap you.
|
||||
*/
|
||||
function stranded(shown: readonly StripPane[]): boolean {
|
||||
if (shown.some(pane => !pane.uncloseable && pane.placement === 'main')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return shown.length === 1 && shown[0].collapsePane
|
||||
}
|
||||
|
||||
export function resolveTabStripVisible(zone: StripZone): boolean {
|
||||
if (zone.shown.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// A page is not a tab-able surface. Contextual and self-lifting: the strip
|
||||
// returns with the chat, so it is resolved ahead of any stored choice and
|
||||
// never written down.
|
||||
if (zone.headerVeto) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (stranded(zone.shown)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (zone.mode) {
|
||||
return zone.mode === 'always'
|
||||
}
|
||||
|
||||
// Auto: a lone pane is not a "tab", so it goes without a strip; two or more
|
||||
// need one to switch between them.
|
||||
return zone.shown.length > 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a zone straight from what the layout knows about it. Both callers —
|
||||
* TreeGroup from its render inputs, the store from the registry — go through
|
||||
* here, so neither can drift on which chrome flags feed the answer or forget to
|
||||
* fold in the app-wide default.
|
||||
*/
|
||||
export function tabStripVisibleForZone(zone: {
|
||||
/** The zone's ACTIVE pane. */
|
||||
active: string
|
||||
isCollapsePane: (id: string) => boolean
|
||||
/** The zone's own choice, before the app default applies. */
|
||||
mode: TabStripMode | undefined
|
||||
paneFor: (id: string) => Contribution | undefined
|
||||
/** Panes currently rendered as chips. */
|
||||
shown: readonly string[]
|
||||
}): boolean {
|
||||
return resolveTabStripVisible({
|
||||
headerVeto: paneChrome(zone.paneFor(zone.active)).headerVeto,
|
||||
mode: effectiveTabStripMode(zone.mode),
|
||||
shown: zone.shown.map(id => ({
|
||||
collapsePane: zone.isCollapsePane(id),
|
||||
placement: paneChrome(zone.paneFor(id)).placement,
|
||||
uncloseable: paneChrome(zone.paneFor(id)).uncloseable
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -4,8 +4,15 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
|
||||
import { group, split } from '../model'
|
||||
import { $layoutTree, markCollapsePane, registerPaneCloser } from '../store'
|
||||
import { group, type GroupNode, split } from '../model'
|
||||
import {
|
||||
$layoutTree,
|
||||
markCollapsePane,
|
||||
registerPaneCloser,
|
||||
setTreeGroupTabStrip,
|
||||
tabStripVisibleForGroup,
|
||||
toggleTargetZoneTabStrip
|
||||
} from '../store'
|
||||
|
||||
import { TreeGroup } from './tree-group'
|
||||
|
||||
@@ -16,8 +23,9 @@ function LiveTreeGroup() {
|
||||
return <TreeGroup node={zoneAt(0)} parentAxis="column" />
|
||||
}
|
||||
|
||||
// Pins the tab-strip hide grammar: the double-tap hide belongs to the STRIP
|
||||
// BACKGROUND alone — tabs are activate-only and must never hide the bar.
|
||||
// Pins the tab-strip hide grammar. Hiding is a COMMAND now, not a gesture: the
|
||||
// pointer can no longer take the strip away by accident, and whatever does take
|
||||
// it away leaves a way back that does not depend on the chrome it just removed.
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
@@ -53,6 +61,8 @@ beforeEach(async () => {
|
||||
|
||||
markCollapsePane('terminal')
|
||||
registerPaneCloser('terminal', () => undefined)
|
||||
|
||||
$layoutTree.set(split('column', [group(['workspace', 'terminal'], { active: 'terminal', id: 'grp-main' })]))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -69,13 +79,13 @@ const zoneAt = (index: number) => {
|
||||
const groupNode = () => {
|
||||
const node = $layoutTree.get()!
|
||||
|
||||
return (node.type === 'split' ? node.children[0] : node) as { headerHidden?: boolean; panes: string[] }
|
||||
return (node.type === 'split' ? node.children[0] : node) as GroupNode
|
||||
}
|
||||
|
||||
const tablist = () => globalThis.document.querySelector('[role="tablist"]')
|
||||
|
||||
/** Two sub-threshold taps: pointerdown on the target, pointerup on window
|
||||
* (drag-session listens there), twice — the synthesized double-tap path. */
|
||||
* (drag-session listens there), twice — the retired double-tap path. */
|
||||
const doubleTap = (target: Element) => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
fireEvent.pointerDown(target, { button: 0, clientX: 10, clientY: 10, pointerType: 'mouse' })
|
||||
@@ -84,35 +94,33 @@ const doubleTap = (target: Element) => {
|
||||
}
|
||||
|
||||
describe('tab strip hide grammar', () => {
|
||||
it('double-clicking a tab does NOT hide the strip', () => {
|
||||
// $layoutTree.set, not declareDefaultTree — the latter only adopts into an
|
||||
// existing tree, and the store is module state that survives between tests.
|
||||
$layoutTree.set(
|
||||
split('column', [group(['workspace', 'terminal'], { active: 'terminal', id: 'grp-main' })])
|
||||
)
|
||||
it('no pointer gesture hides the strip', () => {
|
||||
render(<LiveTreeGroup />)
|
||||
|
||||
const tab = globalThis.document.querySelector<HTMLElement>('[data-tree-tab="terminal"]')
|
||||
expect(tab).toBeTruthy()
|
||||
// Both halves of the strip: the tab, which was always activate-only, and
|
||||
// the background, which used to answer a double-tap nothing announced.
|
||||
doubleTap(globalThis.document.querySelector('[data-tree-tab="terminal"]')!)
|
||||
doubleTap(globalThis.document.querySelector('[data-zone-tabstrip="grp-main"]')!)
|
||||
|
||||
doubleTap(tab!)
|
||||
|
||||
// The strip is still there and the tree never recorded a hide.
|
||||
expect(tablist()).toBeTruthy()
|
||||
expect(groupNode().headerHidden).not.toBe(true)
|
||||
expect(groupNode().tabStrip).toBeUndefined()
|
||||
})
|
||||
|
||||
it('double-tapping the strip background still hides the header (documented gesture)', () => {
|
||||
$layoutTree.set(
|
||||
split('column', [group(['workspace', 'terminal'], { active: 'terminal', id: 'grp-main' })])
|
||||
)
|
||||
it('renders no strip at all for a zone set to never', () => {
|
||||
setTreeGroupTabStrip('grp-main', 'never')
|
||||
render(<LiveTreeGroup />)
|
||||
|
||||
const strip = globalThis.document.querySelector<HTMLElement>('[data-zone-tabstrip="grp-main"]')
|
||||
expect(strip).toBeTruthy()
|
||||
expect(tablist()).toBeNull()
|
||||
})
|
||||
|
||||
doubleTap(strip!)
|
||||
// The state that had no way out. The command targets the zone by
|
||||
// hover/focus/workspace fallback, so restoring the strip never depends on
|
||||
// the strip — or on any other chrome the hide took away.
|
||||
it('the toggle command reaches a zone that has no chrome left to click', () => {
|
||||
setTreeGroupTabStrip('grp-main', 'never')
|
||||
expect(tabStripVisibleForGroup(groupNode())).toBe(false)
|
||||
|
||||
expect(groupNode().headerHidden).toBe(true)
|
||||
expect(toggleTargetZoneTabStrip()).toBe('always')
|
||||
expect(tabStripVisibleForGroup(groupNode())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { Contribution } from '@/contrib/types'
|
||||
import type { GroupNode, LayoutNode } from '../model'
|
||||
import { allPaneIds } from '../model'
|
||||
|
||||
import type { DoubleTapContext } from './drag-session'
|
||||
import type { FloatingAnchor } from './floating-rect'
|
||||
|
||||
export const MIN_PANE_PX = 80
|
||||
@@ -78,10 +77,10 @@ interface PaneChrome extends PaneSizing {
|
||||
tabWrap?: (tab: React.ReactElement) => React.ReactNode
|
||||
/** Override this pane's TAB drag (a session tab drags like a sidebar row —
|
||||
* stack / split / composer-link — not the generic pane move). Given the
|
||||
* tab's tap (activate) + double-tap (hide header) so those gestures survive.
|
||||
* Returns whether it took the drag; `false` (or absent) defers to
|
||||
* `startPaneDrag` — e.g. the workspace tab on a fresh draft, nothing to link. */
|
||||
tabDrag?: (event: React.PointerEvent<HTMLElement>, onTap: () => void, double?: DoubleTapContext) => boolean
|
||||
* tab's tap (activate) so that gesture survives. Returns whether it took the
|
||||
* drag; `false` (or absent) defers to `startPaneDrag` — e.g. the workspace
|
||||
* tab on a fresh draft, nothing to link. */
|
||||
tabDrag?: (event: React.PointerEvent<HTMLElement>, onTap: () => void) => boolean
|
||||
/** Suppress the zone header while THIS pane is active — full-page views
|
||||
* (artifacts/skills/plugin pages) are not tab-able surfaces. The flag is
|
||||
* live: the workspace contribution re-registers it on route changes. */
|
||||
|
||||
@@ -27,10 +27,12 @@ function render(ui: ReactNode) {
|
||||
function terminalGroup(minimized: boolean): GroupNode {
|
||||
return {
|
||||
active: 'terminal',
|
||||
headerHidden: false,
|
||||
id: 'terminal-zone',
|
||||
minimized,
|
||||
panes: ['terminal'],
|
||||
// The chevron lives in the strip, so this zone has to be showing one. A
|
||||
// lone unregistered pane is on auto and would render none.
|
||||
tabStrip: 'always',
|
||||
type: 'group'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { $layoutEditMode } from '../../edit-mode'
|
||||
@@ -58,8 +59,8 @@ import {
|
||||
restoreTreePane,
|
||||
SESSION_TILE_DRAG,
|
||||
setStripTabHidden,
|
||||
setTreeGroupHeaderHidden,
|
||||
setTreeGroupMinimized,
|
||||
setTreeGroupTabStrip,
|
||||
treeTabCloseTargets
|
||||
} from '../store'
|
||||
import {
|
||||
@@ -71,8 +72,8 @@ import {
|
||||
toggleTabSelected
|
||||
} from '../tab-selection'
|
||||
|
||||
import { type DoubleTapContext, startPaneDrag } from './drag-session'
|
||||
import { forceLoneHeaderForPanes } from './lone-header'
|
||||
import { startPaneDrag } from './drag-session'
|
||||
import { tabStripVisibleForZone } from './strip-visibility'
|
||||
import { useActiveTabVisible } from './tab-strip-scroll'
|
||||
import { paneChrome } from './track-model'
|
||||
|
||||
@@ -85,9 +86,9 @@ function ZoneMenu({
|
||||
children,
|
||||
closable,
|
||||
minimizable = true,
|
||||
headerHidden,
|
||||
minimized,
|
||||
nodeId,
|
||||
stripVisible,
|
||||
targetPane
|
||||
}: {
|
||||
children: ReactNode
|
||||
@@ -97,9 +98,11 @@ function ZoneMenu({
|
||||
/** False for the zone hosting the uncloseable workspace — collapsing the
|
||||
* MAIN pane strands the app behind a strip. */
|
||||
minimizable?: boolean
|
||||
headerHidden?: boolean
|
||||
minimized?: boolean
|
||||
nodeId: string
|
||||
/** Whether the strip is on screen — the Hide/Show row toggles against what
|
||||
* the user can see, not against the stored mode (a zone on auto has none). */
|
||||
stripVisible?: boolean
|
||||
/** The right-clicked chip (else the active pane) — what the close-others /
|
||||
* to-the-right / all verbs measure from. Called when the menu RENDERS, not
|
||||
* on every zone re-render: resolving the siblings reads the layout tree,
|
||||
@@ -108,6 +111,10 @@ function ZoneMenu({
|
||||
targetPane: () => string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
// Hiding the strip takes this menu with it, so the row that hides it is the
|
||||
// last place to say how to get it back — the status bar's hide row does the
|
||||
// same for the same reason.
|
||||
const toggleHint = useKeybindHint('view.toggleTabStrip')
|
||||
|
||||
// Resolved at render: the menu mounts on open, after the right-click set
|
||||
// menuPane — so an uncloseable target hides Close instead of offering a
|
||||
@@ -157,9 +164,17 @@ function ZoneMenu({
|
||||
})()}
|
||||
<kit.Separator />
|
||||
{renderActionItem(kit, {
|
||||
icon: headerHidden ? 'eye' : 'eye-closed',
|
||||
label: headerHidden ? t.zones.showHeader : t.zones.hideHeader,
|
||||
onSelect: () => setTreeGroupHeaderHidden(nodeId, !headerHidden)
|
||||
icon: stripVisible ? 'eye-closed' : 'eye',
|
||||
key: 'zone-tabstrip',
|
||||
label: (
|
||||
<>
|
||||
{/* The hint's `ml-auto` makes the label the row's flexible part,
|
||||
so without this it breaks mid-phrase before the menu widens. */}
|
||||
<span className="whitespace-nowrap">{stripVisible ? t.zones.hideTabStrip : t.zones.showTabStrip}</span>
|
||||
{toggleHint && <span className="ml-auto pl-2 text-(--ui-text-quaternary)">{toggleHint}</span>}
|
||||
</>
|
||||
),
|
||||
onSelect: () => setTreeGroupTabStrip(nodeId, stripVisible ? 'never' : 'always')
|
||||
})}
|
||||
{minimizable &&
|
||||
renderActionItem(kit, {
|
||||
@@ -256,23 +271,17 @@ export function TreeGroup({
|
||||
const paneLifecycle = lifecycleRef.current.entries
|
||||
const keptPanes = shown.filter(id => paneLifecycle[id] && paneLifecycle[id].lifecycle !== 'parked')
|
||||
|
||||
// ONE header style: the app's compact pane-header. DEFAULT is contextual —
|
||||
// a single pane isn't a "tab", so its header auto-hides; a stack shows its
|
||||
// chips. EXCEPTIONS force a lone pane to keep its header (tab + close X):
|
||||
// - a TILE (closeable, placement 'main' — a session/page split), else a
|
||||
// tile in its own zone is unclosable (the "3rd tile has no tab" trap);
|
||||
// - a TOOL PANEL (terminal/logs — a collapse pane) dragged out of the main
|
||||
// stack, else it's a dead zone with no tab to grab or ✕ to close.
|
||||
// The uncloseable workspace and side chrome (sessions/files) keep the clean
|
||||
// no-tab default. Double-click toggles it either way; a minimized group
|
||||
// always shows its header (it IS the header).
|
||||
// Session-tile ids force the header even before chrome registers — cycling
|
||||
// onto a freshly-split tile used to land headerless ("name card missing").
|
||||
const forceLoneHeader = forceLoneHeaderForPanes(shown, id => paneChrome(paneFor(id)), isCollapsePane)
|
||||
|
||||
// A full-page view (headerVeto) suppresses the strip while it's the active
|
||||
// pane — a page is not a tab-able surface; the bar returns with the chat.
|
||||
const headerHidden = paneChrome(active).headerVeto || (node.headerHidden ?? (shown.length <= 1 && !forceLoneHeader))
|
||||
// ONE header style: the app's compact pane-header. Whether this zone shows
|
||||
// it is the resolver's call, not this component's — see strip-visibility.ts
|
||||
// for the precedence. The same resolver answers for the toggle command, so
|
||||
// the keystroke and the screen always agree about which way "toggle" points.
|
||||
const stripVisible = tabStripVisibleForZone({
|
||||
active: activeId,
|
||||
isCollapsePane,
|
||||
mode: node.tabStrip,
|
||||
paneFor,
|
||||
shown
|
||||
})
|
||||
|
||||
// A group collapses ALONG its parent split's axis. In a row that means the
|
||||
// WIDTH collapses — a full-width horizontal header would strand a tall
|
||||
@@ -280,7 +289,8 @@ export function TreeGroup({
|
||||
// (tabs reading top-to-bottom). In a column (stacked zones) the horizontal
|
||||
// header IS the collapsed form, exactly as before.
|
||||
const verticalCollapse = Boolean(node.minimized) && parentAxis === 'row' && !isEmpty
|
||||
const headerVisible = !isEmpty && !verticalCollapse && (Boolean(node.minimized) || !headerHidden)
|
||||
// A minimized group IS its header, so it shows one regardless.
|
||||
const headerVisible = !isEmpty && !verticalCollapse && (Boolean(node.minimized) || stripVisible)
|
||||
|
||||
// Keep the activated tab — and, on the last one, the trailing "+" — inside
|
||||
// the strip's scroll window. Opening a tab past the right edge otherwise
|
||||
@@ -291,18 +301,6 @@ export function TreeGroup({
|
||||
tabCount: shown.length
|
||||
})
|
||||
|
||||
// The STRIP background owns the synthesized double-tap hide — it is the
|
||||
// documented explicit gesture (model.ts). TABS never carry it: a tab is
|
||||
// where people double-click for mundane reasons (select a title, retry a
|
||||
// click), so tab presses pass no double-tap context and stay activate-only.
|
||||
const hideHeaderDoubleTap: DoubleTapContext = {
|
||||
key: `hide-header-${node.id}`,
|
||||
onDoubleTap: () => {
|
||||
setTreeGroupMinimized(node.id, false)
|
||||
setTreeGroupHeaderHidden(node.id, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Zone-menu close targets read the layout tree, but this component must NOT
|
||||
// subscribe to it: `useStore($layoutTree)` here wires every zone — and
|
||||
// therefore every mounted pane and its whole transcript — to the entire
|
||||
@@ -349,19 +347,13 @@ export function TreeGroup({
|
||||
// Same menu on the header strip and the edit veil — one prop bag.
|
||||
const zoneMenu = {
|
||||
closable,
|
||||
headerHidden,
|
||||
minimizable,
|
||||
minimized: node.minimized,
|
||||
nodeId: node.id,
|
||||
stripVisible,
|
||||
targetPane
|
||||
}
|
||||
|
||||
// NO body double-click toggle: virtualized content (the thread) recreates
|
||||
// its nodes between clicks, so the gesture was hopelessly unreliable. The
|
||||
// bar's lifecycle is explicit instead — gaining a tab sticky-shows it
|
||||
// (insertAtGroup pins headerHidden false), the main tab's context menu
|
||||
// hides it, and full-page views veto it via paneChrome.headerVeto.
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-(--ui-editor-surface-background)"
|
||||
@@ -443,17 +435,11 @@ export function TreeGroup({
|
||||
listRef={tabsRef}
|
||||
onPointerDown={e =>
|
||||
// Tap the header to collapse to it / expand back — the DetailPane
|
||||
// / sidebar-section gesture (never for the main zone). The
|
||||
// double-tap hide rides the strip background below, not the tabs.
|
||||
// Drag still moves the pane.
|
||||
startPaneDrag(
|
||||
activeId,
|
||||
e,
|
||||
() => minimizable && toggleCollapse(),
|
||||
undefined,
|
||||
hideHeaderDoubleTap,
|
||||
active?.title ?? activeId
|
||||
)
|
||||
// / sidebar-section gesture (never for the main zone). Drag still
|
||||
// moves the pane. No double-tap hide belongs here: hiding the
|
||||
// strip unmounts every affordance the zone has, including the
|
||||
// menu offering "Show", so it stays a named command.
|
||||
startPaneDrag(activeId, e, () => minimizable && toggleCollapse(), undefined, active?.title ?? activeId)
|
||||
}
|
||||
ref={stripRef}
|
||||
style={{ cursor: 'grab' }}
|
||||
@@ -547,7 +533,6 @@ export function TreeGroup({
|
||||
e,
|
||||
onTap,
|
||||
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
|
||||
undefined,
|
||||
t.zones.tabCount(dragSelection.length),
|
||||
dragSelection
|
||||
)
|
||||
@@ -565,7 +550,6 @@ export function TreeGroup({
|
||||
e,
|
||||
onTap,
|
||||
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
|
||||
undefined,
|
||||
title
|
||||
)
|
||||
}
|
||||
@@ -681,7 +665,7 @@ export function TreeGroup({
|
||||
// barely-tinted wash; the light blur reads as "edit mode" the same
|
||||
// way the zone editor's backdrop does.
|
||||
className="absolute inset-x-0 bottom-0 z-50 flex cursor-grab items-center justify-center outline-1 -outline-offset-2 outline-dashed backdrop-blur-[2px]"
|
||||
onPointerDown={e => startPaneDrag(activeId, e, undefined, undefined, undefined, active?.title ?? activeId)}
|
||||
onPointerDown={e => startPaneDrag(activeId, e, undefined, undefined, active?.title ?? activeId)}
|
||||
style={{
|
||||
top: headerVisible ? 28 : 0,
|
||||
background:
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
isLayoutNode,
|
||||
type LayoutNode,
|
||||
mergeZonesWithPane as mergeZonesWithPaneOp,
|
||||
migratePersistedTree,
|
||||
mirrorTreeHorizontal,
|
||||
movePane as movePaneOp,
|
||||
movePanes as movePanesOp,
|
||||
@@ -34,12 +35,14 @@ import {
|
||||
removePane,
|
||||
reorderPanesInGroup as reorderPanesInGroupOp,
|
||||
setActivePane as setActivePaneOp,
|
||||
setGroupHeaderHidden as setGroupHeaderHiddenOp,
|
||||
setGroupMinimized,
|
||||
setGroupTabStrip as setGroupTabStripOp,
|
||||
setSplitWeights as setSplitWeightsOp,
|
||||
type SplitNode
|
||||
type SplitNode,
|
||||
type TabStripMode
|
||||
} from './model'
|
||||
import { FLOATING_PLACEMENT } from './renderer/floating-rect'
|
||||
import { tabStripVisibleForZone } from './renderer/strip-visibility'
|
||||
import { rootChildSide } from './renderer/track-model'
|
||||
|
||||
// v2: v1 trees were saved against placeholder panes with index-order zone
|
||||
@@ -53,9 +56,10 @@ let defaultTree: LayoutNode | null = null
|
||||
function loadPersisted(): LayoutNode | null {
|
||||
const parsed = readJson<unknown>(STORAGE_KEY)
|
||||
|
||||
// Canonicalize on load: strips stale attributes older code persisted
|
||||
// (e.g. explicit headerHidden on lone-pane zones) and re-flattens.
|
||||
return isLayoutNode(parsed) ? normalize(parsed) : null
|
||||
// Canonicalize on load: bring attributes onto the current schema (see
|
||||
// migratePersistedTree — the retired `headerHidden` is dropped here) and
|
||||
// re-flatten the structure.
|
||||
return isLayoutNode(parsed) ? normalize(migratePersistedTree(parsed)) : null
|
||||
}
|
||||
|
||||
function persist(tree: LayoutNode | null) {
|
||||
@@ -715,6 +719,22 @@ function shownPanesInGroup(group: { panes: readonly string[] }): string[] {
|
||||
})
|
||||
}
|
||||
|
||||
/** Is this zone showing a tab strip right now? The store's adapter over the
|
||||
* shared resolver — TreeGroup answers the same question from its own render
|
||||
* inputs, so the toggle command and the strip on screen cannot disagree about
|
||||
* which way "toggle" points. */
|
||||
export function tabStripVisibleForGroup(group: GroupNode): boolean {
|
||||
const registered = registry.getArea('panes')
|
||||
|
||||
return tabStripVisibleForZone({
|
||||
active: group.active,
|
||||
isCollapsePane,
|
||||
mode: group.tabStrip,
|
||||
paneFor: (id: string) => registered.find(c => c.id === id),
|
||||
shown: shownPanesInGroup(group)
|
||||
})
|
||||
}
|
||||
|
||||
/** ⌘1…⌘9: activate the Nth *visible* tab of the target zone — the first of
|
||||
* hovered / focused / workspace that is a real tab strip (≥2 shown panes).
|
||||
* Pointing at the sidebar (or nothing) therefore still switches main's tabs
|
||||
@@ -761,13 +781,11 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): null | string {
|
||||
const nextId = panes[(idx + direction + panes.length) % panes.length]
|
||||
activateTreePane(group.id, nextId)
|
||||
|
||||
// Cycling onto a session/main tab must surface the name card — a zone that
|
||||
// was double-tap-hidden stays headerless otherwise ("the one that cycles
|
||||
// never gets it").
|
||||
if (isMainStripPane(nextId)) {
|
||||
setTreeGroupHeaderHidden(group.id, false)
|
||||
}
|
||||
|
||||
// No strip repair here: cycling needs two shown tabs, which is exactly when
|
||||
// auto shows a strip anyway. The old force-show existed because a stray
|
||||
// double-tap could leave a multi-tab zone headerless; that gesture is gone,
|
||||
// and a zone the user deliberately set to `never` must not be argued with by
|
||||
// a keystroke that was only asked to change tabs.
|
||||
return nextId
|
||||
}
|
||||
|
||||
@@ -1262,16 +1280,11 @@ function enforceDockedPanes(
|
||||
}
|
||||
|
||||
if (dock.pos === 'center' && from.id === anchor.id) {
|
||||
// Already stacked with its anchor — but an enforced tab must be
|
||||
// REACHABLE, not just co-located. Community regression (Aug 2026):
|
||||
// persisted trees where the enforced pane was center-stacked with the
|
||||
// strip hidden and itself active left the ANCHOR invisible with no
|
||||
// strip to switch back ("my ui only shows bots now... cant find the
|
||||
// sessions"). An enforced zone always shows its strip.
|
||||
if (anchor.headerHidden === true) {
|
||||
next = setGroupHeaderHiddenOp(next, anchor.id, false) ?? next
|
||||
}
|
||||
|
||||
// Already stacked with its anchor, and nothing to repair: the trees that
|
||||
// produced the "my ui only shows bots now... cant find the sessions"
|
||||
// regression carried an accidental `headerHidden: true`, which the load
|
||||
// migration now drops outright. A surviving `never` here is deliberate
|
||||
// and recoverable from the toggle command, so boot does not overrule it.
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1354,14 +1367,14 @@ function adoptContributedPanes(): void {
|
||||
const target = findGroupOfPane(next, anchor ?? '')?.id
|
||||
|
||||
if (target) {
|
||||
// Whether the DESTINATION zone's header was explicitly hidden, read
|
||||
// BEFORE the insert — `insertAtGroup` pins `headerHidden: false` on a
|
||||
// center drop (a stack you can't see is a trap), which is right for a
|
||||
// drag but wrong for adoption into a zone whose bar the user hid.
|
||||
const hostHeaderHidden = findGroup(next, target)?.headerHidden === true
|
||||
|
||||
// Silent adoption: don't front over the zone's active tab — a reveal
|
||||
// does. An edge dock re-takes the share the pane held when it closed.
|
||||
//
|
||||
// Nothing writes the strip choice afterwards. This used to read the
|
||||
// host's hidden flag before the insert and stamp it back on after, purely
|
||||
// to undo the pin `insertAtGroup` applied; with the pin gone the zone's
|
||||
// own preference simply survives, and the adopted pane arrives with a
|
||||
// chip whenever auto says the zone has more than one.
|
||||
next =
|
||||
insertAtGroup(
|
||||
next,
|
||||
@@ -1372,20 +1385,6 @@ function adoptContributedPanes(): void {
|
||||
false,
|
||||
recalledEdgeWeights(pane.id)
|
||||
) ?? next
|
||||
|
||||
// An adopted pane ARRIVES with its chip showing — a surprise zone with
|
||||
// zero chrome has no obvious handle to drag or close. (Explicit reveal;
|
||||
// the next structural op returns lone panes to the auto-hide default.)
|
||||
//
|
||||
// EXCEPT into a zone whose header the user explicitly hid: that's a
|
||||
// standing preference about the zone, not a stale default. Without this
|
||||
// the bar came back every time a tool panel was closed and toggled on
|
||||
// again — Close dismisses the pane, the toggle re-adopts it through here.
|
||||
const landed = findGroupOfPane(next, pane.id)
|
||||
|
||||
if (landed) {
|
||||
next = setGroupHeaderHiddenOp(next, landed.id, hostHeaderHidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1846,15 +1845,50 @@ export function collapseTreePane(paneId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Hide/show a zone's header entirely (double-click gesture). */
|
||||
export function setTreeGroupHeaderHidden(groupId: string, headerHidden: boolean) {
|
||||
/** Write a zone's standing tab-strip choice; `undefined` returns it to auto. */
|
||||
export function setTreeGroupTabStrip(groupId: string, tabStrip: TabStripMode | undefined) {
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
if (tree) {
|
||||
commit(setGroupHeaderHiddenOp(tree, groupId, headerHidden))
|
||||
commit(setGroupTabStripOp(tree, groupId, tabStrip))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone `view.toggleTabStrip` and its ⌘K row act on: the first of hovered /
|
||||
* focused / workspace that renders panes at all. Deliberately the widest
|
||||
* eligibility of any tab verb — the whole point of the command is to reach a
|
||||
* zone showing no chrome, so it must not require the chrome it restores.
|
||||
*/
|
||||
const tabStripTargetGroup = () => tabTargetGroup(candidate => shownPanesInGroup(candidate).length > 0)
|
||||
|
||||
/** Is the toggle's target zone currently showing a strip? Null when no zone
|
||||
* qualifies — the ⌘K row reads this to describe what pressing it will do. */
|
||||
export function targetZoneTabStripVisible(): boolean | null {
|
||||
const group = tabStripTargetGroup()
|
||||
|
||||
return group ? tabStripVisibleForGroup(group) : null
|
||||
}
|
||||
|
||||
/** Flip the target zone's strip. Returns the mode written, or null when there
|
||||
* was no zone to act on. */
|
||||
export function toggleTargetZoneTabStrip(): TabStripMode | null {
|
||||
const group = tabStripTargetGroup()
|
||||
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Toggle against what is ON SCREEN, not against the stored mode: a zone on
|
||||
// auto has no stored mode, and "toggle" means "do the other thing to what I
|
||||
// am looking at". Both outcomes are explicit, so the zone leaves auto either
|
||||
// way rather than drifting with its tab count afterwards.
|
||||
const next: TabStripMode = tabStripVisibleForGroup(group) ? 'never' : 'always'
|
||||
setTreeGroupTabStrip(group.id, next)
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function setTreeSplitWeights(splitId: string, weights: number[]) {
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { type LayoutNode, migratePersistedTree } from './model'
|
||||
|
||||
// A stored `headerHidden: true` is ambiguous — a deliberate "Hide header" and
|
||||
// an accidental double-tap wrote the same byte — and it is the state people got
|
||||
// stuck in, because hiding removed the only control offering to unhide. The
|
||||
// migration therefore drops it rather than translating it to `tabStrip: 'never'`.
|
||||
|
||||
const persisted = (node: unknown) => migratePersistedTree(node as LayoutNode) as never as Record<string, unknown>
|
||||
|
||||
describe('migratePersistedTree', () => {
|
||||
it('returns a hidden zone to auto instead of re-stranding it', () => {
|
||||
const migrated = persisted({
|
||||
active: 'workspace',
|
||||
headerHidden: true,
|
||||
id: 'g',
|
||||
panes: ['workspace'],
|
||||
type: 'group'
|
||||
})
|
||||
|
||||
expect(migrated.headerHidden).toBeUndefined()
|
||||
expect(migrated.tabStrip).toBeUndefined()
|
||||
expect(migrated.panes).toEqual(['workspace'])
|
||||
})
|
||||
|
||||
it('drops a stored false too — the repair paths wrote most of them, not users', () => {
|
||||
expect(persisted({ headerHidden: false, id: 'g', panes: ['workspace'], type: 'group' }).tabStrip).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a tabStrip choice, which only a user can have written', () => {
|
||||
expect(persisted({ id: 'g', panes: ['workspace'], tabStrip: 'never', type: 'group' }).tabStrip).toBe('never')
|
||||
expect(persisted({ id: 'g', panes: ['workspace'], tabStrip: 'always', type: 'group' }).tabStrip).toBe('always')
|
||||
})
|
||||
|
||||
it('discards a tabStrip value outside the schema', () => {
|
||||
expect(persisted({ id: 'g', panes: ['workspace'], tabStrip: 'sometimes', type: 'group' }).tabStrip).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reaches groups nested in splits', () => {
|
||||
const migrated = persisted({
|
||||
children: [
|
||||
{ active: 'workspace', headerHidden: true, id: 'a', panes: ['workspace'], type: 'group' },
|
||||
{
|
||||
children: [{ headerHidden: true, id: 'b', panes: ['terminal'], type: 'group' }],
|
||||
id: 'inner',
|
||||
orientation: 'column',
|
||||
type: 'split',
|
||||
weights: [1]
|
||||
}
|
||||
],
|
||||
id: 'root',
|
||||
orientation: 'row',
|
||||
type: 'split',
|
||||
weights: [1, 1]
|
||||
})
|
||||
|
||||
expect(JSON.stringify(migrated)).not.toContain('headerHidden')
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
closeToolPane,
|
||||
isPaneVisible,
|
||||
revealTreePane,
|
||||
setTreeGroupHeaderHidden,
|
||||
setTreeGroupTabStrip,
|
||||
togglePaneVisible
|
||||
} from './store'
|
||||
|
||||
@@ -69,7 +69,7 @@ const toolZone = () => {
|
||||
? tree.children.find(c => c.type === 'group' && (c.panes.includes('terminal') || c.panes.includes('logs')))
|
||||
: null
|
||||
|
||||
return found as { active?: string; headerHidden?: boolean; minimized?: boolean; panes: string[] } | null
|
||||
return found as { active?: string; minimized?: boolean; panes: string[]; tabStrip?: string } | null
|
||||
}
|
||||
|
||||
/** Terminal dragged to the bottom; logs adopted into the same zone.
|
||||
@@ -77,13 +77,13 @@ const toolZone = () => {
|
||||
* Set via `$layoutTree.set`, NOT `declareDefaultTree` — that only adopts into
|
||||
* an existing tree, and `$layoutTree` is module state that survives between
|
||||
* tests, so the second case would silently assert against the first's shape. */
|
||||
const stackTree = (options?: { active?: string; headerHidden?: boolean }) => {
|
||||
const stackTree = (options?: { active?: string; tabStrip?: 'always' | 'never' }) => {
|
||||
$layoutTree.set(
|
||||
split('column', [
|
||||
group(['workspace'], { active: 'workspace', id: 'grp-main' }),
|
||||
group(['terminal', 'logs'], {
|
||||
active: options?.active ?? 'terminal',
|
||||
headerHidden: options?.headerHidden,
|
||||
tabStrip: options?.tabStrip,
|
||||
id: 'g-tools'
|
||||
})
|
||||
])
|
||||
@@ -363,23 +363,23 @@ describe('a terminal that owns its own zone (Default / Terminal deck / Quad)', (
|
||||
|
||||
describe('a zone whose header the user hid', () => {
|
||||
it('keeps it hidden after a stacked sibling is closed and toggled back', () => {
|
||||
stackTree({ headerHidden: true })
|
||||
stackTree({ tabStrip: 'never' })
|
||||
bindPaneCollapse('terminal', atom(true))
|
||||
const $logs = atom(true)
|
||||
bindPaneCollapse('logs', $logs)
|
||||
|
||||
setTreeGroupHeaderHidden('g-tools', true)
|
||||
setTreeGroupTabStrip('g-tools', 'never')
|
||||
|
||||
// Close logs: the zone drops to one pane. normalize used to DISCARD the
|
||||
// hidden flag here ("a lone zone is headerless anyway"), so the bar
|
||||
// reappeared the moment logs was toggled back in.
|
||||
closeToolPane('logs')
|
||||
expect(toolZone()?.headerHidden).toBe(true)
|
||||
expect(toolZone()?.tabStrip).toBe('never')
|
||||
|
||||
$logs.set(true)
|
||||
|
||||
expect(toolZone()?.panes).toContain('logs')
|
||||
expect(toolZone()?.headerHidden).toBe(true)
|
||||
expect(toolZone()?.tabStrip).toBe('never')
|
||||
})
|
||||
|
||||
it('keeps it hidden when a closed pane is re-adopted into it', () => {
|
||||
@@ -388,14 +388,14 @@ describe('a zone whose header the user hid', () => {
|
||||
bindPaneCollapse('terminal', $terminal)
|
||||
bindPaneCollapse('logs', atom(true))
|
||||
|
||||
setTreeGroupHeaderHidden('g-tools', true)
|
||||
setTreeGroupTabStrip('g-tools', 'never')
|
||||
|
||||
// Re-adoption pins headerHidden:false so a surprise pane always has a
|
||||
// handle — correct for a new pane, wrong for a zone the user hid.
|
||||
// Re-adoption used to pin the strip visible so a surprise pane always had
|
||||
// a handle — correct for a new pane, wrong for a zone the user hid.
|
||||
closeToolPane('terminal')
|
||||
$terminal.set(true)
|
||||
|
||||
expect(toolZone()?.panes).toContain('terminal')
|
||||
expect(toolZone()?.headerHidden).toBe(true)
|
||||
expect(toolZone()?.tabStrip).toBe('never')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2345,8 +2345,8 @@ export const ar = defineLocale({
|
||||
}
|
||||
},
|
||||
zones: {
|
||||
showHeader: 'إظهار الرأس',
|
||||
hideHeader: 'إخفاء الرأس',
|
||||
showTabStrip: 'إظهار علامات التبويب',
|
||||
hideTabStrip: 'إخفاء علامات التبويب',
|
||||
showStripTab: title => `إظهار ${title}`,
|
||||
hideStripTab: title => `إخفاء ${title}`,
|
||||
lastTabKeptTitle: 'يبقى آخر تبويب',
|
||||
|
||||
@@ -2980,8 +2980,8 @@ export const en: Translations = {
|
||||
},
|
||||
|
||||
zones: {
|
||||
showHeader: 'Show header',
|
||||
hideHeader: 'Hide header',
|
||||
showTabStrip: 'Show tabs',
|
||||
hideTabStrip: 'Hide tabs',
|
||||
showStripTab: title => `Show ${title}`,
|
||||
hideStripTab: title => `Hide ${title}`,
|
||||
lastTabKeptTitle: 'Last tab stays',
|
||||
|
||||
@@ -2636,8 +2636,8 @@ export const ja = defineLocale({
|
||||
},
|
||||
|
||||
zones: {
|
||||
showHeader: 'ヘッダーを表示',
|
||||
hideHeader: 'ヘッダーを隠す',
|
||||
showTabStrip: 'タブを表示',
|
||||
hideTabStrip: 'タブを隠す',
|
||||
showStripTab: title => `${title} を表示`,
|
||||
hideStripTab: title => `${title} を隠す`,
|
||||
lastTabKeptTitle: '最後のタブは残ります',
|
||||
|
||||
@@ -2554,8 +2554,8 @@ export interface Translations {
|
||||
}
|
||||
|
||||
zones: {
|
||||
showHeader: string
|
||||
hideHeader: string
|
||||
showTabStrip: string
|
||||
hideTabStrip: string
|
||||
showStripTab: (title: string) => string
|
||||
hideStripTab: (title: string) => string
|
||||
lastTabKeptTitle: string
|
||||
|
||||
@@ -2549,8 +2549,8 @@ export const zhHant = defineLocale({
|
||||
},
|
||||
|
||||
zones: {
|
||||
showHeader: '顯示標題列',
|
||||
hideHeader: '隱藏標題列',
|
||||
showTabStrip: '顯示分頁',
|
||||
hideTabStrip: '隱藏分頁',
|
||||
showStripTab: title => `顯示 ${title}`,
|
||||
hideStripTab: title => `隱藏 ${title}`,
|
||||
lastTabKeptTitle: '保留最後一個分頁',
|
||||
|
||||
@@ -3147,8 +3147,8 @@ export const zh: Translations = {
|
||||
},
|
||||
|
||||
zones: {
|
||||
showHeader: '显示标题栏',
|
||||
hideHeader: '隐藏标题栏',
|
||||
showTabStrip: '显示标签',
|
||||
hideTabStrip: '隐藏标签',
|
||||
showStripTab: title => `显示 ${title}`,
|
||||
hideStripTab: title => `隐藏 ${title}`,
|
||||
lastTabKeptTitle: '保留最后一个标签',
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { TabStripMode } from '@/components/pane-shell/tree/model'
|
||||
import { type Codec, persistentAtom } from '@/lib/persisted'
|
||||
|
||||
const TAB_STRIP_DEFAULT_STORAGE_KEY = 'hermes.desktop.tabStripDefault'
|
||||
|
||||
/** What a zone does when it has made no choice of its own. */
|
||||
export type TabStripDefault = 'auto' | TabStripMode
|
||||
|
||||
const codec: Codec<TabStripDefault> = {
|
||||
decode: raw => (raw === 'always' || raw === 'never' ? raw : 'auto'),
|
||||
encode: value => (value === 'auto' ? null : value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The app-wide answer for zones on auto, VS Code's `workbench.editor.showTabs`
|
||||
* and Zed's `tab_bar.show`. `auto` keeps the contextual rule (a lone pane is
|
||||
* not a tab); the other two are for people who want one answer everywhere
|
||||
* rather than a per-zone choice they have to repeat.
|
||||
*
|
||||
* A zone that states its own preference still wins — this is the fallback, not
|
||||
* an override — and neither value can strand a pane (see resolveTabStripVisible).
|
||||
*/
|
||||
export const $tabStripDefault = persistentAtom<TabStripDefault>(TAB_STRIP_DEFAULT_STORAGE_KEY, 'auto', codec)
|
||||
|
||||
export function setTabStripDefault(value: TabStripDefault) {
|
||||
$tabStripDefault.set(value)
|
||||
}
|
||||
|
||||
/** The mode a zone resolves against: its own choice, else the app default. */
|
||||
export function effectiveTabStripMode(zoneMode: TabStripMode | undefined): TabStripMode | undefined {
|
||||
if (zoneMode) {
|
||||
return zoneMode
|
||||
}
|
||||
|
||||
const fallback = $tabStripDefault.get()
|
||||
|
||||
return fallback === 'auto' ? undefined : fallback
|
||||
}
|
||||
Reference in New Issue
Block a user