refactor(shell): retire the plugin's own right panel and free windows

DSH 0.1.5 owns the right column (its native Sidebar hosts every tab type
this plugin registers), so the plugin's own right panel, its width drag /
new-session seed prefs, and the free-window feature are gone. The bottom
workbench is the only plugin-owned surface left, and its expand/collapse
control is registered into DSH's session-header utilities list slot (the
header's corner seat belongs to the native sidebar's own control).

- state: one `bottomSplits` tree; `makeDefaultState()` takes no arguments;
  `toggleBottomPanel` / `openTabInBottomPane` are the only landing
  reducers; `setBottomHeight` and `sanitizeState` share the
  `CONVERSATION_MIN` cap (a persisted tall height no longer snaps on the
  first drag).
- service: every landing in the workbench (creation or focus) expands it;
  `SIDEBAR_FEATURES` drops `'floatWindows'`; the open/close/activate tab
  pools cover the single tree.
- shell: `Sidebar.tsx` renders only the bottom workbench; the toggle lives
  in `sidebar/bottom-toggle.tsx`; layout-push pushes height only (no width,
  no `--dsh-sidebar-width`).
- prefs: `openByDefault` / `defaultWidthPercent` / `changesDiffFloat`
  removed from the schema, defaults, client parser, settings page, and all
  20 dictionaries; the retired keys in stored documents are ignored.
- tests: free-window specs deleted, panel/float assertions dropped,
  remaining suites adapted (124 files / 1287 tests); the mount lane asserts
  the header toggle and sweeps the native sidebar's guide page.
