Merge pull request #542 from omdsh-dev/feat/split-sidebar

feat(client): Sidebar.tsx 按关注点增量拆分
This commit is contained in:
Atlas Zeng
2026-09-03 21:20:15 +08:00
committed by GitHub
6 changed files with 887 additions and 708 deletions
+53 -708
View File
@@ -28,56 +28,39 @@
* drawer floats). Widening does not migrate back: the tabs keep living in * drawer floats). Widening does not migrate back: the tabs keep living in
* the right tree. * the right tree.
*/ */
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react' import { useSyncExternalStore } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarSessionList } from '../context-types.ts' import type { Context } from '../context-types.ts'
import { appendToDraft, insertFileReference } from './conversation-draft.ts' import { appendToDraft, insertFileReference } from './conversation-draft.ts'
import { import {
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab, BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, floatTab,
isAgentTabId, leafWithTab, migrateBottomTabs, isAgentTabId, leafWithTab, migrateBottomTabs,
moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, reconcileAgentTerminals, moveTab, moveTabToEdge, openDiffTab, resizeSplitIn,
resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type DropZone, type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts' } from './state.ts'
import { collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree, isPinnedVirtualId, parsePinnedVirtualId, type PinnedTabEntry } from './pinned.ts' import { getPinnedHomeScope } from './pinned.ts'
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { isNarrowWidth, useViewportSize } from './breakpoints.ts' import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
import { layoutPushSize } from './layout-push.ts' import { layoutPushSize } from './layout-push.ts'
import { resolveCenterColumn } from './center-column.ts'
import { parseDesktopEnv } from './desktop-env.ts' import { parseDesktopEnv } from './desktop-env.ts'
import { getWcoSnapshot, subscribeWco } from './wco.ts' import { getWcoSnapshot, subscribeWco } from './wco.ts'
import { getShellPreset } from './shell-presets.ts' import { getShellPreset } from './shell-presets.ts'
import { computeTitleBarStrip } from './titlebar-strip.ts' import { computeTitleBarStrip } from './titlebar-strip.ts'
import type { NewTabOption } from './TabBar.tsx' import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx' import { useCenterColumn } from './sidebar/use-center-column.ts'
import { FreeWindow } from './FreeWindow.tsx' import { useHostFeeds } from './sidebar/use-host-feeds.ts'
import { usePinnedTabs } from './sidebar/use-pinned-tabs.ts'
import { FreeWindowLayer, useFloatDragout } from './sidebar/free-windows.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { relativeTo } from './paths.ts' import { relativeTo } from './paths.ts'
import { OrphanedTab } from './OrphanedTab.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { tabContentCompare, type TabContentMemoKey } from './tab-content-memo.ts'
import { detectNewDirectSubagent } from './subagent-detect.ts'
import { detectNewJob } from './subagent-jobs.ts'
import { t } from './locales.ts' import { t } from './locales.ts'
import { api, type SessionScope } from './api.ts' import { api } from './api.ts'
import css from './sidebar.module.css' 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
/**
* Subagent auto-open debounce (ms). The host delivers a new child's origin
* and its title in SEPARATE frames: a Side Chat thread's first visible
* frame still shows a fallback title (no 'Side: ' prefix), so an immediate
* 0→N decision mistakes it for a genuine subagent and pops the task page.
* The trigger therefore re-evaluates against the live snapshot once the
* title frame has had time to land.
*/
const AUTO_OPEN_DEBOUNCE_MS = 500
/** /**
* OS file drags over the sidebar belong to the sidebar, not to the chat: * 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 * DSH's composer (InputBar) listens for file drags on the DOCUMENT and
@@ -119,65 +102,6 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
return tag return tag
} }
/** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus
* the runtime objects/callbacks the cell renders with. The memo comparator
* is the pure `tabContentCompare`; anything in the key decides a re-render
* must propagate, anything outside it must be a stable object (ctx/store)
* or covered by a compared field (paneId covers onOpenDiff's captured
* pane; sessionId/cwd cover onReferenceFile). */
interface TabContentProps extends TabContentMemoKey {
onToggleDir: (path: string) => void
onReferenceFile: (path: string, isDir: boolean) => void
ctx: Context
store: SidebarStore
/** 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
}
/** Render the content of one tab (dispatched by type). */
const TabContent = memo(function TabContent(props: TabContentProps) {
const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
const scope = { sessionId, cwd }
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
if (descriptor === undefined) {
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
}
// For pinned virtual tabs, the tab descriptor's component (e.g. TerminalView)
// must receive the ORIGINAL tab id so it connects to the home session's PTY.
// The virtual tab's own id is a unique display key (prefixed); effectiveTabId
// restores the real id at the component boundary.
const componentTab = effectiveTabId !== undefined ? { ...tab, id: effectiveTabId } : tab
return createElement(
RenderBoundary,
{ className: css.tabBoundaryError },
createElement(descriptor.component, {
ctx, store, scope, tab: componentTab, visible, expanded, revealed,
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
}),
)
}, tabContentCompare)
/** 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.get('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 }) { export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const { ctx, store } = props const { ctx, store } = props
@@ -420,517 +344,25 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
[state, ctx, sessionId, cwd], [state, ctx, sessionId, cwd],
) )
/** // Host feeds (sidebar/use-host-feeds.ts): the agent-terminals / agent-opens
* Agent terminals push: subscribe to the host's live list of agent-owned // WebSocket pushes and the subagent / background-job auto-activation
* terminals for this session (created by the model through the // triggers, all keyed on the current session. The jump-back ref is the one
* `terminal_create` tool). The host pushes a JSON array on every // piece the render side consumes (renderTab's onSubagentJump arms it).
* create / close / exit; the sidebar reconciles the list into tabs const { subagentJumpRef } = useHostFeeds({ ctx, store, sessionList, sessionId })
* (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.get('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, ctx, store])
/** // Center-column tracking (sidebar/use-center-column.ts): the bottom panel
* Agent opens push: subscribe to the host's `sidebar_open` requests for // spans ONLY the app shell's center column ("squeezes the agent output
* this session (the model actively opens a file / folder / HTTP(S) page). // area"); the hook locates the AppFrame's center column DOM (host anchor
* The host pushes one JSON request per open; the sidebar routes it to the // observers + slow retry), measures its edges into a ref, and writes them
* matching built-in tab: a file opens in the editor (per-path dedupe), a // straight to the bottom panel element — per-frame tracking without
* folder opens a file window whose tree is rooted at the folder // re-rendering the shell (the comments live with the hook now).
* (`meta.dir`), and a URL opens in the browser tab. A disconnected socket const bottomRef = useRef<HTMLDivElement | null>(null)
* retries with a short backoff (mirror of the agent-terminals loop): the const { centerColRef, centerRectRef, centerMeasured, measureCenter, draggingRef } = useCenterColumn(bottomRef, state?.bottomOpen)
* host queue keeps undelivered requests and replays them on the first
* attach, so a refresh or a session switch lands the opens the model
* queued while no view was connected.
* While the side-card setting is off, pushes are ignored as a defensive
* gate — the host already unregisters the tool and drains the queue.
*/
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-opens', 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 request = JSON.parse(event.data) as { kind?: unknown; target?: unknown; title?: unknown }
if (request === null || typeof request !== 'object') return
if (request.kind !== 'file' && request.kind !== 'folder' && request.kind !== 'url') return
if (typeof request.target !== 'string' || request.target === '') return
if (store.getPrefs().agentOpenTools !== true) return
const scope = { sessionId }
const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined
if (request.kind === 'url') {
ctx.get('betterSidebar')?.openTab({ type: 'browser', url: request.target, title }, scope)
} else if (request.kind === 'folder') {
ctx.get('betterSidebar')?.openTab({
type: 'editor',
title,
path: request.target,
id: `editor:${request.target}`,
meta: { dir: true },
}, scope)
} else {
ctx.get('betterSidebar')?.openFile(scope, request.target, title)
}
} catch {
// Malformed push: ignore (the next push carries its own request).
}
}
socket.onclose = () => {
if (closed) return
failures += 1
if (failures >= FAILURE_LIMIT) {
console.error('[dsh-better-sidebar] agent-opens 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, ctx, store])
/** // Free-window drag-out gesture (sidebar/free-windows.tsx): watches the
* Subagent auto-activation: the moment the current conversation spawns its // document for OUR tab drags hovering the conversation column and floats
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto // the tab on release. Returns the drop-zone hint geometry for the layer.
* open" pref is on, and the Tasks tab type is enabled in settings, activate const { floatHint } = useFloatDragout({ narrow, sessionId, store, centerColRef })
* the Tasks page. Single-instance semantics focus an existing pane tab in
* place or raise an existing free window; a new tab lands in the right pane
* and is never duplicated. On wide viewports the right panel also expands;
* on narrow viewports background activity never forces the full-screen
* drawer open over the chat.
* Switching to a session that already has subagents never triggers — its
* baseline starts at the current count — so a deliberate layout is never
* fought.
*
* The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread
* is also a subagent-origin child, and its 'Side: ' title lands one frame
* after its origin — an immediate check would misread that first frame as
* a new subagent and pop this page on every thread creation. The timer
* re-evaluates the ORIGINAL baseline against the live snapshot; by then
* the title filter (isSideThreadSummary) sees the settled label.
*/
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null)
useEffect(() => {
const prev = listBaselineRef.current
listBaselineRef.current = sessionList
if (sessionId === undefined || prev === undefined) return
if (autoOpenPendingRef.current !== null) return
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
const baseline = prev
const timer = window.setTimeout(() => {
autoOpenPendingRef.current = null
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
// Read the viewport when the delayed activation fires: a resize while
// the debounce is armed must not let background activity force the
// narrow full-screen drawer open over the chat.
if (!isNarrowWidth(window.innerWidth)) {
store.reduce(s => s.panelOpen ? s : togglePanel(s))
}
// Choose the right panel as the landing pane for a newly created Tasks
// tab. Single-instance dedupe still activates an existing pane tab in
// place or raises an existing free window.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
}, AUTO_OPEN_DEBOUNCE_MS)
autoOpenPendingRef.current = { baseline, timer }
}, [sessionList, sessionId, store, ctx])
// A session switch (or unmount) voids any armed auto-open recheck.
useEffect(() => () => {
const pending = autoOpenPendingRef.current
if (pending !== null) window.clearTimeout(pending.timer)
autoOpenPendingRef.current = null
}, [sessionId])
/**
* 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 Tasks tab type is enabled, activate the Tasks
* page that contains the background-jobs section. The right panel expands
* only on wide viewports. 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.get('betterSidebar')?.isTabEnabled('subagent') === false) return
if (!isNarrowWidth(window.innerWidth)) {
store.reduce(s => s.panelOpen ? s : togglePanel(s))
}
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
}, [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.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionId, store, ctx])
/**
/**
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
* inject as VIRTUAL tabs into the first leaf of the right panel's split
* tree. The virtual tabs have unique ids (prefixed with the home session)
* and carry the home scope in meta. Clicking a virtual tab sets
* `activePinnedTabId` — the augmented tree overrides the leaf's `active`
* so the pinned tab's content renders in-place (TerminalView connects to
* the home session's PTY via WS, no session jump).
*
* Closing/unpinning a virtual tab targets the HOME session via reduceFor
* (which doesn't notify — targeted opens must not re-render the active
* session). The `pinnedRevision` state bump forces the pinnedEntries
* useMemo to recompute after such an action.
*/
const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
const [pinnedRevision, setPinnedRevision] = useState(0)
/**
* Cross-session pinned-tab collection. Recomputed on every store notify,
* session-list change, and pinned action (the revision bump covers
* reduceFor updates that don't notify). Only tabs from OTHER sessions —
* the viewer's own pinned tabs are already on its tab strip.
*/
const pinnedEntries: readonly PinnedTabEntry[] = useMemo(() => {
if (sessionId === undefined) return []
return collectPinnedTabs(store.getSessionStates(), { sessionId, cwd })
// snapshot / pinnedRevision are deliberate cache-busters (see the comment
// above): they recompute this memo for changes that carry no dep of their own.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [store, sessionId, cwd, snapshot, pinnedRevision])
/** Virtual SidebarTab objects for the pinned entries (stable references
* via useMemo so TabContent's memo comparator holds). */
const pinnedVirtualTabs = useMemo(
() => pinnedEntries.map(createPinnedVirtualTab),
[pinnedEntries],
)
/** The right panel's split tree with pinned virtual tabs injected into the
* first leaf. When `activePinnedTabId` is set, that leaf's `active` is
* overridden so the pinned tab's content is visible. */
const augmentedTree = useMemo(
() => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
[state, pinnedVirtualTabs, activePinnedTabId],
)
// 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 AppFrame padding reservation
// 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).
// The rect lives in a REF (not state): the open/close transition resizes
// the center column EVERY frame for its duration, and reacting per frame
// with setState re-renders the whole Sidebar (every mounted tab) at
// animation cadence — the visible toggle jank (#315). measureCenter
// writes the bottom panel's edges directly (same DOM-write pattern as
// applyDrag), so the panel still tracks the column per frame with zero
// React work; `centerMeasured` flips ONCE to gate the hidden→visible
// first-paint fallback.
const centerRectRef = useRef({ left: 0, right: 0 })
const [centerMeasured, setCenterMeasured] = useState(false)
// 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()
// Ref + direct DOM write (see the centerRectRef comment): the bottom
// panel keeps tracking the center column per frame during the right
// panel's open/close animation without re-rendering the shell. The
// one-shot measured flip renders the panel visible once (a stale
// {0,0} fallback would flash full-width).
centerRectRef.current = { left: rect.left, right: rect.right }
const bottom = bottomRef.current
if (bottom !== null) {
bottom.style.setProperty('left', `${rect.left}px`)
bottom.style.setProperty('right', `${window.innerWidth - rect.right}px`)
}
setCenterMeasured(prev => (prev ? prev : true))
}, [])
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
// Hot path (#403): streaming output mutates #root at token cadence.
// Reuse a still-connected center column and only query after boot/HMR
// detached the cached node.
const col = resolveCenterColumn(centerColRef.current)
if (col === undefined || !col.isConnected) {
if (centerColRef.current !== null) {
centerColRef.current.removeAttribute('data-dsh-center-col')
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. The tag retargets with the ref: layout.css's
// bottom-push rule anchors on [data-dsh-center-col], so exactly the
// measured node carries it (a stale tag on a swapped-out node would
// leave the push rule anchorless or doubled).
centerColRef.current?.removeAttribute('data-dsh-center-col')
centerColRef.current = col
col.setAttribute('data-dsh-center-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
// Mid-drag every frame writes --dsh-sidebar-* on <html>'s style
// attribute, which is the mutation this watcher observes — relocating
// per drag frame is pointless (the center column node cannot change
// while the pointer is captured) and adds locator work to every
// frame's budget (#315). The 1.5s retry below still covers any node
// swap that somehow lands mid-drag.
if (draggingRef.current) 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 query-free while the
// cached column stays connected; only a detached/missing cache falls
// back to the document selector.
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?.removeAttribute('data-dsh-center-col')
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])
/**
* Free windows — drag-out detection. The tab strips already drive HTML5
* DnD (payload application/x-dsh-tab) with drops owned by the panes
* (split/merge); this shell watches the DOCUMENT (capture) for the same
* drag hovering OUTSIDE the panel host: while the pointer is over the
* conversation column it arms the drop (preventDefault) and shows a hint
* overlay there, and the drop floats the tab at the release point. Targets
* inside the host are ignored here, so pane drops keep their behavior
* untouched. Only OUR tab drags count (the body flag is the tab strip's;
* OS file drags and any DSH drags pass through). Narrow viewports skip
* the gesture — the merged drawer covers the conversation, leaving
* nothing to drop onto (the tab context menu entry still floats tabs).
*/
const [floatHint, setFloatHint] = useState<{ left: number; top: number; width: number; height: number } | null>(null)
const floatHintRef = useRef(false)
useEffect(() => {
if (narrow || sessionId === undefined) return
const inPanelHost = (target: EventTarget | null): boolean =>
target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
/** The conversation column's rect when the pointer is over it (and not
* over our own surfaces); null otherwise. */
const overConversation = (event: DragEvent): DOMRect | null => {
if (inPanelHost(event.target)) return null
const col = centerColRef.current
if (col === null || !col.isConnected) return null
const rect = col.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return null
const { clientX: x, clientY: y } = event
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
return rect
}
const onDragOver = (event: DragEvent): void => {
if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
const rect = overConversation(event)
if (rect !== null) {
// preventDefault on dragover is what makes the browser deliver the
// drop (and drop the "no" cursor) over the conversation area.
event.preventDefault()
setFloatHint((prev) => {
const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
if (prev !== null && prev.left === next.left && prev.top === next.top
&& prev.width === next.width && prev.height === next.height) return prev
return next
})
floatHintRef.current = true
} else if (floatHintRef.current) {
floatHintRef.current = false
setFloatHint(null)
}
}
const onDrop = (event: DragEvent): void => {
if (!floatHintRef.current) return
floatHintRef.current = false
setFloatHint(null)
const rect = overConversation(event)
if (rect === null) return
event.preventDefault()
event.stopPropagation()
const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
if (payload === null) return
store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
}
const clear = (): void => {
if (!floatHintRef.current) return
floatHintRef.current = false
setFloatHint(null)
}
document.addEventListener('dragover', onDragOver, true)
document.addEventListener('drop', onDrop, true)
window.addEventListener('dragend', clear, true)
window.addEventListener('blur', clear)
return () => {
document.removeEventListener('dragover', onDragOver, true)
document.removeEventListener('drop', onDrop, true)
window.removeEventListener('dragend', clear, true)
window.removeEventListener('blur', clear)
}
}, [narrow, sessionId, store])
/** /**
* Bottom-panel first-expansion auto terminal: the FIRST time the user * Bottom-panel first-expansion auto terminal: the FIRST time the user
@@ -970,7 +402,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// move, which is the visible drag lag. The store is committed once on // move, which is the visible drag lag. The store is committed once on
// pointer up (clamping + persistence). // pointer up (clamping + persistence).
const panelRef = useRef<HTMLDivElement | null>(null) const panelRef = useRef<HTMLDivElement | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null)
const widthDrag = useRef({ startX: 0, startWidth: 0 }) const widthDrag = useRef({ startX: 0, startWidth: 0 })
const [draggingWidth, setDraggingWidth] = useState(false) const [draggingWidth, setDraggingWidth] = useState(false)
const bottomDrag = useRef({ startY: 0, startHeight: 0 }) const bottomDrag = useRef({ startY: 0, startHeight: 0 })
@@ -986,7 +417,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
useEffect(() => { useEffect(() => {
draggingRef.current = anyDragging draggingRef.current = anyDragging
if (!anyDragging) measureCenter() if (!anyDragging) measureCenter()
}, [anyDragging, measureCenter]) // draggingRef is a stable ref object from useCenterColumn (same
// provenance note as the float effect above).
}, [anyDragging, measureCenter, draggingRef])
// Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store // Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
// re-clamps on commit; these keep the panels from overshooting mid-drag). // re-clamps on commit; these keep the panels from overshooting mid-drag).
@@ -1298,83 +731,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
pinTab: (tabId, scope) => { pinTab: (tabId, scope) => {
store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd })) store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd }))
}, },
}), [store, sessionId, cwd, ctx]) }), [store, sessionId, cwd, ctx, centerColRef])
/** // Pinned virtual tabs (sidebar/use-pinned-tabs.ts): cross-session pinned
* Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from // tabs inject into the right panel's first leaf, and the actions are
* other sessions). Regular tab ids pass through unchanged. Virtual ids are // wrapped so pinned virtual ids route to the HOME session (reduceFor +
* detected by the `pinned:` prefix and routed to the HOME session via // revision bump).
* reduceFor (which doesn't notify — the revision bump is the local signal). const { augmentedTree, wrappedActions } = usePinnedTabs({ store, sessionId, cwd, snapshot, actions })
*/
const wrappedActions = useMemo<WorkbenchActions>(() => {
if (pinnedVirtualTabs.length === 0) return actions
const closePinnedInHome = (virtualId: string): void => {
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
// The home cwd lives in the virtual tab's meta (snapshotted at pin
// time) — pass it to ptyClose so the host resolves the PTY in the
// correct workspace container (same scope the WS open used).
const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
store.reduceFor(homeSessionId, s => {
const leaf = leafWithTab(s.splits, originalId) ?? leafWithTab(s.bottomSplits, originalId)
if (leaf !== undefined) return closeTab(s, leaf.id, originalId)
if (s.floats.some(f => f.tab.id === originalId)) return closeFloatByTab(s, originalId)
return s
})
if (isAgentTabId(originalId)) {
void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
} else {
void api.ptyClose({ sessionId: homeSessionId, ...(homeCwd !== undefined ? { cwd: homeCwd } : {}) }, originalId).catch(() => { /* already released */ })
}
if (activePinnedTabId === virtualId) setActivePinnedTabId(null)
setPinnedRevision(v => v + 1)
}
return {
...actions,
activateTab: (paneId, tabId) => {
if (isPinnedVirtualId(tabId)) {
setActivePinnedTabId(tabId)
} else {
setActivePinnedTabId(null)
actions.activateTab(paneId, tabId)
}
},
closeTab: (paneId, tabId) => {
if (isPinnedVirtualId(tabId)) {
closePinnedInHome(tabId)
} else {
actions.closeTab(paneId, tabId)
}
},
moveTabBefore: (payload, toPane, beforeTabId) => {
if (isPinnedVirtualId(payload.tabId)) return
if (isPinnedVirtualId(beforeTabId)) {
actions.moveTabToEdge(payload, toPane, 'center')
} else {
actions.moveTabBefore(payload, toPane, beforeTabId)
}
},
moveTabToEdge: (payload, toPane, zone) => {
if (isPinnedVirtualId(payload.tabId)) return
actions.moveTabToEdge(payload, toPane, zone)
},
floatTab: (tabId) => {
if (isPinnedVirtualId(tabId)) return
actions.floatTab(tabId)
},
pinTab: (tabId, scope) => {
if (isPinnedVirtualId(tabId)) {
if (scope !== null) return
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(tabId)
store.reduceFor(homeSessionId, s => setTabPin(s, originalId, null))
if (activePinnedTabId === tabId) setActivePinnedTabId(null)
setPinnedRevision(v => v + 1)
} else {
actions.pinTab?.(tabId, scope)
}
},
}
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
/** /**
* The explorer's @-reference button. Directories append the folder mention * The explorer's @-reference button. Directories append the folder mention
@@ -1793,39 +1156,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
</div> </div>
)} )}
{/* {/*
Free windows: tabs dragged out onto the conversation area (or floated Free windows + the drag-out hint: the layer renders the floats (the
from the tab context menu). They live in the panel host like the array's order is the stacking order) reusing the regular tab
panels (viewport coordinates, immune to desktop-shell transforms) but renderer, followed by the dashed drop-zone overlay (sidebar/
are independent of panel state — a window stays up while panels free-windows.tsx renders both as one fragment — DOM unchanged).
collapse. The floats array's order is the stacking order; the content
reuses the regular tab renderer, so every tab type floats unchanged.
*/} */}
{state.floats.map(float => ( <FreeWindowLayer
<FreeWindow floats={state.floats}
key={float.id} hint={floatHint}
float={float} renderTab={renderTab}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'float')} getTabIcon={tabIconOf}
getTabIcon={tabIconOf} store={store}
onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }} ctx={ctx}
onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }} sessionId={sessionId}
onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }} cwd={cwd}
onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }} />
onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
/>
))}
{/*
The drag-out hint: while a tab drag hovers the conversation column,
a dashed overlay marks the drop zone there (pointer-transparent — it
must not disturb the drag it describes).
*/}
{floatHint !== null && (
<div
className={css.floatDropHint}
style={{ left: floatHint.left, top: floatHint.top, width: floatHint.width, height: floatHint.height }}
>
<span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
</div>
)}
</div> </div>
) )
} }
+74
View File
@@ -0,0 +1,74 @@
/**
* The tab-content cell + the + menu builder, extracted from Sidebar.tsx
* (behavior identical): one memoized cell dispatches a tab to its registry
* descriptor's component, and buildNewTabOptions derives the + menu rows
* from the same registry.
*/
import { createElement, memo } from 'react'
import type { Context } from '../../context-types.ts'
import type { SidebarState, SidebarStore, SidebarTab } from '../state.ts'
import type { SessionScope } from '../api.ts'
import { OrphanedTab } from '../OrphanedTab.tsx'
import { RenderBoundary } from '../RenderBoundary.tsx'
import { tabContentCompare, type TabContentMemoKey } from '../tab-content-memo.ts'
import type { NewTabOption } from '../TabBar.tsx'
import css from '../sidebar.module.css'
/** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus
* the runtime objects/callbacks the cell renders with. The memo comparator
* is the pure `tabContentCompare`; anything in the key decides a re-render
* must propagate, anything outside it must be a stable object (ctx/store)
* or covered by a compared field (paneId covers onOpenDiff's captured
* pane; sessionId/cwd cover onReferenceFile). */
interface TabContentProps extends TabContentMemoKey {
onToggleDir: (path: string) => void
onReferenceFile: (path: string, isDir: boolean) => void
ctx: Context
store: SidebarStore
/** 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
}
/** Render the content of one tab (dispatched by type). */
export const TabContent = memo(function TabContent(props: TabContentProps) {
const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
const scope = { sessionId, cwd }
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
if (descriptor === undefined) {
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
}
// For pinned virtual tabs, the tab descriptor's component (e.g. TerminalView)
// must receive the ORIGINAL tab id so it connects to the home session's PTY.
// The virtual tab's own id is a unique display key (prefixed); effectiveTabId
// restores the real id at the component boundary.
const componentTab = effectiveTabId !== undefined ? { ...tab, id: effectiveTabId } : tab
return createElement(
RenderBoundary,
{ className: css.tabBoundaryError },
createElement(descriptor.component, {
ctx, store, scope, tab: componentTab, visible, expanded, revealed,
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
}),
)
}, tabContentCompare)
/** 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. */
export function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
const service = ctx.get('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,
}))
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Free windows (extracted from Sidebar.tsx, behavior identical): the
* drag-out gesture that floats a tab onto the conversation column, plus the
* render layer for the floating windows and its drop-zone hint overlay.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { Context } from '../../context-types.ts'
import {
dockFloat, floatTab, moveFloat, raiseFloat, resizeFloat,
type FloatWindow, type SidebarStore, type SidebarTab,
} from '../state.ts'
import { TAB_DRAG_TYPE, parseDrag } from '../TabBar.tsx'
import { FreeWindow } from '../FreeWindow.tsx'
import { t } from '../locales.ts'
import css from '../sidebar.module.css'
/** The dashed drop-zone hint's geometry (viewport coordinates). */
export interface FloatDropHint {
left: number
top: number
width: number
height: number
}
/**
* Free windows — drag-out detection. The tab strips already drive HTML5
* DnD (payload application/x-dsh-tab) with drops owned by the panes
* (split/merge); this hook watches the DOCUMENT (capture) for the same
* drag hovering OUTSIDE the panel host: while the pointer is over the
* conversation column it arms the drop (preventDefault) and shows a hint
* overlay there, and the drop floats the tab at the release point. Targets
* inside the host are ignored here, so pane drops keep their behavior
* untouched. Only OUR tab drags count (the body flag is the tab strip's;
* OS file drags and any DSH drags pass through). Narrow viewports skip
* the gesture — the merged drawer covers the conversation, leaving
* nothing to drop onto (the tab context menu entry still floats tabs).
*/
export function useFloatDragout(input: {
narrow: boolean
sessionId: string | undefined
store: SidebarStore
/** The AppFrame center column (from useCenterColumn); stable ref object. */
centerColRef: { readonly current: HTMLElement | null }
}): { floatHint: FloatDropHint | null } {
const { narrow, sessionId, store, centerColRef } = input
const [floatHint, setFloatHint] = useState<FloatDropHint | null>(null)
const floatHintRef = useRef(false)
useEffect(() => {
if (narrow || sessionId === undefined) return
const inPanelHost = (target: EventTarget | null): boolean =>
target instanceof Element && target.closest('[data-dsh-panel-host]') !== null
/** The conversation column's rect when the pointer is over it (and not
* over our own surfaces); null otherwise. */
const overConversation = (event: DragEvent): DOMRect | null => {
if (inPanelHost(event.target)) return null
const col = centerColRef.current
if (col === null || !col.isConnected) return null
const rect = col.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return null
const { clientX: x, clientY: y } = event
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null
return rect
}
const onDragOver = (event: DragEvent): void => {
if (!document.body.hasAttribute('data-dsh-tab-dragging')) return
const rect = overConversation(event)
if (rect !== null) {
// preventDefault on dragover is what makes the browser deliver the
// drop (and drop the "no" cursor) over the conversation area.
event.preventDefault()
setFloatHint((prev) => {
const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
if (prev !== null && prev.left === next.left && prev.top === next.top
&& prev.width === next.width && prev.height === next.height) return prev
return next
})
floatHintRef.current = true
} else if (floatHintRef.current) {
floatHintRef.current = false
setFloatHint(null)
}
}
const onDrop = (event: DragEvent): void => {
if (!floatHintRef.current) return
floatHintRef.current = false
setFloatHint(null)
const rect = overConversation(event)
if (rect === null) return
event.preventDefault()
event.stopPropagation()
const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '')
if (payload === null) return
store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY))
}
const clear = (): void => {
if (!floatHintRef.current) return
floatHintRef.current = false
setFloatHint(null)
}
document.addEventListener('dragover', onDragOver, true)
document.addEventListener('drop', onDrop, true)
window.addEventListener('dragend', clear, true)
window.addEventListener('blur', clear)
return () => {
document.removeEventListener('dragover', onDragOver, true)
document.removeEventListener('drop', onDrop, true)
window.removeEventListener('dragend', clear, true)
window.removeEventListener('blur', clear)
}
// centerColRef is a stable ref object from useCenterColumn (it lost its
// useRef provenance crossing the hook boundary, so it is listed here).
}, [narrow, sessionId, store, centerColRef])
return { floatHint }
}
/**
* The free-window render layer: tabs dragged out onto the conversation area
* (or floated from the tab context menu). They live in the panel host like
* the panels (viewport coordinates, immune to desktop-shell transforms) but
* are independent of panel state — a window stays up while panels collapse.
* The floats array's order is the stacking order; the content reuses the
* regular tab renderer, so every tab type floats unchanged. Renders a
* fragment so the DOM is exactly the floats followed by the drag-out hint.
*/
export function FreeWindowLayer(props: {
floats: readonly FloatWindow[]
hint: FloatDropHint | null
renderTab: (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float') => ReactNode
getTabIcon: (tab: SidebarTab) => ReactNode
store: SidebarStore
ctx: Context
sessionId: string
cwd: string | undefined
}) {
const { floats, hint, renderTab, getTabIcon, store, ctx, sessionId, cwd } = props
return (
<>
{floats.map(float => (
<FreeWindow
key={float.id}
float={float}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'float')}
getTabIcon={getTabIcon}
onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }}
onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
/>
))}
{/*
The drag-out hint: while a tab drag hovers the conversation column,
a dashed overlay marks the drop zone there (pointer-transparent — it
must not disturb the drag it describes).
*/}
{hint !== null && (
<div
className={css.floatDropHint}
style={{ left: hint.left, top: hint.top, width: hint.width, height: hint.height }}
>
<span className={css.floatDropHintLabel}>{t('floatDropHint')}</span>
</div>
)}
</>
)
}
+171
View File
@@ -0,0 +1,171 @@
/**
* Center-column tracking (extracted from Sidebar.tsx, behavior identical):
* the bottom panel spans ONLY the app shell's center column ("squeezes the
* agent output area") — it starts at the app sidebar's right edge and ends
* at the details column's left edge (the details column sits between the
* center and the right panel). Measured directly from the AppFrame's center
* column DOM (the parent of the [data-slot="conversation"] wrapper —
* layout.css's center column) so the bottom panel tracks the column's real
* horizontal edges — including the animated AppFrame padding reservation
* while the right panel opens/closes; a frame that never appears keeps the
* initial zero-size fallback (the panel renders at 0 width until measured).
* The rect lives in a REF (not state): the open/close transition resizes
* the center column EVERY frame for its duration, and reacting per frame
* with setState re-renders the whole Sidebar (every mounted tab) at
* animation cadence — the visible toggle jank (#315). measureCenter
* writes the bottom panel's edges directly (same DOM-write pattern as
* applyDrag), so the panel still tracks the column per frame with zero
* React work; `centerMeasured` flips ONCE to gate the hidden→visible
* first-paint fallback.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { resolveCenterColumn } from '../center-column.ts'
export function useCenterColumn(
/** The bottom panel element: measureCenter writes its edges directly. */
bottomRef: { readonly current: HTMLDivElement | null },
/** Re-runs the whole locate/measure chain on change (opening the bottom
* panel re-runs the chain: a panel opened before the center column was
* ever found must not stay invisible forever). */
bottomOpen: boolean | undefined,
) {
const centerRectRef = useRef({ left: 0, right: 0 })
const [centerMeasured, setCenterMeasured] = useState(false)
// 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()
// Ref + direct DOM write (see the centerRectRef comment): the bottom
// panel keeps tracking the center column per frame during the right
// panel's open/close animation without re-rendering the shell. The
// one-shot measured flip renders the panel visible once (a stale
// {0,0} fallback would flash full-width).
centerRectRef.current = { left: rect.left, right: rect.right }
const bottom = bottomRef.current
if (bottom !== null) {
bottom.style.setProperty('left', `${rect.left}px`)
bottom.style.setProperty('right', `${window.innerWidth - rect.right}px`)
}
setCenterMeasured(prev => (prev ? prev : true))
}, [bottomRef])
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
// Hot path (#403): streaming output mutates #root at token cadence.
// Reuse a still-connected center column and only query after boot/HMR
// detached the cached node.
const col = resolveCenterColumn(centerColRef.current)
if (col === undefined || !col.isConnected) {
if (centerColRef.current !== null) {
centerColRef.current.removeAttribute('data-dsh-center-col')
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. The tag retargets with the ref: layout.css's
// bottom-push rule anchors on [data-dsh-center-col], so exactly the
// measured node carries it (a stale tag on a swapped-out node would
// leave the push rule anchorless or doubled).
centerColRef.current?.removeAttribute('data-dsh-center-col')
centerColRef.current = col
col.setAttribute('data-dsh-center-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
// Mid-drag every frame writes --dsh-sidebar-* on <html>'s style
// attribute, which is the mutation this watcher observes — relocating
// per drag frame is pointless (the center column node cannot change
// while the pointer is captured) and adds locator work to every
// frame's budget (#315). The 1.5s retry below still covers any node
// swap that somehow lands mid-drag.
if (draggingRef.current) 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 query-free while the
// cached column stays connected; only a detached/missing cache falls
// back to the document selector.
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?.removeAttribute('data-dsh-center-col')
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, bottomOpen])
return { centerColRef, centerRectRef, centerMeasured, measureCenter, draggingRef }
}
+269
View File
@@ -0,0 +1,269 @@
/**
* Host-feed subscriptions (extracted from Sidebar.tsx, behavior identical):
* the WebSocket pushes (agent terminals, agent opens) and the session-list
* driven auto-activation triggers (subagent, background jobs, topology
* jump-back). All of it reacts to the host's live feeds for the CURRENT
* session; the sidebar shell only consumes the returned jump-back ref.
*/
import { useEffect, useRef } from 'react'
import type { Context, SidebarSessionList } from '../../context-types.ts'
import { firstLeaf, reconcileAgentTerminals, togglePanel, type SidebarStore } from '../state.ts'
import { isNarrowWidth } from '../breakpoints.ts'
import { detectNewDirectSubagent } from '../subagent-detect.ts'
import { detectNewJob } from '../subagent-jobs.ts'
import { t } from '../locales.ts'
/** 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
/**
* Subagent auto-open debounce (ms). The host delivers a new child's origin
* and its title in SEPARATE frames: a Side Chat thread's first visible
* frame still shows a fallback title (no 'Side: ' prefix), so an immediate
* 0→N decision mistakes it for a genuine subagent and pops the task page.
* The trigger therefore re-evaluates against the live snapshot once the
* title frame has had time to land.
*/
const AUTO_OPEN_DEBOUNCE_MS = 500
export function useHostFeeds(feeds: {
ctx: Context
store: SidebarStore
sessionList: SidebarSessionList
sessionId: string | undefined
}): { subagentJumpRef: { current: string | undefined } } {
const { ctx, store, sessionList, sessionId } = feeds
/**
* 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.get('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, ctx, store])
/**
* Agent opens push: subscribe to the host's `sidebar_open` requests for
* this session (the model actively opens a file / folder / HTTP(S) page).
* The host pushes one JSON request per open; the sidebar routes it to the
* matching built-in tab: a file opens in the editor (per-path dedupe), a
* folder opens a file window whose tree is rooted at the folder
* (`meta.dir`), and a URL opens in the browser tab. A disconnected socket
* retries with a short backoff (mirror of the agent-terminals loop): the
* host queue keeps undelivered requests and replays them on the first
* attach, so a refresh or a session switch lands the opens the model
* queued while no view was connected.
* While the side-card setting is off, pushes are ignored as a defensive
* gate — the host already unregisters the tool and drains the queue.
*/
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-opens', 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 request = JSON.parse(event.data) as { kind?: unknown; target?: unknown; title?: unknown }
if (request === null || typeof request !== 'object') return
if (request.kind !== 'file' && request.kind !== 'folder' && request.kind !== 'url') return
if (typeof request.target !== 'string' || request.target === '') return
if (store.getPrefs().agentOpenTools !== true) return
const scope = { sessionId }
const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined
if (request.kind === 'url') {
ctx.get('betterSidebar')?.openTab({ type: 'browser', url: request.target, title }, scope)
} else if (request.kind === 'folder') {
ctx.get('betterSidebar')?.openTab({
type: 'editor',
title,
path: request.target,
id: `editor:${request.target}`,
meta: { dir: true },
}, scope)
} else {
ctx.get('betterSidebar')?.openFile(scope, request.target, title)
}
} catch {
// Malformed push: ignore (the next push carries its own request).
}
}
socket.onclose = () => {
if (closed) return
failures += 1
if (failures >= FAILURE_LIMIT) {
console.error('[dsh-better-sidebar] agent-opens 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, ctx, 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 Tasks tab type is enabled in settings, activate
* the Tasks page. Single-instance semantics focus an existing pane tab in
* place or raise an existing free window; a new tab lands in the right pane
* and is never duplicated. On wide viewports the right panel also expands;
* on narrow viewports background activity never forces the full-screen
* drawer open over the chat.
* Switching to a session that already has subagents never triggers — its
* baseline starts at the current count — so a deliberate layout is never
* fought.
*
* The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread
* is also a subagent-origin child, and its 'Side: ' title lands one frame
* after its origin — an immediate check would misread that first frame as
* a new subagent and pop this page on every thread creation. The timer
* re-evaluates the ORIGINAL baseline against the live snapshot; by then
* the title filter (isSideThreadSummary) sees the settled label.
*/
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null)
useEffect(() => {
const prev = listBaselineRef.current
listBaselineRef.current = sessionList
if (sessionId === undefined || prev === undefined) return
if (autoOpenPendingRef.current !== null) return
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
const baseline = prev
const timer = window.setTimeout(() => {
autoOpenPendingRef.current = null
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
// Read the viewport when the delayed activation fires: a resize while
// the debounce is armed must not let background activity force the
// narrow full-screen drawer open over the chat.
if (!isNarrowWidth(window.innerWidth)) {
store.reduce(s => s.panelOpen ? s : togglePanel(s))
}
// Choose the right panel as the landing pane for a newly created Tasks
// tab. Single-instance dedupe still activates an existing pane tab in
// place or raises an existing free window.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
}, AUTO_OPEN_DEBOUNCE_MS)
autoOpenPendingRef.current = { baseline, timer }
}, [sessionList, sessionId, store, ctx])
// A session switch (or unmount) voids any armed auto-open recheck.
useEffect(() => () => {
const pending = autoOpenPendingRef.current
if (pending !== null) window.clearTimeout(pending.timer)
autoOpenPendingRef.current = null
}, [sessionId])
/**
* 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 Tasks tab type is enabled, activate the Tasks
* page that contains the background-jobs section. The right panel expands
* only on wide viewports. 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.get('betterSidebar')?.isTabEnabled('subagent') === false) return
if (!isNarrowWidth(window.innerWidth)) {
store.reduce(s => s.panelOpen ? s : togglePanel(s))
}
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
}, [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.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionId, store, ctx])
return { subagentJumpRef }
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Inline pinned terminals (extracted from Sidebar.tsx, behavior identical):
* pinned tabs from OTHER sessions inject as VIRTUAL tabs into the first
* leaf of the right panel's split tree, and the workbench actions are
* wrapped so virtual ids route back to the HOME session. The shell only
* consumes the augmented tree and the wrapped actions.
*/
import { useMemo, useState } from 'react'
import {
agentUuidOf, closeFloatByTab, closeTab, isAgentTabId, leafWithTab, setTabPin,
type SidebarSnapshot, type SidebarStore, type SplitNode,
} from '../state.ts'
import {
collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree,
isPinnedVirtualId, parsePinnedVirtualId, type PinnedTabEntry,
} from '../pinned.ts'
import type { WorkbenchActions } from '../split-pane.tsx'
import { api } from '../api.ts'
export function usePinnedTabs(input: {
store: SidebarStore
sessionId: string | undefined
cwd: string | undefined
snapshot: SidebarSnapshot
actions: WorkbenchActions
}): { augmentedTree: SplitNode | undefined; wrappedActions: WorkbenchActions } {
const { store, sessionId, cwd, snapshot, actions } = input
const state = snapshot.state
/**
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
* inject as VIRTUAL tabs into the first leaf of the right panel's split
* tree. The virtual tabs have unique ids (prefixed with the home session)
* and carry the home scope in meta. Clicking a virtual tab sets
* `activePinnedTabId` — the augmented tree overrides the leaf's `active`
* so the pinned tab's content renders in-place (TerminalView connects to
* the home session's PTY via WS, no session jump).
*
* Closing/unpinning a virtual tab targets the HOME session via reduceFor
* (which doesn't notify — targeted opens must not re-render the active
* session). The `pinnedRevision` state bump forces the pinnedEntries
* useMemo to recompute after such an action.
*/
const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
const [pinnedRevision, setPinnedRevision] = useState(0)
/**
* Cross-session pinned-tab collection. Recomputed on every store notify,
* session-list change, and pinned action (the revision bump covers
* reduceFor updates that don't notify). Only tabs from OTHER sessions —
* the viewer's own pinned tabs are already on its tab strip.
*/
const pinnedEntries: readonly PinnedTabEntry[] = useMemo(() => {
if (sessionId === undefined) return []
return collectPinnedTabs(store.getSessionStates(), { sessionId, cwd })
// snapshot / pinnedRevision are deliberate cache-busters (see the comment
// above): they recompute this memo for changes that carry no dep of their own.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [store, sessionId, cwd, snapshot, pinnedRevision])
/** Virtual SidebarTab objects for the pinned entries (stable references
* via useMemo so TabContent's memo comparator holds). */
const pinnedVirtualTabs = useMemo(
() => pinnedEntries.map(createPinnedVirtualTab),
[pinnedEntries],
)
/** The right panel's split tree with pinned virtual tabs injected into the
* first leaf. When `activePinnedTabId` is set, that leaf's `active` is
* overridden so the pinned tab's content is visible. */
const augmentedTree = useMemo(
() => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
[state, pinnedVirtualTabs, activePinnedTabId],
)
/**
* Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from
* other sessions). Regular tab ids pass through unchanged. Virtual ids are
* detected by the `pinned:` prefix and routed to the HOME session via
* reduceFor (which doesn't notify — the revision bump is the local signal).
*/
const wrappedActions = useMemo<WorkbenchActions>(() => {
if (pinnedVirtualTabs.length === 0) return actions
const closePinnedInHome = (virtualId: string): void => {
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
// The home cwd lives in the virtual tab's meta (snapshotted at pin
// time) — pass it to ptyClose so the host resolves the PTY in the
// correct workspace container (same scope the WS open used).
const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
store.reduceFor(homeSessionId, s => {
const leaf = leafWithTab(s.splits, originalId) ?? leafWithTab(s.bottomSplits, originalId)
if (leaf !== undefined) return closeTab(s, leaf.id, originalId)
if (s.floats.some(f => f.tab.id === originalId)) return closeFloatByTab(s, originalId)
return s
})
if (isAgentTabId(originalId)) {
void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
} else {
void api.ptyClose({ sessionId: homeSessionId, ...(homeCwd !== undefined ? { cwd: homeCwd } : {}) }, originalId).catch(() => { /* already released */ })
}
if (activePinnedTabId === virtualId) setActivePinnedTabId(null)
setPinnedRevision(v => v + 1)
}
return {
...actions,
activateTab: (paneId, tabId) => {
if (isPinnedVirtualId(tabId)) {
setActivePinnedTabId(tabId)
} else {
setActivePinnedTabId(null)
actions.activateTab(paneId, tabId)
}
},
closeTab: (paneId, tabId) => {
if (isPinnedVirtualId(tabId)) {
closePinnedInHome(tabId)
} else {
actions.closeTab(paneId, tabId)
}
},
moveTabBefore: (payload, toPane, beforeTabId) => {
if (isPinnedVirtualId(payload.tabId)) return
if (isPinnedVirtualId(beforeTabId)) {
actions.moveTabToEdge(payload, toPane, 'center')
} else {
actions.moveTabBefore(payload, toPane, beforeTabId)
}
},
moveTabToEdge: (payload, toPane, zone) => {
if (isPinnedVirtualId(payload.tabId)) return
actions.moveTabToEdge(payload, toPane, zone)
},
floatTab: (tabId) => {
if (isPinnedVirtualId(tabId)) return
actions.floatTab(tabId)
},
pinTab: (tabId, scope) => {
if (isPinnedVirtualId(tabId)) {
if (scope !== null) return
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(tabId)
store.reduceFor(homeSessionId, s => setTabPin(s, originalId, null))
if (activePinnedTabId === tabId) setActivePinnedTabId(null)
setPinnedRevision(v => v + 1)
} else {
actions.pinTab?.(tabId, scope)
}
},
}
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
return { augmentedTree, wrappedActions }
}