diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts index dcefeb8b619..56a31366c33 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts @@ -34,7 +34,16 @@ beforeEach(() => { }) describe('useAgentRowConversationName', () => { - it('returns null while the setting is off', () => { + it('returns null without reading the tab map while the setting is off', () => { + const tabsByWorktree = new Proxy( + {}, + { + get: () => { + throw new Error('inactive rows must not read the tab map') + } + } + ) + storeState.current = { settings: {}, tabsByWorktree } expect(useAgentRowConversationName(makeAgent())).toBeNull() }) @@ -43,11 +52,54 @@ describe('useAgentRowConversationName', () => { expect(useAgentRowConversationName(makeAgent())).toBe('Patient sync spike') }) - it('never renames subagent child rows after the parent tab', () => { - storeState.current = { settings: { agentRowsUseConversationName: true }, tabsByWorktree: {} } + it('never reads the parent tab for subagent child rows', () => { + const tabsByWorktree = new Proxy( + {}, + { + get: () => { + throw new Error('subagent rows must not read the parent tab') + } + } + ) + storeState.current = { settings: { agentRowsUseConversationName: true }, tabsByWorktree } expect(useAgentRowConversationName(makeAgent({ rowSource: 'subagent' }))).toBeNull() }) + it('indexes one immutable tab array once across rows', () => { + let tabReads = 0 + const tabs = new Proxy( + [ + { id: 'tab-1', worktreeId: 'wt-1', customTitle: 'First name', title: '' }, + { id: 'tab-2', worktreeId: 'wt-1', customTitle: 'Second name', title: '' } + ], + { + get: (target, property, receiver) => { + if (typeof property === 'string' && /^\d+$/.test(property)) { + tabReads += 1 + } + return Reflect.get(target, property, receiver) + } + } + ) + storeState.current = { + settings: { agentRowsUseConversationName: true }, + tabsByWorktree: { 'wt-1': tabs } + } + + expect(useAgentRowConversationName(makeAgent())).toBe('First name') + const readsAfterFirstRow = tabReads + expect( + useAgentRowConversationName( + makeAgent({ + paneKey: 'tab-2:leaf-1', + tab: { id: 'tab-2', worktreeId: 'wt-1', customTitle: null, title: '' } + } as Partial) + ) + ).toBe('Second name') + expect(readsAfterFirstRow).toBeGreaterThan(0) + expect(tabReads).toBe(readsAfterFirstRow) + }) + it('prefers the live store tab over the stale row snapshot', () => { storeState.current = { settings: { agentRowsUseConversationName: true }, diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts index 213db0236bb..0fa93461a19 100644 --- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts @@ -1,24 +1,42 @@ import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name' import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' import type { DashboardAgentRow } from './useDashboardData' +type WorktreeTabs = NonNullable + +const tabIndexByTabs = new WeakMap>() + +function getIndexedTab( + tabs: WorktreeTabs | undefined, + tabId: string +): WorktreeTabs[number] | undefined { + if (!tabs) { + return undefined + } + let tabIndex = tabIndexByTabs.get(tabs) + if (!tabIndex) { + tabIndex = new Map(tabs.map((tab) => [tab.id, tab])) + tabIndexByTabs.set(tabs, tabIndex) + } + return tabIndex.get(tabId) +} + /** The row's conversation name, or null when the mode is off or nothing usable exists. */ export function useAgentRowConversationName(agent: DashboardAgentRow): string | null { - const enabled = useAppStore((s) => s.settings?.agentRowsUseConversationName === true) - const generatedTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true) - // Why: row data patches live entries in place and keeps the tab snapshot from - // row creation, so renames/agent titles landing after that would never show. - // Read the current tab from the store; retained rows fall back to the snapshot. + const mode = useAppStore((s) => { + if (agent.rowSource === 'subagent' || s.settings?.agentRowsUseConversationName !== true) { + return 0 + } + return s.settings?.tabAutoGenerateTitle === true ? 2 : 1 + }) + // Why: the opt-in gate keeps inactive rows off the hot tab map entirely. const liveTab = useAppStore((s) => - s.tabsByWorktree[agent.tab.worktreeId]?.find((tab) => tab.id === agent.tab.id) + mode === 0 ? undefined : getIndexedTab(s.tabsByWorktree[agent.tab.worktreeId], agent.tab.id) ) - if (!enabled) { + if (mode === 0) { return null } - // Why: subagent child rows describe the child, and their agentType carries the - // child's name — the parent tab's conversation name would mislabel them. - if (agent.rowSource === 'subagent') { - return null - } - return getAgentRowConversationName(liveTab ?? agent.tab, agent.agentType, generatedTitlesEnabled) + // Why: retained row snapshots need a fallback after their live tab disappears. + return getAgentRowConversationName(liveTab ?? agent.tab, agent.agentType, mode === 2) } diff --git a/src/renderer/src/components/settings/agent-row-conversation-name-copy.ts b/src/renderer/src/components/settings/agent-row-conversation-name-copy.ts index 927fae2fa29..82032469581 100644 --- a/src/renderer/src/components/settings/agent-row-conversation-name-copy.ts +++ b/src/renderer/src/components/settings/agent-row-conversation-name-copy.ts @@ -11,7 +11,7 @@ export function getAgentRowConversationNamesTitle(): string { export function getAgentRowConversationNamesDescription(): string { return translate( 'auto.components.settings.agent-row-conversation-name-copy.description', - 'Show the conversation name (tab rename or agent-set title) on sidebar and dashboard agent rows instead of the last message. Rows without a name keep the last message.' + 'Show conversation names on sidebar and dashboard agent rows instead of the last message. Rows without a usable name keep the last message.' ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b5e61e80de0..9038eff8fc9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9318,7 +9318,7 @@ }, "agent-row-conversation-name-copy": { "title": "Label agent rows with conversation names", - "description": "Show the conversation name (tab rename or agent-set title) on sidebar and dashboard agent rows instead of the last message. Rows without a name keep the last message." + "description": "Show conversation names on sidebar and dashboard agent rows instead of the last message. Rows without a usable name keep the last message." } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8559f7e0105..68c8b1d2c06 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9295,7 +9295,7 @@ }, "agent-row-conversation-name-copy": { "title": "Label agent rows with conversation names", - "description": "Show the conversation name (tab rename or agent-set title) on sidebar and dashboard agent rows instead of the last message. Rows without a name keep the last message." + "description": "Show conversation names on sidebar and dashboard agent rows instead of the last message. Rows without a usable name keep the last message." } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9872a0136ef..ff0e5ee517f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9295,7 +9295,7 @@ }, "agent-row-conversation-name-copy": { "title": "Label agent rows with conversation names", - "description": "Show the conversation name (tab rename or agent-set title) on sidebar and dashboard agent rows instead of the last message. Rows without a name keep the last message." + "description": "Show conversation names on sidebar and dashboard agent rows instead of the last message. Rows without a usable name keep the last message." } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 660aab98523..9d6b71b981a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9295,7 +9295,7 @@ }, "agent-row-conversation-name-copy": { "title": "Label agent rows with conversation names", - "description": "Show the conversation name (tab rename or agent-set title) on sidebar and dashboard agent rows instead of the last message. Rows without a name keep the last message." + "description": "Show conversation names on sidebar and dashboard agent rows instead of the last message. Rows without a usable name keep the last message." } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b65818ad8f5..1138b5afcae 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9295,7 +9295,7 @@ }, "agent-row-conversation-name-copy": { "title": "Label agent rows with conversation names", - "description": "Show the conversation name (tab rename or agent-set title) on sidebar and dashboard agent rows instead of the last message. Rows without a name keep the last message." + "description": "Show conversation names on sidebar and dashboard agent rows instead of the last message. Rows without a usable name keep the last message." } }, "right": { diff --git a/src/shared/agent-row-conversation-name.test.ts b/src/shared/agent-row-conversation-name.test.ts index c25071fd771..0c579fa5a46 100644 --- a/src/shared/agent-row-conversation-name.test.ts +++ b/src/shared/agent-row-conversation-name.test.ts @@ -59,6 +59,16 @@ describe('getAgentRowConversationName', () => { expect( getAgentRowConversationName(makeTab({ title: 'orca/workspaces' }), 'codex', false) ).toBeNull() + expect( + getAgentRowConversationName( + makeTab({ title: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }), + 'codex', + false + ) + ).toBeNull() + expect( + getAgentRowConversationName(makeTab({ title: 'repos\\orca' }), 'codex', false) + ).toBeNull() }) it('accepts multi-word titles that merely contain a slash', () => { @@ -81,6 +91,22 @@ describe('getAgentRowConversationName', () => { it('rejects identity-echo, management, and placeholder titles', () => { expect(getAgentRowConversationName(makeTab({ title: 'Claude' }), 'claude', false)).toBeNull() + expect( + getAgentRowConversationName(makeTab({ title: '✳ Claude Code' }), 'claude', false) + ).toBeNull() + expect( + getAgentRowConversationName( + makeTab({ title: 'Claude Code - action required' }), + 'claude', + false + ) + ).toBeNull() + expect( + getAgentRowConversationName(makeTab({ title: '✦ Gemini CLI' }), 'gemini', false) + ).toBeNull() + expect( + getAgentRowConversationName(makeTab({ title: '◇ Ready (orca)' }), 'gemini', false) + ).toBeNull() expect( getAgentRowConversationName(makeTab({ title: 'claude agents' }), 'claude', false) ).toBeNull() diff --git a/src/shared/agent-row-conversation-name.ts b/src/shared/agent-row-conversation-name.ts index 9835d59c220..7e0e441f876 100644 --- a/src/shared/agent-row-conversation-name.ts +++ b/src/shared/agent-row-conversation-name.ts @@ -32,18 +32,54 @@ const SYNTHETIC_STATUS_TITLES_LOWER: ReadonlySet = new Set( // (worktree-agent-row-fallback-tab.ts); it is a placeholder, not a name. const FALLBACK_TAB_TITLE_LOWER = 'agent' +const AGENT_IDENTITY_ALIASES_LOWER: Readonly> = { + claude: ['claude code'], + gemini: ['gemini cli'] +} + +const STATUS_WITH_CONTEXT_RE = /^(?:ready|idle|done)(?:\s+\([^)]*\))?$/i + +function isIdentityStatusTitle(titleLower: string, identityLower: string): boolean { + return ( + titleLower === identityLower || + titleLower === `${identityLower} ready` || + titleLower === `${identityLower} idle` || + titleLower === `${identityLower} done` || + titleLower === `${identityLower} working` || + titleLower === `${identityLower} thinking` || + titleLower === `${identityLower} running` || + titleLower === `${identityLower} - action required` + ) +} + +function isAgentIdentityStatusTitle( + titleLower: string, + agentType: AgentType | null | undefined, + agentTypeLabelLower: string +): boolean { + if (isIdentityStatusTitle(titleLower, agentTypeLabelLower)) { + return true + } + return ( + AGENT_IDENTITY_ALIASES_LOWER[agentType ?? '']?.some((identity) => + isIdentityStatusTitle(titleLower, identity) + ) ?? false + ) +} + function isCwdLikeTitle(title: string): boolean { // Hook-less agents over SSH surface spinner+cwd titles (#8711); once the // spinner is stripped, what remains is a path, not a conversation name. - if (/^(?:~|\/|[A-Za-z]:[\\/])/.test(title)) { + if (/^(?:~|[\\/]|[A-Za-z]:[\\/])/.test(title)) { return true } // A single path-ish token ("orca/workspaces") is still a cwd, not a name. - return !/\s/.test(title) && title.includes('/') + return !/\s/.test(title) && /[\\/]/.test(title) } function conversationNameFromLiveTitle( liveTitle: string, + agentType: AgentType | null | undefined, agentTypeLabelLower: string, defaultTitle: string | undefined ): string | null { @@ -55,7 +91,8 @@ function conversationNameFromLiveTitle( if ( SYNTHETIC_STATUS_TITLES_LOWER.has(lower) || lower === FALLBACK_TAB_TITLE_LOWER || - lower === agentTypeLabelLower || + isAgentIdentityStatusTitle(lower, agentType, agentTypeLabelLower) || + STATUS_WITH_CONTEXT_RE.test(stripped) || isClaudeManagementTitle(stripped) || isCwdLikeTitle(stripped) ) { @@ -97,6 +134,7 @@ export function getAgentRowConversationName( } return conversationNameFromLiveTitle( liveTitle, + agentType, formatAgentTypeLabel(agentType).toLowerCase(), tab.defaultTitle )