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:
Jinjing 2026-03-24 21:43:19 -07:00 committed by GitHub
parent 624786d4cd
commit bcf134c19f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 104 additions and 1 deletions

View File

@ -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)

View File

@ -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')
})
})

View File

@ -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+2800U+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