feat(agent-rows): opt-in conversation-name labels for agent rows

Sidebar worktree-card and dashboard agent rows always show the last
message sent to the agent, so rows relabel on every turn and a 'continue'
prompt becomes the row's name. Add an opt-in Agents setting that labels
rows with the conversation name instead, resolved with the tab bar's
precedence: manual rename, quick-command label, OpenCode session title,
generated title (behind its existing setting), then the agent-set live
title. Live titles count only when they carry a real name - status
decoration is stripped, and pure status, identity-echo, spinner+cwd, and
placeholder titles fall back to the last-message label. Subagent child
rows keep their own descriptions.

Locale note: sync:localization-catalog also restored parity for keys
already missing on main (add-host, sleep-worktree copy).
This commit is contained in:
Brennan Benson
2026-07-22 13:43:11 -07:00
parent 4c2bb508c3
commit a3a2771b53
16 changed files with 535 additions and 24 deletions
@@ -11,6 +11,7 @@ import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
import { useAgentRowConversationName } from './use-agent-row-conversation-name'
// Why: narrow the dashboard's rollup states to shared dot states, defaulting unknowns to 'idle' so a row never crashes.
function asDotState(state: AgentStatusState | 'idle'): AgentDotState {
@@ -155,7 +156,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
)
const startedAt = agent.startedAt > 0 ? agent.startedAt : null
const doneAt = lastEnteredDoneAt(agent)
const prompt = getAgentRowPrimaryText(agent.entry)
const conversationName = useAgentRowConversationName(agent)
const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry)
// Why: prompt is '' when unknown, so fall back to the state label to keep the row labeled.
const displayLabel = prompt || agentStateLabel(asDotState(agent.state))
const model = agent.entry.model?.trim() ?? ''
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import { useAgentRowConversationName } from './use-agent-row-conversation-name'
import type { DashboardAgentRow } from './useDashboardData'
const storeState = vi.hoisted(() => ({
current: { settings: {} } as { current?: unknown; settings: Record<string, unknown> }
}))
// Why: the mocked selector makes the hook a pure function, so tests can call it
// directly without mounting a component.
vi.mock('@/store', () => ({
useAppStore: (selector: (state: AppState) => unknown) =>
selector(storeState.current as unknown as AppState)
}))
function makeAgent(overrides: Partial<DashboardAgentRow> = {}): DashboardAgentRow {
return {
paneKey: 'tab-1:leaf-1',
entry: { prompt: 'fix the sidebar' },
tab: { customTitle: 'Patient sync spike', title: '' },
agentType: 'claude',
state: 'working',
startedAt: 0,
...overrides
} as DashboardAgentRow
}
beforeEach(() => {
storeState.current = { settings: {} }
})
describe('useAgentRowConversationName', () => {
it('returns null while the setting is off', () => {
expect(useAgentRowConversationName(makeAgent())).toBeNull()
})
it('returns the conversation name when the setting is on', () => {
storeState.current = { settings: { agentRowsUseConversationName: true } }
expect(useAgentRowConversationName(makeAgent())).toBe('Patient sync spike')
})
it('never renames subagent child rows after the parent tab', () => {
storeState.current = { settings: { agentRowsUseConversationName: true } }
expect(useAgentRowConversationName(makeAgent({ rowSource: 'subagent' }))).toBeNull()
})
it('honors the generated-titles setting for generated names', () => {
const agent = makeAgent({
tab: { customTitle: null, title: '', generatedTitle: 'Fix intake flow' }
} as Partial<DashboardAgentRow>)
storeState.current = { settings: { agentRowsUseConversationName: true } }
expect(useAgentRowConversationName(agent)).toBeNull()
storeState.current = {
settings: { agentRowsUseConversationName: true, tabAutoGenerateTitle: true }
}
expect(useAgentRowConversationName(agent)).toBe('Fix intake flow')
})
})
@@ -0,0 +1,18 @@
import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name'
import { useAppStore } from '@/store'
import type { DashboardAgentRow } from './useDashboardData'
/** 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)
if (!enabled) {
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(agent.tab, agent.agentType, generatedTitlesEnabled)
}
@@ -22,6 +22,10 @@ import {
getAgentGeneratedTabTitlesDescription,
getAgentGeneratedTabTitlesTitle
} from './agent-generated-tab-title-copy'
import {
getAgentRowConversationNamesDescription,
getAgentRowConversationNamesTitle
} from './agent-row-conversation-name-copy'
import { getAgentStatusHooksDescription, getAgentStatusHooksTitle } from './agent-status-hooks-copy'
import {
SettingsBadge,
@@ -833,6 +837,8 @@ export function AgentsPane({
<AgentGeneratedTabTitlesSetting settings={settings} updateSettings={updateSettings} />
<AgentRowConversationNamesSetting settings={settings} updateSettings={updateSettings} />
<AgentAwakeSetting settings={settings} updateSettings={updateSettings} />
<AgentCacheTimerSection settings={settings} updateSettings={updateSettings} />
@@ -1003,3 +1009,25 @@ export function AgentGeneratedTabTitlesSetting({
</section>
)
}
export function AgentRowConversationNamesSetting({
settings,
updateSettings
}: AgentsPaneProps): React.JSX.Element {
const enabled = settings.agentRowsUseConversationName === true
return (
<section className="space-y-3">
<SettingsSwitchRow
label={getAgentRowConversationNamesTitle()}
description={getAgentRowConversationNamesDescription()}
checked={enabled}
onChange={() =>
updateSettings({
agentRowsUseConversationName: !enabled
})
}
ariaLabel={getAgentRowConversationNamesTitle()}
/>
</section>
)
}
@@ -0,0 +1,35 @@
import { translate } from '@/i18n/i18n'
import { searchKeywords } from './settings-search-keywords'
export function getAgentRowConversationNamesTitle(): string {
return translate(
'auto.components.settings.agent-row-conversation-name-copy.title',
'Label agent rows with conversation names'
)
}
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.'
)
}
export function getAgentRowConversationNamesSearchKeywords(): string[] {
return searchKeywords([
{ key: 'auto.components.settings.agents.search.96ba2373b6', fallback: 'agent' },
{ key: 'auto.components.settings.agents.search.conversation', fallback: 'conversation' },
{ key: 'auto.components.settings.agents.search.966890236d', fallback: 'name' },
{
key: 'auto.components.settings.agents.search.conversationName',
fallback: 'conversation name'
},
{ key: 'auto.components.settings.agents.search.sidebar', fallback: 'sidebar' },
{ key: 'auto.components.settings.agents.search.dashboard', fallback: 'dashboard' },
{ key: 'auto.components.settings.agents.search.activityRow', fallback: 'activity' },
{ key: 'auto.components.settings.agents.search.lastMessage', fallback: 'last message' },
{ key: 'auto.components.settings.agents.search.c64059f50d', fallback: 'prompt' },
{ key: 'auto.components.settings.agents.search.5784ae8c43', fallback: 'rename' },
{ key: 'auto.components.settings.agents.search.rowLabel', fallback: 'label' }
])
}
@@ -9,6 +9,11 @@ import {
getAgentGeneratedTabTitlesSearchKeywords,
getAgentGeneratedTabTitlesTitle
} from './agent-generated-tab-title-copy'
import {
getAgentRowConversationNamesDescription,
getAgentRowConversationNamesSearchKeywords,
getAgentRowConversationNamesTitle
} from './agent-row-conversation-name-copy'
import {
getAgentStatusHooksDescription,
getAgentStatusHooksSearchKeywords,
@@ -111,6 +116,11 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [
description: getAgentGeneratedTabTitlesDescription(),
keywords: getAgentGeneratedTabTitlesSearchKeywords()
},
{
title: getAgentRowConversationNamesTitle(),
description: getAgentRowConversationNamesDescription(),
keywords: getAgentRowConversationNamesSearchKeywords()
},
{
title: getAgentAwakeTitle(),
description: getAgentAwakeDescription(),
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'
import { getAgentDotState } from './worktree-card-agent-summary'
import { translate } from '@/i18n/i18n'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
import { useAgentRowConversationName } from '@/components/dashboard/use-agent-row-conversation-name'
import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp'
import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer'
@@ -27,8 +28,11 @@ function formatShortTimeAgo(ts: number, now: number): string {
return `${Math.floor(hours / 24)}d`
}
function getCompactAgentPrimary(agent: DashboardAgentRowData): string {
const prompt = getAgentRowPrimaryText(agent.entry)
function getCompactAgentPrimary(
agent: DashboardAgentRowData,
conversationName: string | null
): string {
const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry)
return prompt || agentStateLabel(getAgentDotState(agent))
}
@@ -116,7 +120,8 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
// "?" glyph. Nesting under the parent already conveys identity.
const hideIcon = hideIdentityIcon || agent.rowSource === 'subagent'
const dotState = getAgentDotState(agent)
const primary = getCompactAgentPrimary(agent)
const conversationName = useAgentRowConversationName(agent)
const primary = getCompactAgentPrimary(agent, conversationName)
const isLineageChild = agent.lineage?.depth === 1
const secondary = getCompactAgentSecondary(agent)
const model = agent.entry.model?.trim() ?? ''
+33 -4
View File
@@ -1269,7 +1269,17 @@
"noRunTargets": "No run targets are ready for this project.",
"perWorkspaceEnvHint": "Provision an on-demand environment from a recipe",
"branchName": "Branch name",
"branchNamePlaceholder": "feature/my-branch"
"branchNamePlaceholder": "feature/my-branch",
"connectTimedOut": "Connection timed out. It may still be connecting in the background.",
"connectingHost": "Connecting…",
"connectHost": "Connect",
"addHost": "Add host",
"addHostHint": "Register another machine or Orca server",
"addSshHost": "Add SSH host",
"addSshHostHint": "Use an existing machine over SSH",
"addRemoteOrcaServer": "Add Remote Orca Server",
"addRemoteOrcaServerHint": "Pair another Orca runtime",
"hostConnectionFailed": "Connection failed"
},
"NewWorkspaceComposerModal": {
"createWorktree": "Create worktree",
@@ -4549,7 +4559,14 @@
"worktree": {
"flow": {
"c460fecc4a": "Failed to sleep some workspaces",
"8bc3fc0671": "Failed to sleep workspace"
"8bc3fc0671": "Failed to sleep workspace",
"legacy": {
"unverified": "The older host runtime could not confirm terminal shutdown. The workspace was kept open; update the host and try again."
},
"host": {
"unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again."
},
"retry": "The workspace was kept open. Try again; if the problem continues, check the host connection."
}
}
},
@@ -4816,7 +4833,8 @@
"sshImportSynced": "Synced {{value0}} host{{value1}}.",
"sshImportFailed": "Failed to import SSH config.",
"importing": "Importing...",
"importSshConfig": "Import ~/.ssh/config"
"importSshConfig": "Import ~/.ssh/config",
"advanced": "Advanced"
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "Reconnection failed",
@@ -7283,7 +7301,14 @@
"agentPermissions": "Agent Permissions",
"agentPermissionsDescription": "Switch agent permission defaults between Yolo and Manual.",
"agentRuntime": "Agent Runtime",
"agentRuntimeDescription": "Choose whether agents are detected and launched on Windows or in WSL by default."
"agentRuntimeDescription": "Choose whether agents are detected and launched on Windows or in WSL by default.",
"conversation": "conversation",
"conversationName": "conversation name",
"sidebar": "sidebar",
"dashboard": "dashboard",
"activityRow": "activity",
"lastMessage": "last message",
"rowLabel": "label"
}
},
"appearance": {
@@ -9290,6 +9315,10 @@
"title": "Editor Font Family",
"description": "Font used by file editors and diff views. Leave empty to follow the terminal font.",
"placeholder": "Same as terminal font"
},
"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."
}
},
"right": {
+33 -4
View File
@@ -1246,7 +1246,17 @@
"noRunTargets": "No hay destinos de ejecución listos para este proyecto.",
"perWorkspaceEnvHint": "Provisiona un entorno bajo demanda a partir de una receta",
"branchName": "Nombre de rama",
"branchNamePlaceholder": "feature/my-branch"
"branchNamePlaceholder": "feature/my-branch",
"connectTimedOut": "Connection timed out. It may still be connecting in the background.",
"connectingHost": "Connecting…",
"connectHost": "Connect",
"addHost": "Add host",
"addHostHint": "Register another machine or Orca server",
"addSshHost": "Add SSH host",
"addSshHostHint": "Use an existing machine over SSH",
"addRemoteOrcaServer": "Add Remote Orca Server",
"addRemoteOrcaServerHint": "Pair another Orca runtime",
"hostConnectionFailed": "Connection failed"
},
"NewWorkspaceComposerModal": {
"createWorktree": "Crear worktree",
@@ -4503,7 +4513,14 @@
"worktree": {
"flow": {
"c460fecc4a": "No se pudieron poner en reposo algunos espacios de trabajo",
"8bc3fc0671": "No se pudo poner en reposo el espacio de trabajo"
"8bc3fc0671": "No se pudo poner en reposo el espacio de trabajo",
"legacy": {
"unverified": "The older host runtime could not confirm terminal shutdown. The workspace was kept open; update the host and try again."
},
"host": {
"unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again."
},
"retry": "The workspace was kept open. Try again; if the problem continues, check the host connection."
}
}
},
@@ -4793,7 +4810,8 @@
"sshImportFailed": "No se pudo importar la configuración SSH.",
"importing": "Importando...",
"importSshConfig": "Importar ~/.ssh/config",
"sshPersistenceDefault": "Los terminales remotos en este host seguirán activos hasta que los cierres o restablezcas el relay."
"sshPersistenceDefault": "Los terminales remotos en este host seguirán activos hasta que los cierres o restablezcas el relay.",
"advanced": "Advanced"
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "Error de reconexión",
@@ -7223,7 +7241,14 @@
"agentPermissions": "Permisos de agentes",
"agentPermissionsDescription": "Cambia los permisos predeterminados de los agentes entre Yolo y Manual.",
"agentRuntime": "Runtime del agente",
"agentRuntimeDescription": "Elige si los agentes se detectan y ejecutan en Windows o en WSL de forma predeterminada."
"agentRuntimeDescription": "Elige si los agentes se detectan y ejecutan en Windows o en WSL de forma predeterminada.",
"conversation": "conversation",
"conversationName": "conversation name",
"sidebar": "sidebar",
"dashboard": "dashboard",
"activityRow": "activity",
"lastMessage": "last message",
"rowLabel": "label"
}
},
"appearance": {
@@ -9267,6 +9292,10 @@
"title": "Editor Font Family",
"description": "Font used by file editors and diff views. Leave empty to follow the terminal font.",
"placeholder": "Same as terminal font"
},
"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."
}
},
"right": {
+33 -4
View File
@@ -1246,7 +1246,17 @@
"noRunTargets": "このプロジェクトには実行ターゲットが準備されていません。",
"perWorkspaceEnvHint": "レシピからオンデマンド環境をプロビジョニングする",
"branchName": "ブランチ名",
"branchNamePlaceholder": "feature/my-branch"
"branchNamePlaceholder": "feature/my-branch",
"connectTimedOut": "Connection timed out. It may still be connecting in the background.",
"connectingHost": "Connecting…",
"connectHost": "Connect",
"addHost": "Add host",
"addHostHint": "Register another machine or Orca server",
"addSshHost": "Add SSH host",
"addSshHostHint": "Use an existing machine over SSH",
"addRemoteOrcaServer": "Add Remote Orca Server",
"addRemoteOrcaServerHint": "Pair another Orca runtime",
"hostConnectionFailed": "Connection failed"
},
"NewWorkspaceComposerModal": {
"createWorktree": "ワークツリーを作成する",
@@ -4484,7 +4494,14 @@
"worktree": {
"flow": {
"c460fecc4a": "一部のワークスペースのスリープに失敗しました",
"8bc3fc0671": "ワークスペースのスリープに失敗しました"
"8bc3fc0671": "ワークスペースのスリープに失敗しました",
"legacy": {
"unverified": "The older host runtime could not confirm terminal shutdown. The workspace was kept open; update the host and try again."
},
"host": {
"unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again."
},
"retry": "The workspace was kept open. Try again; if the problem continues, check the host connection."
}
}
},
@@ -4793,7 +4810,8 @@
"sshImportFailed": "SSH 構成のインポートに失敗しました。",
"importing": "インポート中...",
"importSshConfig": "~/.ssh/config をインポートします",
"sshPersistenceDefault": "このホスト上のリモートターミナルは、終了するかリレーをリセットするまで動作し続けます。"
"sshPersistenceDefault": "このホスト上のリモートターミナルは、終了するかリレーをリセットするまで動作し続けます。",
"advanced": "Advanced"
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "再接続に失敗しました",
@@ -7245,7 +7263,14 @@
"agentPermissions": "Agent Permissions",
"agentPermissionsDescription": "Switch agent permission defaults between Yolo and Manual.",
"agentRuntime": "Agent のランタイム",
"agentRuntimeDescription": "デフォルトで agents を Windows または WSL のどちらで検出および起動するかを選択します。"
"agentRuntimeDescription": "デフォルトで agents を Windows または WSL のどちらで検出および起動するかを選択します。",
"conversation": "conversation",
"conversationName": "conversation name",
"sidebar": "sidebar",
"dashboard": "dashboard",
"activityRow": "activity",
"lastMessage": "last message",
"rowLabel": "label"
}
},
"appearance": {
@@ -9267,6 +9292,10 @@
"title": "Editor Font Family",
"description": "Font used by file editors and diff views. Leave empty to follow the terminal font.",
"placeholder": "Same as terminal font"
},
"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."
}
},
"right": {
+33 -4
View File
@@ -1246,7 +1246,17 @@
"noRunTargets": "이 프로젝트에는 실행 대상이 준비되어 있지 않습니다.",
"perWorkspaceEnvHint": "레시피에서 온디맨드 환경 프로비저닝",
"branchName": "브랜치 이름",
"branchNamePlaceholder": "feature/my-branch"
"branchNamePlaceholder": "feature/my-branch",
"connectTimedOut": "Connection timed out. It may still be connecting in the background.",
"connectingHost": "Connecting…",
"connectHost": "Connect",
"addHost": "Add host",
"addHostHint": "Register another machine or Orca server",
"addSshHost": "Add SSH host",
"addSshHostHint": "Use an existing machine over SSH",
"addRemoteOrcaServer": "Add Remote Orca Server",
"addRemoteOrcaServerHint": "Pair another Orca runtime",
"hostConnectionFailed": "Connection failed"
},
"NewWorkspaceComposerModal": {
"createWorktree": "작업 트리 만들기",
@@ -4484,7 +4494,14 @@
"worktree": {
"flow": {
"c460fecc4a": "일부 워크스페이스를 절전 모드로 전환하지 못했습니다.",
"8bc3fc0671": "워크스페이스를 절전 모드로 전환하지 못했습니다."
"8bc3fc0671": "워크스페이스를 절전 모드로 전환하지 못했습니다.",
"legacy": {
"unverified": "The older host runtime could not confirm terminal shutdown. The workspace was kept open; update the host and try again."
},
"host": {
"unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again."
},
"retry": "The workspace was kept open. Try again; if the problem continues, check the host connection."
}
}
},
@@ -4793,7 +4810,8 @@
"sshImportFailed": "SSH 구성을 가져오지 못했습니다.",
"importing": "가져오는 중...",
"importSshConfig": "~/.ssh/config 가져오기",
"sshPersistenceDefault": "이 호스트의 원격 터미널은 종료하거나 릴레이를 재설정할 때까지 계속 실행됩니다."
"sshPersistenceDefault": "이 호스트의 원격 터미널은 종료하거나 릴레이를 재설정할 때까지 계속 실행됩니다.",
"advanced": "Advanced"
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "재연결 실패",
@@ -7208,7 +7226,14 @@
"agentPermissions": "Agent 권한",
"agentPermissionsDescription": "agent 권한 기본값을 Yolo와 수동 사이에서 전환합니다.",
"agentRuntime": "Agent 런타임",
"agentRuntimeDescription": "기본적으로 Windows 또는 WSL에서 agents를 감지하고 시작할지 선택합니다."
"agentRuntimeDescription": "기본적으로 Windows 또는 WSL에서 agents를 감지하고 시작할지 선택합니다.",
"conversation": "conversation",
"conversationName": "conversation name",
"sidebar": "sidebar",
"dashboard": "dashboard",
"activityRow": "activity",
"lastMessage": "last message",
"rowLabel": "label"
}
},
"appearance": {
@@ -9267,6 +9292,10 @@
"title": "Editor Font Family",
"description": "Font used by file editors and diff views. Leave empty to follow the terminal font.",
"placeholder": "Same as terminal font"
},
"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."
}
},
"right": {
+33 -4
View File
@@ -1246,7 +1246,17 @@
"noRunTargets": "此项目还没有可用的运行目标。",
"perWorkspaceEnvHint": "通过环境模板按需创建环境",
"branchName": "分支名称",
"branchNamePlaceholder": "feature/my-branch"
"branchNamePlaceholder": "feature/my-branch",
"connectTimedOut": "Connection timed out. It may still be connecting in the background.",
"connectingHost": "Connecting…",
"connectHost": "Connect",
"addHost": "Add host",
"addHostHint": "Register another machine or Orca server",
"addSshHost": "Add SSH host",
"addSshHostHint": "Use an existing machine over SSH",
"addRemoteOrcaServer": "Add Remote Orca Server",
"addRemoteOrcaServerHint": "Pair another Orca runtime",
"hostConnectionFailed": "Connection failed"
},
"NewWorkspaceComposerModal": {
"createWorktree": "创建工作树",
@@ -4484,7 +4494,14 @@
"worktree": {
"flow": {
"c460fecc4a": "无法睡眠某些工作区",
"8bc3fc0671": "无法睡眠工作区"
"8bc3fc0671": "无法睡眠工作区",
"legacy": {
"unverified": "The older host runtime could not confirm terminal shutdown. The workspace was kept open; update the host and try again."
},
"host": {
"unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again."
},
"retry": "The workspace was kept open. Try again; if the problem continues, check the host connection."
}
}
},
@@ -4793,7 +4810,8 @@
"sshImportFailed": "导入 SSH 配置失败。",
"importing": "正在导入...",
"importSshConfig": "导入 ~/.ssh/config",
"sshPersistenceDefault": "此主机上的远程终端会保持运行,直到你结束它们或重置中继。"
"sshPersistenceDefault": "此主机上的远程终端会保持运行,直到你结束它们或重置中继。",
"advanced": "Advanced"
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "重新连接失败",
@@ -7208,7 +7226,14 @@
"agentPermissions": "智能体权限",
"agentPermissionsDescription": "在 Yolo 和手动之间切换智能体权限默认值。",
"agentRuntime": "智能体运行时",
"agentRuntimeDescription": "选择默认在 Windows 还是 WSL 中检测并启动智能体。"
"agentRuntimeDescription": "选择默认在 Windows 还是 WSL 中检测并启动智能体。",
"conversation": "conversation",
"conversationName": "conversation name",
"sidebar": "sidebar",
"dashboard": "dashboard",
"activityRow": "activity",
"lastMessage": "last message",
"rowLabel": "label"
}
},
"appearance": {
@@ -9267,6 +9292,10 @@
"title": "Editor Font Family",
"description": "Font used by file editors and diff views. Leave empty to follow the terminal font.",
"placeholder": "Same as terminal font"
},
"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."
}
},
"right": {
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
getAgentRowConversationName,
type ConversationNameTab
} from './agent-row-conversation-name'
function makeTab(overrides: Partial<ConversationNameTab> = {}): ConversationNameTab {
return { customTitle: null, title: '', ...overrides }
}
describe('getAgentRowConversationName', () => {
it('prefers the manual tab rename over every other source', () => {
const tab = makeTab({
customTitle: 'Patient sync spike',
quickCommandLabel: 'Run tests',
generatedTitle: 'Fix intake flow',
title: '✳ Investigate replay bug'
})
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Patient sync spike')
})
it('falls back to the quick-command label before titles', () => {
const tab = makeTab({ quickCommandLabel: 'Run tests', title: '✳ Investigate replay bug' })
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Run tests')
})
it('keeps OpenCode semantic session titles whole', () => {
const tab = makeTab({ title: 'OC | build the release pipeline' })
expect(getAgentRowConversationName(tab, 'opencode', false)).toBe(
'OC | build the release pipeline'
)
})
it('uses the generated title only when generated titles are enabled', () => {
const tab = makeTab({ generatedTitle: 'Fix intake flow', title: '✳ Investigate replay bug' })
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Fix intake flow')
expect(getAgentRowConversationName(tab, 'claude', false)).toBe('Investigate replay bug')
})
it('strips leading status decoration from agent-set titles', () => {
expect(
getAgentRowConversationName(makeTab({ title: '✳ Fix patient intake flow' }), 'claude', false)
).toBe('Fix patient intake flow')
expect(
getAgentRowConversationName(makeTab({ title: '⠋ Refactor replay guard' }), 'codex', false)
).toBe('Refactor replay guard')
})
it('rejects spinner+cwd titles instead of surfacing paths as names', () => {
expect(
getAgentRowConversationName(makeTab({ title: '⠋ ~/orca/workspaces' }), 'codex', false)
).toBeNull()
expect(
getAgentRowConversationName(makeTab({ title: '/Users/dev/repo' }), 'codex', false)
).toBeNull()
expect(
getAgentRowConversationName(makeTab({ title: 'C:\\repos\\orca' }), 'codex', false)
).toBeNull()
expect(
getAgentRowConversationName(makeTab({ title: 'orca/workspaces' }), 'codex', false)
).toBeNull()
})
it('accepts multi-word titles that merely contain a slash', () => {
expect(
getAgentRowConversationName(makeTab({ title: 'Fix a/b toggle in settings' }), 'codex', false)
).toBe('Fix a/b toggle in settings')
})
it('rejects synthetic status titles', () => {
expect(
getAgentRowConversationName(makeTab({ title: 'Codex ready' }), 'codex', false)
).toBeNull()
expect(
getAgentRowConversationName(makeTab({ title: 'Codex - action required' }), 'codex', false)
).toBeNull()
expect(
getAgentRowConversationName(makeTab({ title: 'Cursor Agent' }), 'cursor', false)
).toBeNull()
})
it('rejects identity-echo, management, and placeholder titles', () => {
expect(getAgentRowConversationName(makeTab({ title: 'Claude' }), 'claude', false)).toBeNull()
expect(
getAgentRowConversationName(makeTab({ title: 'claude agents' }), 'claude', false)
).toBeNull()
expect(getAgentRowConversationName(makeTab({ title: 'Agent' }), 'claude', false)).toBeNull()
})
it('rejects empty, glyph-only, and default terminal titles', () => {
expect(getAgentRowConversationName(makeTab(), 'claude', false)).toBeNull()
expect(getAgentRowConversationName(makeTab({ title: '✳' }), 'claude', false)).toBeNull()
expect(
getAgentRowConversationName(
makeTab({ title: 'Terminal 2', defaultTitle: 'Terminal 2' }),
'claude',
false
)
).toBeNull()
})
})
+103
View File
@@ -0,0 +1,103 @@
// Resolves the stable "conversation name" an agent row can show instead of the
// live last-message preview. Sources, in the same precedence the tab bar uses
// (tab-title-resolution.ts): manual rename → quick-command label → OpenCode's
// semantic session title → Orca's generated title → the agent-set live title.
// Live titles are accepted only when they carry a real name — pure status,
// identity-echo, and spinner/cwd titles yield null so callers keep the
// last-message label.
import type { AgentType } from './agent-status-types'
import { isClaudeManagementTitle } from './agent-title-core'
import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
import { formatAgentTypeLabel } from './agent-type-label'
import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title'
import { SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title'
import type { TerminalTab } from './types'
export type ConversationNameTab = Pick<
TerminalTab,
'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title' | 'defaultTitle'
>
// Why: synthetic status titles ("Codex ready", "Cursor - action required") are
// state, not names. Precomputed once; the profile table is a module constant.
const SYNTHETIC_STATUS_TITLES_LOWER: ReadonlySet<string> = new Set(
Object.values(SYNTHETIC_AGENT_TITLE_PROFILES).flatMap((profile) => [
profile.workingLabel.toLowerCase(),
profile.permissionLabel.toLowerCase(),
profile.idleLabel.toLowerCase()
])
)
// Why: retained rows without a live tab synthesize `title: 'Agent'`
// (worktree-agent-row-fallback-tab.ts); it is a placeholder, not a name.
const FALLBACK_TAB_TITLE_LOWER = 'agent'
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)) {
return true
}
// A single path-ish token ("orca/workspaces") is still a cwd, not a name.
return !/\s/.test(title) && title.includes('/')
}
function conversationNameFromLiveTitle(
liveTitle: string,
agentTypeLabelLower: string,
defaultTitle: string | undefined
): string | null {
const stripped = stripLeadingAgentTitleDecorationOrEmpty(liveTitle.trim()).trim()
if (!stripped) {
return null
}
const lower = stripped.toLowerCase()
if (
SYNTHETIC_STATUS_TITLES_LOWER.has(lower) ||
lower === FALLBACK_TAB_TITLE_LOWER ||
lower === agentTypeLabelLower ||
isClaudeManagementTitle(stripped) ||
isCwdLikeTitle(stripped)
) {
return null
}
if (defaultTitle && stripped === defaultTitle.trim()) {
return null
}
return stripped
}
/**
* The conversation name for an agent row, or null when no usable name exists
* and the caller should keep its last-message label.
*/
export function getAgentRowConversationName(
tab: ConversationNameTab,
agentType: AgentType | null | undefined,
generatedTitlesEnabled: boolean
): string | null {
const customTitle = tab.customTitle?.trim()
if (customTitle) {
return customTitle
}
const quickCommandLabel = tab.quickCommandLabel?.trim()
if (quickCommandLabel) {
return quickCommandLabel
}
const liveTitle = tab.title?.trim() ?? ''
if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) {
return liveTitle
}
const generatedTitle = generatedTitlesEnabled ? tab.generatedTitle?.trim() : ''
if (generatedTitle) {
return generatedTitle
}
if (!liveTitle) {
return null
}
return conversationNameFromLiveTitle(
liveTitle,
formatAgentTypeLabel(agentType).toLowerCase(),
tab.defaultTitle
)
}
+1
View File
@@ -321,6 +321,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
agentYoloDefaultsMigrated: true,
agentStatusHooksEnabled: true,
tabAutoGenerateTitle: false,
agentRowsUseConversationName: false,
confirmClosePinnedTab: true,
keepComputerAwakeWhileAgentsRun: false,
// Why: 'auto' probes keyboard layout so non-US users can type Option chars like @/€/[ out of the box (issue #903). See src/renderer/src/lib/keyboard-layout/*.
+4
View File
@@ -2868,6 +2868,10 @@ export type GlobalSettings = {
dismissedSkillFreshnessNudges?: string[]
/** Why: generated tab titles are subjective, so they stay opt-in and manual renames win. */
tabAutoGenerateTitle: boolean
/** Sidebar/dashboard agent rows label with the conversation name (tab rename or
* agent-set title) instead of the last message; rows without a usable name
* keep the last-message label. Opt-in: the live prompt stays the default. */
agentRowsUseConversationName?: boolean
/** Why: pinned tabs can still be closed via keyboard/native-menu; this gates that behind a confirmation. Defaults on. */
confirmClosePinnedTab: boolean
/** When true, Orca requests local awake assertions while hook-reported agents are working. */