Merge pull request #542 from omdsh-dev/feat/split-sidebar
feat(client): Sidebar.tsx 按关注点增量拆分
This commit is contained in:
+53
-708
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user