mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
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:
@@ -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.'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user