Files
apes-sidebar/src/client/Sidebar.tsx
T
figmar 5a7e2139a0 chore(sync): re-base the fork onto upstream dsh-better-sidebar v0.19.1
The fork was pinned at upstream v0.18.0. That is the entire reason the window
ran TWO sidebars and TWO near-identical panel toggles: DSH 2.0.10 ships its own
right sidebar (@deepseek-ai/dsh-client-ui-sidebar-right), and upstream only
adopted it in v0.19.0-alpha.1 — which arrived after our fork point. v0.19.1 is
also the one upstream line whose support range actually matches our host
(engines.dsh >=0.1.5-rc.1, 14 DSH peers ^0.1.5-rc.1, host = 0.1.5-rc.2), so it
is the only tag the host's startup precheck accepts.

What upstream v0.18.0 → v0.19.1 brings
- src/client/native/{index,surface,tab-adapter}.tsx: every tab the plugin can
  draw becomes an OFFICIAL native tab type + body, and an external plugin's
  descriptor registered later gets its native type too. This is what retires
  the fork's own right panel — and with it the duplicate toggle cluster
  (`toggleCluster` is gone from v0.19.1).
- native tab-chip icons via the `sidebar.right.pane.tab.title` seat, coloured
  built-in tab icons, file/folder glyphs from DSH's own `FileTypeIcon`.
