From 6c1efc1b00a3ff007fec5385c99129416cf9fe16 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 10 Sep 2026 17:13:21 +0800 Subject: [PATCH 1/2] fix(shell): land auto-activation in DSH's native right Sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two auto-open switches (a new subagent, a new background job) and the topology jump-back opened the Tasks page with `target: 'bottom'` — the plugin's own bottom workbench, i.e. the bottom bar. The right column IS DSH's native Sidebar since v0.19.0 and every other open in this plugin already lands there, so `service.ts`'s native branch was bypassed and the switches did NOT do what their copy promises (README feature table + `settingsSubagentDesc` / `settingsJobsDesc` in all 20 dictionaries: "wide viewports also expand the sidebar, while narrow full-screen drawers are not forced open"). - use-host-feeds: one `activateTasksPage(ctx, sessionId, { background })` carries all three triggers and opens the Tasks page at the default (native) landing, so the host expands the column in the same step. - Narrow viewports keep that promise: below 768px the host draws the column FULLSCREEN (`autoFullscreen = viewportWidth < 768`, `position: fixed; inset: 0`) and expands it on every open with no way to suppress it, so a BACKGROUND activation parks the tab — reads `ctx.sidebarRight.isExpanded()` first and puts the column back to collapsed in the same React batch (only for the mounted session, which is what that face acts on, and only when the user had it collapsed). The explicit jump-back is a user gesture and always takes the host's expansion. - The bottom workbench keeps its own flows on `target: 'bottom'` (its `+` menu, the first-expansion auto-terminal); `OpenTabSeed.target` is unchanged. - tests/sidebar-auto-activation.spec.tsx: asserts the native surface receives `openTab { kind: 'subagent', revealIfOpened: true }`, that the workbench stays closed and gains no tab, the park counts per width, the resize-before -fire read, and the jump-back's no-park. All 10 cases fail against the unfixed module. Root cause: 223b254 (retire the plugin's own right panel, v0.19.0-alpha.0) rewrote these three landings from the plugin's right pane to the bottom one. --- src/client/sidebar/use-host-feeds.ts | 86 +++++++-- tests/sidebar-auto-activation.spec.tsx | 232 +++++++++++++++++++++---- 2 files changed, 265 insertions(+), 53 deletions(-) diff --git a/src/client/sidebar/use-host-feeds.ts b/src/client/sidebar/use-host-feeds.ts index 5b8505c..6a27505 100644 --- a/src/client/sidebar/use-host-feeds.ts +++ b/src/client/sidebar/use-host-feeds.ts @@ -8,6 +8,7 @@ import { useEffect, useRef } from 'react' import type { Context, SidebarSessionList } from '../../context-types.ts' import { reconcileAgentTerminals, 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' @@ -26,6 +27,55 @@ const FAILURE_LIMIT = 3 */ const AUTO_OPEN_DEBOUNCE_MS = 500 +/** + * The native column's public face, as this module reaches it. Both actions + * act on the session whose surface is MOUNTED (the controller reads its + * binding), which is why the park below is gated on the target session being + * the on-screen one. + */ +interface NativeColumnFace { + isExpanded?: () => boolean + toggleExpanded?: () => void +} + +/** + * Activate the Tasks page (the `subagent` tab type) in DSH's native right + * Sidebar — the landing the two auto-open switches promise: the right column + * IS the sidebar the user means, while the plugin's own bottom workbench only + * serves its own flows (its `+` menu and the first-expansion terminal). Every + * other open in this plugin already lands there, so a `target: 'bottom'` here + * puts the Tasks page in the bottom bar instead. + * + * A background activation must not take over a narrow viewport: below 768px + * the host draws that column FULLSCREEN, so the tab is placed and the column + * is put back to collapsed — parked, waiting behind the expand control, + * instead of covering the chat. The host expands on every open + * (`openContent` plans `setExpanded(true)`) and offers no option to suppress + * it, hence the read-then-restore pair: both commits land in one React batch, + * so the column never renders the intermediate state. The viewport is read + * when the activation FIRES (the debounced subagent trigger included), so a + * resize while arming is honoured. + * + * @param ctx - the client context (`ctx.sidebarRight` + `ctx.betterSidebar`). + * @param sessionId - the session the feed reports the activity for. + * @param options.background - `true` for background activity (parks on narrow + * viewports); `false` for the explicit topology jump-back, which is a user + * gesture and always leaves the column as the host expanded it. + */ +function activateTasksPage(ctx: Context, sessionId: string, options: { background: boolean }): void { + const column = ctx.get('sidebarRight') as unknown as NativeColumnFace | undefined + const park = options.background + // The face acts on the MOUNTED session: parking is only meaningful (and + // only safe) when the activation targets the one on screen. + && ctx.sessions.list.getSnapshot().current === sessionId + && isNarrowWidth(window.innerWidth) + // Only a column the user had COLLAPSED is put back: an expanded one is in + // use, and closing it under the user would be worse than the takeover. + && column?.isExpanded?.() === false + ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') }) + if (park) column?.toggleExpanded?.() +} + export function useHostFeeds(feeds: { ctx: Context store: SidebarStore @@ -166,11 +216,11 @@ export function useHostFeeds(feeds: { * 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. + * the Tasks page in DSH's native right Sidebar. Single-instance semantics + * focus an existing tab in place; a new tab lands in that column and is + * never duplicated. Landing it EXPANDS the column on wide viewports, while + * a narrow viewport (where the host draws that column fullscreen) parks the + * tab instead of taking the screen over — see {@link activateTasksPage}. * Switching to a session that already has subagents never triggers — its * baseline starts at the current count — so a deliberate layout is never * fought. @@ -196,7 +246,7 @@ export function useHostFeeds(feeds: { if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return if (!store.getPrefs().autoOpenSubagent) return if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return - ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' }) + activateTasksPage(ctx, sessionId, { background: true }) }, AUTO_OPEN_DEBOUNCE_MS) autoOpenPendingRef.current = { baseline, timer } }, [sessionList, sessionId, store, ctx]) @@ -212,11 +262,12 @@ export function useHostFeeds(feeds: { * 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. + * page that contains the background-jobs section — in DSH's native right + * Sidebar, expanded on wide viewports and parked on narrow ones exactly like + * the subagent trigger ({@link activateTasksPage}). Unlike that 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(undefined) useEffect(() => { @@ -226,7 +277,7 @@ export function useHostFeeds(feeds: { if (!detectNewJob(prev, sessionList, sessionId)) return if (!store.getPrefs().autoOpenJobs) return if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return - ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' }) + activateTasksPage(ctx, sessionId, { background: true }) }, [sessionList, sessionId, store, ctx]) /** @@ -235,17 +286,18 @@ export function useHostFeeds(feeds: { * 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. + * target, re-open the Tasks page on top of the child's layout, in DSH's + * native right Sidebar. This one is an explicit user gesture, so it always + * takes the host's expansion (no narrow-viewport parking). Only this 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 - ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' }) + activateTasksPage(ctx, sessionId, { background: false }) }, [sessionId, store, ctx]) return { subagentJumpRef } diff --git a/tests/sidebar-auto-activation.spec.tsx b/tests/sidebar-auto-activation.spec.tsx index c7b7249..6f2674f 100644 --- a/tests/sidebar-auto-activation.spec.tsx +++ b/tests/sidebar-auto-activation.spec.tsx @@ -1,13 +1,21 @@ /** - * Real-Sidebar regressions for issue #162: background activity (a new - * subagent, a new background job) activates the Tasks page in the bottom - * workbench. The right column belongs to DSH's native Sidebar, so there is no - * full-screen drawer to guard against any more — the activation lands in the - * docked workbench on every viewport width. + * Auto-activation regressions for issue #162: background activity (a new + * subagent, a new background job) activates the Tasks page in DSH's NATIVE + * right Sidebar — the column the two auto-open switches and the README promise + * ("wide viewports also expand the sidebar, while narrow full-screen drawers + * are not forced open"). The plugin's own bottom workbench is NOT that + * surface: it serves only its own flows (its + menu, the first-expansion + * auto-terminal), so a background activation must leave it exactly as it was. + * + * The narrow half of that promise is a PARK: the host draws the native column + * fullscreen below 768px and expands it on every open, so the activation places + * the tab and puts the column back to collapsed (src/client/sidebar/ + * use-host-feeds.ts `activateTasksPage`). The topology jump-back is an explicit + * user gesture and always takes the host's expansion. */ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createElement } from 'react' +import { createElement, useEffect, useRef, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' @@ -15,8 +23,13 @@ import { setupReactAct } from './test-utils.ts' setupReactAct() import { Sidebar } from '../src/client/Sidebar.tsx' -import { allLeaves, createSidebarStore, firstLeaf, type SidebarStore } from '../src/client/state.ts' -import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts' +import { allLeaves, createSidebarStore, type SidebarStore } from '../src/client/state.ts' +import { + createBetterSidebarService, + type BetterSidebarService, + type SidebarSurface, + type TabComponentProps, +} from '../src/client/service.ts' import type { Context, SidebarSessionList } from '../src/context-types.ts' class FakeWebSocket { @@ -45,10 +58,79 @@ function makeSessionFeed(initial: SidebarSessionList) { type SessionFeed = ReturnType +/** One recorded native-surface open. */ +interface NativeOpen { + sessionId: string + kind: string + params: unknown + revealIfOpened: boolean +} + +/** The native right Sidebar, replaced by a recording stand-in: the shell's + * opens must reach THIS surface, not the plugin's own layout. */ +interface NativeSurfaceSpy { + surface: SidebarSurface + opens: NativeOpen[] +} + +function makeNativeSurfaceSpy(): NativeSurfaceSpy { + const opens: NativeOpen[] = [] + return { + opens, + surface: { + openTab: input => { opens.push({ ...input }) }, + openResource: () => {}, + fileAddress: (sessionId, cwd, path) => `addr://${sessionId}${cwd ?? ''}${path}`, + close: () => undefined, + update: () => false, + activate: () => false, + has: () => false, + }, + } +} + +/** `ctx.sidebarRight`, replaced by a stand-in column: `isExpanded` reports the + * current state, `toggleExpanded` flips it and counts the calls. */ +interface NativeColumnSpy { + face: { isExpanded: () => boolean; toggleExpanded: () => void } + toggles: number +} + +function makeNativeColumnSpy(expanded: boolean): NativeColumnSpy { + const spy = { + toggles: 0, + expanded, + face: {} as { isExpanded: () => boolean; toggleExpanded: () => void }, + } + spy.face = { + isExpanded: () => spy.expanded, + toggleExpanded: () => { + spy.toggles += 1 + spy.expanded = !spy.expanded + }, + } + return spy +} + +/** Stands in for the Tasks page: its node click is the jump gesture that arms + * the shell's jump-back (fired once, like a real click). */ +function JumpHarness({ onSubagentJump }: TabComponentProps): ReactNode { + const fired = useRef(false) + useEffect(() => { + if (fired.current) return + fired.current = true + onSubagentJump?.('child') + }, [onSubagentJump]) + return null +} + interface MountedSidebar { store: SidebarStore service: BetterSidebarService feed: SessionFeed + surface: NativeSurfaceSpy + column: NativeColumnSpy + sessionId: string unmount: () => void } @@ -59,7 +141,11 @@ function setViewport(width: number): void { Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }) } -function mountSidebar(width: number, bottomOpen = false): MountedSidebar { +function mountSidebar( + width: number, + bottomOpen = false, + options: { columnExpanded?: boolean } = {}, +): MountedSidebar { setViewport(width) vi.stubGlobal('WebSocket', FakeWebSocket) const sessionId = `auto-activation-${++sessionSeq}` @@ -76,13 +162,18 @@ function mountSidebar(width: number, bottomOpen = false): MountedSidebar { store.setSession(sessionId) store.reduce(state => ({ ...state, bottomOpen })) const service = createBetterSidebarService(store) - service.registerTab({ id: 'subagent', title: 'Subagent', component: () => null }) + const surface = makeNativeSurfaceSpy() + service.setSurface(surface.surface) + service.registerTab({ id: 'subagent', title: 'Subagent', component: JumpHarness }) + const column = makeNativeColumnSpy(options.columnExpanded ?? false) const localeSnapshot = { active: 'en' } const ctx = { locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot }, sessions: { list: feed }, betterSidebar: service, - get: (name: string) => name === 'betterSidebar' ? service : undefined, + get: (name: string) => name === 'betterSidebar' + ? service + : name === 'sidebarRight' ? column.face : undefined, } as unknown as Context const container = document.createElement('div') document.body.append(container) @@ -92,6 +183,9 @@ function mountSidebar(width: number, bottomOpen = false): MountedSidebar { store, service, feed, + surface, + column, + sessionId, unmount: () => { act(() => { root.unmount() }) container.remove() @@ -101,6 +195,18 @@ function mountSidebar(width: number, bottomOpen = false): MountedSidebar { return result } +type ActivitySource = 'subagent' | 'job' + +/** Deliver one piece of background activity through the host's own feeds. */ +function publishActivity(sidebar: MountedSidebar, source: ActivitySource): void { + if (source === 'job') { + publishJob(sidebar) + return + } + publishSubagent(sidebar) + flushSubagentDebounce() +} + function publishSubagent(sidebar: MountedSidebar): void { const before = sidebar.feed.getSnapshot() const sessionId = before.current! @@ -145,14 +251,44 @@ function publishJob(sidebar: MountedSidebar): void { }) } -function expectSubagentFocused(sidebar: MountedSidebar): void { +/** Switch the conversation to the child session the Tasks page jumped to. */ +function switchToChild(sidebar: MountedSidebar): void { + const before = sidebar.feed.getSnapshot() + const parent = before.current! + act(() => { + sidebar.feed.set({ + ...before, + current: 'child', + byId: { + ...before.byId, + child: { + id: 'child', + displayTitle: 'Worker', + origin: 'subagent', + parentId: parent, + running: true, + }, + }, + }) + }) +} + +/** The Tasks page landed in the native right Sidebar (the default open). */ +function expectNativeTasksOpen(sidebar: MountedSidebar, sessionId: string): void { + expect(sidebar.surface.opens).toEqual([{ + sessionId, + kind: 'subagent', + params: expect.objectContaining({ title: expect.any(String) }), + revealIfOpened: true, + }]) +} + +/** The plugin's own bottom workbench kept its own state and gained no tab. */ +function expectWorkbenchUntouched(sidebar: MountedSidebar, open = false): void { const state = sidebar.store.getSnapshot().state! - const pane = firstLeaf(state.bottomSplits) - expect(state.bottomOpen).toBe(true) - expect(state.activePane).toBe(pane.id) + expect(state.bottomOpen).toBe(open) expect(allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs) - .filter(tab => tab.type === 'subagent')).toHaveLength(1) - expect(pane.tabs.find(tab => tab.id === pane.active)?.type).toBe('subagent') + .filter(tab => tab.type === 'subagent')).toHaveLength(0) } beforeEach(() => { @@ -176,33 +312,57 @@ describe('Sidebar background-activity auto-activation (#162)', () => { { source: 'job', width: 390 }, { source: 'subagent', width: 1024 }, { source: 'job', width: 1024 }, - ] as const)('$source activation at $width px focuses the Tasks page in the workbench', ({ source, width }) => { + ] as const)('$source activation at $width px activates the Tasks page in the native Sidebar', ({ source, width }) => { const sidebar = mountSidebar(width) - if (source === 'subagent') { - publishSubagent(sidebar) - flushSubagentDebounce() - } else { - publishJob(sidebar) - } - expectSubagentFocused(sidebar) + publishActivity(sidebar, source) + expectNativeTasksOpen(sidebar, sidebar.sessionId) + expectWorkbenchUntouched(sidebar) + // Parking is the narrow-viewport half of the same promise. + expect(sidebar.column.toggles).toBe(width < 768 ? 1 : 0) }) - it.each(['subagent', 'job'] as const)('%s activation keeps an already-open workbench open', (source) => { - const sidebar = mountSidebar(390, true) - if (source === 'subagent') { - publishSubagent(sidebar) - flushSubagentDebounce() - } else { - publishJob(sidebar) - } - expectSubagentFocused(sidebar) + it.each(['subagent', 'job'] as const)('%s activation leaves a narrow fullscreen column to the park', (source) => { + const sidebar = mountSidebar(390) + publishActivity(sidebar, source) + expect(sidebar.column.toggles).toBe(1) }) - it('a resize between arming and firing the debounce does not change the landing', () => { + it('a narrow column the user already expanded is not closed under them', () => { + const sidebar = mountSidebar(390, false, { columnExpanded: true }) + publishActivity(sidebar, 'subagent') + expectNativeTasksOpen(sidebar, sidebar.sessionId) + expect(sidebar.column.toggles).toBe(0) + }) + + it('reads the viewport when the debounced activation fires, not when it arms', () => { const sidebar = mountSidebar(1024) publishSubagent(sidebar) setViewport(390) flushSubagentDebounce() - expectSubagentFocused(sidebar) + expectNativeTasksOpen(sidebar, sidebar.sessionId) + expect(sidebar.column.toggles).toBe(1) + }) + + it('leaves an already-open bottom workbench open and untouched', () => { + const sidebar = mountSidebar(1024, true) + publishActivity(sidebar, 'subagent') + expectNativeTasksOpen(sidebar, sidebar.sessionId) + expectWorkbenchUntouched(sidebar, true) + }) + + it('the topology jump-back opens the Tasks page for the child session and never parks it', () => { + const sidebar = mountSidebar(390) + // The Tasks page the user opened in the workbench (its own + menu) is what + // arms the jump: its node click records the child session. + act(() => { + sidebar.service.openTab( + { type: 'subagent', title: 'Tasks', target: 'bottom' }, + { sessionId: sidebar.sessionId }, + ) + }) + expect(sidebar.store.getSnapshot().state!.bottomOpen).toBe(true) + switchToChild(sidebar) + expectNativeTasksOpen(sidebar, 'child') + expect(sidebar.column.toggles).toBe(0) }) }) From 17db3cf014182ac553db77c86a698027d7ed208d Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 10 Sep 2026 17:13:32 +0800 Subject: [PATCH 2/2] docs(plans): record the auto-activation landing regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.19.0 regression, its root cause in 223b254, the four decisions (native landing, narrow-viewport park, jump-back always expands, bottom workbench keeps its own flows), the change list, and the evidence — including the environment trap that the PATH `dsh` on this machine is 0.1.2-rc.1 and cannot register the `file:` tarball into a scratch profile. --- ...2026-09-09-dsh-0.1.5-alpha.2-adaptation.md | 2 + ...9-10-auto-activation-native-landing-fix.md | 60 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 docs/plans/2026-09-10-auto-activation-native-landing-fix.md diff --git a/docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md b/docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md index b8416c7..5e65f3e 100644 --- a/docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md +++ b/docs/plans/2026-09-09-dsh-0.1.5-alpha.2-adaptation.md @@ -18,6 +18,8 @@ DSH `dsh-v0.1.5-alpha.1..dsh-v0.1.5-alpha.2` 的宿主契约里有一批会实 | 6 | 基线升到 0.1.5-alpha.2(peer 下限 / manifest / CI 钉版) | 插件基线 = alpha.2 only;alpha.1 用户留在 v0.19.0-alpha.0 | > 后续(2026-09-10):`SidebarRightGuideEntry.description` 在 DSH 0.1.5-rc.1 回归(可选,且宿主只在 guide 列出的条目 ≤ 4 条时渲染),插件已在 **v0.19.0** 恢复该字段——见 [2026-09-10-dsh-0.1.5-rc.1-adaptation.md](./2026-09-10-dsh-0.1.5-rc.1-adaptation.md)。 +> +> 后续(2026-09-10):退役自绘右侧面板(#605)时,`use-host-feeds.ts` 的三处自动激活被误改成 `target: 'bottom'`,任务页因此落到插件自己的底部工作台而非 DSH 原生右侧栏。修复见 [2026-09-10-auto-activation-native-landing-fix.md](./2026-09-10-auto-activation-native-landing-fix.md)。 ## 决策 diff --git a/docs/plans/2026-09-10-auto-activation-native-landing-fix.md b/docs/plans/2026-09-10-auto-activation-native-landing-fix.md new file mode 100644 index 0000000..c78e733 --- /dev/null +++ b/docs/plans/2026-09-10-auto-activation-native-landing-fix.md @@ -0,0 +1,60 @@ +# 自动激活落点修复:任务页回到 DSH 原生右侧栏 + +日期:2026-09-10 分支:`fix/auto-activation-native-landing` + +## 背景 + +用户报告:**自动打开的侧边栏是底栏,而不是右侧边栏**。两个自动激活开关(`autoOpenSubagent` / `autoOpenJobs`)与子代理拓扑跳回在 v0.19.0 上把任务页(`subagent` tab)开进了插件自绘的**底部工作台**,底栏随之展开;而产品文案描述的是侧边栏落点——README 特性表「新子代理 / 新任务可自动激活任务页,**宽屏同时展开侧边栏**,窄屏不强制展开全屏抽屉」,`settingsSubagentDesc` / `settingsJobsDesc` 及全部 20 份词典同样措辞,README 的 #314 条目也写着「宽屏展开侧边栏,窄屏只准备 Tab」。 + +## 根因 + +`223b254`(v0.19.0-alpha.0「退役插件自绘右侧面板与自由窗口」)改写了 `src/client/sidebar/use-host-feeds.ts` 的三处触发: + +| | 改动前(v0.18.x) | 改动后(v0.19.0) | +|---|---|---| +| 展开 | `store.reduce(s => s.panelOpen ? s : togglePanel(s))`(宽屏才展开) | 无(由落点决定) | +| 落点 | `activePane = firstLeaf(s.splits).id` + `openTab({ type:'subagent', title })` —— **无 `target`** | `openTab({ type:'subagent', title, target:'bottom' })` | + +`service.ts:683` 的判据是「装了原生面 + 非 `bottom` → 落 DSH 原生右侧栏」,`target: 'bottom'` 直接绕过它走 `land = openTabInBottomPane`(`service.ts:744` → `state.ts:453`,置 `bottomOpen: true`)。也就是说:退役右侧面板时,`splits`(插件自己的右面板)消失,落点被改成了插件自己的**底**面板,而正解是当时同文件其它打开早已默认落上的新宿主——DSH 原生右侧栏。 + +## 决策 + +| # | 决策 | 理由 | +|---|---|---| +| 1 | 三处触发改回默认落点(`'right'`) | 宿主 `openContent` 内部 `planSetExpanded(state, true)`(`dsh-client-ui-sidebar-right`),打开即在同一步展开原生栏;这才是文案里的「侧边栏」 | +| 2 | 后台活动(子代理 / 新任务)在窄屏**停放**,不展开 | 宿主 `RightbarSeat` 有 `autoFullscreen = viewportWidth < 768`,该宽度下原生栏以 `position: fixed; inset: 0` 全屏绘制——展开即整屏抢占,正是 #373 修掉的行为。宿主没有「只放 tab、不展开」的公开选项,故开前读 `ctx.sidebarRight.isExpanded()`、开后 `toggleExpanded()` 收回;两次提交落在同一 React 批内,中间态不会渲染 | +| 3 | 停放仅在目标会话在屏时执行 | `isExpanded` / `toggleExpanded` 作用于**已挂载会话**(`mounted()` = `binding.surfaces[binding.sessionId]`);目标不在屏时执行会误动用户当前那一栏 | +| 4 | 拓扑跳回(用户点击)一律展开 | 它是显式手势,不是后台活动;对齐 v0.18.x 跳回无窄屏门禁的语义 | +| 5 | 底部工作台自有流程保持 `target: 'bottom'` | 底栏 `+` 菜单(`Sidebar.tsx:628`)与首次展开自动终端(`:336`)本就该落底栏;`OpenTabSeed.target` API 不变 | + +阈值一致性:插件既有 `NARROW_MAX_WIDTH = 768`(`width < 768`,`src/client/breakpoints.ts`)与宿主 `viewportWidth < 768` 的全屏规则逐字对齐,不引入第二个断点。 + +窄屏语义与 v0.18.x 等价:**tab 已就位、抽屉不展开**(用户点开会话头右侧的展开控件即可看到任务页)。 + +## 改动清单(子系统级) + +| 子系统 | 文件 | 变更 | +|---|---|---| +| 触发 | `src/client/sidebar/use-host-feeds.ts` | 新增模块内 `activateTasksPage(ctx, sessionId, { background })`(落点 + 停放判定集中在此);三处调用改为它(子代理 / 后台任务 `background: true`,跳回 `false`);重新引入 `isNarrowWidth`;三段 docblock 与文件头同步 | +| 测试 | `tests/sidebar-auto-activation.spec.tsx` | 断言原生面收到 `openTab { kind:'subagent', revealIfOpened:true }`、底部工作台保持原状(未展开、未新增 tab)、窄屏停放次数(1 / 0)、宽屏与已展开栏不停放、去抖触发时读视口、跳回不停放 | + +无 API / 持久化 / i18n / README 行为文案改动:文案本就在描述修复后的行为。 + +## 验证 + +- **回归守护(确定性)**:新 spec 10 passed;把 `src/client/sidebar/use-host-feeds.ts` 回退到 `HEAD` 后同 10 条**全红**(10 failed),证明它测的正是这次修复。 +- **门槛**:`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 干净;`pnpm test` = **124 files / 1299 passed / 9 skipped**(较基线 +3 条,即本 spec 的净增)。 +- **打包挂载**:`pnpm build && pnpm pack && pnpm test:mount` = **7 passed**(真实 DSH 0.1.5-rc.1,scratch profile,不触碰用户 `~/.dsh`)。 +- **环境事实(踩过一次)**:本机 PATH 上的 `dsh` 是 **0.1.2-rc.1**,用它跑挂载车道会在 `dsh plugin add` 后报 `dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册`(该版本不会把 `file:` tarball 注册进 profile bundles,且插件 peer 要求 `^0.1.5-rc.1`)。CI 装的是钉版 0.1.5-rc.1;本地需用同版本的 shim / `DSH_CMD` 才能复现车道结果。 +- **真机**:见 PR 描述(3080 实例,web profile 换装新 tarball 后人工触发子代理 / 后台任务确认)。 + +## 未覆盖(诚实记录) + +- 挂载车道是 keyless 的(无模型),无法真派生子代理或后台任务,因此**该行为只有单测是确定性证据**;宿主 `session/create` 的请求体不接受 `origin` / `parentId`,也无法用 RPC 造出 `origin: 'subagent'` 的子会话,所以真机断言只能依赖人工触发或非确定性的 prompt。 +- 宿主若在窄屏自行折叠(`shown && !fullscreen && !canShow` 的 room rule),停放不会与之冲突(两者都指向「不展开」)。 + +## 不做 + +- 不新增设置项、不改变 `OpenTabSeed.target` 的语义、不把任务页改到别处承载。 +- 不动底栏 `+` 菜单 / 首次展开自动终端(它们是底部工作台自己的流程)。 +- 不改 DSH 源码(仓库硬约束 §1)。