diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx
index 9af63ed..fb159ac 100644
--- a/src/client/SideCardSection.tsx
+++ b/src/client/SideCardSection.tsx
@@ -32,6 +32,7 @@
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
import {
IconCheckOutline16,
+ IconCodeOutline16,
IconPanelLeftOutline16,
IconSettingsOutline16,
Input,
@@ -347,6 +348,13 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
{t('settingsWidthSuffix')}
+ {renderCard({
+ title: t('settingsOpenPathTitle'),
+ desc: t('settingsOpenPathDesc'),
+ icon: ,
+ enabled: prefs.interceptOpenPath,
+ onToggle: (next) => { togglePref({ interceptOpenPath: next }) },
+ })}
{/* 侧边栏内容: one small card per registered tab type in a responsive
grid; features declaring `settings.toggles` open their settings in
diff --git a/src/client/index.tsx b/src/client/index.tsx
index 0af52ee..a7bf720 100644
--- a/src/client/index.tsx
+++ b/src/client/index.tsx
@@ -15,7 +15,7 @@ import { createSidebarStore } from './state.ts'
import { createBetterSidebarService } from './service.ts'
import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx'
-import { registerTurnTailInterception } from './intercept.tsx'
+import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
import { loadPrefs } from './prefs.ts'
import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts'
@@ -24,7 +24,7 @@ import css from './sidebar.module.css'
import './layout.css'
/** Services required before mounting (provided by the client runtime). */
-export const inject = ['slots', 'sessions', 'connection']
+export const inject = ['slots', 'sessions', 'connection', 'workspaces']
/**
* Error boundary over the sidebar tree: a render error must never blank the
@@ -146,6 +146,18 @@ export function apply(ctx: Context): void {
'dsh-better-sidebar: turn-tail interception',
)
+ ctx.effect(
+ () => {
+ try {
+ return registerOpenPathInterception(ctx, sidebarStore)
+ } catch (error) {
+ fail('interception', error)
+ return undefined
+ }
+ },
+ 'dsh-better-sidebar: open-path interception',
+ )
+
// The "Side card" settings section: appears in the DSH Settings shell
// once the shell's declaration is on the ledger (slots.inject waits for
// it); the section reads/writes the prefs through the plugin's own
diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx
index a34cf0d..867fc48 100644
--- a/src/client/intercept.tsx
+++ b/src/client/intercept.tsx
@@ -11,6 +11,7 @@ import type { Context } from '../context-types.ts'
import type { SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
+import { wrapOpenPath } from './openpath-intercept.ts'
import css from './sidebar.module.css'
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
@@ -74,3 +75,20 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
}),
}, SidebarProducedFiles)
}
+
+/**
+ * Register the chat file-open interception: wraps `ctx.workspaces.openPath`
+ * — the single funnel every chat-side file open goes through (tool-row path
+ * links, the produced-files row, prose mentions) — so opens land in the
+ * sidebar editor instead of the Host OS. Gated by BOTH the `interceptOpenPath`
+ * pref and the editor tab's enable switch; declined opens fall through to
+ * the original method. Returns the disposer restoring the original (HMR-safe).
+ */
+export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
+ return wrapOpenPath(ctx.workspaces, {
+ takeoverEnabled: () => store.getPrefs().interceptOpenPath !== false
+ && store.getPrefs().tabsEnabled['editor'] !== false,
+ currentSessionId: () => ctx.sessions.list.getSnapshot().current,
+ openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
+ })
+}
diff --git a/src/client/locales.ts b/src/client/locales.ts
index 9e7cb42..d138dd1 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -105,6 +105,8 @@ const zh = {
settingsWidthTitle: '默认宽度占比',
settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
settingsWidthSuffix: '%',
+ settingsOpenPathTitle: '聊天区文件在侧边栏打开',
+ settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
settingsSaveFailed: '保存失败',
settingsConflict: '设置已被其他窗口修改,请重试',
binaryNoPreview: '此文件类型不支持预览',
@@ -251,6 +253,8 @@ const en: Record = {
settingsWidthTitle: 'Default width share',
settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)',
settingsWidthSuffix: '%',
+ settingsOpenPathTitle: 'Open chat files in the sidebar',
+ settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
settingsSaveFailed: 'Failed to save',
settingsConflict: 'The setting changed in another window — please retry',
binaryNoPreview: 'This file type cannot be previewed',
diff --git a/src/client/openpath-intercept.ts b/src/client/openpath-intercept.ts
new file mode 100644
index 0000000..708e897
--- /dev/null
+++ b/src/client/openpath-intercept.ts
@@ -0,0 +1,62 @@
+/**
+ * Interception of the chat's file-open funnel. The client runtime's
+ * `ctx.workspaces.openPath` is the SINGLE door every chat-side file open goes
+ * through — ui-conversation's apply.ts resolves the path against the session
+ * cwd and calls it for tool-row path links, the produced-files row, and
+ * prose file mentions alike (verified against the DSH source:
+ * `packages/client/ui-conversation/src/client/apply.ts` is the only
+ * production caller). Wrapping that one method reroutes those opens into the
+ * sidebar editor instead of the Host OS — no DSH modification needed.
+ *
+ * The wrapper is dependency-free by design (no React / ui-primitives), so
+ * the takeover logic is unit-testable and the file stays importable from the
+ * test runtime.
+ */
+
+/** The one service method the wrapper replaces (mirror of the runtime IWorkspaces). */
+export interface OpenPathService {
+ openPath(path: string): Promise
+}
+
+/** Per-call decisions the wrapper needs (wired to the store + ctx in the client half). */
+export interface OpenPathInterceptDeps {
+ /**
+ * Whether to take over this call: the `interceptOpenPath` pref AND the
+ * editor tab's own enable switch must both be on (an editor that cannot
+ * open must not swallow opens — they fall through to the Host).
+ */
+ takeoverEnabled(): boolean
+ /** The session whose scope the sidebar editor loads the file in (current session). */
+ currentSessionId(): string | undefined
+ /** Route the open into the sidebar editor (the established openSidebarFile). */
+ openInSidebar(path: string, sessionId: string): void
+}
+
+/**
+ * Wrap `workspaces.openPath`: intercepted calls open the file in the sidebar
+ * editor instead of the Host OS and resolve as success (the original's
+ * callers ignore the result); anything that declines falls through to the
+ * original method untouched.
+ * @param workspaces - the client workspaces service to wrap.
+ * @param deps - per-call takeover decisions.
+ * @returns the disposer restoring the original method (HMR-safe).
+ */
+export function wrapOpenPath(workspaces: OpenPathService, deps: OpenPathInterceptDeps): () => void {
+ // The RAW method reference (never a bound copy): restore must put back the
+ // exact original so a chain of wrappers (other plugins wrapping the same
+ // method) keeps working across disposals in any order.
+ const original = workspaces.openPath
+ workspaces.openPath = (path: string): Promise => {
+ if (deps.takeoverEnabled()) {
+ const sessionId = deps.currentSessionId()
+ if (sessionId !== undefined) {
+ deps.openInSidebar(path, sessionId)
+ return Promise.resolve()
+ }
+ }
+ return original.call(workspaces, path)
+ }
+ return () => {
+ workspaces.openPath = original
+ }
+}
diff --git a/src/client/prefs.ts b/src/client/prefs.ts
index c7d3d82..9f973ce 100644
--- a/src/client/prefs.ts
+++ b/src/client/prefs.ts
@@ -43,6 +43,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
? record.agentTerminalTools
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
+ interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
+ ? record.interceptOpenPath
+ : SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
tabsEnabled: booleanMapOf(record.tabsEnabled),
viewersEnabled: booleanMapOf(record.viewersEnabled),
}
diff --git a/src/config.ts b/src/config.ts
index 3b4774a..e744c3e 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -80,6 +80,7 @@ export const PrefsSchema: z = z.object({
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
autoOpenSubagent: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false),
+ interceptOpenPath: z.boolean().default(true),
// Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or
// external): an absent key means enabled, so old documents resolve to {}
// (everything on) with no migration. Non-boolean values fail validation.
diff --git a/src/context-types.ts b/src/context-types.ts
index aab1b7a..ba7716b 100644
--- a/src/context-types.ts
+++ b/src/context-types.ts
@@ -232,6 +232,17 @@ export interface SidebarConversation {
}
}
+/**
+ * The client workspaces service face (mirror of the runtime IWorkspaces). Only
+ * the chat's file-open funnel is touched: `openPath` hands an absolute path
+ * to the Host OS's default application, and every chat-side file open
+ * (tool rows, produced-files, prose mentions) funnels through it.
+ */
+export interface SidebarWorkspacesService {
+ /** Open a filesystem path with the Host operating system's default application. */
+ openPath(path: string): Promise
+}
+
/**
* The invariant service face (mirror of @deepseek-ai/dsh-invariants'
* InvariantService). The upstream augmentation does not reach this Context
@@ -307,6 +318,7 @@ declare module 'cordis' {
connection: SidebarConnectionHandle
loader: SidebarLoader
slots: SidebarSlotsService
+ workspaces: SidebarWorkspacesService
settings: SidebarSettingsService
invariants: SidebarInvariantsService
tools: SidebarToolsService
diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts
index 2b1742f..9d9178b 100644
--- a/src/prefs-shared.ts
+++ b/src/prefs-shared.ts
@@ -27,6 +27,14 @@ export interface SidebarPrefs {
* user explicitly enables it in the side card settings.
*/
agentTerminalTools: boolean
+ /**
+ * Whether chat-side file opens (tool-row path links, the produced-files
+ * row, prose file mentions — every path that funnels through the client
+ * runtime's `ctx.workspaces.openPath`) open in the sidebar editor instead
+ * of the Host OS's default application. On by default; the editor tab's
+ * own enable switch gates it too (both must be on for the takeover).
+ */
+ interceptOpenPath: boolean
/**
* Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit
@@ -57,6 +65,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true,
agentTerminalTools: false,
+ interceptOpenPath: true,
tabsEnabled: {},
viewersEnabled: {},
}
diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts
index b473251..f6858bd 100644
--- a/tests/plugin-shape.spec.ts
+++ b/tests/plugin-shape.spec.ts
@@ -57,6 +57,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as {
(input: Record | undefined): Record
})({ openByDefault: false, defaultWidthPercent: 45 })
- expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
+ expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} })
})
})
diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx
index db825be..faffe9e 100644
--- a/tests/side-card-section.spec.tsx
+++ b/tests/side-card-section.spec.tsx
@@ -78,10 +78,10 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>explorer<')
expect(html).toContain('data-icon="subagent"')
expect(html).toContain('>Subagents<')
- // Default prefs: openByDefault + both tabs + the image viewer are all
- // enabled → 4 cards pressed, none pressed=false. The nested auto-open
- // toggle is NOT an inline card (it lives in the popup).
- expect(pressedCount(html, 'true')).toBe(4)
+ // Default prefs: openByDefault + interceptOpenPath + both tabs + the
+ // image viewer are all enabled → 5 cards pressed, none pressed=false.
+ // The nested auto-open toggle is NOT an inline card (it lives in the popup).
+ expect(pressedCount(html, 'true')).toBe(5)
expect(pressedCount(html, 'false')).toBe(0)
expect(html).not.toContain('Auto-open Subagents')
})
@@ -111,7 +111,8 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>Subagents<')
expect(html).toContain('>Image<')
expect(pressedCount(html, 'false')).toBe(2)
- expect(pressedCount(html, 'true')).toBe(2)
+ // openByDefault + interceptOpenPath + the explorer stay pressed.
+ expect(pressedCount(html, 'true')).toBe(3)
})
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts
index 0f81ce8..c2ddb8e 100644
--- a/tests/smoke.spec.ts
+++ b/tests/smoke.spec.ts
@@ -507,6 +507,7 @@ describe('side card settings routes', () => {
defaultWidthPercent: 30,
autoOpenSubagent: true,
agentTerminalTools: false,
+ interceptOpenPath: true,
// The enable-switch maps default to {} (everything on).
tabsEnabled: {},
viewersEnabled: {},
diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts
index df8d379..3123e94 100644
--- a/tests/unit.spec.ts
+++ b/tests/unit.spec.ts
@@ -15,6 +15,9 @@ import { isPdfExt } from '../src/client/pdf-types.ts'
import { isImageExt } from '../src/client/image-types.ts'
import { relativeTo } from '../src/client/paths.ts'
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
+import { wrapOpenPath, type OpenPathInterceptDeps, type OpenPathService } from '../src/client/openpath-intercept.ts'
+import { registerOpenPathInterception } from '../src/client/intercept.tsx'
+import type { Context } from '../src/context-types.ts'
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
import {
collectBranchIds, countSubagentDescendants, detectNewDirectSubagent, directSubagentCount, rootAncestor,
@@ -834,6 +837,7 @@ describe('side card preferences', () => {
defaultWidthPercent: 60,
autoOpenSubagent: false,
agentTerminalTools: true,
+ interceptOpenPath: true,
tabsEnabled: {},
viewersEnabled: {},
})
@@ -846,6 +850,7 @@ describe('side card preferences', () => {
defaultWidthPercent: 33,
autoOpenSubagent: true,
agentTerminalTools: false,
+ interceptOpenPath: true,
tabsEnabled: {},
viewersEnabled: {},
})
@@ -858,6 +863,7 @@ describe('side card preferences', () => {
defaultWidthPercent: 40,
autoOpenSubagent: true,
agentTerminalTools: false,
+ interceptOpenPath: true,
tabsEnabled: {},
viewersEnabled: {},
})
@@ -872,6 +878,16 @@ describe('side card preferences', () => {
.toBe(true)
})
+ it('defaults interceptOpenPath to true; only an explicit false turns the takeover off', async () => {
+ // Absent or malformed → on (the takeover is the safe default).
+ expect((await loadPrefs(wire({}))).interceptOpenPath).toBe(true)
+ expect((await loadPrefs(wire({ interceptOpenPath: 'yes' }))).interceptOpenPath).toBe(true)
+ expect((await loadPrefs(wire({ interceptOpenPath: 0 }))).interceptOpenPath).toBe(true)
+ // Explicit booleans survive verbatim.
+ expect((await loadPrefs(wire({ interceptOpenPath: false }))).interceptOpenPath).toBe(false)
+ expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true)
+ })
+
it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => {
// A non-object map falls back to {} (everything enabled).
expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({})
@@ -889,9 +905,9 @@ describe('side card preferences', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference.
- store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
+ store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} })
store.setSession('fresh-session')
- expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
+ expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
@@ -904,7 +920,7 @@ describe('side card preferences', () => {
it('skips the default explorer tab when the explorer type is disabled', () => {
const store = createSidebarStore()
- store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: { explorer: false }, viewersEnabled: {} })
+ store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setSession('no-explorer')
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -912,7 +928,7 @@ describe('side card preferences', () => {
expect(state.splits.kind).toBe('leaf')
// Re-enabling seeds the explorer tab again.
const openStore = createSidebarStore()
- openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
+ openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setSession('with-explorer')
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])
@@ -1103,3 +1119,142 @@ describe('subagent activity summary parser', () => {
expect(live.tool).toEqual({ name: 'web', args: '' })
})
})
+
+describe('open-path interception', () => {
+ /** A minimal fake of the workspaces.openPath service method. */
+ const service = (): OpenPathService & { calls: string[]; opened: string[] } => {
+ const fake = {
+ calls: [] as string[],
+ opened: [] as string[],
+ async openPath(path: string): Promise {
+ this.calls.push(path)
+ this.opened.push(path)
+ },
+ }
+ return fake
+ }
+
+ const deps = (overrides: Partial = {}): OpenPathInterceptDeps & {
+ sidebar: string[]
+ } => {
+ const sidebar: string[] = []
+ return {
+ sidebar,
+ takeoverEnabled: () => true,
+ currentSessionId: () => 's1',
+ openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) },
+ ...overrides,
+ }
+ }
+
+ it('routes an intercepted open into the sidebar and resolves without the original call', async () => {
+ const ws = service()
+ const d = deps()
+ const restore = wrapOpenPath(ws, d)
+ await expect(ws.openPath('/abs/a.ts')).resolves.toBeUndefined()
+ expect(ws.calls).toEqual([])
+ expect(d.sidebar).toEqual(['s1:/abs/a.ts'])
+ restore()
+ })
+
+ it('falls through to the original when the takeover is disabled', async () => {
+ const ws = service()
+ const d = deps({ takeoverEnabled: () => false })
+ const restore = wrapOpenPath(ws, d)
+ await ws.openPath('/abs/a.ts')
+ expect(ws.opened).toEqual(['/abs/a.ts'])
+ expect(d.sidebar).toEqual([])
+ restore()
+ })
+
+ it('falls through when no session is current (nothing to scope the editor load to)', async () => {
+ const ws = service()
+ const d = deps({ currentSessionId: () => undefined })
+ const restore = wrapOpenPath(ws, d)
+ await ws.openPath('/abs/a.ts')
+ expect(ws.opened).toEqual(['/abs/a.ts'])
+ expect(d.sidebar).toEqual([])
+ restore()
+ })
+
+ it('passes the current session into the sidebar opener', async () => {
+ const ws = service()
+ let current = 's1'
+ const d = deps({ currentSessionId: () => current })
+ const restore = wrapOpenPath(ws, d)
+ await ws.openPath('/abs/a.ts')
+ current = 's2'
+ await ws.openPath('/abs/b.ts')
+ expect(d.sidebar).toEqual(['s1:/abs/a.ts', 's2:/abs/b.ts'])
+ restore()
+ })
+
+ it('restores the original method on dispose (HMR-safe)', async () => {
+ const ws = service()
+ const d = deps()
+ const original = ws.openPath
+ const restore = wrapOpenPath(ws, d)
+ expect(ws.openPath).not.toBe(original)
+ restore()
+ expect(ws.openPath).toBe(original)
+ await ws.openPath('/abs/a.ts')
+ expect(ws.opened).toEqual(['/abs/a.ts'])
+ })
+
+ it('treats a rejected promise like the original would (no swallowing)', async () => {
+ const failing: OpenPathService = {
+ async openPath() { throw new Error('host refused') },
+ }
+ const d = deps({ takeoverEnabled: () => false })
+ const restore = wrapOpenPath(failing, d)
+ await expect(failing.openPath('/abs/a.ts')).rejects.toThrow('host refused')
+ restore()
+ })
+})
+
+describe('open-path interception wiring', () => {
+ it('registerOpenPathInterception routes chat opens into the editor tab and restores on dispose', async () => {
+ // A realistic client-context fake: the sessions list feed (current + cwd),
+ // the workspaces funnel, and the sidebar service the editor goes through.
+ const opened: Array> = []
+ const funnel = { openPath: async (): Promise => {} }
+ const ctx = {
+ sessions: {
+ list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) },
+ },
+ workspaces: funnel,
+ betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record) } },
+ } as unknown as Context
+ const store = createSidebarStore()
+ const original = ctx.workspaces.openPath
+ const restore = registerOpenPathInterception(ctx, store)
+
+ // Default prefs: the takeover routes the open into the sidebar editor
+ // with the session-scoped absolute path (chat already resolved it).
+ await ctx.workspaces.openPath('/w/src/a.ts')
+ expect(opened).toEqual([{
+ type: 'editor',
+ title: 'a.ts',
+ path: '/w/src/a.ts',
+ id: 'editor:/w/src/a.ts',
+ }])
+
+ // The interceptOpenPath pref off → the original funnel runs untouched.
+ store.setPrefs({ ...store.getPrefs(), interceptOpenPath: false })
+ const calls: string[] = []
+ ctx.workspaces.openPath = async (path: string) => { calls.push(path) }
+ await ctx.workspaces.openPath('/w/src/b.ts')
+ expect(calls).toEqual(['/w/src/b.ts'])
+ expect(opened).toHaveLength(1)
+
+ // The editor tab disabled → falls through too (an editor that cannot
+ // open must not swallow opens).
+ store.setPrefs({ ...store.getPrefs(), interceptOpenPath: true, tabsEnabled: { editor: false } })
+ await ctx.workspaces.openPath('/w/src/c.ts')
+ expect(calls).toEqual(['/w/src/b.ts', '/w/src/c.ts'])
+
+ // Disposal restores the raw original method (HMR-safe).
+ restore()
+ expect(ctx.workspaces.openPath).toBe(original)
+ })
+})