fix: clear stale agent working indicators when title stops updating (#100)
Adds clearWorkingIndicators to strip spinner/prefix/keyword markers from terminal titles after 3s of data flow without a title update, preventing the UI from showing a stuck "working" status after an agent exits. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
624786d4cd
commit
bcf134c19f
|
|
@ -1,3 +1,5 @@
|
|||
import { detectAgentStatusFromTitle, clearWorkingIndicators } from '@/lib/agent-status'
|
||||
|
||||
export type PtyTransport = {
|
||||
connect: (options: {
|
||||
url: string
|
||||
|
|
@ -69,6 +71,12 @@ export function createIpcPtyTransport(
|
|||
let pendingEscape = false
|
||||
let inOsc = false
|
||||
let pendingOscEscape = false
|
||||
let lastEmittedTitle: string | null = null
|
||||
let staleTitleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// How long data must flow without a title update before we consider
|
||||
// the last agent-working title stale and clear it (ms).
|
||||
const STALE_TITLE_TIMEOUT = 3000
|
||||
let storedCallbacks: {
|
||||
onConnect?: () => void
|
||||
onDisconnect?: () => void
|
||||
|
|
@ -110,7 +118,33 @@ export function createIpcPtyTransport(
|
|||
if (onTitleChange) {
|
||||
const title = extractLastOscTitle(data)
|
||||
if (title !== null) {
|
||||
// Got a fresh title — clear any pending stale-title timer
|
||||
if (staleTitleTimer) {
|
||||
clearTimeout(staleTitleTimer)
|
||||
staleTitleTimer = null
|
||||
}
|
||||
lastEmittedTitle = title
|
||||
onTitleChange(title)
|
||||
} else if (
|
||||
lastEmittedTitle &&
|
||||
detectAgentStatusFromTitle(lastEmittedTitle) === 'working'
|
||||
) {
|
||||
// Data flowing but no title update — the agent may have exited.
|
||||
// Start/restart a debounce timer to clear the stale working title.
|
||||
if (staleTitleTimer) {
|
||||
clearTimeout(staleTitleTimer)
|
||||
}
|
||||
staleTitleTimer = setTimeout(() => {
|
||||
staleTitleTimer = null
|
||||
if (
|
||||
lastEmittedTitle &&
|
||||
detectAgentStatusFromTitle(lastEmittedTitle) === 'working'
|
||||
) {
|
||||
const cleared = clearWorkingIndicators(lastEmittedTitle)
|
||||
lastEmittedTitle = cleared
|
||||
onTitleChange(cleared)
|
||||
}
|
||||
}, STALE_TITLE_TIMEOUT)
|
||||
}
|
||||
}
|
||||
if (onBell && chunkContainsBell(data)) {
|
||||
|
|
@ -120,6 +154,10 @@ export function createIpcPtyTransport(
|
|||
|
||||
const spawnedId = result.id
|
||||
ptyExitHandlers.set(spawnedId, (code) => {
|
||||
if (staleTitleTimer) {
|
||||
clearTimeout(staleTitleTimer)
|
||||
staleTitleTimer = null
|
||||
}
|
||||
connected = false
|
||||
ptyId = null
|
||||
unregisterPtyHandlers(spawnedId)
|
||||
|
|
@ -137,6 +175,10 @@ export function createIpcPtyTransport(
|
|||
},
|
||||
|
||||
disconnect() {
|
||||
if (staleTitleTimer) {
|
||||
clearTimeout(staleTitleTimer)
|
||||
staleTitleTimer = null
|
||||
}
|
||||
if (ptyId) {
|
||||
const id = ptyId
|
||||
window.api.pty.kill(id)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { detectAgentStatusFromTitle } from './agent-status'
|
||||
import { detectAgentStatusFromTitle, clearWorkingIndicators } from './agent-status'
|
||||
|
||||
describe('detectAgentStatusFromTitle', () => {
|
||||
it('detects permission requests from agent titles', () => {
|
||||
|
|
@ -11,3 +11,28 @@ describe('detectAgentStatusFromTitle', () => {
|
|||
expect(detectAgentStatusFromTitle('◇ Gemini CLI')).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWorkingIndicators', () => {
|
||||
it('strips Claude Code ". " working prefix', () => {
|
||||
const cleared = clearWorkingIndicators('. claude')
|
||||
expect(cleared).toBe('claude')
|
||||
expect(detectAgentStatusFromTitle(cleared)).not.toBe('working')
|
||||
})
|
||||
|
||||
it('strips braille spinner characters and working keywords', () => {
|
||||
const cleared = clearWorkingIndicators('⠋ Codex is thinking')
|
||||
expect(cleared).toBe('Codex is')
|
||||
expect(detectAgentStatusFromTitle(cleared)).not.toBe('working')
|
||||
})
|
||||
|
||||
it('strips Gemini working symbol', () => {
|
||||
const cleared = clearWorkingIndicators('✦ Gemini CLI')
|
||||
expect(cleared).toBe('Gemini CLI')
|
||||
expect(detectAgentStatusFromTitle(cleared)).not.toBe('working')
|
||||
})
|
||||
|
||||
it('returns original title if no working indicators found', () => {
|
||||
expect(clearWorkingIndicators('* claude')).toBe('* claude')
|
||||
expect(clearWorkingIndicators('Terminal 1')).toBe('Terminal 1')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -26,6 +26,42 @@ function containsAny(title: string, words: string[]): boolean {
|
|||
return words.some((word) => lower.includes(word))
|
||||
}
|
||||
|
||||
const WORKING_KEYWORDS = ['working', 'thinking', 'running']
|
||||
|
||||
/**
|
||||
* Strip working-status indicators from a title so that
|
||||
* `detectAgentStatusFromTitle` will no longer return 'working'.
|
||||
* Used to clear stale titles when an agent exits without resetting its title.
|
||||
*/
|
||||
export function clearWorkingIndicators(title: string): string {
|
||||
let cleaned = title
|
||||
|
||||
// Gemini working symbol
|
||||
cleaned = cleaned.replace(GEMINI_WORKING, '')
|
||||
|
||||
// Braille spinner characters (U+2800–U+28FF)
|
||||
// eslint-disable-next-line no-control-regex -- intentional unicode range
|
||||
cleaned = cleaned.replace(/[\u2800-\u28FF]/g, '')
|
||||
|
||||
// Claude Code ". " working prefix
|
||||
if (cleaned.startsWith('. ')) {
|
||||
cleaned = cleaned.slice(2)
|
||||
}
|
||||
|
||||
// Strip working keywords that detectAgentStatusFromTitle would pick up
|
||||
// when the title also contains an agent name.
|
||||
if (containsAgentName(cleaned)) {
|
||||
for (const keyword of WORKING_KEYWORDS) {
|
||||
cleaned = cleaned.replace(new RegExp(`\\b${keyword}\\b`, 'gi'), '')
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse whitespace after removals
|
||||
cleaned = cleaned.replace(/\s{2,}/g, ' ').trim()
|
||||
|
||||
return cleaned || title
|
||||
}
|
||||
|
||||
export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
|
||||
if (!title) {
|
||||
return null
|
||||
|
|
|
|||
Loading…
Reference in New Issue