diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx
index 1f824a2..39b0356 100644
--- a/src/client/Sidebar.tsx
+++ b/src/client/Sidebar.tsx
@@ -15,7 +15,7 @@ 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 {
- agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab, openTab,
+ agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals,
resizeSplit, setWidth, toggleExpanded, togglePanel,
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
@@ -185,12 +185,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
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])
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
+ ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
+ }, [sessionList, sessionId, store, ctx])
/**
* Topology jump-back: clicking a subagent node on the Subagent page calls
@@ -208,12 +205,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
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])
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
+ ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
+ }, [sessionId, store, ctx])
// Panel width drag (left edge strip).
const widthDrag = useRef({ startX: 0, startWidth: 0 })
@@ -443,6 +437,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
+ getTabIcon={(tab) => {
+ const descriptor = ctx.betterSidebar?.getTab(tab.type)
+ if (descriptor === undefined) return null
+ return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
+ }}
/>
diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx
index 493f99b..116c9af 100644
--- a/src/client/TabBar.tsx
+++ b/src/client/TabBar.tsx
@@ -11,7 +11,6 @@ import {
IconCloseFill14, IconPlusOutline16, Menu,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTab } from './state.ts'
-import { tabTypeIcon } from './icons.tsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
@@ -62,9 +61,11 @@ export function TabBar(props: {
newTabOptions: NewTabOption[]
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
onDropTab: (payload: TabDragPayload, before: string | null) => void
+ /** Icon resolver for tab labels (reads from the tab descriptor registry). */
+ getTabIcon?: (tab: SidebarTab) => ReactNode
}) {
const {
- paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab,
+ paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
} = props
const [menuOpen, setMenuOpen] = useState(false)
const [dragOver, setDragOver] = useState(false)
@@ -134,7 +135,7 @@ export function TabBar(props: {
}
}}
>
- {tabTypeIcon(tab.type, 14)}
+ {getTabIcon?.(tab) ?? null}
{tab.title}
))}
@@ -123,8 +122,9 @@ function LeafView(props: {
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
+ getTabIcon?: (tab: SidebarTab) => ReactNode
}) {
- const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
+ const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
const [dropZone, setDropZone] = useState(null)
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
@@ -177,6 +177,7 @@ function LeafView(props: {
onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }}
onNewTab={onNewTab}
newTabOptions={newTabOptions}
+ getTabIcon={getTabIcon}
onDropTab={(payload, before) => {
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
else actions.moveTabBefore(payload, leaf.id, before)
@@ -215,8 +216,9 @@ function NodeView(props: {
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
+ getTabIcon?: (tab: SidebarTab) => ReactNode
}) {
- const { node, state, newTabOptions, actions, onNewTab, renderTab } = props
+ const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
if (node.kind === 'leaf') {
return (
)
}
@@ -250,6 +253,7 @@ function NodeView(props: {
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
+ getTabIcon={getTabIcon}
/>
@@ -265,8 +269,9 @@ export function Workbench(props: {
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
+ getTabIcon?: (tab: SidebarTab) => ReactNode
}) {
- const { state, newTabOptions, actions, onNewTab, renderTab } = props
+ const { state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
return (
)
diff --git a/src/client/state.ts b/src/client/state.ts
index 82eb64a..c6ce084 100644
--- a/src/client/state.ts
+++ b/src/client/state.ts
@@ -309,15 +309,13 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
}
}
-/** Whether a tab type is single-instance per session (explorer/git/subagent). */
-function isSingle(type: TabType): boolean {
- return type === 'explorer' || type === 'git' || type === 'subagent'
-}
-
/**
- * Open a tab (or focus its existing instance). Single-instance types focus
- * the existing tab wherever it lives; editors dedupe by path. The tab lands
- * in the active pane (or the first pane when the active one is gone).
+ * Open a tab (or focus its existing instance by id). Dedup strategies
+ * (single-instance, per-path, per-change) are owned by the tab descriptor
+ * through {@link BetterSidebarService.openTab} / `dedupeKey`; this reducer
+ * only handles the id-based safety net (reconcile/openDiffTab already
+ * check existence before calling) and lands the tab in the active pane
+ * (or the first pane when the active one is gone).
*/
export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
let targetId = state.activePane ?? firstLeaf(state.splits).id
@@ -326,24 +324,10 @@ export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) {
targetId = firstLeaf(state.splits).id
}
- if (isSingle(tab.type)) {
- for (const leaf of allLeaves(state.splits)) {
- const existing = leaf.tabs.find(candidate => candidate.type === tab.type)
- if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
- }
- }
- if (tab.type === 'editor' && tab.path !== undefined) {
- for (const leaf of allLeaves(state.splits)) {
- const existing = leaf.tabs.find(candidate => candidate.type === 'editor' && candidate.path === tab.path)
- if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
- }
- }
- if (tab.type === 'diff') {
- // One tab per change: the same path+side (or commit) focuses its instance.
- for (const leaf of allLeaves(state.splits)) {
- const existing = leaf.tabs.find(candidate => candidate.type === 'diff' && candidate.id === tab.id)
- if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
- }
+ // Id-based safety net: if a tab with the same id exists, focus it.
+ for (const leaf of allLeaves(state.splits)) {
+ const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
+ if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
return {
...state,
diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts
index 2a36ba1..c16fdc2 100644
--- a/tests/unit.spec.ts
+++ b/tests/unit.spec.ts
@@ -214,23 +214,26 @@ describe('git parsing', () => {
describe('sidebar state', () => {
const state = (): SidebarState => makeDefaultState()
- it('opens tabs into the active pane and dedupes single-instance types', () => {
+ it('opens tabs into the active pane and dedupes by id (safety net)', () => {
let s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
s = openTab(s, gitTab)
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
- // Reopening git focuses the existing tab instead of duplicating.
- const after = openTab(s, { id: 'git2', type: 'git' as const, title: 'Git' })
+ // Reopening with the SAME id focuses the existing tab instead of duplicating.
+ const after = openTab(s, { id: 'git', type: 'git' as const, title: 'Git' })
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
+ // A different id opens a new tab (type-level dedupe is the service's job).
+ const after2 = openTab(s, { id: 'git2', type: 'git' as const, title: 'Git' })
+ expect((after2.splits as { tabs: unknown[] }).tabs).toHaveLength(3)
})
- it('dedupes editors by path', () => {
+ it('opens multiple editors with distinct ids (path-level dedupe is the service descriptor\'s job)', () => {
let s = state()
const firstId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
s = openTab(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
const after = openTab(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
- expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1'])
+ expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1', 'e2'])
})
const diffTab = (id: string): SidebarTab => ({