fix(agent-rows): reject status labels and bound lookup work

Reject native provider/status titles and Windows/UNC cwd frames so conversation-name mode falls back to the user's last message instead of relabeling rows with identity or path text. Keep default-off and subagent rows off the tab map, and share a WeakMap-backed tab index across mounted rows to avoid repeated linear scans on store writes.
This commit is contained in:
Brennan Benson
2026-07-22 14:36:39 -07:00
parent a839195d01
commit 9d6d4f4e0d
10 changed files with 159 additions and 25 deletions
@@ -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<DashboardAgentRow>)
)
).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 },
@@ -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<AppState['tabsByWorktree'][string]>
const tabIndexByTabs = new WeakMap<WorktreeTabs, ReadonlyMap<string, WorktreeTabs[number]>>()
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)
}
@@ -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.'
)
}
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
@@ -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()
+41 -3
View File
@@ -32,18 +32,54 @@ const SYNTHETIC_STATUS_TITLES_LOWER: ReadonlySet<string> = 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<Record<string, readonly string[]>> = {
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
)