feat(desktop): sessions/bots tabs are show/hide chrome — no close gestures, right-click + Cmd-K toggles

Builds on #89551 (@calvinnwq, cherry-picked): his showCloseButton flag
hid the hover X; this completes the model so standing chrome can never
be closed at all, only shown/hidden (#89546).

- hideOnly pane chrome (sessions + Bots): no hover X, no middle/meta
  click close, no Close verbs in the tab menu, excluded from
  close-others/right/all sweeps
- zone right-click menu gains Show/Hide rows for the strip's chrome
  tabs (Hide bots / Show sessions, localized in 6 locales)
- Cmd-K palette: auto-registered "Toggle <tab> tab" rows for every
  hideOnly pane, on-screen truth semantics, plugin panes included via
  registry subscription
- hides persist across launches (survive the enforced dock re-adopt);
  reveal intent and Layout reset clear them
- last-visible-tab guard: hiding the zone's last shown tab is refused
  with a toast, so the strip can never become an empty dead zone
This commit is contained in:
Teknium
2026-08-18 17:03:47 -07:00
parent 34d1aed3f1
commit 4ac938ddec
12 changed files with 372 additions and 9 deletions
@@ -29,6 +29,7 @@ import {
removeTreePane,
resetLayoutTree,
revealTreePane,
setStripTabHidden,
togglePaneVisible,
watchContributedPanes
} from '@/components/pane-shell/tree/store'
@@ -38,6 +39,7 @@ import { Slot } from '@/contrib/react/slot'
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { translateNow } from '@/i18n'
import { NEW_SESSION_TITLE, sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
import { Download, FileText, LayoutDashboard, PanelBottom, Terminal, Upload, Zap } from '@/lib/icons'
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
@@ -155,6 +157,9 @@ registry.registerMany([
dock: { pane: 'workspace', pos: 'left' },
revealAliases: ['chat-sidebar'],
showCloseButton: false,
// Standing chrome: no close gestures at all — the tab is shown/hidden
// (zone menu Show/Hide rows + the auto-registered ⌘K toggle below).
hideOnly: true,
width: `${SIDEBAR_DEFAULT_WIDTH}px`,
minWidth: `${SIDEBAR_DEFAULT_WIDTH}px`,
maxWidth: `${SIDEBAR_MAX_WIDTH}px`
@@ -673,6 +678,63 @@ registry.register(
})
)
// Hide-only chrome tabs (sessions / Bots) get a ⌘K toggle each — the palette
// door onto the same show/hide the zone menu offers. Auto-registered from the
// panes area so a plugin's hideOnly pane (Bots registers at plugin load, after
// this module runs) gets its row for free; disposers keep it in step when a
// plugin unloads. Registry writes during a subscriber callback are safe (the
// registry snapshots per-area and re-notifies), and re-registering the same
// palette id replaces the row instead of stacking duplicates.
{
const stripTabToggles = new Map<string, () => void>()
const syncStripTabToggles = () => {
const hideOnlyPanes = registry
.getArea('panes')
.filter(c => (c.data as { hideOnly?: boolean } | undefined)?.hideOnly)
const wanted = new Set(hideOnlyPanes.map(c => c.id))
for (const [paneId, dispose] of stripTabToggles) {
if (!wanted.has(paneId)) {
dispose()
stripTabToggles.delete(paneId)
}
}
for (const pane of hideOnlyPanes) {
if (stripTabToggles.has(pane.id)) {
continue
}
const title = String(pane.title ?? pane.id)
stripTabToggles.set(
pane.id,
registry.register(
paletteToggle({
id: `strip-tab.${pane.id}`,
label: translateNow('zones.toggleStripTab', title),
icon: LayoutDashboard,
keywords: [title.toLowerCase(), 'tab', 'pane', 'sidebar', 'show', 'hide'],
// On-screen truth, same contract as the logs toggle above.
get: () => isPaneVisible(pane.id),
set: visible => {
if (visible) {
revealTreePane(pane.id)
} else {
setStripTabHidden(pane.id, true)
}
}
})
)
)
}
}
syncStripTabToggles()
registry.subscribeArea('panes', syncStripTabToggles)
}
// YOLO (dangerous-command approval bypass) is a status-bar zap and a /yolo
// command; ⌘K is the third door onto the SAME store function, so a user who
// lives in the palette never has to hunt for the pill.
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { registry } from '@/contrib/registry'
import { allPaneIds, group, split } from './model'
import {
$hiddenStripTabs,
$hiddenTreePanes,
$layoutTree,
closeAllTreeTabs,
hideOnlyZoneTabs,
isHideOnlyPane,
revealTreePane,
setStripTabHidden,
treeTabCloseTargets
} from './store'
vi.mock('@/store/notifications', () => ({ notify: vi.fn() }))
import { notify } from '@/store/notifications'
const disposers: (() => void)[] = []
function registerPane(id: string, data: Record<string, unknown>) {
disposers.push(registry.register({ area: 'panes', data, id, render: () => null, title: id }))
}
/** The SESSIONS | BOTS shape: both hide-only chrome tabs stacked in one zone. */
function sessionsBotsTree() {
registerPane('sessions', { placement: 'left', hideOnly: true })
registerPane('hermes-bots:pane', { placement: 'left', hideOnly: true })
registerPane('workspace', { placement: 'main', uncloseable: true })
$layoutTree.set(
split('row', [
group(['sessions', 'hermes-bots:pane'], { active: 'sessions', id: 'g-side' }),
group(['workspace'], { active: 'workspace', id: 'g-main' })
])
)
}
beforeEach(() => {
window.localStorage.clear()
$hiddenStripTabs.set(new Set())
$hiddenTreePanes.set(new Set())
vi.mocked(notify).mockReset()
})
afterEach(() => {
disposers.splice(0).forEach(dispose => dispose())
})
describe('hide-only strip tabs', () => {
it('hides and shows a chrome tab, keeping the pane in the tree', () => {
sessionsBotsTree()
expect(setStripTabHidden('hermes-bots:pane', true)).toBe(true)
expect($hiddenTreePanes.get()).toContain('hermes-bots:pane')
expect($hiddenStripTabs.get()).toContain('hermes-bots:pane')
// Hidden, not dismissed: the pane stays in the layout tree.
expect(allPaneIds($layoutTree.get()!)).toContain('hermes-bots:pane')
expect(setStripTabHidden('hermes-bots:pane', false)).toBe(true)
expect($hiddenTreePanes.get()).not.toContain('hermes-bots:pane')
expect($hiddenStripTabs.get()).not.toContain('hermes-bots:pane')
})
it('refuses to hide the zone last visible tab', () => {
sessionsBotsTree()
setStripTabHidden('hermes-bots:pane', true)
// Sessions is now the only visible tab in the zone — the hide is refused
// and the user is told, so the zone can never become an empty dead strip.
expect(setStripTabHidden('sessions', true)).toBe(false)
expect($hiddenTreePanes.get()).not.toContain('sessions')
expect(vi.mocked(notify)).toHaveBeenCalledTimes(1)
})
it('persists hides and clears them on reveal', () => {
sessionsBotsTree()
setStripTabHidden('hermes-bots:pane', true)
const persisted = JSON.parse(window.localStorage.getItem('hermes.desktop.hiddenStripTabs.v1') ?? '[]')
expect(persisted).toContain('hermes-bots:pane')
// Reveal intent (⌘K toggle on, a programmatic reveal) beats the hide —
// including the persisted record, so the tab can't pop back hidden on the
// next launch while visibly on screen now.
revealTreePane('hermes-bots:pane')
expect($hiddenTreePanes.get()).not.toContain('hermes-bots:pane')
expect(window.localStorage.getItem('hermes.desktop.hiddenStripTabs.v1')).toBeNull()
})
it('lists the zone hide-only tabs with live hidden state for the menu', () => {
sessionsBotsTree()
setStripTabHidden('hermes-bots:pane', true)
expect(hideOnlyZoneTabs('g-side')).toEqual([
{ hidden: false, id: 'sessions', title: 'sessions' },
{ hidden: true, id: 'hermes-bots:pane', title: 'hermes-bots:pane' }
])
expect(hideOnlyZoneTabs('g-main')).toEqual([])
})
it('excludes hide-only tabs from every close verb', () => {
registerPane('sessions', { placement: 'left', hideOnly: true })
registerPane('hermes-bots:pane', { placement: 'left', hideOnly: true })
registerPane('session-tile:x', { placement: 'main' })
$layoutTree.set(
group(['sessions', 'hermes-bots:pane', 'session-tile:x'], { active: 'sessions', id: 'g-mixed' })
)
expect(isHideOnlyPane('sessions')).toBe(true)
// Close-others measured from the tile must not sweep standing chrome.
expect(treeTabCloseTargets('session-tile:x')).toEqual({ all: 1, others: 0, right: 0 })
// Close-all leaves both chrome tabs in the tree.
closeAllTreeTabs('sessions')
expect(allPaneIds($layoutTree.get()!)).toContain('sessions')
expect(allPaneIds($layoutTree.get()!)).toContain('hermes-bots:pane')
expect(allPaneIds($layoutTree.get()!)).not.toContain('session-tile:x')
})
})
@@ -67,6 +67,11 @@ interface PaneChrome extends PaneSizing {
uncloseable?: boolean
/** Hide the hover ✕ while retaining explicit close behavior for this pane. */
showCloseButton?: boolean
/** Standing chrome tab (sessions / Bots) whose tab shows NO ✕ and no Close
* verbs — it is shown/hidden instead (the zone menu's Show/Hide rows and a
* ⌘K toggle, via `setStripTabHidden`). Close was too destructive for these:
* an accidental ✕ removed Bot Mode until the next launch. */
hideOnly?: boolean
/** Wrap this pane's TAB (e.g. in a domain context menu — a session tile's
* pin/branch/rename/archive/delete). The wrapper must render `tab` as its
* interactive child; the zone's own strip menu still owns non-tab space. */
@@ -49,6 +49,7 @@ import {
closeTabPane,
closeTreeTabsToRight,
collapseTreePane,
hideOnlyZoneTabs,
isCollapsePane,
isMainStripPane,
isSessionStripPane,
@@ -56,6 +57,7 @@ import {
reloadTreePane,
restoreTreePane,
SESSION_TILE_DRAG,
setStripTabHidden,
setTreeGroupHeaderHidden,
setTreeGroupMinimized,
treeTabCloseTargets
@@ -129,6 +131,30 @@ function ZoneMenu({
onCloseOthers: () => closeOtherTreeTabs(targetId),
onCloseToRight: () => closeTreeTabsToRight(targetId)
})}
{(() => {
// Show/hide rows for the zone's hide-only chrome tabs (sessions /
// Bots) — their Close replacement. Resolved when the menu OPENS,
// same no-subscription contract as the close-verb counts above.
const hideOnly = hideOnlyZoneTabs(nodeId)
if (hideOnly.length === 0) {
return null
}
return (
<>
<kit.Separator />
{hideOnly.map(tab =>
renderActionItem(kit, {
icon: tab.hidden ? 'eye' : 'eye-closed',
key: `strip-tab-${tab.id}`,
label: tab.hidden ? t.zones.showStripTab(tab.title) : t.zones.hideStripTab(tab.title),
onSelect: () => setStripTabHidden(tab.id, !tab.hidden)
})
)}
</>
)
})()}
<kit.Separator />
{renderActionItem(kit, {
icon: headerHidden ? 'eye' : 'eye-closed',
@@ -287,11 +313,13 @@ export function TreeGroup({
const targetPane = () => menuPane ?? activeId
// Close targets the right-clicked chip (falling back to the active pane);
// only panes that declare `uncloseable` (the main workspace) are exempt.
// panes that declare `uncloseable` (the main workspace) or `hideOnly`
// (sessions / Bots — show/hide replaces Close) are exempt.
const closable = () => {
const paneId = targetPane()
const chrome = paneChrome(paneFor(paneId))
return paneChrome(paneFor(paneId)).uncloseable ? undefined : paneId
return chrome.uncloseable || chrome.hideOnly ? undefined : paneId
}
// The zone hosting the uncloseable workspace never minimizes — collapsing
@@ -304,8 +332,12 @@ export function TreeGroup({
// A pane whose store owns Close keeps the gesture even when the pane itself
// is uncloseable — the workspace tab empties to a fresh draft rather than
// leaving the tree.
const closeableTab = (paneId: string) => !paneChrome(paneFor(paneId)).uncloseable || panesWithCloser.has(paneId)
// leaving the tree. Hide-only chrome (sessions / Bots) opts out of every
// close gesture: its tabs are shown/hidden (zone menu, ⌘K), never closed —
// an accidental ✕ on standing chrome removed Bot Mode until the next launch.
const closeableTab = (paneId: string) =>
!paneChrome(paneFor(paneId)).hideOnly &&
(!paneChrome(paneFor(paneId)).uncloseable || panesWithCloser.has(paneId))
// A pane's own live label when it has one, else its registered string.
const tabLabel = (paneId: string) => paneChrome(paneFor(paneId)).tabTitle?.() ?? paneFor(paneId)?.title ?? paneId
@@ -248,6 +248,72 @@ function recalledEdgeWeights(paneId: string): [number, number] | undefined {
return validShare(share) ? [1 - share, share] : undefined
}
// HIDE-ONLY STRIP TABS (`hideOnly` chrome: sessions / Bots) — standing chrome
// whose tab must never grow a ✕. Show/hide replaces Close for them: the zone
// menu's Show/Hide rows and the auto-registered ⌘K toggles both land here.
// Persisted separately from `$hiddenTreePanes` (whose persistence each side
// binding owns) so a hidden Bots tab stays hidden across launches even though
// dock enforcement re-adopts the pane into the sessions zone every boot.
const HIDDEN_STRIP_TAB_KEY = 'hermes.desktop.hiddenStripTabs.v1'
export const $hiddenStripTabs = atom<ReadonlySet<string>>(new Set(readJson<string[]>(HIDDEN_STRIP_TAB_KEY) ?? []))
function saveHiddenStripTabs(next: ReadonlySet<string>) {
$hiddenStripTabs.set(next)
writeJson(HIDDEN_STRIP_TAB_KEY, next.size === 0 ? null : [...next])
}
export function isStripTabHidden(paneId: string): boolean {
return $hiddenStripTabs.get().has(paneId)
}
/** Would hiding `paneId` leave its zone with no visible tab? Hiding the last
* one strands an empty zone (or collapses the whole sidebar with no strip
* left to right-click), so the setter refuses and says why. */
function isLastShownInGroup(paneId: string): boolean {
const tree = $layoutTree.get()
const group = tree ? findGroupOfPane(tree, paneId) : null
if (!group) {
return false
}
const hidden = $hiddenTreePanes.get()
return !group.panes.some(id => id !== paneId && !hidden.has(id))
}
/** Show/hide a hide-only chrome tab (the Close replacement for `hideOnly`
* panes). Returns false when the hide was refused — the zone must keep at
* least one visible tab, so the LAST shown tab can't be hidden. */
export function setStripTabHidden(paneId: string, hidden: boolean): boolean {
if (hidden && isLastShownInGroup(paneId)) {
notify({
kind: 'info',
title: translateNow('zones.lastTabKeptTitle'),
message: translateNow('zones.lastTabKeptBody')
})
return false
}
const next = toggledSet($hiddenStripTabs.get(), paneId, hidden)
if (next) {
saveHiddenStripTabs(next)
}
setTreePaneHidden(paneId, hidden)
return true
}
// Boot hydration: re-apply persisted hides through the same chrome-hidden set
// the strips render from ($hiddenTreePanes starts empty every launch).
for (const paneId of $hiddenStripTabs.get()) {
setTreePaneHidden(paneId, true)
}
const paneClosers: Record<string, () => void> = {}
const paneOpeners: Record<string, () => void> = {}
@@ -409,6 +475,12 @@ const isUncloseablePane = (paneId: string): boolean =>
(registry.getArea('panes').find(c => c.id === paneId)?.data as { uncloseable?: boolean } | undefined)?.uncloseable
)
/** Hide-only chrome tabs (sessions / Bots): excluded from every close verb —
* Close-others / Close-all sweeping the sessions strip must not take standing
* chrome with it. They hide through `setStripTabHidden` instead. */
export const isHideOnlyPane = (paneId: string): boolean =>
Boolean((registry.getArea('panes').find(c => c.id === paneId)?.data as { hideOnly?: boolean } | undefined)?.hideOnly)
/** A pane that belongs to a CHAT tab strip — the workspace or a session tile.
* Chat surfaces only: this gates where a session may DOCK (drops, ⌘T's "+"),
* not which zones the generic tab verbs serve — that's `isMainStripPane`. */
@@ -506,8 +578,8 @@ function closeableTreeSiblings(paneId: string): { others: string[]; right: strin
const idx = panes.indexOf(paneId)
return {
others: panes.filter(id => id !== paneId && !isUncloseablePane(id)),
right: panes.filter((id, i) => i > idx && !isUncloseablePane(id))
others: panes.filter(id => id !== paneId && !isUncloseablePane(id) && !isHideOnlyPane(id)),
right: panes.filter((id, i) => i > idx && !isUncloseablePane(id) && !isHideOnlyPane(id))
}
}
@@ -515,7 +587,11 @@ function closeableTreeSiblings(paneId: string): { others: string[]; right: strin
export function treeTabCloseTargets(paneId: string): { all: number; others: number; right: number } {
const { others, right } = closeableTreeSiblings(paneId)
return { all: others.length + (isUncloseablePane(paneId) ? 0 : 1), others: others.length, right: right.length }
return {
all: others.length + (isUncloseablePane(paneId) || isHideOnlyPane(paneId) ? 0 : 1),
others: others.length,
right: right.length
}
}
/**
@@ -557,7 +633,32 @@ export function closeAllTreeTabs(paneId: string): void {
const tree = $layoutTree.get()
const panes = (tree ? findGroupOfPane(tree, paneId) : null)?.panes ?? []
panes.filter(id => !isUncloseablePane(id)).forEach(closeTabPane)
panes.filter(id => !isUncloseablePane(id) && !isHideOnlyPane(id)).forEach(closeTabPane)
}
/** Hide-only chrome tabs in `groupId` (sessions / Bots), with live hidden
* state — the zone menu's Show/Hide rows. Resolved when the menu OPENS (same
* contract as the close-verb counts), never subscribed from a zone render. */
export function hideOnlyZoneTabs(groupId: string): { hidden: boolean; id: string; title: string }[] {
const tree = $layoutTree.get()
const group = tree ? findGroup(tree, groupId) : null
if (!group) {
return []
}
const panes = registry.getArea('panes')
const hidden = $hiddenTreePanes.get()
return group.panes.flatMap(id => {
const pane = panes.find(p => p.id === id)
if (!(pane?.data as { hideOnly?: boolean } | undefined)?.hideOnly) {
return []
}
return [{ hidden: hidden.has(id), id, title: String(pane?.title ?? id) }]
})
}
/** Pane ids in the tree under a `${prefix}:` namespace — lets a mirror prune
@@ -923,6 +1024,12 @@ export function revealTreePane(paneId: string) {
adoptContributedPanes()
}
// Reveal beats a hide too: clear the persisted hide-only record, or the
// pane pops back hidden on the next launch even though it's on screen now.
if ($hiddenStripTabs.get().has(paneId)) {
saveHiddenStripTabs(toggledSet($hiddenStripTabs.get(), paneId, false) ?? $hiddenStripTabs.get())
}
const side = treeSideOfPane(paneId)
if (side && $collapsedTreeSides.get().has(side)) {
@@ -1796,6 +1903,11 @@ export function resetLayoutTree() {
// placement back to the app (user-placed pins cleared).
saveDismissed(new Set())
saveUserPlaced(new Set())
// Hide-only chrome tabs (sessions / Bots) come back too — clear their
// persisted hides through the setter so $hiddenTreePanes agrees.
for (const paneId of [...$hiddenStripTabs.get()]) {
setStripTabHidden(paneId, false)
}
$layoutTree.set(defaultTree)
markActivePreset('default')
// Owners PRE-PLACE their panes into the fresh default (session tiles stack
+5
View File
@@ -2327,6 +2327,11 @@ export const ar = defineLocale({
zones: {
showHeader: 'إظهار الرأس',
hideHeader: 'إخفاء الرأس',
showStripTab: title => `إظهار ${title}`,
hideStripTab: title => `إخفاء ${title}`,
lastTabKeptTitle: 'يبقى آخر تبويب',
lastTabKeptBody: 'تحتاج هذه المنطقة إلى تبويب مرئي واحد على الأقل. أظهر تبويبا آخر أولا، أو اطو الشريط الجانبي بأكمله.',
toggleStripTab: title => `تبديل تبويب ${title}`,
minimize: 'تصغير',
restore: 'استعادة',
closeRunningTitle: 'إغلاق تبويب يعمل؟',
+5
View File
@@ -2958,6 +2958,11 @@ export const en: Translations = {
zones: {
showHeader: 'Show header',
hideHeader: 'Hide header',
showStripTab: title => `Show ${title}`,
hideStripTab: title => `Hide ${title}`,
lastTabKeptTitle: 'Last tab stays',
lastTabKeptBody: 'This zone needs at least one visible tab. Show another tab first, or collapse the whole sidebar.',
toggleStripTab: title => `Toggle ${title} tab`,
minimize: 'Minimize',
restore: 'Restore',
closeRunningTitle: 'Close running tab?',
+5
View File
@@ -2618,6 +2618,11 @@ export const ja = defineLocale({
zones: {
showHeader: 'ヘッダーを表示',
hideHeader: 'ヘッダーを隠す',
showStripTab: title => `${title} を表示`,
hideStripTab: title => `${title} を隠す`,
lastTabKeptTitle: '最後のタブは残ります',
lastTabKeptBody: 'このゾーンには少なくとも 1 つの表示タブが必要です。先に別のタブを表示するか、サイドバー全体を折りたたんでください。',
toggleStripTab: title => `${title} タブを切り替え`,
minimize: '最小化',
restore: '復元',
reload: '再読み込み',
+5
View File
@@ -2530,6 +2530,11 @@ export interface Translations {
zones: {
showHeader: string
hideHeader: string
showStripTab: (title: string) => string
hideStripTab: (title: string) => string
lastTabKeptTitle: string
lastTabKeptBody: string
toggleStripTab: (title: string) => string
minimize: string
restore: string
closeRunningTitle: string
+5
View File
@@ -2532,6 +2532,11 @@ export const zhHant = defineLocale({
zones: {
showHeader: '顯示標題列',
hideHeader: '隱藏標題列',
showStripTab: title => `顯示 ${title}`,
hideStripTab: title => `隱藏 ${title}`,
lastTabKeptTitle: '保留最後一個分頁',
lastTabKeptBody: '此區域至少需要一個可見分頁。請先顯示另一個分頁,或收合整個側邊欄。',
toggleStripTab: title => `切換 ${title} 分頁`,
minimize: '最小化',
restore: '還原',
reload: '重新載入',
+5
View File
@@ -3124,6 +3124,11 @@ export const zh: Translations = {
zones: {
showHeader: '显示标题栏',
hideHeader: '隐藏标题栏',
showStripTab: title => `显示 ${title}`,
hideStripTab: title => `隐藏 ${title}`,
lastTabKeptTitle: '保留最后一个标签',
lastTabKeptBody: '该区域至少需要一个可见标签。请先显示另一个标签,或折叠整个侧边栏。',
toggleStripTab: title => `切换 ${title} 标签`,
minimize: '最小化',
restore: '还原',
closeRunningTitle: '关闭正在运行的标签?',
@@ -9887,7 +9887,7 @@ export default {
// sessions pane collapses alone without this flag. The zone then keeps
// a stranded BOTS tab on screen. The narrow edge overlay mirrors the
// zone's tab strip, so the pane stays reachable while collapsed.
data: { placement: 'left', width: '260px', collapsible: true, showCloseButton: false, dock: { pane: 'sessions', pos: 'center', enforce: true } },
data: { placement: 'left', width: '260px', collapsible: true, showCloseButton: false, hideOnly: true, dock: { pane: 'sessions', pos: 'center', enforce: true } },
render: () => jsx(BotsPane, {})
})