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
+23
View File
@@ -44,6 +44,7 @@ import type { TabDragPayload } from './TabBar.tsx'
import { relativeTo } from './paths.ts'
import { OrphanedTab } from './OrphanedTab.tsx'
import { detectNewDirectSubagent } from './subagent-detect.ts'
import { detectNewTask } from './subagent-tasks.ts'
import { t } from './locales.ts'
import { api, type SessionScope } from './api.ts'
import css from './sidebar.module.css'
@@ -241,6 +242,28 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionList, sessionId, store, ctx])
/**
* Task auto-activation: the moment a NEW background task appears for the
* current conversation (a task id the previous snapshot lacked), the
* auto-open pref is on, and the Tasks tab type is enabled, open the panel
* (if collapsed) and focus the Tasks page. Unlike the subagent trigger
* (0 → N only), ANY new task id triggers: the agent may start several
* tasks in one session, and each should surface. A fresh page load never
* triggers — its baseline starts at the current snapshot.
*/
const taskBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
useEffect(() => {
const prev = taskBaselineRef.current
taskBaselineRef.current = sessionList
if (sessionId === undefined || prev === undefined) return
if (!detectNewTask(prev, sessionList, sessionId)) return
if (!store.getPrefs().autoOpenTasks) return
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionList, sessionId, store, ctx])
/**
* Topology jump-back: clicking a subagent node on the Subagent page calls
* the official `openSubagent`, which switches the sidebar to that child
+6 -2
View File
@@ -109,13 +109,17 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
icon: (size: number) => <IconThinkOutline16 size={size} />,
order: 30,
single: true,
// Declarative settings: the auto-open switch renders under this row in
// the Side card settings page (the Subagent page's own related setting).
// Declarative settings: the auto-open switches render under this row in
// the Side card settings page (the Tasks page's own related settings).
settings: {
toggles: [{
key: 'autoOpenSubagent',
title: () => t('settingsSubagentTitle'),
desc: () => t('settingsSubagentDesc'),
}, {
key: 'autoOpenTasks',
title: () => t('settingsTasksTitle'),
desc: () => t('settingsTasksDesc'),
}],
},
component: ({ ctx, scope, visible, onSubagentJump }) => (
+6 -2
View File
@@ -121,6 +121,8 @@ const zh = {
zoomHint: 'Alt + 滚轮',
settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
settingsTasksTitle: '有新后台任务时自动展开任务管理页',
settingsTasksDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开任务管理页(每个新任务都会触发);关闭后需手动打开',
settingsToolsTitle: '为模型注入终端工具',
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
@@ -194,7 +196,7 @@ const zh = {
taskViewOutput: '查看输出',
taskHideOutput: '收起输出',
taskNoOutput: '暂无输出',
taskNotReadYet: '模型尚未读取该任务的输出,读取后会显示在这里',
taskNotReadYet: '等待模型读取该任务的输出(模型执行 task_output 后,输出会显示在这里)',
taskOutputTruncated: '输出过长,已截断显示',
taskOutputError: '输出读取失败',
taskKill: '终止',
@@ -318,6 +320,8 @@ const en: Record<keyof typeof zh, string> = {
zoomHint: 'Alt + wheel',
settingsSubagentTitle: 'Auto-open the Tasks page when a subagent appears',
settingsSubagentDesc: 'Expand the side card and open the Tasks page when the current conversation spawns a new subagent; turn off to open it manually',
settingsTasksTitle: 'Auto-open the Tasks page on a new background task',
settingsTasksDesc: 'Expand the side card and open the Tasks page whenever a new background task appears for the current conversation (every new task triggers); turn off to open it manually',
settingsToolsTitle: 'Inject terminal tools for the model',
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion',
@@ -391,7 +395,7 @@ const en: Record<keyof typeof zh, string> = {
taskViewOutput: 'View output',
taskHideOutput: 'Hide output',
taskNoOutput: 'No output yet',
taskNotReadYet: 'The model has not read this task\'s output yet; it will appear here once read',
taskNotReadYet: 'Waiting for the model to read this task; its output appears here once the model runs task_output',
taskOutputTruncated: 'Output truncated',
taskOutputError: 'Failed to read output',
taskKill: 'Kill',
+3
View File
@@ -40,6 +40,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
? record.autoOpenSubagent
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
autoOpenTasks: typeof record.autoOpenTasks === 'boolean'
? record.autoOpenTasks
: SIDEBAR_PREFS_DEFAULTS.autoOpenTasks,
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
? record.agentTerminalTools
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
+17
View File
@@ -57,6 +57,23 @@ export function treeSessionIds(
return ids
}
/**
* Whether a NEW background task appeared for one session between two
* consecutive list snapshots (a task id the previous snapshot lacked).
* Unlike the subagent auto-open (0 → N only), ANY new task id triggers: the
* agent may start several tasks over a session, and each new one should
* surface the Tasks page (a fresh page load never triggers — its baseline
* starts at the current snapshot).
*/
export function detectNewTask(
prev: SidebarSessionList,
next: SidebarSessionList,
sessionId: string,
): boolean {
const prevIds = new Set((prev.tasksBySession?.[sessionId] ?? []).map(task => task.id))
return (next.tasksBySession?.[sessionId] ?? []).some(task => !prevIds.has(task.id))
}
/**
* Collect the background tasks of the whole current tree, owner-labeled.
* Sessions without a mirror entry contribute nothing; an absent mirror
+1
View File
@@ -79,6 +79,7 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
openByDefault: z.boolean().default(true),
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
autoOpenSubagent: z.boolean().default(true),
autoOpenTasks: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false),
bottomPanelAutoTerminal: z.boolean().default(true),
interceptOpenPath: z.boolean().default(true),
+7
View File
@@ -20,6 +20,12 @@ export interface SidebarPrefs {
* Subagent page when the current conversation spawns a new subagent.
*/
autoOpenSubagent: boolean
/**
* Whether the sidebar auto-activates (opens the panel) and expands the
* Tasks page when a NEW background task appears for the current
* conversation (any new task id, not just the first one).
*/
autoOpenTasks: boolean
/**
* Whether the model-facing agent terminal tools (terminal_create / list /
* send / read / wait_for / resize / signal / close) are injected into the
@@ -103,6 +109,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true,
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true,
autoOpenTasks: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
+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'])