- docs: README / README_EN / guide §0,§11 / AGENTS test map.
This commit is contained in:
Menghuan1918
2026-09-09 15:18:06 +08:00
parent 91de0e2932
commit 223b2545d9
77 changed files with 1029 additions and 4727 deletions
+4 -5
View File
@@ -42,7 +42,7 @@ import { relativeTo } from './paths.ts'
import { resolveSidebarPath } from './produced-files.ts'
import { closePathTabs, retargetPathTabs } from './tree-mutations.ts'
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, type SidebarStore, type SidebarTab } from './state.ts'
import css from './sidebar.module.css'
type EditorLoad =
@@ -178,8 +178,7 @@ export function EditorHost(props: {
*/
const openFileSide = (absolute: string): void => {
store.reduce((state) => {
const key = treeOf(state, tab.id)
const pane = leafWithTab(state[key], tab.id) ?? firstLeaf(state[key])
const pane = leafWithTab(state.bottomSplits, tab.id) ?? firstLeaf(state.bottomSplits)
const fresh: SidebarTab = {
id: mintTabId(),
type: 'editor',
@@ -187,8 +186,8 @@ export function EditorHost(props: {
path: absolute,
meta: { treeOpen: false },
}
const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false)
return { ...state, [key]: node, activePane: leafId }
const { node, leafId } = insertLeafAt(state.bottomSplits, pane.id, 'row', fresh, false)
return { ...state, bottomSplits: node, activePane: leafId }
})
}
-338
View File
@@ -1,338 +0,0 @@
/**
* One free window: a tab dragged out of the workbench floating over the
* conversation area at viewport coordinates (rendered inside the panel host,
* so desktop-shell transforms can never hijack its fixed containing block).
*
* The header drags the window with the panel-resize pattern — pointer
* capture + per-frame direct DOM writes + a store commit on release — and
* doubling as the DOCK-BACK gesture: while the pointer is over a workbench
* pane ([data-dsh-pane], either panel), that pane highlights live and
* releasing docks the tab into it (center merge); releasing anywhere else
* just moves the window. The SE corner resizes, any press raises (the
* floats array's order is the stacking order), the header right-click menu
* and the X button dock / close. The tab content reuses the regular tab
* renderer, so every tab type (terminal, editor, plugin tabs) floats
* unchanged.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type { FloatWindow, SidebarTab } from './state.ts'
import { FLOAT_MIN_H, FLOAT_MIN_W } from './state.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** A drag's mutable bookkeeping (start geometry + the last applied values). */
interface DragState {
/** Which handle owns the drag (the abort path reads it from this ref —
* the React `dragging` state lags a render behind pointerdown). */
mode: 'move' | 'resize'
pointerX: number
pointerY: number
startX: number
startY: number
startW: number
startH: number
/** Last geometry actually written to the DOM (the cancel-path fallback). */
applied: { x: number; y: number; w: number; h: number }
/** Settled after the final pointer event (the up position wins over the
* rAF-pending value — a fast flick coalesces its tail into the up event). */
committed: boolean
}
/** The pane under a viewport point, if any (rect hit-test; the dragged
* window itself is not a pane, so it cannot shadow the targets). */
function paneAt(x: number, y: number): HTMLElement | null {
for (const pane of document.querySelectorAll<HTMLElement>('[data-dsh-pane]')) {
const rect = pane.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) continue
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return pane
}
return null
}
/** Pointer-capture helpers tolerant of environments without the API (jsdom
* lacks setPointerCapture — component tests dispatch plain MouseEvents, so
* the optional calls keep them driving the drag; real browsers always have
* it and a missing pointerId can never occur there). */
const capturePointer = (element: HTMLElement, pointerId: number): void => {
element.setPointerCapture?.(pointerId)
}
const releasePointer = (element: HTMLElement, pointerId: number): void => {
element.releasePointerCapture?.(pointerId)
}
/** Whether the element holds the pointer (assumed true without the API). */
const holdsPointer = (element: HTMLElement, pointerId: number): boolean => {
return element.hasPointerCapture?.(pointerId) !== false
}
export function FreeWindow(props: {
float: FloatWindow
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
onRaise: () => void
onMove: (x: number, y: number) => void
onResize: (w: number, h: number) => void
/** Dock the tab back; the pane id, or null for the active pane. */
onDock: (paneId: string | null) => void
onClose: () => void
}) {
const { float, renderTab, getTabIcon, onRaise, onMove, onResize, onDock, onClose } = props
const rootRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<DragState | null>(null)
// The pane currently highlighted as the dock target (a direct DOM write:
// per-frame React state would re-render every mounted tab at drag cadence).
const dockTargetRef = useRef<HTMLElement | null>(null)
// rAF coalescing: pointermove outruns the display, one write per frame.
const frameRef = useRef<number | null>(null)
const pendingRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null)
const [dragging, setDragging] = useState<'move' | 'resize' | null>(null)
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
useEffect(() => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
}, [])
/** Apply the pending geometry to the DOM (one rAF per frame at most). */
const scheduleApply = (geo: { x: number; y: number; w: number; h: number }): void => {
pendingRef.current = geo
if (frameRef.current !== null) return
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null
const pending = pendingRef.current
const drag = dragRef.current
const root = rootRef.current
if (pending === null || drag === null || root === null) return
drag.applied = pending
root.style.left = `${pending.x}px`
root.style.top = `${pending.y}px`
root.style.width = `${pending.w}px`
root.style.height = `${pending.h}px`
})
}
/** Flush the pending frame synchronously (the release path's last write). */
const flushNow = (): void => {
const pending = pendingRef.current
const drag = dragRef.current
const root = rootRef.current
if (pending === null || drag === null || root === null) return
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current)
frameRef.current = null
}
pendingRef.current = null
drag.applied = pending
root.style.left = `${pending.x}px`
root.style.top = `${pending.y}px`
root.style.width = `${pending.w}px`
root.style.height = `${pending.h}px`
}
const clearDockHighlight = (): void => {
dockTargetRef.current?.removeAttribute('data-dsh-float-dock-over')
dockTargetRef.current = null
}
/** Release a drag: `pane` (when set) docks instead of moving. */
const finishDrag = (mode: 'move' | 'resize', geo: { x: number; y: number; w: number; h: number }, pane: HTMLElement | null): void => {
const drag = dragRef.current
if (drag === null || drag.committed) return
drag.committed = true
dragRef.current = null
setDragging(null)
const target = pane ?? dockTargetRef.current
clearDockHighlight()
if (mode === 'move' && target !== null) {
onDock(target.getAttribute('data-dsh-pane'))
} else if (mode === 'move') {
onMove(geo.x, geo.y)
} else {
onResize(geo.w, geo.h)
}
}
/** Cancel-path settle (pointercancel / lostpointercapture): the last
* APPLIED geometry is the user-visible truth — commit it, never roll back
* (the panel drags' issue-#247 semantics). The mode comes from the ref —
* the React `dragging` state can still be null when the cancel lands
* before the pointerdown re-render commits. */
const abortDrag = (): void => {
const drag = dragRef.current
if (drag === null || drag.committed) return
flushNow()
finishDrag(drag.mode, drag.applied, null)
}
const clampMove = (x: number, y: number): { x: number; y: number; w: number; h: number } => {
const vw = window.innerWidth
const vh = window.innerHeight
return {
x: Math.min(Math.max(x, 0), Math.max(0, vw - float.w)),
y: Math.min(Math.max(y, 0), Math.max(0, vh - float.h)),
w: float.w,
h: float.h,
}
}
const clampResize = (w: number, h: number): { x: number; y: number; w: number; h: number } => {
const vw = window.innerWidth
const vh = window.innerHeight
return {
x: float.x,
y: float.y,
w: Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x))),
h: Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y))),
}
}
return (
<div
ref={rootRef}
className={clsx(css.floatWindow, dragging !== null && css.floatWindowDragging)}
data-dsh-float-window
data-dsh-float-id={float.id}
style={{ left: float.x, top: float.y, width: float.w, height: float.h }}
onPointerDown={() => { onRaise() }}
>
<div
className={css.floatHeader}
onPointerDown={(event) => {
if (event.button !== 0) return
// Portaled overlays (the header Menu) live at document.body in the
// DOM, yet their events still bubble through the React tree into
// this handler — without the containment guard a menu-row click
// would start a drag, preventDefault (killing the row's compat
// mousedown) and capture the pointer (retargeting the release at
// the header, so the row's click never fires). Buttons inside the
// header (the close X) need the same exclusion: a capture here
// swallows their click.
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return
if (event.target instanceof Element && event.target.closest('button') !== null) return
event.preventDefault()
capturePointer(event.currentTarget, event.pointerId)
dragRef.current = {
mode: 'move',
pointerX: event.clientX,
pointerY: event.clientY,
startX: float.x,
startY: float.y,
startW: float.w,
startH: float.h,
applied: { x: float.x, y: float.y, w: float.w, h: float.h },
committed: false,
}
setDragging('move')
}}
onPointerMove={(event) => {
const drag = dragRef.current
if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return
const geo = clampMove(drag.startX + (event.clientX - drag.pointerX), drag.startY + (event.clientY - drag.pointerY))
scheduleApply(geo)
const target = paneAt(event.clientX, event.clientY)
if (target !== dockTargetRef.current) {
clearDockHighlight()
if (target !== null) target.setAttribute('data-dsh-float-dock-over', '')
dockTargetRef.current = target
}
}}
onPointerUp={(event) => {
if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return
releasePointer(event.currentTarget, event.pointerId)
flushNow()
const geo = clampMove(
dragRef.current.startX + (event.clientX - dragRef.current.pointerX),
dragRef.current.startY + (event.clientY - dragRef.current.pointerY),
)
finishDrag('move', geo, paneAt(event.clientX, event.clientY))
}}
onPointerCancel={() => { abortDrag() }}
onLostPointerCapture={() => { abortDrag() }}
onContextMenu={(event) => {
event.preventDefault()
setMenu({ x: event.clientX, y: event.clientY })
}}
>
{getTabIcon?.(float.tab) ?? null}
<span className={css.floatTitle} title={float.tab.title}>{float.tab.title}</span>
<button
type="button"
className={css.floatClose}
aria-label={t('close')}
onClick={(event) => { event.stopPropagation(); onClose() }}
>
<IconCloseFill14 />
</button>
<Menu
open={menu !== null}
onClose={() => { setMenu(null) }}
items={[
{ id: 'dock', label: t('dockToSidebar') },
{ id: 'close', label: t('close') },
]}
onSelect={(id) => {
setMenu(null)
if (id === 'dock') onDock(null)
else if (id === 'close') onClose()
}}
portal
compact
align="start"
getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))}
anchor={<span />}
/>
</div>
{/* The tab cell reuses .paneTab (the pane's per-tab wrapper): the
column-flex chain (window → .floatContent → .paneTab) is what
gives the component a definite width via align-stretch — the exact
sizing contract a tab gets inside a pane. */}
<div className={css.floatContent}>
<div className={css.paneTab}>{renderTab(float.tab, true, float.id)}</div>
</div>
<div
className={css.floatResize}
onPointerDown={(event) => {
if (event.button !== 0) return
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return
event.preventDefault()
capturePointer(event.currentTarget, event.pointerId)
dragRef.current = {
mode: 'resize',
pointerX: event.clientX,
pointerY: event.clientY,
startX: float.x,
startY: float.y,
startW: float.w,
startH: float.h,
applied: { x: float.x, y: float.y, w: float.w, h: float.h },
committed: false,
}
setDragging('resize')
}}
onPointerMove={(event) => {
const drag = dragRef.current
if (drag === null || !holdsPointer(event.currentTarget, event.pointerId)) return
scheduleApply(clampResize(
drag.startW + (event.clientX - drag.pointerX),
drag.startH + (event.clientY - drag.pointerY),
))
}}
onPointerUp={(event) => {
if (dragRef.current === null || !holdsPointer(event.currentTarget, event.pointerId)) return
releasePointer(event.currentTarget, event.pointerId)
flushNow()
const geo = clampResize(
dragRef.current.startW + (event.clientX - dragRef.current.pointerX),
dragRef.current.startH + (event.clientY - dragRef.current.pointerY),
)
finishDrag('resize', geo, null)
}}
onPointerCancel={() => { abortDrag() }}
onLostPointerCapture={() => { abortDrag() }}
/>
</div>
)
}
+1 -1
View File
@@ -7,7 +7,7 @@
* errors in the sidebar shell itself (Workbench, drag layout, …) — a full
* swap keeps the page alive.
* - PER-TAB (Sidebar.tsx TabContent, `css.tabBoundaryError`): a crashing
* viewer/editor shows a strip inside ITS OWN pane; the toggle cluster, the
* viewer/editor shows a strip inside ITS OWN pane; the workbench shell, the
* other tabs, and the panel itself stay alive (issue #31 — a tab crash
* must never take down the whole sidebar).
*
+2 -62
View File
@@ -54,11 +54,8 @@ import clsx from 'clsx'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import {
clampWidthPercent,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
type SidebarPrefs,
} from '../prefs-shared.ts'
import { api } from './api.ts'
@@ -572,7 +569,6 @@ export function SettingsBody(props: {
*/
export function SideCardSection({ store, service }: SideCardSectionProps) {
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
const [error, setError] = useState<string | null>(null)
// Which feature's secondary settings popup is open (null = closed).
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
@@ -624,9 +620,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
if (cancelled) return
revisionRef.current = view.revision
if (dirtyRef.current) return
const next = parsePrefs(view.value)
setPrefs(next)
setWidthDraft(String(next.defaultWidthPercent))
setPrefs(parsePrefs(view.value))
}).catch(() => { /* the store's defaults stay authoritative */ })
return () => { cancelled = true }
}, [])
@@ -657,9 +651,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
/** Settle one commit: success adopts the server values, failure reverts. */
const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => {
const settled = outcome.ok ? outcome.prefs : previous
setPrefs(settled)
setWidthDraft(String(settled.defaultWidthPercent))
setPrefs(outcome.ok ? outcome.prefs : previous)
}
/** Optimistically apply one pref patch, then commit (revert on failure). */
@@ -672,10 +664,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
void commit(patch).then(outcome => applyOutcome(previous, outcome))
}
const onToggle = (next: boolean): void => {
applyPref({ openByDefault: next })
}
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
const onToggleTab = (id: string, next: boolean): void => {
applyPref({ tabsEnabled: { ...optimisticRef.current.tabsEnabled, [id]: next } })
@@ -778,20 +766,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
return raw
}
const commitWidth = (): void => {
const parsed = Number(widthDraft)
if (!Number.isFinite(parsed)) {
setWidthDraft(String(prefs.defaultWidthPercent))
return
}
const clamped = clampWidthPercent(parsed)
const previous = prefs
setPrefs({ ...previous, defaultWidthPercent: clamped })
setWidthDraft(String(clamped))
setError(null)
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
}
/**
* One SMALL toggle card for the responsive inventory grid: the card's main
* area is the switch (click to flips, visual state IS the state), the icon
@@ -867,40 +841,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
<div className={css.group}>
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenTitle')}</span>
<span className={css.desc}>{t('settingsOpenDesc')}</span>
</span>
<Switch
label={t('settingsOpenTitle')}
checked={prefs.openByDefault}
onChange={onToggle}
/>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsWidthTitle')}</span>
<span className={css.desc}>{t('settingsWidthDesc')}</span>
</span>
<span className={css.control}>
<Input
type="number"
className={css.percentInput}
value={widthDraft}
min={WIDTH_PERCENT_MIN}
max={WIDTH_PERCENT_MAX}
step={1}
aria-label={t('settingsWidthTitle')}
onChange={event => { setWidthDraft(event.currentTarget.value) }}
onBlur={commitWidth}
onKeyDown={event => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
/>
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
</span>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenToolsTitle')}</span>
+192 -565
View File
File diff suppressed because it is too large Load Diff
+6 -14
View File
@@ -4,9 +4,8 @@
* button cluster, and the + menu that opens new tabs (explorer / git /
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
* dropping on the strip background appends to this pane. Right-clicking a
* tab opens the tab context menu (float as a free window / close / close
* others / close to the left / close to the right, the close ones scoped to
* this pane).
* tab opens the tab context menu (close / close others / close to the left /
* close to the right, the close ones scoped to this pane).
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
@@ -68,9 +67,6 @@ export function TabBar(props: {
newTabOptions: NewTabOption[]
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
onDropTab: (payload: TabDragPayload, before: string | null) => void
/** Float a tab out as a free window (the tab context menu's entry; the
* drag-to-conversation gesture is handled at the Sidebar shell level). */
onFloatTab: (tabId: string) => void
/**
* Pin/unpin a terminal tab (v0.17.0+). Called with `'workspace'` or
* `'global'` to pin (the shell snapshots the home cwd), or `null` to
@@ -85,7 +81,7 @@ export function TabBar(props: {
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const {
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onFloatTab, onPinTab, getTabIcon, getTabBadge,
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onPinTab, getTabIcon, getTabBadge,
} = props
const [menuOpen, setMenuOpen] = useState(false)
// The tab right-click context menu: the target tab plus the cursor
@@ -298,9 +294,8 @@ export function TabBar(props: {
// The target tab drives the pin entry's shape: terminal tabs
// get either a "Pin ▸" submenu (unpinned) or a single "Unpin"
// row (pinned). Non-terminal tabs and missing onPinTab get no
// pin entry at all — the menu stays exactly the legacy 5-item
// shape. Pinned VIRTUAL tabs (injected from other sessions)
// get a stripped menu: only Unpin + Close (no float, no
// pin entry at all. Pinned VIRTUAL tabs (injected from other
// sessions) get a stripped menu: only Unpin + Close (no
// close-others/left/right — those are pane-scoped operations
// that don't apply to cross-session virtual tabs).
const targetTab = tabMenuIndex >= 0 ? tabs[tabMenuIndex] : undefined
@@ -325,7 +320,6 @@ export function TabBar(props: {
]
}
return [
{ id: 'float', label: t('moveToFreeWindow') },
...pinEntries,
{ id: 'close', label: t('close') },
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
@@ -339,9 +333,7 @@ export function TabBar(props: {
setTabMenu(null)
const index = tabs.findIndex(tab => tab.id === target.tabId)
if (index < 0) return
if (id === 'float') {
onFloatTab(target.tabId)
} else if (id === 'pinWorkspace') {
if (id === 'pinWorkspace') {
onPinTab?.(target.tabId, 'workspace')
} else if (id === 'pinGlobal') {
onPinTab?.(target.tabId, 'global')
+2 -27
View File
@@ -22,7 +22,7 @@ import { SubagentView } from '../SubagentView.tsx'
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
import { api } from '../api.ts'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16, IconFloatWindowOutline16, IconPanelBottomOutline16 } from '../icons.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
import type { ComponentType } from 'react'
import type { SessionScope } from '../api.ts'
@@ -72,7 +72,7 @@ function terminalUuid(): string {
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
function uiTerminalCount(state: SidebarState): number {
return allLeaves(state.splits)
return allLeaves(state.bottomSplits)
.flatMap(leaf => leaf.tabs)
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
}
@@ -155,31 +155,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
const count = opCountOf(scope.sessionId)
return count === undefined || count === 0 ? null : count
},
// Declarative settings: the diff-open picker (free window vs docked
// pane) renders as an iconed select row under the changes card's gear
// in the Side card settings page.
settings: {
toggles: [{
key: 'changesDiffFloat',
type: 'select',
title: () => t('changesDiffOpenTitle'),
desc: () => t('changesDiffOpenDesc'),
options: [
{
value: true,
icon: (size: number) => <IconFloatWindowOutline16 size={size} />,
title: () => t('changesDiffOpenFloat'),
desc: () => t('changesDiffOpenFloatDesc'),
},
{
value: false,
icon: (size: number) => <IconPanelBottomOutline16 size={size} />,
title: () => t('changesDiffOpenPane'),
desc: () => t('changesDiffOpenPaneDesc'),
},
],
}],
},
component: ({ ctx, store, scope, tab, visible, onOpenDiff }) => (
<ChangesTab
ctx={ctx}
+7 -14
View File
@@ -3,9 +3,9 @@
* (repository truth: staged/unstaged files, commit box, history) and the
* session round (agent truth: every file the model read, wrote, or edited).
* Both lenses preview their selections in a shared resizable bottom pane
* ({@link DiffPane}); git targets expand into the dedicated diff tab —
* docked in a pane, or floated as a free window per the tab's setting.
* The active lens and the pane height persist in the tab's meta, so the tab
* ({@link DiffPane}); git targets expand into the dedicated diff tab docked
* in the workbench's diff pane. The active lens and the pane height persist
* in the tab's meta, so the tab
* reopens exactly where it was left.
*
* The session events ride the host's `changes.ops` route (the client
@@ -19,7 +19,7 @@ import type { TabComponentProps } from '../service.ts'
import { t } from '../locales.ts'
import { api } from '../api.ts'
import { usePolling } from '../use-polling.ts'
import { floatTab, type SidebarDiffRef } from '../state.ts'
import type { SidebarDiffRef } from '../state.ts'
import { GitLens } from './GitLens.tsx'
import { SessionLens } from './SessionLens.tsx'
import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
@@ -137,18 +137,11 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
setPreview({ kind: 'op', path, op, prior: knownContentBefore(ops, path, op) })
}
/** Expand the current git preview into the dedicated diff tab: docked
* into the shell's diff pane, or floated as a free window centered on
* the viewport when the tab's diff-open setting asks for it (default). */
/** Expand the current git preview into the dedicated diff tab, docked
* into the workbench's diff pane. */
const expandPreview = (): void => {
if (preview?.kind !== 'git') return
const diffTab = diffTabOf(preview.ref)
onOpenDiff?.(diffTab)
if (store.getPrefs().changesDiffFloat !== false) {
const x = Math.round(window.innerWidth / 2)
const y = Math.round(window.innerHeight / 2)
store.reduce(state => floatTab(state, diffTab.id, x, y))
}
onOpenDiff?.(diffTabOf(preview.ref))
}
const previewKey = (target: ChangesPreview): string => target.kind === 'git'
+2 -26
View File
@@ -1,24 +1,12 @@
/**
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
* 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
* icon library has none), a diff glyph, and the bottom-workbench toggle.
* 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.
@@ -161,15 +149,3 @@ export const IconVscode16 = ({ size = 16, className }: IconProps) => (
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
</svg>
)
/**
* Free-window glyph in the app's outline style (1.5px stroke, currentColor):
* a background frame with a detached rounded mini-window floating over its
* top-right — the changes tab's "diff opens as a free window" setting.
*/
export const IconFloatWindowOutline16 = ({ 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="4.5" width="10" height="10" rx="2" stroke="currentColor" strokeWidth="1.5" />
<rect x="9.5" y="1.5" width="5" height="5" rx="1.5" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
)
+9 -3
View File
@@ -20,6 +20,7 @@ import { RenderBoundary } from './RenderBoundary.tsx'
import { registerTurnTailInterception } from './intercept.tsx'
import { createNativeTabRecords } from './native/tab-adapter.tsx'
import { registerNativeSurface } from './native/index.ts'
import { registerBottomToggle } from './sidebar/bottom-toggle.tsx'
import { createNativeSurface } from './native/surface.ts'
import { registerLinkInterception } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts'
@@ -151,6 +152,12 @@ export function apply(ctx: Context): void {
() => registerNativeSurface({ ctx, store: sidebarStore, service, records: nativeRecords }),
'dsh-better-sidebar: native right-Sidebar registrations',
)
// The bottom workbench's expand/collapse button in DSH's session header
// (the header's corner seat belongs to the native sidebar's own control).
ctx.effect(
() => registerBottomToggle(ctx, sidebarStore),
'dsh-better-sidebar: bottom-workbench toggle',
)
ctx.effect(
() => () => { nativeSurface.dispose(); service.setSurface(undefined) },
'dsh-better-sidebar: native right-Sidebar surface',
@@ -166,8 +173,7 @@ export function apply(ctx: Context): void {
terminalTitle = name
const snapshot = service.getSnapshot()
if (snapshot.state === undefined) return
const tabs = allLeaves(snapshot.state.splits)
.concat(allLeaves(snapshot.state.bottomSplits))
const tabs = allLeaves(snapshot.state.bottomSplits)
.flatMap(leaf => leaf.tabs)
for (const tab of tabs) {
if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) {
@@ -302,7 +308,7 @@ export function apply(ctx: Context): void {
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
root = createRoot(host)
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore, nativeSurface: true })))
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
mounted = true
guardAnchor()
scheduleHostCheck()
+3 -13
View File
@@ -8,7 +8,7 @@
*/
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { firstLeaf, revealPaths, togglePanel, type SidebarStore } from './state.ts'
import { revealPaths, type SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
import css from './sidebar.module.css'
@@ -26,9 +26,8 @@ export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: st
/**
* Reveal the produced files in the sidebar explorer: expand their parent
* directories, highlight the rows, and focus the explorer tab (expanding the
* hosting panel when it is collapsed). Unknown files fall back to revealing
* the workspace root itself.
* directories, highlight the rows, and focus the explorer tab. Unknown
* files fall back to revealing the workspace root itself.
*/
export function revealInExplorer(
ctx: Context,
@@ -45,15 +44,6 @@ export function revealInExplorer(
? files.map(path => resolveSidebarPath(cwd, path))
: cwd === undefined ? [] : [cwd]
store.reduce(state => revealPaths(state, cwd, targets))
// A type-only open never auto-expands the panel (only content opens do,
// see service.openTab) — so a reveal opens the panel itself when it is
// collapsed, exactly like the subagent auto-open flows, or the highlight
// would be set on an invisible panel.
store.reduce(s => (s.panelOpen ? s : togglePanel(s)))
// Pin the landing to the right panel: the files window 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 }))
// Focus the single-instance editor home tab (the files window) where the
// reveal highlight renders. Read via ctx.get like every other internal
// consumer (#357): the provider is not on this fiber chain, so a direct
+16 -25
View File
@@ -1,37 +1,28 @@
/**
* Size written to `--dsh-sidebar-width` / `--dsh-sidebar-height`.
* The conversation column (output + composer) must keep at least
* {@link PANEL_MIN} of the viewport after the bottom panel claims height.
* Size written to `--dsh-sidebar-height`. The conversation column (output +
* composer) must keep at least {@link CONVERSATION_MIN} of the viewport after
* the bottom workbench claims height; a closed workbench pushes 0. The right
* column belongs to DSH's native Sidebar, so this shell pushes no width.
*/
import { PANEL_MIN } from './state.ts'
import { CONVERSATION_MIN } from './state.ts'
export interface LayoutPushInput {
narrow: boolean
panelOpen: boolean
bottomOpen: boolean
width: number
bottomHeight: number
viewportWidth: number
viewportHeight: number
}
export interface LayoutPushSize {
width: number
export interface BottomPushInput {
/** Whether the bottom workbench is expanded. */
open: boolean
/** Committed (or mid-drag) bottom height in px. */
height: number
/** Viewport height the cap is measured against (visual viewport when known). */
viewportHeight: number
}
function finiteNonNegative(value: number): number {
return Number.isFinite(value) ? Math.max(0, value) : 0
}
/** Compute the live layout-push size. Narrow drawers float and push 0. */
export function layoutPushSize(input: LayoutPushInput): LayoutPushSize {
if (input.narrow) return { width: 0, height: 0 }
const viewportWidth = finiteNonNegative(input.viewportWidth)
/** Compute the live layout-push height for the bottom workbench. */
export function bottomPushHeight(input: BottomPushInput): number {
if (!input.open) return 0
const viewportHeight = finiteNonNegative(input.viewportHeight)
const maxHeight = Math.max(0, viewportHeight - Math.min(PANEL_MIN, viewportHeight))
return {
width: input.panelOpen ? Math.min(finiteNonNegative(input.width), viewportWidth) : 0,
height: input.bottomOpen ? Math.min(finiteNonNegative(input.bottomHeight), maxHeight) : 0,
}
const maxHeight = Math.max(0, viewportHeight - Math.min(CONVERSATION_MIN, viewportHeight))
return Math.min(finiteNonNegative(input.height), maxHeight)
}
+12 -47
View File
@@ -1,44 +1,19 @@
/**
* Layout push: an open panel occupies layout instead of floating over it.
* The right panel reserves padding inside the AppFrame three-column grid,
* so the app sidebar, conversation, and details column all remain to its
* left while the frame's border-box stays viewport-wide. Harness measures
* that border-box to choose desktop versus narrow presentation; preserving
* it prevents a desktop window from entering narrow mode merely because the
* plugin panel opened.
* Layout push: the bottom workbench occupies layout instead of floating over
* the conversation. The right column belongs to DSH's native Sidebar, so this
* stylesheet pushes no width — only the workbench's height.
*
* AppFrame positions its details drag handle from the measured border-box.
* Move that handle left by the same reserved width so it stays on the details
* column edge. The ordinary sidebar handle remains at its native coordinate.
*
* The bottom panel squeezes only the conversation column. The anchor is the
* The workbench squeezes only the conversation column. The anchor is the
* [data-dsh-center-col] tag the Sidebar shell's locator writes on the
* frame's measured center grid item (Sidebar.tsx locate — not a positional
* path: the frame also contains an overlay layer and drag handles). A
* stretched grid item shrinks by its bottom margin, so the conversation
* output and composer lift without touching either sidebar.
*
* Sizes ride CSS variables updated by the Sidebar shell (0 while collapsed).
* Expand/collapse uses the host theme duration; drags disable transitions so
* every reserved edge tracks the pointer.
* The size rides a CSS variable updated by the Sidebar shell (0 while
* collapsed). Expand/collapse uses the host theme duration; drags disable
* transitions so the reserved edge tracks the pointer.
*/
/* Current shells expose [data-dsh-frame]; rc.8-era shells expose the same
AppFrame only as the root slot's direct child. */
#root [data-dsh-frame],
#root > [data-slot="root"] > div {
box-sizing: border-box;
padding-right: var(--dsh-sidebar-width, 0px);
transition: padding-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
#root [data-dsh-frame] > [data-side="details"],
#root > [data-slot="root"] > div > [data-side="details"] {
transform: translateX(calc(0px - var(--dsh-sidebar-width, 0px)));
transition:
left var(--ds-transition-duration-slow) var(--ds-ease-in-out),
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* The AppFrame's center column. The Sidebar shell's locator (Sidebar.tsx
locate/measureCenter) tags the measured column node with
[data-dsh-center-col] — one attribute write per node lifetime. The rule
@@ -73,13 +48,7 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
padding-right: 78px;
}
body[data-dsh-sidebar-dragging] #root [data-dsh-frame],
body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div,
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-side="details"],
body[data-dsh-sidebar-dragging] #root > [data-slot="root"] > div > [data-side="details"],
body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
#root [data-dsh-frame][data-dragging] > [data-side="details"],
#root > [data-slot="root"] > div[data-dragging] > [data-side="details"] {
body[data-dsh-sidebar-dragging] #root [data-dsh-center-col] {
transition: none;
}
@@ -103,10 +72,6 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
}
@media (prefers-reduced-motion: reduce) {
#root [data-dsh-frame],
#root > [data-slot="root"] > div,
#root [data-dsh-frame] > [data-side="details"],
#root > [data-slot="root"] > div > [data-side="details"],
#root [data-dsh-center-col] {
transition: none;
}
@@ -117,10 +82,10 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
* The ui-primitives Menu clamps its main portal list into the viewport but
* positions nested submenus with static CSS only — anchored at the parent
* row's bottom, growing UP and to the RIGHT — with no clamp whatsoever.
* Docked on the window's right edge, both defaults are the worst case for
* this panel: a tall "open with" submenu escapes past the viewport top when
* the row menu opens near the top, and past the right edge on narrow panels
* (PANEL_MIN 280 < card 218 + gap + submenu 165).
* Both defaults are the worst case for our menus: a tall "open with" submenu
* escapes past the viewport top when the row menu opens near the top, and
* past the right edge in a narrow workbench pane (a pane can be far narrower
* than the card 218 + gap + submenu 165 a submenu needs).
*
* menu-flip.ts publishes `data-dsh-sidebar-submenu` on <body> — with the
* words "down" and/or "left" — for exactly the lifetime of one of OUR
-14
View File
@@ -83,7 +83,6 @@ export const ar: Record<string, string> = {
closeOtherTabs: 'إغلاق التبويبات الأخرى',
closeLeftTabs: 'إغلاق التبويبات على اليسار',
closeRightTabs: 'إغلاق التبويبات على اليمين',
moveToFreeWindow: 'النقل إلى نافذة حرة',
floatDropHint: 'حرّك للفتح في نافذة حرة',
dockToSidebar: 'العودة إلى الشريط الجانبي',
pinTerminal: 'تثبيت الطرفية',
@@ -98,8 +97,6 @@ export const ar: Record<string, string> = {
pinnedTerminalScopeGlobal: 'مثبتة عام',
pinnedRailLabel: 'الطرفيات المثبتة',
closePinnedTerminal: 'إغلاق الطرفية',
collapse: 'طي الشريط الجانبي',
expand: 'توسيع الشريط الجانبي',
collapseBottomPanel: 'طي اللوحة السفلية',
expandBottomPanel: 'توسيع اللوحة السفلية',
terminalError: 'فشل اتصال الطرفية',
@@ -155,12 +152,6 @@ export const ar: Record<string, string> = {
changesOpenDiffTab: 'فتح في تبويب diff منفصل',
changesClosePreview: 'إغلاق المعاينة',
changesResizePreview: 'تغيير حجم المعاينة',
changesDiffOpenTitle: 'يفتح الفرق كـ',
changesDiffOpenDesc: 'أين يضع إجراء «التوسيع إلى تبويب diff» الفرق',
changesDiffOpenFloat: 'نافذة عائمة',
changesDiffOpenFloatDesc: 'نافذة عائمة في وسط الشاشة — سحب وتحجيم وتثبيت فوق',
changesDiffOpenPane: 'لوحة',
changesDiffOpenPaneDesc: 'مثبتة أسفل اللوحة المصدر (تقسيم diff بنمط VSCode)',
changesLoadError: 'سجلات ملفات الجلسة غير متاحة الآن',
staged: 'مُدرج',
unstaged: 'غير مُدرج',
@@ -234,11 +225,6 @@ export const ar: Record<string, string> = {
settingsIntro: 'إدارة ما تعرضه البطاقة الجانبية وكيف تتصرف',
settingsPopupDesc: 'تكوين خيارات ذات صلة بـ {feature}',
settingsDone: 'تم',
settingsOpenTitle: 'فتح افتراضياً للمحادثات الجديدة',
settingsOpenDesc: 'توسيع البطاقة الجانبية تلقائياً للمحادثات الجديدة تماماً؛ المحادثات الموجودة تحتفظ بتخطيطاتها',
settingsWidthTitle: 'الحصة الافتراضية للعرض',
settingsWidthDesc: 'الحصة الافتراضية للبطاقة الجانبية من عرض النافذة للمحادثات الجديدة (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'حقن أداة فتح الشريط الجانبي للنموذج',
settingsOpenToolsDesc: 'عند التفعيل، يمكن للنموذج فتح الملفات والمجلدات وصفحات HTTP(S) في الشريط الجانبي عبر أداة sidebar_open (معطّل افتراضياً)',
settingsTitleBarTitle: 'وضع توافق الموضع',
-14
View File
@@ -68,7 +68,6 @@ export const de: Record<string, string> = {
closeOtherTabs: 'Andere Tabs schließen',
closeLeftTabs: 'Tabs links schließen',
closeRightTabs: 'Tabs rechts schließen',
moveToFreeWindow: 'In freies Fenster verschieben',
floatDropHint: 'Loslassen, um in einem freien Fenster zu öffnen',
dockToSidebar: 'Zurück zur Seitenleiste',
pinTerminal: 'Terminal anheften',
@@ -83,8 +82,6 @@ export const de: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Global angeheftet',
pinnedRailLabel: 'Angeheftete Terminals',
closePinnedTerminal: 'Terminal schließen',
collapse: 'Seitenleiste einklappen',
expand: 'Seitenleiste ausklappen',
collapseBottomPanel: 'Unteres Panel einklappen',
expandBottomPanel: 'Unteres Panel ausklappen',
terminalError: 'Terminalverbindung fehlgeschlagen',
@@ -140,12 +137,6 @@ export const de: Record<string, string> = {
changesOpenDiffTab: 'In eigenem Tab öffnen',
changesClosePreview: 'Vorschau schließen',
changesResizePreview: 'Vorschauhöhe anpassen',
changesDiffOpenTitle: 'Diff öffnet als',
changesDiffOpenDesc: 'Wo die Aktion „In eigenem Tab öffnen“ den Diff platziert',
changesDiffOpenFloat: 'Freies Fenster',
changesDiffOpenFloatDesc: 'Ein schwebendes Fenster mittig im Viewport — verschieben, skalieren, oben halten',
changesDiffOpenPane: 'Bereich',
changesDiffOpenPaneDesc: 'Unter dem Quellbereich angedockt (VSCode-artiger Diff-Split)',
changesLoadError: 'Sitzungsdatei-Aufzeichnungen sind derzeit nicht verfügbar',
staged: 'Gestaged',
unstaged: 'Nicht gestaged',
@@ -219,11 +210,6 @@ export const de: Record<string, string> = {
settingsIntro: 'Verwalten Sie, was die Seitenkarte anzeigt und wie sie sich verhält',
settingsPopupDesc: 'Optionen für „{feature}“ konfigurieren',
settingsDone: 'Fertig',
settingsOpenTitle: 'Für neue Unterhaltungen standardmäßig geöffnet',
settingsOpenDesc: 'Die Seitenkarte wird für brandneue Unterhaltungen automatisch ausgeklappt; bestehende Unterhaltungen behalten ihr eigenes Layout',
settingsWidthTitle: 'Standardbreite',
settingsWidthDesc: 'Standardanteil der Seitenkarte an der Fensterbreite für neue Unterhaltungen (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Seitenleisten-Öffnungswerkzeug für das Modell bereitstellen',
settingsOpenToolsDesc: 'Wenn aktiviert, kann das Modell über das sidebar_open-Werkzeug Dateien, Ordner und HTTP(S)-Seiten in der Seitenleiste öffnen (standardmäßig deaktiviert)',
settingsTitleBarTitle: 'Kompatibilitätsmodus der Position',
-14
View File
@@ -75,7 +75,6 @@ export const fr: Record<string, string> = {
closeOtherTabs: 'Fermer les autres onglets',
closeLeftTabs: 'Fermer les onglets à gauche',
closeRightTabs: 'Fermer les onglets à droite',
moveToFreeWindow: 'Déplacer vers la fenêtre flottante',
floatDropHint: 'Relâchez pour ouvrir dans une fenêtre flottante',
dockToSidebar: 'Revenir à la barre latérale',
pinTerminal: 'Épingler le terminal',
@@ -90,8 +89,6 @@ export const fr: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Épinglé globalement',
pinnedRailLabel: 'Terminaux épinglés',
closePinnedTerminal: 'Fermer le terminal',
collapse: 'Réduire la barre latérale',
expand: 'Déployer la barre latérale',
collapseBottomPanel: 'Réduire le panneau inférieur',
expandBottomPanel: 'Déployer le panneau inférieur',
terminalError: 'Échec de la connexion au terminal',
@@ -147,12 +144,6 @@ export const fr: Record<string, string> = {
changesOpenDiffTab: 'Ouvrir dans un onglet diff',
changesClosePreview: 'Fermer l\'aperçu',
changesResizePreview: 'Redimensionner l\'aperçu',
changesDiffOpenTitle: 'Le diff s\'ouvre en',
changesDiffOpenDesc: 'Où l\'action « Ouvrir dans un onglet diff » place le diff',
changesDiffOpenFloat: 'Fenêtre flottante',
changesDiffOpenFloatDesc: 'Une fenêtre flottante centrée sur la fenêtre — déplacer, redimensionner, garder au premier plan',
changesDiffOpenPane: 'Panneau',
changesDiffOpenPaneDesc: 'Ancré sous le panneau source (division de diff style VSCode)',
changesLoadError: 'Les enregistrements de fichiers de la session sont indisponibles',
staged: 'En scène',
unstaged: 'Hors scène',
@@ -226,11 +217,6 @@ export const fr: Record<string, string> = {
settingsIntro: 'Gérer le contenu affiché par la carte latérale et son comportement par défaut',
settingsPopupDesc: 'Configurer les options liées à « {feature} »',
settingsDone: 'Terminé',
settingsOpenTitle: 'Ouvert par défaut pour les nouvelles sessions',
settingsOpenDesc: 'Déployer automatiquement la carte latérale lors de la création d’une session ; les sessions existantes conservent chacune leur disposition',
settingsWidthTitle: 'Part de largeur par défaut',
settingsWidthDesc: 'Pourcentage de la largeur de la fenêtre occupé par la carte latérale pour les nouvelles sessions (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Injecter l\'outil d\'ouverture latérale pour le modèle',
settingsOpenToolsDesc: 'Une fois activé, le modèle peut ouvrir des fichiers, dossiers et pages HTTP(S) dans la barre latérale via l\'outil sidebar_open (désactivé par défaut)',
settingsTitleBarTitle: 'Mode de compatibilité de position',
-14
View File
@@ -82,7 +82,6 @@ export const hi: Record<string, string> = {
closeOtherTabs: 'अन्य टैब बंद करें',
closeLeftTabs: 'बाएँ टैब बंद करें',
closeRightTabs: 'दाएँ टैब बंद करें',
moveToFreeWindow: 'फ़्री विंडो में ले जाएँ',
floatDropHint: 'फ़्री विंडो में खोलने के लिए छोड़ें',
dockToSidebar: 'साइडबार पर वापस जाएँ',
pinTerminal: 'टर्मिनल पिन करें',
@@ -97,8 +96,6 @@ export const hi: Record<string, string> = {
pinnedTerminalScopeGlobal: 'वैश्विक रूप से पिन किया गया',
pinnedRailLabel: 'पिन किए गए टर्मिनल',
closePinnedTerminal: 'टर्मिनल बंद करें',
collapse: 'साइडबार संक्षिप्त करें',
expand: 'साइडबार विस्तृत करें',
collapseBottomPanel: 'निचला पैनल संक्षिप्त करें',
expandBottomPanel: 'निचला पैनल विस्तृत करें',
terminalError: 'टर्मिनल कनेक्शन विफल',
@@ -154,12 +151,6 @@ export const hi: Record<string, string> = {
changesOpenDiffTab: 'अलग diff टैब में खोलें',
changesClosePreview: 'पूर्वावलोकन बंद करें',
changesResizePreview: 'पूर्वावलोकन का आकार बदलें',
changesDiffOpenTitle: 'diff खुलता है',
changesDiffOpenDesc: '«अलग diff टैब में खोलें» क्रिया diff को कहाँ रखती है',
changesDiffOpenFloat: 'मुक्त विंडो',
changesDiffOpenFloatDesc: 'व्यूपोर्ट के केंद्र में एक तैरती विंडो — खींचें, आकार बदलें, ऊपर रखें',
changesDiffOpenPane: 'पैनल',
changesDiffOpenPaneDesc: 'सोर्स पैनल के नीचे डॉक किया (VSCode-शैली diff स्प्लिट)',
changesLoadError: 'सेशन फ़ाइल रिकॉर्ड अभी अनुपलब्ध हैं',
staged: 'स्टेज किया',
unstaged: 'अनस्टेज',
@@ -233,11 +224,6 @@ export const hi: Record<string, string> = {
settingsIntro: 'साइड कार्ड क्या दिखाता है और कैसे व्यवहार करता है प्रबंधित करें',
settingsPopupDesc: '{feature} के लिए संबंधित विकल्प कॉन्फ़िगर करें',
settingsDone: 'पूर्ण',
settingsOpenTitle: 'नए वार्तालाप के लिए डिफ़ॉल्ट रूप से खोलें',
settingsOpenDesc: 'बिल्कुल नए वार्तालाप के लिए साइड कार्ड स्वतः विस्तृत करें; मौजूदा वार्तालाप अपना लेआउट रखते हैं',
settingsWidthTitle: 'डिफ़ॉल्ट चौड़ाई हिस्सा',
settingsWidthDesc: 'नए वार्तालाप के लिए साइड कार्ड की विंडो चौड़ाई का डिफ़ॉल्ट हिस्सा (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'मॉडल के लिए साइडबार ओपन टूल इंजेक्ट करें',
settingsOpenToolsDesc: 'चालू होने पर, मॉडल sidebar_open टूल से साइडबार में फ़ाइलें, फ़ोल्डर और HTTP(S) पेज खोल सकता है (डिफ़ॉल्ट रूप से बंद)',
settingsTitleBarTitle: 'स्थिति संगतता मोड',
-14
View File
@@ -80,7 +80,6 @@ export const id: Record<string, string> = {
closeOtherTabs: 'Tutup Tab Lain',
closeLeftTabs: 'Tutup Tab di Kiri',
closeRightTabs: 'Tutup Tab di Kanan',
moveToFreeWindow: 'Pindahkan ke jendela bebas',
floatDropHint: 'Lepaskan untuk membuka di jendela bebas',
dockToSidebar: 'Kembali ke bilah samping',
pinTerminal: 'Sematkan Terminal',
@@ -95,8 +94,6 @@ export const id: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Disematkan global',
pinnedRailLabel: 'Terminal Tersemat',
closePinnedTerminal: 'Tutup Terminal',
collapse: 'Lipat sidebar',
expand: 'Bentangkan sidebar',
collapseBottomPanel: 'Lipat panel bawah',
expandBottomPanel: 'Bentangkan panel bawah',
terminalError: 'Koneksi terminal gagal',
@@ -152,12 +149,6 @@ export const id: Record<string, string> = {
changesOpenDiffTab: 'Buka di tab diff terpisah',
changesClosePreview: 'Tutup pratinjau',
changesResizePreview: 'Ubah ukuran pratinjau',
changesDiffOpenTitle: 'Diff terbuka sebagai',
changesDiffOpenDesc: 'Di mana aksi "buka di tab diff terpisah" menempatkan diff',
changesDiffOpenFloat: 'Jendela bebas',
changesDiffOpenFloatDesc: 'Jendela mengambang di tengah viewport — geser, ubah ukuran, selalu di atas',
changesDiffOpenPane: 'Panel',
changesDiffOpenPaneDesc: 'Ditambat di bawah panel sumber (split diff gaya VSCode)',
changesLoadError: 'Catatan berkas sesi sedang tidak tersedia',
staged: 'Staged',
unstaged: 'Unstaged',
@@ -231,11 +222,6 @@ export const id: Record<string, string> = {
settingsIntro: 'Kelola apa yang ditampilkan kartu samping dan perilakunya',
settingsPopupDesc: 'Konfigurasikan opsi terkait untuk {feature}',
settingsDone: 'Selesai',
settingsOpenTitle: 'Buka secara default untuk obrolan baru',
settingsOpenDesc: 'Bentangkan kartu samping secara otomatis untuk obrolan baru; obrolan yang sudah ada mempertahankan tata letaknya',
settingsWidthTitle: 'Porsi lebar default',
settingsWidthDesc: 'Porsi lebar jendela default kartu samping untuk obrolan baru (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Suntik alat buka sidebar untuk model',
settingsOpenToolsDesc: 'Saat diaktifkan, model dapat membuka file, folder, dan halaman HTTP(S) di sidebar melalui alat sidebar_open (nonaktif secara default)',
settingsTitleBarTitle: 'Mode kompatibilitas posisi',
-14
View File
@@ -73,7 +73,6 @@ export const it: Record<string, string> = {
closeOtherTabs: 'Chiudi le altre schede',
closeLeftTabs: 'Chiudi le schede a sinistra',
closeRightTabs: 'Chiudi le schede a destra',
moveToFreeWindow: 'Sposta in finestra libera',
floatDropHint: 'Rilascia per aprire in una finestra libera',
dockToSidebar: 'Torna alla barra laterale',
pinTerminal: 'Blocca Terminale',
@@ -88,8 +87,6 @@ export const it: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Bloccato globalmente',
pinnedRailLabel: 'Terminali Bloccati',
closePinnedTerminal: 'Chiudi Terminale',
collapse: 'Comprimi barra laterale',
expand: 'Espandi barra laterale',
collapseBottomPanel: 'Comprimi pannello inferiore',
expandBottomPanel: 'Espandi pannello inferiore',
terminalError: 'Connessione del terminale non riuscita',
@@ -145,12 +142,6 @@ export const it: Record<string, string> = {
changesOpenDiffTab: 'Apri in una scheda diff',
changesClosePreview: 'Ridimensiona l\'anteprima',
changesResizePreview: 'Ridimensiona anteprima',
changesDiffOpenTitle: 'Il diff si apre come',
changesDiffOpenDesc: 'Dove l\'azione "apri in una scheda diff" colloca il diff',
changesDiffOpenFloat: 'Finestra libera',
changesDiffOpenFloatDesc: 'Una finestra flottante centrata sul viewport — trascina, ridimensiona, sempre in primo piano',
changesDiffOpenPane: 'Pannello',
changesDiffOpenPaneDesc: 'Ancorato sotto il pannello sorgente (divisione diff in stile VSCode)',
changesLoadError: 'I record dei file della sessione non sono disponibili',
staged: 'In stage',
unstaged: 'Non in stage',
@@ -224,11 +215,6 @@ export const it: Record<string, string> = {
settingsIntro: 'Gestisca cosa mostra la scheda laterale e come si comporta',
settingsPopupDesc: 'Configuri le opzioni relative a {feature}',
settingsDone: 'Fatto',
settingsOpenTitle: 'Apri per impostazione predefinita per le nuove conversazioni',
settingsOpenDesc: 'Espande automaticamente la scheda laterale per le conversazioni nuove; le conversazioni esistenti mantengono il proprio layout',
settingsWidthTitle: 'Quota di larghezza predefinita',
settingsWidthDesc: 'La quota predefinita della scheda laterale sulla larghezza della finestra per le nuove conversazioni (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Iniettare lo strumento di apertura della barra laterale per il modello',
settingsOpenToolsDesc: 'Se attivato, il modello può aprire file, cartelle e pagine HTTP(S) nella barra laterale tramite lo strumento sidebar_open (disattivato per impostazione predefinita)',
settingsTitleBarTitle: 'Modalità di compatibilità della posizione',
-14
View File
@@ -82,7 +82,6 @@ export const ja: Record<string, string> = {
closeOtherTabs: '他のタブを閉じる',
closeLeftTabs: '左のタブを閉じる',
closeRightTabs: '右のタブを閉じる',
moveToFreeWindow: 'フリーウィンドウへ移動',
floatDropHint: '離すとフリーウィンドウで開きます',
dockToSidebar: 'サイドバーへ戻す',
pinTerminal: 'ターミナルを固定',
@@ -97,8 +96,6 @@ export const ja: Record<string, string> = {
pinnedTerminalScopeGlobal: 'グローバルに固定',
pinnedRailLabel: '固定されたターミナル',
closePinnedTerminal: 'ターミナルを閉じる',
collapse: 'サイドバーを折りたたむ',
expand: 'サイドバーを展開',
collapseBottomPanel: '下パネルを折りたたむ',
expandBottomPanel: '下パネルを展開',
terminalError: 'ターミナル接続に失敗',
@@ -154,12 +151,6 @@ export const ja: Record<string, string> = {
changesOpenDiffTab: '独立タブで開く',
changesClosePreview: 'プレビューを閉じる',
changesResizePreview: 'プレビューの高さを変更',
changesDiffOpenTitle: 'diff の展開先',
changesDiffOpenDesc: '「独立タブで開く」操作が diff をどこに配置するか',
changesDiffOpenFloat: 'フリーウィンドウ',
changesDiffOpenFloatDesc: 'ビューポート中央に浮かぶウィンドウ — ドラッグ・リサイズ・最前面固定',
changesDiffOpenPane: 'ペイン',
changesDiffOpenPaneDesc: 'ソースペインの下にドッキング(VSCode 式 diff 分割)',
changesLoadError: 'セッションのファイル記録は現在利用できません',
staged: 'ステージ済み',
unstaged: '未ステージ',
@@ -233,11 +224,6 @@ export const ja: Record<string, string> = {
settingsIntro: 'サイドカードの表示内容とデフォルト挙動を管理',
settingsPopupDesc: '「{feature}」の関連オプションを設定',
settingsDone: '完了',
settingsOpenTitle: '新規会話でデフォルト展開',
settingsOpenDesc: '新規会話時にサイドカードを自動展開。既存の会話はそれぞれのレイアウトを維持',
settingsWidthTitle: 'デフォルト幅の比率',
settingsWidthDesc: '新規会話時のサイドカードがウィンドウ幅に占める割合 (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'モデルにサイドバー開くツールを注入',
settingsOpenToolsDesc: 'オンにすると、モデルは sidebar_open ツールでサイドバーにファイル・フォルダー・HTTP(S) ページを開ける(デフォルトオフ)',
settingsTitleBarTitle: '位置互換モード',
-14
View File
@@ -74,7 +74,6 @@ export const ko: Record<string, string> = {
closeOtherTabs: '다른 탭 닫기',
closeLeftTabs: '왼쪽 탭 닫기',
closeRightTabs: '오른쪽 탭 닫기',
moveToFreeWindow: '자유 창으로 이동',
floatDropHint: '놓으면 자유 창에서 열립니다',
dockToSidebar: '사이드바로 돌아가기',
pinTerminal: '터미널 고정',
@@ -89,8 +88,6 @@ export const ko: Record<string, string> = {
pinnedTerminalScopeGlobal: '전역에 고정됨',
pinnedRailLabel: '고정된 터미널',
closePinnedTerminal: '터미널 닫기',
collapse: '사이드바 접기',
expand: '사이드바 펼치기',
collapseBottomPanel: '하단 패널 접기',
expandBottomPanel: '하단 패널 펼치기',
terminalError: '터미널 연결 실패',
@@ -146,12 +143,6 @@ export const ko: Record<string, string> = {
changesOpenDiffTab: '별도 탭에서 열기',
changesClosePreview: '미리보기 닫기',
changesResizePreview: '미리보기 높이 조정',
changesDiffOpenTitle: 'diff 열기 위치',
changesDiffOpenDesc: '「별도 탭에서 열기」 작업이 diff를 배치하는 곳',
changesDiffOpenFloat: '자유 창',
changesDiffOpenFloatDesc: '뷰포트 중앙의 떠 있는 창 — 드래그, 크기 조절, 항상 위',
changesDiffOpenPane: '패널',
changesDiffOpenPaneDesc: '소스 패널 아래에 도킹(VSCode 스타일 diff 분할)',
changesLoadError: '세션 파일 기록을 지금 사용할 수 없습니다',
staged: '스테이징됨',
unstaged: '스테이징 안 됨',
@@ -225,11 +216,6 @@ export const ko: Record<string, string> = {
settingsIntro: '사이드 카드의 표시 내용과 기본 동작을 관리합니다',
settingsPopupDesc: '"{feature}" 관련 옵션을 구성합니다',
settingsDone: '완료',
settingsOpenTitle: '새 대화 기본 열림',
settingsOpenDesc: '새 대화를 만들 때 사이드 카드를 자동으로 펼칩니다. 기존 대화는 각자의 레이아웃을 유지합니다',
settingsWidthTitle: '기본 너비 비율',
settingsWidthDesc: '새 대화에서 사이드 카드가 창 너비에서 차지하는 비율(%) (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: '모델에 사이드바 열기 도구 주입',
settingsOpenToolsDesc: '켜면 모델이 sidebar_open 도구로 사이드바에서 파일·폴더·HTTP(S) 페이지를 열 수 있습니다(기본 꺼짐)',
settingsTitleBarTitle: '위치 호환 모드',
-14
View File
@@ -80,7 +80,6 @@ export const nl: Record<string, string> = {
closeOtherTabs: 'Andere tabbladen sluiten',
closeLeftTabs: 'Tabbladen links sluiten',
closeRightTabs: 'Tabbladen rechts sluiten',
moveToFreeWindow: 'Naar vrij venster verplaatsen',
floatDropHint: 'Loslaten om in een vrij venster te openen',
dockToSidebar: 'Terug naar de zijbalk',
pinTerminal: 'Terminal vastmaken',
@@ -95,8 +94,6 @@ export const nl: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Wereldwijd vastgemaakt',
pinnedRailLabel: 'Vastgemaakte terminals',
closePinnedTerminal: 'Terminal sluiten',
collapse: 'Zijbalk inklappen',
expand: 'Zijbalk uitklappen',
collapseBottomPanel: 'Onderpaneel inklappen',
expandBottomPanel: 'Onderpaneel uitklappen',
terminalError: 'Terminalverbinding mislukt',
@@ -152,12 +149,6 @@ export const nl: Record<string, string> = {
changesOpenDiffTab: 'In eigen diff-tabblad openen',
changesClosePreview: 'Voorbeeld sluiten',
changesResizePreview: 'Voorbeeld vergroten/verkleinen',
changesDiffOpenTitle: 'Diff opent als',
changesDiffOpenDesc: 'Waar de actie „in eigen diff-tabblad openen” de diff plaatst',
changesDiffOpenFloat: 'Vrij venster',
changesDiffOpenFloatDesc: 'Een zwevend venster in het midden — slepen, schalen, bovenop houden',
changesDiffOpenPane: 'Paneel',
changesDiffOpenPaneDesc: 'Vastgemaakt onder het bronpaneel (VSCode-achtige diff-split)',
changesLoadError: 'Sessiebestandsregistraties zijn nu niet beschikbaar',
staged: 'Gestaged',
unstaged: 'Niet gestaged',
@@ -231,11 +222,6 @@ export const nl: Record<string, string> = {
settingsIntro: 'Beheer wat de zijkaart toont en hoe deze zich gedraagt',
settingsPopupDesc: 'Gerelateerde opties voor {feature} configureren',
settingsDone: 'Klaar',
settingsOpenTitle: 'Standaard openen voor nieuwe conversaties',
settingsOpenDesc: 'De zijkaart automatisch uitklappen voor gloednieuwe conversaties; bestaande conversaties behouden hun eigen layout',
settingsWidthTitle: 'Standaard breedte-aandeel',
settingsWidthDesc: 'Het standaard breedte-aandeel van de zijkaart voor nieuwe conversaties (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Zijbalk-openen-tool voor het model injecteren',
settingsOpenToolsDesc: 'Indien ingeschakeld kan het model bestanden, mappen en HTTP(S)-pagina\'s in de zijbalk openen via de sidebar_open-tool (standaard uit)',
settingsTitleBarTitle: 'Positiecompatibiliteitsmodus',
-14
View File
@@ -84,7 +84,6 @@ export const pl: Record<string, string> = {
closeOtherTabs: 'Zamknij inne karty',
closeLeftTabs: 'Zamknij karty po lewej',
closeRightTabs: 'Zamknij karty po prawej',
moveToFreeWindow: 'Przenieś do wolnego okna',
floatDropHint: 'Puść, aby otworzyć w wolnym oknie',
dockToSidebar: 'Wróć do panelu bocznego',
pinTerminal: 'Przypnij Terminal',
@@ -99,8 +98,6 @@ export const pl: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Przypięty globalnie',
pinnedRailLabel: 'Przypięte terminale',
closePinnedTerminal: 'Zamknij terminal',
collapse: 'Zwiń panel boczny',
expand: 'Rozwiń panel boczny',
collapseBottomPanel: 'Zwiń panel dolny',
expandBottomPanel: 'Rozwiń panel dolny',
terminalError: 'Połączenie z terminalem nie powiodło się',
@@ -156,12 +153,6 @@ export const pl: Record<string, string> = {
changesOpenDiffTab: 'Otwórz w osobnej karcie',
changesClosePreview: 'Zamknij podgląd',
changesResizePreview: 'Zmień wysokość podglądu',
changesDiffOpenTitle: 'Diff otwiera się jako',
changesDiffOpenDesc: 'Gdzie akcja „otwórz w osobnej karcie” umieszcza diff',
changesDiffOpenFloat: 'Odrębne okno',
changesDiffOpenFloatDesc: 'Pływające okno wyśrodkowane na ekranie — przeciągaj, skaluj, trzymaj na wierzchu',
changesDiffOpenPane: 'Panel',
changesDiffOpenPaneDesc: 'Zadokowane pod panelem źródłowym (podział diff w stylu VSCode)',
changesLoadError: 'Zapisy plików sesji są teraz niedostępne',
staged: 'W indeksie',
unstaged: 'Poza indeksem',
@@ -235,11 +226,6 @@ export const pl: Record<string, string> = {
settingsIntro: 'Zarządzaj tym, co pokazuje karta boczna i jak się zachowuje',
settingsPopupDesc: 'Skonfiguruj opcje powiązane z: {feature}',
settingsDone: 'Gotowe',
settingsOpenTitle: 'Otwórz domyślnie dla nowych rozmów',
settingsOpenDesc: 'Rozwiń kartę boczną automatycznie dla zupełnie nowych rozmów; istniejące rozmowy zachowują własne układy',
settingsWidthTitle: 'Domyślny udział szerokości',
settingsWidthDesc: 'Domyślny udział szerokości okna dla karty bocznej w nowych rozmowach (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Wstrzyknij narzędzie otwierania panelu bocznego dla modelu',
settingsOpenToolsDesc: 'Po włączeniu model może otwierać pliki, foldery i strony HTTP(S) w panelu bocznym za pomocą narzędzia sidebar_open (domyślnie wyłączone)',
settingsTitleBarTitle: 'Tryb zgodności pozycji',
-14
View File
@@ -65,7 +65,6 @@ export const pt: Record<string, string> = {
closeOtherTabs: 'Fechar outras abas',
closeLeftTabs: 'Fechar abas à esquerda',
closeRightTabs: 'Fechar abas à direita',
moveToFreeWindow: 'Mover para janela flutuante',
floatDropHint: 'Solte para abrir em uma janela flutuante',
dockToSidebar: 'Voltar para a barra lateral',
pinTerminal: 'Fixar Terminal',
@@ -80,8 +79,6 @@ export const pt: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Fixado globalmente',
pinnedRailLabel: 'Terminais Fixados',
closePinnedTerminal: 'Fechar Terminal',
collapse: 'Recolher barra lateral',
expand: 'Expandir barra lateral',
collapseBottomPanel: 'Recolher painel inferior',
expandBottomPanel: 'Expandir painel inferior',
terminalError: 'Falha na conexão do terminal',
@@ -137,12 +134,6 @@ export const pt: Record<string, string> = {
changesOpenDiffTab: 'Abrir em uma aba de diff',
changesClosePreview: 'Fechar prévia',
changesResizePreview: 'Ajustar altura da prévia',
changesDiffOpenTitle: 'O diff abre como',
changesDiffOpenDesc: 'Onde a ação "abrir em uma aba de diff" coloca o diff',
changesDiffOpenFloat: 'Janela livre',
changesDiffOpenFloatDesc: 'Uma janela flutuante centralizada — arraste, redimensione, mantenha no topo',
changesDiffOpenPane: 'Painel',
changesDiffOpenPaneDesc: 'Ancorado abaixo do painel de origem (divisão de diff estilo VSCode)',
changesLoadError: 'Os registros de arquivos da sessão estão indisponíveis no momento',
staged: 'Preparadas',
unstaged: 'Não preparadas',
@@ -216,11 +207,6 @@ export const pt: Record<string, string> = {
settingsIntro: 'Gerencie o que o cartão lateral mostra e como ele se comporta',
settingsPopupDesc: 'Configurar opções relacionadas para {feature}',
settingsDone: 'Concluído',
settingsOpenTitle: 'Abrir por padrão em novas conversas',
settingsOpenDesc: 'Expandir o cartão lateral automaticamente em conversas totalmente novas; conversas existentes mantêm seus próprios layouts',
settingsWidthTitle: 'Participação padrão na largura',
settingsWidthDesc: 'Participação padrão do cartão lateral na largura da janela em novas conversas (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Injetar a ferramenta de abertura na barra lateral para o modelo',
settingsOpenToolsDesc: 'Quando ativado, o modelo pode abrir arquivos, pastas e páginas HTTP(S) na barra lateral pela ferramenta sidebar_open (desativado por padrão)',
settingsTitleBarTitle: 'Modo de compatibilidade de posição',
-14
View File
@@ -78,7 +78,6 @@ export const ru: Record<string, string> = {
closeOtherTabs: 'Закрыть остальные вкладки',
closeLeftTabs: 'Закрыть вкладки слева',
closeRightTabs: 'Закрыть вкладки справа',
moveToFreeWindow: 'Переместить в свободное окно',
floatDropHint: 'Отпустите, чтобы открыть в свободном окне',
dockToSidebar: 'Вернуть на боковую панель',
pinTerminal: 'Закрепить терминал',
@@ -93,8 +92,6 @@ export const ru: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Закреплён глобально',
pinnedRailLabel: 'Закреплённые терминалы',
closePinnedTerminal: 'Закрыть терминал',
collapse: 'Свернуть боковую панель',
expand: 'Развернуть боковую панель',
collapseBottomPanel: 'Свернуть нижнюю панель',
expandBottomPanel: 'Развернуть нижнюю панель',
terminalError: 'Сбой подключения терминала',
@@ -150,12 +147,6 @@ export const ru: Record<string, string> = {
changesOpenDiffTab: 'Открыть в отдельной вкладке',
changesClosePreview: 'Закрыть просмотр',
changesResizePreview: 'Изменить высоту просмотра',
changesDiffOpenTitle: 'Diff открывается как',
changesDiffOpenDesc: 'Куда действие «открыть в отдельной вкладке» помещает diff',
changesDiffOpenFloat: 'Свободное окно',
changesDiffOpenFloatDesc: 'Плавающее окно по центру экрана — перетаскивание, масштаб, поверх всех',
changesDiffOpenPane: 'Панель',
changesDiffOpenPaneDesc: 'Прикреплено под исходной панелью (разделение diff в стиле VSCode)',
changesLoadError: 'Записи файлов сессии сейчас недоступны',
staged: 'В индексе',
unstaged: 'Не в индексе',
@@ -230,11 +221,6 @@ export const ru: Record<string, string> = {
settingsIntro: 'Управление содержимым и поведением боковой карточки по умолчанию',
settingsPopupDesc: 'Настройка параметров «{feature}»',
settingsDone: 'Готово',
settingsOpenTitle: 'Открывать по умолчанию для новых сессий',
settingsOpenDesc: 'Автоматически разворачивать боковую карточку для новых сессий; у существующих сессий сохраняется их собственная раскладка',
settingsWidthTitle: 'Доля ширины по умолчанию',
settingsWidthDesc: 'Доля боковой карточки в ширине окна для новых сессий, в процентах (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Внедрить инструмент открытия в боковой панели для модели',
settingsOpenToolsDesc: 'Если включено, модель может открывать файлы, папки и HTTP(S)-страницы в боковой панели с помощью инструмента sidebar_open (по умолчанию выключено)',
settingsTitleBarTitle: 'Режим совместимости заголовка',
-14
View File
@@ -65,7 +65,6 @@ export const sv: Record<string, string> = {
closeOtherTabs: 'Stäng andra flikar',
closeLeftTabs: 'Stäng flikar till vänster',
closeRightTabs: 'Stäng flikar till höger',
moveToFreeWindow: 'Flytta till fritt fönster',
floatDropHint: 'Släpp för att öppna i ett fritt fönster',
dockToSidebar: 'Tillbaka till sidopanelen',
pinTerminal: 'Fäst Terminal',
@@ -80,8 +79,6 @@ export const sv: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Fäst globalt',
pinnedRailLabel: 'Fästa terminaler',
closePinnedTerminal: 'Stäng terminal',
collapse: 'Komprimera sidopanel',
expand: 'Expandera sidopanel',
collapseBottomPanel: 'Komprimera bottenpanel',
expandBottomPanel: 'Expandera bottenpanel',
terminalError: 'Terminalanslutning misslyckades',
@@ -137,12 +134,6 @@ export const sv: Record<string, string> = {
changesOpenDiffTab: 'Öppna i egen diff-flik',
changesClosePreview: 'Stäng förhandsvisning',
changesResizePreview: 'Ändra storlek på förhandsvisningen',
changesDiffOpenTitle: 'Diff öppnas som',
changesDiffOpenDesc: 'Var åtgärden "öppna i egen diff-flik" placerar diffen',
changesDiffOpenFloat: 'Fritt fönster',
changesDiffOpenFloatDesc: 'Ett flytande fönster centrerat i vyn — dra, ändra storlek, håll överst',
changesDiffOpenPane: 'Panel',
changesDiffOpenPaneDesc: 'Dockad under källpanelen (VSCode-liknande diff-delning)',
changesLoadError: 'Sessionens filposter är inte tillgängliga just nu',
staged: 'Köade',
unstaged: 'Avköade',
@@ -216,11 +207,6 @@ export const sv: Record<string, string> = {
settingsIntro: 'Hantera vad sidokortet visar och hur det beter sig',
settingsPopupDesc: 'Konfigurera relaterade alternativ för {feature}',
settingsDone: 'Klar',
settingsOpenTitle: 'Öppna som standard för nya konversationer',
settingsOpenDesc: 'Expandera sidokortet automatiskt för helt nya konversationer; befintliga konversationer behåller sina egna layouter',
settingsWidthTitle: 'Standardbreddandel',
settingsWidthDesc: 'Sidokortets standardandel av fönsterbredden för nya konversationer (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Injicera sidopanel-öppningsverktyg för modellen',
settingsOpenToolsDesc: 'När aktiverat kan modellen öppna filer, mappar och HTTP(S)-sidor i sidopanelen via sidebar_open-verktyget (av som standard)',
settingsTitleBarTitle: 'Positions kompatibilitetsläge',
-14
View File
@@ -82,7 +82,6 @@ export const th: Record<string, string> = {
closeOtherTabs: 'ปิด Tab อื่น',
closeLeftTabs: 'ปิด Tab ด้านซ้าย',
closeRightTabs: 'ปิด Tab ด้านขวา',
moveToFreeWindow: 'ย้ายไปยังหน้าต่างอิสระ',
floatDropHint: 'ปล่อยเพื่อเปิดในหน้าต่างอิสระ',
dockToSidebar: 'กลับไปที่แถบข้าง',
pinTerminal: 'ปักหมุดเทอร์มินัล',
@@ -97,8 +96,6 @@ export const th: Record<string, string> = {
pinnedTerminalScopeGlobal: 'ปักหมุดแบบ Global',
pinnedRailLabel: 'เทอร์มินัลที่ปักหมุด',
closePinnedTerminal: 'ปิดเทอร์มินัล',
collapse: 'ย่อแถบด้านข้าง',
expand: 'ขยายแถบด้านข้าง',
collapseBottomPanel: 'ย่อพาเนลด้านล่าง',
expandBottomPanel: 'ขยายพาเนลด้านล่าง',
terminalError: 'การเชื่อมต่อเทอร์มินัลล้มเหลว',
@@ -154,12 +151,6 @@ export const th: Record<string, string> = {
changesOpenDiffTab: 'เปิดในแท็บ diff แยก',
changesClosePreview: 'ปิดตัวอย่าง',
changesResizePreview: 'ปรับขนาดตัวอย่าง',
changesDiffOpenTitle: 'diff เปิดเป็น',
changesDiffOpenDesc: 'การกระทำ «เปิดในแท็บ diff แยก» วาง diff ไว้ที่ใด',
changesDiffOpenFloat: 'หน้าต่างลอย',
changesDiffOpenFloatDesc: 'หน้าต่างลอยกลางจอ — ลาก ย่อขยาย ปักหมุดไว้บนสุด',
changesDiffOpenPane: 'แพเนล',
changesDiffOpenPaneDesc: 'จอดใต้แพเนลต้นทาง (แยก diff สไตล์ VSCode)',
changesLoadError: 'บันทึกไฟล์ของเซสชันยังใช้ไม่ได้ตอนนี้',
staged: 'Staged',
unstaged: 'Unstaged',
@@ -233,11 +224,6 @@ export const th: Record<string, string> = {
settingsIntro: 'จัดการสิ่งที่การ์ดด้านข้างแสดงและพฤติกรรมของมัน',
settingsPopupDesc: 'กำหนดค่าตัวเลือกที่เกี่ยวข้องสำหรับ {feature}',
settingsDone: 'เสร็จสิ้น',
settingsOpenTitle: 'เปิดโดยค่าเริ่มต้นสำหรับแชทใหม่',
settingsOpenDesc: 'ขยายการ์ดด้านข้างโดยอัตโนมัติสำหรับแชทใหม่; แชทที่มีอยู่แล้วยังคงเค้าโครงของตัวเอง',
settingsWidthTitle: 'สัดส่วนความกว้างเริ่มต้น',
settingsWidthDesc: 'สัดส่วนความกว้างเริ่มต้นของการ์ดด้านข้างจากความกว้างหน้าต่างสำหรับแชทใหม่ (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'ฉีดเครื่องมือเปิดแถบด้านข้างสำหรับโมเดล',
settingsOpenToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถเปิดไฟล์ โฟลเดอร์ และหน้า HTTP(S) ในแถบด้านข้างผ่านเครื่องมือ sidebar_open (ปิดเป็นค่าเริ่มต้น)',
settingsTitleBarTitle: 'โหมดความเข้ากันได้ของตำแหน่ง',
-14
View File
@@ -82,7 +82,6 @@ export const tr: Record<string, string> = {
closeOtherTabs: 'Diğer sekmeleri kapat',
closeLeftTabs: 'Soldaki sekmeleri kapat',
closeRightTabs: 'Sağdaki sekmeleri kapat',
moveToFreeWindow: 'Serbest pencereye taşı',
floatDropHint: 'Serbest pencerede açmak için bırakın',
dockToSidebar: 'Kenar çubuğuna geri dön',
pinTerminal: 'Terminali Sabitle',
@@ -97,8 +96,6 @@ export const tr: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Genel olarak sabitlendi',
pinnedRailLabel: 'Sabitlenmiş Terminaller',
closePinnedTerminal: 'Terminali Kapat',
collapse: 'Kenar çubuğunu daralt',
expand: 'Kenar çubuğunu genişlet',
collapseBottomPanel: 'Alt paneli daralt',
expandBottomPanel: 'Alt paneli genişlet',
terminalError: 'Terminal bağlantısı başarısız',
@@ -154,12 +151,6 @@ export const tr: Record<string, string> = {
changesOpenDiffTab: 'Ayrı bir diff sekmesinde aç',
changesClosePreview: 'Önizlemeyi kapat',
changesResizePreview: 'Önizlemeyi boyutlandır',
changesDiffOpenTitle: 'diff açılır',
changesDiffOpenDesc: '"Ayrı bir diff sekmesinde aç" eylemi diff\'i nereye koyar',
changesDiffOpenFloat: 'Serbest pencere',
changesDiffOpenFloatDesc: 'Görünümün ortasında yüzen pencere — sürükle, boyutlandır, üstte tut',
changesDiffOpenPane: 'Panel',
changesDiffOpenPaneDesc: 'Kaynak panelin altına sabitlenmiş (VSCode tarzı diff bölmesi)',
changesLoadError: 'Oturum dosya kayıtları şu anda kullanılamıyor',
staged: 'Sahnelendi',
unstaged: 'Sahnelenmedi',
@@ -233,11 +224,6 @@ export const tr: Record<string, string> = {
settingsIntro: 'Yan kartın ne gösterdiğini ve nasıl davrandığını yönetin',
settingsPopupDesc: '«{feature}» için ilgili seçenekleri yapılandırın',
settingsDone: 'Bitti',
settingsOpenTitle: 'Yeni sohbetlerde varsayılan olarak aç',
settingsOpenDesc: 'Yepyeni sohbetler için yan kartı otomatik genişlet; var olan sohbetler kendi düzenlerini korur',
settingsWidthTitle: 'Varsayılan genişlik payı',
settingsWidthDesc: 'Yeni sohbetler için yan kartın pencere genişliğindeki varsayılan payı (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Model için kenar çubuğu açma aracı enjekte et',
settingsOpenToolsDesc: 'Etkinleştirildiğinde model, sidebar_open aracıyla kenar çubuğunda dosyaları, klasörleri ve HTTP(S) sayfalarını açabilir (varsayılan kapalı)',
settingsTitleBarTitle: 'Konum uyumluluk modu',
-14
View File
@@ -82,7 +82,6 @@ export const vi: Record<string, string> = {
closeOtherTabs: 'Đóng các tab khác',
closeLeftTabs: 'Đóng tab bên trái',
closeRightTabs: 'Đóng tab bên phải',
moveToFreeWindow: 'Chuyển sang cửa sổ tự do',
floatDropHint: 'Thả để mở trong cửa sổ tự do',
dockToSidebar: 'Quay lại thanh bên',
pinTerminal: 'Ghim Terminal',
@@ -97,8 +96,6 @@ export const vi: Record<string, string> = {
pinnedTerminalScopeGlobal: 'Đã ghim toàn cục',
pinnedRailLabel: 'Terminal đã ghim',
closePinnedTerminal: 'Đóng Terminal',
collapse: 'Thu gọn thanh bên',
expand: 'Mở rộng thanh bên',
collapseBottomPanel: 'Thu gọn panel dưới',
expandBottomPanel: 'Mở rộng panel dưới',
terminalError: 'Kết nối terminal thất bại',
@@ -154,12 +151,6 @@ export const vi: Record<string, string> = {
changesOpenDiffTab: 'Mở trong tab diff riêng',
changesClosePreview: 'Đóng xem trước',
changesResizePreview: 'Điều chỉnh kích thước xem trước',
changesDiffOpenTitle: 'diff mở thành',
changesDiffOpenDesc: 'Hành động «mở trong tab diff riêng» đặt diff ở đâu',
changesDiffOpenFloat: 'Cửa sổ tự do',
changesDiffOpenFloatDesc: 'Cửa sổ nổi giữa khung nhìn — kéo, đổi kích thước, ghim trên cùng',
changesDiffOpenPane: 'Bảng',
changesDiffOpenPaneDesc: 'Gắn dưới bảng nguồn (chia diff kiểu VSCode)',
changesLoadError: 'Bản ghi tệp phiên hiện không khả dụng',
staged: 'Đã stage',
unstaged: 'Chưa stage',
@@ -233,11 +224,6 @@ export const vi: Record<string, string> = {
settingsIntro: 'Quản lý nội dung hiển thị và hành vi mặc định của thẻ bên',
settingsPopupDesc: 'Cấu hình tùy chọn cho «{feature}»',
settingsDone: 'Xong',
settingsOpenTitle: 'Mở mặc định cho phiên mới',
settingsOpenDesc: 'Tự động mở rộng thẻ bên cho phiên mới; phiên đã có giữ nguyên bố cục',
settingsWidthTitle: 'Tỷ lệ chiều rộng mặc định',
settingsWidthDesc: 'Tỷ lệ phần trăm chiều rộng cửa sổ mà thẻ bên chiếm cho phiên mới (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Tiêm công cụ mở thanh bên cho mô hình',
settingsOpenToolsDesc: 'Khi bật, mô hình có thể mở tệp, thư mục và trang HTTP(S) trong thanh bên qua công cụ sidebar_open (mặc định tắt)',
settingsTitleBarTitle: 'Chế độ tương thích thanh tiêu đề',
-14
View File
@@ -97,7 +97,6 @@ export const zhHK: Record<string, string> = {
closeOtherTabs: '關閉其他標籤',
closeLeftTabs: '關閉左側標籤',
closeRightTabs: '關閉右側標籤',
moveToFreeWindow: '移動到自由視窗',
floatDropHint: '放開以在自由視窗中開啟',
dockToSidebar: '返回側邊欄',
pinTerminal: '固定終端',
@@ -112,8 +111,6 @@ export const zhHK: Record<string, string> = {
pinnedTerminalScopeGlobal: '固定到全域',
pinnedRailLabel: '固定終端',
closePinnedTerminal: '關閉終端',
collapse: '收起側邊欄',
expand: '展開側邊欄',
collapseBottomPanel: '收起底部面板',
expandBottomPanel: '展開底部面板',
terminalError: '終端連線失敗',
@@ -169,12 +166,6 @@ export const zhHK: Record<string, string> = {
changesOpenDiffTab: '在獨立分頁中開啟',
changesClosePreview: '關閉預覽',
changesResizePreview: '調整預覽高度',
changesDiffOpenTitle: '差異展開方式',
changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡',
changesDiffOpenFloat: '浮動視窗',
changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
changesDiffOpenPane: '面板',
changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
changesLoadError: '工作階段檔案記錄暫不可用',
staged: '已暫存',
unstaged: '未暫存',
@@ -248,11 +239,6 @@ export const zhHK: Record<string, string> = {
settingsIntro: '管理側邊卡片的顯示內容與預設行為',
settingsPopupDesc: '為「{feature}」設定相關選項',
settingsDone: '完成',
settingsOpenTitle: '新工作階段預設開啟',
settingsOpenDesc: '新增工作階段時自動展開側邊卡片;已存在的工作階段保持各自佈局',
settingsWidthTitle: '預設寬度佔比',
settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
-14
View File
@@ -97,7 +97,6 @@ export const zhMO: Record<string, string> = {
closeOtherTabs: '關閉其他標籤',
closeLeftTabs: '關閉左側標籤',
closeRightTabs: '關閉右側標籤',
moveToFreeWindow: '移動到自由視窗',
floatDropHint: '放開以在自由視窗中開啟',
dockToSidebar: '返回側邊欄',
pinTerminal: '固定終端',
@@ -112,8 +111,6 @@ export const zhMO: Record<string, string> = {
pinnedTerminalScopeGlobal: '固定到全域',
pinnedRailLabel: '固定終端',
closePinnedTerminal: '關閉終端',
collapse: '收起側邊欄',
expand: '展開側邊欄',
collapseBottomPanel: '收起底部面板',
expandBottomPanel: '展開底部面板',
terminalError: '終端連線失敗',
@@ -169,12 +166,6 @@ export const zhMO: Record<string, string> = {
changesOpenDiffTab: '在獨立分頁中開啟',
changesClosePreview: '關閉預覽',
changesResizePreview: '調整預覽高度',
changesDiffOpenTitle: '差異展開方式',
changesDiffOpenDesc: '「展開為獨立分頁」把 diff 放到哪裡',
changesDiffOpenFloat: '浮動視窗',
changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
changesDiffOpenPane: '面板',
changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
changesLoadError: '工作階段檔案記錄暫不可用',
staged: '已暫存',
unstaged: '未暫存',
@@ -248,11 +239,6 @@ export const zhMO: Record<string, string> = {
settingsIntro: '管理側邊卡片的顯示內容與預設行為',
settingsPopupDesc: '為「{feature}」設定相關選項',
settingsDone: '完成',
settingsOpenTitle: '新工作階段預設開啟',
settingsOpenDesc: '新增工作階段時自動展開側邊卡片;已存在的工作階段保持各自佈局',
settingsWidthTitle: '預設寬度佔比',
settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
-14
View File
@@ -97,7 +97,6 @@ export const zhTW: Record<string, string> = {
closeOtherTabs: '關閉其他標籤',
closeLeftTabs: '關閉左側標籤',
closeRightTabs: '關閉右側標籤',
moveToFreeWindow: '移動到自由視窗',
floatDropHint: '放開以在自由視窗中開啟',
dockToSidebar: '返回側邊欄',
pinTerminal: '固定終端',
@@ -112,8 +111,6 @@ export const zhTW: Record<string, string> = {
pinnedTerminalScopeGlobal: '固定到全域',
pinnedRailLabel: '固定終端',
closePinnedTerminal: '關閉終端',
collapse: '收起側邊欄',
expand: '展開側邊欄',
collapseBottomPanel: '收起底部面板',
expandBottomPanel: '展開底部面板',
terminalError: '終端連線失敗',
@@ -169,12 +166,6 @@ export const zhTW: Record<string, string> = {
changesOpenDiffTab: '在獨立頁籤中開啟',
changesClosePreview: '關閉預覽',
changesResizePreview: '調整預覽高度',
changesDiffOpenTitle: '差異展開方式',
changesDiffOpenDesc: '「展開為獨立頁籤」把 diff 放到哪裡',
changesDiffOpenFloat: '浮動視窗',
changesDiffOpenFloatDesc: '作為自由視窗置中開啟,可拖拽、縮放、置頂',
changesDiffOpenPane: '面板',
changesDiffOpenPaneDesc: '停駐在來源面板下方(VSCode 式 diff 分欄)',
changesLoadError: '工作階段檔案記錄暫不可用',
staged: '已暫存',
unstaged: '未暫存',
@@ -248,11 +239,6 @@ export const zhTW: Record<string, string> = {
settingsIntro: '管理側邊卡片的顯示內容與預設行為',
settingsPopupDesc: '為「{feature}」設定相關選項',
settingsDone: '完成',
settingsOpenTitle: '新工作階段預設開啟',
settingsOpenDesc: '新增工作階段時自動展開側邊卡片;已存在的工作階段保持各自佈局',
settingsWidthTitle: '預設寬度佔比',
settingsWidthDesc: '新增工作階段時側邊卡片佔視窗寬度的百分比 (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
-28
View File
@@ -94,7 +94,6 @@ export const zh = {
closeOtherTabs: '关闭其他页签',
closeLeftTabs: '关闭左侧页签',
closeRightTabs: '关闭右侧页签',
moveToFreeWindow: '移动到自由窗口',
floatDropHint: '松开以在自由窗口中打开',
dockToSidebar: '回到侧边栏',
pinTerminal: '固定终端',
@@ -109,8 +108,6 @@ export const zh = {
pinnedTerminalScopeGlobal: '固定到全局',
pinnedRailLabel: '固定终端',
closePinnedTerminal: '关闭终端',
collapse: '折叠侧边栏',
expand: '展开侧边栏',
collapseBottomPanel: '折叠底部面板',
expandBottomPanel: '展开底部面板',
terminalError: '终端连接失败',
@@ -165,12 +162,6 @@ export const zh = {
changesOpenDiffTab: '在独立页签中打开',
changesClosePreview: '关闭预览',
changesResizePreview: '调整预览高度',
changesDiffOpenTitle: '差异展开方式',
changesDiffOpenDesc: '「展开为独立页签」把 diff 放到哪里',
changesDiffOpenFloat: '浮窗',
changesDiffOpenFloatDesc: '作为自由窗口居中打开,可拖拽、缩放、置顶',
changesDiffOpenPane: '面板',
changesDiffOpenPaneDesc: '停靠在源面板下方(VSCode 式 diff 分栏)',
changesLoadError: '会话文件记录暂不可用',
staged: '已暂存',
unstaged: '未暂存',
@@ -245,11 +236,6 @@ export const zh = {
settingsIntro: '管理侧边卡片的显示内容与默认行为',
settingsPopupDesc: '为「{feature}」配置相关选项',
settingsDone: '完成',
settingsOpenTitle: '新会话默认打开',
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
settingsWidthTitle: '默认宽度占比',
settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: '为模型注入侧边栏打开工具',
settingsOpenToolsDesc: '开启后,模型可通过 sidebar_open 工具在侧边栏主动打开文件、文件夹和 HTTP(S) 网页(默认关闭)',
settingsTitleBarTitle: '位置兼容模式',
@@ -543,7 +529,6 @@ export const en: Record<keyof typeof zh, string> = {
closeOtherTabs: 'Close Other Tabs',
closeLeftTabs: 'Close Tabs to the Left',
closeRightTabs: 'Close Tabs to the Right',
moveToFreeWindow: 'Move to Free Window',
floatDropHint: 'Release to open in a free window',
dockToSidebar: 'Dock Back to Sidebar',
pinTerminal: 'Pin Terminal',
@@ -558,8 +543,6 @@ export const en: Record<keyof typeof zh, string> = {
pinnedTerminalScopeGlobal: 'Pinned globally',
pinnedRailLabel: 'Pinned Terminals',
closePinnedTerminal: 'Close Terminal',
collapse: 'Collapse sidebar',
expand: 'Expand sidebar',
collapseBottomPanel: 'Collapse bottom panel',
expandBottomPanel: 'Expand bottom panel',
terminalError: 'Terminal connection failed',
@@ -614,12 +597,6 @@ export const en: Record<keyof typeof zh, string> = {
changesOpenDiffTab: 'Open in a diff tab',
changesClosePreview: 'Close preview',
changesResizePreview: 'Resize preview',
changesDiffOpenTitle: 'Diff opens as',
changesDiffOpenDesc: 'Where the "expand to a diff tab" action lands the diff',
changesDiffOpenFloat: 'Free window',
changesDiffOpenFloatDesc: 'A floating window centered on the viewport — drag, resize, keep on top',
changesDiffOpenPane: 'Pane',
changesDiffOpenPaneDesc: 'Docked below the source panel (VSCode-style diff split)',
changesLoadError: 'Session file records are unavailable right now',
staged: 'Staged',
unstaged: 'Unstaged',
@@ -694,11 +671,6 @@ export const en: Record<keyof typeof zh, string> = {
settingsIntro: 'Manage what the side card shows and how it behaves',
settingsPopupDesc: 'Configure related options for {feature}',
settingsDone: 'Done',
settingsOpenTitle: 'Open by default for new conversations',
settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
settingsWidthTitle: 'Default width share',
settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)',
settingsWidthSuffix: '%',
settingsOpenToolsTitle: 'Inject the sidebar-open tool for the model',
settingsOpenToolsDesc: 'When enabled, the model can actively open files, folders, and HTTP(S) pages in the sidebar through the sidebar_open tool (off by default)',
settingsTitleBarTitle: 'Position compatibility mode',
+3 -10
View File
@@ -143,9 +143,8 @@ export function pinnedVisibleTo(tab: SidebarTab, viewer: PinnedViewer): boolean
* Collect every pinned terminal visible to the viewer across ALL cached
* session states. Excludes the viewer's own session (those tabs are on its
* own strip). Order is stable: sessions in the cache's insertion order,
* tabs in tree order (splits → bottomSplits → floats) within each session
* — the order tabs were opened/pinned, so the rail never reorders between
* renders.
* tabs in tree order within each session — the order tabs were
* opened/pinned, so the rail never reorders between renders.
*/
export function collectPinnedTabs(
bySession: ReadonlyMap<string, SidebarState>,
@@ -154,20 +153,14 @@ export function collectPinnedTabs(
const entries: PinnedTabEntry[] = []
for (const [homeSessionId, state] of bySession) {
if (homeSessionId === viewer.sessionId) continue
collectFromTree(state.splits, homeSessionId, viewer, entries)
collectFromTree(state.bottomSplits, homeSessionId, viewer, entries)
for (const float of state.floats) {
if (float.tab.type === 'terminal' && pinnedVisibleTo(float.tab, viewer)) {
entries.push({ tab: float.tab, homeSessionId })
}
}
}
return entries
}
/** Walk one split tree depth-first, collecting visible pinned terminals. */
function collectFromTree(
node: SidebarState['splits'],
node: SplitNode,
homeSessionId: string,
viewer: PinnedViewer,
out: PinnedTabEntry[],
-11
View File
@@ -12,7 +12,6 @@ import type { api } from './api.ts'
import {
clampTerminalFontSize,
clampTitleBarStrip,
clampWidthPercent,
SIDEBAR_PREFS_DEFAULTS,
TITLE_BAR_SCHEMES,
TITLE_BAR_STRIP_DEFAULT,
@@ -26,7 +25,6 @@ export {
TITLE_BAR_STRIP_DEFAULT,
clampTerminalFontSize,
clampTitleBarStrip,
clampWidthPercent,
}
export type { SidebarPrefs, TitleBarScheme }
@@ -43,12 +41,6 @@ export function parsePrefs(value: unknown): SidebarPrefs {
if (value === null || typeof value !== 'object') return { ...SIDEBAR_PREFS_DEFAULTS }
const record = value as Record<string, unknown>
return {
openByDefault: typeof record.openByDefault === 'boolean'
? record.openByDefault
: SIDEBAR_PREFS_DEFAULTS.openByDefault,
defaultWidthPercent: typeof record.defaultWidthPercent === 'number' && Number.isFinite(record.defaultWidthPercent)
? clampWidthPercent(record.defaultWidthPercent)
: SIDEBAR_PREFS_DEFAULTS.defaultWidthPercent,
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
? record.autoOpenSubagent
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
@@ -79,9 +71,6 @@ export function parsePrefs(value: unknown): SidebarPrefs {
editorExplorer: typeof record.editorExplorer === 'boolean'
? record.editorExplorer
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
changesDiffFloat: typeof record.changesDiffFloat === 'boolean'
? record.changesDiffFloat
: SIDEBAR_PREFS_DEFAULTS.changesDiffFloat,
workspaceFence: typeof record.workspaceFence === 'boolean'
? record.workspaceFence
: SIDEBAR_PREFS_DEFAULTS.workspaceFence,
+28 -85
View File
@@ -22,11 +22,10 @@
import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts'
import {
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, closeFloatByTab, floatWithTab,
leafWithTab, openTabInActivePane, openTabInBottomPane, patchTab, raiseFloat, tabOpenIn, togglePanel, treeOf,
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer,
leafWithTab, openTabInBottomPane, patchTab, tabOpenIn,
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, type TabType,
} from './state.ts'
import { isNarrowWidth } from './breakpoints.ts'
import { extOf } from './paths.ts'
import type { SessionScope } from './api.ts'
import type { SidebarPrefs } from '../prefs-shared.ts'
@@ -430,12 +429,10 @@ export interface BetterSidebarService {
* without switching the UI's active session; when absent the open lands
* in the currently active session (the pre-0.12 behavior).
*
* A CONTENT open (a `path` or `url` seed) must land in sight: when the
* panel hosting the landing pane is collapsed, it is expanded
* automatically (the right panel by default, the bottom panel when the
* active pane lives there; on narrow viewports the merged drawer opens).
* Type-only opens (the + menu, agent-terminal auto-tabs) never expand —
* the panel behavior is their caller's business.
* Every open lands in the bottom workbench and expands it (the workbench
* is the plugin's only own surface; the right column is DSH's native
* Sidebar). An open carrying a `path` or `url` goes through the native
* surface instead, which never touches this state.
*
* Note: `available` gates the + menu's disabled state only — it does NOT
* refuse `openTab` (only the settings disable switch does).
@@ -536,9 +533,10 @@ export const SIDEBAR_SERVICE_VERSION = '0.19.0-alpha.0'
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
* - 'floatWindows' (v0.16.0): tabs float as free windows — openTab's dedupe/
* id focus targets RAISE the floating window (never duplicate the tab or
* expand panels), closeTab on a floating tab closes it with its window.
*
* v0.19.0 REMOVED 'floatWindows': the free-window feature is gone (DSH 0.1.5
* owns the right column, so the plugin keeps only its bottom workbench).
* Consumers must not gate on it any more.
*/
export const SIDEBAR_FEATURES = [
'badge',
@@ -551,7 +549,6 @@ export const SIDEBAR_FEATURES = [
'pluginSettings',
'urlTarget',
'settingSelect',
'floatWindows',
] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */
@@ -733,7 +730,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
// A bottom-targeted open lands in the bottom workbench's own pane; the
// right tree it would otherwise follow is no longer rendered (DSH's
// native sidebar owns the right column).
const land = seed.target === 'bottom' ? openTabInBottomPane : openTabInActivePane
const land = openTabInBottomPane
const reducer = (state: SidebarState): SidebarState => {
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
let tab: SidebarTab
@@ -766,8 +763,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
// tab that never closes.
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
const inputTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
.concat(state.floats.map(f => f.tab))
const inputTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
const existedByKey = key !== undefined
&& inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
const existedById = tabOpenIn(state, tab.id)
@@ -787,57 +783,23 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
if (isCreation) {
// Resolve the ACTUAL landed tab — the url patch mints a new object,
// so the callback must see the tab that was really inserted.
const landedTabs = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
const landedTabs = allLeaves(landed.bottomSplits).flatMap(leaf => leaf.tabs)
created = landedTabs.find(candidate => candidate.id === tab.id) ?? tab
} else {
// A focus happened: resolve the tab that is actually active now and
// report THAT to onActivate (never the caller's un-inserted seed).
// The pool covers free windows too — a floating instance focuses by
// raising, and the callback must see the real (patched) tab.
const candidates = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
.concat(landed.floats.map(f => f.tab))
const candidates = allLeaves(landed.bottomSplits).flatMap(leaf => leaf.tabs)
activated = key !== undefined
? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
: candidates.find(candidate => candidate.id === tab.id)
activated ??= tab
}
// A CONTENT open that focuses an existing FLOATING tab is already in
// sight (free windows render regardless of panel state): expanding a
// panel for it would point the user at a pane the content is not in.
if (
!isCreation
&& floatWithTab(landed, activated?.id ?? tab.id) !== undefined
) {
return landed
}
// A CONTENT open (file / browser) must land in sight: when the panel
// hosting the landing pane is collapsed, expand it. On narrow
// viewports the two workbenches merge into one drawer, so the drawer
// (panelOpen) is the only lever; on wide viewports the landing pane's
// own panel opens — the bottom panel when the active pane lives in the
// bottom tree, else the right panel. Type-only opens (+ menu,
// agent-terminal auto-tabs) never expand (the panel behavior is their
// caller's business). The check runs on the post-dedupe state, so a
// content open that merely FOCUSES an existing tab expands the panel
// too — the open must never land out of sight. Opens targeted at an
// INACTIVE session never expand (nothing is in sight for the user).
if (
!targetsInactiveSession
&& typeof window !== 'undefined'
&& (seed.path !== undefined || seed.url !== undefined)
) {
if (isNarrowWidth(window.innerWidth)) {
if (!landed.panelOpen) return togglePanel(landed)
} else {
const hostKey = treeOf(landed, landed.activePane ?? '')
if (hostKey === 'bottomSplits') {
if (!landed.bottomOpen) return { ...landed, bottomOpen: true }
} else if (!landed.panelOpen) {
return togglePanel(landed)
}
}
}
return landed
// Every open that lands in the workbench is visible: a creation
// expands it (openTabInBottomPane) and so does a FOCUS (the dedupe/id
// reducers only activate the tab). An open targeted at an INACTIVE
// session expands THAT session's workbench — it is waiting for the
// user when they switch to it.
return landed.bottomOpen ? landed : { ...landed, bottomOpen: true }
}
// A scope targeting ANOTHER session lands the open there without
// switching the UI; a scope naming the active session (or no scope)
@@ -871,14 +833,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
// Unknown tab ids are a strict no-op: no state churn, no notify, no
// pointless localStorage rewrite (mirrors updateTab's short-circuit).
if (!tabOpenIn(state, tabId)) return state
// A floating tab closes WITH its window — the float is the tab's pane.
const float = floatWithTab(state, tabId)
if (float !== undefined) {
closed = float.tab
return closeFloatByTab(state, tabId)
}
const paneId = findPaneIdOf(state, tabId)
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
const leaf = leafWithTab(state.bottomSplits, tabId)
closed = leaf?.tabs.find(tab => tab.id === tabId)
return closeTabReducer(state, paneId, tabId)
})
@@ -915,14 +871,8 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
store.reduce((state) => {
// Unknown tab ids are a strict no-op (no state churn / notify).
if (!tabOpenIn(state, tabId)) return state
// A floating tab "activates" by raising its window — no pane switch.
const float = floatWithTab(state, tabId)
if (float !== undefined) {
activated = float.tab
return raiseFloat(state, float.id)
}
const paneId = findPaneIdOf(state, tabId)
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
const leaf = leafWithTab(state.bottomSplits, tabId)
activated = leaf?.tabs.find(tab => tab.id === tabId)
return activateTabReducer(state, paneId, tabId)
})
@@ -968,37 +918,30 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
/**
* Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the
* same type exists, focus it; otherwise land the tab through
* `openTabInActivePane` (the id safety net + active-pane landing are that
* reducer's job — not re-implemented here).
* same type exists, focus it; otherwise land the tab through `land` (the id
* safety net + landing are that reducer's job — not re-implemented here).
* `single: true` resolves to the id-key sugar when no explicit key is given.
*/
function applyDedupe(
state: SidebarState,
tab: SidebarTab,
descriptor: TabDescriptor,
land: (state: SidebarState, tab: SidebarTab) => SidebarState = openTabInActivePane,
land: (state: SidebarState, tab: SidebarTab) => SidebarState = openTabInBottomPane,
): SidebarState {
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
if (key !== undefined) {
// 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))) {
for (const leaf of allLeaves(state.bottomSplits)) {
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id)
}
// A floating instance focuses by raising its window (no duplicate tab,
// no panel expansion — the window is the tab's pane).
const floated = state.floats.find(f => f.tab.type === tab.type && dedupeKey!(f.tab) === key)
if (floated !== undefined) return raiseFloat(state, floated.id)
}
return land(state, tab)
}
/** Find which pane hosts a tab id ('' if none). Either tree is searched. */
/** Find which pane hosts a tab id ('' if none). */
function findPaneIdOf(state: SidebarState, tabId: string): string {
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
for (const leaf of allLeaves(state.bottomSplits)) {
if (leaf.tabs.some(t => t.id === tabId)) return leaf.id
}
return state.activePane ?? ''
+8 -327
View File
@@ -69,41 +69,6 @@
left: 0;
}
/* 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. Host-internal z-index 45
(above the panels' 40); the host's global layer is z-index 25, so the
whole cluster stays below DSH's ui-cordis dynamic-plugin panel (30) and
the app's overlay stack (menus/tooltips/modals sit at 100+), so a cordis
approval popup is never hidden behind the workbench (issue #52). Absolute
within the panel host (the host's inset: 0 makes viewport coordinates
identical). */
.toggleCluster {
position: absolute;
/* env() resolves to 0 on devices without a top inset (desktops). */
top: calc(3px + env(safe-area-inset-top));
right: 10px;
z-index: 45;
display: flex;
flex-direction: row;
gap: 4px;
pointer-events: auto;
/* The cluster recenters between the panel's tab strip (top: 3) and DSH's
session header row (top: 14, see the collapsed rule below) on every
expand/collapse — ride the panel slide's own duration/easing so the two
moves stay in lockstep instead of the cluster snapping. */
transition: top var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* 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),
@@ -136,75 +101,6 @@
cursor: default;
}
.panel {
box-sizing: border-box;
position: absolute;
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;
/* Host-internal z-index 40 (sibling of the bottom panel's 40; the two
never overlap — the bottom panel's right edge is this panel's left
edge — and both sit below the toggle cluster's 45). The absolute
global layer comes from the host ([data-dsh-panel-host], 25) — the
whole stack stays above the app's in-flow content and below DSH's
ui-cordis dynamic-plugin panel (30) and the app's overlay stack (100+),
so menus/tooltips/modals always win over the panels (issue #52). The
corner handle is a child and stacks within the panel. */
z-index: 40;
display: flex;
flex-direction: column;
/* Re-enable interaction inside the click-transparent panel host. */
pointer-events: auto;
/* The generic card surface (see the root contract above). Never
`--dsw-specific-sidebar-fill` — skin plugins restyle that token for the
app's own left nav and a panel consuming it loses its fill or clashes
with labels (#60/#105/#106). */
background: var(--dsw-alias-bg-layer-1);
border-left: 1px solid var(--dsw-alias-border-l2);
/* Bottom safe-area (notched phones): the drawer's bottom content clears
the home indicator; 0 on desktops. */
padding-bottom: env(safe-area-inset-bottom);
/* Expand/collapse slides the panel; fullscreen animates the width. */
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Collapsed: slid off-screen, interactive nothing, hidden after the slide. */
.panelHidden {
transform: translateX(102%);
pointer-events: none;
visibility: hidden;
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
width var(--ds-transition-duration-slow) var(--ds-ease-in-out),
visibility 0s linear var(--ds-transition-duration-slow);
}
/* Width drags write at pointer cadence; easing would detach the edge. */
.panel[data-dragging] {
transition: none;
}
.panelResize {
position: absolute;
/* Centered on the panel edge (an 8px hit strip straddling the border, the
same pattern as the host's own frame handles). */
left: -4px;
top: 0;
bottom: 0;
width: 8px;
cursor: col-resize;
z-index: 2;
touch-action: none;
}
.panelResizeActive {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
.panelBody {
flex: 1;
min-height: 0;
@@ -263,7 +159,6 @@
will-change: transform is only live while a drag is (the body attribute
toggles with the drag state), promoting the panels to their own layer
without paying the compositing cost at rest. */
.panel,
.bottomPanel {
contain: layout style;
}
@@ -324,148 +219,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
padding-right: 40px;
}
/* ── Free windows ──────────────────────────────────────────────────────── */
/* One floating tab: the same card surface as the panels, at viewport
coordinates inside the panel host (immune to shell transforms). z-index
42: above the panels (40) so a window drags across them, below the
toggle cluster (45) and the app's overlay stack (100+, issue #52).
Stacking among windows is the floats array order (siblings share the
z-index; DOM order wins). Deliberately NO box transition — the header/
resize drags write left/top/width/height per frame. Elevated surface,
so unlike the flat panels it earns a shadow token. */
.floatWindow {
position: absolute;
z-index: 42;
display: flex;
flex-direction: column;
/* Re-enable interaction inside the click-transparent panel host. */
pointer-events: auto;
background: var(--dsw-alias-bg-layer-1);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
box-shadow: var(--dsw-shadow-lv3);
overflow: hidden;
contain: layout style;
}
.floatWindowDragging {
will-change: left, top, width, height;
}
/* The header: the move/dock-back drag handle, mirroring the tab strip's
chrome (34px, hairline bottom, secondary label). */
.floatHeader {
flex: none;
display: flex;
align-items: center;
gap: 4px;
height: 34px;
padding: 0 4px 0 10px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
border-bottom: 1px solid var(--dsw-alias-border-l1);
cursor: grab;
user-select: none;
}
.floatWindowDragging .floatHeader {
cursor: grabbing;
}
.floatTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Mirrors .tabClose. */
.floatClose {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
flex: none;
}
.floatClose:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* Mirrors .paneContent's column-flex contract: the tab cell must hand its
child a definite WIDTH via align-stretch. A row flex (the default) sizes
the main axis (width) from content — intrinsic-zero hosts (e.g. a plugin
tab UI) collapse to a 0px-wide sliver and paint nothing (the blank
whale-report window). */
.floatContent {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* The SE resize corner (mirror of .cornerHandle). */
.floatResize {
position: absolute;
right: 0;
bottom: 0;
width: 14px;
height: 14px;
z-index: 2;
cursor: nwse-resize;
touch-action: none;
}
.floatResize:hover {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
/* A pane highlighted as the dock target while a float header drag hovers it
(the attribute is written directly by the drag at pointer cadence;
skin-addressable via [data-dsh-pane][data-dsh-float-dock-over]). */
.pane[data-dsh-float-dock-over] {
outline: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -2px;
}
/* The drag-out hint over the conversation column: a dashed frame marking
the drop zone, pointer-transparent so it never disturbs the drag it
describes. z-index 46: above the windows (42) for unambiguous feedback,
still below the app's overlay stack. */
.floatDropHint {
position: absolute;
z-index: 46;
pointer-events: none;
border: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
border-radius: 8px;
background: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover-accent) 12%, transparent);
display: flex;
align-items: center;
justify-content: center;
}
.floatDropHintLabel {
padding: 4px 12px;
border-radius: 999px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1);
border: 1px solid var(--dsw-alias-border-l2);
}
/* ── Desktop drag-region opt-out ──────────────────────────────────────── */
/* Frameless desktop shells make the top strip of the web content draggable
@@ -475,10 +228,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
`no-drag` itself — some shells (Tauri, older desktop builds, third-party
wrappers) have no blanket button rule. The property is a no-op in plain
browsers and in shells without drag regions — safe everywhere. */
.toggleCluster,
.toggleButton,
.tabBar,
.floatHeader {
.tabBar {
-webkit-app-region: no-drag;
}
@@ -487,67 +238,10 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
/* Windows frameless / hidden-title-bar windows draw the native caption
strip (minimize / maximize / close) at the window's top-right corner,
OVER the web content. When the user enables the "position compatibility"
pref, the sidebar yields the strip: the toggle cluster drops below it and
the right panel's content starts below it — the panel's surface still
spans the full height, so the caption buttons float over empty sidebar
fill (the VSCode hidden-title-bar look). The strip height is user-tunable
(`--dsh-title-bar-strip`, written by the Sidebar shell from
`titleBarStripPx`; 40px fallback). The bottom panel is unaffected: it
sits at the window's bottom, never under the top strip. */
:global(body[data-dsh-title-bar-compat]) .toggleCluster {
top: calc(var(--dsh-title-bar-strip, 40px) + 3px);
}
:global(body[data-dsh-title-bar-compat]) .panel {
/* The caption strip is drawn over the panel's own top edge: keep the
surface full-height and push the content (the tab strip) below the
strip instead. .panelResize is absolutely positioned and unaffected. */
padding-top: var(--dsh-title-bar-strip, 40px);
}
/* ── Collapsed cluster alignment with the DSH web session header ───────── */
/* While the right panel is open, the cluster rides the panel's 34px tab
strip (28px buttons at top: 3). Collapsed, it floats over DSH's own
session header instead — whose controls (title, "Session log" capsule)
are vertically centered at 28px on both desktop and narrow viewports.
top: 3 then strands the cluster ~11px above that row; re-center the
28px buttons at 14px. The attribute disappears while the panel is open,
so the tab-strip alignment above is untouched. With position
compatibility active the DSH header sits below the caption strip, hence
strip + 14. */
:global(body[data-dsh-sidebar-collapsed]) .toggleCluster {
top: calc(14px + env(safe-area-inset-top));
}
:global(body[data-dsh-sidebar-collapsed][data-dsh-title-bar-compat]) .toggleCluster {
top: calc(var(--dsh-title-bar-strip, 40px) + 14px);
}
/* 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. Rendered INSIDE the right panel and positioned
relative to it (left edge + the bottom panel's height from the layout
variable), so no JS-written viewport coordinates are involved. The
z-index is panel-local (above the panel's content; the panel itself sits
at 40, below the app's overlay stack). */
.cornerHandle {
position: absolute;
left: -6px;
bottom: calc(var(--dsh-sidebar-height, 0px) + 6px);
width: 12px;
height: 12px;
z-index: 2;
cursor: nwse-resize;
touch-action: none;
}
.cornerHandle:hover,
.cornerHandle[data-dragging] {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
pref, the Sidebar shell publishes the strip height as
`--dsh-title-bar-strip` plus `body[data-dsh-title-bar-compat]` for the
user's own CSS; the plugin's remaining chrome (the bottom workbench) sits
at the window's bottom and is never under the top strip. */
/* 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. */
@@ -2444,11 +2138,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
/* Reduced motion: no slides, fades or color transitions. */
@media (prefers-reduced-motion: reduce) {
.panel,
.panelHidden,
.bottomPanel,
.bottomPanelHidden,
.toggleCluster,
.toggleButton,
.tab,
.tabBarPlus,
@@ -2465,20 +2156,10 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
/* ── Narrow (mobile) viewport ──────────────────────────────────────────── */
/* Pairs with NARROW_MAX_WIDTH = 768 in src/client/breakpoints.ts: widths
strictly below 768px (i.e. max-width: 767px) switch the sidebar to the
mobile layout — one full-screen drawer (the bottom panel's tabs were
migrated into the right tree), one toggle button. The structural
switches (which panels render, the migration) are JS-driven via
useNarrowViewport; this block only adapts the pure style details. */
strictly below 768px (i.e. max-width: 767px) get the phone-width tab
rhythm; the bottom workbench keeps its own geometry (it spans the center
column at every width). */
@media (max-width: 767px) {
/* The toggle cluster holds a single button on narrow viewports (the
bottom panel button is not rendered), so the open panel's tab strip
reserves 40px instead of the desktop 72px — the tabs and + menu still
yield space to the one remaining control. */
.panel:not(.panelHidden) .tabBar {
padding-right: 40px;
}
/* Narrower tabs: a phone-width drawer fits more open tabs before the
strip scrolls. */
.tab {
+29
View File
@@ -0,0 +1,29 @@
/**
* The bottom workbench's expand/collapse control, registered into DSH's
* session header utilities list (`conversation.session.header.utilities`).
*
* DSH 0.1.5 draws its own right-Sidebar expand button in the header's corner
* slot (`conversation.session.header.corner`, a SINGLE slot owned by the
* native sidebar package), so the plugin cannot take that seat. The utilities
* list sits immediately left of it — the same row as the host's "Session
* log" capsule — which is where a dock toggle belongs: in flow, so it never
* overlaps host chrome and needs no absolute positioning or measurement.
*
* Registration goes through `slots.inject` because the declaration may not be
* on the ledger yet at plugin activation (the ui-conversation parent entry
* declares it); the callback re-runs if the declaration collapses and is
* re-declared. `order: 10` places the button after the host's own utilities.
*/
import type { Context } from '../../context-types.ts'
import type { SidebarStore } from '../state.ts'
import { BottomDockToggle } from '../Sidebar.tsx'
/** Register the header toggle; returns the disposer. */
export function registerBottomToggle(ctx: Context, store: SidebarStore): () => void {
return ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
name: 'conversation.session.header.utilities',
id: 'dsh-better-sidebar:bottom-toggle',
order: 10,
registrant: 'dsh-better-sidebar',
}, () => <BottomDockToggle store={store} />))
}
-167
View File
@@ -1,167 +0,0 @@
/**
* Free windows (extracted from Sidebar.tsx, behavior identical): the
* drag-out gesture that floats a tab onto the conversation column, plus the
* render layer for the floating windows and its drop-zone hint overlay.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { Context } from '../../context-types.ts'
import {
dockFloat, floatTab, moveFloat, raiseFloat, resizeFloat,
type FloatWindow, type SidebarStore, type SidebarTab,
} from '../state.ts'
import { TAB_DRAG_TYPE, parseDrag } from '../TabBar.tsx'
import { FreeWindow } from '../FreeWindow.tsx'
import { t } from '../locales.ts'
import css from '../sidebar.module.css'
/** The dashed drop-zone hint's geometry (viewport coordinates). */
export interface FloatDropHint {
left: number
top: number
width: number
height: number
}
/**
* Free windows — drag-out detection. The tab strips already drive HTML5
* DnD (payload application/x-dsh-tab) with drops owned by the panes
* (split/merge); this hook watches the DOCUMENT (capture) for the same
* drag hovering OUTSIDE the panel host: while the pointer is over the
* conversation column it arms the drop (preventDefault) and shows a hint
* overlay there, and the drop floats the tab at the release point. Targets
* inside the host are ignored here, so pane drops keep their behavior
* untouched. Only OUR tab drags count (the body flag is the tab strip's;
* OS file drags and any DSH drags pass through). Narrow viewports skip
* the gesture — the merged drawer covers the conversation, leaving
* nothing to drop onto (the tab context menu entry still floats tabs).
*/
export function useFloatDragout(input: {
narrow: boolean
sessionId: string | undefined
store: SidebarStore
/** The AppFrame center column (from useCenterColumn); stable ref object. */
centerColRef: { readonly current: HTMLElement | null }
}): { floatHint: FloatDropHint | null } {
const { narrow, sessionId, store, centerColRef } = input
const [floatHint, setFloatHint] = useState<FloatDropHint | null>(null)
const floatHintRef = useRef(false)
useEffect(() => {
if (narrow || sessionId === undefined) return
const inPanelHost = (target: EventTarget | null): boolean =>
target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
/** The conversation column's rect when the pointer is over it (and not
* over our own surfaces); null otherwise. */
const overConversation = (event: DragEvent): DOMRect | null => {
if (inPanelHost(event.target)) return null
const col = centerColRef.current
if (col === null || !col.isConnected) return null
const rect = col.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return null
const { clientX: x, clientY: y } = event
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
return rect
}
const onDragOver = (event: DragEvent): void => {
if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
const rect = overConversation(event)
if (rect !== null) {
// preventDefault on dragover is what makes the browser deliver the
// drop (and drop the "no" cursor) over the conversation area.
event.preventDefault()
setFloatHint((prev) => {
const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
if (prev !== null && prev.left === next.left && prev.top === next.top
&& prev.width === next.width && prev.height === next.height) return prev
return next
})
floatHintRef.current = true
} else if (floatHintRef.current) {
floatHintRef.current = false
setFloatHint(null)
}
}
const onDrop = (event: DragEvent): void => {
if (!floatHintRef.current) return
floatHintRef.current = false
setFloatHint(null)
const rect = overConversation(event)
if (rect === null) return
event.preventDefault()
event.stopPropagation()
const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
if (payload === null) return
store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
}
const clear = (): void => {
if (!floatHintRef.current) return
floatHintRef.current = false
setFloatHint(null)
}
document.addEventListener('dragover', onDragOver, true)
document.addEventListener('drop', onDrop, true)
window.addEventListener('dragend', clear, true)
window.addEventListener('blur', clear)
return () => {
document.removeEventListener('dragover', onDragOver, true)
document.removeEventListener('drop', onDrop, true)
window.removeEventListener('dragend', clear, true)
window.removeEventListener('blur', clear)
}
// centerColRef is a stable ref object from useCenterColumn (it lost its
// useRef provenance crossing the hook boundary, so it is listed here).
}, [narrow, sessionId, store, centerColRef])
return { floatHint }
}
/**
* The free-window render layer: tabs dragged out onto the conversation area
* (or floated from the tab context menu). They live in the panel host like
* the panels (viewport coordinates, immune to desktop-shell transforms) but
* are independent of panel state — a window stays up while panels collapse.
* The floats array's order is the stacking order; the content reuses the
* regular tab renderer, so every tab type floats unchanged. Renders a
* fragment so the DOM is exactly the floats followed by the drag-out hint.
*/
export function FreeWindowLayer(props: {
floats: readonly FloatWindow[]
hint: FloatDropHint | null
renderTab: (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float') => ReactNode
getTabIcon: (tab: SidebarTab) => ReactNode
store: SidebarStore
ctx: Context
sessionId: string
cwd: string | undefined
}) {
const { floats, hint, renderTab, getTabIcon, store, ctx, sessionId, cwd } = props
return (
<>
{floats.map(float => (
<FreeWindow
key={float.id}
float={float}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'float')}
getTabIcon={getTabIcon}
onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }}
onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
/>
))}
{/*
The drag-out hint: while a tab drag hovers the conversation column,
a dashed overlay marks the drop zone there (pointer-transparent — it
must not disturb the drag it describes).
*/}
{hint !== null && (
<div
className={css.floatDropHint}
style={{ left: hint.left, top: hint.top, width: hint.width, height: hint.height }}
>
<span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
</div>
)}
</>
)
}
+7 -8
View File
@@ -1,13 +1,12 @@
/**
* Center-column tracking (extracted from Sidebar.tsx, behavior identical):
* the bottom panel spans ONLY the app shell's 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 directly from the AppFrame's center
* column DOM (the parent of the [data-slot="conversation"] wrapper —
* layout.css's center column) so the bottom panel tracks the column's real
* horizontal edges — including the animated AppFrame padding reservation
* while the right panel opens/closes; a frame that never appears keeps the
* the bottom workbench spans ONLY the app shell's 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. Measured directly from the
* AppFrame's center column DOM (the parent of the [data-slot="conversation"]
* wrapper — layout.css's center column) so the workbench tracks the column's
* real horizontal edges, including the animated track moves while DSH's
* native right Sidebar opens/closes; a frame that never appears keeps the
* initial zero-size fallback (the panel renders at 0 width until measured).
* The rect lives in a REF (not state): the open/close transition resizes
* the center column EVERY frame for its duration, and reacting per frame
+4 -21
View File
@@ -7,8 +7,7 @@
*/
import { useEffect, useRef } from 'react'
import type { Context, SidebarSessionList } from '../../context-types.ts'
import { firstLeaf, reconcileAgentTerminals, togglePanel, type SidebarStore } from '../state.ts'
import { isNarrowWidth } from '../breakpoints.ts'
import { reconcileAgentTerminals, type SidebarStore } from '../state.ts'
import { detectNewDirectSubagent } from '../subagent-detect.ts'
import { detectNewJob } from '../subagent-jobs.ts'
import { t } from '../locales.ts'
@@ -197,17 +196,7 @@ export function useHostFeeds(feeds: {
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
// Read the viewport when the delayed activation fires: a resize while
// the debounce is armed must not let background activity force the
// narrow full-screen drawer open over the chat.
if (!isNarrowWidth(window.innerWidth)) {
store.reduce(s => s.panelOpen ? s : togglePanel(s))
}
// Choose the right panel as the landing pane for a newly created Tasks
// tab. Single-instance dedupe still activates an existing pane tab in
// place or raises an existing free window.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' })
}, AUTO_OPEN_DEBOUNCE_MS)
autoOpenPendingRef.current = { baseline, timer }
}, [sessionList, sessionId, store, ctx])
@@ -237,11 +226,7 @@ export function useHostFeeds(feeds: {
if (!detectNewJob(prev, sessionList, sessionId)) return
if (!store.getPrefs().autoOpenJobs) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
if (!isNarrowWidth(window.innerWidth)) {
store.reduce(s => s.panelOpen ? s : togglePanel(s))
}
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' })
}, [sessionList, sessionId, store, ctx])
/**
@@ -260,9 +245,7 @@ export function useHostFeeds(feeds: {
const pending = subagentJumpRef.current
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.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' })
}, [sessionId, store, ctx])
return { subagentJumpRef }
+10 -16
View File
@@ -1,13 +1,13 @@
/**
* Inline pinned terminals (extracted from Sidebar.tsx, behavior identical):
* pinned tabs from OTHER sessions inject as VIRTUAL tabs into the first
* leaf of the right panel's split tree, and the workbench actions are
* leaf of the bottom workbench's split tree, and the workbench actions are
* wrapped so virtual ids route back to the HOME session. The shell only
* consumes the augmented tree and the wrapped actions.
*/
import { useMemo, useState } from 'react'
import {
agentUuidOf, closeFloatByTab, closeTab, isAgentTabId, leafWithTab, setTabPin,
agentUuidOf, closeTab, isAgentTabId, leafWithTab, setTabPin,
type SidebarSnapshot, type SidebarStore, type SplitNode,
} from '../state.ts'
import {
@@ -29,8 +29,8 @@ export function usePinnedTabs(input: {
/**
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
* inject as VIRTUAL tabs into the first leaf of the right panel's split
* tree. The virtual tabs have unique ids (prefixed with the home session)
* inject as VIRTUAL tabs into the first leaf of the bottom workbench's
* split tree. The virtual tabs have unique ids (prefixed with the home session)
* and carry the home scope in meta. Clicking a virtual tab sets
* `activePinnedTabId` — the augmented tree overrides the leaf's `active`
* so the pinned tab's content renders in-place (TerminalView connects to
@@ -65,11 +65,11 @@ export function usePinnedTabs(input: {
[pinnedEntries],
)
/** The right panel's split tree with pinned virtual tabs injected into the
* first leaf. When `activePinnedTabId` is set, that leaf's `active` is
* overridden so the pinned tab's content is visible. */
/** The bottom workbench's split tree with pinned virtual tabs injected
* into the first leaf. When `activePinnedTabId` is set, that leaf's
* `active` is overridden so the pinned tab's content is visible. */
const augmentedTree = useMemo(
() => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
() => state === undefined ? undefined : injectPinnedIntoTree(state.bottomSplits, pinnedVirtualTabs, activePinnedTabId),
[state, pinnedVirtualTabs, activePinnedTabId],
)
@@ -89,10 +89,8 @@ export function usePinnedTabs(input: {
const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
store.reduceFor(homeSessionId, s => {
const leaf = leafWithTab(s.splits, originalId) ?? leafWithTab(s.bottomSplits, originalId)
if (leaf !== undefined) return closeTab(s, leaf.id, originalId)
if (s.floats.some(f => f.tab.id === originalId)) return closeFloatByTab(s, originalId)
return s
const leaf = leafWithTab(s.bottomSplits, originalId)
return leaf === undefined ? s : closeTab(s, leaf.id, originalId)
})
if (isAgentTabId(originalId)) {
void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
@@ -131,10 +129,6 @@ export function usePinnedTabs(input: {
if (isPinnedVirtualId(payload.tabId)) return
actions.moveTabToEdge(payload, toPane, zone)
},
floatTab: (tabId) => {
if (isPinnedVirtualId(tabId)) return
actions.floatTab(tabId)
},
pinTab: (tabId, scope) => {
if (isPinnedVirtualId(tabId)) {
if (scope !== null) return
+1 -4
View File
@@ -29,8 +29,6 @@ export interface WorkbenchActions {
/** Reorder within a pane (drop onto another tab inserts before it). */
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void
resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
/** Float a docked tab out as a free window (tab context menu entry). */
floatTab: (tabId: string) => void
/**
* Pin/unpin a terminal tab (v0.17.0+). The shell snapshots the home cwd
* at pin time; null clears the pin. Optional: when undefined the tab
@@ -216,7 +214,6 @@ function LeafView(props: {
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
else actions.moveTabBefore(payload, leaf.id, before)
}}
onFloatTab={actions.floatTab}
onPinTab={actions.pinTab}
/>
{leaf.tabs.length > 0 ? (
@@ -319,7 +316,7 @@ export function Workbench(props: {
return (
<div className={css.workbench}>
<NodeView
node={tree ?? state.splits}
node={tree ?? state.bottomSplits}
state={state}
newTabOptions={newTabOptions}
actions={actions}
+83 -579
View File
@@ -1,8 +1,8 @@
/**
* Per-session sidebar state: the panel geometry, the split-pane workbench
* tree, open tabs, and the explorer expansion set. One state instance per
* conversation id, persisted to localStorage under `dsh-sidebar:v1:<id>` so
* a reload restores the exact layout of the session it belongs to — switching
* Per-session sidebar state: the bottom workbench's split-pane tree, open
* tabs, and the explorer expansion set. One state instance per conversation
* id, persisted to localStorage under `dsh-sidebar:v1:<id>` so a reload
* restores the exact layout of the session it belongs to — switching
* conversations swaps the whole state (memory + isolation).
*
* The split tree is a recursive structure: a leaf holds a tab group, a split
@@ -10,7 +10,6 @@
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
*/
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
import { isNarrowWidth } from './breakpoints.ts'
/**
* Tab type identifier. Builtins register their ids (editor / git / terminal
@@ -64,30 +63,9 @@ export interface SidebarSplit {
export type SplitNode = SidebarLeaf | SidebarSplit
/**
* One free window: a tab dragged out of the workbench onto the conversation
* area floats in the panel host at viewport coordinates. The tab is OWNED by
* the window exactly like a pane owns its tabs (moved, not copied); geometry
* persists with the session so a reload restores the window in place.
* Stacking order is the array order (last = topmost).
*/
export interface FloatWindow {
id: string
tab: SidebarTab
/** Viewport coordinates of the window's top-left corner. */
x: number
y: number
w: number
h: number
}
/** The full per-session state. */
export interface SidebarState {
panelOpen: boolean
width: number
/** 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}. */
/** The pane receiving newly opened tabs (the last pane the user touched). */
activePane: string | null
/** Monotonic terminal tab counter (ids survive reloads). */
nextTerminal: number
@@ -101,9 +79,7 @@ export interface SidebarState {
* unhighlighted.
*/
revealed: string[]
/** The right sidebar's split tree (the original workbench). */
splits: SplitNode
/** Whether the bottom panel (a second, independent workbench) is open. */
/** Whether the bottom panel (the plugin's one workbench) is open. */
bottomOpen: boolean
/** The bottom panel's height (clamped to the contract range). */
bottomHeight: number
@@ -113,31 +89,18 @@ export interface SidebarState {
* 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). */
/** The bottom workbench's split tree. */
bottomSplits: SplitNode
/** Free windows (tabs dragged out onto the conversation area). */
floats: FloatWindow[]
}
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}). */
/** Bottom panel geometry contract (the upper bound is the viewport, enforced
* by {@link setBottomHeight}). */
export const BOTTOM_MIN = 120
export const BOTTOM_DEFAULT = 220
/** Free-window geometry contract: the floor keeps the window usable (a
* header plus some content), the ceiling is the viewport. */
export const FLOAT_MIN_W = 320
export const FLOAT_MIN_H = 200
/** Geometry a fresh free window starts with: a phone-like portrait ratio
* (390×780 ≈ 1:2). The creation path additionally caps the size to the
* viewport (minus a 24px margin), so a short viewport gets a shorter —
* not overflowing — window instead of an exact ratio. */
export const FLOAT_DEFAULT_W = 390
export const FLOAT_DEFAULT_H = 780
/** The conversation column keeps at least this much height when the bottom
* workbench claims space (see {@link setBottomHeight}). */
export const CONVERSATION_MIN = 280
let nextIdCounter = 0
/** Unique pane/tab id within one state instance. */
@@ -155,7 +118,7 @@ export function mintTabId(): string {
/**
* The largest numeric suffix across a raw persisted state's counter ids
* (`pane:N` / `tab:N` / `split:N` / `float:N`). The uid counter is module-global and
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
* resets on every reload, so a split minted AFTER a reload would collide
* with the persisted ids (a fresh "pane:1" beside the persisted "pane:1");
* mapLeaf would then visit BOTH leaves and every open would land in both
@@ -166,7 +129,7 @@ function maxCounterId(parsed: unknown): number {
let max = 0
const consider = (id: unknown): void => {
if (typeof id !== 'string') return
const match = /^(?:pane|tab|split|float):(\d+)$/.exec(id)
const match = /^(?:pane|tab|split):(\d+)$/.exec(id)
if (match !== null) max = Math.max(max, Number(match[1]))
}
const walk = (node: unknown): void => {
@@ -182,75 +145,31 @@ function maxCounterId(parsed: unknown): number {
for (const child of record.children) walk(child)
}
}
walk((parsed as Record<string, unknown> | null)?.splits)
walk((parsed as Record<string, unknown> | null)?.bottomSplits)
// Free windows mint through the same shared counter; their ids must seed it
// too (a persisted float:N with a lower pane max would collide otherwise).
const floats = (parsed as Record<string, unknown> | null)?.floats
if (Array.isArray(floats)) {
for (const float of floats) {
if (float !== null && typeof float === 'object') consider((float as Record<string, unknown>).id)
}
}
return max
}
/** The default tab a fresh session seeds. */
export type DefaultSeed = 'editor-home' | 'none'
/** A fresh default state: one seeded tab in one pane, open per the caller's
* preference. `width` is the caller's preferred panel width (default
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
* true); the store seeds new sessions from the user's side card prefs.
* `seed` picks the seeded tab: 'editor-home' places the EMPTY files window
* (an editor tab with no path whose tree panel starts open,
* `meta.treeOpen: true`) — in BOTH editorExplorer modes that window is the
* file explorer page — and 'none' starts with an empty pane (the store
* passes it when the user disabled the editor tab type in settings). */
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: DefaultSeed = 'editor-home'): SidebarState {
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
if (seed === 'editor-home') {
// No path: the editor host renders its empty-state hint and the docked
// tree panel (treeOpen defaults open for path-less tabs; meta pins it).
leaf.tabs = [{ id: uid('tab'), type: 'editor', title: 'Files', meta: { treeOpen: true } }]
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).
/**
* A fresh default state: one empty pane in the bottom workbench, closed.
* (The right column belongs to DSH's native Sidebar, so this plugin's own
* layout has nothing to seed — its welcome cards offer the openable types on
* first expansion.)
*/
export function makeDefaultState(): SidebarState {
const bottomLeaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
return {
panelOpen,
width,
activePane: leaf.id,
activePane: bottomLeaf.id,
nextTerminal: 1,
nextBrowser: 1,
expanded: [],
revealed: [],
splits: leaf,
bottomOpen: false,
bottomHeight: BOTTOM_DEFAULT,
bottomOpenedOnce: false,
bottomSplits: bottomLeaf,
floats: [],
}
}
/** 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') {
@@ -275,44 +194,6 @@ export function firstLeaf(node: SplitNode): SidebarLeaf {
return firstLeaf(node.children[0]!)
}
/** Empty every leaf of a tree (the bottom tree after its tabs migrate out). */
function clearAllTabs(node: SplitNode): SplitNode {
if (node.kind === 'leaf') return { ...node, tabs: [], active: null }
return { ...node, children: node.children.map(clearAllTabs) }
}
/**
* Narrow-viewport migration: the bottom panel's tabs are thrown INTO the
* right sidebar — the "merged display" on mobile is the right panel alone,
* whose tab strips now carry the bottom tree's tabs (depth-first order,
* appended to the right tree's FIRST leaf). The bottom tree is emptied (its
* structure stays — the desktop bottom panel re-renders its welcome cards)
* and the panel closes. The active pane moves to the right tree's first
* leaf so every new tab lands in the visible panel.
*
* Idempotent: a bottom tree with no tabs and a closed panel returns the
* same reference. Runs when the viewport enters narrow (see the Sidebar
* shell); migrating is permanent for the session — the tabs now live in the
* right tree, exactly like the user "threw them in".
*/
export function migrateBottomTabs(state: SidebarState): SidebarState {
const bottomTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
const activeInBottom = state.activePane !== null && treeHasId(state.bottomSplits, state.activePane)
if (bottomTabs.length === 0 && !state.bottomOpen && !activeInBottom) return state
const target = firstLeaf(state.splits)
return {
...state,
activePane: target.id,
bottomOpen: false,
splits: bottomTabs.length > 0
? mapLeaf(state.splits, target.id, leaf => {
leaf.tabs = [...leaf.tabs, ...bottomTabs]
})
: state.splits,
bottomSplits: bottomTabs.length > 0 ? clearAllTabs(state.bottomSplits) : state.bottomSplits,
}
}
/** Find the leaf containing a tab id, if any. */
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
if (node.kind === 'leaf') {
@@ -331,22 +212,9 @@ export function allLeaves(node: SplitNode): SidebarLeaf[] {
return node.children.flatMap(allLeaves)
}
/** Whether a tab exists anywhere in a state (either tree, any pane, or any
* free window — a floating tab is as open as a docked one). */
/** Whether a tab is open anywhere in the session's workbench. */
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))
|| state.floats.some(float => float.tab.id === tabId)
}
/** The free window holding a tab id, if any. */
export function floatWithTab(state: SidebarState, tabId: string): FloatWindow | undefined {
return state.floats.find(float => float.tab.id === tabId)
}
/** The free window with the given window id, if any. */
export function floatById(state: SidebarState, floatId: string): FloatWindow | undefined {
return state.floats.find(float => float.id === floatId)
return 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. */
@@ -402,9 +270,6 @@ 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,
@@ -417,38 +282,7 @@ export function moveTabToEdge(
// Dropped back onto its own pane's center: reorder to the end.
return moveTab(state, fromPane, tabId, toPane, -1)
}
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 node = state.bottomSplits
const source = leafWithTab(node, tabId)
if (source === undefined) return state
const tab = source.tabs.find(candidate => candidate.id === tabId)!
@@ -464,11 +298,11 @@ export function moveTabToEdge(
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
})
return { ...state, [key]: splits, activePane: toPane }
return { ...state, bottomSplits: splits, activePane: toPane }
}
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up')
return { ...state, [key]: result.node, activePane: result.leafId }
return { ...state, bottomSplits: result.node, activePane: result.leafId }
}
/**
@@ -491,7 +325,7 @@ 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)
const key = 'bottomSplits'
let emptied = false
const splits = mapLeaf(state[key], paneId, (leaf) => {
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
@@ -503,7 +337,7 @@ export function closeTab(state: SidebarState, paneId: string, tabId: string): Si
/** 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)
const key = 'bottomSplits'
return {
...state,
activePane: paneId,
@@ -541,17 +375,14 @@ export function patchTab(
const children = node.children.map(walk)
return children === node.children ? node : { ...node, children }
}
const splits = walk(state.splits)
const bottomSplits = walk(state.bottomSplits)
const floats = state.floats.map(float => (float.tab.id === tabId ? { ...float, tab: apply(float.tab) } : float))
return changed ? { ...state, splits, bottomSplits, floats } : state
return changed ? { ...state, bottomSplits } : state
}
/**
* Set or clear the pin marker on one open tab (v0.17.0+). A pin marker is
* structural metadata (NOT display fields like title/path), so it walks
* both split trees AND the free windows exactly like {@link patchTab} —
* the tab may live in either tree or float. Passing `null` clears the pin
* the workbench's split tree exactly like {@link patchTab}. Passing `null` clears the pin
* (the tab stays open in its home session); passing a `{ scope, homeCwd }`
* object sets it. An unknown tab id is a strict no-op (same reference
* returned) so a stale pin request never churns the state or rewrites
@@ -606,80 +437,26 @@ export function setTabPin(
if (children.every((child, i) => child === node.children[i])) return node
return { ...node, children }
}
const splits = walk(state.splits)
const bottomSplits = walk(state.bottomSplits)
const floatIdx = state.floats.findIndex(f => f.tab.id === tabId)
const floats = floatIdx < 0 ? state.floats : (() => {
const oldFloat = state.floats[floatIdx]!
const newTab = apply(oldFloat.tab)
if (newTab === oldFloat.tab) return state.floats
const next = state.floats.slice()
next[floatIdx] = { ...oldFloat, tab: newTab }
return next
})()
return changed ? { ...state, splits, bottomSplits, floats } : state
return changed ? { ...state, bottomSplits } : state
}
/**
* Land a tab in the active pane (or focus its existing instance by id).
* Dedup strategies (single-instance, per-path, per-change) are owned by the
* tab descriptor through {@link BetterSidebarService.openTab} / `dedupeKey`;
* this reducer only handles the id-based safety net (reconcile and
* 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 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 — in a
// pane (activate) or in a free window (raise, no panel switch).
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)
}
const floated = floatWithTab(state, tab.id)
if (floated !== undefined) return raiseFloat(state, floated.id)
return {
...state,
activePane: targetId,
[targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
}),
}
}
/**
* Land a tab in the BOTTOM workbench's first pane (the bottom panel's own
* opens — its + menu, the auto-terminal): the plugin's right tree is no longer
* rendered on 0.1.5 (DSH's native sidebar is the right column), so an open
* that must land in the bottom dock cannot follow the active pane, which may
* point at the retired tree.
* Land a tab in the workbench's first pane — the plugin's own opens (its
* bottom-panel + menu, the auto-terminal, and every open when no native
* surface is installed): the plugin owns no right column any more (DSH's
* native sidebar is the right one), so the bottom workbench is the only tree.
* @param state - the session state.
* @param tab - the tab to land.
* @returns the next state, with the bottom panel open.
*/
export function openTabInBottomPane(state: SidebarState, tab: SidebarTab): SidebarState {
const targetId = firstLeaf(state.bottomSplits).id
// Id-based safety net: if a tab with the same id exists, focus it — in a
// pane (activate) or in a free window (raise, no panel switch).
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
// Id-based safety net: if a tab with the same id exists, focus it.
for (const leaf of allLeaves(state.bottomSplits)) {
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
const floated = floatWithTab(state, tab.id)
if (floated !== undefined) return raiseFloat(state, floated.id)
return {
...state,
bottomOpen: true,
@@ -695,8 +472,8 @@ export function openTabInBottomPane(state: SidebarState, tab: SidebarTab): Sideb
* 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)
const fromKey = 'bottomSplits'
const toKey = 'bottomSplits'
if (fromKey !== toKey) {
let moved: SidebarTab | undefined
let emptied = false
@@ -743,8 +520,8 @@ export function moveTab(state: SidebarState, fromPane: string, tabId: string, to
/** 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
const key = treeOf(state, paneId)
const paneId = state.activePane ?? firstLeaf(state.bottomSplits).id
const key = 'bottomSplits'
return { ...state, [key]: splitLeafAt(state[key], paneId, dir) }
}
@@ -763,14 +540,14 @@ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState
* @returns the new state, with the diff pane active.
*/
export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState {
const existingLeaf = leafWithTab(state.splits, tab.id)
const existingLeaf = leafWithTab(state.bottomSplits, tab.id)
if (existingLeaf !== undefined) return activateTab(state, existingLeaf.id, tab.id)
const diffLeaf = allLeaves(state.splits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
const diffLeaf = allLeaves(state.bottomSplits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
if (diffLeaf !== undefined) {
return {
...state,
activePane: diffLeaf.id,
splits: mapLeaf(state.splits, diffLeaf.id, (leaf) => {
bottomSplits: mapLeaf(state.bottomSplits, diffLeaf.id, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
}),
@@ -779,37 +556,25 @@ export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: Side
// First diff: split the source pane, the diff tab in the new LOWER leaf.
// (A stale sourcePaneId — its pane closed meanwhile — degrades to the
// regular open path instead of dropping the tab into an orphaned leaf.)
if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) {
return openTabInActivePane(state, tab)
if (!allLeaves(state.bottomSplits).some(leaf => leaf.id === sourcePaneId)) {
return openTabInBottomPane(state, tab)
}
const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false)
return { ...state, splits: result.node, activePane: result.leafId }
const result = insertLeafAt(state.bottomSplits, sourcePaneId, 'col', tab, false)
return { ...state, bottomSplits: result.node, activePane: result.leafId }
}
/** Toggle the panel open/closed (opening restores the previous layout). */
export function togglePanel(state: SidebarState): SidebarState {
return { ...state, panelOpen: !state.panelOpen }
}
/** Toggle the bottom panel open/closed (independent of the right panel). */
/** Expand/collapse the bottom workbench. */
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 {
const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
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. */
/** Set the bottom workbench height (clamped to the contract range). The
* upper bound leaves the conversation column at least {@link CONVERSATION_MIN}
* tall — without the cap the workbench 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)
const max = Math.max(BOTTOM_MIN, viewport - CONVERSATION_MIN)
return { ...state, bottomHeight: Math.min(max, Math.max(BOTTOM_MIN, Math.round(height))) }
}
@@ -874,147 +639,10 @@ 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)
const key = 'bottomSplits'
return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) }
}
// ── Free windows ────────────────────────────────────────────────────────────
/** The viewport size, or Infinity where there is no (usable) window — unit
* tests stub partial window objects, and a NaN bound would poison geometry. */
function viewportW(): number {
return typeof window !== 'undefined' && Number.isFinite(window.innerWidth) ? window.innerWidth : Infinity
}
function viewportH(): number {
return typeof window !== 'undefined' && Number.isFinite(window.innerHeight) ? window.innerHeight : Infinity
}
/** Clamp free-window geometry: sizes respect the floor and the viewport, and
* the position keeps the whole window inside the viewport. Without a window
* (unit tests) only the floor applies — the caller's values pass through. */
export function clampFloatGeometry(x: number, y: number, w: number, h: number): Pick<FloatWindow, 'x' | 'y' | 'w' | 'h'> {
const vw = viewportW()
const vh = viewportH()
const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw)))
const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh)))
return {
x: Math.round(Math.min(Math.max(x, 0), Math.max(0, vw - width))),
y: Math.round(Math.min(Math.max(y, 0), Math.max(0, vh - height))),
w: width,
h: height,
}
}
/**
* Float a docked tab: remove it from its pane (either tree; an emptied pane
* collapses like any move) and append a free window centered on the drop
* point, with the default size clamped to the viewport. The stacking order
* is the array order, so a fresh window is born topmost. An unknown tab id
* (or one already floating) is a strict no-op.
*/
export function floatTab(state: SidebarState, tabId: string, x: number, y: number): SidebarState {
let source: SidebarLeaf | undefined
let key: 'splits' | 'bottomSplits' | undefined
for (const treeKey of ['splits', 'bottomSplits'] as const) {
source = leafWithTab(state[treeKey], tabId)
if (source !== undefined) { key = treeKey; break }
}
if (key === undefined || source === undefined) return state
const tab = source.tabs.find(candidate => candidate.id === tabId)!
let emptied = false
let node = 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) node = removeLeafAt(node, source.id)
// Phone-ratio default, capped to the viewport before centering so the
// clamped position never leaves the window's bottom past the fold.
const vw = viewportW()
const vh = viewportH()
const width = Math.min(FLOAT_DEFAULT_W, Math.max(FLOAT_MIN_W, vw - 24))
const height = Math.min(FLOAT_DEFAULT_H, Math.max(FLOAT_MIN_H, vh - 24))
const window = clampFloatGeometry(
x - width / 2,
y - height / 2,
width,
height,
)
const next: SidebarState = {
...state,
[key]: node,
floats: [...state.floats, { id: uid('float'), tab, ...window }],
}
// The pane the user was working in may have just collapsed with the tab.
if (emptied && state.activePane === source.id) next.activePane = firstLeaf(next.splits).id
return next
}
/** Move a free window (clamped to the viewport); unknown ids are a no-op. */
export function moveFloat(state: SidebarState, floatId: string, x: number, y: number): SidebarState {
const float = floatById(state, floatId)
if (float === undefined) return state
const geo = clampFloatGeometry(x, y, float.w, float.h)
if (geo.x === float.x && geo.y === float.y) return state
return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, ...geo } : f)) }
}
/** Resize a free window from its SE corner: the top-left corner stays
* anchored, sizes clamp to the floor and to the viewport's remaining room. */
export function resizeFloat(state: SidebarState, floatId: string, w: number, h: number): SidebarState {
const float = floatById(state, floatId)
if (float === undefined) return state
const vw = viewportW()
const vh = viewportH()
const width = Math.round(Math.min(Math.max(w, FLOAT_MIN_W), Math.max(FLOAT_MIN_W, vw - float.x)))
const height = Math.round(Math.min(Math.max(h, FLOAT_MIN_H), Math.max(FLOAT_MIN_H, vh - float.y)))
if (width === float.w && height === float.h) return state
return { ...state, floats: state.floats.map(f => (f.id === floatId ? { ...f, w: width, h: height } : f)) }
}
/** Bring a free window to the top (the array's end). Already topmost (or the
* only window) returns the same reference — no persist churn on every click. */
export function raiseFloat(state: SidebarState, floatId: string): SidebarState {
if (state.floats.length < 2) return state
const index = state.floats.findIndex(f => f.id === floatId)
if (index < 0 || index === state.floats.length - 1) return state
const floats = [...state.floats]
const [raised] = floats.splice(index, 1)
floats.push(raised!)
return { ...state, floats }
}
/** Dock a free window back into a pane (center merge): the tab joins the
* target pane and activates. `toPane` defaults to the active pane with the
* right tree's first leaf as the stale-id fallback (mirrors
* {@link openTabInActivePane}). Unknown window ids are a no-op. */
export function dockFloat(state: SidebarState, floatId: string, toPane?: string): SidebarState {
const float = floatById(state, floatId)
if (float === undefined) return state
let targetId = toPane ?? state.activePane ?? firstLeaf(state.splits).id
if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) {
targetId = firstLeaf(state.splits).id
}
const targetKey = treeOf(state, targetId)
return {
...state,
floats: state.floats.filter(f => f.id !== floatId),
activePane: targetId,
[targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
leaf.tabs = [...leaf.tabs, float.tab]
leaf.active = float.tab.id
}),
}
}
/** Close the free window holding a tab (the tab closes WITH the window —
* the caller fires the descriptor's onClose lifecycle). */
export function closeFloatByTab(state: SidebarState, tabId: string): SidebarState {
if (!state.floats.some(f => f.tab.id === tabId)) return state
return { ...state, floats: state.floats.filter(f => f.tab.id !== tabId) }
}
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
export const AGENT_TAB_PREFIX = 'agent:'
@@ -1049,8 +677,7 @@ export function reconcileAgentTerminals(
state: SidebarState,
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
): SidebarState {
const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
.concat(state.floats.map(float => float.tab))
const existingTabs = 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))
@@ -1064,29 +691,25 @@ export function reconcileAgentTerminals(
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)) && tab.pin === undefined)
if (toAdd.length === 0 && toRemove.length === 0) return state
// Remove tabs whose uuids vanished from the server list (the agent closed
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup;
// a FLOATED agent terminal leaves with its window.
let splits = state.splits
let floats = state.floats
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
let bottomSplits = state.bottomSplits
for (const tab of toRemove) {
const leaf = leafWithTab(splits, tab.id)
const leaf = leafWithTab(bottomSplits, tab.id)
if (leaf !== undefined) {
splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits
}
if (floats.some(float => float.tab.id === tab.id)) {
floats = floats.filter(float => float.tab.id !== tab.id)
bottomSplits = closeTab({ ...state, bottomSplits }, leaf.id, tab.id).bottomSplits
}
}
// Add tabs for new uuids (the agent created a terminal). They land in the
// active pane via openTabInActivePane; the next reconcile is a no-op for them.
let next: SidebarState = { ...state, splits, floats }
// workbench's landing pane via openTabInBottomPane; the next reconcile is a
// no-op for them.
let next: SidebarState = { ...state, bottomSplits }
for (const terminal of toAdd) {
const tab: SidebarTab = {
id: agentTabId(terminal.uuid),
type: 'terminal',
title: terminal.title,
}
next = openTabInActivePane(next, tab)
next = openTabInBottomPane(next, tab)
}
return next
}
@@ -1102,37 +725,6 @@ const STORAGE_PREFIX = 'dsh-sidebar:v1'
* cache-hit session switches, so a drag in one conversation carries to all
* the others (last drag wins).
*/
const GLOBAL_WIDTH_KEY = 'dsh-sidebar:v1:width'
/** Clamp one width to the contract and the current viewport (mirror of {@link setWidth}). */
function clampWidth(width: number): number {
const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
return Math.min(max, Math.max(PANEL_MIN, Math.round(width)))
}
/** Read the cross-session panel width (undefined when never dragged). */
function readGlobalWidth(): number | undefined {
try {
const raw = localStorage.getItem(GLOBAL_WIDTH_KEY)
if (raw !== null) {
const parsed = Number(raw)
if (Number.isFinite(parsed) && parsed > 0) return clampWidth(parsed)
}
} catch {
// Storage unavailable: fall back to the per-session behavior.
}
return undefined
}
/** Persist the cross-session panel width (best-effort, like the session states). */
function writeGlobalWidth(width: number): void {
try {
localStorage.setItem(GLOBAL_WIDTH_KEY, String(width))
} catch {
// Storage full or unavailable: layout memory is best-effort.
}
}
/** Immutable snapshot handed to React (replaced only on real changes). */
export interface SidebarSnapshot {
sessionId: string | undefined
@@ -1145,13 +737,6 @@ export interface SidebarSnapshot {
prefs: SidebarPrefs
}
/** Default panel width for one viewport: the prefs percent of the window,
* clamped to the panel floor (a tiny percent must stay usable) and to the
* viewport (a large one must never cover the whole window). */
export function defaultWidthFor(viewport: number, percent: number): number {
return Math.min(viewport, Math.max(PANEL_MIN, Math.round(viewport * percent / 100)))
}
/**
* URL escape hatch (#369): loading the app with `?dsh-sidebar-reset` drops
* the persisted layout for the session instead of restoring it. When a
@@ -1171,21 +756,15 @@ function resetRequested(): boolean {
}
}
function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
function loadState(sessionId: string): SidebarState {
const reset = resetRequested()
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
if (reset) {
try {
localStorage.removeItem(`${STORAGE_PREFIX}:${sessionId}`)
localStorage.removeItem(GLOBAL_WIDTH_KEY)
} catch {
// Storage unavailable: the default layout below is still the escape.
}
}
// The panel width is a cross-session preference: the last dragged width
// wins over a session's own persisted value, so switching conversations
// keeps the width the user chose anywhere.
const globalWidth = reset ? undefined : readGlobalWidth()
if (!reset) {
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
@@ -1195,38 +774,13 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
// sanitize re-ids any duplicates the pre-seeding counter left behind.
nextIdCounter = maxCounterId(parsed)
const sanitized = sanitizeState(parsed)
if (sanitized !== undefined) {
const restored = globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
// A narrow panel is a full-screen drawer, so every page/session load
// starts it closed even when the prior desktop/mobile state persisted
// `panelOpen: true`. Preserve the entire restored workbench (panes,
// tabs, bottom panel, and free windows); visibility is the sole
// viewport-derived override.
return viewport !== undefined && isNarrowWidth(viewport) && restored.panelOpen
? { ...restored, panelOpen: false }
: restored
}
if (sanitized !== undefined) return sanitized
}
} catch {
// Corrupt or unavailable storage: fall through to the default.
}
}
// New sessions seed from the user's side card prefs: the width is the
// chosen percent of the window (clamped to the panel floor and the
// viewport so a huge percent can never crush the app shell), the panel
// starts open only when the preference says so, and the seed tab is the
// empty files window (tree panel open) in BOTH editorExplorer modes — a
// disabled editor type seeds nothing. On a NARROW viewport a brand-new
// session starts collapsed instead — the panel is a full-screen drawer
// there, and auto-opening it on first paint would cover the conversation
// before the user asked. Persisted layouts follow the same narrow-load
// visibility rule above, while their workbench contents remain intact.
const width = globalWidth ?? (viewport === undefined
? PANEL_DEFAULT
: defaultWidthFor(viewport, prefs.defaultWidthPercent))
const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home'
return makeDefaultState(width, openByDefault, seed)
return makeDefaultState()
}
/**
@@ -1240,8 +794,6 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
export function sanitizeState(parsed: unknown): SidebarState | undefined {
if (parsed === null || typeof parsed !== 'object') return undefined
const record = parsed as Record<string, unknown>
if (typeof record.panelOpen !== 'boolean') return undefined
if (typeof record.width !== 'number' || !Number.isFinite(record.width)) return undefined
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
return undefined
}
@@ -1253,67 +805,36 @@ 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.
// Pane/split ids must be globally unique (the runtime uid counter is
// shared), so a duplicate id gets a fresh one.
const seen = new Set<string>()
const reid = new Map<string, string>()
const restoredSplits = sanitizeNode(record.splits, seen, reid)
if (restoredSplits === undefined) return undefined
const splits = pruneEmptyPanes(restoredSplits)
// 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.
// Workbench 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).
// Cap the persisted height so the conversation column (the agent output
// area) keeps a usable minimum (a stale full-height panel from an older
// build must never squeeze the conversation to zero). The cap is
// setBottomHeight's own contract, or a restored height would snap on the
// first drag.
const maxHeight = typeof window !== 'undefined' ? window.innerHeight : Infinity
const bottomCap = Math.max(BOTTOM_MIN, maxHeight - PANEL_MIN)
const bottomCap = Math.max(BOTTOM_MIN, maxHeight - CONVERSATION_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 = pruneEmptyPanes(sanitizeNode(record.bottomSplits, seen, reid)
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null })
// Free windows arrived with the float feature: a missing field on an older
// persisted state defaults to none (like the bottom-panel fields above).
// Malformed ENTRIES drop individually — unlike the split trees, where
// corruption resets the whole state, a bad window must not cost the user
// their whole layout. Geometry clamps into the CURRENT viewport so a stale
// off-screen window stays reachable; the window ids join the same
// uniqueness set as the pane/split ids (the uid counter mints for both).
const floats: FloatWindow[] = []
if (Array.isArray(record.floats)) {
for (const entry of record.floats) {
if (entry === null || typeof entry !== 'object') continue
const candidate = entry as Record<string, unknown>
if (typeof candidate.id !== 'string' || seen.has(candidate.id)) continue
const tab = sanitizePersistedTab(candidate.tab)
if (tab === undefined || tab === 'diff') continue
if (
typeof candidate.x !== 'number' || !Number.isFinite(candidate.x)
|| typeof candidate.y !== 'number' || !Number.isFinite(candidate.y)
|| typeof candidate.w !== 'number' || !Number.isFinite(candidate.w)
|| typeof candidate.h !== 'number' || !Number.isFinite(candidate.h)
) continue
seen.add(candidate.id)
floats.push({ id: candidate.id, tab, ...clampFloatGeometry(candidate.x, candidate.y, candidate.w, candidate.h) })
}
}
const requestedActivePane = typeof record.activePane === 'string'
? (reid.get(record.activePane) ?? record.activePane)
: null
const activePane = requestedActivePane === null
? null
: treeHasId(splits, requestedActivePane) || treeHasId(bottomSplits, requestedActivePane)
: allLeaves(bottomSplits).some(leaf => leaf.id === requestedActivePane)
? requestedActivePane
: firstLeaf(splits).id
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
: firstLeaf(bottomSplits).id
return {
panelOpen: record.panelOpen,
width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
// A stale duplicate pane id may have been re-ided; follow the rename so
// new tabs still land in the pane the user was using.
activePane,
@@ -1321,7 +842,6 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
nextBrowser,
expanded: record.expanded as string[],
revealed: [],
splits,
bottomOpen,
bottomHeight,
// An older persisted state never expanded the bottom panel (the field
@@ -1329,7 +849,6 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
// auto-terminal exactly once after the upgrade.
bottomOpenedOnce: record.bottomOpenedOnce === true,
bottomSplits,
floats,
}
}
@@ -1520,20 +1039,13 @@ export class SidebarStore {
} else {
let state = this.bySession.get(sessionId)
if (state === undefined) {
state = loadState(sessionId, this.prefs)
state = loadState(sessionId)
this.bySession.set(sessionId, state)
} else {
// Cache hit: another session's load/ops may have left the uid
// counter below THIS session's persisted ids — re-seed so fresh
// pane/split ids can never collide with its tree.
nextIdCounter = maxCounterId(state)
// The panel width is cross-session: adopt the latest dragged width
// (a cached session keeps its own layout otherwise).
const globalWidth = readGlobalWidth()
if (globalWidth !== undefined && state.width !== globalWidth) {
state = { ...state, width: globalWidth }
this.bySession.set(sessionId, state)
}
}
this.snapshot = { sessionId, state, prefs: this.prefs }
}
@@ -1624,7 +1136,7 @@ export class SidebarStore {
const counterBefore = nextIdCounter
let state = this.bySession.get(sessionId)
if (state === undefined) {
state = loadState(sessionId, this.prefs)
state = loadState(sessionId)
this.bySession.set(sessionId, state)
} else {
// Re-seed the uid counter past THIS session's persisted ids, exactly
@@ -1641,14 +1153,6 @@ export class SidebarStore {
}
private schedulePersist(sessionId: string, state: SidebarState): void {
// Keep the cross-session width in sync: any width change (drag, fullscreen
// toggle) on the ACTIVE session becomes the shared width for every
// conversation. A targeted open persists an INACTIVE session (reduceFor)
// and must not clobber that global — its width is stale by definition,
// so writing it would break "last drag wins".
if (sessionId === this.snapshot.sessionId) {
writeGlobalWidth(state.width)
}
// Per-session debounce timers: one session's pending write must never
// cancel another's (targeted opens schedule writes for INACTIVE
// sessions while the active session may already have one pending —
+3 -6
View File
@@ -1,8 +1,8 @@
/**
* Open-tab reconciliation after a file-tree mutation (rename/delete).
*
* The tree owns the rows; the TABS live in the sidebar state (either split
* tree, the bottom panel, or free windows). A rename must retarget every
* The tree owns the rows; the TABS live in the sidebar state (the bottom
* workbench's split tree). A rename must retarget every
* tab whose `path` is the renamed file (the editor content survives and
* later saves land on the new path); a delete must close every tab at or
* under the removed path — files and anything inside a removed directory
@@ -16,15 +16,12 @@ import { baseName } from './FileTree.tsx'
import { allLeaves, type SidebarSnapshot, type SidebarStore, type SidebarTab } from './state.ts'
import { isWithinWorkspace } from './paths.ts'
/** Every open tab that carries a file path (either split tree, the bottom
* panel, and free windows — a floating tab is as open as a docked one). */
/** Every open tab that carries a file path. */
function pathTabsOf(snapshot: SidebarSnapshot): SidebarTab[] {
const state = snapshot.state
if (state === undefined) return []
const tabs: SidebarTab[] = []
for (const leaf of allLeaves(state.splits)) tabs.push(...leaf.tabs)
for (const leaf of allLeaves(state.bottomSplits)) tabs.push(...leaf.tabs)
for (const float of state.floats) tabs.push(float.tab)
return tabs.filter(tab => tab.path !== undefined)
}
-9
View File
@@ -13,9 +13,6 @@ import {
TITLE_BAR_STRIP_DEFAULT,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
type SidebarPrefs,
} from './prefs-shared.ts'
@@ -28,9 +25,6 @@ export {
TITLE_BAR_STRIP_DEFAULT,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
type SidebarPrefs,
} from './prefs-shared.ts'
@@ -115,8 +109,6 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
export const PrefsSchema: z<SidebarPrefs> = z.object({
openByDefault: z.boolean().default(false),
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
autoOpenSubagent: z.boolean().default(true),
autoOpenJobs: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false),
@@ -125,7 +117,6 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
terminalFontFamily: z.string().default(''),
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
editorExplorer: z.boolean().default(false),
changesDiffFloat: z.boolean().default(true),
workspaceFence: z.boolean().default(true),
terminalShell: z.string().default(''),
terminalShellArgs: z.string().default(''),
+3 -31
View File
@@ -9,24 +9,17 @@
/** The user-settings namespace holding the side card preferences. */
export const SIDEBAR_PREFS_NS = 'dsh-better-sidebar'
/** User-facing side card preferences (new-conversation defaults). */
/** User-facing side card preferences. */
export interface SidebarPrefs {
/** Whether a brand-new conversation opens the side card by default. */
openByDefault: boolean
/** Default panel width as a percent of the window width (20–60). */
defaultWidthPercent: number
/**
* Whether the sidebar auto-activates the Tasks page when the current
* conversation spawns a new subagent. Wide viewports also open the panel;
* narrow viewports prepare the tab without opening the full-screen drawer.
* conversation spawns a new subagent.
*/
autoOpenSubagent: boolean
/**
* Whether the sidebar auto-activates the Tasks page containing the
* background-jobs section when a NEW job appears for the current
* conversation (any new job id, not just the first one). Wide viewports also
* open the panel; narrow viewports prepare the tab without opening the
* full-screen drawer.
* conversation (any new job id, not just the first one).
*/
autoOpenJobs: boolean
/**
@@ -72,14 +65,6 @@ export interface SidebarPrefs {
* Side card settings; off restores the pre-merge editor exactly.
*/
editorExplorer: boolean
/**
* Where the changes tab's "expand to a diff tab" action lands the diff:
* true (default) floats it as a free window centered on the viewport;
* false docks it into the shell's diff pane below the source panel (the
* pre-float behavior). The select lives under the changes card's gear in
* the Side card settings.
*/
changesDiffFloat: boolean
/**
* Whether the sidebar's filesystem routes enforce the workspace fence:
* every client-supplied path must resolve (through symlinks) inside the
@@ -238,11 +223,6 @@ export interface SidebarPrefs {
pluginSettings: Record<string, Record<string, unknown>>
}
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
export const WIDTH_PERCENT_MIN = 20
export const WIDTH_PERCENT_MAX = 60
export const WIDTH_PERCENT_DEFAULT = 35
/** Range contract of {@link SidebarPrefs.terminalFontSize}. */
export const TERMINAL_FONT_SIZE_MIN = 9
export const TERMINAL_FONT_SIZE_MAX = 32
@@ -259,8 +239,6 @@ export type TitleBarScheme = typeof TITLE_BAR_SCHEMES[number]
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: false,
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false,
@@ -269,7 +247,6 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
terminalFontFamily: '',
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
editorExplorer: false,
changesDiffFloat: true,
workspaceFence: true,
terminalShell: '',
terminalShellArgs: '',
@@ -290,11 +267,6 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
pluginSettings: {},
}
/** Clamp one width percent into the contract range (shared by schema and client reads). */
export function clampWidthPercent(value: number): number {
return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value)))
}
/** Clamp one terminal font size into the contract range (shared by schema and client reads). */
export function clampTerminalFontSize(value: number): number {
return Math.min(TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value)))