feat: 任务管理页二级开关「有新后台任务时自动展开」(autoOpenTasks)

- 新 pref autoOpenTasks(默认开):当前会话出现新后台任务(任意新任务 id,
  非仅首个)时自动展开侧边栏并打开任务管理页,与 autoOpenSubagent 并列
- 注册为 subagent tab 的声明式二级开关(设置页齿轮弹窗,key 在 PrefsSchema)
- Sidebar 新增独立基线 effect(镜像子代理自动展开;刷新不触发、开关关闭/类型
  禁用不触发);detectNewTask 纯函数(settling/同 id/他会话不触发)
- 测试:detectNewTask 用例、prefs 解析断言、builtins 开关清单、schema 默认值
  断言;全量 371 通过
This commit is contained in:
Menghuan1918
2026-08-12 23:04:30 +08:00
parent b90251cce5
commit 88f116338a
13 changed files with 117 additions and 15 deletions
+2 -2
View File
@@ -41,10 +41,10 @@ describe('built-in tab registrations', () => {
}
})
it('the subagent tab declares its auto-open related setting', () => {
it('the subagent tab declares its auto-open related settings', () => {
const { service } = setup()
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent'])
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenTasks'])
})
it('the terminal tab declares the model terminal-tools related setting', () => {
+3 -1
View File
@@ -45,6 +45,8 @@ describe('dsh-better-sidebar plugin export shape', () => {
expect(resolved.openByDefault).toBe(true)
expect(resolved.defaultWidthPercent).toBe(30)
expect(resolved.autoOpenSubagent).toBe(true)
// A new background task auto-opens the Tasks page too.
expect(resolved.autoOpenTasks).toBe(true)
// The terminal tools default OFF (the feature is dormant until the user
// enables it in the side card settings).
expect(resolved.agentTerminalTools).toBe(false)
@@ -57,6 +59,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
})
})
+1
View File
@@ -514,6 +514,7 @@ describe('side card settings routes', () => {
openByDefault: true,
defaultWidthPercent: 30,
autoOpenSubagent: true,
autoOpenTasks: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
+3 -3
View File
@@ -225,9 +225,9 @@ describe('SubagentView background tasks', () => {
)
const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement
await act(async () => { row.click() })
// read:false → the pane explains the model has not read it (never the
// model's cursor, so there is nothing to steal yet).
expect(container.textContent).toContain('模型尚未读取该任务的输出')
// read:false → the pane explains the output awaits the model's task_output
// (never the model's cursor, so there is nothing to steal yet).
expect(container.textContent).toContain('等待模型读取该任务的输出')
unmount()
})
+33 -1
View File
@@ -5,6 +5,7 @@
import { describe, expect, it } from 'vitest'
import {
collectTreeTasks,
detectNewTask,
formatTaskDuration,
isTaskLive,
orderTasks,
@@ -12,7 +13,7 @@ import {
taskStatusLabel,
treeSessionIds,
} from '../src/client/subagent-tasks.ts'
import type { SidebarSessionSummary, SidebarTaskStatus, SidebarTaskView } from '../src/context-types.ts'
import type { SidebarSessionList, SidebarSessionSummary, SidebarTaskStatus, SidebarTaskView } from '../src/context-types.ts'
/** The translator stub: renders duration templates like the real locale copy. */
const templates: Record<string, string> = {
@@ -131,3 +132,34 @@ describe('status presentation helpers', () => {
expect(formatTaskDuration(-5, t)).toBe('0秒')
})
})
describe('detectNewTask', () => {
const list = (tasksBySession: Record<string, SidebarTaskView[]>): SidebarSessionList => ({
current: 'root',
byId: { root: { id: 'root', displayTitle: 'root' } },
subagentsByParent: {},
tasksBySession: tasksBySession,
})
it('fires on EVERY new task id for the session (not just the first)', () => {
expect(detectNewTask(list({}), list({ root: [task('bash-1')] }), 'root')).toBe(true)
expect(detectNewTask(
list({ root: [task('bash-1')] }),
list({ root: [task('bash-1'), task('bash-2')] }),
'root',
)).toBe(true)
})
it('stays quiet on settling, same ids, other sessions, or an absent mirror', () => {
// Settling only mutates status, never adds ids.
expect(detectNewTask(
list({ root: [task('bash-1')] }),
list({ root: [task('bash-1', { status: 'completed', finishedAt: 2_000 })] }),
'root',
)).toBe(false)
expect(detectNewTask(list({ root: [task('bash-1')] }), list({ root: [task('bash-1')] }), 'root')).toBe(false)
// Tasks owned by another session do not trigger the current one.
expect(detectNewTask(list({}), list({ child: [task('bash-1')] }), 'root')).toBe(false)
expect(detectNewTask(list({}), list({}), 'root')).toBe(false)
})
})
+12 -4
View File
@@ -1150,6 +1150,7 @@ describe('side card preferences', () => {
openByDefault: false,
defaultWidthPercent: 60,
autoOpenSubagent: false,
autoOpenTasks: true,
agentTerminalTools: true,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
@@ -1168,6 +1169,7 @@ describe('side card preferences', () => {
openByDefault: true,
defaultWidthPercent: 33,
autoOpenSubagent: true,
autoOpenTasks: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
@@ -1186,6 +1188,7 @@ describe('side card preferences', () => {
openByDefault: false,
defaultWidthPercent: 40,
autoOpenSubagent: true,
autoOpenTasks: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
@@ -1205,6 +1208,11 @@ describe('side card preferences', () => {
.toBe(false)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
.toBe(true)
// The task auto-open is ON by default; only an explicit false turns it off.
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenTasks: 1 }))).autoOpenTasks)
.toBe(true)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenTasks: false }))).autoOpenTasks)
.toBe(false)
})
it('defaults interceptOpenPath to true; only an explicit false turns the takeover off', async () => {
@@ -1234,9 +1242,9 @@ describe('side card preferences', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
@@ -1272,7 +1280,7 @@ describe('side card preferences', () => {
it('skips the default explorer tab when the explorer type is disabled', () => {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setSession('no-explorer')
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -1280,7 +1288,7 @@ describe('side card preferences', () => {
expect(state.splits.kind).toBe('leaf')
// Re-enabling seeds the explorer tab again.
const openStore = createSidebarStore()
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setSession('with-explorer')
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])