diff --git a/README.md b/README.md index f86db03..a8c6fc1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取 - **🧩 Subagent 页面**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开 -- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏、分隔线调比例、宽度拖拽、折叠 + 全屏 +- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏、分隔线调比例;右上角持久按钮折叠/展开,支持右侧栏 + 底部面板双工作台,共享拐角双向拖动调节两面板尺寸 - **🔁 会话隔离**:布局/分栏/Tab 按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开 - **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、沙箱开关等)经齿轮按钮在原生弹窗中编辑 - **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md)) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 47f3895..83a2713 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -1,25 +1,34 @@ /** - * The sidebar shell: a fixed-position right panel portalled onto - * document.body (the core AppFrame owns the left sidebar / center / details - * columns and has no right-side hole for plugins), plus the edge toggle - * button. The panel width drags from its left edge; the whole layout lives - * in the per-session store, so switching conversations swaps the sidebar. + * The sidebar shell: fixed-position panels portalled onto document.body + * (the core AppFrame owns the left sidebar / center / details columns and + * has no right-side hole for plugins). The right panel hosts the original + * workbench; the bottom panel hosts a second, independent workbench. The + * bottom panel squeezes ONLY the center column (the agent output area): it + * spans from the app shell's own left sidebar to the right panel's left + * edge, so neither sidebar gives up any position (the right panel keeps its + * full height). A persistent two-button cluster at the top-right corner + * toggles each panel; the right panel's width drags from its left edge, the + * bottom panel's height from its top edge, and the shared corner drags both + * at once. The whole layout lives in the per-session store, so switching + * conversations swaps the sidebar. * * The shell binds the workbench actions to the store and dispatches tab - * content to the four views. New tabs come from the + menu (explorer / git / - * terminal; editors open from the explorer). + * content to the views. New tabs come from the + menu (explorer / git / + * terminal; editors open from the explorer). Tabs live in one tree only — + * they never cross panels; only the panel sizes drag against each other. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' -import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts' import { - agentUuidOf, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab, + BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab, reconcileAgentTerminals, - resizeSplit, setWidth, toggleExpanded, togglePanel, + resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, } from './state.ts' +import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx' import type { NewTabOption } from './TabBar.tsx' import type { TabDragPayload } from './TabBar.tsx' @@ -194,6 +203,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (!store.getPrefs().autoOpenSubagent) return if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return store.reduce(s => s.panelOpen ? s : togglePanel(s)) + // Pin the landing to the right panel: the auto-opened Subagent page must + // appear where the panel just expanded, not in a bottom-panel pane the + // user last touched. + store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') }) }, [sessionList, sessionId, store, ctx]) @@ -214,33 +227,151 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (pending === undefined || sessionId !== pending) return subagentJumpRef.current = undefined store.reduce(s => s.panelOpen ? s : togglePanel(s)) + store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') }) }, [sessionId, store, ctx]) - // Panel width drag (left edge strip). + // The app shell's own columns: the bottom panel spans ONLY the center + // column ("squeezes the agent output area") — it starts at the app + // sidebar's right edge and ends at the details column's left edge (the + // details column sits between the center and the right panel). Measured + // from the AppFrame's DOM (the plugin already couples to #root in + // layout.css); a missing frame degrades to 0 (full width). + const [appSidebarWidth, setAppSidebarWidth] = useState(0) + const [detailsWidth, setDetailsWidth] = useState(0) + useEffect(() => { + const frame = document.querySelector('#root > div') + const sidebarCol = frame?.children[0] as HTMLElement | undefined + const detailsCol = frame?.children[2] as HTMLElement | undefined + if (sidebarCol === undefined || detailsCol === undefined) return + const measure = (): void => { + setAppSidebarWidth(sidebarCol.getBoundingClientRect().width) + setDetailsWidth(detailsCol.getBoundingClientRect().width) + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(sidebarCol) + observer.observe(detailsCol) + return () => { observer.disconnect() } + }, []) + + /** + * Bottom-panel first-expansion auto terminal: the FIRST time the user + * expands the bottom panel in a session, try to open a fresh terminal tab + * there. "Try" is literal — the terminal's own quota and enable switch + * gate the attempt (a full quota or a disabled terminal type makes it a + * no-op). Gated on the bottomPanelAutoTerminal pref (the terminal tab's + * nested settings toggle, default on). Only a false→true TRANSITION fires + * (a panel persisted open never counts as an expansion), and the session's + * bottomOpenedOnce flag is set atomically with the first fire so later + * expansions never repeat it. + */ + const bottomWasOpenRef = useRef(undefined) + useEffect(() => { + if (state === undefined) return + const wasOpen = bottomWasOpenRef.current + bottomWasOpenRef.current = state.bottomOpen + if (wasOpen === undefined || wasOpen || !state.bottomOpen) return + if (state.bottomOpenedOnce) return + if (store.getPrefs().bottomPanelAutoTerminal === false) return + if (ctx.betterSidebar?.isTabEnabled('terminal') === false) return + // Land the tab in the bottom panel's first pane; the once-flag is set + // atomically so later expansions never repeat the auto-open. + store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true })) + ctx.betterSidebar?.openTab({ type: 'terminal' }) + }, [state, store, ctx]) + + // Panel drags: the right panel's width (left edge strip), the bottom + // panel's height (top edge strip), and the shared corner (both at once). + // Drags write the sizes DIRECTLY to the DOM (panel styles + the layout CSS + // variables) instead of round-tripping the store on every pointer move — + // a store reduce re-renders both workbenches (terminals, editors…) per + // move, which is the visible drag lag. The store is committed once on + // pointer up (clamping + persistence). + const panelRef = useRef(null) + const bottomRef = useRef(null) + const cornerRef = useRef(null) const widthDrag = useRef({ startX: 0, startWidth: 0 }) const [draggingWidth, setDraggingWidth] = useState(false) + const bottomDrag = useRef({ startY: 0, startHeight: 0 }) + const [draggingBottom, setDraggingBottom] = useState(false) + const cornerDrag = useRef({ startX: 0, startY: 0, startWidth: 0, startHeight: 0 }) + const [draggingCorner, setDraggingCorner] = useState(false) + const anyDragging = draggingWidth || draggingBottom || draggingCorner - // Layout push: the app shell gives up the panel's width while it is open - // (0 while collapsed), so the conversation and input bar are squeezed - // instead of covered. The margin is capped at the viewport so a stale - // persisted width (e.g. fullscreen on a bigger window) can never crush - // the app shell to zero. Dragging disables the layout transition. + // Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store + // re-clamps on commit; these keep the panels from overshooting mid-drag). + const clampWidth = (width: number): number => + Math.min(Math.max(PANEL_MIN, Math.round(width)), Math.max(PANEL_MIN, window.innerWidth)) + const clampHeight = (height: number): number => + Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN)) + + /** Apply a drag size to the DOM without touching React state or the store. + * The bottom panel's right edge tracks the right panel's left edge HERE + * too — React state only updates on release, so the inline right must be + * written directly or the bottom panel would lag the sidebar mid-drag. */ + const applyDrag = (width: number, height: number): void => { + panelRef.current?.style.setProperty('width', `${width}px`) + bottomRef.current?.style.setProperty('height', `${height}px`) + bottomRef.current?.style.setProperty('right', `${width + detailsWidth}px`) + document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`) + document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`) + if (cornerRef.current !== null) { + cornerRef.current.style.left = `${window.innerWidth - width - 6}px` + cornerRef.current.style.top = `${window.innerHeight - height - 6}px` + } + } + + // Drags write at most once per frame: pointer events fire several times + // faster than the display refresh, and each write reflows the app shell + // (the layout push) plus the panels — batching to one write per frame is + // what keeps the drag smooth. The store is still committed once on release. + const dragFrame = useRef(null) + const pendingDrag = useRef<{ width: number; height: number } | null>(null) + const scheduleDrag = (width: number, height: number): void => { + pendingDrag.current = { width, height } + if (dragFrame.current !== null) return + dragFrame.current = requestAnimationFrame(() => { + dragFrame.current = null + const pending = pendingDrag.current + if (pending !== null) { + pendingDrag.current = null + applyDrag(pending.width, pending.height) + } + }) + } + + /** Flush any pending drag write and stop scheduling (the store commit on + * pointer up applies the final clamped values). */ + const stopDragScheduling = (): void => { + if (dragFrame.current !== null) { + cancelAnimationFrame(dragFrame.current) + dragFrame.current = null + } + pendingDrag.current = null + } + + // Layout push: the app shell gives up the panel's width/height while the + // panels are open (0 while collapsed), so the conversation and input bar + // are squeezed instead of covered. The margins are capped at the viewport + // so a stale persisted size (e.g. fullscreen on a bigger window) can never + // crush the app shell to zero. Dragging disables the layout transition. useEffect(() => { const width = snapshot.state?.panelOpen === true ? Math.min(snapshot.state.width, window.innerWidth) : 0 + const height = snapshot.state?.bottomOpen === true + ? Math.min(snapshot.state.bottomHeight, window.innerHeight) + : 0 document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`) - }, [snapshot.state?.panelOpen, snapshot.state?.width]) + document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`) + }, [snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight]) useEffect(() => { - if (draggingWidth) document.body.setAttribute('data-dsh-sidebar-dragging', '') + if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '') else document.body.removeAttribute('data-dsh-sidebar-dragging') - }, [draggingWidth]) + }, [anyDragging]) - // Whether the panel currently fills the viewport (fullscreen expansion). - const fullscreen = state !== undefined && window.innerWidth - state.width < 8 - const actions: WorkbenchActions = useMemo(() => ({ closeTab: (paneId, tabId) => { // A closed terminal releases its pty immediately — including when its @@ -287,7 +418,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { }) }, resizeSplit: (splitId, index, deltaFrac) => { - store.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) })) + store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac)) }, }), [store, sessionId, cwd]) @@ -318,10 +449,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (state === undefined || sessionId === undefined) { return ( -
+
+ + +
@@ -349,7 +485,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { * polling while the page is not actually visible). The pane id travels * with the tab so diff tabs can split below their source pane. */ - const renderTab = (tab: SidebarTab, active: boolean, paneId: string) => ( + const renderTab = (tab: SidebarTab, active: boolean, paneId: string, bottom = false) => ( { subagentJumpRef.current = childSessionId }} onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }} /> @@ -367,28 +503,47 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { return ( <> - {!state.panelOpen && ( -
- - - -
- )} {/* - The panel stays mounted while collapsed (hidden off-screen) so the - slide in/out can animate; visibility hides it after the slide settles. + The persistent toggle cluster at the top-right corner: the bottom + panel's button (bottom glyph) LEFT of the right panel's (side glyph). + Always pinned to the viewport corner — inside the right panel's + top-right while it is open, sitting flush in the tab strip whose + right end it really squeezes (the strip reserves its width via CSS), + so the tabs genuinely yield space to it. + */} +
+ + + + + + +
+ {/* + The right panel stays mounted while collapsed (hidden off-screen) so + the slide in/out can animate; visibility hides it after the slide + settles. Its bottom edge follows the bottom panel's height (0 while + the bottom panel is closed) — the VSCode-style "sidebar above panel". */}
{ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return const { startX, startWidth } = widthDrag.current - store.reduce(s => setWidth(s, startWidth + (startX - event.clientX))) + const width = clampWidth(startWidth + (startX - event.clientX)) + const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0 + scheduleDrag(width, height) }} onPointerUp={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return event.currentTarget.releasePointerCapture(event.pointerId) + const { startX, startWidth } = widthDrag.current + stopDragScheduling() + store.reduce(s => setWidth(s, startWidth + (startX - event.clientX))) setDraggingWidth(false) }} /> -
- - - {t('explorer')} - {cwd !== undefined ? ` · ${cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd}` : ''} - - -
+ {/* + The bottom panel: a second, independent workbench. It squeezes ONLY + the center column (the agent output area): it starts at the app + shell's own left sidebar and ends at the right panel's left edge — + neither sidebar gives up any position (the right panel keeps its + full height). Its resize strip is the top edge; hidden by sliding + down like the right panel. + */} +
+
{ + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight } + setDraggingBottom(true) + }} + onPointerMove={(event) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + const { startY, startHeight } = bottomDrag.current + const height = clampHeight(startHeight + (startY - event.clientY)) + scheduleDrag(Math.min(state.width, window.innerWidth), height) + }} + onPointerUp={(event) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + event.currentTarget.releasePointerCapture(event.pointerId) + const { startY, startHeight } = bottomDrag.current + stopDragScheduling() + store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY))) + setDraggingBottom(false) + }} + /> + {/* + The bottom panel's own close control at its tab strip's right end + (the strip reserves the width via CSS so the + menu never hides + under it): one tap collapses the panel. + */} + + + +
+ renderTab(tab, active, paneId, true)} + getTabIcon={(tab) => { + const descriptor = ctx.betterSidebar?.getTab(tab.type) + if (descriptor === undefined) return null + return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon + }} + /> +
+
+ {/* + The shared corner (only while BOTH panels are open): the intersection + of the right panel's left edge and the bottom panel's top edge. + Horizontal drags resize the right panel's width, vertical drags the + bottom panel's height — the two panels drag against each other. + */} + {state.panelOpen && state.bottomOpen && ( +
{ + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + cornerDrag.current = { + startX: event.clientX, + startY: event.clientY, + startWidth: state.width, + startHeight: state.bottomHeight, + } + setDraggingCorner(true) + }} + onPointerMove={(event) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + const { startX, startY, startWidth, startHeight } = cornerDrag.current + const width = clampWidth(startWidth + (startX - event.clientX)) + const height = clampHeight(startHeight + (startY - event.clientY)) + scheduleDrag(width, height) + }} + onPointerUp={(event) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + event.currentTarget.releasePointerCapture(event.pointerId) + const { startX, startY, startWidth, startHeight } = cornerDrag.current + stopDragScheduling() + store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY))) + setDraggingCorner(false) + }} + /> + )} ) } diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index e4c305f..36538fe 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -106,14 +106,19 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { icon: (size: number) => , order: 40, available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT, - // Declarative settings: the model-facing terminal tools switch renders - // under this row in the Side card settings page (the Terminal page's - // own related setting; the host gates the toolset on it independently). + // Declarative settings: the model-facing terminal tools switch and the + // bottom-panel first-expansion auto-terminal switch render under this + // row in the Side card settings page (the Terminal page's own related + // settings; the host gates the toolset on the tools one independently). settings: { toggles: [{ key: 'agentTerminalTools', title: () => t('settingsToolsTitle'), desc: () => t('settingsToolsDesc'), + }, { + key: 'bottomPanelAutoTerminal', + title: () => t('settingsBottomTerminalTitle'), + desc: () => t('settingsBottomTerminalDesc'), }], }, createTab: (state) => { diff --git a/src/client/icons.tsx b/src/client/icons.tsx index 747cc82..8e61af1 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -1,11 +1,35 @@ /** * Icons the sidebar needs beyond the primitives set: a terminal glyph (the - * icon library has none) and a diff glyph. Per-tab icons live on the tab - * descriptors (`descriptor.icon`), not in a type-keyed switch — the icon - * mapping was registry-ized with the tab types. + * icon library has none), a diff glyph, and the two panel-toggle glyphs for + * the top-right cluster. Per-tab icons live on the tab descriptors + * (`descriptor.icon`), not in a type-keyed switch — the icon mapping was + * registry-ized with the tab types. */ import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' +/** + * Right-panel toggle glyph (the "侧拉" button): a frame with a filled strip + * along its RIGHT edge, in the app's outline style (1.5px stroke, + * currentColor). + */ +export const IconPanelRightOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + +/** + * Bottom-panel toggle glyph (the "底栏" button): a frame with a filled strip + * along its BOTTOM edge, in the app's outline style. + */ +export const IconPanelBottomOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + /** * Terminal glyph in the app's outline style (1.5px stroke, currentColor): * a rounded frame with a prompt chevron and underscore cursor. diff --git a/src/client/layout.css b/src/client/layout.css index be80a10..3ead995 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -1,15 +1,20 @@ /** - * Layout push: when the sidebar is open it OCCUPIES the layout instead of + * Layout push: when a panel is open it OCCUPIES the layout instead of * floating over it — the app shell (#root, the AppFrame three-column grid) - * gives up the panel's width on the right. Only the center column is - * flexible (1fr), so the squeeze lands exactly on the conversation output - * and the input bar, like a VSCode sidebar. The fixed panel covers the - * vacated strip, which looks seamless because #root's background is the - * theme base. + * gives up space. Only the center column is flexible (1fr), so the right + * panel's width squeeze (margin-right on #root) lands exactly on the + * conversation output and the input bar, like a VSCode sidebar. * - * The width rides a CSS variable updated by the Sidebar shell (0 while - * collapsed); expand/collapse animates both the margin and the panel slide - * on the same theme duration. Width drags disable the transition so the + * The bottom panel squeezes ONLY the center column — it must not cover the + * app's own left sidebar or the right panel. The AppFrame's grid items are + * (#root > div > div:nth-child(2)): sidebarCol, centerCol, detailsCol, so + * the vertical push lands on the center column alone. The fixed panels + * cover the vacated strips, which looks seamless because #root's background + * is the theme base. + * + * The sizes ride CSS variables updated by the Sidebar shell (0 while + * collapsed); expand/collapse animates both the margins and the panel + * slides on the same theme duration. Drags disable the transition so the * layout tracks the pointer. */ #root { @@ -17,12 +22,23 @@ transition: margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out); } -body[data-dsh-sidebar-dragging] #root { +/* The AppFrame's grid items are sidebarCol, centerCol, detailsCol (children + 1-3 of #root > div) — nth-child(2) is the center column. A stretched grid + item shrinks by its margins, so the conversation content (output + input + bar) lifts without touching the sidebars. */ +#root > div > div:nth-child(2) { + margin-bottom: var(--dsh-sidebar-height, 0px); + transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +body[data-dsh-sidebar-dragging] #root, +body[data-dsh-sidebar-dragging] #root > div > div:nth-child(2) { transition: none; } @media (prefers-reduced-motion: reduce) { - #root { + #root, + #root > div > div:nth-child(2) { transition: none; } } diff --git a/src/client/locales.ts b/src/client/locales.ts index fe54814..b995f55 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -18,8 +18,8 @@ const zh = { close: '关闭', collapse: '折叠侧边栏', expand: '展开侧边栏', - expandFullscreen: '展开为全屏', - restoreFullscreen: '还原宽度', + collapseBottomPanel: '折叠底部面板', + expandBottomPanel: '展开底部面板', terminalError: '终端连接失败', terminalConnectFailed: '终端多次连接失败', terminalRetry: '重试', @@ -123,6 +123,8 @@ const zh = { settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开', settingsToolsTitle: '为模型注入终端工具', settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)', + settingsBottomTerminalTitle: '底部面板首次展开自动开终端', + settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)', settingsTabsTitle: '侧边栏内容', settingsViewersTitle: '文件预览', settingsGeneralTitle: '常规', @@ -193,8 +195,8 @@ const en: Record = { close: 'Close', collapse: 'Collapse sidebar', expand: 'Expand sidebar', - expandFullscreen: 'Expand to full width', - restoreFullscreen: 'Restore width', + collapseBottomPanel: 'Collapse bottom panel', + expandBottomPanel: 'Expand bottom panel', terminalError: 'Terminal connection failed', terminalConnectFailed: 'Terminal failed to connect repeatedly', terminalRetry: 'Retry', @@ -298,6 +300,8 @@ const en: Record = { settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually', settingsToolsTitle: 'Inject terminal tools for the model', settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)', + settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion', + settingsBottomTerminalDesc: 'When the bottom panel is expanded for the first time in a session, try to open a fresh terminal tab there (the terminal quota still applies; on by default)', settingsTabsTitle: 'Sidebar content', settingsViewersTitle: 'File viewers', settingsGeneralTitle: 'General', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index e7c7daf..7213de7 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -43,6 +43,9 @@ export function parsePrefs(value: unknown): SidebarPrefs { agentTerminalTools: typeof record.agentTerminalTools === 'boolean' ? record.agentTerminalTools : SIDEBAR_PREFS_DEFAULTS.agentTerminalTools, + bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean' + ? record.bottomPanelAutoTerminal + : SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal, interceptOpenPath: typeof record.interceptOpenPath === 'boolean' ? record.interceptOpenPath : SIDEBAR_PREFS_DEFAULTS.interceptOpenPath, diff --git a/src/client/service.ts b/src/client/service.ts index ee4795a..2cfed4c 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -379,7 +379,9 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined) const key = dedupeKey?.(tab) if (key !== undefined) { - for (const leaf of allLeaves(state.splits)) { + // The scan covers BOTH trees: opening a single-instance tab from the + // bottom panel focuses an existing instance wherever it lives. + for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) { const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key) if (existing !== undefined) return activateTab(state, leaf.id, existing.id) } @@ -387,9 +389,9 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri return openTabInActivePane(state, tab) } -/** Find which pane hosts a tab id ('' if none). */ +/** Find which pane hosts a tab id ('' if none). Either tree is searched. */ function findPaneIdOf(state: SidebarState, tabId: string): string { - for (const leaf of allLeaves(state.splits)) { + for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) { if (leaf.tabs.some(t => t.id === tabId)) return leaf.id } return state.activePane ?? '' diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 43ec342..5975c2d 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -10,27 +10,39 @@ /* ── Shell ─────────────────────────────────────────────────────────────── */ -.toggleRail { +/* The persistent expand/collapse cluster at the top-right corner: two rail + controls side by side (bottom panel's glyph LEFT of the right panel's). + Always pinned to the viewport corner — inside the right panel's top-right + while it is open, sitting flush in the 34px tab strip (28px buttons at + top: 3) whose right end it really squeezes. */ +.toggleCluster { position: fixed; - right: 0; - top: 50%; - transform: translateY(-50%); + top: 3px; + right: 10px; z-index: 55; display: flex; - align-items: center; - /* Native rail inset: the 36px control sits 10px off the column edge. */ - padding: 0 10px; + flex-direction: row; + gap: 4px; } -/* The closed-state affordance is the native rail icon control: a plain 36px - circle, transparent fill, secondary ink; hover raises the fill. No border, - no shadow — the icon alone is the affordance (SidebarRoot .iconButton). */ +/* The tab strip of the OPEN right panel reserves the cluster's width at its + right end, so the tabs and the + menu genuinely yield space to the cluster + (never hiding under it). */ +.panel:not(.panelHidden) .tabBar { + padding-right: 72px; +} + +/* The closed-state affordance is the native rail icon control: a plain + 28px circle (the app's icon-button size — the cluster must stay inside + the 34px tab-strip band so it never covers the pane headers below), + transparent fill, secondary ink; hover raises the fill. No border, no + shadow — the icon alone is the affordance. */ .toggleButton { display: flex; align-items: center; justify-content: center; - width: 36px; - height: 36px; + width: 28px; + height: 28px; border: none; border-radius: 50%; background: transparent; @@ -55,6 +67,8 @@ position: fixed; top: 0; right: 0; + /* Full height: the bottom panel squeezes only the center column, so the + right panel never gives up any vertical position. */ bottom: 0; z-index: 50; display: flex; @@ -100,25 +114,6 @@ background: var(--dsw-alias-interactive-bg-hover-accent); } -.panelHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - height: 36px; - padding: 0 8px 0 12px; - border-bottom: 1px solid var(--dsw-alias-border-l1); - flex: none; -} - -.panelTitle { - font: var(--dsw-font-s-14); - color: var(--dsw-alias-label-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .panelBody { flex: 1; min-height: 0; @@ -126,6 +121,112 @@ display: flex; } +/* ── Bottom panel ──────────────────────────────────────────────────────── */ + +/* The bottom panel: an independent workbench at the bottom of the CENTER + column only (the Sidebar shell writes left/right inline: from the app's + own left sidebar to the right panel's left edge) — the sidebars never + give up any position. Same surface as the right panel, with a top + hairline instead of the left one. */ +.bottomPanel { + position: fixed; + bottom: 0; + z-index: 50; + display: flex; + flex-direction: column; + background: var(--dsw-specific-sidebar-fill); + border-top: 1px solid var(--dsw-alias-border-l2); + /* Expand/collapse slides the panel; height drags animate the height. */ + transition: + transform var(--ds-transition-duration-slow) var(--ds-ease-in-out), + height var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +/* Collapsed: slid off-screen, interactive nothing, hidden after the slide. */ +.bottomPanelHidden { + transform: translateY(102%); + pointer-events: none; + visibility: hidden; + transition: + transform var(--ds-transition-duration-slow) var(--ds-ease-in-out), + height var(--ds-transition-duration-slow) var(--ds-ease-in-out), + visibility 0s linear var(--ds-transition-duration-slow); +} + +/* Height drags write at pointer cadence; easing would detach the edge. */ +.bottomPanel[data-dragging] { + transition: none; +} + +/* The bottom panel's height drag: the top edge strip (mirror of the right + panel's width strip). */ +.bottomResize { + position: absolute; + left: 0; + right: 0; + top: -4px; + height: 8px; + cursor: row-resize; + z-index: 2; + touch-action: none; +} + +.bottomResizeActive { + background: var(--dsw-alias-interactive-bg-hover-accent); +} + +/* The bottom panel's close control: the app's icon-button pattern (28px + circle), pinned to the tab strip's right end — one tap collapses the + panel. The tab strips below reserve its width so the + menu never hides + under it. */ +.bottomClose { + position: absolute; + top: 3px; + right: 6px; + z-index: 4; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + flex: none; +} + +.bottomClose:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +/* The bottom panel's tab strips reserve the close control's width at their + right end, so the + menu never hides under it. */ +.bottomPanel .tabBar { + padding-right: 40px; +} + +/* The shared corner (both panels open): the intersection of the right + panel's left edge and the bottom panel's top edge. Horizontal drags + resize the width, vertical drags the height — the two panels drag + against each other. */ +.cornerHandle { + position: fixed; + width: 12px; + height: 12px; + z-index: 52; + cursor: nwse-resize; + touch-action: none; +} + +.cornerHandle:hover, +.cornerHandle[data-dragging] { + background: var(--dsw-alias-interactive-bg-hover-accent); +} + /* Icon button: the app's own icon-button pattern (ui-workspace) — a 28px circle, secondary label, hover fill; no border so the icon alone is the affordance. */ @@ -317,28 +418,35 @@ display: none; } +/* An empty pane's welcome cards: a responsive grid of compact cards — as + many per row as the pane width allows; rows that cannot fit the pane + height are clipped (never scrolled or squeezed smaller). */ .paneEmptyCards { flex: 1; min-height: 0; - display: flex; - flex-direction: column; - justify-content: center; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + align-content: start; gap: 8px; - padding: 16px; + padding: 12px; + overflow: hidden; } .paneCard { display: flex; + flex-direction: column; align-items: center; - gap: 10px; - padding: 10px 12px; + justify-content: center; + gap: 6px; + min-width: 0; + padding: 12px 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxs-strong-12); cursor: pointer; - text-align: left; + text-align: center; } .paneCard:hover:not(:disabled) { @@ -1713,6 +1821,7 @@ /* Keyboard focus stays visible on every interactive control (the styling policy requires focus visibility; the accent token doubles as the ring). */ .toggleButton:focus-visible, +.bottomClose:focus-visible, .iconButton:focus-visible, .tab:focus-visible, .tabClose:focus-visible, @@ -1739,6 +1848,9 @@ @media (prefers-reduced-motion: reduce) { .panel, .panelHidden, + .bottomPanel, + .bottomPanelHidden, + .toggleCluster, .toggleButton, .tab, .tabBarPlus, diff --git a/src/client/split-pane.tsx b/src/client/split-pane.tsx index a6048c6..ee339a3 100644 --- a/src/client/split-pane.tsx +++ b/src/client/split-pane.tsx @@ -262,20 +262,24 @@ function NodeView(props: { ) } -/** The workbench: the split tree filling the sidebar body. */ +/** The workbench: the split tree filling the sidebar body. `tree` selects + * which tree renders (the right panel's by default; the bottom panel passes + * `state.bottomSplits` — the actions route by pane id, so one action set + * serves both). */ export function Workbench(props: { state: SidebarState + tree?: SplitNode newTabOptions: NewTabOption[] actions: WorkbenchActions onNewTab: (optionId: string) => void renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode getTabIcon?: (tab: SidebarTab) => ReactNode }) { - const { state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props + const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props return (
| null)?.splits) + walk((parsed as Record | null)?.bottomSplits) return max } @@ -126,6 +147,9 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedEx leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }] leaf.active = leaf.tabs[0]!.id } + // The bottom panel starts closed with an empty pane (its welcome cards + // offer the openable types on first use). + const bottomLeaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null } return { panelOpen, width, @@ -134,9 +158,29 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedEx nextBrowser: 1, expanded: [], splits: leaf, + bottomOpen: false, + bottomHeight: BOTTOM_DEFAULT, + bottomOpenedOnce: false, + bottomSplits: bottomLeaf, } } +/** Whether a tree node (or any descendant) carries the given pane/split id. */ +function treeHasId(node: SplitNode, id: string): boolean { + if (node.id === id) return true + if (node.kind === 'split') return node.children.some(child => treeHasId(child, id)) + return false +} + +/** Which tree owns a pane/split id: 'bottomSplits' when the id lives in the + * bottom panel's tree, else 'splits' (the right panel's tree). Ids are + * globally unique (the shared uid counter), so an id in neither tree falls + * back to the right tree, where tree operations no-op on a missing node — + * the pre-bottom-panel behavior. */ +export function treeOf(state: SidebarState, id: string): 'splits' | 'bottomSplits' { + return treeHasId(state.bottomSplits, id) ? 'bottomSplits' : 'splits' +} + /** Walk the tree and apply `visit` to the leaf with the given id. */ export function mapLeaf(node: SplitNode, paneId: string, visit: (leaf: SidebarLeaf) => void): SplitNode { if (node.kind === 'leaf') { @@ -179,9 +223,10 @@ export function allLeaves(node: SplitNode): SidebarLeaf[] { return node.children.flatMap(allLeaves) } -/** Whether a tab exists anywhere in a state tree (any pane). */ +/** Whether a tab exists anywhere in a state (either tree, any pane). */ export function tabOpenIn(state: SidebarState, tabId: string): boolean { return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId)) + || allLeaves(state.bottomSplits).some(leaf => leaf.tabs.some(tab => tab.id === tabId)) } /** Replace a leaf with a split of it plus a fresh empty leaf. */ @@ -237,6 +282,9 @@ export type DropZone = 'left' | 'right' | 'up' | 'down' | 'center' * The VSCode drag gesture: move a tab out of its pane and either merge it * into the target pane (center) or split the target pane with the tab in a * fresh leaf (edge). The source pane collapses when it empties. + * + * The panes may live in DIFFERENT trees (dragging a tab between the two + * panels): the tab then leaves its own tree and lands in the other one. */ export function moveTabToEdge( state: SidebarState, @@ -249,11 +297,43 @@ export function moveTabToEdge( // Dropped back onto its own pane's center: reorder to the end. return moveTab(state, fromPane, tabId, toPane, -1) } - const source = leafWithTab(state.splits, tabId) + const key = treeOf(state, fromPane) + const toKey = treeOf(state, toPane) + if (key !== toKey) { + // Cross-panel drop: remove the tab from its own tree, then merge (center) + // or split (edge) a pane of the OTHER tree with the tab. + const source = leafWithTab(state[key], tabId) + if (source === undefined) return state + const tab = source.tabs.find(candidate => candidate.id === tabId)! + let emptied = false + let sourceNode = mapLeaf(state[key], source.id, (leaf) => { + leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId) + if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null + if (leaf.tabs.length === 0) emptied = true + }) + if (emptied) sourceNode = removeLeafAt(sourceNode, source.id) + let targetNode = state[toKey] + let activePane: string + if (zone === 'center') { + targetNode = mapLeaf(targetNode, toPane, (leaf) => { + leaf.tabs = [...leaf.tabs, tab] + leaf.active = tab.id + }) + activePane = toPane + } else { + const dir = zone === 'left' || zone === 'right' ? 'row' : 'col' + const result = insertLeafAt(targetNode, toPane, dir, tab, zone === 'left' || zone === 'up') + targetNode = result.node + activePane = result.leafId + } + return { ...state, [key]: sourceNode, [toKey]: targetNode, activePane } + } + const node = state[key] + const source = leafWithTab(node, tabId) if (source === undefined) return state const tab = source.tabs.find(candidate => candidate.id === tabId)! let emptied = false - let splits = mapLeaf(state.splits, source.id, (leaf) => { + let splits = mapLeaf(node, source.id, (leaf) => { leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId) if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null if (leaf.tabs.length === 0) emptied = true @@ -264,11 +344,11 @@ export function moveTabToEdge( leaf.tabs = [...leaf.tabs, tab] leaf.active = tab.id }) - return { ...state, splits, activePane: toPane } + return { ...state, [key]: splits, activePane: toPane } } const dir = zone === 'left' || zone === 'right' ? 'row' : 'col' const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up') - return { ...state, splits: result.node, activePane: result.leafId } + return { ...state, [key]: result.node, activePane: result.leafId } } /** @@ -291,21 +371,23 @@ export function removeLeafAt(node: SplitNode, paneId: string): SplitNode { /** Close a tab; an emptied leaf is removed (unless it is the only pane). */ export function closeTab(state: SidebarState, paneId: string, tabId: string): SidebarState { + const key = treeOf(state, paneId) let emptied = false - const splits = mapLeaf(state.splits, paneId, (leaf) => { + const splits = mapLeaf(state[key], paneId, (leaf) => { leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId) if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null if (leaf.tabs.length === 0) emptied = true }) - return { ...state, splits: emptied ? removeLeafAt(splits, paneId) : splits } + return { ...state, [key]: emptied ? removeLeafAt(splits, paneId) : splits } } -/** Activate a tab in its pane. */ +/** Activate a tab in its pane (the pane's own tree). */ export function activateTab(state: SidebarState, paneId: string, tabId: string): SidebarState { + const key = treeOf(state, paneId) return { ...state, activePane: paneId, - splits: mapLeaf(state.splits, paneId, (leaf) => { + [key]: mapLeaf(state[key], paneId, (leaf) => { if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId }), } @@ -314,7 +396,7 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string): /** Update the display fields of one open tab (title / path) without * re-opening it. The browser tab persists its current URL and hostname * title through this reducer so a reload restores the visited page. A - * missing tab id is a no-op. */ + * missing tab id is a no-op. The tab may live in either tree. */ export function patchTab( state: SidebarState, tabId: string, @@ -338,7 +420,8 @@ export function patchTab( return children === node.children ? node : { ...node, children } } const splits = walk(state.splits) - return changed ? { ...state, splits } : state + const bottomSplits = walk(state.bottomSplits) + return changed ? { ...state, splits, bottomSplits } : state } /** @@ -349,34 +432,68 @@ export function patchTab( * openDiffTab already check existence before calling) and the landing * itself — the service's dedupe path delegates here after its dedupeKey * check misses. + * + * The active pane may live in EITHER tree (pane ids are globally unique): + * a stale id that survives in neither tree falls back to the right tree's + * first pane instead of swallowing the open. */ export function openTabInActivePane(state: SidebarState, tab: SidebarTab): SidebarState { let targetId = state.activePane ?? firstLeaf(state.splits).id // A stale activePane (its pane was closed since) must not swallow the - // open: fall back to the first pane instead of dropping the tab. - if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) { + // open: fall back to the first pane of the right tree instead of dropping + // the tab. + if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) { targetId = firstLeaf(state.splits).id } + const targetKey = treeOf(state, targetId) // Id-based safety net: if a tab with the same id exists, focus it. - for (const leaf of allLeaves(state.splits)) { + for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) { const existing = leaf.tabs.find(candidate => candidate.id === tab.id) if (existing !== undefined) return activateTab(state, leaf.id, existing.id) } return { ...state, activePane: targetId, - splits: mapLeaf(state.splits, targetId, (leaf) => { + [targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => { leaf.tabs = [...leaf.tabs, tab] leaf.active = tab.id }), } } -/** Move a tab from one pane to another (insert at index; -1 appends). */ +/** Move a tab from one pane to another (insert at index; -1 appends). + * The panes may live in DIFFERENT trees — dragging a tab between the two + * panels removes it from its own tree and lands it in the other one. */ export function moveTab(state: SidebarState, fromPane: string, tabId: string, toPane: string, index = -1): SidebarState { + const fromKey = treeOf(state, fromPane) + const toKey = treeOf(state, toPane) + if (fromKey !== toKey) { + let moved: SidebarTab | undefined + let emptied = false + const source = mapLeaf(state[fromKey], fromPane, (leaf) => { + const found = leaf.tabs.find(tab => tab.id === tabId) + if (found === undefined) return + moved = found + leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId) + if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null + if (leaf.tabs.length === 0) emptied = true + }) + if (moved === undefined) return state + const target = mapLeaf(state[toKey], toPane, (leaf) => { + const insertAt = index >= 0 && index <= leaf.tabs.length ? index : leaf.tabs.length + leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)] + leaf.active = moved!.id + }) + return { + ...state, + [fromKey]: emptied ? removeLeafAt(source, fromPane) : source, + [toKey]: target, + activePane: toPane, + } + } let moved: SidebarTab | undefined let emptied = false - let splits = mapLeaf(state.splits, fromPane, (leaf) => { + let splits = mapLeaf(state[fromKey], fromPane, (leaf) => { const found = leaf.tabs.find(tab => tab.id === tabId) if (found === undefined) return moved = found @@ -391,13 +508,14 @@ export function moveTab(state: SidebarState, fromPane: string, tabId: string, to leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)] leaf.active = moved!.id }) - return { ...state, splits, activePane: toPane } + return { ...state, [fromKey]: splits, activePane: toPane } } /** Split the active pane (or the pane containing the active tab). */ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState { const paneId = state.activePane ?? firstLeaf(state.splits).id - return { ...state, splits: splitLeafAt(state.splits, paneId, dir) } + const key = treeOf(state, paneId) + return { ...state, [key]: splitLeafAt(state[key], paneId, dir) } } /** @@ -443,6 +561,11 @@ export function togglePanel(state: SidebarState): SidebarState { return { ...state, panelOpen: !state.panelOpen } } +/** Toggle the bottom panel open/closed (independent of the right panel). */ +export function toggleBottomPanel(state: SidebarState): SidebarState { + return { ...state, bottomOpen: !state.bottomOpen } +} + /** Set the panel width (clamped to the contract range; the upper bound is * the viewport so the fullscreen expansion can fill the window). */ export function setWidth(state: SidebarState, width: number): SidebarState { @@ -450,6 +573,16 @@ export function setWidth(state: SidebarState, width: number): SidebarState { return { ...state, width: Math.min(max, Math.max(PANEL_MIN, Math.round(width))) } } +/** Set the bottom panel height (clamped to the contract range). The upper + * bound leaves the center column (the agent output area) at least PANEL_MIN + * tall — without the cap the bottom panel could swallow the whole viewport + * and squeeze the conversation to zero height. */ +export function setBottomHeight(state: SidebarState, height: number): SidebarState { + const viewport = typeof window !== 'undefined' ? window.innerHeight : Infinity + const max = Math.max(BOTTOM_MIN, viewport - PANEL_MIN) + return { ...state, bottomHeight: Math.min(max, Math.max(BOTTOM_MIN, Math.round(height))) } +} + /** Toggle a directory in the explorer expansion set. */ export function toggleExpanded(state: SidebarState, path: string): SidebarState { const expanded = state.expanded.includes(path) @@ -476,6 +609,13 @@ export function resizeSplit(node: SplitNode, splitId: string, index: number, del } } +/** State-level {@link resizeSplit} route: the divider may live in either + * tree (split ids are globally unique). */ +export function resizeSplitIn(state: SidebarState, splitId: string, index: number, delta: number): SidebarState { + const key = treeOf(state, splitId) + return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) } +} + /** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */ export const AGENT_TAB_PREFIX = 'agent:' @@ -510,7 +650,7 @@ export function reconcileAgentTerminals( state: SidebarState, agentTerminals: ReadonlyArray<{ uuid: string; title: string }>, ): SidebarState { - const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) + const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs) const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id)) const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id))) const serverUuids = new Set(agentTerminals.map(t => t.uuid)) @@ -613,9 +753,29 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined { : 1 if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined + // The seen/reid maps are SHARED across both trees: pane/split ids must be + // globally unique (the runtime uid counter is shared too), so a duplicate + // seen first in the right tree gets a fresh id when it reappears in the + // bottom tree. + const seen = new Set() const reid = new Map() - const splits = sanitizeNode(record.splits, new Set(), reid) + const splits = sanitizeNode(record.splits, seen, reid) if (splits === undefined) return undefined + // Bottom-panel fields arrived in a later build: a missing or malformed + // value on an OLDER persisted state defaults (closed / default height / + // empty pane) so existing layouts keep loading, like nextBrowser. + const bottomOpen = record.bottomOpen === true + // Cap the persisted height so the center column (the agent output area) + // keeps at least PANEL_MIN tall (a stale full-height bottom panel from an + // older build must never squeeze the conversation to zero). + const maxHeight = typeof window !== 'undefined' ? window.innerHeight : Infinity + const bottomCap = Math.max(BOTTOM_MIN, maxHeight - PANEL_MIN) + const rawHeight = typeof record.bottomHeight === 'number' && Number.isFinite(record.bottomHeight) + ? record.bottomHeight + : BOTTOM_DEFAULT + const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight))) + const bottomSplits = sanitizeNode(record.bottomSplits, seen, reid) + ?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null } const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity return { panelOpen: record.panelOpen, @@ -627,6 +787,13 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined { nextBrowser, expanded: record.expanded as string[], splits, + bottomOpen, + bottomHeight, + // An older persisted state never expanded the bottom panel (the field + // arrived later): defaulting to false gives it the first-expansion + // auto-terminal exactly once after the upgrade. + bottomOpenedOnce: record.bottomOpenedOnce === true, + bottomSplits, } } diff --git a/src/config.ts b/src/config.ts index 0487419..b44cc9d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -80,6 +80,7 @@ export const PrefsSchema: z = z.object({ defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT), autoOpenSubagent: z.boolean().default(true), agentTerminalTools: z.boolean().default(false), + bottomPanelAutoTerminal: z.boolean().default(true), interceptOpenPath: z.boolean().default(true), htmlViewerNoSandbox: z.boolean().default(false), htmlViewerDefaultUnsafe: z.boolean().default(false), diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index c4b4182..8c66577 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -27,6 +27,13 @@ export interface SidebarPrefs { * user explicitly enables it in the side card settings. */ agentTerminalTools: boolean + /** + * Whether expanding the bottom panel for the FIRST time in a session tries + * to open a fresh terminal tab there (the terminal quota/type still gates + * the attempt). On by default; the switch lives under the terminal tab's + * row in the Side card settings. + */ + bottomPanelAutoTerminal: boolean /** * Whether chat-side file opens (tool-row path links, the produced-files * row, prose file mentions — every path that funnels through the client @@ -97,6 +104,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { defaultWidthPercent: WIDTH_PERCENT_DEFAULT, autoOpenSubagent: true, agentTerminalTools: false, + bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, diff --git a/tests/browser-globals.ts b/tests/browser-globals.ts index 264e244..3380ce8 100644 --- a/tests/browser-globals.ts +++ b/tests/browser-globals.ts @@ -48,6 +48,7 @@ if (g.window === undefined) { clearTimeout: () => {}, setTimeout: (_fn: () => void) => 0, innerWidth: 1024, + innerHeight: 768, getComputedStyle: () => ({ getPropertyValue: () => '' }), } } diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 5309dfa..aefab05 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -50,7 +50,7 @@ describe('built-in tab registrations', () => { it('the terminal tab declares the model terminal-tools related setting', () => { const { service } = setup() const toggles = service.getTab('terminal')?.settings?.toggles ?? [] - expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools']) + expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal']) }) it('the browser tab declares its sandbox and link-takeover related settings', () => { diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 90f986e..fbfca4d 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -57,6 +57,6 @@ describe('dsh-better-sidebar plugin export shape', () => { const overridden = (PrefsSchema as unknown as { (input: Record | undefined): Record })({ openByDefault: false, defaultWidthPercent: 45 }) - expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) + expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) }) }) diff --git a/tests/service.spec.ts b/tests/service.spec.ts index ac83dfd..63d4164 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -414,3 +414,54 @@ describe('service.openTab dedupe', () => { expect(tabs).toHaveLength(1) }) }) + +describe('service.openTab across the two panels', () => { + it('openTab lands in the bottom tree when the active pane lives there', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ id: 'git', title: 'Git', component: () => null }) + store.setSession('s1') + store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id })) + service.openTab({ type: 'git', title: 'Git' }) + const state = store.getSnapshot().state! + expect(allLeaves(state.bottomSplits).flatMap(l => l.tabs).some(t => t.type === 'git')).toBe(true) + expect(allLeaves(state.splits).flatMap(l => l.tabs).some(t => t.type === 'git')).toBe(false) + }) + + it('dedupeKey focuses an existing instance in the OTHER tree (single-instance across panels)', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ + id: 'singleton', + title: 'Singleton', + single: true, + component: () => null, + }) + store.setSession('s1') + // Open in the right tree first. + service.openTab({ type: 'singleton', title: 'Singleton' }) + // Switch the active pane to the bottom tree and open again: the dedupe + // scan covers both trees, so the existing instance is focused, not + // duplicated in the bottom panel. + store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id })) + service.openTab({ type: 'singleton', title: 'Singleton' }) + const state = store.getSnapshot().state! + const total = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)) + .flatMap(l => l.tabs).filter(t => t.type === 'singleton') + expect(total).toHaveLength(1) + }) + + it('closeTab by id closes a tab living in the bottom tree', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ id: 'git', title: 'Git', component: () => null }) + store.setSession('s1') + store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id })) + service.openTab({ type: 'git', title: 'Git' }) + const state = store.getSnapshot().state! + const gitTab = allLeaves(state.bottomSplits).flatMap(l => l.tabs).find(t => t.type === 'git')! + service.closeTab(gitTab.id) + const after = store.getSnapshot().state! + expect(allLeaves(after.bottomSplits).flatMap(l => l.tabs).some(t => t.id === gitTab.id)).toBe(false) + }) +}) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 23a7b2c..4480df7 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -507,6 +507,7 @@ describe('side card settings routes', () => { defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, + bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index 8e4deb3..9a6ba5b 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -3,8 +3,8 @@ import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from ' import { parseLogLines, parsePorcelainZ } from '../src/git.ts' import { parseUnifiedDiff } from '../src/client/DiffView.tsx' import { - activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState, - moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded, + activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState, + moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight, splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel, type SidebarState, type SidebarTab, type SplitNode, } from '../src/client/state.ts' import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts' @@ -520,6 +520,203 @@ describe('sidebar state', () => { s = openTabInActivePane(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' }) expect(tabOpenIn(s, 'terminal:9')).toBe(true) }) + + // ── Bottom panel (the second, independent workbench) ─────────────────── + + it('toggleBottomPanel flips the bottom panel independently of the right panel', () => { + let s = state() + expect(s.bottomOpen).toBe(false) + s = toggleBottomPanel(s) + expect(s.bottomOpen).toBe(true) + // Collapsing the right panel leaves the bottom panel open (independent toggles). + s = togglePanel(s) + expect(s.panelOpen).toBe(false) + expect(s.bottomOpen).toBe(true) + }) + + it('setBottomHeight clamps to the contract range', () => { + expect(setBottomHeight(state(), 50).bottomHeight).toBe(BOTTOM_MIN) + const g = globalThis as Record + const previous = g.window + g.window = { innerHeight: 800 } + try { + // The bottom panel must leave the center column at least PANEL_MIN + // tall (800 - 280), regardless of the right panel's open state. + expect(setBottomHeight(state(), 9999).bottomHeight).toBe(800 - 280) + expect(setBottomHeight({ ...state(), panelOpen: false }, 9999).bottomHeight).toBe(800 - 280) + } finally { + if (previous === undefined) delete g.window + else g.window = previous + } + }) + + it('openTabInActivePane lands in the bottom tree when the active pane lives there', () => { + let s = state() + s = toggleBottomPanel(s) + const bottomPane = (s.bottomSplits as { id: string }).id + s = { ...s, activePane: bottomPane } + const tab = { id: 'git', type: 'git' as const, title: 'Git' } + s = openTabInActivePane(s, tab) + expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain('git') + // The right tree is untouched. + expect((s.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toEqual(['explorer']) + expect(s.activePane).toBe(bottomPane) + // The id safety net works across trees: reopening the same id focuses it. + const after = openTabInActivePane(s, tab) + expect((after.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['git']) + }) + + it('openTabInActivePane falls back to the right tree when the active pane is stale', () => { + let s = state() + s = toggleBottomPanel(s) + s = { ...s, activePane: 'pane:gone' } + const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' }) + expect((after.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git') + }) + + it('closeTab routes to the bottom tree', () => { + let s = state() + s = toggleBottomPanel(s) + const bottomPane = (s.bottomSplits as { id: string }).id + s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' }) + expect(tabOpenIn(s, 'terminal:1')).toBe(true) + s = closeTab(s, bottomPane, 'terminal:1') + expect(tabOpenIn(s, 'terminal:1')).toBe(false) + // The right tree is untouched. + expect(tabOpenIn(s, (s.splits as { tabs: { id: string }[] }).tabs[0]!.id)).toBe(true) + }) + + it('moveTabToEdge splits within the bottom tree', () => { + let s = state() + s = toggleBottomPanel(s) + const bottomPane = (s.bottomSplits as { id: string }).id + s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' }) + s = moveTabToEdge(s, bottomPane, 'terminal:1', bottomPane, 'right') + expect(s.bottomSplits.kind).toBe('split') + expect(s.splits.kind).toBe('leaf') + expect(tabOpenIn(s, 'terminal:1')).toBe(true) + // The fresh leaf (the drop's active pane) differs from the source pane. + expect(s.activePane).not.toBe(bottomPane) + }) + + it('resizeSplitIn routes a divider to its own tree', () => { + let s = state() + s = toggleBottomPanel(s) + const bottomPane = (s.bottomSplits as { id: string }).id + s = splitPane({ ...s, activePane: bottomPane }, 'row') + const split = s.bottomSplits as Extract + s = resizeSplitIn(s, split.id, 0, 0.1) + const next = s.bottomSplits as Extract + expect(next.sizes[0]).toBeCloseTo(0.6) + expect(s.splits.kind).toBe('leaf') + }) + + it('sanitize defaults the bottom fields for older persisted states and repairs a broken bottom tree', () => { + const base = { + panelOpen: true, + width: 400, + nextTerminal: 1, + activePane: 'pane:1', + expanded: [], + splits: { + kind: 'leaf', + id: 'pane:1', + active: null, + tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }], + }, + } + // Older persisted states lack the bottom fields: defaults, state kept. + const s = sanitizeState(base) + expect(s?.bottomOpen).toBe(false) + expect(s?.bottomHeight).toBe(BOTTOM_DEFAULT) + expect(s?.bottomSplits.kind).toBe('leaf') + expect((s?.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) + // A malformed bottom tree is replaced with a fresh empty pane. + const broken = sanitizeState({ ...base, bottomSplits: 'junk' }) + expect(broken?.splits).toBeDefined() + expect(broken?.bottomSplits.kind).toBe('leaf') + // A valid persisted bottom tree survives. + const withBottom = sanitizeState({ + ...base, + bottomOpen: true, + bottomHeight: 300, + bottomSplits: { + kind: 'leaf', + id: 'pane:9', + active: 'b1', + tabs: [{ id: 'b1', type: 'terminal', title: 'T' }], + }, + }) + expect(withBottom?.bottomOpen).toBe(true) + expect(withBottom?.bottomHeight).toBe(300) + expect((withBottom?.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toEqual(['b1']) + // Heights are clamped to the contract range. + expect(sanitizeState({ ...base, bottomHeight: 10 })?.bottomHeight).toBe(BOTTOM_MIN) + // A stale full-height bottom panel must not squeeze the center column + // (the agent output area) to zero: the cap leaves it at least PANEL_MIN + // tall, regardless of the right panel's open state. + const g = globalThis as Record + const previous = g.window + g.window = { innerHeight: 800 } + try { + expect(sanitizeState({ ...base, panelOpen: true, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280) + expect(sanitizeState({ ...base, panelOpen: false, bottomHeight: 9999 })?.bottomHeight).toBe(800 - 280) + } finally { + if (previous === undefined) delete g.window + else g.window = previous + } + }) + + it('tabOpenIn and patchTab see tabs in the bottom tree', () => { + let s = state() + s = toggleBottomPanel(s) + const bottomPane = (s.bottomSplits as { id: string }).id + s = openTabInActivePane( + { ...s, activePane: bottomPane }, + { id: 'browser:1', type: 'browser', title: 'example.com', path: 'https://example.com' }, + ) + expect(tabOpenIn(s, 'browser:1')).toBe(true) + s = patchTab(s, 'browser:1', { title: 'other.com', path: 'https://other.com' }) + const tab = allLeaves(s.bottomSplits).flatMap(leaf => leaf.tabs).find(t => t.id === 'browser:1') + expect(tab?.title).toBe('other.com') + }) + + it('moves a tab across panels (center merge into the other tree)', () => { + let s = state() + s = toggleBottomPanel(s) + const rightPane = (s.splits as { id: string }).id + const bottomPane = (s.bottomSplits as { id: string }).id + const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id + // Drag the explorer tab from the right panel into the bottom panel (center). + s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'center') + expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs.map(t => t.id)).toContain(explorerId) + expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) + expect(s.activePane).toBe(bottomPane) + // And back, inserted at an index. + s = moveTab(s, bottomPane, explorerId, rightPane, 0) + expect((s.splits as { tabs: SidebarTab[] }).tabs[0]!.id).toBe(explorerId) + expect((s.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) + }) + + it('moves a tab across panels by splitting the target pane (edge drop)', () => { + let s = state() + s = toggleBottomPanel(s) + const rightPane = (s.splits as { id: string }).id + const bottomPane = (s.bottomSplits as { id: string }).id + const explorerId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id + s = moveTabToEdge(s, rightPane, explorerId, bottomPane, 'right') + // The source tree empties back to a leaf; the target tree splits. + expect(s.splits.kind).toBe('leaf') + expect((s.splits as { tabs: SidebarTab[] }).tabs).toHaveLength(0) + expect(s.bottomSplits.kind).toBe('split') + expect(tabOpenIn(s, explorerId)).toBe(true) + const split = s.bottomSplits as Extract + expect(split.children.some( + child => child.kind === 'leaf' && (child as { tabs: SidebarTab[] }).tabs.some(t => t.id === explorerId), + )).toBe(true) + // The fresh leaf (the drop's active pane) differs from the source pane. + expect(s.activePane).not.toBe(rightPane) + }) }) describe('agent terminal reconciliation', () => { @@ -576,6 +773,17 @@ describe('agent terminal reconciliation', () => { expect(tabOpenIn(s, 'agent:aaa-111')).toBe(false) expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false) }) + + it('lands new agent terminals in the active tree (bottom panel pane)', () => { + let s = makeDefaultState(400, true) + s = toggleBottomPanel(s) + const bottomPane = (s.bottomSplits as { id: string }).id + s = { ...s, activePane: bottomPane } + s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'dev server' }]) + expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true) + expect(allLeaves(s.bottomSplits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(true) + expect(allLeaves(s.splits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(false) + }) }) describe('pty helpers', () => { @@ -883,6 +1091,7 @@ describe('side card preferences', () => { defaultWidthPercent: 60, autoOpenSubagent: false, agentTerminalTools: true, + bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, @@ -900,6 +1109,7 @@ describe('side card preferences', () => { defaultWidthPercent: 33, autoOpenSubagent: true, agentTerminalTools: false, + bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, @@ -917,6 +1127,7 @@ describe('side card preferences', () => { defaultWidthPercent: 40, autoOpenSubagent: true, agentTerminalTools: false, + bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, @@ -963,9 +1174,9 @@ describe('side card preferences', () => { const store = createSidebarStore() // Node environment: no window → the width falls back to PANEL_DEFAULT, // while the open flag still follows the preference. - store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) + store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) store.setSession('fresh-session') - expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) + expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) const snapshot = store.getSnapshot() expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.state?.panelOpen).toBe(false) @@ -978,7 +1189,7 @@ describe('side card preferences', () => { it('skips the default explorer tab when the explorer type is disabled', () => { const store = createSidebarStore() - store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} }) + store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} }) store.setSession('no-explorer') const state = store.getSnapshot().state! const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) @@ -986,7 +1197,7 @@ describe('side card preferences', () => { expect(state.splits.kind).toBe('leaf') // Re-enabling seeds the explorer tab again. const openStore = createSidebarStore() - openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) + openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) openStore.setSession('with-explorer') const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])