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/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 } + } +) 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 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) 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 ? ( {' │ '}