From dda103d2cf69dbdd730d0a7179bb3021b6467501 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:21:57 -0700 Subject: [PATCH 1/3] fix(native-chat): one `/` picker for every agent, anywhere in the prompt (#19832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): one `/` picker for every agent, anywhere in the prompt The composer only opened its picker when `/` was the first character of the draft, so a skill named mid-sentence ("validate it with /electron") offered nothing. Codex was worse: its `/` menu listed commands only, and skills lived on a separate `$` trigger, so the prompt box behaved differently per agent. `/` is now the whole composer grammar. It opens one grouped commands+skills menu for every agent with a known grammar, both at the start of the draft and mid-prompt after whitespace. The `$` trigger is gone. Per-agent invocation is preserved where it belongs — in what a pick writes. Each row carries its own token, so choosing a skill in Codex inserts `$electron` while Claude inserts `/electron`, and the text that reaches the agent stays the text that agent actually invokes. Only a draft-leading command is dispatchable; picking one mid-sentence completes the token instead of sending the command on its own and discarding the draft. Name collisions now key on whether both kinds share a sigil, so a Codex `/review` command and a `$review` skill stay separate rows. * test(native-chat): model dismissal inputs on the live `/` grammar The trigger-key swap cases still used `$:4` keys. editReplacesTriggerToken is sigil-agnostic so they passed, but they modelled an input the composer can no longer produce. * test(native-chat): pin inline picker dispatch and discovery reuse --------- Co-authored-by: Merge Sim --- .../NativeChatAutocompleteMenus.test.tsx | 3 + .../NativeChatAutocompleteMenus.tsx | 12 +- .../native-chat/NativeChatComposerField.tsx | 13 +- .../native-chat-composer-state.test.ts | 201 ++++++++++++++---- .../native-chat/native-chat-composer-state.ts | 86 ++++---- .../native-chat/native-chat-picker-items.ts | 54 +++-- .../use-native-chat-composer-catalog.test.tsx | 1 + .../use-native-chat-composer-keydown.test.tsx | 44 +++- .../use-native-chat-composer-keydown.ts | 6 +- ...tive-chat-picker-command-dispatch.test.tsx | 1 + .../use-native-chat-picker-state.ts | 20 +- .../use-native-chat-skills.react.test.tsx | 24 +++ src/shared/native-chat-agent-profiles.test.ts | 9 +- src/shared/native-chat-agent-profiles.ts | 5 - 14 files changed, 342 insertions(+), 137 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx index d5dac1efab5..88506c51838 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx @@ -13,6 +13,7 @@ function autocomplete( query: '', triggerKey: '/:0', prefix: '/', + dispatchable: true, grouped: true, commandsEnabled: true, skillsEnabled: true, @@ -21,6 +22,7 @@ function autocomplete( kind: 'command', id: 'command:clear', name: 'clear', + token: '/clear', description: 'Clear history', skillCollision: false }, @@ -28,6 +30,7 @@ function autocomplete( kind: 'skill', id: 'skill:browser', name: 'browser', + token: '/browser', description: 'Use a browser', sources: [{ sourceKind: 'repo', skillFilePath: '/repo/browser/SKILL.md' }] } diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index 8357931fb49..7f1cd943c0f 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -12,7 +12,7 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({ onChoose, onRetry }: { - autocomplete: Extract + autocomplete: Extract activeIndex: number listboxId: string onChoose: (item: NativeChatPickerItem) => void @@ -54,7 +54,6 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({ + autocomplete: Extract ): string { - if (autocomplete.mode === 'skill' || !autocomplete.commandsEnabled) { + if (!autocomplete.commandsEnabled) { return translate('components.native-chat.composer.noSkills', 'No matching skills') } if (autocomplete.skillsEnabled) { @@ -174,7 +172,6 @@ function PickerStatus({ children }: { children: React.ReactNode }): React.JSX.El function PickerOption({ item, - prefix, index, activeIndex, listboxId, @@ -182,7 +179,6 @@ function PickerOption({ onChoose }: { item: NativeChatPickerItem - prefix: '/' | '$' index: number activeIndex: number listboxId: string @@ -213,7 +209,7 @@ function PickerOption({ ) : null} - {prefix + item.name} + {item.token} {item.description ? ( {item.description} ) : null} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index 6b474a2f267..f51bf4c5400 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -166,7 +166,7 @@ export function NativeChatComposerField({ {/* Extra bottom padding keeps the input box off the window rim. */}
- {autocomplete.mode === 'slash' || autocomplete.mode === 'skill' ? ( + {autocomplete.mode === 'slash' ? ( 0 + autocomplete.mode === 'slash' && autocomplete.items.length > 0 ? `${pickerListboxId}-option-${Math.min(activeSuggestion, autocomplete.items.length - 1)}` : undefined } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts index 483d29f1ff2..9d116d373fb 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts @@ -9,6 +9,7 @@ import { editReplacesTriggerToken, EMPTY_HISTORY, filterSlashCommands, + isSkillPickerTriggered, isSlashCommandDraft, pushHistory, recallNext, @@ -96,28 +97,30 @@ describe('deriveComposerAutocomplete — mention', () => { }) }) -describe('deriveComposerAutocomplete — skill', () => { +describe('deriveComposerAutocomplete — one grammar for every agent', () => { const skills = [ skill({ name: 'typescript' }), skill({ name: 'react-useeffect', directoryPath: '/repo/.agents/skills/react-useeffect' }) ] + const codex = getNativeChatAgentProfile('codex') - it('enters skill mode with the query after `$`', () => { - const result = deriveComposerAutocomplete('use $type', 9, COMMANDS, skills) - expect(result.mode).toBe('skill') - if (result.mode !== 'skill') { + it('offers Codex skills under `/`, tokenised as the form Codex invokes', () => { + const result = deriveComposerAutocomplete('use /type', 9, COMMANDS, skills, codex) + expect(result.mode).toBe('slash') + if (result.mode !== 'slash') { return } expect(result.query).toBe('type') - expect(result.items.map((entry) => entry.name)).toEqual(['typescript']) + expect(result.items.map((entry) => entry.token)).toEqual(['$typescript']) }) - it('fires at the start of input too', () => { - expect(deriveComposerAutocomplete('$react', 6, COMMANDS, skills).mode).toBe('skill') + it('no longer treats `$` as a composer trigger', () => { + expect(deriveComposerAutocomplete('use $type', 9, COMMANDS, skills, codex).mode).toBe('none') + expect(deriveComposerAutocomplete('$react', 6, COMMANDS, skills, codex).mode).toBe('none') }) it('does not fire inside shell-style text', () => { - expect(deriveComposerAutocomplete('price$tag', 9, COMMANDS, skills).mode).toBe('none') + expect(deriveComposerAutocomplete('price$tag', 9, COMMANDS, skills, codex).mode).toBe('none') }) }) @@ -194,31 +197,151 @@ describe('apply suggestions', () => { expect(result.caret).toBe('open @src/app.ts '.length) }) - it('applyPickerSuggestion replaces the active $token at the caret', () => { - const result = applyPickerSuggestion( - 'use $typ now', - 8, - { kind: 'skill', id: 'skill:typescript', name: 'typescript', description: null, sources: [] }, - '$' - ) + it('applyPickerSuggestion swaps the typed /token for the agent-native token', () => { + const result = applyPickerSuggestion('use /typ now', 8, { + kind: 'skill', + id: 'skill:typescript', + name: 'typescript', + token: '$typescript', + description: null, + sources: [] + }) expect(result.draft).toBe('use $typescript now') expect(result.caret).toBe('use $typescript '.length) + expect(result.insertedToken).toBe('$typescript') }) }) describe('native skill and command picker', () => { - it('keeps Codex commands under slash and skills under dollar', () => { - const profile = getNativeChatAgentProfile('codex') - const slash = deriveComposerAutocomplete('/', 1, COMMANDS, [skill({})], profile) + it('puts Codex commands and skills in one `/` menu, each with its own token', () => { + const slash = deriveComposerAutocomplete( + '/', + 1, + COMMANDS, + [skill({ name: 'browser' })], + getNativeChatAgentProfile('codex') + ) expect(slash.mode).toBe('slash') - if (slash.mode === 'slash') { - expect(slash.items.every((item) => item.kind === 'command')).toBe(true) + if (slash.mode !== 'slash') { + return } - const dollar = deriveComposerAutocomplete('$', 1, COMMANDS, [skill({})], profile) - expect(dollar.mode).toBe('skill') - if (dollar.mode === 'skill') { - expect(dollar.items.every((item) => item.kind === 'skill')).toBe(true) + expect(slash.grouped).toBe(true) + expect(slash.items.filter((item) => item.kind === 'command').map((item) => item.token)).toEqual( + ['/clear', '/compact', '/help'] + ) + expect(slash.items.filter((item) => item.kind === 'skill').map((item) => item.token)).toEqual([ + '$browser' + ]) + }) + + it('keeps a Codex command and a same-named skill as separate rows', () => { + const result = deriveComposerAutocomplete( + '/clear', + 6, + COMMANDS, + [skill({ name: 'clear' })], + getNativeChatAgentProfile('codex') + ) + expect(result.mode).toBe('slash') + if (result.mode !== 'slash') { + return } + expect(result.items.map((item) => item.token)).toEqual(['/clear', '$clear']) + expect(result.items.find((item) => item.kind === 'command')?.skillCollision).toBe(false) + }) + + it('offers the same commands and skills for a `/` typed mid-prompt as for a leading one', () => { + const args = [ + COMMANDS, + [skill({ name: 'electron' })], + getNativeChatAgentProfile('claude') + ] as const + const leading = deriveComposerAutocomplete('/', 1, ...args) + const midPrompt = deriveComposerAutocomplete('validate it with /', 18, ...args) + expect(midPrompt.mode).toBe('slash') + if (midPrompt.mode !== 'slash' || leading.mode !== 'slash') { + return + } + expect(midPrompt.items).toEqual(leading.items) + expect(midPrompt.items.map((item) => item.kind)).toContain('command') + expect(midPrompt.items.map((item) => item.kind)).toContain('skill') + expect(midPrompt.grouped).toBe(leading.grouped) + }) + + it('filters the mid-prompt `/` menu by the typed token', () => { + const result = deriveComposerAutocomplete( + 'validate it with /elec', + 22, + COMMANDS, + [skill({ name: 'electron' })], + getNativeChatAgentProfile('claude') + ) + expect(result.mode).toBe('slash') + if (result.mode === 'slash') { + expect(result.prefix).toBe('/') + expect(result.items.map((item) => item.name)).toEqual(['electron']) + } + }) + + it('marks only a draft-leading `/command` dispatchable', () => { + const profile = getNativeChatAgentProfile('claude') + const leading = deriveComposerAutocomplete('/comp', 5, COMMANDS, [], profile) + const midPrompt = deriveComposerAutocomplete('then /comp', 10, COMMANDS, [], profile) + expect(leading.mode === 'slash' && leading.dispatchable).toBe(true) + expect(midPrompt.mode === 'slash' && midPrompt.dispatchable).toBe(false) + }) + + it('leaves a mid-prompt path alone', () => { + expect( + deriveComposerAutocomplete( + 'open /Users/me/notes', + 20, + COMMANDS, + [skill({ name: 'electron' })], + getNativeChatAgentProfile('claude') + ).mode + ).toBe('none') + }) + + it('opens the mid-prompt `/` menu for Codex too, tokenised for Codex', () => { + const result = deriveComposerAutocomplete( + 'validate it with /elec', + 22, + COMMANDS, + [skill({ name: 'electron' })], + getNativeChatAgentProfile('codex') + ) + expect(result.mode).toBe('slash') + if (result.mode !== 'slash') { + return + } + expect(result.dispatchable).toBe(false) + expect(result.items.map((item) => item.token)).toEqual(['$electron']) + }) + + it.each(['claude', 'codex'] as const)( + 'loads the skill catalog for both `/` trigger positions on %s', + (agent) => { + const profile = getNativeChatAgentProfile(agent) + expect(isSkillPickerTriggered('/elec', profile)).toBe(true) + expect(isSkillPickerTriggered('validate it with /elec', profile)).toBe(true) + expect(isSkillPickerTriggered('open /Users/me', profile)).toBe(false) + // Without a catalog fetch the menu would sit on a permanent loading row. + expect(isSkillPickerTriggered('use $elec', profile)).toBe(false) + } + ) + + it('applyPickerSuggestion replaces a mid-prompt /token at the caret', () => { + const result = applyPickerSuggestion('validate it with /elec now', 22, { + kind: 'skill', + id: 'skill:electron', + name: 'electron', + token: '/electron', + description: null, + sources: [] + }) + expect(result.draft).toBe('validate it with /electron now') + expect(result.caret).toBe('validate it with /electron '.length) }) it('groups Claude commands and skills under slash', () => { @@ -327,7 +450,7 @@ describe('native skill and command picker', () => { '$' ) expect(items.map((item) => item.name)).toEqual([longName]) - const applied = applyPickerSuggestion('$sk', 3, items[0], '$') + const applied = applyPickerSuggestion('/sk', 3, items[0]) expect(applied.draft).toBe(`$${longName} `) }) @@ -364,12 +487,14 @@ describe('native skill and command picker', () => { }) it('replaces only the active slash token and preserves text after the caret', () => { - const result = applyPickerSuggestion( - '/bro trailing', - 4, - { kind: 'skill', id: 'skill:browser', name: 'browser', description: null, sources: [] }, - '/' - ) + const result = applyPickerSuggestion('/bro trailing', 4, { + kind: 'skill', + id: 'skill:browser', + name: 'browser', + token: '/browser', + description: null, + sources: [] + }) expect(result.draft).toBe('/browser trailing') expect(result.caret).toBe('/browser '.length) }) @@ -396,29 +521,29 @@ describe('native skill and command picker', () => { it('treats a one-edit token swap as a new trigger occurrence', () => { expect(editReplacesTriggerToken('/foo', '/bar', '/:0')).toBe(true) - expect(editReplacesTriggerToken('use $foo', 'use $bar', '$:4')).toBe(true) + expect(editReplacesTriggerToken('use /foo', 'use /bar', '/:4')).toBe(true) }) it('keeps suppression while typing or deleting inside the dismissed token', () => { expect(editReplacesTriggerToken('/foo', '/food', '/:0')).toBe(false) expect(editReplacesTriggerToken('/food', '/foo', '/:0')).toBe(false) - expect(editReplacesTriggerToken('use $foo now', 'ran $foo now', '$:4')).toBe(false) + expect(editReplacesTriggerToken('use /foo now', 'ran /foo now', '/:4')).toBe(false) }) it('suppresses only the dismissed trigger occurrence', () => { const profile = getNativeChatAgentProfile('codex') - expect(deriveComposerAutocomplete('use $bro', 8, COMMANDS, [skill({})], profile).mode).toBe( - 'skill' + expect(deriveComposerAutocomplete('use /bro', 8, COMMANDS, [skill({})], profile).mode).toBe( + 'slash' ) expect( deriveComposerAutocomplete( - 'use $bro', + 'use /bro', 8, COMMANDS, [skill({})], profile, { status: 'ready', skills: [skill({})] }, - '$:4' + '/:4' ).mode ).toBe('none') }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.ts index 3535a90c04b..b76622ed255 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.ts @@ -9,6 +9,8 @@ import { } from '../../../../shared/native-chat-slash-commands' import { buildNativeChatPickerItems, + LEADING_SLASH_TRIGGER, + MID_PROMPT_SLASH_TRIGGER, type NativeChatPickerItem, type NativeChatSkillDiscoverySnapshot } from './native-chat-picker-items' @@ -28,7 +30,9 @@ type PickerAutocomplete = { query: string items: NativeChatPickerItem[] triggerKey: string - prefix: '/' | '$' + prefix: '/' + /** Only a draft-leading `/command` reaches the agent as a command. */ + dispatchable: boolean grouped: boolean commandsEnabled: boolean skillsEnabled: boolean @@ -40,10 +44,20 @@ export type ComposerAutocomplete = | { mode: 'none' } | ({ mode: 'slash' } & PickerAutocomplete) | { mode: 'mention'; query: string } - | ({ mode: 'skill' } & PickerAutocomplete) const EMPTY_DISCOVERY: NativeChatSkillDiscoverySnapshot = { status: 'ready', skills: [] } +/** Whether the caret sits in a token that needs the skill catalog loaded. */ +export function isSkillPickerTriggered( + before: string, + profile: NativeChatAgentProfile | null +): boolean { + if (!profile) { + return false + } + return LEADING_SLASH_TRIGGER.test(before) || MID_PROMPT_SLASH_TRIGGER.test(before) +} + export function deriveComposerAutocomplete( draft: string, caret: number, @@ -55,9 +69,11 @@ export function deriveComposerAutocomplete( sessionSkillNames?: readonly string[] ): ComposerAutocomplete { const before = draft.slice(0, caret) - if (before.startsWith('/') && !/\s/.test(before)) { + const leadingMatch = before.match(LEADING_SLASH_TRIGGER) + if (leadingMatch) { return deriveSlashAutocomplete( - before, + leadingMatch[1], + 0, agentCommands, profile, discovery, @@ -69,70 +85,64 @@ export function deriveComposerAutocomplete( if (mentionMatch) { return { mode: 'mention', query: mentionMatch[1] } } - const skillMatch = - profile?.skillPrefix === '$' || (!profile && skills.length > 0) - ? before.match(/(?:^|\s)\$(\S*)$/) - : null - if (!skillMatch) { + // Why: `/` is the whole composer grammar, so a mid-prompt token opens the same + // menu a leading one does — it just cannot dispatch. + const midPromptMatch = profile ? before.match(MID_PROMPT_SLASH_TRIGGER) : null + if (!midPromptMatch) { return { mode: 'none' } } - const triggerKey = `$:${before.length - skillMatch[1].length - 1}` - if (dismissedTriggerKey === triggerKey) { - return { mode: 'none' } - } - const query = skillMatch[1] - return { - mode: 'skill', + const query = midPromptMatch[1] + return deriveSlashAutocomplete( query, - triggerKey, - prefix: '$', - grouped: false, - commandsEnabled: false, - skillsEnabled: true, - items: buildNativeChatPickerItems([], discovery.skills, query, '$', sessionSkillNames), - skillStatus: discovery.status === 'idle' ? 'loading' : discovery.status, - ...(discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {}) - } + before.length - query.length - 1, + agentCommands, + profile, + discovery, + dismissedTriggerKey, + sessionSkillNames + ) } function deriveSlashAutocomplete( - before: string, + query: string, + triggerPosition: number, agentCommands: readonly SlashCommandSuggestion[], profile: NativeChatAgentProfile | null, discovery: NativeChatSkillDiscoverySnapshot, dismissedTriggerKey: string | null, sessionSkillNames: readonly string[] | undefined ): ComposerAutocomplete { - const triggerKey = '/:0' + const triggerKey = `/:${triggerPosition}` if (dismissedTriggerKey === triggerKey) { return { mode: 'none' } } - const query = before.slice(1) - const hasSlashSkills = profile?.skillPrefix === '/' - // Why: the caller owns catalog policy (e.g. Grok ships skills-only until a - // verified catalog lands); this derivation must not re-gate per agent. + // Every agent with a known grammar offers skills here; only the token a pick + // inserts differs. The caller owns catalog policy (e.g. Grok ships skills-only + // until a verified catalog lands), so this derivation must not re-gate per agent. + const skillsEnabled = profile !== null const items = buildNativeChatPickerItems( agentCommands, - hasSlashSkills ? discovery.skills : [], + skillsEnabled ? discovery.skills : [], query, - '/', - hasSlashSkills ? sessionSkillNames : [] + profile?.skillPrefix ?? '/', + skillsEnabled ? sessionSkillNames : [] ) return { mode: 'slash', query, triggerKey, prefix: '/', - grouped: profile?.groupedSlash === true, + dispatchable: triggerPosition === 0, + grouped: skillsEnabled, commandsEnabled: agentCommands.length > 0, - skillsEnabled: hasSlashSkills, + skillsEnabled, items, - skillStatus: hasSlashSkills + skillStatus: skillsEnabled ? discovery.status === 'idle' ? 'loading' : discovery.status : 'ready', - ...(hasSlashSkills && discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {}) + ...(skillsEnabled && discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {}) } } diff --git a/src/renderer/src/components/native-chat/native-chat-picker-items.ts b/src/renderer/src/components/native-chat/native-chat-picker-items.ts index 427e3b74497..1a21a2461c6 100644 --- a/src/renderer/src/components/native-chat/native-chat-picker-items.ts +++ b/src/renderer/src/components/native-chat/native-chat-picker-items.ts @@ -18,6 +18,8 @@ export type NativeChatPickerItem = kind: 'command' id: string name: string + /** Exactly what a pick inserts — the form the agent invokes. */ + token: string description?: string skillCollision: boolean } @@ -25,6 +27,7 @@ export type NativeChatPickerItem = kind: 'skill' id: string name: string + token: string description: string | null sources: { sourceKind: SkillSourceKind; skillFilePath: string }[] } @@ -47,16 +50,24 @@ export function buildNativeChatPickerItems( commands: readonly SlashCommandSuggestion[], skills: readonly DiscoveredSkill[], query: string, - prefix: '/' | '$', + skillSigil: '/' | '$', sessionSkillNames?: readonly string[] ): NativeChatPickerItem[] { + // A name can only collide when both kinds invoke through the same sigil; + // where skills carry their own, `/review` and `$review` are distinct entries. + const sharedSigil = skillSigil === '/' const unclassifiedNames = new Set( commands.filter((command) => command.kindUnspecified).map((command) => command.name) ) - const mergedSkills = mergeNativeChatSkills(skills, sessionSkillNames, unclassifiedNames) + const mergedSkills = mergeNativeChatSkills( + skills, + sessionSkillNames, + unclassifiedNames, + skillSigil + ) const skillNames = new Set(mergedSkills.map((skill) => skill.name)) const resolvedCommands = commands.filter( - (command) => !(command.kindUnspecified && skillNames.has(command.name)) + (command) => !(sharedSigil && command.kindUnspecified && skillNames.has(command.name)) ) const commandNames = new Set(resolvedCommands.map((command) => command.name)) const commandItems = rankItems( @@ -67,8 +78,9 @@ export function buildNativeChatPickerItems( // it is inserted verbatim; only untrusted skill text gets sanitized. id: `command:${command.name}`, name: command.name, + token: `/${command.name}`, description: command.description ? sanitizePickerText(command.description, 240) : undefined, - skillCollision: prefix === '/' && skillNames.has(command.name) + skillCollision: sharedSigil && skillNames.has(command.name) }, stableOrder: index })), @@ -76,7 +88,7 @@ export function buildNativeChatPickerItems( ) const skillItems = rankItems( mergedSkills - .filter((skill) => !(prefix === '/' && commandNames.has(skill.name))) + .filter((skill) => !(sharedSigil && commandNames.has(skill.name))) .map((item, index) => ({ item, stableOrder: index })), query ) @@ -89,7 +101,8 @@ export function buildNativeChatPickerItems( function mergeNativeChatSkills( skills: readonly DiscoveredSkill[], sessionSkillNames: readonly string[] | undefined, - unclassifiedNames: ReadonlySet + unclassifiedNames: ReadonlySet, + skillSigil: '/' | '$' ): Extract[] { const exactPaths = new Map() for (const skill of skills) { @@ -106,7 +119,10 @@ function mergeNativeChatSkills( byName.set(safeName, [...(byName.get(safeName) ?? []), { ...skill, name: safeName }]) } const discovered = new Map( - [...byName.entries()].map(([name, namedSkills]) => [name, pickerSkill(name, namedSkills)]) + [...byName.entries()].map(([name, namedSkills]) => [ + name, + pickerSkill(name, namedSkills, skillSigil) + ]) ) // Why: when the running session reports its own skills, that report is the // authority on which ones exist — a disk scan cannot see what the session @@ -121,19 +137,21 @@ function mergeNativeChatSkills( ] : [...discovered.keys()] return [...new Set(names)] - .map((name) => discovered.get(name) ?? pickerSkill(name, [])) + .map((name) => discovered.get(name) ?? pickerSkill(name, [], skillSigil)) .sort(comparePickerSkills) } function pickerSkill( name: string, - namedSkills: readonly DiscoveredSkill[] + namedSkills: readonly DiscoveredSkill[], + skillSigil: '/' | '$' ): Extract { const sorted = [...namedSkills].sort(compareDiscoveredSkills) return { kind: 'skill' as const, id: `skill:${name}`, name, + token: `${skillSigil}${name}`, description: sorted[0]?.description ? sanitizePickerText(sorted[0].description, 240) : null, sources: sorted.map((skill) => ({ sourceKind: skill.sourceKind, @@ -245,21 +263,27 @@ function comparePickerSkills( ) } +// `/` is the composer's only trigger, for every agent. A draft-leading slash is +// the one that can dispatch; elsewhere the token starts after whitespace and its +// query stops at the next `/` so file paths stay prose. +export const LEADING_SLASH_TRIGGER = /^\/(\S*)$/ +export const MID_PROMPT_SLASH_TRIGGER = /\s\/([^\s/]*)$/ + +/** Replaces the typed `/token` with the item's own token, which for a skill is + * the agent-native form even though every agent is typed the same way. */ export function applyPickerSuggestion( draft: string, caret: number, - item: NativeChatPickerItem, - prefix: '/' | '$' + item: NativeChatPickerItem ): { draft: string; caret: number; insertedToken: string } { const before = draft.slice(0, caret) const after = draft.slice(caret) - const match = prefix === '/' ? before.match(/^\/(\S*)$/) : before.match(/(^|\s)\$(\S*)$/) + const match = before.match(LEADING_SLASH_TRIGGER) ?? before.match(MID_PROMPT_SLASH_TRIGGER) if (!match) { return { draft, caret, insertedToken: '' } } const query = match.at(-1) ?? '' const tokenStart = before.length - query.length - 1 - const insertedToken = `${prefix}${item.name}` - const nextBefore = `${before.slice(0, tokenStart)}${insertedToken} ` - return { draft: nextBefore + after, caret: nextBefore.length, insertedToken } + const nextBefore = `${before.slice(0, tokenStart)}${item.token} ` + return { draft: nextBefore + after, caret: nextBefore.length, insertedToken: item.token } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx index 1c68ac91ffa..9e7b3abc5b2 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx @@ -103,6 +103,7 @@ it('Enter completes a known pre-init skill while still dispatching a built-in co items, triggerKey: '/', prefix: '/', + dispatchable: true, grouped: true, commandsEnabled: true, skillsEnabled: true, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx index ed3814e8476..bf39562169e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx @@ -2,13 +2,20 @@ import { renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { EMPTY_HISTORY, type ComposerAutocomplete } from './native-chat-composer-state' +import { + applyPickerSuggestion, + deriveComposerAutocomplete, + EMPTY_HISTORY, + type ComposerAutocomplete +} from './native-chat-composer-state' +import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' const COMMAND = { kind: 'command' as const, id: 'command:clear', name: 'clear', + token: '/clear', description: 'Clear history', skillCollision: false } @@ -20,6 +27,7 @@ function picker(items = [COMMAND]): Extract composing, ...callbacks @@ -81,6 +89,36 @@ describe('useNativeChatComposerKeyDown', () => { expect(callbacks.send).toHaveBeenCalledOnce() }) + it.each(['claude', 'openclaude', 'codex', 'grok'] as const)( + 'completes mid-prompt command Enter without dispatching or losing prose for %s', + (agent) => { + const draft = 'Explain /cle before continuing' + const caret = 'Explain /cle'.length + const autocomplete = deriveComposerAutocomplete( + draft, + caret, + [COMMAND], + [], + getNativeChatAgentProfile(agent) + ) + expect(autocomplete.mode).toBe('slash') + const { handler, callbacks } = setup(autocomplete, false, draft) + const event = keyEvent('Enter') + handler(event as never) + + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(callbacks.dispatchPickerCommand).not.toHaveBeenCalled() + expect(callbacks.send).not.toHaveBeenCalled() + expect(callbacks.completePickerItem).toHaveBeenCalledOnce() + const [item] = callbacks.completePickerItem.mock.calls[0] + expect(applyPickerSuggestion(draft, caret, item)).toEqual({ + draft: 'Explain /clear before continuing', + caret: 'Explain /clear '.length, + insertedToken: '/clear' + }) + } + ) + it('dismisses Escape without interrupting the agent', () => { const { handler, callbacks } = setup() handler(keyEvent('Escape') as never) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts index 2fb712f9825..c85ad949aad 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts @@ -51,7 +51,7 @@ export function useNativeChatComposerKeyDown({ return } - if (autocomplete.mode === 'slash' || autocomplete.mode === 'skill') { + if (autocomplete.mode === 'slash') { const items = autocomplete.items if (event.key === 'ArrowDown' && items.length > 0) { event.preventDefault() @@ -66,7 +66,9 @@ export function useNativeChatComposerKeyDown({ if ((event.key === 'Enter' || event.key === 'Tab') && items.length > 0) { event.preventDefault() const item = items[activeSuggestion] ?? items[0] - if (event.key === 'Enter' && item.kind === 'command') { + // A mid-prompt command is part of the sentence being written, so Enter + // completes the token instead of sending the command on its own. + if (event.key === 'Enter' && item.kind === 'command' && autocomplete.dispatchable) { dispatchPickerCommand(item) } else { completePickerItem(item) diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx index 90c495a2bc7..7584dcc15a4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx @@ -23,6 +23,7 @@ const COMMAND = { kind: 'command' as const, id: 'command:status', name: 'status', + token: '/status', description: 'Show status', skillCollision: false } diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts index d96060342e5..9d4054b433e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts @@ -18,6 +18,7 @@ import { classifyNativeChatSend, deriveComposerAutocomplete, editReplacesTriggerToken, + isSkillPickerTriggered, type ComposerAutocomplete, type NativeChatPickerItem, type NativeChatSendClassification @@ -68,13 +69,7 @@ export function useNativeChatPickerState(args: { setActiveSuggestion } = args const profile = useMemo(() => getNativeChatAgentProfile(agent), [agent]) - const beforeCaret = draft.slice(0, caret) - const skillPickerTriggered = - profile?.skillPrefix === '$' - ? /(?:^|\s)\$\S*$/.test(beforeCaret) - : profile?.skillPrefix === '/' - ? beforeCaret.startsWith('/') && !/\s/.test(beforeCaret) - : false + const skillPickerTriggered = isSkillPickerTriggered(draft.slice(0, caret), profile) const discovery = useNativeChatSkills(agent, terminalTabId, skillPickerTriggered) const listboxId = `native-chat-picker-${useId().replaceAll(':', '')}` const dismissalContext = `${draftScopeKey}:${agent}` @@ -114,7 +109,7 @@ export function useNativeChatPickerState(args: { }, [dismissalContext]) useEffect(() => { - if (autocomplete.mode !== 'slash' && autocomplete.mode !== 'skill') { + if (autocomplete.mode !== 'slash') { lastOpenKeyRef.current = null return } @@ -127,10 +122,10 @@ export function useNativeChatPickerState(args: { const completeItem = useCallback( (item: NativeChatPickerItem) => { - if (autocomplete.mode !== 'slash' && autocomplete.mode !== 'skill') { + if (autocomplete.mode !== 'slash') { return } - const result = applyPickerSuggestion(draft, caret, item, autocomplete.prefix) + const result = applyPickerSuggestion(draft, caret, item) if (item.kind === 'skill' && textareaRef.current?.insertSkill) { const from = result.caret - result.insertedToken.length - 1 textareaRef.current.insertSkill(from, caret, result.insertedToken) @@ -174,10 +169,7 @@ export function useNativeChatPickerState(args: { null, sessionSkillNames ) - if ( - (next.mode !== 'slash' && next.mode !== 'skill') || - next.triggerKey !== dismissed.triggerKey - ) { + if (next.mode !== 'slash' || next.triggerKey !== dismissed.triggerKey) { setDismissed(null) } }, diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx index f360394cf37..e05cbb7a870 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -3,6 +3,8 @@ import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatSkillDiscovery } from './use-native-chat-skills' +import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' +import { isSkillPickerTriggered } from './native-chat-composer-state' const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn(), @@ -56,6 +58,10 @@ function Probe({ enabled }: { enabled: boolean }): null { return null } +function DraftProbe({ draft }: { draft: string }): React.JSX.Element { + return +} + describe('useNativeChatSkills', () => { beforeEach(() => { mocks.state = stateForHost('local') @@ -132,6 +138,24 @@ describe('useNativeChatSkills', () => { ) }) + it('reuses one discovery while typing and reopening leading and mid-prompt slash tokens', async () => { + const view = render() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + + view.rerender() + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + for (const draft of ['Explain /b', 'Explain /br', 'Explain /bro']) { + view.rerender() + expect(mocks.snapshots.at(-1)?.skills.map((skill) => skill.name)).toEqual(['browser']) + } + view.rerender() + expect(mocks.snapshots.at(-1)?.status).toBe('idle') + view.rerender() + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + view.rerender() + expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(1) + }) + it('surfaces discovery failure instead of remaining loading', async () => { mocks.callRuntimeRpc.mockRejectedValueOnce(new Error('scan failed')) render() diff --git a/src/shared/native-chat-agent-profiles.test.ts b/src/shared/native-chat-agent-profiles.test.ts index 27b7e07d33b..4d64e283c01 100644 --- a/src/shared/native-chat-agent-profiles.test.ts +++ b/src/shared/native-chat-agent-profiles.test.ts @@ -2,24 +2,23 @@ import { describe, expect, it } from 'vitest' import { getNativeChatAgentProfile } from './native-chat-agent-profiles' describe('native chat agent picker profiles', () => { - it('keeps Codex dollar skills separate from slash commands', () => { + // The composer types the same `/` for every agent; skillPrefix is only the + // form a picked skill is written as. + it('keeps Codex skills invocable as dollar tokens', () => { expect(getNativeChatAgentProfile('codex')).toMatchObject({ skillPrefix: '$', - groupedSlash: false, skillSourceOwner: 'codex' }) }) - it('groups Claude-family and Grok skills under slash', () => { + it('writes Claude-family and Grok skills as slash tokens', () => { expect(getNativeChatAgentProfile('claude')).toMatchObject({ skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'claude' }) expect(getNativeChatAgentProfile('openclaude')).toMatchObject({ skillSourceOwner: 'claude' }) expect(getNativeChatAgentProfile('grok')).toMatchObject({ skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'grok' }) }) diff --git a/src/shared/native-chat-agent-profiles.ts b/src/shared/native-chat-agent-profiles.ts index ae52b48efef..6f86313d17d 100644 --- a/src/shared/native-chat-agent-profiles.ts +++ b/src/shared/native-chat-agent-profiles.ts @@ -3,7 +3,6 @@ import { getAgentSlashCommands, type SlashCommandSuggestion } from './native-cha export type NativeChatAgentProfile = { skillPrefix: '$' | '/' - groupedSlash: boolean /** OpenClaude reads Claude-owned roots, so this can differ from the agent. */ skillSourceOwner: AgentType } @@ -11,22 +10,18 @@ export type NativeChatAgentProfile = { const NATIVE_CHAT_AGENT_PROFILES: Partial> = { codex: { skillPrefix: '$', - groupedSlash: false, skillSourceOwner: 'codex' }, claude: { skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'claude' }, openclaude: { skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'claude' }, grok: { skillPrefix: '/', - groupedSlash: true, skillSourceOwner: 'grok' } } From 4e0aa7a4735621f72f7bbb5b0ba46585eb3e4066 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:40:42 +0000 Subject: [PATCH 2/3] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 39008eaa963..3ea69247a26 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 45m + + downloads: 46m @@ -15,7 +15,7 @@ downloads downloads - 45m - 45m + 46m + 46m From 2bf298d1dc1a41c03a270e89eca08834c36f13e0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:47:47 -0700 Subject: [PATCH 3/3] feat(native-chat): the background-tasks strip says what is running (#19311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): name, group, and state the background-tasks strip The strip above the composer described five different kinds of background work as "Monitoring background tasks", with identical flat-dot rows. Now: - Wire: additive optional `name`, `state`, `startedAt` on AgentSessionBackgroundTask, plus `settledTasks` on the state object so terminal siblings of a live fan-out stay visible without changing what old clients render (they keep exactly the live `tasks` list). - Reducer equality learns the new fields, so a publish whose only change is a task's state is no longer judged equal and dropped. - Header counts by kind and lists states within a kind; past three kind segments (or on a narrow strip, measured by its own border-box against the live root font size) it falls back to an honest total, never a partial enumeration, and the strip stays expandable whenever the header is lossy. - Rows group by kind (Agents / Shell / Monitors / Workflows / Tasks), stable-sorted first-seen-then-id, each with a kind icon, its own state dot, a resolved name (description -> name -> kind label), and elapsed. - Claude producer: task frames now carry name (agent_type/subagent_type), a run state mapped from patch status, and first-seen startedAt. Terminal statuses settle a task (completed->done, failed->blocked, killed/stopped->idle) instead of deleting it; settled tasks render only beside still-live work and flush when the last live task ends, so the strip exits exactly when it does today. An unreadable patch leaves a task open, never settled. - Turn gating moves off the strip: the tracker no longer zeroes its roster during a foreground turn, and the client renders the strip whenever it has contents while the idle-only flag now gates just the animated monitoring indicator and conversation commands. * feat(sidebar): indent native-chat subagents under their session row buildSubagentChildRows() has always rendered indented children from parentEntry.subagents, and the structured-session status bridge has always published an AgentStatusEntry for native chat — it just never populated subagents. Connect them: - Wire: additive optional `backgroundTasks` on AgentSessionStatusSummary (live tasks only), projected by the host status feed from the provider's backgroundTaskState hook and republished on task edges via the background-task channel, with the shared task equality suppressing no-op re-projections. - Bridge: maps agent-kind tasks onto the sidebar's own AgentSubagentState (working/waiting/blocked, terminal -> idle) — kinds stay distinct, so a backgrounded shell never lands in a subagent count — and extends its pre-write equality with the existing agentSubagentsEqual. - parentIsFresh for a bridge entry means "the host feed reported a change inside the sidebar's ordinary evidence window": every publish restamps evidenceObservedAt, and a dead feed stops restamping, so children decay to idle on lost contact instead of pinning 'working'. * fix(native-chat): settle tasks the aggregate roster evicted first; carry usage Real-agent QA showed settledTasks never rendered. A frame capture from the SDK (probe against claude 2.1.261) explains it: when a backgrounded child finishes, the producer emits `background_tasks_changed` FIRST — with the task already absent — and only then `task_updated`/`task_notification` with the outcome, in the same tick. The tracker's settle path looked the task up in the live roster the aggregate had just evicted, so retention lost the race 100% of the time. Fix: aggregate eviction of a live backgrounded task now parks its details in a bounded recently-removed map (new claude-settled-background-tasks.ts, which also owns the settled roster), and the trailing terminal edge consumes it. A removal whose outcome frame never arrives still vanishes — nothing is guessed into a finished state. A second terminal edge for the same task re-derives the settled state and can add final usage. The captured sequence is replayed verbatim as a tracker test, including the kill-at-exit tail proving the strip still exits with the last live task. The same capture disproved the PR's earlier claim that Claude task frames carry no usage: task_progress and task_notification both carry usage.total_tokens. Additive optional `totalTokens` on the wire task, covered by the shared equality; the tracker takes usage (never the transient "Running " description) from task_progress, and rows render the mock's "18.1k · 2m" meta — settled rows keep final usage with no still-growing clock. * chore(i18n): sync runtime-required catalog for backgroundTasks.runningList * fix(native-chat): preserve background task lifecycle and bound update work * fix(native-chat): transfer resumed background tasks to one live owner * fix(native-chat): bring structured session host under the line cap and restore subscribe fixture * fix(native-chat): complete journal stubs and stop notifying on feed teardown The status feed's projection cache calls journal.cursor(); the rename test's stubs are cast through unknown, so the missing method only surfaced at runtime. Teardown runs only once nothing is activated, so there is no mounted reader to notify - clearing confirmed sessions is what prevents a stale live on reactivation. * feat(native-chat): lead each strip header count with its kind icon The header carried one aggregate state dot, so a fan-out of agents and a monitor looked alike. Each count segment now leads with its own kind glyph; a collapsed total spans kinds and takes none. Monitor is the heartbeat AgentStateDot already draws for monitoring, so the strip and the agent sidebar speak one vocabulary. * feat(native-chat): give the strip's monitor heartbeat the sidebar amber The glyph matched AgentStateDot but the colour did not, so a monitor in the strip did not read as the monitor in the agent sidebar. One shared tone helper now serves the header segment and the expanded row, so they cannot diverge. Monitoring is a state the app already colours; the other four kinds are plain markers and stay neutral. A running turn still dims the whole set. * fix(native-chat): draw the strip header separator in a visible tone The separator used `text-border`, a divider-line token that is 7% white in dark mode - an order of magnitude fainter than the counts on either side, so the dot between them read as absent. main.css already records that token as too faint for a visible mark. * fix(native-chat): give the worktree-ps journal stub a cursor The status feed's projection cache calls journal.cursor(); this stub is cast through unknown, so the missing method only surfaced at runtime. Its journal never changes, so a real one would hold the cursor steady. * refactor(native-chat): split the sidebar subagent rows out of this PR The strip stands alone: the sidebar mapping, its observation plumbing and the AgentStatusEntry.subagents wiring move to a stacked follow-up. No wire field here is sidebar-only - the strip's rows read name, state, elapsed and tokens. * perf(native-chat): keep task usage out of the session status summary A `task_progress` frame ticks a background task's `totalTokens`, which failed the status feed's equality check and re-broadcast a full summary to every `agentSession.subscribeStatus` subscriber — paired-web and SSH/relay clients included — for a number no session list renders. The projection now drops usage; tokens keep flowing on the background-task channel the strip reads. * fix(native-chat): correct token unit rounding and drop the unused dot state `formatBackgroundTaskTokens` rounded before choosing the unit, so 999_950 rendered as "1000k" instead of "1m"; pick the unit from the rounded value. `backgroundTasksDotState` has no caller on this branch or the stacked sidebar PR, and its multi-kind branch would report 'monitoring' over an attention state. Delete it rather than leave it to be wired up. * fix(i18n): drop the orphaned backgroundTasks.runningList key The strip rewrite removed its only call site, and an unreferenced key gets promoted into the eagerly parsed boot catalog. Delete it from en.json and regenerate en-runtime-required.json. * fix(native-chat): show the reason on every attention row The row guarded the reason line on 'waiting', so an 'unverifiable' child ("no contact") and a 'blocked' one ("failed") rendered bare while the collapsed header named exactly those reasons. `backgroundTaskStateReason` already returns null for the non-attention states, so the guard was only lossy — the SSH boundary requires the unverifiable verdict stay legible. Also keys the header segments off their kind discriminant instead of the translated display text. * fix(native-chat): make the strip header agree with its own count The headline counts live AND settled rows, but the state breakdown omitted 'done', so one working agent beside four settled ones read "5 agents — 1 working": the count said five, the breakdown accounted for one. Done now appears in the muted detail (never as an emphasised segment) so the two agree. The single-command header also drew an elapsed clock on a settled task, which the row already refuses as a lie about finished work. * perf(native-chat): memoize the background-task roster grouping The 1 Hz elapsed tick re-rendered the strip, and the render body regrouped, re-sorted and re-translated every task each time only `now` had changed. The header still derives from `now` on purpose. * test(native-chat): cover settled rows and the mid-turn mounted strip Neither headline behaviour had component coverage: every strip render passed `settledTasks={[]}`, and the `showBackgroundTasks` seam was never set true, so the strip staying mounted through a running turn was exercised nowhere. Adds a settled-beside-live row test (final usage kept, no clock, no stop) and a mid-turn mount test (strip present, turn owns the voice). The background-task tests share one session-element helper so the file stays under its line cap. * refactor(claude): keep MAX_TASK_ID_LENGTH module-private Nothing outside claude-background-task-frames.ts references it; the export was residue from this PR's split. * test(native-chat): give the mid-turn strip test a real turn main now gates the composer's stop button on a provider-minted turnId rather than the send-time working signal, so a test claiming a running turn has to supply one. The controller mock hardcoded turnId null. --------- Co-authored-by: Merge Sim --- .../first-work-branch-rename.test.ts | 7 +- .../claude/claude-background-task-frames.ts | 106 ++++++ .../claude-background-task-resume.test.ts | 92 +++++ .../claude-background-task-tracker.test.ts | 336 +++++++++++++++--- .../claude/claude-background-task-tracker.ts | 253 +++++++------ .../claude/claude-settled-background-tasks.ts | 127 +++++++ .../claude-structured-session-close.test.ts | 8 +- .../provider-frame-activity.test.ts | 4 +- ...d-agent-session-background-task-channel.ts | 6 +- .../structured-agent-session-host.ts | 13 +- ...ructured-agent-session-status-feed.test.ts | 152 +++++++- .../structured-agent-session-status-feed.ts | 86 ++++- .../local-file-sink-memory.test.ts | 6 +- .../worktree-ps-structured-host.spec.ts | 2 + ...ructured-agent-session-rpc.test-fixture.ts | 1 + .../NativeChatBackgroundTasksStatus.test.tsx | 239 ++++++++++++- .../NativeChatBackgroundTasksStatus.tsx | 265 ++++++++++---- .../NativeChatStructuredSession.test.tsx | 120 ++++--- .../NativeChatStructuredSession.tsx | 11 +- .../background-task-header-content.ts | 192 ++++++++++ .../background-task-roster.test.ts | 245 +++++++++++++ .../native-chat/background-task-roster.ts | 181 ++++++++++ ...tructured-session-background-tasks-view.ts | 32 +- .../use-structured-agent-session.ts | 11 +- src/renderer/src/i18n/locales/en.json | 30 +- src/shared/agent-session-wire.ts | 58 +++ .../structured-agent-session-reducer.test.ts | 71 ++++ .../structured-agent-session-reducer.ts | 29 +- 28 files changed, 2332 insertions(+), 351 deletions(-) create mode 100644 src/main/claude/claude-background-task-frames.ts create mode 100644 src/main/claude/claude-background-task-resume.test.ts create mode 100644 src/main/claude/claude-settled-background-tasks.ts create mode 100644 src/renderer/src/components/native-chat/background-task-header-content.ts create mode 100644 src/renderer/src/components/native-chat/background-task-roster.test.ts create mode 100644 src/renderer/src/components/native-chat/background-task-roster.ts diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 9cd4d544041..8ecd4997aa0 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -100,10 +100,14 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { isPendingFirstAgentMessageRename: () => true }) const items: AgentJournalRenderItem[] = [] + // A real journal's sequence only ever advances, so the feed's projection + // cache must miss on every publish here: this test is about the rename. + let sequence = 0 const journal = { snapshot: () => ({ items }), lastActivityAt: () => 1, - isReadOnly: false + isReadOnly: false, + cursor: () => ({ epoch: 1, sequence: (sequence += 1) }) } as unknown as AgentSessionJournal const pending: Promise[] = [] const observe = vi.fn((summary, options) => { @@ -175,6 +179,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { const journal = { isReadOnly: false, lastActivityAt: () => 1, + cursor: () => ({ epoch: 1, sequence: 1 }), snapshot: () => ({ items: [ { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] } }, diff --git a/src/main/claude/claude-background-task-frames.ts b/src/main/claude/claude-background-task-frames.ts new file mode 100644 index 00000000000..d954128d91b --- /dev/null +++ b/src/main/claude/claude-background-task-frames.ts @@ -0,0 +1,106 @@ +// Field readers for the Claude SDK's background-task lifecycle frames +// (task_started / task_updated / task_notification / background_tasks_changed). +// Pure and bounded: every reader rejects absent, non-string, or oversized +// values so a malformed frame degrades to "field unknown", never to a throw. + +import type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskRunState +} from '../../shared/agent-session-wire' + +const MAX_TASK_ID_LENGTH = 512 +const MAX_TASK_TEXT_LENGTH = 512 + +export type ClaudeBackgroundTaskKind = AgentSessionBackgroundTask['kind'] + +export function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null +} + +/** The bound every task id shares, wherever it enters. An id the roster stores + * becomes a durable entry key, so a provisional one takes the same bound the + * announced path applies — an over-long id is rejected, never truncated. */ +export function isBoundedClaudeTaskId(value: string): boolean { + return value.length > 0 && value.length <= MAX_TASK_ID_LENGTH +} + +export function taskId(message: Record): string | null { + const value = message.task_id + return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : null +} + +function boundedTaskText(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim().replace(/\s+/g, ' ') + return trimmed.length > 0 ? trimmed.slice(0, MAX_TASK_TEXT_LENGTH) : undefined +} + +export function taskDescription(value: unknown): string | undefined { + return boundedTaskText(value) +} + +/** The provider-reported identity for a task. Subagent frames have carried the + * type under both `agent_type` and `subagent_type` across SDK versions. */ +export function taskName(frame: Record): string | undefined { + return ( + boundedTaskText(frame.name) ?? + boundedTaskText(frame.agent_type) ?? + boundedTaskText(frame.subagent_type) + ) +} + +export function classifyClaudeBackgroundTaskKind(taskType: unknown): ClaudeBackgroundTaskKind { + switch (taskType) { + case 'local_agent': + return 'agent' + case 'local_workflow': + return 'workflow' + case 'local_bash': + return 'command' + case 'monitor': + return 'monitor' + default: + return 'unknown' + } +} + +/** Cumulative token usage from a task_progress / task_notification frame. */ +export function taskUsageTotalTokens(frame: Record): number | undefined { + const usage = record(frame.usage) + const total = usage?.total_tokens + return typeof total === 'number' && Number.isFinite(total) && total >= 0 + ? Math.floor(total) + : undefined +} + +/** Settled state for a terminal status. Null for anything else — an unreadable + * status never settles a task by itself. */ +export function terminalClaudeTaskRunState( + status: unknown +): AgentSessionBackgroundTaskRunState | null { + switch (status) { + case 'completed': + return 'done' + case 'failed': + return 'blocked' + case 'killed': + case 'stopped': + return 'idle' + default: + return null + } +} + +/** Live state for a non-terminal status. Null leaves the tracked state alone. */ +export function liveClaudeTaskRunState(status: unknown): AgentSessionBackgroundTaskRunState | null { + switch (status) { + case 'pending': + case 'running': + case 'paused': + return 'working' + default: + return null + } +} diff --git a/src/main/claude/claude-background-task-resume.test.ts b/src/main/claude/claude-background-task-resume.test.ts new file mode 100644 index 00000000000..c043d485304 --- /dev/null +++ b/src/main/claude/claude-background-task-resume.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' + +const agent = { + task_id: 'a962f88aa82feb1c1', + task_type: 'local_agent', + description: 'Long proof writer' +} +const shell = { task_id: 'bcl6x3ixf', task_type: 'local_bash', description: 'sleep 150' } +const sibling = { task_id: 'sibling', task_type: 'local_agent' } + +function system(subtype: string, fields: Record) { + return { type: 'system', subtype, ...fields } +} + +describe('Claude background task pause/resume ownership', () => { + it('moves a retained child back to live ownership across eviction, outcome, and auto-resume', () => { + let now = 100 + const tracker = new ClaudeBackgroundTaskTracker(() => now) + const roster = (tasks: unknown[]) => + tracker.observe(system('background_tasks_changed', { tasks })) + roster([agent, sibling, shell]) + tracker.observe( + system('task_progress', { task_id: agent.task_id, usage: { total_tokens: 18000 } }) + ) + tracker.observe({ type: 'result' }) + expect(tracker.state?.tasks).toHaveLength(3) + roster([sibling, shell]) + expect(tracker.state?.settledTasks).toBeUndefined() + tracker.observe( + system('task_updated', { task_id: agent.task_id, patch: { status: 'completed' } }) + ) + tracker.observe( + system('task_notification', { + task_id: agent.task_id, + status: 'completed', + usage: { total_tokens: 19003 } + }) + ) + expect(tracker.state?.settledTasks).toEqual([ + expect.objectContaining({ + id: agent.task_id, + state: 'done', + startedAt: 100, + totalTokens: 19003 + }) + ]) + now = 150000 + roster([agent, sibling, shell]) + expect(tracker.state?.settledTasks).toBeUndefined() + expect(tracker.state?.tasks).toEqual([ + expect.objectContaining({ + id: agent.task_id, + state: 'working', + startedAt: 100, + totalTokens: 19003 + }), + expect.objectContaining({ id: sibling.task_id }), + expect.objectContaining({ id: shell.task_id }) + ]) + expect(tracker.stoppableTaskIds).toEqual([agent.task_id, sibling.task_id, shell.task_id]) + roster([sibling, shell]) + tracker.observe( + system('task_notification', { + task_id: agent.task_id, + status: 'completed', + usage: { total_tokens: 21000 } + }) + ) + expect(tracker.state?.settledTasks).toEqual([ + expect.objectContaining({ id: agent.task_id, startedAt: 100, totalTokens: 21000 }) + ]) + roster([]) + expect(tracker.state).toBeNull() + }) + + it('reconciles an edge-only resume without keeping its earlier settled copy', () => { + const tracker = new ClaudeBackgroundTaskTracker(() => 100) + for (const task of [agent, sibling]) { + tracker.observe(system('task_started', { ...task, is_backgrounded: true })) + } + tracker.observe(system('task_notification', { task_id: agent.task_id, status: 'completed' })) + tracker.observe( + system('task_updated', { + task_id: agent.task_id, + patch: { status: 'running', is_backgrounded: true } + }) + ) + expect(tracker.state?.tasks).toHaveLength(2) + expect(tracker.state?.settledTasks).toBeUndefined() + }) +}) diff --git a/src/main/claude/claude-background-task-tracker.test.ts b/src/main/claude/claude-background-task-tracker.test.ts index d8f316d7dcd..df89a64a968 100644 --- a/src/main/claude/claude-background-task-tracker.test.ts +++ b/src/main/claude/claude-background-task-tracker.test.ts @@ -16,6 +16,11 @@ function aggregate(tasks: unknown[]): Record { return system('background_tasks_changed', { tasks }) } +function trackerAt(times: number[]): ClaudeBackgroundTaskTracker { + let index = 0 + return new ClaudeBackgroundTaskTracker(() => times[Math.min(index++, times.length - 1)]) +} + describe('ClaudeBackgroundTaskTracker', () => { it('classifies SDK task types without inferring them from descriptions', () => { expect(classifyClaudeBackgroundTaskKind('local_agent')).toBe('agent') @@ -25,27 +30,30 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(classifyClaudeBackgroundTaskKind('future_task')).toBe('unknown') }) - it('waits for the foreground turn to settle before monitoring a background task', () => { - const tracker = new ClaudeBackgroundTaskTracker() + it('publishes a backgrounded task while the foreground turn is still running', () => { + const tracker = trackerAt([100]) tracker.observe({ type: 'user' }, true) - tracker.observe( - system('task_started', { - task_id: 'task-1', - task_type: 'local_agent', - is_backgrounded: true - }) - ) - expect(tracker.state).toBeNull() - - expect(tracker.observe(result())).toBe(true) + expect( + tracker.observe( + system('task_started', { + task_id: 'task-1', + task_type: 'local_agent', + is_backgrounded: true + }) + ) + ).toBe(true) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: 'task-1', kind: 'agent' }] + tasks: [{ id: 'task-1', kind: 'agent', state: 'working', startedAt: 100 }] }) + + // The turn settling changes nothing the strip renders. + expect(tracker.observe(result())).toBe(false) + expect(tracker.state?.tasks).toHaveLength(1) }) it('uses an explicit background update for a foreground task and ignores progress alone', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe({ type: 'user' }, true) tracker.observe( system('task_started', { @@ -63,12 +71,12 @@ describe('ClaudeBackgroundTaskTracker', () => { tracker.observe(system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } })) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: 'task-1', kind: 'command' }] + tasks: [{ id: 'task-1', kind: 'command', state: 'working', startedAt: 100 }] }) }) it('publishes bounded display details when a running task description changes', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) expect( tracker.observe( system('task_started', { @@ -81,7 +89,15 @@ describe('ClaudeBackgroundTaskTracker', () => { ).toBe(true) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: 'task-1', kind: 'command', description: 'run the build' }] + tasks: [ + { + id: 'task-1', + kind: 'command', + description: 'run the build', + state: 'working', + startedAt: 100 + } + ] }) expect( @@ -103,8 +119,193 @@ describe('ClaudeBackgroundTaskTracker', () => { ).toBe(false) }) + it('carries provider-reported names and re-derives classification per transition', () => { + const tracker = trackerAt([100]) + tracker.observe( + system('task_started', { + task_id: 'task-1', + task_type: 'future_task', + is_backgrounded: true + }) + ) + expect(tracker.state?.tasks?.[0]).toMatchObject({ kind: 'unknown' }) + + expect( + tracker.observe( + system('task_updated', { + task_id: 'task-1', + patch: { task_type: 'local_agent', agent_type: 'deep_review' } + }) + ) + ).toBe(true) + expect(tracker.state?.tasks?.[0]).toMatchObject({ + kind: 'agent', + name: 'deep_review', + state: 'working' + }) + }) + + it('retains settled siblings beside live work and exits with the last live task', () => { + const tracker = trackerAt([100, 200]) + tracker.observe( + system('task_started', { task_id: 'task-a', task_type: 'local_agent', is_backgrounded: true }) + ) + tracker.observe( + system('task_started', { task_id: 'task-b', task_type: 'local_agent', is_backgrounded: true }) + ) + + expect( + tracker.observe(system('task_updated', { task_id: 'task-a', patch: { status: 'completed' } })) + ).toBe(true) + expect(tracker.state).toEqual({ + state: 'monitoring', + tasks: [{ id: 'task-b', kind: 'agent', state: 'working', startedAt: 200 }], + settledTasks: [{ id: 'task-a', kind: 'agent', state: 'done', startedAt: 100 }] + }) + expect(tracker.stoppableTaskIds).toEqual(['task-b']) + + expect( + tracker.observe(system('task_updated', { task_id: 'task-b', patch: { status: 'killed' } })) + ).toBe(true) + expect(tracker.state).toBeNull() + }) + + it('settles a sibling from the captured producer order: aggregate eviction, then the outcome', () => { + // Verbatim sequence from a real SDK capture (2026-09-07): the aggregate + // roster arrives FIRST, already missing the finished task, and the + // terminal edges trail in the same tick. + const tracker = trackerAt([100, 200]) + tracker.observe( + system('task_started', { + task_id: 'bh4zn8der', + tool_use_id: 'toolu_01M', + description: 'Sleep for 5 seconds', + is_backgrounded: true, + task_type: 'local_bash' + }) + ) + tracker.observe( + aggregate([ + { task_id: 'bh4zn8der', task_type: 'local_bash', description: 'Sleep for 5 seconds' }, + { task_id: 'bprosaiim', task_type: 'local_bash', description: 'Sleep for 25 seconds' } + ]) + ) + + // The settling child is evicted by the aggregate before any outcome frame. + tracker.observe( + aggregate([ + { task_id: 'bprosaiim', task_type: 'local_bash', description: 'Sleep for 25 seconds' } + ]) + ) + tracker.observe( + system('task_updated', { + task_id: 'bh4zn8der', + patch: { status: 'completed', end_time: 1788804376515 } + }) + ) + expect( + tracker.observe( + system('task_notification', { + task_id: 'bh4zn8der', + tool_use_id: 'toolu_01M', + status: 'completed', + summary: 'Background command "Sleep for 5 seconds" completed (exit code 0)', + usage: { total_tokens: 18130, tool_uses: 1, duration_ms: 10772 } + }) + ) + ).toBe(true) + expect(tracker.state).toEqual({ + state: 'monitoring', + tasks: [ + { + id: 'bprosaiim', + kind: 'command', + description: 'Sleep for 25 seconds', + state: 'working', + startedAt: 200 + } + ], + settledTasks: [ + { + id: 'bh4zn8der', + kind: 'command', + description: 'Sleep for 5 seconds', + state: 'done', + startedAt: 100, + totalTokens: 18130 + } + ] + }) + + // Last task killed, same captured order: the strip exits. + tracker.observe(aggregate([])) + tracker.observe(system('task_updated', { task_id: 'bprosaiim', patch: { status: 'killed' } })) + tracker.observe(system('task_notification', { task_id: 'bprosaiim', status: 'stopped' })) + expect(tracker.state).toBeNull() + }) + + it('carries task_progress usage into a live row without clobbering its name', () => { + const tracker = trackerAt([100]) + tracker.observe( + system('task_started', { + task_id: 'agent-1', + task_type: 'local_agent', + subagent_type: 'general-purpose', + description: 'Sleep 6 seconds test', + is_backgrounded: true + }) + ) + expect( + tracker.observe( + system('task_progress', { + task_id: 'agent-1', + description: 'Running Sleep for 6 seconds', + subagent_type: 'general-purpose', + usage: { total_tokens: 14866, tool_uses: 1, duration_ms: 2818 }, + last_tool_name: 'Bash' + }) + ) + ).toBe(true) + expect(tracker.state?.tasks?.[0]).toEqual({ + id: 'agent-1', + kind: 'agent', + // Progress descriptions are transient activity, never the task's name. + description: 'Sleep 6 seconds test', + name: 'general-purpose', + state: 'working', + startedAt: 100, + totalTokens: 14866 + }) + }) + + it('maps terminal statuses onto settled states', () => { + const tracker = trackerAt([100, 200]) + tracker.observe( + system('task_started', { task_id: 'live', task_type: 'local_agent', is_backgrounded: true }) + ) + tracker.observe( + system('task_started', { task_id: 'failed', task_type: 'local_agent', is_backgrounded: true }) + ) + tracker.observe(system('task_notification', { task_id: 'failed', status: 'failed' })) + expect(tracker.state?.settledTasks).toEqual([ + { id: 'failed', kind: 'agent', state: 'blocked', startedAt: 200 } + ]) + }) + + it('leaves a task open when a patch cannot be read', () => { + const tracker = trackerAt([100]) + tracker.observe( + system('task_started', { task_id: 'task-1', task_type: 'local_agent', is_backgrounded: true }) + ) + expect(tracker.observe(system('task_updated', { task_id: 'task-1', patch: 'garbage' }))).toBe( + false + ) + expect(tracker.state?.tasks).toHaveLength(1) + expect(tracker.state?.settledTasks).toBeUndefined() + }) + it('replaces its roster from aggregate lifecycle frames and preserves stoppable provider ids', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) expect( tracker.observe( aggregate([ @@ -117,8 +318,8 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(tracker.state).toEqual({ state: 'monitoring', tasks: [ - { id: 'task-agent', kind: 'agent', description: 'agent' }, - { id: 'task-bash', kind: 'command', description: 'bash' } + { id: 'task-agent', kind: 'agent', description: 'agent', state: 'working', startedAt: 100 }, + { id: 'task-bash', kind: 'command', description: 'bash', state: 'working', startedAt: 100 } ] }) @@ -128,18 +329,31 @@ describe('ClaudeBackgroundTaskTracker', () => { ) ).toBe(true) expect(tracker.stoppableTaskIds).toEqual(['task-next']) - expect(tracker.state).toEqual({ - state: 'monitoring', - tasks: [{ id: 'task-next', kind: 'workflow', description: 'workflow' }] - }) expect(tracker.observe(aggregate([]))).toBe(true) expect(tracker.stoppableTaskIds).toEqual([]) expect(tracker.state).toBeNull() }) + it('preserves first-seen timestamps across aggregate roster replacement', () => { + const tracker = trackerAt([100, 200]) + tracker.observe( + system('task_started', { task_id: 'task-1', task_type: 'local_agent', is_backgrounded: true }) + ) + tracker.observe( + aggregate([ + { task_id: 'task-1', task_type: 'local_agent' }, + { task_id: 'task-2', task_type: 'local_bash' } + ]) + ) + expect(tracker.state?.tasks).toEqual([ + { id: 'task-1', kind: 'agent', state: 'working', startedAt: 100 }, + { id: 'task-2', kind: 'command', state: 'working', startedAt: 200 } + ]) + }) + it('excludes ambient aggregate tasks', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe( aggregate([ { task_id: 'ambient', task_type: 'monitor', description: 'watcher', ambient: true }, @@ -151,7 +365,7 @@ describe('ClaudeBackgroundTaskTracker', () => { }) it('does not let late edge frames revive tasks cleared by an aggregate roster', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe( aggregate([{ task_id: 'task-late', task_type: 'local_agent', description: 'agent' }]) ) @@ -173,7 +387,7 @@ describe('ClaudeBackgroundTaskTracker', () => { }) it('lets an authoritative aggregate roster replace earlier terminal-edge evidence', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe(system('task_notification', { task_id: 'task-live', status: 'completed' })) tracker.observe( @@ -183,12 +397,28 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(tracker.stoppableTaskIds).toEqual(['task-live']) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: 'task-live', kind: 'agent', description: 'agent' }] + tasks: [ + { id: 'task-live', kind: 'agent', description: 'agent', state: 'working', startedAt: 100 } + ] }) }) + it('retracts a settled copy when an authoritative roster reports the task live again', () => { + const tracker = trackerAt([100, 200, 300]) + const tasks = [ + { task_id: 'agent', task_type: 'local_agent', description: 'Review sample' }, + { task_id: 'shell', task_type: 'local_bash' } + ] + tracker.observe(aggregate(tasks)) + tracker.observe(system('task_notification', { task_id: 'agent', status: 'completed' })) + expect(tracker.state?.settledTasks).toHaveLength(1) + tracker.observe(aggregate(tasks)) + expect(tracker.state?.tasks?.map((task) => task.id)).toEqual(['agent', 'shell']) + expect(tracker.state?.settledTasks).toBeUndefined() + }) + it('keeps terminal edges authoritative on either side of aggregate replacement', () => { - const terminalFirst = new ClaudeBackgroundTaskTracker() + const terminalFirst = trackerAt([100]) terminalFirst.observe( system('task_notification', { task_id: 'task-first', status: 'completed' }) ) @@ -202,7 +432,7 @@ describe('ClaudeBackgroundTaskTracker', () => { ) expect(terminalFirst.state).toBeNull() - const terminalLast = new ClaudeBackgroundTaskTracker() + const terminalLast = trackerAt([100]) terminalLast.observe( aggregate([{ task_id: 'task-last', task_type: 'local_agent', description: 'agent' }]) ) @@ -218,7 +448,7 @@ describe('ClaudeBackgroundTaskTracker', () => { }) it('keeps terminal evidence authoritative across duplicates and out-of-order starts', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) const terminal = system('task_notification', { task_id: 'task-late', status: 'completed' }) tracker.observe(terminal) tracker.observe(terminal) @@ -239,7 +469,7 @@ describe('ClaudeBackgroundTaskTracker', () => { ) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: 'task-live', kind: 'monitor' }] + tasks: [{ id: 'task-live', kind: 'monitor', state: 'monitoring', startedAt: 100 }] }) expect( tracker.observe(system('task_updated', { task_id: 'task-live', patch: { status: 'killed' } })) @@ -249,17 +479,24 @@ describe('ClaudeBackgroundTaskTracker', () => { it('recognizes task types that are registered only as background work', () => { for (const taskType of ['local_workflow', 'monitor']) { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe(system('task_started', { task_id: taskType, task_type: taskType })) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: taskType, kind: taskType === 'local_workflow' ? 'workflow' : 'monitor' }] + tasks: [ + { + id: taskType, + kind: taskType === 'local_workflow' ? 'workflow' : 'monitor', + state: taskType === 'local_workflow' ? 'working' : 'monitoring', + startedAt: 100 + } + ] }) } }) it('admits unknown background updates conservatively and bounds edge-only fallback ids', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe( system('task_updated', { task_id: 'unknown', patch: { is_backgrounded: true } }) ) @@ -278,7 +515,7 @@ describe('ClaudeBackgroundTaskTracker', () => { }) it('bounds aggregate rosters and resets to the edge-only fallback on clear', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe( aggregate( Array.from({ length: 400 }, (_, index) => ({ @@ -301,23 +538,30 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(tracker.stoppableTaskIds).toEqual(['edge-after-reset']) }) - it('gates aggregate monitoring behind foreground turn completion', () => { - const tracker = new ClaudeBackgroundTaskTracker() + it('publishes an aggregate roster observed mid-turn', () => { + const tracker = trackerAt([100]) tracker.observe({ type: 'user' }, true) - tracker.observe( - aggregate([{ task_id: 'task-live', task_type: 'local_bash', description: 'command' }]) - ) - expect(tracker.state).toBeNull() - - expect(tracker.observe(result())).toBe(true) + expect( + tracker.observe( + aggregate([{ task_id: 'task-live', task_type: 'local_bash', description: 'command' }]) + ) + ).toBe(true) expect(tracker.state).toEqual({ state: 'monitoring', - tasks: [{ id: 'task-live', kind: 'command', description: 'command' }] + tasks: [ + { + id: 'task-live', + kind: 'command', + description: 'command', + state: 'working', + startedAt: 100 + } + ] }) }) it('ignores ambient SDK tasks and clears all liveness when the session ends', () => { - const tracker = new ClaudeBackgroundTaskTracker() + const tracker = trackerAt([100]) tracker.observe( system('task_started', { task_id: 'ambient', diff --git a/src/main/claude/claude-background-task-tracker.ts b/src/main/claude/claude-background-task-tracker.ts index de24b9a4fba..68c7f4f1185 100644 --- a/src/main/claude/claude-background-task-tracker.ts +++ b/src/main/claude/claude-background-task-tracker.ts @@ -1,78 +1,54 @@ import type { AgentSessionBackgroundTask, + AgentSessionBackgroundTaskRunState, AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import { + classifyClaudeBackgroundTaskKind, + liveClaudeTaskRunState, + record, + taskDescription, + taskId, + taskName, + taskUsageTotalTokens, + terminalClaudeTaskRunState +} from './claude-background-task-frames' +import { + ClaudeSettledBackgroundTasks, + claudeBackgroundTaskDetail, + type TrackedClaudeBackgroundTask +} from './claude-settled-background-tasks' + +// `claude-subagent-*` reads this channel through these names; the readers themselves +// live in the frames module so both consumers share one definition. +export { + classifyClaudeBackgroundTaskKind, + isBoundedClaudeTaskId, + taskDescription as claudeTaskDescription, + taskId as claudeTaskId +} from './claude-background-task-frames' +export type { ClaudeBackgroundTaskKind } from './claude-background-task-frames' const MAX_TRACKED_TASKS = 256 -const MAX_TASK_ID_LENGTH = 512 -const MAX_TASK_DESCRIPTION_LENGTH = 512 -const TERMINAL_TASK_STATES = new Set(['completed', 'failed', 'killed', 'stopped']) - -export type ClaudeBackgroundTaskKind = AgentSessionBackgroundTask['kind'] - -type TrackedTask = { - backgrounded: boolean - kind: ClaudeBackgroundTaskKind - description?: string -} - -function record(value: unknown): Record | null { - return typeof value === 'object' && value !== null ? (value as Record) : null -} - -/** The bound every task id shares, wherever it enters. An id the roster stores - * becomes a durable entry key, so a provisional one takes the same bound the - * announced path applies — an over-long id is rejected, never truncated. */ -export function isBoundedClaudeTaskId(value: string): boolean { - return value.length > 0 && value.length <= MAX_TASK_ID_LENGTH -} - -/** The task's canonical, resume-stable id. Shared with the subagent roster so - * both readers of this channel agree on what identifies a task. */ -export function claudeTaskId(message: Record): string | null { - const value = message.task_id - return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : null -} - -/** A task's human label, collapsed and bounded. */ -export function claudeTaskDescription(value: unknown): string | undefined { - if (typeof value !== 'string') { - return undefined - } - const trimmed = value.trim().replace(/\s+/g, ' ') - return trimmed.length > 0 ? trimmed.slice(0, MAX_TASK_DESCRIPTION_LENGTH) : undefined -} - -export function classifyClaudeBackgroundTaskKind(taskType: unknown): ClaudeBackgroundTaskKind { - switch (taskType) { - case 'local_agent': - return 'agent' - case 'local_workflow': - return 'workflow' - case 'local_bash': - return 'command' - case 'monitor': - return 'monitor' - default: - return 'unknown' - } -} export class ClaudeBackgroundTaskTracker { - private readonly tasks = new Map() + private readonly tasks = new Map() + private readonly retention = new ClaudeSettledBackgroundTasks() private readonly terminalTaskIds = new Set() private aggregateRosterObserved = false - private foregroundTurnActive = false private monitoring = false private publishedTasksFingerprint = '' + constructor(private readonly now: () => number = () => Date.now()) {} + get state(): AgentSessionBackgroundTaskState | null { if (!this.monitoring) { return null } return { state: 'monitoring', - tasks: this.backgroundTaskDetails() + tasks: this.backgroundTaskDetails(), + ...(this.retention.hasSettled ? { settledTasks: this.retention.settledDetails() } : {}) } } @@ -87,16 +63,14 @@ export class ClaudeBackgroundTaskTracker { } observe(message: Record, startsTurn = false): boolean { - if (startsTurn) { - this.foregroundTurnActive = true - } - if (message.type === 'result') { - this.foregroundTurnActive = false - } else if (message.type === 'system') { + // Background work publishes through a foreground turn: the strip stays + // honest mid-fan-out and the client alone decides when the idle-only + // monitoring label may speak. + if (message.type === 'system') { if (!this.observeSystemFrame(message) && !startsTurn) { return false } - } else if (!startsTurn) { + } else if (!startsTurn && message.type !== 'result') { return false } return this.refreshMonitoring() @@ -104,47 +78,51 @@ export class ClaudeBackgroundTaskTracker { clear(): boolean { this.tasks.clear() + this.retention.clear() this.terminalTaskIds.clear() this.aggregateRosterObserved = false - this.foregroundTurnActive = false return this.refreshMonitoring() } + private settle( + id: string, + state: AgentSessionBackgroundTaskRunState, + outcome: { totalTokens?: number } = {} + ): void { + this.retention.settle(id, state, outcome, this.tasks.get(id)) + this.finish(id) + } + private observeSystemFrame(message: Record): boolean { if (message.subtype === 'background_tasks_changed') { this.replaceAggregateRoster(message.tasks) return true } - const id = claudeTaskId(message) + const id = taskId(message) if (!id) { return false } if (message.subtype === 'task_notification') { - this.finish(id) + // The notification is affirmative terminal evidence even when its status + // field is unreadable — matching the liveness semantics this edge always had. + this.settle(id, terminalClaudeTaskRunState(message.status) ?? 'done', { + totalTokens: taskUsageTotalTokens(message) + }) + return true + } + if (message.subtype === 'task_progress') { + // Progress `description` is the current activity ("Running "), not + // the task's name — only usage (and a missing identity) may update. + const existing = this.tasks.get(id) + const totalTokens = taskUsageTotalTokens(message) + if (!existing?.backgrounded || totalTokens === undefined) { + return false + } + this.tasks.set(id, { ...existing, totalTokens, name: existing.name ?? taskName(message) }) return true } if (message.subtype === 'task_updated') { - const patch = record(message.patch) - if (!patch) { - return false - } - if (TERMINAL_TASK_STATES.has(String(patch.status))) { - this.finish(id) - return true - } - const existing = this.tasks.get(id) - if ( - (patch.is_backgrounded === true || claudeTaskDescription(patch.description)) && - (!this.aggregateRosterObserved || existing) - ) { - this.upsert(id, { - backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true, - kind: existing?.kind ?? 'unknown', - description: claudeTaskDescription(patch.description) ?? existing?.description - }) - return true - } - return false + return this.observeTaskUpdated(id, message) } if (message.subtype !== 'task_started' || this.terminalTaskIds.has(id)) { return false @@ -160,15 +138,55 @@ export class ClaudeBackgroundTaskTracker { this.upsert(id, { backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor', kind, - description: claudeTaskDescription(message.description) + description: taskDescription(message.description), + name: taskName(message), + state: liveClaudeTaskRunState(message.status) ?? undefined, + startedAt: this.now() }) return true } + private observeTaskUpdated(id: string, message: Record): boolean { + const patch = record(message.patch) + if (!patch) { + return false + } + const settledState = terminalClaudeTaskRunState(patch.status) + if (settledState) { + this.settle(id, settledState) + return true + } + const existing = this.tasks.get(id) + // Classification is re-derived per transition: a later frame that reveals a + // real type moves the task between buckets instead of pinning first-seen. + const patchKind = + 'task_type' in patch ? classifyClaudeBackgroundTaskKind(patch.task_type) : undefined + const liveState = liveClaudeTaskRunState(patch.status) + const hasContent = + patch.is_backgrounded === true || + taskDescription(patch.description) !== undefined || + taskName(patch) !== undefined || + liveState !== null || + (patchKind !== undefined && patchKind !== 'unknown') + if (hasContent && (!this.aggregateRosterObserved || existing)) { + this.upsert(id, { + backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true, + kind: patchKind ?? existing?.kind ?? 'unknown', + description: taskDescription(patch.description), + name: taskName(patch), + state: liveState ?? undefined, + startedAt: this.now() + }) + return true + } + return false + } + private replaceAggregateRoster(value: unknown): void { if (!Array.isArray(value)) { return } + const prior = new Map(this.tasks) this.aggregateRosterObserved = true this.tasks.clear() this.terminalTaskIds.clear() @@ -180,29 +198,33 @@ export class ClaudeBackgroundTaskTracker { if (!task || task.ambient === true) { continue } - const id = claudeTaskId(task) + const id = taskId(task) if (!id) { continue } + // An authoritative live roster supersedes an earlier terminal edge. + const retained = this.retention.resume(id) + const existing = prior.get(id) ?? retained + const kind = classifyClaudeBackgroundTaskKind(task.task_type) this.tasks.set(id, { backgrounded: true, - kind: classifyClaudeBackgroundTaskKind(task.task_type), - description: claudeTaskDescription(task.description) + kind: kind !== 'unknown' ? kind : (existing?.kind ?? 'unknown'), + description: taskDescription(task.description) ?? existing?.description, + name: taskName(task) ?? existing?.name, + state: liveClaudeTaskRunState(task.status) ?? existing?.state, + startedAt: existing?.startedAt ?? this.now(), + totalTokens: existing?.totalTokens }) } + for (const [id, task] of prior) { + if (task.backgrounded && !this.tasks.has(id)) { + this.retention.rememberRemoved(id, task) + } + } } - private upsert(id: string, task: TrackedTask): void { - const existing = this.tasks.get(id) - if (existing) { - this.tasks.set(id, { - backgrounded: existing.backgrounded || task.backgrounded, - kind: existing.kind === 'unknown' ? task.kind : existing.kind, - description: task.description ?? existing.description - }) - return - } - if (this.tasks.size >= MAX_TRACKED_TASKS) { + private upsert(id: string, task: TrackedClaudeBackgroundTask): void { + if (!this.tasks.has(id) && this.tasks.size >= MAX_TRACKED_TASKS) { let foregroundId: string | undefined for (const [candidateId, candidate] of this.tasks) { if (!candidate.backgrounded) { @@ -215,6 +237,20 @@ export class ClaudeBackgroundTaskTracker { } this.tasks.delete(foregroundId) } + const existing = this.tasks.get(id) ?? this.retention.resume(id) + this.terminalTaskIds.delete(id) + if (existing) { + this.tasks.set(id, { + backgrounded: existing.backgrounded || task.backgrounded, + kind: task.kind !== 'unknown' ? task.kind : existing.kind, + description: task.description ?? existing.description, + name: task.name ?? existing.name, + state: task.state ?? existing.state, + startedAt: existing.startedAt, + totalTokens: existing.totalTokens + }) + return + } this.tasks.set(id, task) } @@ -231,9 +267,12 @@ export class ClaudeBackgroundTaskTracker { } private refreshMonitoring(): boolean { - const details = this.foregroundTurnActive ? [] : this.backgroundTaskDetails() + const details = this.backgroundTaskDetails() + if (details.length === 0 && this.retention.hasSettled) { + this.retention.flushSettled() + } const next = details.length > 0 - const fingerprint = next ? JSON.stringify(details) : '' + const fingerprint = next ? JSON.stringify([details, this.retention.settledDetails()]) : '' if (next === this.monitoring && fingerprint === this.publishedTasksFingerprint) { return false } @@ -248,11 +287,7 @@ export class ClaudeBackgroundTaskTracker { if (!task.backgrounded) { continue } - details.push({ - id, - kind: task.kind, - ...(task.description ? { description: task.description } : {}) - }) + details.push(claudeBackgroundTaskDetail(id, task)) } return details } diff --git a/src/main/claude/claude-settled-background-tasks.ts b/src/main/claude/claude-settled-background-tasks.ts new file mode 100644 index 00000000000..91e975d07bb --- /dev/null +++ b/src/main/claude/claude-settled-background-tasks.ts @@ -0,0 +1,127 @@ +// Retention state for background tasks that have reached a terminal edge. +// +// The real producer settles a task in two steps inside one tick: +// `background_tasks_changed` arrives FIRST with the task already absent, then +// `task_updated` / `task_notification` carry the outcome. So the terminal edge +// must be able to settle a task the live roster no longer holds — that is what +// `rememberRemoved` preserves. A removal whose outcome frame never arrives +// simply vanishes: removed tasks are never rendered and never guessed into a +// finished state. + +import type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskRunState +} from '../../shared/agent-session-wire' + +const MAX_RETAINED_TASKS = 256 + +export type TrackedClaudeBackgroundTask = { + backgrounded: boolean + kind: AgentSessionBackgroundTask['kind'] + description?: string + name?: string + state?: AgentSessionBackgroundTaskRunState + /** First-observed epoch ms; preserved across updates and roster replacement + * so clients can render elapsed and keep a stable first-seen sort. */ + startedAt: number + totalTokens?: number +} + +export function claudeBackgroundTaskDetail( + id: string, + task: TrackedClaudeBackgroundTask +): AgentSessionBackgroundTask { + return { + id, + kind: task.kind, + ...(task.description ? { description: task.description } : {}), + ...(task.name ? { name: task.name } : {}), + state: task.state ?? (task.kind === 'monitor' ? 'monitoring' : 'working'), + startedAt: task.startedAt, + ...(task.totalTokens !== undefined ? { totalTokens: task.totalTokens } : {}) + } +} + +function setBounded(map: Map, key: K, value: V): void { + map.delete(key) + map.set(key, value) + if (map.size > MAX_RETAINED_TASKS) { + const oldest = map.keys().next() + if (!oldest.done) { + map.delete(oldest.value) + } + } +} + +export class ClaudeSettledBackgroundTasks { + private readonly settled = new Map() + private readonly recentlyRemoved = new Map() + + /** An aggregate roster evicted a still-live backgrounded task; hold its + * details so the outcome frame trailing in the same tick can settle it. */ + rememberRemoved(id: string, task: TrackedClaudeBackgroundTask): void { + setBounded(this.recentlyRemoved, id, task) + } + + /** Terminal edge for `id`. `liveSource` is the live roster's entry when it + * still has one; otherwise the recently-removed copy is consumed. A second + * edge (updated, then notification) re-derives the settled state and can + * add the final usage the first edge lacked. */ + settle( + id: string, + state: AgentSessionBackgroundTaskRunState, + outcome: { totalTokens?: number }, + liveSource: TrackedClaudeBackgroundTask | undefined + ): void { + const source = liveSource ?? this.recentlyRemoved.get(id) + const already = this.settled.get(id) + if (source?.backgrounded) { + setBounded(this.settled, id, { + ...claudeBackgroundTaskDetail(id, { + ...source, + totalTokens: outcome.totalTokens ?? source.totalTokens + }), + state + }) + } else if (already) { + this.settled.set(id, { + ...already, + state, + ...(outcome.totalTokens !== undefined ? { totalTokens: outcome.totalTokens } : {}) + }) + } + this.recentlyRemoved.delete(id) + } + + /** Positive live evidence transfers identity back to the tracker, never the old outcome. */ + resume(id: string): TrackedClaudeBackgroundTask | undefined { + const settled = this.settled.get(id) + const removed = this.recentlyRemoved.get(id) + this.settled.delete(id) + this.recentlyRemoved.delete(id) + const source = settled ?? removed + if (!source || source.startedAt === undefined) { + return undefined + } + return { ...source, backgrounded: true, state: undefined, startedAt: source.startedAt } + } + + get hasSettled(): boolean { + return this.settled.size > 0 + } + + settledDetails(): AgentSessionBackgroundTask[] { + return [...this.settled.values()] + } + + /** Settled context only makes sense beside live work; the strip exits at the + * same instant it always has — when the last live task ends. */ + flushSettled(): void { + this.settled.clear() + } + + clear(): void { + this.settled.clear() + this.recentlyRemoved.clear() + } +} diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 670c0daaf8c..0de5049a71c 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -55,7 +55,9 @@ describe('Claude published session close lifecycle', () => { expect(backgroundStates).toEqual([ { state: 'monitoring', - tasks: [{ id: 'background-1', kind: 'agent' }], + tasks: [ + { id: 'background-1', kind: 'agent', state: 'working', startedAt: expect.any(Number) } + ], supportsTaskStop: true } ]) @@ -74,7 +76,9 @@ describe('Claude published session close lifecycle', () => { expect(backgroundStates).toEqual([ { state: 'monitoring', - tasks: [{ id: 'background-1', kind: 'agent' }], + tasks: [ + { id: 'background-1', kind: 'agent', state: 'working', startedAt: expect.any(Number) } + ], supportsTaskStop: true }, null diff --git a/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts index ba2eba58062..c8860f227cc 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts @@ -50,7 +50,9 @@ describe('provider frame activity', () => { expect(claudeProviderFrameActivity(kind, payload)).toBeNull() } // `requesting` holds for nearly the whole turn and says no more than the fallback. - expect(claudeProviderFrameActivity('message:system:status', { status: 'requesting' })).toBeNull() + expect( + claudeProviderFrameActivity('message:system:status', { status: 'requesting' }) + ).toBeNull() expect(claudeProviderFrameActivity('message:system:status', { status: 'compacting' })).toBe( 'Compacting the conversation' ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index 4592dea26e9..6dd9db80e1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -21,7 +21,10 @@ export class StructuredAgentSessionBackgroundTaskChannel { private readonly requireSession: (sessionId: string) => StructuredAgentSessionHostSession, private readonly handoffStatus: ( sessionId: string - ) => Parameters[0]['handoff'] + ) => Parameters[0]['handoff'], + /** Task edges change the status summary too; the feed's equality check + * keeps a no-op re-projection from reaching subscribers. */ + private readonly onPublished: (sessionId: string) => void ) {} history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult { @@ -53,6 +56,7 @@ export class StructuredAgentSessionBackgroundTaskChannel { const state = publishedState !== undefined ? publishedState : this.state(sessionId) if (session && state !== undefined) { this.subscribers.backgroundTasks(sessionId, state, session.fence) + this.onPublished(sessionId) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 2df2fdf5412..358ca952b57 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -53,7 +53,8 @@ import type { StructuredAgentSessionHostSession, StructuredAgentSessionReveal } from './structured-agent-session-host-types' -import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import { createStructuredAgentSessionHostStatusFeed } from './structured-agent-session-status-feed' +import type { StructuredAgentSessionStatusSubscriber } from './structured-agent-session-status-feed' import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -64,11 +65,10 @@ export class StructuredAgentSessionHost { this ) private readonly sessions = new Map() - private readonly statusFeed = new StructuredAgentSessionStatusFeed({ + private readonly statusFeed = createStructuredAgentSessionHostStatusFeed({ sessions: this.sessions, - getRecord: (sessionId) => this.deps.store.getRecord(sessionId), now: () => this.now(), - onStatusChanged: (summary, options) => this.deps.onSessionStatusChanged?.(summary, options) + deps: () => this.deps }) private readonly subscribers = new AgentSessionSubscribers({ readCommands: (sessionId) => this.deps.adapter.readCommands?.(sessionId), @@ -91,7 +91,8 @@ export class StructuredAgentSessionHost { this.sessions, this.subscribers, (sessionId) => this.requireSession(sessionId), - (sessionId) => this.handoffs.status(sessionId) + (sessionId) => this.handoffs.status(sessionId), + (sessionId) => this.statusFeed.publish(sessionId) ) this.runtimeState = new StructuredAgentSessionHostRuntimeState( deps, @@ -345,7 +346,7 @@ export class StructuredAgentSessionHost { unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id) /** Every session's projected status for session lists; unlike `subscribe`, retains nothing. */ - subscribeStatus: StructuredAgentSessionStatusFeed['subscribe'] = (subscriber) => + subscribeStatus = (subscriber: StructuredAgentSessionStatusSubscriber): (() => void) => this.statusFeed.subscribe(subscriber) private requireSession(sessionId: string): StructuredAgentSessionHostSession { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 7bd53742430..5fbd58016d0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -1,9 +1,12 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionBackgroundTask, + AgentSessionStatusEvent +} from '../../../shared/agent-session-wire' import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' import { publishCodexTurnLifecycle } from '../../codex/codex-structured-journal-translation-turns' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -74,11 +77,13 @@ function feedFor( { journal: Awaited>; hasProviderChild?: boolean; fence?: number } >, record: Partial | null = null, - onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] + onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'], + readBackgroundTasks?: StructuredAgentSessionStatusFeedDeps['readBackgroundTasks'] ) { let now = 1_000 const feed = new StructuredAgentSessionStatusFeed({ ...(onStatusChanged ? { onStatusChanged } : {}), + ...(readBackgroundTasks ? { readBackgroundTasks } : {}), sessions: { get: (sessionId: string) => { const session = sessions.get(sessionId) @@ -621,6 +626,147 @@ describe('StructuredAgentSessionStatusFeed', () => { session: expect.objectContaining({ status: 'idle', latestPrompt: 'hello' }) }) }) + + it('reuses the journal projection across task progress and invalidates on journal changes', async () => { + const journal = await openJournal() + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, + { fence: 1 } + ) + await journal.appendItem( + TURN_IDENTITY, + { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, + { fence: 1 } + ) + const snapshot = vi.spyOn(journal, 'snapshot') + let taskState: 'working' | 'waiting' = 'working' + const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({ + state: 'monitoring', + tasks: [{ id: 'child', kind: 'agent', state: taskState }] + })) + for (let tick = 1; tick <= 100; tick++) { + taskState = tick % 2 === 1 ? 'waiting' : 'working' + feed.publish(SESSION) + } + expect(events).toHaveLength(101) + expect(snapshot).toHaveBeenCalledTimes(1) + expect(events.at(-1)).toMatchObject({ + type: 'status', + session: { status: 'working', backgroundTasks: [{ state: 'working' }] } + }) + await journal.appendTombstone(TURN_IDENTITY, { fence: 1 }) + feed.publish(SESSION) + expect(snapshot).toHaveBeenCalledTimes(2) + expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } }) + }) + + it('invalidates cached status on unreadability and keeps record metadata live', async () => { + const journal = await openJournal() + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + const record = { options: { model: 'first-model' }, providerHandleChain: [] } + const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), record) + record.options.model = 'second-model' + feed.publish(SESSION) + expect(events.at(-1)).toMatchObject({ + type: 'status', + session: { status: 'idle', model: 'second-model' } + }) + const readOnly = vi.spyOn(journal, 'isReadOnly', 'get').mockReturnValue(true) + feed.publish(SESSION) + expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: null } }) + readOnly.mockRestore() + feed.publish(SESSION) + expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } }) + }) + + it('projects live background tasks and republishes a task-only state change', async () => { + const journal = await openJournal() + let tasks = [ + { id: 'task-1', kind: 'agent' as const, name: 'deep_review', state: 'working' as const } + ] + const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({ + state: 'monitoring', + tasks + })) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, + { fence: 1 } + ) + feed.publish(SESSION, journal) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }] + }) + }) + + // No journal change: only the task state moved. + tasks = [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'waiting' as never }] + const before = events.length + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 1) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [expect.objectContaining({ state: 'waiting' })] + }) + }) + + // An identical projection is suppressed. + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 1) + }) + + it('omits task usage so a progress tick never re-broadcasts the summary', async () => { + const journal = await openJournal() + let tasks: AgentSessionBackgroundTask[] = [ + { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', totalTokens: 10 } + ] + const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({ + state: 'monitoring', + tasks + })) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, + { fence: 1 } + ) + feed.publish(SESSION, journal) + const before = events.length + + // A `task_progress` frame moves only usage, which no status-summary reader renders; + // re-broadcasting the whole summary per frame would cost every remote subscriber. + tasks = [ + { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', totalTokens: 4_200 } + ] + feed.publish(SESSION, journal) + expect(events).toHaveLength(before) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }] + }) + }) + + // A state change on the same task still reaches subscribers. + tasks = [ + { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'waiting', totalTokens: 4_200 } + ] + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 1) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [expect.objectContaining({ state: 'waiting' })] + }) + }) + }) }) /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 3c4ce4b97a6..bddb9ae93ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -14,9 +14,11 @@ import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types' -import type { - AgentSessionStatusEvent, - AgentSessionStatusSummary +import { + agentSessionBackgroundTasksEqual, + type AgentSessionBackgroundTaskState, + type AgentSessionStatusEvent, + type AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -41,6 +43,9 @@ export type StructuredAgentSessionStatusFeedDeps = { /** Every projection change, whether or not anyone is subscribed. `replay` marks a re-projection * of state the host already knew (restore, an arriving subscriber) rather than a journal edge. */ onStatusChanged?: (summary: AgentSessionStatusSummary, options: { replay: boolean }) => void + /** Live provider-owned background tasks for the summary, so session lists can + * render subagent children. Optional: a provider without the hook projects none. */ + readBackgroundTasks?: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined } function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean { @@ -57,13 +62,50 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma a.toolName === b.toolName && a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && + agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) ) } +/** Wire the host's own deps into a feed; keeps the host at one call site. + * `deps` is a thunk because the host builds the feed in a field initializer, + * before its constructor parameters are assigned. */ +export function createStructuredAgentSessionHostStatusFeed(args: { + sessions: StructuredAgentSessionStatusFeedDeps['sessions'] + now: () => number + deps: () => { + store: { getRecord: (sessionId: string) => AgentSessionRecord | null } + adapter: { + backgroundTaskState?: ( + sessionId: string + ) => AgentSessionBackgroundTaskState | null | undefined + } + onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] + } +}): StructuredAgentSessionStatusFeed { + return new StructuredAgentSessionStatusFeed({ + sessions: args.sessions, + getRecord: (sessionId) => args.deps().store.getRecord(sessionId), + now: args.now, + onStatusChanged: (summary, options) => args.deps().onSessionStatusChanged?.(summary, options), + readBackgroundTasks: (sessionId) => args.deps().adapter.backgroundTaskState?.(sessionId) + }) +} + export class StructuredAgentSessionStatusFeed { private readonly subscribers = new Map() private readonly published = new Map() + // Task progress must not sort and scan an unchanged conversation. Journal identity owns cleanup. + private readonly journalProjections = new WeakMap< + AgentSessionJournal, + { + epoch: string + sequence: number + readOnly: boolean + fence: number | undefined + summary: ReturnType + } + >() constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} @@ -154,24 +196,54 @@ export class StructuredAgentSessionStatusFeed { journal: AgentSessionJournal ): AgentSessionStatusSummary { // An unreadable journal projects as "no turn": the chat itself shows the reset. - const snapshot = journal.isReadOnly ? null : journal.snapshot() - const items = snapshot?.items ?? [] - const submissions = snapshot?.submissions ?? [] + const cursor = journal.cursor() + const readOnly = journal.isReadOnly + const fence = session.fence + let projection = this.journalProjections.get(journal) + if ( + !projection || + projection.epoch !== cursor.epoch || + projection.sequence !== cursor.sequence || + projection.readOnly !== readOnly || + projection.fence !== fence + ) { + // A journalled submission bumps `lastSequence`, so the send-time working + // signal reaches the cache; the lease fence does not, hence the extra key. + const snapshot = readOnly ? null : journal.snapshot() + projection = { + ...cursor, + readOnly, + fence, + summary: projectStructuredAgentSessionStatusSummary( + snapshot?.items ?? [], + snapshot?.submissions ?? [], + fence + ) + } + this.journalProjections.set(journal, projection) + } const record = this.deps.getRecord(sessionId) const providerSession = structuredAgentSessionProviderSessionMetadata(record) // The journal has no model: the record's acknowledged options are where an owner // handoff or a mid-session switch lands, so the row follows whichever is in force. const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH) + // Usage is dropped here on purpose: a `task_progress` tick would otherwise fail the + // equality check and re-broadcast a full summary to every remote subscriber for a + // number no session list renders. Tokens stay live on the background-task channel. + const backgroundTasks = this.deps + .readBackgroundTasks?.(sessionId) + ?.tasks?.map(({ totalTokens: _totalTokens, ...task }) => task) return { sessionId, workspaceId: session.params.location.workspaceId, agent: session.params.provider, ...(session.hasProviderChild ? { hostExecutionOwned: true as const } : {}), - ...projectStructuredAgentSessionStatusSummary(items, submissions, session.fence), + ...projection.summary, ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), ...(model ? { model } : {}), + ...(backgroundTasks && backgroundTasks.length > 0 ? { backgroundTasks } : {}), ...(providerSession ? { providerSession } : {}), updatedAt: journal.lastActivityAt() || this.deps.now() } diff --git a/src/main/observability/local-file-sink-memory.test.ts b/src/main/observability/local-file-sink-memory.test.ts index fef5b8d00dd..2c6644694b9 100644 --- a/src/main/observability/local-file-sink-memory.test.ts +++ b/src/main/observability/local-file-sink-memory.test.ts @@ -2,11 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createLocalFileSink, - DROPPED_RECORD_TYPE, - type LocalFileSink -} from './local-file-sink' +import { createLocalFileSink, DROPPED_RECORD_TYPE, type LocalFileSink } from './local-file-sink' function parseLine(raw: string): Record { return JSON.parse(raw) as Record diff --git a/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts index 58e7de1b674..5779c65edda 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts @@ -53,6 +53,8 @@ function journalWith(prompt: string): AgentSessionJournal { return { isReadOnly: false, lastActivityAt: () => OBSERVED_AT, + // This journal never changes, so a real one would hold its cursor steady. + cursor: () => ({ epoch: 1, sequence: 1 }), snapshot: () => ({ items: runningTurn(prompt) }) } as unknown as AgentSessionJournal } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index a702bda5afc..f9f60ded887 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -97,6 +97,7 @@ function statusFeed(): StructuredAgentSessionStatusFeed { { journal: { isReadOnly: false, + cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), lastActivityAt: () => 2, snapshot: () => ({ items: STATUS_ITEMS }) } as unknown as AgentSessionJournal, diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx index e137220dd66..96be91820a3 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx @@ -2,12 +2,16 @@ import '@testing-library/jest-dom/vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { Profiler } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) const TASKS: AgentSessionBackgroundTask[] = [ { id: 'codex-agent:child-1', kind: 'agent', description: 'count_a' }, @@ -20,7 +24,10 @@ function renderStrip(props: { supportsTaskStop: boolean; supportsStopAll: boolea const onStop = vi.fn() render( { expect(screen.getByText('sleep 90')).toBeInTheDocument() }) }) + +describe('background-tasks strip header', () => { + function renderHeader(tasks: AgentSessionBackgroundTask[]): HTMLElement { + render( + {}} + /> + ) + return screen.getByRole('button', { expanded: false }) + } + + it('leads each kind segment with that kind icon and keeps the counts in the accessible name', () => { + const header = renderHeader([ + { id: 'a1', kind: 'agent' }, + { id: 'a2', kind: 'agent' }, + { id: 'a3', kind: 'agent' }, + { id: 'm1', kind: 'monitor' } + ]) + expect(header).toHaveAttribute('aria-label', '3 agents · 1 monitor') + expect(header.querySelector('.lucide-bot')).toBeInTheDocument() + // Heartbeat, the same glyph the agent sidebar shows for monitoring. + expect(header.querySelector('.lucide-activity')).toBeInTheDocument() + // Two kind icons and the chevron: the aggregate state dot is gone. + expect(header.querySelectorAll('svg')).toHaveLength(3) + for (const icon of header.querySelectorAll('svg')) { + expect(icon).toHaveAttribute('aria-hidden', 'true') + } + }) + + it('gives the monitor heartbeat the sidebar amber and leaves other kinds neutral', () => { + const header = renderHeader([ + { id: 'a1', kind: 'agent' }, + { id: 'm1', kind: 'monitor' } + ]) + // Same glyph AND same colour as AgentStateDot/StatusIndicator, or a monitor + // here does not read as the monitor there. + expect(header.querySelector('.lucide-activity')?.classList).toContain('text-yellow-500') + expect(header.querySelector('.lucide-bot')?.classList).toContain('text-muted-foreground') + expect(header.querySelector('.lucide-bot')?.classList).not.toContain('text-yellow-500') + }) + + it('dims the monitor amber while a turn owns the voice', () => { + render( + {}} + /> + ) + const header = screen.getByRole('button', { expanded: false }) + expect(header.querySelector('.lucide-activity')?.classList).toContain('text-yellow-500/40') + }) + + it('carries the monitor amber on the expanded row too', () => { + const header = renderHeader([ + { id: 'm1', kind: 'monitor', description: 'watcher' }, + { id: 'c1', kind: 'command', description: 'sleep 90' } + ]) + fireEvent.click(header) + // Each kind group is its own labelled list, so scope to the monitor one. + const monitors = screen.getByRole('list', { name: 'Monitors' }) + expect(monitors.querySelector('.lucide-activity')?.classList).toContain('text-yellow-500') + const shell = screen.getByRole('list', { name: 'Shell' }) + expect(shell.querySelector('.lucide-square-terminal')?.classList).toContain( + 'text-muted-foreground' + ) + }) + + it('draws the segment separator in a visible text tone, not the divider token', () => { + const header = renderHeader([ + { id: 'a1', kind: 'agent' }, + { id: 'c1', kind: 'command' } + ]) + const separators = [...header.querySelectorAll('span')].filter( + (element) => element.textContent === ' · ' + ) + expect(separators).toHaveLength(1) + // `--border` is a divider line (7% white in dark), an order of magnitude + // fainter than the counts it sits between. + expect(separators[0].classList).not.toContain('text-border') + expect(separators[0].classList).toContain('text-muted-foreground') + // One space either side; the icon's own margin is the icon-to-label gap. + expect(header.textContent).toBe('1 agent · 1 shell') + }) + + it('carries no icon on a collapsed total, which spans kinds', () => { + const header = renderHeader([ + { id: 'a1', kind: 'agent' }, + { id: 'c1', kind: 'command' }, + { id: 'm1', kind: 'monitor' }, + { id: 'w1', kind: 'workflow' } + ]) + expect(header).toHaveAttribute('aria-label', '4 background tasks') + expect(header.querySelectorAll('svg')).toHaveLength(1) + }) +}) + +describe('settled rows beside their live siblings', () => { + // Retention is the PR's headline: a finished child stays visible, keeps the + // usage it ended on, and stops claiming a clock or a stop control. + it('keeps a settled row with its final usage, no clock and no stop', () => { + render( + {}} + /> + ) + fireEvent.click(screen.getByRole('button', { expanded: false })) + const agents = screen.getByRole('list', { name: 'Agents' }) + const rows = within(agents).getAllByRole('listitem') + expect(rows).toHaveLength(2) + // First seen first: the settled sibling started earlier. + expect(rows[0].textContent).toBe('settled child18.1k') + expect(rows[1].textContent).toMatch(/^live child4\.1k · .+Stop$/) + expect(within(rows[1]).getByRole('button', { name: 'Stop live child' })).toBeInTheDocument() + expect(within(rows[0]).queryByRole('button')).toBeNull() + }) +}) + +describe('background-task row reasons', () => { + function expandedRows(tasks: AgentSessionBackgroundTask[]): HTMLElement[] { + render( + {}} + /> + ) + fireEvent.click(screen.getByRole('button', { expanded: false })) + return screen.getAllByRole('listitem') + } + + // `unverifiable` is the SSH verdict for "no contact"; a row that hides it reads + // like a working child. `blocked` is the same class of loss. + it('names the reason on every attention state, not only on waiting', () => { + const rows = expandedRows([ + { id: 'a1', kind: 'agent', description: 'ssh child', state: 'unverifiable' }, + { id: 'a2', kind: 'agent', description: 'flaky child', state: 'blocked' }, + { id: 'a3', kind: 'agent', description: 'approval child', state: 'waiting' }, + { id: 'a4', kind: 'agent', description: 'busy child', state: 'working' } + ]) + expect(rows).toHaveLength(4) + expect(rows[0].textContent).toContain('ssh child · no contact') + expect(rows[1].textContent).toContain('flaky child · failed') + expect(rows[2].textContent).toContain('approval child · needs approval') + // A running row has nothing to explain. + expect(rows[3].textContent).not.toContain('·') + }) +}) + +it('stops elapsed renders in a hidden pane and catches up on reveal', () => { + vi.useFakeTimers() + vi.setSystemTime(100_000) + const committed = vi.fn() + const view = (isVisible: boolean) => ( + + {}} + /> + + ) + const { rerender, unmount } = render(view(true)) + committed.mockClear() + act(() => vi.advanceTimersByTime(1_000)) + expect(committed).toHaveBeenCalled() + rerender(view(false)) + committed.mockClear() + act(() => vi.advanceTimersByTime(10_000)) + expect(committed).not.toHaveBeenCalled() + rerender(view(true)) + committed.mockClear() + act(() => vi.advanceTimersByTime(1_000)) + expect(committed).toHaveBeenCalled() + unmount() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index c52b73842a9..21b8d73f258 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -1,62 +1,207 @@ -import { useId, useState } from 'react' -import { ChevronDown } from 'lucide-react' +import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { Activity, Bot, ChevronDown, CircleHelp, SquareTerminal, Workflow } from 'lucide-react' import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' import { AgentStateDot } from '@/components/AgentStateDot' import { Button } from '@/components/ui/button' +import { useNow } from '@/hooks/use-now' import { translate } from '@/i18n/i18n' +import { backgroundTasksHeaderContent } from './background-task-header-content' +import { + backgroundTaskElapsedLabel, + backgroundTaskGroupLabel, + backgroundTaskStateReason, + buildBackgroundTaskGroups, + formatBackgroundTaskTokens, + type BackgroundRosterTask +} from './background-task-roster' -function backgroundTaskLabel(task: AgentSessionBackgroundTask): string { - if (task.description) { - return task.description - } - switch (task.kind) { - case 'agent': - return translate('components.native-chat.backgroundTasks.agent', 'Background agent') - case 'workflow': - return translate('components.native-chat.backgroundTasks.workflow', 'Background workflow') - case 'command': - return translate('components.native-chat.backgroundTasks.command', 'Background command') - case 'monitor': - return translate('components.native-chat.backgroundTasks.monitor', 'Background monitor') - case 'unknown': - return translate('components.native-chat.backgroundTasks.task', 'Background task') - } +/** Below this strip width (border-box, live root font size) the header drops + * its per-kind breakdown for an honest total. A narrow split pane on a wide + * monitor must behave like a narrow window, so no viewport media query. */ +const NARROW_STRIP_REM = 24 + +function rootFontSizePx(): number { + const parsed = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 16 +} + +/** Observe the strip's own border-box width; the viewport is only the + * pre-measurement stand-in before the first observer callback. */ +function useNarrowStrip(ref: React.RefObject): boolean { + const [narrow, setNarrow] = useState(() => window.innerWidth < NARROW_STRIP_REM * 16) + useEffect(() => { + const element = ref.current + if (!element || typeof ResizeObserver === 'undefined') { + return + } + const observer = new ResizeObserver((observerEntries) => { + const width = + observerEntries[0]?.borderBoxSize?.[0]?.inlineSize ?? element.getBoundingClientRect().width + setNarrow(width < NARROW_STRIP_REM * rootFontSizePx()) + }) + observer.observe(element, { box: 'border-box' }) + return () => observer.disconnect() + }, [ref]) + return narrow +} + +const KIND_ICONS = { + agent: Bot, + command: SquareTerminal, + monitor: Activity, + workflow: Workflow, + unknown: CircleHelp +} as const + +/** Monitoring is a STATE the app colours the same on every surface — the agent + * sidebar and `AgentStateDot` both draw an amber heartbeat — so the strip must + * match it or the two stop reading as the same thing. The other four are plain + * kind markers and stay neutral. `dimmed` is the running-turn treatment. */ +function kindIconTone(kind: AgentSessionBackgroundTask['kind'], dimmed: boolean): string { + const tone = kind === 'monitor' ? 'text-yellow-500' : 'text-muted-foreground' + return dimmed ? `${tone}/40` : tone +} + +function BackgroundTaskRow(props: { + entry: BackgroundRosterTask + now: number + supportsTaskStop: boolean + stopping: boolean + onStop: (taskId: string) => void +}): React.JSX.Element { + const { entry, now } = props + const Icon = KIND_ICONS[entry.task.kind] + // Every attention state states its reason on the row, the same ones the collapsed + // header names; `unverifiable` ("no contact") must never be silently dropped. + const reason = backgroundTaskStateReason(entry.state) + // Settled rows keep their final usage but no elapsed — a still-growing clock + // on finished work would lie. + const meta = [ + entry.task.totalTokens !== undefined + ? formatBackgroundTaskTokens(entry.task.totalTokens) + : null, + entry.settled ? null : backgroundTaskElapsedLabel(entry.task, now) + ] + .filter((part): part is string => part !== null) + .join(' · ') + return ( +
  • +
  • + ) } export function NativeChatBackgroundTasksStatus(props: { tasks: readonly AgentSessionBackgroundTask[] + settledTasks: readonly AgentSessionBackgroundTask[] supportsTaskStop: boolean /** False when the provider exposes no honest stop at all; the fallback * control is hidden rather than offering a button that cannot act. */ supportsStopAll: boolean stoppingTaskIds: ReadonlySet stoppingAll: boolean + /** True while the session is idle: only then may the strip speak as the + * animated monitoring indicator. A running turn owns the voice. */ + indicatorActive: boolean + isVisible: boolean onStop: (taskId?: string) => void }): React.JSX.Element { const [expanded, setExpanded] = useState(false) const taskListId = useId() + const stripRef = useRef(null) + const narrow = useNarrowStrip(stripRef) + // The 1 Hz elapsed tick must not re-group, re-sort and re-translate the whole roster. + const groups = useMemo( + () => buildBackgroundTaskGroups(props.tasks, props.settledTasks), + [props.tasks, props.settledTasks] + ) + const singleLiveCommand = + groups.length === 1 && groups[0].kind === 'command' && groups[0].tasks.length === 1 + const hasElapsed = groups.some((group) => + group.tasks.some((entry) => !entry.settled && (entry.task.startedAt ?? 0) > 0) + ) + const now = useNow(1_000, props.isVisible && hasElapsed && (expanded || singleLiveCommand)) + const header = backgroundTasksHeaderContent(groups, { narrow, now }) + const headerText = `${header.segments.map((segment) => segment.text).join(' · ')}${header.detail ? `${header.segments.length > 0 ? ' — ' : ''}${header.detail}` : ''}` return (
    -
    +
    - ) : null} - - ) - })} - + ))} + +
    + )) ) : (

    {translate( @@ -119,7 +250,7 @@ export function NativeChatBackgroundTasksStatus(props: {

    )} {!props.supportsTaskStop && props.supportsStopAll ? ( -
    0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}> +
    0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}>