From 6e096a850a2c82bdad4e4caa8e2d739ae34086f4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 19:57:45 -0500 Subject: [PATCH 1/4] 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 } + } +) From 563d347e4d420e233a2ed77274e949d93598057f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 19:57:51 -0500 Subject: [PATCH 2/4] feat(desktop): show a calm "will resume" notice for background delegate_task When idle with a top-level delegate_task still in flight, render a static, shimmering system-note at the transcript tail instead of a spinner (which reads as "stuck"). Reuses the shared steer / slash-status chrome (centered, 0.6875rem, muted, Codicon) so it sits in the thread like every other meta line, and mirrors the primary child's latest stream line, falling back to generic copy. i18n across en/ja/zh/zh-hant; markdown prose/heading rhythm tuned so a re-entered turn breathes. --- .../src/components/assistant-ui/thread.tsx | 33 ++++++++++++++++++- apps/desktop/src/i18n/en.ts | 4 +++ apps/desktop/src/i18n/ja.ts | 4 +++ apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 2 ++ apps/desktop/src/i18n/zh.ts | 2 ++ apps/desktop/src/styles.css | 10 +++++- 7 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 66bb707766b..8b3fb2de373 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -96,6 +96,7 @@ import { extractPreviewTargets } from '@/lib/preview-targets' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' +import { $backgroundResume } from '@/store/background-delegation' import { $compactionActive } from '@/store/compaction' import type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' @@ -243,7 +244,7 @@ export const Thread: FC<{ clampToComposer={clampToComposer} components={messageComponents} emptyPlaceholder={emptyPlaceholder} - loadingIndicator={loading === 'response' ? : null} + loadingIndicator={loading === 'response' ? : } sessionKey={sessionKey} /> {loading === 'session' && } @@ -422,6 +423,36 @@ const ResponseLoadingIndicator: FC = () => { ) } +// Parked-background affordance: a top-level delegate_task runs in the +// background, so the parent turn ends and the app goes idle while the subagent +// keeps working and its result re-enters as a fresh turn later. Instead of a +// spinner (reads as "stuck"), reuse the same compact, centered system-note +// chrome as the steer / slash-status lines (SystemMessage above) so it sits in +// the thread like every other meta line. Idle-only (gated upstream). Null when +// nothing is parked. +const BackgroundResumeNotice: FC = () => { + const { t } = useI18n() + const resume = useStore($backgroundResume) + + if (!resume) { + return null + } + + const label = resume.activity ?? t.assistant.thread.resumeWhenBackgroundDone(resume.count) + + return ( +
+ + {label} +
+ ) +} + // Seconds of no visible output (text or part count) before a still-running turn // is treated as stalled and the thinking indicator returns at the tail. const STREAM_STALL_S = 2 diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a7021d1a6cc..d0852aee998 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1965,6 +1965,10 @@ export const en: Translations = { loadingSession: 'Loading session', showEarlier: 'Show earlier messages', loadingResponse: 'Hermes is loading a response', + resumeWhenBackgroundDone: count => + count === 1 + ? 'Will resume when the background task finishes' + : `Will resume when ${count} background tasks finish`, thinking: 'Thinking', today: time => `Today, ${time}`, yesterday: time => `Yesterday, ${time}`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 53680d2334d..b7e5943cd0d 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2090,6 +2090,10 @@ export const ja = defineLocale({ loadingSession: 'セッションを読み込み中', showEarlier: '以前のメッセージを表示', loadingResponse: 'Hermes が応答を読み込み中', + resumeWhenBackgroundDone: count => + count === 1 + ? 'バックグラウンドタスクの完了後に再開します' + : `${count} 件のバックグラウンドタスクの完了後に再開します`, thinking: '考え中', today: time => `今日 ${time}`, yesterday: time => `昨日 ${time}`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 21b6d43e0e4..436e6422f27 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1622,6 +1622,7 @@ export interface Translations { loadingSession: string showEarlier: string loadingResponse: string + resumeWhenBackgroundDone: (count: number) => string thinking: string today: (time: string) => string yesterday: (time: string) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index e45e7f649af..c484c7e60c2 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2027,6 +2027,8 @@ export const zhHant = defineLocale({ loadingSession: '正在載入工作階段', showEarlier: '顯示較早的訊息', loadingResponse: 'Hermes 正在載入回覆', + resumeWhenBackgroundDone: count => + count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`, thinking: '思考中', today: time => `今天,${time}`, yesterday: time => `昨天,${time}`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 1543e0589a4..b3afaec9cf5 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2139,6 +2139,8 @@ export const zh: Translations = { loadingSession: '正在加载会话', showEarlier: '显示更早的消息', loadingResponse: 'Hermes 正在加载回复', + resumeWhenBackgroundDone: count => + count === 1 ? '后台任务完成后将自动继续' : `${count} 个后台任务完成后将自动继续`, thinking: '思考中', today: time => `今天,${time}`, yesterday: time => `昨天,${time}`, diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index e87b5bf9639..6a9306768ad 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -334,7 +334,7 @@ /* Paragraph spacing — vertical gap between prose paragraphs, both inside a markdown block and between consecutive prose parts. Single knob; tweak freely. */ - --paragraph-gap: 0.45rem; + --paragraph-gap: 0.7rem; --sticky-human-top: 0.23rem; --file-tree-row-height: 1.375rem; @@ -890,6 +890,14 @@ code { margin-block: var(--paragraph-gap) 0; } +/* Headings are section breaks, so they own a larger top gap than prose — a + leading `# title` (common in re-entered/delegated turns) no longer butts + against the block above. Top-owned + bottom snug; the first-child reset + below still flushes a leading heading. */ +[data-slot='aui_assistant-message-content'] .aui-md :where(h1, h2, h3, h4) { + margin-block: 1rem 0.25rem; +} + /* First rendered element of a prose block is flush — the block-level gap above (tool / paragraph) already provides the separation. Reach one level deep too: Streamdown wraps blocks in a `div.space-y-*`, so the real first line is the From 7f02f30b76517cf125ff3e7120b8b1283a3c19cf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 19:57:58 -0500 Subject: [PATCH 3/4] feat(tui): add width-budgeted "resumes when subagent finishes" status segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When idle with a background subagent still in flight, append a tail status segment spelling out that the agent resumes on its own. Width-budgeted like every tail segment, so it drops first on a tight terminal where the ⛓ count already carries the signal. --- .../__tests__/appChromeStatusRule.test.tsx | 35 +++++++++++++++++++ ui-tui/src/components/appChrome.tsx | 15 ++++++++ 2 files changed, 50 insertions(+) diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx index de823162df2..c428c9dc55f 100644 --- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx @@ -129,6 +129,41 @@ describe('StatusRule background-subagent indicator', () => { expect(textContent(element)).not.toContain('⛓') }) + it('spells out the auto-resume hint when idle with subagents in flight', () => { + const element = StatusRule({ + ...baseProps, + usage: { ...baseProps.usage, active_subagents: 1 } + }) + + expect(textContent(element)).toContain('resumes when subagent finishes') + }) + + it('pluralizes the resume hint for multiple in-flight subagents', () => { + const element = StatusRule({ + ...baseProps, + usage: { ...baseProps.usage, active_subagents: 3 } + }) + + expect(textContent(element)).toContain('resumes when 3 subagents finish') + }) + + it('hides the resume hint mid-turn (a busy turn owns the indicator)', () => { + const element = StatusRule({ + ...baseProps, + busy: true, + turnStartedAt: Date.now(), + usage: { ...baseProps.usage, active_subagents: 2 } + }) + + expect(textContent(element)).not.toContain('resumes when') + }) + + it('omits the resume hint when no subagents are running', () => { + const element = StatusRule({ ...baseProps }) + + expect(textContent(element)).not.toContain('resumes when') + }) + it('drops the subagent segment before the bg segment on a narrow terminal', () => { // cols=44 is below the subagents breakpoint (92) but the bg breakpoint // (88) too — both gone. Assert the lower-priority subagent indicator is diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index ed0588f5420..9992b227390 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -512,6 +512,15 @@ export function StatusRule({ const showBg = segs.bg && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`)) const subagentCount = typeof usage.active_subagents === 'number' ? usage.active_subagents : 0 const showSubagents = segs.subagents && subagentCount > 0 && fits(SEP + stringWidth(`⛓ ${subagentCount}`)) + // Parked-background reassurance: a top-level delegate_task runs in the + // background, so the turn ends (idle) while the subagent keeps working and its + // result re-enters as a fresh turn later. When idle with work still in flight, + // spell out that the agent resumes on its own — no spinner, nothing to poll. + // Width-budgeted like every tail segment, so it drops first on a tight + // terminal where ⛓ already carries the signal. + const resumeHintText = + subagentCount === 1 ? '↩ resumes when subagent finishes' : `↩ resumes when ${subagentCount} subagents finish` + const showResumeHint = !busy && subagentCount > 0 && fits(SEP + stringWidth(resumeHintText)) // Dev-gated readout (HERMES_DEV_CREDITS), lowest priority, // so it consumes tail budget LAST and drops first on a narrow terminal. const showDevCredits = !!devCreditsText && fits(SEP + stringWidth(devCreditsText)) @@ -624,6 +633,12 @@ export function StatusRule({ ⛓ {subagentCount} ) : null} + {showResumeHint ? ( + + {' │ '} + {resumeHintText} + + ) : null} {showDevCredits ? ( {' │ '} From 985350dd858eef3d855b45327f187367355a0ffd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 25 Jun 2026 19:57:58 -0500 Subject: [PATCH 4/4] feat(cli): note background delegate_task dispatch in _on_tool_complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A top-level delegate_task dispatches in the background and re-enters as a fresh turn when done. Print a one-line dispatch-time note — no spinner, nothing to poll — so the idle prompt doesn't read as "nothing happened." --- cli.py | 15 ++++ .../test_cli_delegate_background_notice.py | 69 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 tests/cli/test_cli_delegate_background_notice.py diff --git a/cli.py b/cli.py index 7442bfe5ca2..1a92ae93778 100644 --- a/cli.py +++ b/cli.py @@ -10499,6 +10499,21 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): def _on_tool_complete(self, tool_call_id: str, function_name: str, function_args: dict, function_result: str): """Render file edits with inline diff after write-capable tools complete.""" + # A top-level delegate_task dispatches in the background and re-enters as + # a fresh turn when done. Say so once — no spinner, nothing to poll — so + # the idle prompt doesn't read as "nothing happened" (⛓ tracks the work). + if function_name == "delegate_task": + try: + parsed = json.loads(function_result) if isinstance(function_result, str) else (function_result or {}) + except Exception: + parsed = {} + if isinstance(parsed, dict) and parsed.get("status") == "dispatched" and parsed.get("mode") == "background": + n = parsed.get("count") or 1 + noun, tail = ("task", "it finishes") if n == 1 else (f"{n} tasks", "they finish") + try: + _cprint(f"\033[2m\u21a9 Background {noun} running — I'll resume when {tail}. Keep chatting.\033[0m") + except Exception: + pass snapshot = self._pending_edit_snapshots.pop(tool_call_id, None) try: from agent.display import render_edit_diff_with_delta diff --git a/tests/cli/test_cli_delegate_background_notice.py b/tests/cli/test_cli_delegate_background_notice.py new file mode 100644 index 00000000000..23f293c1957 --- /dev/null +++ b/tests/cli/test_cli_delegate_background_notice.py @@ -0,0 +1,69 @@ +"""The CLI spells out auto-resume when a delegate_task goes to the background. + +A top-level ``delegate_task`` returns a handle immediately and runs the subagent +in the background; the result re-enters the conversation as a fresh turn when it +finishes. ``_on_tool_complete`` prints a one-line, no-spinner reassurance at +dispatch so the idle prompt doesn't read as "nothing happened". +""" + +import json + +import cli +from cli import HermesCLI + + +def _make_cli(): + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj._pending_edit_snapshots = {} + return cli_obj + + +def _capture(monkeypatch): + printed: list[str] = [] + monkeypatch.setattr(cli, "_cprint", lambda text: printed.append(text)) + return printed + + +def test_background_dispatch_prints_resume_notice(monkeypatch): + cli_obj = _make_cli() + printed = _capture(monkeypatch) + + result = json.dumps({"status": "dispatched", "mode": "background", "count": 1}) + cli_obj._on_tool_complete("tc1", "delegate_task", {"goal": "x"}, result) + + joined = "\n".join(printed) + assert "resume" in joined.lower() + assert "it finishes" in joined + + +def test_background_batch_dispatch_pluralizes(monkeypatch): + cli_obj = _make_cli() + printed = _capture(monkeypatch) + + result = json.dumps({"status": "dispatched", "mode": "background", "count": 3}) + cli_obj._on_tool_complete("tc2", "delegate_task", {"tasks": []}, result) + + joined = "\n".join(printed) + assert "3 tasks" in joined + assert "they finish" in joined + + +def test_synchronous_delegate_result_prints_no_notice(monkeypatch): + """A non-background result (e.g. the stateless sync fallback) must not claim + a background dispatch.""" + cli_obj = _make_cli() + printed = _capture(monkeypatch) + + result = json.dumps({"results": [{"status": "completed", "summary": "done"}]}) + cli_obj._on_tool_complete("tc3", "delegate_task", {"goal": "x"}, result) + + assert not any("resume" in p.lower() for p in printed) + + +def test_non_delegate_tool_prints_no_notice(monkeypatch): + cli_obj = _make_cli() + printed = _capture(monkeypatch) + + cli_obj._on_tool_complete("tc4", "read_file", {"path": "a"}, '{"ok": true}') + + assert not any("resume" in p.lower() for p in printed)