/** * The sidebar shell: a fixed-position right panel portalled onto * document.body (the core AppFrame owns the left sidebar / center / details * columns and has no right-side hole for plugins), plus the edge toggle * button. The panel width drags from its left edge; the whole layout lives * in the per-session store, so switching conversations swaps the sidebar. * * The shell binds the workbench actions to the store and dispatches tab * content to the four views. New tabs come from the + menu (explorer / git / * terminal; editors open from the explorer). */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts' import { allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab, resizeSplit, setWidth, toggleExpanded, togglePanel, TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, } from './state.ts' import { Workbench, type WorkbenchActions } from './split-pane.tsx' import type { NewTabOption } from './TabBar.tsx' import type { TabDragPayload } from './TabBar.tsx' import { tabTypeIcon } from './icons.tsx' import { relativeTo } from './paths.ts' import { ExplorerView } from './ExplorerView.tsx' import { EditorView } from './EditorView.tsx' import { TerminalView } from './TerminalView.tsx' import { GitView } from './GitView.tsx' import { SubagentView } from './SubagentView.tsx' import { detectNewDirectSubagent } from './subagent-detect.ts' import { t } from './locales.ts' import { api } from './api.ts' import { openSidebarFile } from './intercept.tsx' import css from './sidebar.module.css' /** Render the content of one tab (dispatched by type). */ function TabContent(props: { tab: SidebarTab sessionId: string cwd: string | undefined expanded: string[] onToggleDir: (path: string) => void onReferenceFile: (path: string) => void ctx: Context store: SidebarStore /** Whether this tab is the active one AND the panel is open (live views pause otherwise). */ visible: boolean /** Fired before a topology node jumps to its child session (see Sidebar). */ onSubagentJump: (childSessionId: string) => void }) { const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump } = props const scope = { sessionId, cwd } switch (tab.type) { case 'explorer': return ( { openSidebarFile(ctx, store, sessionId, path) }} onReferenceFile={onReferenceFile} /> ) case 'git': return case 'terminal': return case 'editor': return case 'subagent': return ( { onSubagentJump(address.childSessionId) }} /> ) } } /** The + menu options for the current state (terminal cap applied). */ function buildNewTabOptions(state: SidebarState): NewTabOption[] { const terminalCount = allLeaves(state.splits) .flatMap(leaf => leaf.tabs) .filter(tab => tab.type === 'terminal').length return [ { id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) }, { id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) }, { id: 'subagent', label: t('openSubagent'), icon: tabTypeIcon('subagent', 16) }, { id: 'terminal', label: terminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'), disabled: terminalCount >= TERMINAL_LIMIT, icon: tabTypeIcon('terminal', 16), }, ] } export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const { ctx, store } = props // Current conversation (the sessions list feed). const sessionList = useSyncExternalStore( useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]), useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]), ) const current = sessionList.current // Per-session sidebar state. const snapshot = useSyncExternalStore( useCallback((callback: () => void) => store.subscribe(callback), [store]), useCallback(() => store.getSnapshot(), [store]), ) useEffect(() => { store.setSession(current) }, [current, store]) const state = snapshot.state const sessionId = snapshot.sessionId const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd // While the session's header is still hydrating (or the session is blank), // the list summary may carry no cwd; ask the host once (it falls back to // the process cwd) so the explorer root and terminal cwd are real from // first paint instead of showing "no session". const [fetchedCwd, setFetchedCwd] = useState(undefined) useEffect(() => { setFetchedCwd(undefined) if (sessionId === undefined || summaryCwd !== undefined) return let cancelled = false api.sessionCwd({ sessionId }) .then(result => { if (!cancelled) setFetchedCwd(result.cwd) }) .catch(() => { /* the explorer/git rows surface their own errors */ }) return () => { cancelled = true } }, [sessionId, summaryCwd]) const cwd = summaryCwd ?? fetchedCwd /** * Subagent auto-activation: the moment the current conversation spawns its * FIRST direct subagent (a 0 → N transition on the list feed) and the * "auto open" pref is on, open the panel (if collapsed) and focus the * Subagent page (single-instance: an existing tab is focused, never * duplicated). Switching to a session that already has subagents never * triggers — its baseline starts at the current count — so a deliberate * layout is never fought. */ const listBaselineRef = useRef(undefined) useEffect(() => { const prev = listBaselineRef.current listBaselineRef.current = sessionList if (sessionId === undefined || prev === undefined) return if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return if (!store.getPrefs().autoOpenSubagent) return store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), { id: 'subagent', type: 'subagent', title: t('subagent'), })) }, [sessionList, sessionId, store]) /** * 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(undefined) useEffect(() => { const pending = subagentJumpRef.current if (pending === undefined || sessionId !== pending) return subagentJumpRef.current = undefined store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), { id: 'subagent', type: 'subagent', title: t('subagent'), })) }, [sessionId, store]) // Panel width drag (left edge strip). const widthDrag = useRef({ startX: 0, startWidth: 0 }) const [draggingWidth, setDraggingWidth] = useState(false) // Layout push: the app shell gives up the panel's width while it is open // (0 while collapsed), so the conversation and input bar are squeezed // instead of covered. The margin is capped at the viewport so a stale // persisted width (e.g. fullscreen on a bigger window) can never crush // the app shell to zero. Dragging disables the layout transition. useEffect(() => { const width = snapshot.state?.panelOpen === true ? Math.min(snapshot.state.width, window.innerWidth) : 0 document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`) }, [snapshot.state?.panelOpen, snapshot.state?.width]) useEffect(() => { if (draggingWidth) document.body.setAttribute('data-dsh-sidebar-dragging', '') else document.body.removeAttribute('data-dsh-sidebar-dragging') }, [draggingWidth]) // Whether the panel currently fills the viewport (fullscreen expansion). const fullscreen = state !== undefined && window.innerWidth - state.width < 8 const actions: WorkbenchActions = useMemo(() => ({ closeTab: (paneId, tabId) => { // A closed terminal releases its pty immediately — including when its // socket is mid-reconnect, where the unmount close frame never reaches // the host and the process would hold the quota until the grace ends. const current = store.getSnapshot().state const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId) const tab = leaf?.tabs.find(candidate => candidate.id === tabId) store.reduce(s => closeTab(s, paneId, tabId)) if (sessionId !== undefined && tab?.type === 'terminal') { void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ }) } }, activateTab: (paneId, tabId) => { store.reduce(s => ({ ...s, activePane: paneId, splits: mapLeaf(s.splits, paneId, (leaf) => { if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId }), })) }, focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) }, moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => { store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone)) }, moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => { store.reduce((s) => { let index = -1 const source = leafWithTab(s.splits, beforeTabId) if (source !== undefined && source.id === toPane) { index = source.tabs.findIndex(tab => tab.id === beforeTabId) } return moveTab(s, payload.paneId, payload.tabId, toPane, index) }) }, resizeSplit: (splitId, index, deltaFrac) => { store.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) })) }, }), [store, sessionId, cwd]) /** * The explorer's @-reference button: append `@` to the * session's composer draft (space-separated). The conversation service is * resolved lazily through `ctx.get` (the inject-free read — the app's own * plugins read 'conversation' the same way); a missing service or scope * degrades to a logged no-op, never a crash. Defined above the no-session * early return — a hook must never sit behind a conditional return * (React counts hooks per render). */ const referenceInChat = useCallback((path: string): void => { if (sessionId === undefined) return try { const actx = ctx.sessions.scope(sessionId) if (actx === undefined) return const conversation = ctx.get('conversation') as SidebarConversation | undefined if (conversation === undefined) return const input = conversation.input.for(actx) const mention = `@${relativeTo(cwd ?? '', path)}` const draft = input.state.getSnapshot().draft input.setDraft(draft.trim() === '' ? mention : `${draft} ${mention}`) } catch (error) { console.warn('[dsh-better-sidebar] reference insert failed:', error) } }, [ctx, sessionId, cwd]) if (state === undefined || sessionId === undefined) { return (
) } const onNewTab = (optionId: string): void => { store.reduce((s) => { if (optionId === 'explorer') { return openTab(s, { id: 'explorer', type: 'explorer', title: t('explorer') }) } if (optionId === 'git') { return openTab(s, { id: 'git', type: 'git', title: t('git') }) } if (optionId === 'subagent') { return openTab(s, { id: 'subagent', type: 'subagent', title: t('subagent') }) } if (optionId === 'terminal') { const count = allLeaves(s.splits).flatMap(leaf => leaf.tabs).filter(tab => tab.type === 'terminal').length if (count >= TERMINAL_LIMIT) return s const tab: SidebarTab = { id: `terminal:${s.nextTerminal}`, type: 'terminal', title: `${t('terminal')} ${s.nextTerminal}`, } return { ...openTab(s, tab), nextTerminal: s.nextTerminal + 1 } } return s }) } /** * The explorer's @-reference button: append `@` to the * session's composer draft (space-separated). Resolves the session-scope * ctx and the conversation input service at click time; a missing service * or scope degrades to a logged no-op, never a crash. */ /** * Render one tab's content. `active` (from the workbench) tells whether * this tab is the active one in its pane; combined with the panel's * open/closed state it gates live views (the Subagent topology pauses its * polling while the page is not actually visible). */ const renderTab = (tab: SidebarTab, active: boolean) => ( { store.reduce(s => toggleExpanded(s, path)) }} onReferenceFile={referenceInChat} ctx={ctx} store={store} visible={state.panelOpen && active} onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }} /> ) return ( <> {!state.panelOpen && (
)} {/* The panel stays mounted while collapsed (hidden off-screen) so the slide in/out can animate; visibility hides it after the slide settles. */}
{ event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) widthDrag.current = { startX: event.clientX, startWidth: state.width } setDraggingWidth(true) }} onPointerMove={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return const { startX, startWidth } = widthDrag.current store.reduce(s => setWidth(s, startWidth + (startX - event.clientX))) }} onPointerUp={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return event.currentTarget.releasePointerCapture(event.pointerId) setDraggingWidth(false) }} />
{t('explorer')} {cwd !== undefined ? ` · ${cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd}` : ''}
) }