fix(shell): land auto-activation in DSH's native right Sidebar

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.
This commit is contained in:
Menghuan1918
2026-09-10 17:13:21 +08:00
parent bbf953b8a8
commit 6c1efc1b00
2 changed files with 265 additions and 53 deletions
+196 -36
View File
@@ -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<typeof makeSessionFeed>
/** 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)
})
})