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:
@@ -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 |
|
| 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):`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)。
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import type { Context, SidebarSessionList } from '../../context-types.ts'
|
import type { Context, SidebarSessionList } from '../../context-types.ts'
|
||||||
import { reconcileAgentTerminals, type SidebarStore } from '../state.ts'
|
import { reconcileAgentTerminals, type SidebarStore } from '../state.ts'
|
||||||
|
import { isNarrowWidth } from '../breakpoints.ts'
|
||||||
import { detectNewDirectSubagent } from '../subagent-detect.ts'
|
import { detectNewDirectSubagent } from '../subagent-detect.ts'
|
||||||
import { detectNewJob } from '../subagent-jobs.ts'
|
import { detectNewJob } from '../subagent-jobs.ts'
|
||||||
import { t } from '../locales.ts'
|
import { t } from '../locales.ts'
|
||||||
@@ -26,6 +27,55 @@ const FAILURE_LIMIT = 3
|
|||||||
*/
|
*/
|
||||||
const AUTO_OPEN_DEBOUNCE_MS = 500
|
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: {
|
export function useHostFeeds(feeds: {
|
||||||
ctx: Context
|
ctx: Context
|
||||||
store: SidebarStore
|
store: SidebarStore
|
||||||
@@ -166,11 +216,11 @@ export function useHostFeeds(feeds: {
|
|||||||
* Subagent auto-activation: the moment the current conversation spawns its
|
* Subagent auto-activation: the moment the current conversation spawns its
|
||||||
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
|
* 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
|
* 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
|
* the Tasks page in DSH's native right Sidebar. Single-instance semantics
|
||||||
* place or raise an existing free window; a new tab lands in the right pane
|
* focus an existing tab in place; a new tab lands in that column and is
|
||||||
* and is never duplicated. On wide viewports the right panel also expands;
|
* never duplicated. Landing it EXPANDS the column on wide viewports, while
|
||||||
* on narrow viewports background activity never forces the full-screen
|
* a narrow viewport (where the host draws that column fullscreen) parks the
|
||||||
* drawer open over the chat.
|
* tab instead of taking the screen over — see {@link activateTasksPage}.
|
||||||
* Switching to a session that already has subagents never triggers — its
|
* Switching to a session that already has subagents never triggers — its
|
||||||
* baseline starts at the current count — so a deliberate layout is never
|
* baseline starts at the current count — so a deliberate layout is never
|
||||||
* fought.
|
* fought.
|
||||||
@@ -196,7 +246,7 @@ export function useHostFeeds(feeds: {
|
|||||||
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
|
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
|
||||||
if (!store.getPrefs().autoOpenSubagent) return
|
if (!store.getPrefs().autoOpenSubagent) return
|
||||||
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) 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)
|
}, AUTO_OPEN_DEBOUNCE_MS)
|
||||||
autoOpenPendingRef.current = { baseline, timer }
|
autoOpenPendingRef.current = { baseline, timer }
|
||||||
}, [sessionList, sessionId, store, ctx])
|
}, [sessionList, sessionId, store, ctx])
|
||||||
@@ -212,11 +262,12 @@ export function useHostFeeds(feeds: {
|
|||||||
* Job auto-activation: the moment a NEW background job appears for the
|
* Job auto-activation: the moment a NEW background job appears for the
|
||||||
* current conversation (a job id the previous snapshot lacked), 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
|
* 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
|
* page that contains the background-jobs section — in DSH's native right
|
||||||
* only on wide viewports. Unlike the subagent trigger (0 → N only), ANY
|
* Sidebar, expanded on wide viewports and parked on narrow ones exactly like
|
||||||
* new job id triggers: the agent may start several jobs in one session, and
|
* the subagent trigger ({@link activateTasksPage}). Unlike that trigger
|
||||||
* each should surface. A fresh page load never triggers — its baseline starts
|
* (0 → N only), ANY new job id triggers: the agent may start several jobs in
|
||||||
* at the current snapshot.
|
* 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)
|
const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -226,7 +277,7 @@ export function useHostFeeds(feeds: {
|
|||||||
if (!detectNewJob(prev, sessionList, sessionId)) return
|
if (!detectNewJob(prev, sessionList, sessionId)) return
|
||||||
if (!store.getPrefs().autoOpenJobs) return
|
if (!store.getPrefs().autoOpenJobs) return
|
||||||
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) 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])
|
}, [sessionList, sessionId, store, ctx])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,17 +286,18 @@ export function useHostFeeds(feeds: {
|
|||||||
* session's OWN layout (a fresh child session defaults to the explorer).
|
* 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
|
* The README contract says the Subagent page must stay open with the jumped
|
||||||
* node highlighted — so once the current session becomes the recorded jump
|
* 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
|
* target, re-open the Tasks page on top of the child's layout, in DSH's
|
||||||
* the panel first if it is collapsed). Only this explicit node click arms
|
* native right Sidebar. This one is an explicit user gesture, so it always
|
||||||
* the flag, so switching to a subagent session by any other means keeps
|
* takes the host's expansion (no narrow-viewport parking). Only this node
|
||||||
* that session's own layout untouched.
|
* 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)
|
const subagentJumpRef = useRef<string | undefined>(undefined)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pending = subagentJumpRef.current
|
const pending = subagentJumpRef.current
|
||||||
if (pending === undefined || sessionId !== pending) return
|
if (pending === undefined || sessionId !== pending) return
|
||||||
subagentJumpRef.current = undefined
|
subagentJumpRef.current = undefined
|
||||||
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent'), target: 'bottom' })
|
activateTasksPage(ctx, sessionId, { background: false })
|
||||||
}, [sessionId, store, ctx])
|
}, [sessionId, store, ctx])
|
||||||
|
|
||||||
return { subagentJumpRef }
|
return { subagentJumpRef }
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Real-Sidebar regressions for issue #162: background activity (a new
|
* Auto-activation regressions for issue #162: background activity (a new
|
||||||
* subagent, a new background job) activates the Tasks page in the bottom
|
* subagent, a new background job) activates the Tasks page in DSH's NATIVE
|
||||||
* workbench. The right column belongs to DSH's native Sidebar, so there is no
|
* right Sidebar — the column the two auto-open switches and the README promise
|
||||||
* full-screen drawer to guard against any more — the activation lands in the
|
* ("wide viewports also expand the sidebar, while narrow full-screen drawers
|
||||||
* docked workbench on every viewport width.
|
* 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
|
// @vitest-environment jsdom
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
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 { createRoot, type Root } from 'react-dom/client'
|
||||||
import { act } from 'react-dom/test-utils'
|
import { act } from 'react-dom/test-utils'
|
||||||
|
|
||||||
@@ -15,8 +23,13 @@ import { setupReactAct } from './test-utils.ts'
|
|||||||
setupReactAct()
|
setupReactAct()
|
||||||
|
|
||||||
import { Sidebar } from '../src/client/Sidebar.tsx'
|
import { Sidebar } from '../src/client/Sidebar.tsx'
|
||||||
import { allLeaves, createSidebarStore, firstLeaf, type SidebarStore } from '../src/client/state.ts'
|
import { allLeaves, createSidebarStore, type SidebarStore } from '../src/client/state.ts'
|
||||||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
import {
|
||||||
|
createBetterSidebarService,
|
||||||
|
type BetterSidebarService,
|
||||||
|
type SidebarSurface,
|
||||||
|
type TabComponentProps,
|
||||||
|
} from '../src/client/service.ts'
|
||||||
import type { Context, SidebarSessionList } from '../src/context-types.ts'
|
import type { Context, SidebarSessionList } from '../src/context-types.ts'
|
||||||
|
|
||||||
class FakeWebSocket {
|
class FakeWebSocket {
|
||||||
@@ -45,10 +58,79 @@ function makeSessionFeed(initial: SidebarSessionList) {
|
|||||||
|
|
||||||
type SessionFeed = ReturnType<typeof makeSessionFeed>
|
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 {
|
interface MountedSidebar {
|
||||||
store: SidebarStore
|
store: SidebarStore
|
||||||
service: BetterSidebarService
|
service: BetterSidebarService
|
||||||
feed: SessionFeed
|
feed: SessionFeed
|
||||||
|
surface: NativeSurfaceSpy
|
||||||
|
column: NativeColumnSpy
|
||||||
|
sessionId: string
|
||||||
unmount: () => void
|
unmount: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +141,11 @@ function setViewport(width: number): void {
|
|||||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width })
|
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)
|
setViewport(width)
|
||||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||||
const sessionId = `auto-activation-${++sessionSeq}`
|
const sessionId = `auto-activation-${++sessionSeq}`
|
||||||
@@ -76,13 +162,18 @@ function mountSidebar(width: number, bottomOpen = false): MountedSidebar {
|
|||||||
store.setSession(sessionId)
|
store.setSession(sessionId)
|
||||||
store.reduce(state => ({ ...state, bottomOpen }))
|
store.reduce(state => ({ ...state, bottomOpen }))
|
||||||
const service = createBetterSidebarService(store)
|
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 localeSnapshot = { active: 'en' }
|
||||||
const ctx = {
|
const ctx = {
|
||||||
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
|
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
|
||||||
sessions: { list: feed },
|
sessions: { list: feed },
|
||||||
betterSidebar: service,
|
betterSidebar: service,
|
||||||
get: (name: string) => name === 'betterSidebar' ? service : undefined,
|
get: (name: string) => name === 'betterSidebar'
|
||||||
|
? service
|
||||||
|
: name === 'sidebarRight' ? column.face : undefined,
|
||||||
} as unknown as Context
|
} as unknown as Context
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
document.body.append(container)
|
document.body.append(container)
|
||||||
@@ -92,6 +183,9 @@ function mountSidebar(width: number, bottomOpen = false): MountedSidebar {
|
|||||||
store,
|
store,
|
||||||
service,
|
service,
|
||||||
feed,
|
feed,
|
||||||
|
surface,
|
||||||
|
column,
|
||||||
|
sessionId,
|
||||||
unmount: () => {
|
unmount: () => {
|
||||||
act(() => { root.unmount() })
|
act(() => { root.unmount() })
|
||||||
container.remove()
|
container.remove()
|
||||||
@@ -101,6 +195,18 @@ function mountSidebar(width: number, bottomOpen = false): MountedSidebar {
|
|||||||
return result
|
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 {
|
function publishSubagent(sidebar: MountedSidebar): void {
|
||||||
const before = sidebar.feed.getSnapshot()
|
const before = sidebar.feed.getSnapshot()
|
||||||
const sessionId = before.current!
|
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 state = sidebar.store.getSnapshot().state!
|
||||||
const pane = firstLeaf(state.bottomSplits)
|
expect(state.bottomOpen).toBe(open)
|
||||||
expect(state.bottomOpen).toBe(true)
|
|
||||||
expect(state.activePane).toBe(pane.id)
|
|
||||||
expect(allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
|
expect(allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
|
||||||
.filter(tab => tab.type === 'subagent')).toHaveLength(1)
|
.filter(tab => tab.type === 'subagent')).toHaveLength(0)
|
||||||
expect(pane.tabs.find(tab => tab.id === pane.active)?.type).toBe('subagent')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -176,33 +312,57 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
|
|||||||
{ source: 'job', width: 390 },
|
{ source: 'job', width: 390 },
|
||||||
{ source: 'subagent', width: 1024 },
|
{ source: 'subagent', width: 1024 },
|
||||||
{ source: 'job', 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)
|
const sidebar = mountSidebar(width)
|
||||||
if (source === 'subagent') {
|
publishActivity(sidebar, source)
|
||||||
publishSubagent(sidebar)
|
expectNativeTasksOpen(sidebar, sidebar.sessionId)
|
||||||
flushSubagentDebounce()
|
expectWorkbenchUntouched(sidebar)
|
||||||
} else {
|
// Parking is the narrow-viewport half of the same promise.
|
||||||
publishJob(sidebar)
|
expect(sidebar.column.toggles).toBe(width < 768 ? 1 : 0)
|
||||||
}
|
|
||||||
expectSubagentFocused(sidebar)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each(['subagent', 'job'] as const)('%s activation keeps an already-open workbench open', (source) => {
|
it.each(['subagent', 'job'] as const)('%s activation leaves a narrow fullscreen column to the park', (source) => {
|
||||||
const sidebar = mountSidebar(390, true)
|
const sidebar = mountSidebar(390)
|
||||||
if (source === 'subagent') {
|
publishActivity(sidebar, source)
|
||||||
publishSubagent(sidebar)
|
expect(sidebar.column.toggles).toBe(1)
|
||||||
flushSubagentDebounce()
|
|
||||||
} else {
|
|
||||||
publishJob(sidebar)
|
|
||||||
}
|
|
||||||
expectSubagentFocused(sidebar)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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)
|
const sidebar = mountSidebar(1024)
|
||||||
publishSubagent(sidebar)
|
publishSubagent(sidebar)
|
||||||
setViewport(390)
|
setViewport(390)
|
||||||
flushSubagentDebounce()
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user