Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import { forceLoneHeaderForPanes } from './lone-header'
import { forceLoneHeaderForPanes, resolveZoneHeaderHidden } from './lone-header'

describe('forceLoneHeaderForPanes', () => {
const chrome =
Expand Down Expand Up @@ -35,3 +35,62 @@ describe('forceLoneHeaderForPanes', () => {
expect(forceLoneHeaderForPanes(['files'], chrome('right'), noCollapse)).toBe(false)
})
})

describe('resolveZoneHeaderHidden', () => {
it('keeps a lone session workspace strip visible', () => {
expect(
resolveZoneHeaderHidden({
forceLoneHeader: false,
headerVeto: false,
persistedHidden: undefined,
sessionStrip: true,
shownCount: 1
})
).toBe(false)
})

it('ignores a persisted hide for session strips', () => {
expect(
resolveZoneHeaderHidden({
forceLoneHeader: true,
headerVeto: false,
persistedHidden: true,
sessionStrip: true,
shownCount: 2
})
).toBe(false)
})

it('still honors a full-page header veto', () => {
expect(
resolveZoneHeaderHidden({
forceLoneHeader: false,
headerVeto: true,
persistedHidden: false,
sessionStrip: true,
shownCount: 1
})
).toBe(true)
})

it('preserves explicit and automatic hiding for non-session zones', () => {
expect(
resolveZoneHeaderHidden({
forceLoneHeader: true,
headerVeto: false,
persistedHidden: true,
sessionStrip: false,
shownCount: 1
})
).toBe(true)
expect(
resolveZoneHeaderHidden({
forceLoneHeader: false,
headerVeto: false,
persistedHidden: undefined,
sessionStrip: false,
shownCount: 1
})
).toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,24 @@ export function forceLoneHeaderForPanes(

return shown.length === 1 && isCollapsePane(shown[0])
}

export interface ZoneHeaderVisibilityInput {
forceLoneHeader: boolean
headerVeto: boolean
persistedHidden?: boolean
sessionStrip: boolean
shownCount: number
}

/** Resolve header visibility without letting the chat switcher become a dead end. */
export function resolveZoneHeaderHidden(input: ZoneHeaderVisibilityInput): boolean {
if (input.headerVeto) {
return true
}

if (input.sessionStrip) {
return false
}

return input.persistedHidden ?? (input.shownCount <= 1 && !input.forceLoneHeader)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useStore } from '@nanostores/react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'

import { registry } from '@/contrib/registry'
import { stubResizeObserver } from '@/test/jsdom'

import { group } from '../model'
import { $layoutTree } from '../store'

import { TreeGroup } from './tree-group'

function LiveTreeGroup() {
useStore($layoutTree)

return <TreeGroup node={$layoutTree.get() as never} parentAxis="column" />
}

beforeAll(() => {
stubResizeObserver()
vi.stubGlobal('CSS', { ...globalThis.CSS, escape: (value: string) => value })
Element.prototype.hasPointerCapture ??= () => false
Element.prototype.setPointerCapture ??= () => undefined
Element.prototype.releasePointerCapture ??= () => undefined
HTMLElement.prototype.scrollIntoView ??= () => undefined
})

let disposePane: (() => void) | undefined

afterEach(() => {
cleanup()
disposePane?.()
disposePane = undefined
})

const groupNode = () =>
$layoutTree.get() as { headerHidden?: boolean; minimized?: boolean; panes: string[] }

const doubleTap = (target: Element) => {
for (let i = 0; i < 2; i++) {
fireEvent.pointerDown(target, { button: 0, clientX: 10, clientY: 10, pointerType: 'mouse' })
fireEvent.pointerUp(window, { button: 0, clientX: 10, clientY: 10, pointerType: 'mouse' })
}
}

describe('session strip visibility', () => {
it('undoes the first-tap collapse without persisting a header hide', () => {
const paneId = 'session-tile:test'
disposePane = registry.register({
area: 'panes',
data: { placement: 'main' },
id: paneId,
render: () => null,
title: 'Session'
})
$layoutTree.set(group([paneId], { active: paneId, id: 'grp-session' }))
render(<LiveTreeGroup />)

const strip = globalThis.document.querySelector<HTMLElement>('[data-zone-tabstrip="grp-session"]')
expect(strip).toBeTruthy()

doubleTap(strip!)

expect(groupNode().minimized).not.toBe(true)
expect(groupNode().headerHidden).not.toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ describe('right-clicking a tool panel tab', () => {
openContextMenu(tab!)

expect(await screen.findByRole('menuitem', { name: /^close$/i })).toBeTruthy()
expect(await screen.findByRole('menuitem', { name: /^hide header$/i })).toBeTruthy()
})

it('offers Close while the zone is MINIMIZED to its rail', async () => {
Expand All @@ -119,6 +120,18 @@ describe('right-clicking a tool panel tab', () => {
})
})

describe('right-clicking the session workspace tab', () => {
it('does not offer the dead-end Hide header action', async () => {
declareDefaultTree(group(['workspace'], { active: 'workspace', id: 'grp-main' }))
render(<TreeGroup node={zoneAt(0)} parentAxis="row" />)

openContextMenu(tabEl('workspace')!)

expect(await screen.findByRole('menu')).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: /^hide header$/i })).toBeNull()
})
})

describe('⌘W over a focused tool panel', () => {
it('closes the logs tab and the toggle brings it back', async () => {
const { closeActiveTab } = await import('@/app/chat/close-tab')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ function terminalGroup(minimized: boolean): GroupNode {
}
}

function workspaceGroup(): GroupNode {
return {
active: 'workspace',
headerHidden: true,
id: 'workspace-zone',
panes: ['workspace'],
type: 'group'
}
}

const toggle = (label: string) =>
globalThis.document.querySelector<HTMLButtonElement>(
`[data-tree-group="terminal-zone"] button[aria-label="${label}"]`
Expand Down Expand Up @@ -73,4 +83,19 @@ describe('TreeGroup', () => {

expect(toggle('Restore').querySelector('i')!.className).toContain('codicon-chevron-up')
})

it('keeps the session strip visible for a lone workspace with persisted hide', () => {
disposePane = registry.register({
area: 'panes',
data: { placement: 'main', uncloseable: true },
id: 'workspace',
render: () => <div>Workspace</div>,
title: 'Workspace'
})
vi.stubGlobal('CSS', { escape: (value: string) => value })

render(<TreeGroup node={workspaceGroup()} parentAxis="row" />)

expect(globalThis.document.querySelector('[data-tree-group="workspace-zone"][data-zone-header]')).not.toBeNull()
})
})
47 changes: 33 additions & 14 deletions apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import {
} from '../tab-selection'

import { type DoubleTapContext, startPaneDrag } from './drag-session'
import { forceLoneHeaderForPanes } from './lone-header'
import { forceLoneHeaderForPanes, resolveZoneHeaderHidden } from './lone-header'
import { useActiveTabVisible } from './tab-strip-scroll'
import { paneChrome } from './track-model'

Expand All @@ -82,6 +82,7 @@ import { paneChrome } from './track-model'
* a pane with no domain menu of its own (the file tree, a terminal, the main
* tab on a fresh draft) falls through to this one. */
function ZoneMenu({
canHideHeader = true,
children,
closable,
minimizable = true,
Expand All @@ -90,6 +91,8 @@ function ZoneMenu({
nodeId,
targetPane
}: {
/** False for chat switchers, whose strip is the only session-tab recovery surface. */
canHideHeader?: boolean
children: ReactNode
/** The pane the menu closes (the right-clicked chip / the active pane);
* undefined = not closable (the main zone). */
Expand Down Expand Up @@ -155,12 +158,13 @@ function ZoneMenu({
</>
)
})()}
<kit.Separator />
{renderActionItem(kit, {
icon: headerHidden ? 'eye' : 'eye-closed',
label: headerHidden ? t.zones.showHeader : t.zones.hideHeader,
onSelect: () => setTreeGroupHeaderHidden(nodeId, !headerHidden)
})}
{(canHideHeader || minimizable) && <kit.Separator />}
{canHideHeader &&
renderActionItem(kit, {
icon: headerHidden ? 'eye' : 'eye-closed',
label: headerHidden ? t.zones.showHeader : t.zones.hideHeader,
onSelect: () => setTreeGroupHeaderHidden(nodeId, !headerHidden)
})}
{minimizable &&
renderActionItem(kit, {
// Same action-direction contract as the strip button below: the
Expand Down Expand Up @@ -263,16 +267,25 @@ export function TreeGroup({
// 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).
// The session switcher (workspace + session tiles) always keeps its strip:
// hiding the only session navigation surface is an inescapable dead end.
// Standing side chrome (sessions/files) keeps the clean no-tab default. 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)
const sessionStrip = shown.some(isSessionStripPane)
const canHideHeader = !sessionStrip

// 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))
const headerHidden = resolveZoneHeaderHidden({
forceLoneHeader,
headerVeto: Boolean(paneChrome(active).headerVeto),
persistedHidden: node.headerHidden,
sessionStrip,
shownCount: shown.length
})

// 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
Expand All @@ -298,6 +311,11 @@ export function TreeGroup({
key: `hide-header-${node.id}`,
onDoubleTap: () => {
setTreeGroupMinimized(node.id, false)

if (!canHideHeader) {
return
}

setTreeGroupHeaderHidden(node.id, true)
}
}
Expand Down Expand Up @@ -347,6 +365,7 @@ export function TreeGroup({

// Same menu on the header strip and the edit veil β€” one prop bag.
const zoneMenu = {
canHideHeader,
closable,
headerHidden,
minimizable,
Expand All @@ -357,9 +376,9 @@ export function TreeGroup({

// 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.
// bar's lifecycle is explicit instead β€” the session switcher stays visible,
// tool/side zones may hide from their own strip, and full-page views veto it
// via paneChrome.headerVeto.

return (
<div
Expand Down
Loading