Files
apes-sidebar/src/client/Sidebar.tsx
T
bb2456bea9 feat(explorer): upload files into the workspace from the file tree (#239)
* feat(explorer): upload files into the workspace from the file tree

- TreePanel header: upload-files / upload-folder buttons (folder picker keeps
  the tree via webkitdirectory), with a one-line progress/result hint
- FileTree: drop files/folders onto the tree body (workspace root) or onto a
  directory row (that directory); directory context menu gains 上传到此处
- api.ts: uploadFile posts base64 to /sidebar/upload (host writes it through
  fs + shell decode; route provided for now by the local demo plugin)
- upload.ts: bytes->base64 (global-btoa-independent), FileList/DataTransfer
  collection, sequential upload with per-file results; ~48MB per-file cap
- locales: zh/en strings; icons: IconUploadOutline16

* fix(explorer): reload tree after upload and auto-dismiss the status hint

The drop/context-menu upload path cleared the level cache but never
refetched (the refreshTick bump belongs to the button path), so the
visible tree emptied and only the persistent '已上传 N 个文件' hint
remained — the tree only came back after reopening the tab.

- FileTree.uploadInto now reloads root + expanded levels after clearing,
  matching the refresh-tick path
- success status auto-dismisses after 3.5s (failures persist until the
  next action) in both TreePanel and FileTree

* feat(host): add the /sidebar/upload route (raw bytes, workspace-safe)

The file tree's upload UI needs a host route; until now it pointed at a
locally-injected demo route. This is the real implementation:

- src/index.ts: register POST /sidebar/upload (exact route, same fence and
  session-cwd resolution as every /sidebar route). Query params sessionId,
  dir, relativePath; the raw request body is streamed into the workspace.
- src/fs-operations.ts: writeWorkspaceUpload streams chunks to a temp
  sibling and renames into place, so a failed or oversized upload never
  leaves a partial file. Upload dir must sit inside the session cwd, the
  relativePath is sanitized (no '.', '..', empty or absolute segments) and
  the target must stay inside both.
- src/config.ts: uploadLimit (default 128MiB; oversized -> 413 too-large,
  a new wire code in src/wire.ts).
- client: send the File straight into the POST body (no base64 inflation)
  and cap at the same 128MiB (upload.ts, api.ts).

Tests: tests/fs-operations.spec.ts covers write, nested dirs, traversal /
containment refusal, the size cap, and no-partial-file on failure.
(pnpm test: only pre-existing node-pty/git sandbox failures, identical on
clean main; typecheck and build pass.)

* fix(host): harden upload containment, temp-file atomicity and stream failure cleanup

- relativePath: absolute paths and empty segments are refused (400), not
  silently collapsed or re-anchored (the spec's 'reject' semantics)
- temp file: uniquely named per request (randomUUID) so concurrent uploads
  to the same target stay independent; crashed-process leftovers never
  block later uploads (no pid reuse)
- write stream: permanent 'error' listener + error checks after every await
  so a failing disk rejects the operation instead of crashing the host or
  hanging the drain wait; the temp file is removed after the stream fully
  closes (Windows file locks), never leaving a partial file
- docstring now states the containment is lexical (symlink trust model)

* fix(explorer): thread upload error codes and cancellation through the client

- api.uploadFile/fetchUpload accept an AbortSignal (long-op convention);
  an aborted request rejects with the DOMException as-is instead of being
  wrapped into a 'network' error
- UploadResult carries the wire error code; the client pre-check now emits
  the same 'too-large' code as the host, and summarizeResults localizes
  'too-large' instead of showing the raw English 413 message
- uploadToDir stops the queue on abort: completed files stay, in-flight
  request is cancelled, nothing after is started
- uploadTooLarge copy no longer hardcodes a byte number (the host limit is
  configurable); MAX_UPLOAD_BYTES is documented as a mirror, not authority
- relativePathOf rejects absolute paths (isAbsolutePath) instead of
  stripping leading slashes, matching the host's 400 semantics
- progress text shared via uploadHintText for the hint and the overlay;
  success-hint duration is the exported UPLOAD_HINT_MS

* feat(explorer): full-window upload progress with cancel and VSCode-style drop targets

- UploadOverlay: blurred scrim + card over the file window (Modal token
  recipe: bg-mask-1 + mask blur, bg-layer-2, border-inverted, shadow-lv3),
  file-level progress bar, cancel button and Esc; scrim clicks do not abort
- TreePanel owns the upload session: header pickers, tree drag-drop and the
  context-menu picker funnel through startUpload; one session at a time
  (buttons disabled while busy, drops guarded); on settle the tree refreshes
  and the status hint shows success (3.5s, transient), failure or cancel
  (error color, stays)
- FileTree: drops on directory rows target that directory, drops on FILE
  rows target their parent (VSCode semantics, no more accidental root
  uploads), root row and body drop into the workspace root; drag-over
  highlights the target row (explorerRowDropTarget) plus the body outline
  and a hint pill (uploadDropHint / uploadTo) — raw-color inline outline
  replaced with the repo's dashed-accent classes
- upload status hint uses editorSearchHint + editorError like the search
  status; duplicated upload flow removed from FileTree (single caller)

* test: register the upload route in the smoke inventory

The new /sidebar/upload route joined the fenced route list, so the mount
assertion must expect it (the PR's CI gate was red).

* chore: bump to 0.14.1

Rebased onto main and cut a release version for the upload feature:
package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION stay in sync
(test-guarded), README changelogs get the v0.14.1 entry.

* feat(explorer): keep the drop hint above DSH's page overlay and suppress its intake over the tree

DSH's whole-page image-drop intake (ui-conversation InputBar) listens on
document in the bubble phase: any 'Files' drag entering the page shows its
full-screen overlay (z-1000) and a drop anywhere adds images to the chat.
Our tree handled dragover/drop but not dragenter, so the overlay flashed
over the file window and drops outside the tree rows could leak to the
chat.

- TreePanel root now swallows dragenter/dragover/drop (preventDefault +
  stopPropagation) before the events reach document: the file window is an
  upload target, not an image target. Row/body handlers still fire first
  (bubble order) for their own targets; the root is the backstop for the
  search row, gaps and the search-results view.
- The drop hint pill is portaled to document.body at z-index 1001 — above
  DSH's overlay (z-1000) — so when a drag enters from outside the tree and
  DSH's mask lingers until dragend, our 'Drop files/folders here' /
  'Upload into {dir}' hint stays readable on top (transient decoration,
  pointer-events none; the drop still lands on the tree).

* feat(explorer): zone-split the drop overlays and fix the mask flicker over the sidebar

Sidebar.tsx's panel host now swallows the whole file-drag quartet
(enter/over/leave/drop, gated on the 'Files' type): DSH's document-level
intake keeps an enter/leave depth counter, and the leaves that escaped
the tree unbalanced it — that was the native full-screen mask flickering
over the sidebar. The region is now a black hole to that listener, so the
conversation column keeps DSH's native overlay and image intake while the
sidebar presents its own upload surface (drops over it never add images
to the chat).

The tree's drop affordance is now a pointer-inert frame portaled to
document.body at z-1001, anchored to the explorer body's viewport rect:
it replaces DSH's mask over the sidebar instead of competing with it, and
the hint pill docks at the bottom edge so the rows — the actual drop
targets — stay visible and aimable instead of sitting behind a centered
pill. Inside the tree, enter/leave depth counting (InputBar's own
pattern) replaces the relatedTarget check, which is unreliable for drag
events and flickered the row highlight on icon/label transitions; the row
target is now set authoritatively by the continuously-firing dragover.
dragleave handlers on rows are gone.

Polish: capsule hint pill with the upload icon and a float shadow, the
progress overlay fades in (the repo's dsh-row-in motion), its title gains
the icon, and progress numbers render tabular. tests/file-tree-drop.spec
pins the depth-counter behavior (child transitions keep the zone) and the
parent-directory drop semantics.

* feat(explorer): dim the whole viewport behind the tree's drop zone

The bottom-docked pill sat below the fold of attention; the zone frame
alone also failed to teach the left/right split — a drag over the sidebar
gave no hint that the conversation column still accepts drops (into the
chat, natively). The zone frame now carries the full-viewport dim itself
(a 200vmax box-shadow spread in DSH's own mask-drop token): everything
outside the tree's rect fades, the tree stays clear and aimable, and a
muted invitation centered in the space left of the zone (skipped under
200px) names the chat as the image target. The pill moves to the TOP edge
of the zone, right under the search row — the first thing the eye meets.

* feat(explorer): colorful drop heroes for both zones

The overlay is this flow's one brand moment, so it gets the color the
rest of the UI never does: hand-drawn SVG illustrations in the palette of
DSH's own native drop art (#3964FE/#679EFE/#9CE5ED) so the two zones read
as one family. The upload zone docks a hero at its top edge — a tray
illustration over the hint pill, stacked so the rows below stay aimable;
the chat zone's invitation becomes a tilted photo-cards card over its
label.

* feat(explorer): traverse dropped folders instead of reading dataTransfer.files

A dropped OS folder never surfaces in dataTransfer.files (that flat list
carries files only), so folder drops were silent no-ops while the UI
advertised 'drop files/folders'. uploadItemsFromDrop now captures entry
handles via webkitGetAsEntry — synchronously, since the dataTransfer enters
protected mode once the drop dispatch ends, while the captured handles stay
readable — then drains each directory's readEntries batches recursively,
prefixing every descendant's relative path. Entry-derived paths go through
the same sanitizer as picker paths; an entry that fails to read is skipped
rather than failing the whole drop, and the flat file list remains the
fallback where the entry API is unavailable. The FileTree drop handlers
ride the resulting promise.

---------

Co-authored-by: dsh local <dsh@local>
Co-authored-by: Menghuan1918 <menghuan2003@outlook.com>
2026-08-20 18:28:57 +08:00

1245 lines
61 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: panels mounted inside the unified panel host — a
* fixed, viewport-sized containing block ([data-dsh-panel-host]) appended
* to document.body — instead of individual fixed-position elements, so a
* desktop shell's intermediate wrapper transforms can never hijack the
* panels' fixed containing block (the core AppFrame owns the left sidebar /
* center / details columns and has no right-side hole for plugins). The
* right panel hosts the original workbench; the bottom panel hosts a
* second, independent workbench. The bottom panel squeezes ONLY the center
* column (the agent output area): it spans from the app shell's own left
* sidebar to the right panel's left edge, so neither sidebar gives up any
* position (the right panel keeps its full height). A persistent two-button
* cluster at the top-right corner toggles each panel; the right panel's
* width drags from its left edge, the bottom panel's height from its top
* edge, and the shared corner drags both at once. The whole layout lives in
* the per-session store, so switching conversations swaps the sidebar.
*
* The shell binds the workbench actions to the store and dispatches tab
* content to the views. New tabs come from the + menu (explorer / git /
* terminal; editors open from the explorer). Tabs live in one tree only —
* they never cross panels; only the panel sizes drag against each other.
*
* Narrow (mobile, <768px) viewports show ONLY the right sidebar: entering
* narrow migrates the bottom panel's tabs INTO the right tree
* (migrateBottomTabs) — one workbench, the bottom tabs thrown into its
* strips. The right panel becomes a full-width drawer, the bottom panel
* and its toggle button disappear, and the layout push is disabled (the
* drawer floats). Widening does not migrate back: the tabs keep living in
* the right tree.
*/
import { createElement, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, 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, SidebarSessionList } from '../context-types.ts'
import { appendToDraft } from './conversation-draft.ts'
import {
BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, isAgentTabId, leafWithTab, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals,
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { useNarrowViewport } from './breakpoints.ts'
import { parseDesktopEnv } from './desktop-env.ts'
import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { relativeTo } from './paths.ts'
import { OrphanedTab } from './OrphanedTab.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { detectNewDirectSubagent } from './subagent-detect.ts'
import { detectNewJob } from './subagent-jobs.ts'
import { t } from './locales.ts'
import { api, type SessionScope } from './api.ts'
import css from './sidebar.module.css'
/** How many consecutive reconnect failures stop the agent-terminals push loop
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
const FAILURE_LIMIT = 3
/**
* 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. Both panel-host render sites swallow 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: DragEvent): 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,
}
/** Render the content of one tab (dispatched by type). */
function TabContent(props: {
tab: SidebarTab
sessionId: string
cwd: string | undefined
expanded: string[]
onToggleDir: (path: string) => void
onReferenceFile: (path: string) => void
ctx: Context
store: SidebarStore
/** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
visible: boolean
/** Fired before a topology node jumps to its child session (see Sidebar). */
onSubagentJump: (childSessionId: string) => void
/** Open a diff tab from the git panel (placement handled by the store). */
onOpenDiff: (tab: SidebarTab) => void
}) {
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
const scope = { sessionId, cwd }
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) {
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
}
// One boundary per tab: a render crash in a viewer/editor shows a strip in
// THIS tab's pane only — the toggle cluster, the other tabs, and the panel
// stay alive (issue #31). The tab strip (close button) lives outside, so a
// crashing tab stays closable; the root boundary in index.tsx remains the
// last resort for errors in the sidebar shell itself. The descriptor is
// rendered as a REAL element (not called directly): a direct call would
// throw inside TabContent's own render, which the boundary cannot catch —
// as a child fiber, every render error (top-level or deep) lands in it.
return createElement(
RenderBoundary,
{ className: css.tabBoundaryError },
createElement(descriptor.component, {
ctx, store, scope, tab, visible, expanded,
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
}),
)
}
/** The + menu options for the current state, driven by the tab registry.
* Hidden tabs (editor/diff) never show; `available` returning false shows
* a disabled row (e.g. terminal at capacity) instead of hiding the option.
* Tabs the user disabled in the side card settings are filtered out
* entirely — re-enabling them is the settings page's job. */
function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
const service = ctx.betterSidebar
if (service === undefined) return []
return service.getTabs()
.filter(d => !d.hidden && service.isTabEnabled(d.id))
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
.map(d => ({
id: d.id,
label: typeof d.title === 'function' ? d.title() : d.title,
disabled: !(d.available?.(ctx, scope, state) ?? true),
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
}))
}
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
// Narrow (mobile) viewports collapse the two panels into one: the right
// panel becomes a full-width drawer holding BOTH workbenches, the bottom
// panel (and its toggle button) disappears, and the layout push is
// disabled (the drawer floats over the app shell). Entering narrow
// MIGRATES the bottom tree's tabs into the right tree (migrateBottomTabs)
// — the merged display is the right sidebar alone, the bottom tabs thrown
// into its strips. Widening never rewrites the migrated state: the tabs
// keep living in the right tree.
const narrow = useNarrowViewport()
// 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 panels (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)
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)
}
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)
}
}, [])
// 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
// The collapsed toggle cluster reclaims the top-right corner, so the DSH
// session header's right-aligned utilities (the "Session log" download
// capsule) must yield. layout.css keys off this body attribute to push the
// header's right padding out past the cluster. Only the CLOSED panel needs
// it — an open panel already squeezes `#root` left, moving the header clear.
const collapsed = state === undefined || !state.panelOpen
useEffect(() => {
if (collapsed) document.body.setAttribute('data-dsh-sidebar-collapsed', '')
else document.body.removeAttribute('data-dsh-sidebar-collapsed')
return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
}, [collapsed])
// Position compatibility mode (titleBarCompat pref): Windows frameless
// windows draw the native title bar (minimize/maximize/close) at the
// window's top-right corner, OVER the web content. When enabled, the body
// attribute lets sidebar.module.css drop the toggle cluster below the
// strip and push the right panel's content below it. Auto-enabled in the
// win32 advanced desktop shell (the shell stamps the URL — see
// desktop-env.ts — and reserves a 32px overlay for the window controls);
// the manual pref and its strip height still win when the user sets them,
// and the manual switch only ever ADDS compat. The strip height rides a
// CSS variable so the rules stay declarative; the attribute rides the
// snapshot's prefs, so flipping the setting re-renders and re-applies
// immediately; the cleanup removes both on unmount/boundary swap so a
// crashed sidebar never leaves them behind.
const desktopEnv = parseDesktopEnv()
const autoTitleBarCompat = desktopEnv.win32OverlayTop > 0
const titleBarCompat = snapshot.prefs.titleBarCompat || autoTitleBarCompat
const titleBarStrip = snapshot.prefs.titleBarCompat
? snapshot.prefs.titleBarStripPx
: desktopEnv.win32OverlayTop
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])
/**
* Bottom-panel merge on narrow viewports: whenever a session is current
* while narrow (mount, session switch, or a desktop→narrow transition),
* throw the bottom tree's tabs into the right tree. Idempotent — after
* the first migration the bottom tree is empty and the reducer returns
* the same reference, so this effect settles immediately.
*/
useEffect(() => {
if (!narrow || sessionId === undefined) return
store.reduce(migrateBottomTabs)
}, [narrow, sessionId, store])
// 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
/**
* Agent terminals push: subscribe to the host's live list of agent-owned
* terminals for this session (created by the model through the
* `terminal_create` tool). The host pushes a JSON array on every
* create / close / exit; the sidebar reconciles the list into tabs
* (id `agent:<uuid>`, title from the agent). A disconnected socket
* retries with a short backoff so a refresh or transient drop reattaches
* the same shell without losing the agent's work — capped like the
* terminal view's own reconnect loop, so a refused endpoint never spins
* forever (the next session switch restarts the loop).
* While the terminal tab type is disabled in settings, pushes are
* ignored (no auto-added tabs); re-enabling makes the next push converge.
*/
useEffect(() => {
if (sessionId === undefined) return
let socket: WebSocket | null = null
let retry: number | undefined
let closed = false
let failures = 0
const connect = (): void => {
if (closed) return
const url = new URL('/sidebar/ws/agent-terminals', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = new URLSearchParams({ sessionId }).toString()
socket = new WebSocket(url.toString())
socket.onmessage = (event) => {
if (typeof event.data !== 'string') return
try {
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
if (!Array.isArray(list)) return
store.reduce(s => ctx.betterSidebar?.isTabEnabled('terminal') === false
? s
: reconcileAgentTerminals(s, list))
} catch {
// Malformed push: ignore (the next push will reconcile).
}
}
socket.onclose = () => {
if (closed) return
failures += 1
if (failures >= FAILURE_LIMIT) {
console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
return
}
retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => { socket?.close() }
}
connect()
return () => {
closed = true
window.clearTimeout(retry)
socket?.close()
}
}, [sessionId, store])
/**
* Subagent auto-activation: the moment the current conversation spawns its
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
* open" pref is on, and the Subagent tab type is enabled in settings,
* open the panel (if collapsed) and focus the Subagent page
* (single-instance: an existing tab is focused, never duplicated).
* Switching to a session that already has subagents never triggers — its
* baseline starts at the current count — so a deliberate layout is never
* fought.
*/
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
useEffect(() => {
const prev = listBaselineRef.current
listBaselineRef.current = sessionList
if (sessionId === undefined || prev === undefined) return
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
// Pin the landing to the right panel: the auto-opened Subagent page must
// appear where the panel just expanded, not in a bottom-panel pane the
// user last touched.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionList, sessionId, store, ctx])
/**
* Job auto-activation: the moment a NEW background job appears for the
* current conversation (a job id the previous snapshot lacked), the
* auto-open pref is on, and the Jobs tab type is enabled, open the panel
* (if collapsed) and focus the Jobs page. Unlike the subagent trigger
* (0 → N only), ANY new job id triggers: the agent may start several
* jobs in one session, and each should surface. A fresh page load never
* triggers — its baseline starts at the current snapshot.
*/
const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
useEffect(() => {
const prev = jobBaselineRef.current
jobBaselineRef.current = sessionList
if (sessionId === undefined || prev === undefined) return
if (!detectNewJob(prev, sessionList, sessionId)) return
if (!store.getPrefs().autoOpenJobs) return
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionList, sessionId, store, ctx])
/**
* Topology jump-back: clicking a subagent node on the Subagent page calls
* the official `openSubagent`, which switches the sidebar to that child
* session's OWN layout (a fresh child session defaults to the explorer).
* The README contract says the Subagent page must stay open with the jumped
* node highlighted — so once the current session becomes the recorded jump
* target, re-open the Subagent page on top of the child's layout (expanding
* the panel first if it is collapsed). Only this explicit node click arms
* the flag, so switching to a subagent session by any other means keeps
* that session's own layout untouched.
*/
const subagentJumpRef = useRef<string | undefined>(undefined)
useEffect(() => {
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.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionId, store, ctx])
// The app shell's center column: the bottom panel spans ONLY that 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 margin-right push while the
// right panel opens/closes; a frame that never appears keeps the initial
// zero-size fallback (the panel renders at 0 width until measured).
const [centerRect, setCenterRect] = useState({ left: 0, right: 0 })
// Refs keep the measure step stable across renders and let it skip work
// mid-drag: during a width/corner drag the layout push resizes the center
// column every frame, and reacting (setCenterRect → re-render) would
// re-introduce the drag lag this shell deliberately avoids. applyDrag
// writes the bottom panel's edges directly, so measurement pauses then.
const centerColRef = useRef<HTMLElement | null>(null)
const draggingRef = useRef(false)
const measureCenter = useCallback((): void => {
if (draggingRef.current) return
const col = centerColRef.current
if (col === null) return
if (!col.isConnected) {
// The observed column was detached (HMR re-render swapped the node
// in place): its rect is stale garbage. Drop the ref — the locate
// chain re-runs on the next mutation/interval tick and picks up the
// new column node (issue #248).
centerColRef.current = null
return
}
const rect = col.getBoundingClientRect()
// The bottom panel only cares about the horizontal edges: a pure height
// change (the bottom panel itself opening/closing) must not re-render,
// so keep the previous object when left/right are unchanged.
setCenterRect(prev =>
prev.left === rect.left && prev.right === rect.right
? prev
: { left: rect.left, right: rect.right })
}, [])
useEffect(() => {
let disposed = false
let observer: ResizeObserver | undefined
// Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
// [data-slot] containers: the conversation slot wrapper
// ([data-slot="conversation"]) sits directly inside the center column,
// so its parent IS that column — no hashed-class or positional
// dependency (layout.css uses the same anchor). The shell swaps the
// boot page for the AppFrame only AFTER boot settles, so the first
// query may miss it. Never give up: watch #root's subtree (the swap and
// HMR re-renders mutate it) and re-run this locator — querying once and
// bailing would strand the panel at the zero-size fallback forever
// (observed: a 1px sliver at the viewport's left edge).
const locate = (): void => {
if (disposed) return
const col = document.querySelector('#root [data-slot="conversation"]')
?.parentElement as HTMLElement | undefined
if (col === undefined || !col.isConnected) {
if (centerColRef.current !== null) {
centerColRef.current = null
observer?.disconnect()
observer = undefined
}
return
}
if (centerColRef.current !== col) {
// A NEW column node (boot swap, HMR re-render, or a previous locate
// that found nothing): attach the ResizeObserver to THIS node and
// measure it once. Same-node size changes are the ResizeObserver's
// job — no forced measurement here, because a forced
// getBoundingClientRect per mutation would reflow the shell at
// mutation cadence.
centerColRef.current = col
observer?.disconnect()
observer = new ResizeObserver(measureCenter)
observer.observe(col)
measureCenter()
}
}
locate()
// rAF-debounce the mutation watchers: #root's subtree changes at chat
// cadence (streaming turns), and locate() itself must stay cheap.
let locateFrame: number | null = null
const scheduleLocate = (): void => {
if (locateFrame !== null) return
locateFrame = requestAnimationFrame(() => {
locateFrame = null
locate()
})
}
const watcher = new MutationObserver(scheduleLocate)
const root = document.getElementById('root')
if (root !== null) watcher.observe(root, { childList: true, subtree: true })
// The layout push writes --dsh-sidebar-* on <html>. A HMR re-activation
// clears those variables on teardown and re-writes them on setup — and
// that is also the moment the shell may have re-created the center
// column under a REUSED #root child (React swaps nodes in place, so
// #root's childList never changes and the watcher above never fires).
// Watching <html>'s style attribute catches that re-sync: the push
// rewrite re-locates and re-measures, so the bottom panel recovers
// instead of staying hidden on a stale {0,0} center rect.
const htmlStyleWatcher = new MutationObserver(scheduleLocate)
htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
// Last-resort safety net (issue #248): no watcher is guaranteed to fire
// for every HMR teardown/setup interleaving (e.g. the style attribute
// may end up byte-identical, and the col may be swapped before the
// subtree watcher attaches). A slow unconditional re-locate makes the
// panel converge on the real column within a couple of seconds no
// matter what sequence the shell used. locate() is cheap when nothing
// changed (one querySelector + an identity compare; no forced layout).
const retry = window.setInterval(locate, 1500)
return () => {
disposed = true
if (locateFrame !== null) cancelAnimationFrame(locateFrame)
window.clearInterval(retry)
observer?.disconnect()
watcher.disconnect()
htmlStyleWatcher.disconnect()
centerColRef.current = null
}
// Opening the bottom panel re-runs the whole locate/measure chain: a
// panel opened before the center column was ever found must not stay
// invisible forever (the HMR recovery path depends on the observers
// above, this is the belt-and-braces retry for the open moment itself).
}, [measureCenter, 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(() => {
// The bottom panel does not exist on narrow viewports (the two
// workbenches merge into one panel), so the first-expansion auto
// terminal is a desktop-only behavior.
if (narrow) return
if (state === undefined) return
const wasOpen = bottomWasOpenRef.current
bottomWasOpenRef.current = state.bottomOpen
if (wasOpen === undefined || wasOpen || !state.bottomOpen) return
if (state.bottomOpenedOnce) return
if (store.getPrefs().bottomPanelAutoTerminal === false) return
if (ctx.betterSidebar?.isTabEnabled('terminal') === false) return
// Land the tab in the bottom panel's first pane; the once-flag is set
// atomically so later expansions never repeat the auto-open.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
ctx.betterSidebar?.openTab({ type: 'terminal' })
}, [state, store, ctx, narrow])
// Panel drags: the right panel's width (left edge strip), the bottom
// panel's height (top edge strip), and the shared corner (both at once).
// Drags write the sizes DIRECTLY to the DOM (panel styles + the layout CSS
// variables) instead of round-tripping the store on every pointer move —
// a store reduce re-renders both workbenches (terminals, editors…) per
// move, which is the visible drag lag. The store is committed once on
// pointer up (clamping + persistence).
const panelRef = useRef<HTMLDivElement | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null)
const widthDrag = useRef({ startX: 0, startWidth: 0 })
const [draggingWidth, setDraggingWidth] = useState(false)
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
const [draggingBottom, setDraggingBottom] = useState(false)
const cornerDrag = useRef({ startX: 0, startY: 0, startWidth: 0, startHeight: 0 })
const [draggingCorner, setDraggingCorner] = useState(false)
const anyDragging = draggingWidth || draggingBottom || draggingCorner
// 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 width equals the last drag width, so no ResizeObserver event
// fires to refresh centerRect — this explicit re-measure covers that gap.
useEffect(() => {
draggingRef.current = anyDragging
if (!anyDragging) measureCenter()
}, [anyDragging, measureCenter])
// Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
// re-clamps on commit; these keep the panels from overshooting mid-drag).
const clampWidth = (width: number): number =>
Math.min(Math.max(PANEL_MIN, Math.round(width)), Math.max(PANEL_MIN, window.innerWidth))
const clampHeight = (height: number): number =>
Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
/** Single writer for the layout-push variables: the app shell gives up
* the panel's width/height while open (0 while collapsed) through
* layout.css's margins. Every size change — drag frames and committed
* state — flows through here so the push never forks between paths. */
const writeGeometry = (width: number, height: number): void => {
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
// The corner handle positions itself relative to the panel (CSS
// `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
// variables are all it needs — no viewport coordinates written here
// (issue #106: skins that inset the panels must not fight JS coords).
}
/** Last size 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 lastDragSize = useRef<{ width: number; height: number } | null>(null)
/** Apply a drag size to the DOM without touching React state or the store.
* The bottom panel's right edge tracks the right panel's left edge HERE
* too — React state only updates on release, so the inline right must be
* written directly or the bottom panel would lag the sidebar mid-drag.
* The layout push rides the shared writer (writeGeometry). */
const applyDrag = (width: number, height: number): void => {
lastDragSize.current = { width, height }
panelRef.current?.style.setProperty('width', `${width}px`)
bottomRef.current?.style.setProperty('height', `${height}px`)
// centerRect.right is the center column's right edge at the committed
// width (innerWidth - state.width - detailsWidth), so this equals
// `width + detailsWidth` — derived from the measured column, keeping the
// drag write-only (no React re-render mid-drag).
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
writeGeometry(width, height)
}
// Drags write at most once per frame: pointer events fire several times
// faster than the display refresh, and each write reflows the app shell
// (the layout push) plus the panels — batching to one write per frame is
// what keeps the drag smooth. The store is still committed once on release.
const dragFrame = useRef<number | null>(null)
const pendingDrag = useRef<{ width: number; height: number } | null>(null)
const scheduleDrag = (width: number, height: number): void => {
pendingDrag.current = { width, height }
if (dragFrame.current !== null) return
dragFrame.current = requestAnimationFrame(() => {
dragFrame.current = null
const pending = pendingDrag.current
if (pending !== null) {
pendingDrag.current = null
applyDrag(pending.width, pending.height)
}
})
}
/** Flush any pending drag write and stop scheduling (the store commit on
* pointer up applies the final clamped values). */
const stopDragScheduling = (): void => {
if (dragFrame.current !== null) {
cancelAnimationFrame(dragFrame.current)
dragFrame.current = null
}
pendingDrag.current = null
}
/**
* Finalize a drag on pointer up: flush the LAST drag frame to the DOM
* synchronously, then commit the SAME clamped values 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
* bottom panel's React-rendered right edge already reflects the new
* width (otherwise the re-render would re-apply the stale rect — the
* bottom panel visibly jumps for one frame).
*/
const commitDrag = (
width: number,
height: number,
reduce: (state: SidebarState) => SidebarState,
): void => {
stopDragScheduling()
applyDrag(width, height)
draggingRef.current = false
measureCenter()
store.reduce(reduce)
}
/** 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 bottom panel freezes at stale edges and
* stops tracking sidebar/app-rail 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 (lastDragSize). A drag that produced none of
* those (pure down+up at the same spot) commits the store's own sizes —
* a no-op, never an explicit rollback (issue #247: v0.13.1 never reverted
* an interrupted fast flick; the abort path added in the unified-host
* refactor did, and that regression is what this ordering removes).
*
* 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?: { clientX: number; clientY: number }): void => {
if (dragCommitted.current) return
const pending = pendingDrag.current
let width: number | undefined
let height: number | undefined
if (pending !== null) {
width = pending.width
height = pending.height
} else if (event !== undefined) {
// No move ever landed: the cancel position is all we have — commit it
// (clamped) instead of rolling back the flick.
if (draggingWidth) {
width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
height = state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0
} else if (draggingBottom) {
width = Math.min(state?.width ?? 0, window.innerWidth)
height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))
} else if (draggingCorner) {
width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX))
height = clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY))
}
}
if (width !== undefined && height !== undefined) {
dragCommitted.current = true
pendingDrag.current = null
if (dragFrame.current !== null) {
cancelAnimationFrame(dragFrame.current)
dragFrame.current = null
}
applyDrag(width, height)
draggingRef.current = false
measureCenter()
store.reduce(s => setBottomHeight(setWidth(s, width), height))
} else {
// No pending write and no usable event coordinates: keep the size 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). Clamp to
// the current geometry like the layout-push effect (closed/narrow
// panels are written 0 by the push).
dragCommitted.current = true
stopDragScheduling()
const last = lastDragSize.current
const adoptedWidth = !narrow && state?.panelOpen === true
? Math.min(last?.width ?? state?.width ?? 0, window.innerWidth)
: 0
const adoptedHeight = !narrow && state?.bottomOpen === true
? Math.min(last?.height ?? state?.bottomHeight ?? 0, window.innerHeight)
: 0
applyDrag(adoptedWidth, adoptedHeight)
draggingRef.current = false
measureCenter()
store.reduce(s => setBottomHeight(setWidth(s, adoptedWidth), adoptedHeight))
}
reset()
}
// Layout push: the app shell gives up the panel's width/height while the
// panels are open (0 while collapsed), so the conversation and input bar
// are squeezed instead of covered. The margins are capped at the viewport
// so a stale persisted size (e.g. fullscreen on a bigger window) can never
// crush the app shell to zero. Dragging disables the layout transition.
// On NARROW viewports the drawer FLOATS over the app shell — no push, the
// conversation keeps the full width behind the drawer.
useEffect(() => {
const width = !narrow && snapshot.state?.panelOpen === true
? Math.min(snapshot.state.width, window.innerWidth)
: 0
const height = !narrow && snapshot.state?.bottomOpen === true
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
: 0
writeGeometry(width, height)
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
// 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 variables 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 variables on a
// dependency change used to paint the push-less layout for a frame (the
// center column went full width) while the re-add restarted the margin
// transition — the bottom panel flashed full width after every width
// drag (issue #258). Keeping the variables continuously valid while
// mounted makes the push invisible to mid-flush style recals.
useEffect(() => {
return () => {
document.documentElement.style.removeProperty('--dsh-sidebar-width')
document.documentElement.style.removeProperty('--dsh-sidebar-height')
}
}, [])
useEffect(() => {
if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
else document.body.removeAttribute('data-dsh-sidebar-dragging')
}, [anyDragging])
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
// Terminal tabs may live in EITHER tree (the bottom panel hosts them
// too) — the pty-release lookup covers both, or the HTTP fallback is
// skipped for a bottom-panel terminal whose WS frame never arrived.
const leaf = current === undefined
? undefined
: leafWithTab(current.splits, tabId) ?? 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.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 in EITHER
// tree, sets the active pane) and fires descriptor.onActivate; the
// session scope (with its cwd) rides to the callback.
ctx.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.splits, 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))
},
}), [store, sessionId, cwd])
/**
* The explorer's @-reference button: append `@<relative path>` to the
* session's composer draft (space-separated). The conversation service is
* resolved lazily through `ctx.get` (the inject-free read — the app's own
* plugins read 'conversation' the same way); 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): void => {
if (sessionId === undefined) return
appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
}, [ctx, sessionId, cwd])
if (state === undefined || sessionId === undefined) {
return (
<div data-dsh-panel-host {...osFileDragShield}>
<div className={css.toggleCluster}>
{!narrow && (
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
)}
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelRightOutline16 />
</button>
</Tooltip>
</div>
</div>
)
}
const onNewTab = (optionId: string): void => {
const service = ctx.betterSidebar
const descriptor = service?.getTab(optionId)
if (descriptor === undefined) return
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
// The session scope rides along: lifecycle callbacks receive it (and
// the open stays in the current session, as before).
service.openTab({ type: optionId, title }, { sessionId, cwd })
}
/**
* The explorer's @-reference button: append `@<relative path>` to the
* session's composer draft (space-separated). 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.
*/
/** The tab icon from the tab-type registry (shared by every workbench). */
const tabIconOf = (tab: SidebarTab): ReactNode => {
const descriptor = ctx.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 => {
const descriptor = ctx.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-better-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, bottom = false) => (
<TabContent
tab={tab}
sessionId={sessionId}
cwd={cwd}
expanded={state.expanded}
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
/>
)
return (
<div data-dsh-panel-host {...osFileDragShield}>
{/*
The persistent toggle cluster at the top-right corner: the bottom
panel's button (bottom glyph) LEFT of the right panel's (side glyph).
Always pinned to the viewport corner — inside the right panel's
top-right while it is open, sitting flush in the tab strip whose
right end it really squeezes (the strip reserves its width via CSS),
so the tabs genuinely yield space to it.
*/}
<div className={css.toggleCluster}>
{/*
Narrow viewports merge the two workbenches into the one drawer —
there is no bottom panel, so its toggle button is not offered.
*/}
{!narrow && (
<Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
)}
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={state.panelOpen ? t('collapse') : t('expand')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconPanelRightOutline16 />
</button>
</Tooltip>
</div>
{/*
The right panel stays mounted while collapsed (hidden off-screen) so
the slide in/out can animate; visibility hides it after the slide
settles. Its bottom edge follows the bottom panel's height (0 while
the bottom panel is closed) — the VSCode-style "sidebar above panel".
On NARROW viewports it is a full-width drawer holding both
workbenches (see MobileWorkbench); the width drag strip is not
offered there — a full-screen sheet has nothing to drag.
*/}
<div
ref={panelRef}
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
style={{
width: narrow ? '100vw' : Math.min(state.width, window.innerWidth),
// Narrow drawer: keep the bottom-anchored sheet above the on-screen
// keyboard (visualViewport inset); desktop panels are full-height
// and unaffected.
bottom: narrow && keyboardInset > 0 ? `${keyboardInset}px` : undefined,
}}
data-dragging={anyDragging || undefined}
>
{!narrow && (
<div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
dragCommitted.current = false
widthDrag.current = { startX: event.clientX, startWidth: state.width }
setDraggingWidth(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startWidth } = widthDrag.current
const width = clampWidth(startWidth + (startX - event.clientX))
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
if (dragCommitted.current) return
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startWidth } = widthDrag.current
// The up position is the pointer's FINAL position — a fast
// flick's tail is coalesced into the up event, so the last
// pointermove (the rAF pending value) can be stale. Commit
// from the up position (v0.13.1 semantics; issue #247).
const width = clampWidth(startWidth + (startX - event.clientX))
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
commitDrag(width, height, s => setWidth(s, width))
setDraggingWidth(false)
}}
onPointerCancel={(event) => { abortDrag(() => setDraggingWidth(false), event) }}
onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }}
/>
)}
<div className={css.panelBody}>
<Workbench
state={state}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={tabIconOf}
getTabBadge={tabBadgeOf}
/>
</div>
{/*
The shared corner (only while BOTH panels are open): the
intersection of the right panel's left edge and the bottom panel's
top edge. Horizontal drags resize the right panel's width, vertical
drags the bottom panel's height — the two panels drag against each
other. Rendered INSIDE the right panel and positioned by CSS
relative to it (left edge + the bottom panel's height via the
--dsh-sidebar-height layout variable) — no JS-written viewport
coordinates to keep in sync. (Never on narrow viewports: the
bottom panel does not exist there.)
*/}
{!narrow && state.panelOpen && state.bottomOpen && (
<div
className={css.cornerHandle}
data-dragging={draggingCorner || undefined}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
dragCommitted.current = false
cornerDrag.current = {
startX: event.clientX,
startY: event.clientY,
startWidth: state.width,
startHeight: state.bottomHeight,
}
setDraggingCorner(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startY, startWidth, startHeight } = cornerDrag.current
const width = clampWidth(startWidth + (startX - event.clientX))
const height = clampHeight(startHeight + (startY - event.clientY))
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
if (dragCommitted.current) return
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startY, startWidth, startHeight } = cornerDrag.current
// Up position wins over the rAF pending value (see the width
// strip handler — issue #247).
const width = clampWidth(startWidth + (startX - event.clientX))
const height = clampHeight(startHeight + (startY - event.clientY))
commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height))
setDraggingCorner(false)
}}
onPointerCancel={(event) => { abortDrag(() => setDraggingCorner(false), event) }}
onLostPointerCapture={() => { abortDrag(() => setDraggingCorner(false)) }}
/>
)}
</div>
{/*
The bottom panel: a second, independent workbench. It squeezes ONLY
the center column (the agent output area): it starts at the app
shell's own left sidebar and ends at the right panel's left edge —
neither sidebar gives up any position (the right panel keeps its
full height). Its resize strip is the top edge; hidden by sliding
down like the right panel. On NARROW viewports it does not exist —
the bottom workbench lives inside the drawer (MobileWorkbench).
*/}
{/* The bottom panel 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. */}
{!narrow && (
<div
ref={bottomRef}
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
data-dsh-bottom-panel=""
style={{
height: Math.min(state.bottomHeight, window.innerHeight),
left: centerRect.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 (the details column sits between the
// center and the right panel, and the right panel's margin-right
// push is already baked into centerRect.right).
right: window.innerWidth - centerRect.right,
// The seam against the open right panel needs its own hairline
// (the right panel's border-left alone is covered by this panel's
// fill — without it the corner looks cut off).
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
// Unmeasured center column → keep the panel invisible (zero-size
// geometry would flash full-width overflow instead).
visibility: centerRect.right > 0 ? undefined : 'hidden',
}}
data-dragging={(draggingBottom || draggingCorner) || 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
const height = clampHeight(startHeight + (startY - event.clientY))
scheduleDrag(Math.min(state.width, window.innerWidth), height)
}}
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 width
// strip handler — issue #247).
const height = clampHeight(startHeight + (startY - event.clientY))
commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height))
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={state.bottomSplits}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
actions={actions}
onNewTab={onNewTab}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
getTabIcon={tabIconOf}
getTabBadge={tabBadgeOf}
/>
</div>
</div>
)}
</div>
)
}