Merge pull request #619 from omdsh-dev/fix/auto-activation-native-landing

fix(shell): land auto-activation in DSH's native right Sidebar
This commit is contained in:
Atlas Zeng
2026-09-10 21:42:04 +08:00
committed by GitHub
4 changed files with 327 additions and 53 deletions
@@ -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)。
## 决策
@@ -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)。
+69 -17
View File
@@ -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<SidebarSessionList | undefined>(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<string | undefined>(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 }
+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)
})
})