- guide descriptions (DSH renders them while the guide lists <= 4 entries),
  tab bodies that fill the panel, and the 0.1.5-rc.2 adaptation work we were
  carrying by hand (peer/devDep pinning, primitives' transitive devDeps).

How the sync was done
Rebuilt the tree from the v0.19.1 tag rather than resolving 53 conflicted
files: our v0.18.0 delta was measured first and is only (a) one mechanical
rename `dsh-better-sidebar` -> `dsh-apes-sidebar` and (b) ten real edits. So:
tag tree + rename (scoped: code/config/scripts/tests/README, while the 46
historical docs/plans records and switch.sh's upstream package name stay
untouched) + re-applied deltas + our apes module.

Fork deltas re-applied on the new base
- src/client/apes/ (icons / locales / tabs): the three apes:* information-layer
  tabs (agents / apps / profile), registered through the same BetterSidebarService
  as the builtins — which is exactly why they now ride upstream's native adapter
  into the official sidebar for free.
- identity: package.json (name/version 1.2.0/description/forkedFrom), dsh.plugin.json
  (id/version), SIDEBAR_SERVICE_VERSION 1.2.0, README fork notice,
  docs/external-plugin-guide.md.
- scripts/switch.sh + the pnpm switch* scripts (apes <-> upstream two-line switch).
- tests/apes-tabs.spec.ts (unescaped: it starts from the v0.19.1 base).

Dropped
- the Phase A native-registration spike (`apes/native-tabs.tsx` + its spec): it
  re-derived by hand exactly what upstream's native layer already does — the
  hand-written bridge is dead weight now, and v0.19.1's own e2e/jsdom lanes
  cover the same seat contract far better.
- the unused apesProfileGuideDesc key and the two package.json.bak.* backups.

Verified on the new base: typecheck clean; build clean; 1351 passed / 9 skipped
(128 files), up from 1255 on the old base.
2026-10-03 22:51:09 +08:00

833 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The sidebar shell: ONE surface — the bottom workbench docked to the
* conversation column.
*
* DSH 0.1.5 owns the right column: its native Sidebar hosts every tab type
* this plugin registers (client/native/), so this shell renders no right
* panel of its own. What remains plugin-owned is the bottom workbench: it
* spans ONLY the AppFrame's center column (the agent output area), from the
* app shell's own left sidebar to the details column's left edge, so neither
* sidebar gives up any position. Its height drags from its top edge, and the
* expand/collapse control is a header button registered into DSH's
* `conversation.session.header.utilities` list slot
* (sidebar/bottom-toggle.tsx) — the header's right corner belongs to the
* native sidebar's own expand control.
*
* The panel is mounted inside the unified panel host — a fixed,
* viewport-sized containing block ([data-dsh-panel-host]) appended to
* document.body — instead of a fixed-position element, so a desktop shell's
* intermediate wrapper transforms can never hijack its containing block.
* The whole layout lives in the per-session store, so switching
* conversations swaps the workbench.
*
* The shell binds the workbench actions to the store and dispatches tab
* content to the views. New tabs come from the + menu (explorer / git /
* terminal; editors open from the explorer).
*/
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { referenceInChat as referenceInChatShared } from './reference-in-chat.ts'
import {
BOTTOM_MIN, CONVERSATION_MIN, agentUuidOf, firstLeaf, isAgentTabId,
leafWithTab, moveTab, moveTabToEdge, openDiffTab, resizeSplitIn,
setBottomHeight, setTabPin, toggleBottomPanel, toggleExpanded,
type DropZone, type SidebarStore, type SidebarTab,
} from './state.ts'
import { getPinnedHomeScope } from './pinned.ts'
import { IconPanelBottomOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { useViewportSize } from './breakpoints.ts'
import { bottomPushHeight } from './layout-push.ts'
import { parseDesktopEnv } from './desktop-env.ts'
import { getWcoSnapshot, subscribeWco } from './wco.ts'
import { getShellPreset } from './shell-presets.ts'
import { computeTitleBarStrip } from './titlebar-strip.ts'
import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
import { useCenterColumn } from './sidebar/use-center-column.ts'
import { useHostFeeds } from './sidebar/use-host-feeds.ts'
import { usePinnedTabs } from './sidebar/use-pinned-tabs.ts'
import type { TabDragPayload } from './TabBar.tsx'
import { t } from './locales.ts'
import { api } from './api.ts'
import css from './sidebar.module.css'
/**
* OS file drags over the sidebar belong to the sidebar, not to the chat:
* DSH's composer (InputBar) listens for file drags on the DOCUMENT and
* answers with a full-screen "drop image here" mask plus image intake on
* drop. The panel host swallows the whole event quartet —
* enter/over/leave/drop — so the region is a black hole to that document
* listener. All four must be stopped: InputBar keeps an enter/leave depth
* counter, and a leave that escapes without its matching enter unbalances
* the count (this was the full-screen mask flickering over the sidebar).
* The conversation column keeps DSH's native overlay and intake untouched;
* gated on the 'Files' type so in-app drags (tab reorder, split zones)
* propagate exactly as before.
*/
const swallowOsFileDrag = (event: ReactDragEvent): void => {
if (!(event.dataTransfer?.types.includes('Files') ?? false)) return
event.preventDefault()
event.stopPropagation()
}
/** The four drag events a file drag must never carry past the panel host. */
const osFileDragShield = {
onDragEnter: swallowOsFileDrag,
onDragOver: swallowOsFileDrag,
onDragLeave: swallowOsFileDrag,
onDrop: swallowOsFileDrag,
}
/**
* Append one user-space stylesheet (preset or custom CSS) as a tagged
* `<style>` element. The tag attribute carries the source identity so the
* running configuration is inspectable in DevTools; the returned tag is
* removed by the caller's effect cleanup.
*/
function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElement {
const tag = document.createElement('style')
tag.setAttribute(attr, id)
tag.textContent = cssText
document.head.appendChild(tag)
return tag
}
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const { ctx, store } = props
// Copy freshness: re-render the whole tree when the DSH locale switches.
// The module-level t() reads the active locale at call time, so a root
// re-render alone re-localizes every panel (no memo barriers below).
const localeRevision = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.locale.subscribe(callback), [ctx]),
useCallback(() => ctx.locale.getSnapshot().active, [ctx]),
)
void localeRevision
// better-locale override freshness: when @huanlin/dsh-plugin-better-locale
// is installed and the user picks an override language (e.g. ja), the
// store's `active` changes but the DSH locale's `active` does NOT —
// better-locale keeps the dsh active value (zh/en) unchanged and only
// patches `LocaleRuntime.prototype.lookup`. The localeRevision uSES
// above reads `getSnapshot().active`, so it sees no change and skips
// re-render. This second uSES reads the better-locale store's `active`
// directly, so an override switch fires a full re-render and t() picks
// up the new override text. Optional: ctx.get returns undefined when
// better-locale is absent (or when ctx is a minimal test mock without
// a `get` method), in which case this is a no-op uSES.
type BetterLocaleStore = {
readonly active: string | undefined
subscribe(listener: () => void): () => void
}
const betterLocaleStore = typeof ctx.get === 'function'
? (ctx as unknown as {
get(name: 'betterLocale'): BetterLocaleStore | undefined
}).get('betterLocale')
: undefined
const betterLocaleActive = useSyncExternalStore(
useMemo(() => {
const store = betterLocaleStore
if (store === undefined) return (_cb: () => void) => () => {}
return (callback: () => void) => store.subscribe(callback)
}, [betterLocaleStore]),
useMemo(() => {
const store = betterLocaleStore
if (store === undefined) return () => undefined
return () => store.active
}, [betterLocaleStore]),
)
void betterLocaleActive
// Tab-registry revision: TabContent memo cells must pick up a descriptor
// a plugin registers/disposes after mount (the + menu / icons already read
// the registry at render). Rare events (plugin (un)mount), so one full
// re-render per change is fine — this is what keeps the memoized cells
// from going stale, mirroring the localeRevision mechanism above.
const [tabsVersion, setTabsVersion] = useState(0)
useEffect(() => {
const service = ctx.get('betterSidebar')
if (service === undefined) return
return service.subscribe(() => setTabsVersion(version => version + 1))
}, [ctx])
const viewport = useViewportSize()
// On-screen keyboard / visual-viewport inset (mobile, split-screen, …):
// when the visual viewport shrinks below the layout viewport, bottom-
// anchored panels would hide under the keyboard. Track the inset and
// offset the bottom-anchored surfaces by it. The obscured bottom strip is
// innerHeight − (vv.height + vv.offsetTop): offsetTop is nonzero while
// the visual viewport is scrolled/zoomed under browser chrome, so
// omitting it would over-lift the panel (CR #232 P2). offsetTop changes
// through the viewport's scroll event too, so both events are listened.
// Guarded: browsers without visualViewport (older WebViews, jsdom) stay
// at 0. rAF-throttled, same pattern as useNarrowViewport.
const [keyboardInset, setKeyboardInset] = useState(0)
const [visualViewportHeight, setVisualViewportHeight] = useState<number | null>(null)
useEffect(() => {
const vv = window.visualViewport
if (vv === null || vv === undefined) return
let frame: number | null = null
const measure = (): void => {
frame = null
const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop))
setKeyboardInset(inset > 1 ? Math.round(inset) : 0)
setVisualViewportHeight(Math.max(0, Math.round(vv.height)))
}
const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) }
vv.addEventListener('resize', onResize)
vv.addEventListener('scroll', onResize)
measure()
return () => {
vv.removeEventListener('resize', onResize)
vv.removeEventListener('scroll', onResize)
if (frame !== null) cancelAnimationFrame(frame)
}
}, [])
// The bottom workbench is offset above the on-screen keyboard. Cap its
// height against that same visible area, not the taller layout viewport,
// so the conversation keeps CONVERSATION_MIN even on wide touch devices.
const layoutViewportHeight = visualViewportHeight ?? viewport.height
// Current conversation (the sessions list feed).
const sessionList = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
)
const current = sessionList.current
// Per-session sidebar state.
const snapshot = useSyncExternalStore(
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot(), [store]),
)
useEffect(() => { store.setSession(current) }, [current, store])
const state = snapshot.state
const sessionId = snapshot.sessionId
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
// Title-bar / shell compatibility (the "位置兼容模式" scheme):
// auto — CONSERVATIVE: only the standard Window Controls Overlay
// geometry contributes (the real caption-overlay height,
// reactive to maximize/restore). No URL stamp, no preset, no
// guess — plain browsers see zero modification.
// preset — an opt-in built-in shell preset (shell-presets.ts) adds its
// per-shell strip as the no-WCO fallback.
// custom — the user's own CSS (injected below) + the legacy manual
// strip px.
// The resolved strip drives the SAME body attribute + CSS variable as the
// legacy boolean did, so the CSS contract is unchanged (layout.css /
// sidebar.module.css); only the value source changed. The cleanup removes
// both on unmount/boundary swap so a crashed sidebar never leaves them
// behind.
const desktopEnv = parseDesktopEnv()
const wco = useSyncExternalStore(
useMemo(() => subscribeWco, []),
getWcoSnapshot,
)
const scheme = snapshot.prefs.titleBarScheme
const preset = scheme === 'preset' ? getShellPreset(snapshot.prefs.titleBarPresetId) : undefined
const titleBarStrip = computeTitleBarStrip(
desktopEnv, wco, scheme, preset, snapshot.prefs.titleBarStripPx,
)
const titleBarCompat = titleBarStrip > 0
useEffect(() => {
const root = document.documentElement
if (titleBarCompat) {
document.body.setAttribute('data-dsh-title-bar-compat', '')
root.style.setProperty('--dsh-title-bar-strip', `${titleBarStrip}px`)
} else {
document.body.removeAttribute('data-dsh-title-bar-compat')
root.style.removeProperty('--dsh-title-bar-strip')
}
return () => {
document.body.removeAttribute('data-dsh-title-bar-compat')
root.style.removeProperty('--dsh-title-bar-strip')
}
}, [titleBarCompat, titleBarStrip])
// User-space CSS injection (the escape hatch): preset CSS (scheme
// `preset`) and free-form custom CSS (scheme `custom`) are appended AFTER
// the plugin's own styles — later in the cascade wins ties, and
// `!important` can override the JS-written inline strip variable. Each
// source gets its own tagged <style> so the running configuration stays
// inspectable; tags are removed on change/unmount so a stale stylesheet
// never outlives its fiber (HMR-safe).
const presetCss = scheme === 'preset' ? preset?.css ?? '' : ''
const customCss = scheme === 'custom' ? snapshot.prefs.customCss : ''
useEffect(() => {
const tags: HTMLStyleElement[] = []
if (presetCss !== '') tags.push(injectUserCss('data-dsh-preset-css', preset?.id ?? '', presetCss))
if (customCss !== '') tags.push(injectUserCss('data-dsh-custom-css', 'custom', customCss))
return () => { for (const tag of tags) tag.remove() }
}, [presetCss, customCss, preset?.id])
// While the session's header is still hydrating (or the session is blank),
// the list summary may carry no cwd; ask the host once (it falls back to
// the process cwd) so the explorer root and terminal cwd are real from
// first paint instead of showing "no session".
const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
useEffect(() => {
setFetchedCwd(undefined)
if (sessionId === undefined || summaryCwd !== undefined) return
let cancelled = false
api.sessionCwd({ sessionId })
.then(result => { if (!cancelled) setFetchedCwd(result.cwd) })
.catch(() => { /* the explorer/git rows surface their own errors */ })
return () => { cancelled = true }
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
// The + menu options ride a memo so the workbench does not rebuild the
// array identity across renders that did not change the store (drag state,
// viewport resize): fresh arrays per render re-rendered every LeafView's
// + affordance whether or not anything tab-related moved.
const newTabOptions = useMemo(
() => (state === undefined || sessionId === undefined ? [] : buildNewTabOptions(state, ctx, { sessionId, cwd })),
// state is the whole session state — every field it wraps is fair game
// for the descriptors' available() callbacks. (The render's own guard
// sits below every hook; this memo must handle the no-session case
// itself.)
[state, ctx, sessionId, cwd],
)
// Host feeds (sidebar/use-host-feeds.ts): the agent-terminals / agent-opens
// WebSocket pushes and the subagent / background-job auto-activation
// triggers, all keyed on the current session. The jump-back ref is the one
// piece the render side consumes (renderTab's onSubagentJump arms it).
const { subagentJumpRef } = useHostFeeds({ ctx, store, sessionList, sessionId })
// Center-column tracking (sidebar/use-center-column.ts): the bottom
// workbench spans ONLY the app shell's center column ("squeezes the agent
// output area"); the hook locates the AppFrame's center column DOM (host
// anchor observers + slow retry), measures its edges into a ref, and
// writes them straight to the bottom panel element — per-frame tracking
// without re-rendering the shell (the comments live with the hook now).
const bottomRef = useRef<HTMLDivElement | null>(null)
const { centerRectRef, centerMeasured, measureCenter, draggingRef } = useCenterColumn(bottomRef, state?.bottomOpen)
/**
* Bottom-panel first-expansion auto terminal: the FIRST time the user
* expands the bottom panel in a session, try to open a fresh terminal tab
* there. "Try" is literal — the terminal's own quota and enable switch
* gate the attempt (a full quota or a disabled terminal type makes it a
* no-op). Gated on the bottomPanelAutoTerminal pref (the terminal tab's
* nested settings toggle, default on). Only a false→true TRANSITION fires
* (a panel persisted open never counts as an expansion), and the session's
* bottomOpenedOnce flag is set atomically with the first fire so later
* expansions never repeat it.
*/
const bottomWasOpenRef = useRef<boolean | undefined>(undefined)
useEffect(() => {
if (state === undefined) return
const wasOpen = bottomWasOpenRef.current
bottomWasOpenRef.current = state.bottomOpen
if (wasOpen === undefined || wasOpen || !state.bottomOpen) return
if (state.bottomOpenedOnce) return
if (store.getPrefs().bottomPanelAutoTerminal === false) return
if (ctx.get('betterSidebar')?.isTabEnabled('terminal') === false) return
// Land the tab in the bottom panel's first pane; the once-flag is set
// atomically so later expansions never repeat the auto-open.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
ctx.get('betterSidebar')?.openTab({ type: 'terminal', target: 'bottom' })
}, [state, store, ctx])
// The bottom panel's height drag (top edge strip). Drags write the size
// DIRECTLY to the DOM (panel style + the layout CSS variable) instead of
// round-tripping the store on every pointer move — a store reduce
// re-renders the workbench (terminals, editors…) per move, which is the
// visible drag lag. The store is committed once on pointer up
// (clamping + persistence).
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
const [draggingBottom, setDraggingBottom] = useState(false)
// Pause center-column measurement while dragging, and re-measure once the
// drag settles at its committed size. The store commit lands on release and
// the final height equals the last drag height, so no ResizeObserver event
// fires to refresh centerRect — this explicit re-measure covers that gap.
useEffect(() => {
draggingRef.current = draggingBottom
if (!draggingBottom) measureCenter()
// draggingRef is a stable ref object from useCenterColumn (same
// provenance note as the hook's own comments).
}, [draggingBottom, measureCenter, draggingRef])
// Clamp mirror of setBottomHeight for mid-drag values (the store re-clamps
// on commit; this keeps the panel from overshooting mid-drag).
const clampHeight = (height: number): number =>
Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - CONVERSATION_MIN))
/** Single writer for the layout-push variable: the app shell gives up the
* workbench's height while it is open (0 while collapsed) through
* layout.css's margin on the center column. Every size change — drag
* frames and committed state — flows through here so the push never
* forks between paths. The right column is DSH's native Sidebar, so no
* width is written. */
const writeGeometry = (height: number): void => {
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
}
/** Last height a drag actually applied to the DOM (updated by applyDrag).
* When a pointer stream dies without any position info (issue #247: an
* ultra-fast flick whose release events carried no usable coordinates),
* the abort path adopts this instead of rolling back to the pre-drag
* value — the DOM's current size is the only truthful record left. */
const lastDragHeight = useRef<number | null>(null)
/** Apply a drag height to the DOM without touching React state or the
* store. The layout push rides the shared writer (writeGeometry). */
const applyDrag = (height: number): void => {
lastDragHeight.current = height
bottomRef.current?.style.setProperty('height', `${height}px`)
const push = state?.bottomOpen === true ? height + keyboardInset : 0
writeGeometry(push)
}
// Drags write at most once per frame: pointer events fire several times
// faster than the display refresh, and each write reflows the app shell
// (the layout push) plus the panel — batching to one write per frame is
// what keeps the drag smooth. The store is still committed once on release.
const dragFrame = useRef<number | null>(null)
const pendingDrag = useRef<number | null>(null)
const scheduleDrag = (height: number): void => {
pendingDrag.current = height
if (dragFrame.current !== null) return
dragFrame.current = requestAnimationFrame(() => {
dragFrame.current = null
const pending = pendingDrag.current
if (pending !== null) {
pendingDrag.current = null
applyDrag(pending)
}
})
}
/** Flush any pending drag write and stop scheduling (the store commit on
* pointer up applies the final clamped values). */
const stopDragScheduling = (): void => {
if (dragFrame.current !== null) {
cancelAnimationFrame(dragFrame.current)
dragFrame.current = null
}
pendingDrag.current = null
}
/**
* Finalize the drag on pointer up: flush the LAST drag frame to the DOM
* synchronously, then commit the SAME clamped value to the store. A fast
* release cancels the rAF before it ran — without the flush the DOM would
* sit at the pre-drag size until React re-renders with the committed
* value, and a value that never made it into a move handler would never
* be applied at all. The measurement pause ends here too: the center
* column is re-measured BEFORE the committed re-render lands, so the
* panel's React-rendered edges already reflect the new height.
*/
const commitDrag = (height: number): void => {
stopDragScheduling()
applyDrag(height)
draggingRef.current = false
measureCenter()
store.reduce(s => setBottomHeight(s, height))
}
/** Set once a drag's pointerup handler commits — premature capture loss
* (pointercancel / lostpointercapture without pointerup) must then be told
* apart from a normal release. */
const dragCommitted = useRef(false)
/**
* Abort a drag whose pointer stream was interrupted (pointercancel, or
* capture lost before pointerup): no pointerup will arrive, so without
* this the dragging state would stick true and center-column measurement
* would stay paused forever — the panel freezes at stale edges and stops
* tracking layout changes.
*
* A FAST release is the common trigger: browsers merge pointermove bursts,
* and an ultra-fast flick can cancel the stream before ANY move lands.
* The commit order is therefore: the LAST KNOWN dragged size (the rAF
* pending value) first, then the interrupting event's own pointer
* position (only pointercancel is trusted to carry coordinates —
* lostpointercapture's coordinates are not guaranteed, so the handlers
* pass the event only from pointercancel), and finally the size the drag
* last APPLIED to the DOM (lastDragHeight). A drag that produced none of
* those (pure down+up at the same spot) commits the store's own height —
* a no-op, never an explicit rollback (issue #247).
*
* Every commit path marks the drag committed, so the interrupt
* double-fire (pointercancel → lostpointercapture) cannot commit once
* and then roll the same drag back.
*/
const abortDrag = (reset: () => void, event?: { clientY: number }): void => {
if (dragCommitted.current) return
const pending = pendingDrag.current
let height: number | undefined
if (pending !== null) {
height = pending
} else if (event !== undefined) {
// No move ever landed: the cancel position is all we have — commit it
// (clamped) instead of rolling back the flick.
height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))
}
if (height !== undefined) {
dragCommitted.current = true
stopDragScheduling()
applyDrag(height)
draggingRef.current = false
measureCenter()
store.reduce(s => setBottomHeight(s, height))
} else {
// No pending write and no usable event coordinates: keep the height the
// drag last applied instead of rolling back to the pre-drag value (the
// flick's moves may have been consumed by the rAF just before the
// stream died — the DOM already shows the dragged size).
dragCommitted.current = true
stopDragScheduling()
const adopted = clampHeight(lastDragHeight.current ?? state?.bottomHeight ?? BOTTOM_MIN)
applyDrag(adopted)
draggingRef.current = false
measureCenter()
store.reduce(s => setBottomHeight(s, adopted))
}
reset()
}
// Layout push: the app shell gives up the workbench's height while it is
// open (0 while collapsed), so the conversation and input bar are squeezed
// instead of covered. The margin is capped at the viewport so a stale
// persisted height (e.g. fullscreen on a bigger window) can never crush the
// app shell to zero. Dragging disables the layout transition.
useEffect(() => {
const height = bottomPushHeight({
open: snapshot.state?.bottomOpen === true,
height: snapshot.state?.bottomHeight ?? 0,
viewportHeight: layoutViewportHeight,
})
writeGeometry(snapshot.state?.bottomOpen === true ? height + keyboardInset : 0)
}, [snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, layoutViewportHeight, keyboardInset])
// Unmount must release the push (issue #31): when the boundary swaps the
// whole sidebar after a render crash (or the plugin fiber is disposed /
// HMR), the CSS variable would otherwise stay on <html> and layout.css
// keeps squeezing #root with a stale margin — "the sidebar cannot be
// hidden" until a full reload. This lives in an UNMOUNT-ONLY effect, NOT
// in the push effect's cleanup: React can yield between a passive
// effect's cleanup and setup phases, and removing the variable on a
// dependency change used to paint the push-less layout for a frame.
useEffect(() => {
return () => {
document.documentElement.style.removeProperty('--dsh-sidebar-height')
}
}, [])
useEffect(() => {
if (draggingBottom) document.body.setAttribute('data-dsh-sidebar-dragging', '')
else document.body.removeAttribute('data-dsh-sidebar-dragging')
}, [draggingBottom])
const actions: WorkbenchActions = useMemo(() => ({
closeTab: (paneId, tabId) => {
// A closed terminal releases its pty immediately — including when its
// socket is mid-reconnect, where the unmount close frame never reaches
// the host and the process would hold the quota until the grace ends.
// Agent terminals (tabId `agent:<uuid>`) close through a different
// host route: the WS close frame is the primary path (sent by
// TerminalView on unmount), and the agent-pty.close HTTP route is the
// fallback when the WS is down.
const current = store.getSnapshot().state
const leaf = current === undefined ? undefined : leafWithTab(current.bottomSplits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
// Route through the service: the tab-bar close is the canonical close
// path (finds the pane itself, fires descriptor.onClose); the session
// scope (with its cwd) rides to the callback.
ctx.get('betterSidebar')?.closeTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
if (tab?.type === 'terminal') {
if (isAgentTabId(tabId)) {
const uuid = agentUuidOf(tabId)
void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
} else if (sessionId !== undefined) {
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
}
}
},
activateTab: (paneId, tabId) => {
// Route through the service: same reducer (finds the pane, sets the
// active pane) and fires descriptor.onActivate; the session scope
// (with its cwd) rides to the callback.
ctx.get('betterSidebar')?.activateTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
},
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
},
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
store.reduce((s) => {
let index = -1
const source = leafWithTab(s.bottomSplits, beforeTabId)
if (source !== undefined && source.id === toPane) {
index = source.tabs.findIndex(tab => tab.id === beforeTabId)
}
return moveTab(s, payload.paneId, payload.tabId, toPane, index)
})
},
resizeSplit: (splitId, index, deltaFrac) => {
store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
},
// Pin/unpin a terminal tab (v0.17.0+): the home cwd is snapshotted at
// pin time so a workspace-scoped pin only resurfaces in sessions whose
// cwd matches. Unpin passes null — the tab stays open in its home
// session, just unmarked.
pinTab: (tabId, scope) => {
store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd }))
},
}), [store, sessionId, cwd, ctx])
// Pinned virtual tabs (sidebar/use-pinned-tabs.ts): cross-session pinned
// tabs inject into the bottom workbench's first leaf, and the actions are
// wrapped so pinned virtual ids route to the HOME session (reduceFor +
// revision bump).
const { augmentedTree, wrappedActions } = usePinnedTabs({ store, sessionId, cwd, snapshot, actions })
/**
* The explorer's @-reference button. Directories append the folder mention
* (`@dir/`) as plain text so DSH's folder decoration and completion keep
* working; files insert a structured chip like the native `@` picker, so
* the whole reference stays one link instead of decorating only the
* leading folder. Resolves the session-scope ctx and the conversation
* input service at click time; a missing service or scope degrades to a
* logged no-op, never a crash. Defined above the no-session early return
* — a hook must never sit behind a conditional return (React counts hooks
* per render).
*/
const referenceInChat = useCallback((path: string, isDir: boolean): void => {
if (sessionId === undefined) return
referenceInChatShared(ctx, sessionId, cwd, path, isDir)
}, [ctx, sessionId, cwd])
if (state === undefined || sessionId === undefined) {
// No conversation yet: the host stays mounted (the drag shield keeps
// covering the region) but nothing is rendered — the toggle button lives
// in DSH's session header, which does not exist without a session.
return <div data-dsh-panel-host {...osFileDragShield} />
}
const bottomPanelHeight = bottomPushHeight({
open: true,
height: state.bottomHeight,
viewportHeight: layoutViewportHeight,
})
const onNewTab = (optionId: string): void => {
const service = ctx.get('betterSidebar')
const descriptor = service?.getTab(optionId)
if (service === undefined || descriptor === undefined) return
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
// The session scope rides along: lifecycle callbacks receive it. The
// open is bottom-targeted so the tab lands in THIS workbench, not in the
// native right Sidebar.
service.openTab({ type: optionId, title, target: 'bottom' }, { sessionId, cwd })
}
/**
* The tab icon from the tab-type registry. An editor tab WITH a file path
* (the per-path windows of split mode — `meta.dir` marks folder windows,
* which keep the folder glyph) shows the same file icon the tree row shows
* (`fileIcon`, feature `fileIcons`); every other tab uses its tab-type
* descriptor icon.
*/
const tabIconOf = (tab: SidebarTab): ReactNode => {
if (tab.type === 'editor' && tab.path !== undefined && (tab.meta as { dir?: boolean } | undefined)?.dir !== true) {
return ctx.get('betterSidebar')?.fileIcon(tab.path, 14) ?? null
}
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}
/**
* The tab badge from the tab-type registry: a count (99+ capped) or a
* short text pill. A throwing badge is swallowed (no pill) — the tab
* strip must never break because a plugin's badge computation failed.
*/
const tabBadgeOf = (tab: SidebarTab): ReactNode => {
// Agent-terminal wait indicator (sidebar-internal, deliberately NOT a
// TabDescriptor.badge — that API is type-keyed and shared with external
// plugins, and cannot address one tab): the agent-terminals push mirrors
// the model's live terminal_wait_for into state.agentWaits; an agent tab
// whose uuid is waiting shows the hourglass pill.
if (isAgentTabId(tab.id)) {
const wait = state.agentWaits?.[agentUuidOf(tab.id)]
if (wait !== undefined) return <span className={css.tabBadge}>{'⏳'}</span>
}
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
if (descriptor?.badge === undefined) return null
let value: string | number | null | undefined
try {
value = descriptor.badge(ctx, { sessionId, cwd }, state)
} catch (error) {
console.error('[dsh-apes-sidebar] tab badge error:', error)
return null
}
if (value === null || value === undefined || value === '') return null
const text = typeof value === 'number' ? (value > 99 ? '99+' : String(value)) : String(value)
return <span className={css.tabBadge}>{text}</span>
}
/**
* Render one tab's content. `active` (from the workbench) tells whether
* this tab is the active one in its pane; combined with the panel's
* open/closed state it gates live views (the Subagent topology pauses its
* polling while the page is not actually visible). The pane id travels
* with the tab so diff tabs can split below their source pane.
*/
const renderTab = (tab: SidebarTab, active: boolean, paneId: string) => {
// Pinned virtual tabs: pass the home session's scope (sessionId + cwd) so
// TerminalView's WS URL resolves to the home PTY, and effectiveTabId so
// the descriptor component receives the ORIGINAL tab id (the virtual id
// is only a display key). Regular tabs: effectiveTabId is undefined (no
// override), scope is the current session's.
const home = getPinnedHomeScope(tab)
return (
<TabContent
tab={tab}
effectiveTabId={home?.tabId}
paneId={paneId}
sessionId={home?.sessionId ?? sessionId}
cwd={home?.cwd ?? cwd}
expanded={state.expanded}
revealed={state.revealed ?? []}
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
visible={state.bottomOpen && active}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
localeRevision={localeRevision}
tabsVersion={tabsVersion}
/>
)
}
return (
<div data-dsh-panel-host {...osFileDragShield}>
{/*
The bottom workbench: it squeezes ONLY the center column (the agent
output area): it starts at the app shell's own left sidebar and ends
at the details column's left edge — neither sidebar gives up any
position. Its resize strip is the top edge; hidden by sliding down.
It only becomes VISIBLE once the center column is measured: before
that, `centerRect` is the {0,0} fallback and `right` computes to the
full viewport width — the panel (and its overflow content) would
flash full-width for a frame until the first measurement lands.
Rendering stays unconditional so the mount/render chain (auto-terminal
etc.) is never gated on geometry.
*/}
<div
ref={bottomRef}
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
data-dsh-panel
data-dsh-bottom-panel
style={{
height: bottomPanelHeight,
left: centerRectRef.current.left,
// Keep the panel above the on-screen keyboard when the visual
// viewport shrinks (see the keyboardInset effect).
bottom: keyboardInset > 0 ? `${keyboardInset}px` : undefined,
// Direct from the center column's measured right edge: the bottom
// panel spans ONLY the center column, ending exactly at the
// details column's left edge.
right: window.innerWidth - centerRectRef.current.right,
// Unmeasured center column → keep the panel invisible (zero-size
// geometry would flash full-width overflow instead).
visibility: centerMeasured ? undefined : 'hidden',
}}
data-dragging={draggingBottom || undefined}
>
<div
className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
dragCommitted.current = false
bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight }
setDraggingBottom(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startY, startHeight } = bottomDrag.current
scheduleDrag(clampHeight(startHeight + (startY - event.clientY)))
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
if (dragCommitted.current) return
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startY, startHeight } = bottomDrag.current
// Up position wins over the rAF pending value (see the abortDrag
// comment — issue #247).
commitDrag(clampHeight(startHeight + (startY - event.clientY)))
setDraggingBottom(false)
}}
onPointerCancel={(event) => { abortDrag(() => setDraggingBottom(false), event) }}
onLostPointerCapture={() => { abortDrag(() => setDraggingBottom(false)) }}
/>
{/*
The bottom panel's own close control at its tab strip's right end
(the strip reserves the width via CSS so the + menu never hides
under it): one tap collapses the panel.
*/}
<Tooltip label={t('collapseBottomPanel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.bottomClose}
aria-label={t('collapseBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconCloseFill14 />
</button>
</Tooltip>
<div className={css.panelBody}>
<Workbench
state={state}
tree={augmentedTree}
newTabOptions={newTabOptions}
actions={wrappedActions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={tabIconOf}
getTabBadge={tabBadgeOf}
/>
</div>
</div>
</div>
)
}
/** The header control that expands/collapses the bottom workbench (see
* sidebar/bottom-toggle.tsx — registered into DSH's session header). */
export function BottomDockToggle(props: { store: SidebarStore }) {
const { store } = props
const snapshot = useSyncExternalStore(
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot(), [store]),
)
const open = snapshot.state?.bottomOpen === true
const label = open ? t('collapseBottomPanel') : t('expandBottomPanel')
return (
<Tooltip label={label} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
data-dsh-bottom-toggle
data-active={open ? 'true' : undefined}
aria-label={label}
aria-pressed={open}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
)
}