diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index e6a47533118..60724a27dcf 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -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() ?? '' 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 new file mode 100644 index 00000000000..11e1f1ea656 --- /dev/null +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts @@ -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 } +})) + +// 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 { + 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) + storeState.current = { settings: { agentRowsUseConversationName: true } } + expect(useAgentRowConversationName(agent)).toBeNull() + storeState.current = { + settings: { agentRowsUseConversationName: true, tabAutoGenerateTitle: true } + } + expect(useAgentRowConversationName(agent)).toBe('Fix intake flow') + }) +}) 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 new file mode 100644 index 00000000000..91e49ec1862 --- /dev/null +++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts @@ -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) +} diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 99718de46fe..09ee278c347 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -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({ + + @@ -1003,3 +1009,25 @@ export function AgentGeneratedTabTitlesSetting({ ) } + +export function AgentRowConversationNamesSetting({ + settings, + updateSettings +}: AgentsPaneProps): React.JSX.Element { + const enabled = settings.agentRowsUseConversationName === true + return ( +
+ + updateSettings({ + agentRowsUseConversationName: !enabled + }) + } + ariaLabel={getAgentRowConversationNamesTitle()} + /> +
+ ) +} 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 new file mode 100644 index 00000000000..927fae2fa29 --- /dev/null +++ b/src/renderer/src/components/settings/agent-row-conversation-name-copy.ts @@ -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' } + ]) +} diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index 5e378d732d1..4e6b952bea5 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -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(), diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index 7669abb32c2..b95765881b4 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -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() ?? '' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7d5d03e8570..b5e61e80de0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 886b36bb2bd..8559f7e0105 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d487281a670..9872a0136ef 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6888ce7c53c..660aab98523 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f9a298fc367..b65818ad8f5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/shared/agent-row-conversation-name.test.ts b/src/shared/agent-row-conversation-name.test.ts new file mode 100644 index 00000000000..c25071fd771 --- /dev/null +++ b/src/shared/agent-row-conversation-name.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { + getAgentRowConversationName, + type ConversationNameTab +} from './agent-row-conversation-name' + +function makeTab(overrides: Partial = {}): 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() + }) +}) diff --git a/src/shared/agent-row-conversation-name.ts b/src/shared/agent-row-conversation-name.ts new file mode 100644 index 00000000000..9835d59c220 --- /dev/null +++ b/src/shared/agent-row-conversation-name.ts @@ -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 = 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 + ) +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 41b290eb06f..f80373fcc93 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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/*. diff --git a/src/shared/types.ts b/src/shared/types.ts index 437f92d6535..3e0c903741c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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. */