Add opt-in generated agent tab titles (#3224)

* Add opt-in generated agent tab titles

* Preserve generated titles in remote tab sync

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-05-30 12:33:24 -07:00 committed by GitHub
parent 55a33c5b68
commit 8d67f9807c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 885 additions and 116 deletions

View File

@ -39,6 +39,7 @@ vi.mock('node:os', async () => {
function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
const appFontFamily = overrides.appFontFamily ?? 'Geist'
const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true
const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false
return {
workspaceDir: testState.fakeHomeDir,
nestWorkspaces: false,
@ -132,7 +133,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
enableGitHubAttribution: true,
...overrides,
appFontFamily,
agentStatusHooksEnabled
agentStatusHooksEnabled,
tabAutoGenerateTitle
}
}

View File

@ -31,6 +31,7 @@ function decodeEncodedWslBashCommand(command: string): string {
function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
const appFontFamily = overrides.appFontFamily ?? 'Geist'
const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true
const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false
return {
workspaceDir: testState.fakeHomeDir,
nestWorkspaces: false,
@ -124,7 +125,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
enableGitHubAttribution: true,
...overrides,
appFontFamily,
agentStatusHooksEnabled
agentStatusHooksEnabled,
tabAutoGenerateTitle
}
}

View File

@ -74,6 +74,7 @@ import type {
TabGroup,
TerminalTab
} from '../../../../shared/types'
import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution'
import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog'
import { FloatingTerminalResizeHandles } from './FloatingTerminalResizeHandles'
import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls'
@ -166,6 +167,7 @@ export function FloatingTerminalPanel({
const openFile = useAppStore((s) => s.openFile)
const browserDefaultUrl = useAppStore((s) => s.browserDefaultUrl)
const floatingTerminalCwd = useAppStore((s) => s.settings?.floatingTerminalCwd ?? '')
const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true)
const newTerminalShortcutKeys = useShortcutKeys('tab.newTerminal')
const newBrowserShortcutKeys = useShortcutKeys('tab.newBrowser')
const newMarkdownShortcutKeys = useShortcutKeys('tab.newMarkdown')
@ -246,24 +248,35 @@ export function FloatingTerminalPanel({
? activeTab.entityId
: null
const terminalTabById = useMemo(() => new Map(tabs.map((tab) => [tab.id, tab])), [tabs])
const terminalItems = useMemo(
const terminalItems = useMemo<(TerminalTab & { unifiedTabId: string })[]>(
() =>
groupTabs
.filter((tab) => tab.contentType === 'terminal')
.map((tab) => {
.flatMap((tab): (TerminalTab & { unifiedTabId: string })[] => {
const terminalTab = terminalTabById.get(tab.entityId)
return terminalTab
? {
...terminalTab,
unifiedTabId: tab.id,
title: tab.label,
customTitle: tab.customLabel ?? terminalTab.customTitle,
color: tab.color ?? terminalTab.color
}
: null
})
.filter((tab): tab is TerminalTab & { unifiedTabId: string } => tab !== null),
[groupTabs, terminalTabById]
if (!terminalTab) {
return []
}
return [
{
...terminalTab,
unifiedTabId: tab.id,
title: resolveUnifiedTabLabel(
{
...tab,
generatedLabel: tab.generatedLabel ?? terminalTab.generatedTitle
},
generatedTabTitlesEnabled,
tab.label
),
generatedTitle: terminalTab.generatedTitle ?? tab.generatedLabel ?? null,
customTitle: tab.customLabel ?? terminalTab.customTitle,
color: tab.color ?? terminalTab.color
}
]
}),
[generatedTabTitlesEnabled, groupTabs, terminalTabById]
)
const browserItems = useMemo(
() =>

View File

@ -6,11 +6,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings, TuiAgent } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { AGENT_GENERATED_TAB_TITLES_TITLE } from './agent-generated-tab-title-copy'
import { AGENT_STATUS_HOOKS_TITLE } from './agent-status-hooks-copy'
import { getAgentAwakeDescription } from './agent-awake-copy'
import { AgentAwakeSetting } from './AgentAwakeSetting'
import {
AgentAvailabilityControl,
AgentGeneratedTabTitlesSetting,
AgentStatusHooksSetting,
AgentsPane,
AGENTS_PANE_SEARCH_ENTRIES,
@ -201,6 +203,27 @@ describe('AgentsPane', () => {
})
})
it('toggles generated tab titles with the next value', () => {
const updateSettings = vi.fn()
const element = AgentGeneratedTabTitlesSetting({
settings: {
...getDefaultSettings('/tmp'),
tabAutoGenerateTitle: false
},
updateSettings
})
const generatedTitleSwitch = findSwitchRow(element, AGENT_GENERATED_TAB_TITLES_TITLE)
expect(generatedTitleSwitch.props.checked).toBe(false)
const onChange = generatedTitleSwitch.props.onChange as () => void
onChange()
expect(updateSettings).toHaveBeenCalledWith({
tabAutoGenerateTitle: true
})
})
it('includes awake and sleep search metadata for the setting', () => {
expect(matchesSettingsSearch('awake', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
expect(matchesSettingsSearch('sleep', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
@ -213,6 +236,11 @@ describe('AgentsPane', () => {
expect(matchesSettingsSearch('codex', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
})
it('includes generated title search metadata', () => {
expect(matchesSettingsSearch('generated title', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
expect(matchesSettingsSearch('stable session', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
})
it('includes enable and hide search metadata for agent visibility', () => {
expect(matchesSettingsSearch('disable', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
expect(matchesSettingsSearch('hide', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)

View File

@ -11,6 +11,10 @@ import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { cn } from '@/lib/utils'
import { AgentAwakeSetting } from './AgentAwakeSetting'
import {
AGENT_GENERATED_TAB_TITLES_DESCRIPTION,
AGENT_GENERATED_TAB_TITLES_TITLE
} from './agent-generated-tab-title-copy'
import { AgentLocationSetting } from './AgentLocationSetting'
import { AGENT_STATUS_HOOKS_DESCRIPTION, AGENT_STATUS_HOOKS_TITLE } from './agent-status-hooks-copy'
import {
@ -470,6 +474,8 @@ export function AgentsPane({
<AgentStatusHooksSetting settings={settings} updateSettings={updateSettings} />
<AgentGeneratedTabTitlesSetting settings={settings} updateSettings={updateSettings} />
<AgentAwakeSetting settings={settings} updateSettings={updateSettings} />
{detectedAgents.length > 0 && (
@ -580,3 +586,25 @@ export function AgentStatusHooksSetting({
</section>
)
}
export function AgentGeneratedTabTitlesSetting({
settings,
updateSettings
}: AgentsPaneProps): React.JSX.Element {
const enabled = settings.tabAutoGenerateTitle === true
return (
<section className="space-y-3">
<SettingsSwitchRow
label={AGENT_GENERATED_TAB_TITLES_TITLE}
description={AGENT_GENERATED_TAB_TITLES_DESCRIPTION}
checked={enabled}
onChange={() =>
updateSettings({
tabAutoGenerateTitle: !enabled
})
}
ariaLabel={AGENT_GENERATED_TAB_TITLES_TITLE}
/>
</section>
)
}

View File

@ -0,0 +1,19 @@
export const AGENT_GENERATED_TAB_TITLES_TITLE = 'Auto-generate tab titles'
export const AGENT_GENERATED_TAB_TITLES_DESCRIPTION =
'Derive short stable tab names from the first known agent prompt. Manual renames always win.'
export const AGENT_GENERATED_TAB_TITLES_SEARCH_KEYWORDS = [
'agent',
'tab',
'title',
'generated title',
'name',
'generated',
'auto',
'prompt',
'rename',
'stable',
'session',
'stable session'
]

View File

@ -4,6 +4,11 @@ import {
getAgentAwakeDescription,
getAgentAwakeSearchKeywords
} from './agent-awake-copy'
import {
AGENT_GENERATED_TAB_TITLES_DESCRIPTION,
AGENT_GENERATED_TAB_TITLES_SEARCH_KEYWORDS,
AGENT_GENERATED_TAB_TITLES_TITLE
} from './agent-generated-tab-title-copy'
import {
AGENT_STATUS_HOOKS_DESCRIPTION,
AGENT_STATUS_HOOKS_SEARCH_KEYWORDS,
@ -68,6 +73,11 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
description: AGENT_STATUS_HOOKS_DESCRIPTION,
keywords: AGENT_STATUS_HOOKS_SEARCH_KEYWORDS
},
{
title: AGENT_GENERATED_TAB_TITLES_TITLE,
description: AGENT_GENERATED_TAB_TITLES_DESCRIPTION,
keywords: AGENT_GENERATED_TAB_TITLES_SEARCH_KEYWORDS
},
{
title: AGENT_AWAKE_TITLE,
description: getAgentAwakeDescription(),

View File

@ -13,6 +13,7 @@ import type {
TuiAgent,
WorkspaceVisibleTabType
} from '../../../../shared/types'
import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution'
import { useAppStore } from '../../store'
import { buildStatusMap } from '../right-sidebar/status-display'
import type { OpenFile } from '../../store/slices/editor'
@ -115,9 +116,9 @@ type TabItem =
data: BrowserTabState & { tabId?: string }
}
function getTabDragLabel(item: TabItem): string {
function getTabDragLabel(item: TabItem, generatedTitlesEnabled: boolean): string {
if (item.type === 'terminal') {
return item.data.customTitle ?? item.data.title
return resolveTerminalTabTitle(item.data, generatedTitlesEnabled, item.data.title)
}
if (item.type === 'browser') {
return getBrowserTabLabel(item.data)
@ -165,6 +166,7 @@ function TabBarInner({
const newTerminalShortcut = useShortcutLabel('tab.newTerminal')
const newBrowserShortcut = useShortcutLabel('tab.newBrowser')
const newFileShortcut = useShortcutLabel('tab.newMarkdown')
const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true)
const gitStatusEntries = useAppStore(
(s) => s.gitStatusByWorktree[worktreeId] ?? EMPTY_GIT_STATUS_ENTRIES
)
@ -551,15 +553,23 @@ function TabBarInner({
unifiedTabId: item.unifiedTabId,
visibleTabId: item.id,
tabType: item.type,
label: getTabDragLabel(item),
label: getTabDragLabel(item, generatedTabTitlesEnabled),
iconPath: item.type === 'editor' ? item.data.filePath : undefined,
color: item.type === 'terminal' ? (item.data.color ?? null) : null
}
if (item.type === 'terminal') {
const terminalTab = {
...item.data,
title: resolveTerminalTabTitle(
item.data,
generatedTabTitlesEnabled,
item.data.title
)
}
return (
<SortableTab
key={item.id}
tab={item.data}
tab={terminalTab}
tabCount={orderedItems.length}
hasTabsToRight={index < orderedItems.length - 1}
isActive={activeTabType === 'terminal' && item.id === activeTabId}

View File

@ -1,4 +1,5 @@
import type { CtrlTabOrderMode, Tab, TabContentType, TabGroup } from '../../../../shared/types'
import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution'
import type { AppState } from '../../store/types'
import { sanitizeRecentTabIds } from '../../store/slices/tab-group-state'
import { getActiveTabNavOrder, type VisibleTabRef } from './group-tab-order'
@ -29,7 +30,7 @@ type RecentTabSwitchingState = Pick<
| 'tabBarOrderByWorktree'
| 'tabsByWorktree'
| 'unifiedTabsByWorktree'
>
> & { settings?: AppState['settings'] }
export function normalizeCtrlTabOrderMode(
value: CtrlTabOrderMode | null | undefined
@ -84,20 +85,25 @@ function getActiveVisibleTabKey(
return activeEntry ? getVisibleTabKey(activeEntry) : null
}
function getTabLabel(tab: Tab | undefined, fallback: string): string {
return tab?.customLabel?.trim() || tab?.label?.trim() || fallback
function getTabLabel(
tab: Tab | undefined,
generatedTitlesEnabled: boolean,
fallback: string
): string {
return resolveUnifiedTabLabel(tab, generatedTitlesEnabled, fallback)
}
function toSwitcherItem(
entry: VisibleTabRef,
tabById: ReadonlyMap<string, Tab>,
dirtyFileIds: ReadonlySet<string>
dirtyFileIds: ReadonlySet<string>,
generatedTitlesEnabled: boolean
): RecentTabSwitcherItem {
const backingTab = entry.tabId ? tabById.get(entry.tabId) : undefined
return {
...entry,
key: getVisibleTabKey(entry),
label: getTabLabel(backingTab, entry.id),
label: getTabLabel(backingTab, generatedTitlesEnabled, entry.id),
contentType: backingTab?.contentType ?? (entry.type === 'editor' ? 'editor' : entry.type),
isDirty: entry.type === 'editor' && dirtyFileIds.has(entry.id)
}
@ -161,9 +167,10 @@ export function buildRecentTabSwitcherModel(
.filter((file) => file.worktreeId === worktreeId && file.isDirty)
.map((file) => file.id)
)
const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true
const itemByKey = new Map(
visibleEntries.map((entry) => {
const item = toSwitcherItem(entry, tabById, dirtyFileIds)
const item = toSwitcherItem(entry, tabById, dirtyFileIds, generatedTitlesEnabled)
return [item.key, item] as const
})
)

View File

@ -10,6 +10,7 @@ import type {
TabGroup,
TerminalTab
} from '../../../../shared/types'
import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution'
import { useAppStore } from '../../store'
import { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup'
import { requestEditorFileClose } from '../editor/editor-autosave'
@ -53,7 +54,8 @@ export function useTabGroupWorkspaceModel({
terminalTabs: state.tabsByWorktree[worktreeId] ?? EMPTY_TERMINAL_TABS,
openFiles: state.openFiles,
browserTabs: state.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS,
expandedPaneByTabId: state.expandedPaneByTabId
expandedPaneByTabId: state.expandedPaneByTabId,
generatedTabTitlesEnabled: state.settings?.tabAutoGenerateTitle === true
}))
)
@ -114,8 +116,16 @@ export function useTabGroupWorkspaceModel({
unifiedTabId: item.id,
ptyId: terminalTab?.ptyId ?? null,
worktreeId,
title: item.label,
title: resolveUnifiedTabLabel(
{
...item,
generatedLabel: item.generatedLabel ?? terminalTab?.generatedTitle
},
worktreeState.generatedTabTitlesEnabled,
item.label
),
defaultTitle: terminalTab?.defaultTitle,
generatedTitle: terminalTab?.generatedTitle ?? item.generatedLabel ?? null,
customTitle: item.customLabel ?? terminalTab?.customTitle ?? null,
color: item.color ?? terminalTab?.color ?? null,
sortOrder: item.sortOrder,
@ -130,7 +140,7 @@ export function useTabGroupWorkspaceModel({
pendingActivationSpawn: terminalTab?.pendingActivationSpawn
}
}),
[groupTabs, terminalTabById, worktreeId]
[groupTabs, terminalTabById, worktreeId, worktreeState.generatedTabTitlesEnabled]
)
const editorItems = useMemo<GroupEditorItem[]>(

View File

@ -126,6 +126,68 @@ describe('getRuntimeMobileSessionSyncKey', () => {
)
})
it('changes when generated terminal title metadata changes', () => {
const shared = makeSharedOverrides()
const base = makeState({
...shared,
tabsByWorktree: {
'wt-1': [{ id: 'term-1', title: 'Codex working', customTitle: null, ptyId: 'pty-1' }]
} as unknown as AppState['tabsByWorktree']
})
const before = getRuntimeMobileSessionSyncKey(base)
const after = getRuntimeMobileSessionSyncKey(
makeState({
...base,
tabsByWorktree: {
'wt-1': [
{
id: 'term-1',
title: 'Codex working',
generatedTitle: 'Fix remote tabs',
customTitle: null,
ptyId: 'pty-1'
}
]
} as unknown as AppState['tabsByWorktree']
}),
base,
before
)
expect(runtimeMobileSessionSyncKeysEqual(before, after)).toBe(false)
})
it('changes when generated terminal titles are toggled', () => {
const shared = makeSharedOverrides()
const tabsByWorktree = {
'wt-1': [
{
id: 'term-1',
title: 'Codex working',
generatedTitle: 'Fix remote tabs',
customTitle: null,
ptyId: 'pty-1'
}
]
} as unknown as AppState['tabsByWorktree']
const base = makeState({
...shared,
tabsByWorktree,
settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: false }
})
const before = getRuntimeMobileSessionSyncKey(base)
const after = getRuntimeMobileSessionSyncKey(
makeState({
...base,
settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: true }
}),
base,
before
)
expect(runtimeMobileSessionSyncKeysEqual(before, after)).toBe(false)
})
it('changes when terminal split-pane layout changes', () => {
const base = makeState({
terminalLayoutsByTabId: {
@ -611,6 +673,47 @@ describe('buildMobileSessionTabSnapshots', () => {
])
})
it('publishes generated terminal titles to mobile snapshots only when enabled', () => {
const leafId = '11111111-1111-4111-8111-111111111111'
const base = makeState({
settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: false },
tabBarOrderByWorktree: { 'wt-1': ['term-1'] },
tabsByWorktree: {
'wt-1': [
{
id: 'term-1',
title: 'Codex working',
generatedTitle: 'Fix remote tabs',
customTitle: null,
ptyId: 'pty-1'
}
]
} as unknown as AppState['tabsByWorktree'],
terminalLayoutsByTabId: {
'term-1': {
root: { type: 'leaf', leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: 'pty-1' }
}
} as AppState['terminalLayoutsByTabId']
})
expect(buildMobileSessionTabSnapshots(base)[0]?.tabs[0]).toMatchObject({
type: 'terminal',
title: 'Codex working'
})
expect(
buildMobileSessionTabSnapshots({
...base,
settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: true }
})[0]?.tabs[0]
).toMatchObject({
type: 'terminal',
title: 'Fix remote tabs'
})
})
it('publishes the desktop-resolved terminal theme for mobile terminal tabs', () => {
const leafId = '11111111-1111-4111-8111-111111111111'
const state = makeState({

View File

@ -27,8 +27,10 @@ import type {
TabGroup,
TabGroupLayoutNode,
TerminalLayoutSnapshot,
TerminalPaneLayoutNode
TerminalPaneLayoutNode,
TerminalTab
} from '../../../shared/types'
import { resolveTerminalTabTitle } from '../../../shared/tab-title-resolution'
import {
getActiveTabNavOrder,
getGroupVisibleTabOrder,
@ -184,6 +186,7 @@ export type RuntimeMobileSessionSyncKey = {
activeBrowserTabIdByWorktree: AppState['activeBrowserTabIdByWorktree']
agentStatusEpoch: number
agentStatusProjection: string
generatedTabTitlesEnabled: boolean
systemPrefersDark: boolean | null
terminalThemeProjection: string
// Why: these projections still need value-level inspection because the
@ -280,6 +283,7 @@ export function getRuntimeMobileSessionSyncKey(
canReusePrevious && agentStatusByPaneKey === previousAgentStatusByPaneKey
? previousKey.agentStatusProjection
: buildRuntimeMobileAgentStatusProjection(agentStatusByPaneKey),
generatedTabTitlesEnabled: state.settings?.tabAutoGenerateTitle === true,
systemPrefersDark: terminalThemeSystemPrefersDark,
terminalThemeProjection:
canReusePrevious &&
@ -342,6 +346,7 @@ function buildRuntimeMobileTabsProjection(tabsByWorktree: AppState['tabsByWorktr
tabs.map((tab) => ({
id: tab.id,
title: tab.title,
generatedTitle: tab.generatedTitle,
customTitle: tab.customTitle,
launchAgent: tab.launchAgent
}))
@ -359,6 +364,14 @@ function buildRuntimeMobileTabsProjection(tabsByWorktree: AppState['tabsByWorktr
return cachedTabsProjection.projection
}
function resolveRuntimeTerminalTitle(
tab: Pick<TerminalTab, 'customTitle' | 'generatedTitle' | 'title'>,
generatedTitlesEnabled: boolean,
liveTitle = tab.title
): string {
return resolveTerminalTabTitle({ ...tab, title: liveTitle }, generatedTitlesEnabled, liveTitle)
}
function buildRuntimeMobileOpenFilesProjection(openFiles: AppState['openFiles']): string {
return JSON.stringify(
openFiles.map((file) => ({
@ -465,6 +478,7 @@ export function runtimeMobileSessionSyncKeysEqual(
a.activeBrowserTabIdByWorktree === b.activeBrowserTabIdByWorktree &&
a.agentStatusEpoch === b.agentStatusEpoch &&
a.agentStatusProjection === b.agentStatusProjection &&
a.generatedTabTitlesEnabled === b.generatedTabTitlesEnabled &&
a.systemPrefersDark === b.systemPrefersDark &&
a.terminalThemeProjection === b.terminalThemeProjection &&
a.tabsProjection === b.tabsProjection &&
@ -492,6 +506,7 @@ async function syncRuntimeGraph(): Promise<void> {
.flat()
.map((tab) => [tab.id, tab])
)
const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true
const graph: RuntimeSyncWindowGraph = {
tabs: [],
leaves: [],
@ -516,7 +531,7 @@ async function syncRuntimeGraph(): Promise<void> {
graph.tabs.push({
tabId,
worktreeId: registeredTab.worktreeId,
title: tab.customTitle ?? tab.title,
title: resolveRuntimeTerminalTitle(tab, generatedTitlesEnabled),
activeLeafId: activePaneId === null ? null : (manager?.getLeafId(activePaneId) ?? null),
layout: serializePaneTree(root)
})
@ -544,7 +559,11 @@ async function syncRuntimeGraph(): Promise<void> {
paneRuntimeId: pane.id,
ptyId,
paneTitle: paneTitles[pane.id] ?? null,
title: state.runtimePaneTitlesByTabId[tabId]?.[pane.id] ?? tab.customTitle ?? tab.title
title: resolveRuntimeTerminalTitle(
tab,
generatedTitlesEnabled,
state.runtimePaneTitlesByTabId[tabId]?.[pane.id] ?? tab.title
)
})
}
}
@ -965,6 +984,7 @@ function buildMobileTerminalSurfaceTabs(
? (manager?.getLeafId(liveActivePaneId) ?? null)
: (state.terminalLayoutsByTabId[terminal.id]?.activeLeafId ?? leafIds[0] ?? null)
const paneTitles = state.runtimePaneTitlesByTabId[terminal.id] ?? {}
const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true
const savedLayout = state.terminalLayoutsByTabId[terminal.id]
const sanitizedSavedLayout = savedLayout
? sanitizeTerminalLayoutPaneTitles(savedLayout, terminal)
@ -1003,7 +1023,11 @@ function buildMobileTerminalSurfaceTabs(
return {
type: 'terminal' as const,
id: mobileTerminalSurfaceId(terminal.id, leafId),
title: paneTitle ?? terminal.customTitle ?? terminal.title ?? 'Terminal',
title: resolveRuntimeTerminalTitle(
terminal,
generatedTitlesEnabled,
paneTitle ?? terminal.title ?? 'Terminal'
),
parentTabId: terminal.id,
leafId,
ptyId,

View File

@ -490,6 +490,7 @@ function buildTerminalUnifiedTab(tab: TerminalTab, groupId: string): Tab {
worktreeId: tab.worktreeId,
contentType: 'terminal',
label: tab.title,
...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}),
customLabel: tab.customTitle,
color: tab.color,
sortOrder: tab.sortOrder,
@ -1126,6 +1127,7 @@ function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean {
a.worktreeId === b.worktreeId &&
a.title === b.title &&
a.defaultTitle === b.defaultTitle &&
a.generatedTitle === b.generatedTitle &&
a.customTitle === b.customTitle &&
a.color === b.color &&
a.sortOrder === b.sortOrder &&

View File

@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution'
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
const WORKTREE_ID = 'repo1::/path/wt1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
function seedWorktree(store: ReturnType<typeof createTestStore>, enabled: boolean): string {
seedStore(store, {
settings: {
...getDefaultSettings('/tmp'),
tabAutoGenerateTitle: enabled
},
worktreesByRepo: {
repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })]
}
})
return store.getState().createTab(WORKTREE_ID).id
}
describe('generated agent tab titles', () => {
afterEach(() => {
vi.useRealTimers()
})
it('stays disabled by default when agent prompts arrive', () => {
vi.useFakeTimers()
const store = createTestStore()
const tabId = seedWorktree(store, false)
store.getState().setAgentStatus(makePaneKey(tabId, LEAF_ID), {
state: 'working',
prompt: 'Refactor the auth middleware',
agentType: 'codex'
})
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBeUndefined()
expect(store.getState().unifiedTabsByWorktree[WORKTREE_ID][0].generatedLabel).toBeUndefined()
})
it('generates one stable title from the first known agent prompt when enabled', () => {
vi.useFakeTimers()
const store = createTestStore()
const tabId = seedWorktree(store, true)
store.getState().setAgentStatus(makePaneKey(tabId, LEAF_ID), {
state: 'working',
prompt: 'Can you please refactor the auth middleware to use JWT tokens?',
agentType: 'codex'
})
store.getState().setAgentStatus(makePaneKey(tabId, LEAF_ID), {
state: 'working',
prompt: 'Replace this with a later task name',
agentType: 'codex'
})
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe(
'Refactor the auth middleware to use JWT'
)
expect(store.getState().unifiedTabsByWorktree[WORKTREE_ID][0].generatedLabel).toBe(
'Refactor the auth middleware to use JWT'
)
})
it('keeps manual rename precedence over generated and live titles', () => {
vi.useFakeTimers()
const store = createTestStore()
const tabId = seedWorktree(store, true)
store.getState().setAgentStatus(makePaneKey(tabId, LEAF_ID), {
state: 'working',
prompt: 'Fix the flaky status tests',
agentType: 'claude'
})
store.getState().updateTabTitle(tabId, 'Claude working')
store.getState().setTabCustomTitle(tabId, 'Status tests')
const tab = store.getState().tabsByWorktree[WORKTREE_ID][0]
expect(resolveTerminalTabTitle(tab, true)).toBe('Status tests')
expect(tab.generatedTitle).toBe('Fix the flaky status tests')
})
})

View File

@ -359,6 +359,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
sortRelevantChange || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch
}
})
get().setGeneratedTabTitleFromAgentPrompt(paneKey, payload.prompt)
// Why: schedule after set completes so the timer reads the updated map.
// queueMicrotask avoids re-entry into the zustand store during set.
queueMicrotask(() => freshness.schedule())

View File

@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import type { WorkspaceSessionState } from '../../../../shared/types'
import { buildHydratedTabState } from './tabs-hydration'
function makeBaseSession(): WorkspaceSessionState {
return {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {}
}
}
describe('buildHydratedTabState generated terminal titles', () => {
it('hydrates generated terminal labels from persisted terminal metadata', () => {
const session: WorkspaceSessionState = {
...makeBaseSession(),
tabsByWorktree: {
w1: [
{
id: 't1',
ptyId: null,
worktreeId: 'w1',
title: 'Codex working',
generatedTitle: 'Fix flaky tests',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
unifiedTabs: {
w1: [
{
id: 't1',
entityId: 't1',
groupId: 'g1',
worktreeId: 'w1',
contentType: 'terminal',
label: 'Codex working',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
tabGroups: {
w1: [{ id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }]
}
}
const result = buildHydratedTabState(session, new Set(['w1']))
expect(result.unifiedTabsByWorktree.w1[0].generatedLabel).toBe('Fix flaky tests')
})
it('converts legacy generated terminal titles to unified generated labels', () => {
const session: WorkspaceSessionState = {
...makeBaseSession(),
tabsByWorktree: {
w1: [
{
id: 'tt1',
ptyId: null,
worktreeId: 'w1',
title: 'bash',
generatedTitle: 'Persisted agent title',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 100
}
]
}
}
const result = buildHydratedTabState(session, new Set(['w1']))
expect(result.unifiedTabsByWorktree.w1[0].generatedLabel).toBe('Persisted agent title')
})
})

View File

@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import type { WorkspaceSessionState } from '../../../../shared/types'
import { buildHydratedTabState } from './tabs-hydration'
function makeBaseSession(): WorkspaceSessionState {
return {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {}
}
}
describe('buildHydratedTabState group validation', () => {
it('filters out invalid worktree IDs', () => {
const session: WorkspaceSessionState = {
...makeBaseSession(),
unifiedTabs: {
w1: [
{
id: 't1',
entityId: 't1',
groupId: 'g1',
worktreeId: 'w1',
contentType: 'terminal',
label: 'Term',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
],
w_gone: [
{
id: 't2',
entityId: 't2',
groupId: 'g2',
worktreeId: 'w_gone',
contentType: 'terminal',
label: 'Gone',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
tabGroups: {
w1: [{ id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }],
w_gone: [{ id: 'g2', worktreeId: 'w_gone', activeTabId: 't2', tabOrder: ['t2'] }]
}
}
const result = buildHydratedTabState(session, new Set(['w1']))
expect(result.unifiedTabsByWorktree.w1).toHaveLength(1)
expect(result.unifiedTabsByWorktree.w_gone).toBeUndefined()
})
it('validates group references against hydrated tabs', () => {
const session: WorkspaceSessionState = {
...makeBaseSession(),
unifiedTabs: {
w1: [
{
id: 't1',
entityId: 't1',
groupId: 'g1',
worktreeId: 'w1',
contentType: 'terminal',
label: 'Term',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
tabGroups: {
w1: [
{
id: 'g1',
worktreeId: 'w1',
activeTabId: 'deleted-tab',
tabOrder: ['deleted-tab', 't1']
}
]
}
}
const result = buildHydratedTabState(session, new Set(['w1']))
const group = result.groupsByWorktree.w1[0]
expect(group.activeTabId).toBeNull()
expect(group.tabOrder).toEqual(['t1'])
})
})

View File

@ -57,87 +57,6 @@ describe('buildHydratedTabState unified format', () => {
expect(result.activeGroupIdByWorktree.w1).toBe('g1')
})
it('filters out invalid worktree IDs', () => {
const session: WorkspaceSessionState = {
...makeBaseSession(),
unifiedTabs: {
w1: [
{
id: 't1',
entityId: 't1',
groupId: 'g1',
worktreeId: 'w1',
contentType: 'terminal',
label: 'Term',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
],
w_gone: [
{
id: 't2',
entityId: 't2',
groupId: 'g2',
worktreeId: 'w_gone',
contentType: 'terminal',
label: 'Gone',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
tabGroups: {
w1: [{ id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }],
w_gone: [{ id: 'g2', worktreeId: 'w_gone', activeTabId: 't2', tabOrder: ['t2'] }]
}
}
const result = buildHydratedTabState(session, new Set(['w1']))
expect(result.unifiedTabsByWorktree.w1).toHaveLength(1)
expect(result.unifiedTabsByWorktree.w_gone).toBeUndefined()
})
it('validates group references against hydrated tabs', () => {
const session: WorkspaceSessionState = {
...makeBaseSession(),
unifiedTabs: {
w1: [
{
id: 't1',
entityId: 't1',
groupId: 'g1',
worktreeId: 'w1',
contentType: 'terminal',
label: 'Term',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
tabGroups: {
w1: [
{
id: 'g1',
worktreeId: 'w1',
activeTabId: 'deleted-tab',
tabOrder: ['deleted-tab', 't1']
}
]
}
}
const result = buildHydratedTabState(session, new Set(['w1']))
const group = result.groupsByWorktree.w1[0]
expect(group.activeTabId).toBeNull()
expect(group.tabOrder).toEqual(['t1'])
})
it('collapses groups and layout when transient tabs are dropped during hydration', () => {
const session: WorkspaceSessionState = {
...makeBaseSession(),

View File

@ -60,11 +60,23 @@ function hydrateUnifiedFormat(
continue
}
const persistedEditFileIds = persistedEditFileIdsByWorktree[worktreeId] ?? new Set<string>()
const generatedTitleByTerminalId = new Map(
(session.tabsByWorktree[worktreeId] ?? [])
.filter((tab) => tab.generatedTitle?.trim())
.map((tab) => [tab.id, tab.generatedTitle!.trim()])
)
tabsByWorktree[worktreeId] = [...tabs]
.map((tab) => ({
...tab,
entityId: tab.entityId ?? tab.id
}))
.map((tab) => {
if (tab.contentType !== 'terminal' || tab.generatedLabel?.trim()) {
return tab
}
const generatedLabel = generatedTitleByTerminalId.get(tab.entityId)
return generatedLabel ? { ...tab, generatedLabel } : tab
})
.filter((tab) => {
if (tab.contentType === 'terminal') {
// Why: old web-client sessions could persist host surface ids
@ -184,6 +196,7 @@ function hydrateLegacyFormat(
worktreeId,
contentType: 'terminal',
label: tt.title,
...(tt.generatedTitle?.trim() ? { generatedLabel: tt.generatedTitle.trim() } : {}),
customLabel: tt.customTitle,
color: tt.color,
sortOrder: tt.sortOrder,

View File

@ -43,7 +43,14 @@ export type TabsSlice = {
init?: Partial<
Pick<
Tab,
'id' | 'entityId' | 'label' | 'customLabel' | 'color' | 'isPreview' | 'isPinned'
| 'id'
| 'entityId'
| 'label'
| 'generatedLabel'
| 'customLabel'
| 'color'
| 'isPreview'
| 'isPinned'
> & {
targetGroupId: string
activate: boolean
@ -104,7 +111,12 @@ export type TabsSlice = {
copyUnifiedTabToGroup: (
tabId: string,
targetGroupId: string,
init?: Partial<Pick<Tab, 'id' | 'entityId' | 'label' | 'customLabel' | 'color' | 'isPinned'>>
init?: Partial<
Pick<
Tab,
'id' | 'entityId' | 'label' | 'generatedLabel' | 'customLabel' | 'color' | 'isPinned'
>
>
) => Tab | null
mergeGroupIntoSibling: (worktreeId: string, groupId: string) => string | null
setTabGroupSplitRatio: (worktreeId: string, nodePath: string, ratio: number) => void
@ -438,6 +450,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
contentType,
label:
init?.label ?? (contentType === 'terminal' ? `Terminal ${existingTabs.length + 1}` : id),
...(init?.generatedLabel !== undefined ? { generatedLabel: init.generatedLabel } : {}),
customLabel: init?.customLabel ?? null,
color: init?.color ?? null,
sortOrder: nextOrder.length,
@ -1274,6 +1287,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
return get().createUnifiedTab(worktreeId, tab.contentType, {
entityId: init?.entityId ?? tab.entityId,
label: init?.label ?? tab.label,
generatedLabel: init?.generatedLabel ?? tab.generatedLabel,
customLabel: init?.customLabel ?? tab.customLabel,
color: init?.color ?? tab.color,
isPinned: init?.isPinned ?? tab.isPinned,
@ -1379,6 +1393,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
worktreeId,
contentType: 'terminal' as const,
label: tab.title,
...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}),
customLabel: tab.customTitle,
color: tab.color,
sortOrder: tab.sortOrder,

View File

@ -11,6 +11,8 @@ import type {
WorkspaceSessionState
} from '../../../../shared/types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { deriveGeneratedTabTitle } from '../../../../shared/agent-tab-title'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id'
import { isWslUncPath } from '../../../../shared/wsl-paths'
@ -121,6 +123,26 @@ function updateUnifiedTerminalLabel(
return unifiedTabs.map((entry, index) => (index === unifiedIndex ? { ...entry, label } : entry))
}
function updateUnifiedTerminalGeneratedLabel(
unifiedTabs: Tab[],
terminalTabId: string,
generatedLabel: string
): Tab[] | null {
const unifiedIndex = unifiedTabs.findIndex(
(entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId
)
if (unifiedIndex === -1 || unifiedTabs[unifiedIndex]?.generatedLabel === generatedLabel) {
return null
}
return unifiedTabs.map((entry, index) =>
index === unifiedIndex ? { ...entry, generatedLabel } : entry
)
}
function getTabIdFromPaneKey(paneKey: string): string | null {
return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null
}
function isWindowsRendererRuntime(): boolean {
return typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows')
}
@ -299,6 +321,7 @@ export type TerminalSlice = {
setActiveTab: (tabId: string) => void
setActiveTabForWorktree: (worktreeId: string, tabId: string) => void
updateTabTitle: (tabId: string, title: string) => void
setGeneratedTabTitleFromAgentPrompt: (paneKey: string, prompt: string) => void
clearTabLaunchAgent: (tabId: string) => void
setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void
clearRuntimePaneTitle: (tabId: string, paneId: number) => void
@ -1029,6 +1052,54 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
},
setGeneratedTabTitleFromAgentPrompt: (paneKey, prompt) => {
const tabId = getTabIdFromPaneKey(paneKey)
if (!tabId || !prompt.trim()) {
return
}
set((s) => {
if (s.settings?.tabAutoGenerateTitle !== true) {
return s
}
const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId)
if (!ownerWorktreeId) {
return s
}
const tabs = s.tabsByWorktree[ownerWorktreeId] ?? []
const tabIndex = tabs.findIndex((tab) => tab.id === tabId)
const currentTab = tabs[tabIndex]
if (!currentTab || currentTab.customTitle?.trim() || currentTab.generatedTitle?.trim()) {
return s
}
const generatedTitle = deriveGeneratedTabTitle(prompt)
if (!generatedTitle) {
return s
}
const ownerTabs = tabs.map((tab) => (tab.id === tabId ? { ...tab, generatedTitle } : tab))
const currentUnifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? []
const unifiedTabsWithGeneratedLabel = updateUnifiedTerminalGeneratedLabel(
currentUnifiedTabs,
tabId,
generatedTitle
)
scheduleRuntimeGraphSync()
return {
tabsByWorktree: {
...s.tabsByWorktree,
[ownerWorktreeId]: ownerTabs
},
...(unifiedTabsWithGeneratedLabel
? {
unifiedTabsByWorktree: {
...s.unifiedTabsByWorktree,
[ownerWorktreeId]: unifiedTabsWithGeneratedLabel
}
}
: {})
}
})
},
clearTabLaunchAgent: (tabId) => {
set((s) => {
const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId)

View File

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { deriveGeneratedTabTitle, GENERATED_TAB_TITLE_MAX_LENGTH } from './agent-tab-title'
describe('deriveGeneratedTabTitle', () => {
it('derives a short title from the first useful prompt clause', () => {
expect(
deriveGeneratedTabTitle('Can you please refactor the auth middleware to use JWT tokens?')
).toBe('Refactor the auth middleware to use JWT')
})
it('strips markup, links, emoji, and punctuation from generated titles', () => {
expect(
deriveGeneratedTabTitle('Please fix `src/auth.ts`!!! https://example.com 🔥 then add tests')
).toBe('Fix src auth')
})
it('keeps useful text after common issue prefixes', () => {
expect(deriveGeneratedTabTitle('Issue #2056: Opt-in generated tab titles for agents')).toBe(
'Opt in generated tab titles for agents'
)
})
it('bounds titles to the maximum length without adding punctuation', () => {
const title = deriveGeneratedTabTitle(
'I want to replace the terminal reconnection hydration flow with a safer retry path'
)
expect(title).toBeTruthy()
expect(title!.length).toBeLessThanOrEqual(GENERATED_TAB_TITLE_MAX_LENGTH)
expect(title).toMatch(/^[\p{L}\p{N}\s]+$/u)
})
it('returns null when the prompt has no useful title text', () => {
expect(deriveGeneratedTabTitle('please!!!')).toBeNull()
})
})

View File

@ -0,0 +1,69 @@
export const GENERATED_TAB_TITLE_MAX_LENGTH = 40
const LEADING_FILLER_PATTERNS: RegExp[] = [
/^(?:can|could|would)\s+you(?:\s+please)?\s+/i,
/^please(?:\s+|$)/i,
/^i\s+(?:want|need)\s+(?:you\s+)?to\s+/i,
/^help\s+me(?:\s+to)?\s+/i,
/^help\s+/i,
/^let'?s\s+/i,
/^we\s+need\s+to\s+/i,
/^need\s+to\s+/i
]
function capitalizeFirstLetter(value: string): string {
return value.replace(/\p{L}/u, (letter) => letter.toLocaleUpperCase())
}
function truncateAtWordBoundary(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value
}
const rawSlice = value.slice(0, maxLength)
const sliced = rawSlice.trim()
if (sliced.length < rawSlice.length) {
return sliced
}
const lastSpace = sliced.lastIndexOf(' ')
if (lastSpace >= Math.floor(maxLength * 0.55)) {
return sliced.slice(0, lastSpace).trim()
}
return sliced
}
export function deriveGeneratedTabTitle(prompt: string): string | null {
const firstClause = prompt
.trim()
.replace(/[`*_~#>[\]{}()]/g, ' ')
.replace(/^(?:issue|task|bug|feature|pr)\s*(?:#?\d+)?\s*[:-]\s*/i, '')
.replace(/\bhttps?:\/\/\S+/gi, ' ')
.split(/[.!?;\n\r\u2028\u2029]/u)[0]
?.trim()
if (!firstClause) {
return null
}
let candidate = firstClause
for (let i = 0; i < 3; i += 1) {
const before = candidate
for (const pattern of LEADING_FILLER_PATTERNS) {
candidate = candidate.replace(pattern, '')
}
candidate = candidate.trim()
if (candidate === before.trim()) {
break
}
}
candidate = candidate
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!candidate) {
return null
}
return truncateAtWordBoundary(capitalizeFirstLetter(candidate), GENERATED_TAB_TITLE_MAX_LENGTH)
}

View File

@ -264,6 +264,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
agentStatusHooksEnabled: true,
tabAutoGenerateTitle: false,
keepComputerAwakeWhileAgentsRun: false,
// Why: 'auto' runs a layout-aware probe at boot (see
// src/renderer/src/lib/keyboard-layout/*) that picks 'true' for US and

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { resolveTerminalTabTitle, resolveUnifiedTabLabel } from './tab-title-resolution'
describe('tab title resolution', () => {
it('uses live terminal titles when generated titles are disabled', () => {
expect(
resolveTerminalTabTitle(
{ customTitle: null, generatedTitle: 'Refactor auth', title: 'Claude working' },
false
)
).toBe('Claude working')
})
it('places generated titles between manual and live titles when enabled', () => {
expect(
resolveTerminalTabTitle(
{ customTitle: null, generatedTitle: 'Refactor auth', title: 'Claude working' },
true
)
).toBe('Refactor auth')
expect(
resolveTerminalTabTitle(
{ customTitle: 'Payments', generatedTitle: 'Refactor auth', title: 'Claude working' },
true
)
).toBe('Payments')
})
it('uses the same priority for unified tab labels', () => {
expect(
resolveUnifiedTabLabel(
{ customLabel: null, generatedLabel: 'Fix flaky tests', label: 'Codex working' },
true
)
).toBe('Fix flaky tests')
})
})

View File

@ -0,0 +1,27 @@
import type { Tab, TerminalTab } from './types'
export function resolveTerminalTabTitle(
tab: Pick<TerminalTab, 'customTitle' | 'generatedTitle' | 'title'>,
generatedTitlesEnabled: boolean,
fallback = ''
): string {
return (
tab.customTitle?.trim() ||
(generatedTitlesEnabled ? tab.generatedTitle?.trim() : '') ||
tab.title?.trim() ||
fallback
)
}
export function resolveUnifiedTabLabel(
tab: Pick<Tab, 'customLabel' | 'generatedLabel' | 'label'> | undefined,
generatedTitlesEnabled: boolean,
fallback = ''
): string {
return (
tab?.customLabel?.trim() ||
(generatedTitlesEnabled ? tab?.generatedLabel?.trim() : '') ||
tab?.label?.trim() ||
fallback
)
}

View File

@ -427,6 +427,7 @@ export type Tab = {
worktreeId: string
contentType: TabContentType
label: string // display title (auto-derived from PTY or filename)
generatedLabel?: string | null
customLabel: string | null
color: string | null
sortOrder: number
@ -459,6 +460,8 @@ export type TerminalTab = {
* Why: agent CLIs overwrite the live title via OSC updates, but Orca still
* needs the original terminal label for numbering and reset behavior. */
defaultTitle?: string
/** Stable opt-in label derived from the first known agent prompt. */
generatedTitle?: string | null
customTitle: string | null
color: string | null
sortOrder: number
@ -1924,6 +1927,9 @@ export type GlobalSettings = {
/** Why: disabling must persist so startup does not reinstall global agent
* hook entries right after the user removes them from Settings or CLI. */
agentStatusHooksEnabled: boolean
/** Why: generated tab titles are semantic but subjective, so they stay opt-in
* and manual renames remain the stronger user intent. */
tabAutoGenerateTitle: boolean
/** When true, Orca requests local awake assertions while hook-reported agents are working. */
keepComputerAwakeWhileAgentsRun: boolean
/** Why: macOS terminals must choose between letting Option compose layout

View File

@ -134,6 +134,54 @@ describe('parseWorkspaceSession', () => {
}
})
it('preserves generated terminal title fields for persistence hydration', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: 'wt',
activeTabId: 'tab1',
tabsByWorktree: {
wt: [
{
id: 'tab1',
ptyId: null,
worktreeId: 'wt',
title: 'Claude working',
defaultTitle: 'Terminal 1',
generatedTitle: 'Refactor auth',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
]
},
terminalLayoutsByTabId: {},
unifiedTabs: {
wt: [
{
id: 'tab1',
entityId: 'tab1',
groupId: 'group1',
worktreeId: 'wt',
contentType: 'terminal',
label: 'Claude working',
generatedLabel: 'Refactor auth',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
]
}
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.tabsByWorktree.wt[0].generatedTitle).toBe('Refactor auth')
expect(result.value.unifiedTabs?.wt[0].generatedLabel).toBe('Refactor auth')
}
})
it('rejects a session with missing required top-level fields', () => {
const result = parseWorkspaceSession({
activeRepoId: null

View File

@ -63,6 +63,7 @@ const terminalTabSchema = z.object({
worktreeId: z.string(),
title: z.string(),
defaultTitle: z.string().optional(),
generatedTitle: z.string().nullable().optional(),
customTitle: z.string().nullable(),
color: z.string().nullable(),
sortOrder: z.number(),
@ -91,6 +92,7 @@ const tabSchema = z.object({
worktreeId: z.string(),
contentType: tabContentTypeSchema,
label: z.string(),
generatedLabel: z.string().nullable().optional(),
customLabel: z.string().nullable(),
color: z.string().nullable(),
sortOrder: z.number(),