feat: 侧边栏双面板 — 右上角按钮簇、底部面板(首展自动终端/关闭按钮)、跨面板拖 Tab、拖动性能优化

- 右上角持久按钮簇:横向排列、独立 glyph(底栏/侧拉)、始终钉在视口右上角(侧边栏展开时位于其标签条右端,真实挤占 Tab 空间)
- 底部面板:独立工作台,只挤占中间 Agent 输出区(不覆盖左右侧边栏),右缘分割线、关闭 x、首展自动开终端(二级设置开关,默认开)
- 两面板拐角双向拖动调节尺寸;拖动改为 rAF 节流直写 DOM,消除卡顿;面板尺寸互钳制防丢失
- 跨面板拖 Tab(合并/拆分落点),moveTab/moveTabToEdge 支持跨树移动
- 欢迎卡片改响应式网格;布局/几何全部按会话持久化,旧状态平滑迁移
This commit is contained in:
Menghuan1918
2026-08-12 16:43:24 +08:00
parent 7eccc49f6e
commit c7af7dccdb
19 changed files with 1033 additions and 170 deletions
+1 -1
View File
@@ -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))
+327 -74
View File
@@ -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<boolean | undefined>(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<HTMLDivElement | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null)
const cornerRef = useRef<HTMLDivElement | null>(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<number | null>(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 (
<div className={css.toggleRail}>
<div className={css.toggleCluster}>
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelLeftOutline16 size={16} />
<IconPanelBottomOutline16 />
</button>
</Tooltip>
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelRightOutline16 />
</button>
</Tooltip>
</div>
@@ -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) => (
<TabContent
tab={tab}
sessionId={sessionId}
@@ -359,7 +495,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
visible={state.panelOpen && active}
visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
onSubagentJump={(childSessionId) => { 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 && (
<div className={css.toggleRail}>
<Tooltip label={t('expand')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={t('expand')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconPanelLeftOutline16 size={16} />
</button>
</Tooltip>
</div>
)}
{/*
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.
*/}
<div className={css.toggleCluster}>
<Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={state.panelOpen ? t('collapse') : t('expand')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconPanelRightOutline16 />
</button>
</Tooltip>
</div>
{/*
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".
*/}
<div
ref={panelRef}
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
style={{ width: Math.min(state.width, window.innerWidth) }}
data-dragging={draggingWidth || undefined}
data-dragging={anyDragging || undefined}
>
<div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
@@ -401,43 +556,19 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onPointerMove={(event) => {
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)
}}
/>
<div className={css.panelHeader}>
<button
type="button"
className={css.iconButton}
aria-label={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
onClick={() => {
const viewport = window.innerWidth
store.reduce(s => setWidth(s, fullscreen
? Math.max(280, Math.round((viewport - 320) / 2))
: viewport))
}}
>
<IconFullscreenOutline16 />
</button>
<span className={css.panelTitle}>
{t('explorer')}
{cwd !== undefined ? ` · ${cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd}` : ''}
</span>
<button
type="button"
className={css.iconButton}
aria-label={t('collapse')}
title={t('collapse')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconChevronRightOutline14 />
</button>
</div>
<div className={css.panelBody}>
<Workbench
state={state}
@@ -453,6 +584,128 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
/>
</div>
</div>
{/*
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.
*/}
<div
ref={bottomRef}
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
style={{
height: Math.min(state.bottomHeight, window.innerHeight),
left: appSidebarWidth,
// Ends at the details column's left edge (the details column sits
// between the center and the right panel) plus the right panel's
// own width — the bottom panel squeezes ONLY the center column.
right: detailsWidth + (state.panelOpen ? Math.min(state.width, window.innerWidth) : 0),
// The seam against the open right panel needs its own hairline
// (the right panel's border-left alone is covered by this panel's
// fill — without it the corner looks cut off).
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
}}
data-dragging={(draggingBottom || draggingCorner) || undefined}
>
<div
className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
onPointerDown={(event) => {
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.
*/}
<Tooltip label={t('collapseBottomPanel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.bottomClose}
aria-label={t('collapseBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconCloseFill14 />
</button>
</Tooltip>
<div className={css.panelBody}>
<Workbench
state={state}
tree={state.bottomSplits}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
actions={actions}
onNewTab={onNewTab}
renderTab={(tab, active, paneId) => 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
}}
/>
</div>
</div>
{/*
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 && (
<div
ref={cornerRef}
className={css.cornerHandle}
style={{
left: window.innerWidth - state.width - 6,
top: window.innerHeight - state.bottomHeight - 6,
}}
data-dragging={draggingCorner || undefined}
onPointerDown={(event) => {
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)
}}
/>
)}
</>
)
}
+8 -3
View File
@@ -106,14 +106,19 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
icon: (size: number) => <IconTerminalOutline16 size={size} />,
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) => {
+27 -3
View File
@@ -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) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="2" width="13" height="12" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
<rect x="10.5" y="3.25" width="2.75" height="9.5" rx="1" fill="currentColor" stroke="none" />
</svg>
)
/**
* 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) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="2" width="13" height="12" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
<rect x="3.25" y="10" width="9.5" height="2.75" rx="1" fill="currentColor" stroke="none" />
</svg>
)
/**
* Terminal glyph in the app's outline style (1.5px stroke, currentColor):
* a rounded frame with a prompt chevron and underscore cursor.
+27 -11
View File
@@ -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;
}
}
+8 -4
View File
@@ -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<keyof typeof zh, string> = {
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<keyof typeof zh, string> = {
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',
+3
View File
@@ -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,
+5 -3
View File
@@ -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 ?? ''
+150 -38
View File
@@ -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,
+7 -3
View File
@@ -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 (
<div className={css.workbench}>
<NodeView
node={state.splits}
node={tree ?? state.splits}
state={state}
newTabOptions={newTabOptions}
actions={actions}
+189 -22
View File
@@ -57,7 +57,9 @@ export type SplitNode = SidebarLeaf | SidebarSplit
export interface SidebarState {
panelOpen: boolean
width: number
/** The pane receiving newly opened tabs (last pane the user touched). */
/** The pane receiving newly opened tabs (last pane the user touched).
* Pane ids are globally unique across BOTH trees (shared uid counter), so
* one field resolves into either tree — see {@link treeOf}. */
activePane: string | null
/** Monotonic terminal tab counter (ids survive reloads). */
nextTerminal: number
@@ -65,13 +67,31 @@ export interface SidebarState {
nextBrowser: number
/** Explorer expansion set (absolute directory paths). */
expanded: string[]
/** The right sidebar's split tree (the original workbench). */
splits: SplitNode
/** Whether the bottom panel (a second, independent workbench) is open. */
bottomOpen: boolean
/** The bottom panel's height (clamped to the contract range). */
bottomHeight: number
/**
* Whether the bottom panel has been expanded at least once in this
* session — the FIRST expansion tries to auto-open a terminal tab (gated
* on the bottomPanelAutoTerminal pref); later expansions never do.
*/
bottomOpenedOnce: boolean
/** The bottom panel's own split tree (panes/tabs live only in ONE tree;
* tabs never cross panels — the two panels only share panel-size drags). */
bottomSplits: SplitNode
}
export const PANEL_MIN = 280
export const PANEL_MAX = 640
export const PANEL_DEFAULT = 400
export const TAB_MAX_WIDTH = 160
/** Bottom panel geometry contract (mirrors the width contract; the upper
* bound is the viewport, enforced by {@link setBottomHeight}). */
export const BOTTOM_MIN = 120
export const BOTTOM_DEFAULT = 220
let nextIdCounter = 0
/** Unique pane/tab id within one state instance. */
@@ -110,6 +130,7 @@ function maxCounterId(parsed: unknown): number {
}
}
walk((parsed as Record<string, unknown> | null)?.splits)
walk((parsed as Record<string, unknown> | 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<string>()
const reid = new Map<string, string>()
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,
}
}
+1
View File
@@ -80,6 +80,7 @@ export const PrefsSchema: z<SidebarPrefs> = 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),
+8
View File
@@ -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,
+1
View File
@@ -48,6 +48,7 @@ if (g.window === undefined) {
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
innerWidth: 1024,
innerHeight: 768,
getComputedStyle: () => ({ getPropertyValue: () => '' }),
}
}
+1 -1
View File
@@ -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', () => {
+1 -1
View File
@@ -57,6 +57,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ 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: {} })
})
})
+51
View File
@@ -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)
})
})
+1
View File
@@ -507,6 +507,7 @@ describe('side card settings routes', () => {
defaultWidthPercent: 30,
autoOpenSubagent: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
+217 -6
View File
@@ -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<string, unknown>
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<SplitNode, { kind: 'split' }>
s = resizeSplitIn(s, split.id, 0, 0.1)
const next = s.bottomSplits as Extract<SplitNode, { kind: 'split' }>
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<string, unknown>
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<SplitNode, { kind: 'split' }>
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'])