diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 3fd6009096..36cb6462ed 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -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 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. diff --git a/apps/desktop/src/components/pane-shell/tree/hide-only-strip-tabs.test.ts b/apps/desktop/src/components/pane-shell/tree/hide-only-strip-tabs.test.ts new file mode 100644 index 0000000000..048f67f95b --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/hide-only-strip-tabs.test.ts @@ -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) { + 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') + }) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts index dd719f16a9..785ea7519d 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts @@ -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. */ diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 9b26cb1392..28967fd802 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -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 ( + <> + + {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) + }) + )} + + ) + })()} {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 diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 622323d10f..f1e98f9cdb 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -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>(new Set(readJson(HIDDEN_STRIP_TAB_KEY) ?? [])) + +function saveHiddenStripTabs(next: ReadonlySet) { + $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 void> = {} const paneOpeners: Record 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 diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 06d7370adf..44413962db 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -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: 'إغلاق تبويب يعمل؟', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 8fd119411a..25f122255c 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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?', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 0d55b75067..eb1456722e 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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: '再読み込み', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 67704864b4..e162fa0d9c 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 490fc29841..3cfe280c28 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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: '重新載入', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index dc5e7907fc..e803a2cde6 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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: '关闭正在运行的标签?', diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 7fdf85737b..1f5257f18c 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -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, {}) })