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:
parent
55a33c5b68
commit
8d67f9807c
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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[]>(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue