From 6e096a850a2c82bdad4e4caa8e2d739ae34086f4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 19:57:45 -0500 Subject: [PATCH] feat(desktop): add $backgroundResume store for parked delegate_task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track top-level delegate_task work that dispatches in the background and re-enters as a fresh turn. $backgroundResume returns {count, activity} for the active session while idle — count of parked tasks plus the primary child's latest stream line (tool/progress/thinking) when readable. --- .../src/store/background-delegation.test.ts | 65 +++++++++++++++++++ .../src/store/background-delegation.ts | 48 ++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 apps/desktop/src/store/background-delegation.test.ts create mode 100644 apps/desktop/src/store/background-delegation.ts diff --git a/apps/desktop/src/store/background-delegation.test.ts b/apps/desktop/src/store/background-delegation.test.ts new file mode 100644 index 00000000000..cd918c681da --- /dev/null +++ b/apps/desktop/src/store/background-delegation.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { $backgroundResume } from './background-delegation' +import { $activeSessionId, $busy } from './session' +import { $subagentsBySession, type SubagentProgress, type SubagentStreamEntry } from './subagents' + +const sub = (over: Partial = {}): SubagentProgress => ({ + id: over.id ?? 'deleg:1', + parentId: null, + goal: 'do the thing', + status: 'running', + taskCount: 1, + taskIndex: 0, + startedAt: 0, + updatedAt: 0, + filesRead: [], + filesWritten: [], + stream: [], + ...over +}) + +const stream = (text: string): SubagentStreamEntry => ({ at: 0, kind: 'progress', text }) + +describe('$backgroundResume', () => { + beforeEach(() => { + $busy.set(false) + $activeSessionId.set('s1') + $subagentsBySession.set({}) + }) + + it('counts running/queued children for the active session while idle', () => { + $subagentsBySession.set({ s1: [sub({ id: 'a' }), sub({ id: 'b', status: 'queued' })] }) + expect($backgroundResume.get()?.count).toBe(2) + }) + + it('surfaces the primary child latest stream line as live activity', () => { + $subagentsBySession.set({ s1: [sub({ id: 'a', stream: [stream('Searching the web…')] })] }) + expect($backgroundResume.get()?.activity).toBe('Searching the web…') + }) + + it('activity is null when no stream line has arrived (UI uses generic copy)', () => { + $subagentsBySession.set({ s1: [sub({ id: 'a' })] }) + expect($backgroundResume.get()?.activity).toBeNull() + }) + + it('is null while a turn is busy (the turn owns the main loader)', () => { + $subagentsBySession.set({ s1: [sub({ id: 'a' })] }) + $busy.set(true) + expect($backgroundResume.get()).toBeNull() + }) + + it('is null when only terminal children or other sessions have work', () => { + $subagentsBySession.set({ + s1: [sub({ id: 'a', status: 'completed' }), sub({ id: 'b', status: 'failed' })], + s2: [sub({ id: 'c' })] + }) + expect($backgroundResume.get()).toBeNull() + }) + + it('is null when there is no active session', () => { + $subagentsBySession.set({ s1: [sub({ id: 'a' })] }) + $activeSessionId.set(null) + expect($backgroundResume.get()).toBeNull() + }) +}) diff --git a/apps/desktop/src/store/background-delegation.ts b/apps/desktop/src/store/background-delegation.ts new file mode 100644 index 00000000000..72819cb74d7 --- /dev/null +++ b/apps/desktop/src/store/background-delegation.ts @@ -0,0 +1,48 @@ +import { computed } from 'nanostores' + +import { $activeSessionId, $busy } from './session' +import { $subagentsBySession, type SubagentProgress } from './subagents' + +export interface BackgroundResume { + /** Latest live activity from the primary child (its newest stream line), or + * null when nothing readable has arrived yet — the UI then falls back to the + * generic "will resume" copy. */ + activity: string | null + /** Running/queued background children for the active session. */ + count: number +} + +const RUNNING = (s: SubagentProgress) => s.status === 'running' || s.status === 'queued' + +/** + * "Parked" background-delegation signal for the active session. + * + * A top-level `delegate_task` always runs in the background: the parent turn + * ends (`$busy` -> false) while the subagent keeps running, and its result + * re-enters the conversation as a fresh turn when it finishes. During that + * window the app is genuinely idle but work is still happening elsewhere, so we + * surface a calm, shimmering status line (its latest activity, or a generic + * "will resume" fallback) instead of a spinner that reads as "stuck." + * + * Null while `$busy`: an active turn already owns the main loader, and subagents + * spawned inside a running turn (synchronous orchestrator children) are part of + * that turn, not parked background work the user is waiting on. + */ +export const $backgroundResume = computed( + [$subagentsBySession, $activeSessionId, $busy], + (bySession, sid, busy): BackgroundResume | null => { + if (busy || !sid) { + return null + } + + const running = (bySession[sid] ?? []).filter(RUNNING) + + if (running.length === 0) { + return null + } + + const activity = (running[0]!.stream.at(-1)?.text ?? '').trim() || null + + return { activity, count: running.length } + } +)