diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 353b03c..51b44df 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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(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 diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index e778edb..85cf32f 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -109,13 +109,17 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { icon: (size: number) => , 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 }) => ( diff --git a/src/client/locales.ts b/src/client/locales.ts index d16d165..f69ba1d 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -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 = { 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 = { 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', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 7213de7..72167b8 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -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, diff --git a/src/client/subagent-tasks.ts b/src/client/subagent-tasks.ts index 38bccd6..e1fae63 100644 --- a/src/client/subagent-tasks.ts +++ b/src/client/subagent-tasks.ts @@ -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 diff --git a/src/config.ts b/src/config.ts index b44cc9d..687584d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -79,6 +79,7 @@ export const PrefsSchema: z = 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), diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index 8c66577..5fcf9a5 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -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, diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index aefab05..31ecd2a 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -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', () => { diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index fbfca4d..f67acb2 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -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 | undefined): Record })({ 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: {} }) }) }) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 5c6d5c2..89aa3d6 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -514,6 +514,7 @@ describe('side card settings routes', () => { openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, + autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, diff --git a/tests/subagent-tasks-view.spec.tsx b/tests/subagent-tasks-view.spec.tsx index e24f897..b9cb894 100644 --- a/tests/subagent-tasks-view.spec.tsx +++ b/tests/subagent-tasks-view.spec.tsx @@ -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() }) diff --git a/tests/subagent-tasks.spec.ts b/tests/subagent-tasks.spec.ts index 59189d7..fbe1370 100644 --- a/tests/subagent-tasks.spec.ts +++ b/tests/subagent-tasks.spec.ts @@ -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 = { @@ -131,3 +132,34 @@ describe('status presentation helpers', () => { expect(formatTaskDuration(-5, t)).toBe('0秒') }) }) + +describe('detectNewTask', () => { + const list = (tasksBySession: Record): 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) + }) +}) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index e77be73..23a6311 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -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'])