diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx
index e2fbe0a..acdbb9f 100644
--- a/src/client/Sidebar.tsx
+++ b/src/client/Sidebar.tsx
@@ -28,7 +28,7 @@
* drawer floats). Widening does not migrate back: the tabs keep living in
* 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 clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -51,17 +51,14 @@ import { parseDesktopEnv } from './desktop-env.ts'
import { getWcoSnapshot, subscribeWco } from './wco.ts'
import { getShellPreset } from './shell-presets.ts'
import { computeTitleBarStrip } from './titlebar-strip.ts'
-import type { NewTabOption } from './TabBar.tsx'
+import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
import { FreeWindow } from './FreeWindow.tsx'
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 { api, type SessionScope } from './api.ts'
+import { api } from './api.ts'
import css from './sidebar.module.css'
/** How many consecutive reconnect failures stop the agent-terminals push loop
@@ -119,65 +116,6 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
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
- }
- // 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 }) {
const { ctx, store } = props
diff --git a/src/client/sidebar/TabContent.tsx b/src/client/sidebar/TabContent.tsx
new file mode 100644
index 0000000..4755ec4
--- /dev/null
+++ b/src/client/sidebar/TabContent.tsx
@@ -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
+ }
+ // 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,
+ }))
+}