mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(tabs): let a deliberate agent /rename win the tab label mid-session
Claude publishes both its `/rename` and its auto-generated task summaries on one OSC title channel, so tab-title-resolution ranked Orca's first-prompt generatedTitle above the live title — right for the summaries, wrong for a deliberate rename. Only the transcript separates them: `custom-title` for the rename, `ai-title` for the summary, and `/rename` fires no agent hook, so the live title change is the only immediate trigger. On a real title change for a tab with an agent session, main now scans that session's transcript for the newest `custom-title` and the renderer records it as `TerminalTab.agentRenamedTitle` (distinct from Orca's own `customTitle`). The label ranks it on the same rung as the OpenCode native-title carve-out — above generatedTitle, below the manual and quick-command labels — and only while the live title still carries it, so an auto summary or a later revision falls back to the generated title. A cleared rename clears the field. All ranking sites move together: resolveTerminalTabTitle, resolveUnifiedTabLabel, the dashboard agent rows, and the headless mobile session tabs, which had no carve-out at all and kept the stale label on paired clients. SSH panes read the transcript through the pane's remote filesystem provider; any unreadable transcript degrades to today's behavior.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { readAgentSessionRenamedTitle } from './agent-session-rename-title-read'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
async function writeTranscript(lines: readonly unknown[]): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-agent-rename-'))
|
||||
tempRoots.push(root)
|
||||
const transcriptPath = join(root, 'session.jsonl')
|
||||
await writeFile(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`)
|
||||
return transcriptPath
|
||||
}
|
||||
|
||||
describe('readAgentSessionRenamedTitle', () => {
|
||||
it('finds the rename recorded after the auto-generated title', async () => {
|
||||
const transcriptPath = await writeTranscript([
|
||||
{ type: 'user', message: { role: 'user', content: 'What is 2+2? Answer in one word.' } },
|
||||
{ type: 'ai-title', aiTitle: 'Answer simple arithmetic question' },
|
||||
{ type: 'custom-title', customTitle: 'billing-fix' },
|
||||
{ type: 'agent-name', agentName: 'billing-fix' }
|
||||
])
|
||||
await expect(readAgentSessionRenamedTitle({ transcriptPath })).resolves.toBe('billing-fix')
|
||||
})
|
||||
|
||||
it('resolves null for a session with only auto-generated titles', async () => {
|
||||
const transcriptPath = await writeTranscript([
|
||||
{ type: 'ai-title', aiTitle: 'Answer simple arithmetic question' }
|
||||
])
|
||||
await expect(readAgentSessionRenamedTitle({ transcriptPath })).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('resolves null instead of rejecting when the transcript is gone', async () => {
|
||||
await expect(
|
||||
readAgentSessionRenamedTitle({ transcriptPath: join(tmpdir(), 'orca-missing-session.jsonl') })
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('ignores an empty transcript path', async () => {
|
||||
await expect(readAgentSessionRenamedTitle({ transcriptPath: ' ' })).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { createInterface } from 'node:readline'
|
||||
import {
|
||||
CLAUDE_CUSTOM_TITLE_RECORD_MARKER,
|
||||
readClaudeSessionRenamedTitle
|
||||
} from '../shared/agent-session-rename-title'
|
||||
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
|
||||
|
||||
// Why: a rename record is one short line anywhere in the transcript, so the scan
|
||||
// streams the file rather than buffering it. Very long sessions are tail-scanned
|
||||
// from this offset; a rename that old is already superseded by the tab's
|
||||
// persisted value.
|
||||
const MAX_TRANSCRIPT_SCAN_BYTES = 64 * 1024 * 1024
|
||||
const MAX_REMOTE_TRANSCRIPT_READ_BYTES = 8 * 1024 * 1024
|
||||
|
||||
export type AgentSessionRenamedTitleArgs = {
|
||||
transcriptPath: string
|
||||
/** SSH connection owning the transcript; omitted for local/WSL panes. */
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The deliberate rename (Claude `/rename`) recorded in one agent transcript, or
|
||||
* null when the session has none. Best-effort: any read failure resolves null so
|
||||
* the tab keeps its generated title instead of the call rejecting.
|
||||
*/
|
||||
export async function readAgentSessionRenamedTitle(
|
||||
args: AgentSessionRenamedTitleArgs
|
||||
): Promise<string | null> {
|
||||
const transcriptPath = args.transcriptPath.trim()
|
||||
if (!transcriptPath) {
|
||||
return null
|
||||
}
|
||||
const connectionId = args.connectionId?.trim()
|
||||
try {
|
||||
return connectionId
|
||||
? await readRemoteRenamedTitle(transcriptPath, connectionId)
|
||||
: await readLocalRenamedTitle(transcriptPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function readLocalRenamedTitle(transcriptPath: string): Promise<string | null> {
|
||||
const { size } = await stat(transcriptPath)
|
||||
const start = Math.max(0, size - MAX_TRANSCRIPT_SCAN_BYTES)
|
||||
const stream = createReadStream(transcriptPath, { encoding: 'utf8', start })
|
||||
try {
|
||||
const lines = createInterface({ input: stream, crlfDelay: Infinity })
|
||||
// A tail scan starts mid-line; JSON.parse rejects that fragment on its own.
|
||||
return readClaudeSessionRenamedTitle(await collectLines(lines))
|
||||
} finally {
|
||||
stream.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
async function collectLines(lines: AsyncIterable<string>): Promise<string[]> {
|
||||
const collected: string[] = []
|
||||
for await (const line of lines) {
|
||||
if (line.includes(CLAUDE_CUSTOM_TITLE_RECORD_MARKER)) {
|
||||
collected.push(line)
|
||||
}
|
||||
}
|
||||
return collected
|
||||
}
|
||||
|
||||
async function readRemoteRenamedTitle(
|
||||
transcriptPath: string,
|
||||
connectionId: string
|
||||
): Promise<string | null> {
|
||||
// Why: SSH-hosted agents write the transcript on the remote host, so the local
|
||||
// filesystem has nothing to scan. No provider (disconnected) degrades to null.
|
||||
const provider = getSshFilesystemProvider(connectionId)
|
||||
if (!provider) {
|
||||
return null
|
||||
}
|
||||
// Why: the remote read has no tail seek, so cap it well below the local budget
|
||||
// rather than pulling a long session over the link for one short record.
|
||||
const { size } = await provider.stat(transcriptPath)
|
||||
if (size > MAX_REMOTE_TRANSCRIPT_READ_BYTES) {
|
||||
return null
|
||||
}
|
||||
const { content, isBinary } = await provider.readFile(transcriptPath)
|
||||
if (isBinary || !content) {
|
||||
return null
|
||||
}
|
||||
return readClaudeSessionRenamedTitle(content.split('\n'))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
readAgentSessionRenamedTitle,
|
||||
type AgentSessionRenamedTitleArgs
|
||||
} from '../agent-session-rename-title-read'
|
||||
|
||||
/**
|
||||
* Why: Claude publishes a deliberate `/rename` and its auto-generated summaries
|
||||
* on one OSC title channel, so the renderer cannot tell them apart from the live
|
||||
* title. It asks main to check the session transcript, which records the rename
|
||||
* as `custom-title`, and stores the answer on the tab.
|
||||
*/
|
||||
export function registerAgentSessionRenameHandlers(): void {
|
||||
ipcMain.removeHandler('agentSession:getRenamedTitle')
|
||||
ipcMain.handle(
|
||||
'agentSession:getRenamedTitle',
|
||||
async (_event, args?: AgentSessionRenamedTitleArgs): Promise<string | null> => {
|
||||
if (!args || typeof args.transcriptPath !== 'string') {
|
||||
return null
|
||||
}
|
||||
return readAgentSessionRenamedTitle({
|
||||
transcriptPath: args.transcriptPath,
|
||||
...(typeof args.connectionId === 'string' ? { connectionId: args.connectionId } : {})
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -36,6 +36,7 @@ const {
|
||||
registerCodexAccountHandlersMock,
|
||||
registerAgentHookHandlersMock,
|
||||
registerAgentTrustHandlersMock,
|
||||
registerAgentSessionRenameHandlersMock,
|
||||
registerClaudeAccountHandlersMock,
|
||||
registerMiniMaxCredentialsHandlersMock,
|
||||
registerGrokAccountHandlersMock,
|
||||
@@ -101,6 +102,7 @@ const {
|
||||
registerCodexAccountHandlersMock: vi.fn(),
|
||||
registerAgentHookHandlersMock: vi.fn(),
|
||||
registerAgentTrustHandlersMock: vi.fn(),
|
||||
registerAgentSessionRenameHandlersMock: vi.fn(),
|
||||
registerClaudeAccountHandlersMock: vi.fn(),
|
||||
registerMiniMaxCredentialsHandlersMock: vi.fn(),
|
||||
registerGrokAccountHandlersMock: vi.fn(),
|
||||
@@ -323,6 +325,10 @@ vi.mock('./agent-trust', () => ({
|
||||
registerAgentTrustHandlers: registerAgentTrustHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./agent-session-rename', () => ({
|
||||
registerAgentSessionRenameHandlers: registerAgentSessionRenameHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./claude-accounts', () => ({
|
||||
registerClaudeAccountHandlers: registerClaudeAccountHandlersMock
|
||||
}))
|
||||
@@ -419,6 +425,7 @@ describe('registerCoreHandlers', () => {
|
||||
registerCodexAccountHandlersMock.mockReset()
|
||||
registerAgentHookHandlersMock.mockReset()
|
||||
registerAgentTrustHandlersMock.mockReset()
|
||||
registerAgentSessionRenameHandlersMock.mockReset()
|
||||
registerClaudeAccountHandlersMock.mockReset()
|
||||
registerMiniMaxCredentialsHandlersMock.mockReset()
|
||||
registerClipboardHandlersMock.mockReset()
|
||||
|
||||
@@ -63,6 +63,7 @@ import { registerAgentHookHandlers } from './agent-hooks'
|
||||
import { registerCodexConfigSyncHandlers } from './codex-config-sync'
|
||||
import { getPtyIdForPaneKey } from './pty'
|
||||
import { registerAgentTrustHandlers } from './agent-trust'
|
||||
import { registerAgentSessionRenameHandlers } from './agent-session-rename'
|
||||
import { registerClaudeAccountHandlers } from './claude-accounts'
|
||||
import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
|
||||
import { registerGrokAccountHandlers } from './grok-accounts'
|
||||
@@ -148,6 +149,7 @@ export function registerCoreHandlers(
|
||||
registerAgentHookHandlers(runtime, { getPtyIdForPaneKey })
|
||||
registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService)
|
||||
registerAgentTrustHandlers()
|
||||
registerAgentSessionRenameHandlers()
|
||||
registerClaudeAccountHandlers(claudeAccounts)
|
||||
registerMiniMaxCredentialsHandlers(rateLimits)
|
||||
registerGrokAccountHandlers()
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
normalizeTerminalTitle
|
||||
} from '../../shared/agent-detection'
|
||||
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
|
||||
import { isAgentRenamedTerminalTitle } from '../../shared/agent-session-rename-title'
|
||||
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
|
||||
import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction'
|
||||
import { parseFileUriPathParts } from '../daemon/osc7-file-uri'
|
||||
@@ -6707,10 +6708,14 @@ export class OrcaRuntimeService {
|
||||
return leafIds.flatMap((leafId) => {
|
||||
const ptyId =
|
||||
layout?.ptyIdsByLeafId?.[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null)
|
||||
const liveTitle = tab.title?.trim() ?? ''
|
||||
const title =
|
||||
tab.customTitle?.trim() ||
|
||||
// Why: a deliberate agent `/rename` outranks the first-prompt
|
||||
// generated title here too, or paired clients keep the stale label.
|
||||
(isAgentRenamedTerminalTitle(liveTitle, tab.agentRenamedTitle) ? liveTitle : '') ||
|
||||
tab.generatedTitle?.trim() ||
|
||||
tab.title?.trim() ||
|
||||
liveTitle ||
|
||||
tab.defaultTitle?.trim() ||
|
||||
`Terminal ${index + 1}`
|
||||
return [
|
||||
|
||||
@@ -2467,6 +2467,13 @@ export type PreloadApi = {
|
||||
connectionId?: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
agentSession: {
|
||||
/** Deliberate in-agent rename (Claude `/rename`) recorded in a transcript. */
|
||||
getRenamedTitle: (args: {
|
||||
transcriptPath: string
|
||||
connectionId?: string
|
||||
}) => Promise<string | null>
|
||||
}
|
||||
preflight: PreflightApi
|
||||
notifications: {
|
||||
dispatch: (args: NotificationDispatchRequest) => Promise<NotificationDispatchResult>
|
||||
|
||||
@@ -2139,6 +2139,13 @@ const api = {
|
||||
}): Promise<void> => ipcRenderer.invoke('agentTrust:markTrusted', args)
|
||||
},
|
||||
|
||||
agentSession: {
|
||||
getRenamedTitle: (args: {
|
||||
transcriptPath: string
|
||||
connectionId?: string
|
||||
}): Promise<string | null> => ipcRenderer.invoke('agentSession:getRenamedTitle', args)
|
||||
},
|
||||
|
||||
preflight: {
|
||||
check: (args?: {
|
||||
force?: boolean
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { isAgentRenamedTerminalTitle } from '../../../shared/agent-session-rename-title'
|
||||
|
||||
// Why: a live OSC title change is the only immediate signal that a deliberate
|
||||
// `/rename` may have happened — the agent fires no hook for it. One refresh per
|
||||
// tab is in flight at a time so a burst of title frames cannot fan out into
|
||||
// concurrent transcript scans.
|
||||
const inFlightTabIds = new Set<string>()
|
||||
|
||||
export type AgentRenamedTabTitleRefreshArgs = {
|
||||
tabId: string
|
||||
/** Live title the refresh is explaining; a rename must still match it. */
|
||||
liveTitle: string
|
||||
/** Transcript per pane of the tab — a split tab can run several sessions. */
|
||||
transcriptPaths: readonly string[]
|
||||
connectionId: string | null
|
||||
apply: (agentRenamedTitle: string | null) => void
|
||||
}
|
||||
|
||||
export function scheduleAgentRenamedTabTitleRefresh(args: AgentRenamedTabTitleRefreshArgs): void {
|
||||
// Why: the store also runs in the headless/web harness, where `window` and the
|
||||
// desktop preload bridge may be absent.
|
||||
const getRenamedTitle =
|
||||
typeof window === 'undefined' ? undefined : window.api?.agentSession?.getRenamedTitle
|
||||
if (!getRenamedTitle || args.transcriptPaths.length === 0 || inFlightTabIds.has(args.tabId)) {
|
||||
return
|
||||
}
|
||||
inFlightTabIds.add(args.tabId)
|
||||
void (async () => {
|
||||
try {
|
||||
for (const transcriptPath of args.transcriptPaths) {
|
||||
const renamedTitle = await getRenamedTitle({
|
||||
transcriptPath,
|
||||
...(args.connectionId ? { connectionId: args.connectionId } : {})
|
||||
})
|
||||
if (isAgentRenamedTerminalTitle(args.liveTitle, renamedTitle)) {
|
||||
args.apply(renamedTitle)
|
||||
return
|
||||
}
|
||||
}
|
||||
args.apply(null)
|
||||
} catch {
|
||||
// Best-effort: an unreadable transcript leaves the generated title in place.
|
||||
} finally {
|
||||
inFlightTabIds.delete(args.tabId)
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -514,6 +514,7 @@ function buildRuntimeMobileTabsProjection(tabsByWorktree: AppState['tabsByWorktr
|
||||
title: tab.title,
|
||||
quickCommandLabel: tab.quickCommandLabel,
|
||||
generatedTitle: tab.generatedTitle,
|
||||
agentRenamedTitle: tab.agentRenamedTitle,
|
||||
customTitle: tab.customTitle,
|
||||
launchAgent: tab.launchAgent
|
||||
}))
|
||||
@@ -532,7 +533,10 @@ function buildRuntimeMobileTabsProjection(tabsByWorktree: AppState['tabsByWorktr
|
||||
}
|
||||
|
||||
function resolveRuntimeTerminalTitle(
|
||||
tab: Pick<TerminalTab, 'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title'>,
|
||||
tab: Pick<
|
||||
TerminalTab,
|
||||
'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title' | 'agentRenamedTitle'
|
||||
>,
|
||||
generatedTitlesEnabled: boolean,
|
||||
liveTitle = tab.title
|
||||
): string {
|
||||
|
||||
@@ -652,6 +652,8 @@ function buildMirroredTerminalTabs(
|
||||
...(quickCommandLabel ? { quickCommandLabel } : {}),
|
||||
...(startupCwd ? { startupCwd } : {}),
|
||||
customTitle: existing?.customTitle ?? null,
|
||||
// Why: the agent's deliberate rename is client-derived from the transcript; host snapshots never carry it.
|
||||
...(existing?.agentRenamedTitle ? { agentRenamedTitle: existing.agentRenamedTitle } : {}),
|
||||
color,
|
||||
isPinned,
|
||||
...(viewMode ? { viewMode } : {}),
|
||||
@@ -840,6 +842,7 @@ function buildTerminalUnifiedTab(
|
||||
label: tab.title,
|
||||
...(tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}),
|
||||
...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}),
|
||||
...(tab.agentRenamedTitle?.trim() ? { agentRenamedLabel: tab.agentRenamedTitle.trim() } : {}),
|
||||
customLabel: tab.customTitle,
|
||||
color: tab.color,
|
||||
sortOrder: tab.sortOrder,
|
||||
@@ -1531,6 +1534,7 @@ function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean {
|
||||
a.quickCommandLabel === b.quickCommandLabel &&
|
||||
a.startupCwd === b.startupCwd &&
|
||||
a.generatedTitle === b.generatedTitle &&
|
||||
a.agentRenamedTitle === b.agentRenamedTitle &&
|
||||
a.customTitle === b.customTitle &&
|
||||
a.color === b.color &&
|
||||
a.sortOrder === b.sortOrder &&
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import {
|
||||
resolveTerminalTabTitle,
|
||||
resolveUnifiedTabLabel
|
||||
} 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'
|
||||
const TRANSCRIPT_PATH = '/home/dev/.claude/projects/wt1/session.jsonl'
|
||||
|
||||
const getRenamedTitle = vi.fn<(args: { transcriptPath: string }) => Promise<string | null>>()
|
||||
const originalWindow = globalThis.window
|
||||
|
||||
beforeEach(() => {
|
||||
getRenamedTitle.mockReset()
|
||||
globalThis.window = { api: { agentSession: { getRenamedTitle } } } as never
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.window = originalWindow
|
||||
})
|
||||
|
||||
function seedAgentTab(store: ReturnType<typeof createTestStore>): string {
|
||||
seedStore(store, {
|
||||
settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: true },
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })]
|
||||
}
|
||||
})
|
||||
const tabId = store.getState().createTab(WORKTREE_ID).id
|
||||
const paneKey = makePaneKey(tabId, LEAF_ID)
|
||||
store.getState().setAgentStatus(paneKey, {
|
||||
state: 'working',
|
||||
prompt: 'What is 2+2? Answer in one word.',
|
||||
agentType: 'claude'
|
||||
})
|
||||
// Why: hook-reported provider sessions only reach the store through the hook
|
||||
// path; seed it directly so the transcript lookup has a path to scan.
|
||||
store.setState({
|
||||
agentStatusByPaneKey: {
|
||||
...store.getState().agentStatusByPaneKey,
|
||||
[paneKey]: {
|
||||
...store.getState().agentStatusByPaneKey[paneKey]!,
|
||||
providerSession: { key: 'session_id', id: 'session-1', transcriptPath: TRANSCRIPT_PATH }
|
||||
}
|
||||
}
|
||||
})
|
||||
return tabId
|
||||
}
|
||||
|
||||
async function flushRenameRefresh(): Promise<void> {
|
||||
await vi.waitFor(() => expect(getRenamedTitle).toHaveBeenCalled())
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('deliberate in-agent rename vs generated tab title', () => {
|
||||
it('lets a mid-session /rename win the tab label', async () => {
|
||||
const store = createTestStore()
|
||||
const tabId = seedAgentTab(store)
|
||||
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].generatedTitle).toBe('What is 2 2')
|
||||
|
||||
getRenamedTitle.mockResolvedValue('billing-fix')
|
||||
store.getState().updateTabTitle(tabId, '✳ billing-fix')
|
||||
await flushRenameRefresh()
|
||||
|
||||
expect(getRenamedTitle).toHaveBeenCalledWith({ transcriptPath: TRANSCRIPT_PATH })
|
||||
const tab = store.getState().tabsByWorktree[WORKTREE_ID][0]
|
||||
expect(resolveTerminalTabTitle(tab, true)).toBe('✳ billing-fix')
|
||||
expect(
|
||||
resolveUnifiedTabLabel(store.getState().unifiedTabsByWorktree[WORKTREE_ID][0], true)
|
||||
).toBe('✳ billing-fix')
|
||||
})
|
||||
|
||||
it('keeps the generated title when the agent only auto-summarized', async () => {
|
||||
const store = createTestStore()
|
||||
const tabId = seedAgentTab(store)
|
||||
|
||||
getRenamedTitle.mockResolvedValue(null)
|
||||
store.getState().updateTabTitle(tabId, '✳ Answer simple arithmetic question')
|
||||
await flushRenameRefresh()
|
||||
|
||||
const tab = store.getState().tabsByWorktree[WORKTREE_ID][0]
|
||||
expect(tab.agentRenamedTitle ?? null).toBeNull()
|
||||
expect(resolveTerminalTabTitle(tab, true)).toBe('What is 2 2')
|
||||
})
|
||||
|
||||
it('drops the rename once the agent moves its title elsewhere', async () => {
|
||||
const store = createTestStore()
|
||||
const tabId = seedAgentTab(store)
|
||||
|
||||
getRenamedTitle.mockResolvedValue('billing-fix')
|
||||
store.getState().updateTabTitle(tabId, '✳ billing-fix')
|
||||
await flushRenameRefresh()
|
||||
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].agentRenamedTitle).toBe('billing-fix')
|
||||
|
||||
store.getState().updateTabTitle(tabId, '✳ Fix the intake flow')
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getState().tabsByWorktree[WORKTREE_ID][0].agentRenamedTitle).toBeNull()
|
||||
)
|
||||
expect(resolveTerminalTabTitle(store.getState().tabsByWorktree[WORKTREE_ID][0], true)).toBe(
|
||||
'What is 2 2'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not scan transcripts while generated titles are off', async () => {
|
||||
const store = createTestStore()
|
||||
const tabId = seedAgentTab(store)
|
||||
store.setState({ settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: false } })
|
||||
|
||||
store.getState().updateTabTitle(tabId, '✳ billing-fix')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(getRenamedTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -219,6 +219,7 @@ function hydrateLegacyFormat(
|
||||
label: tt.title,
|
||||
...(tt.quickCommandLabel?.trim() ? { quickCommandLabel: tt.quickCommandLabel.trim() } : {}),
|
||||
...(tt.generatedTitle?.trim() ? { generatedLabel: tt.generatedTitle.trim() } : {}),
|
||||
...(tt.agentRenamedTitle?.trim() ? { agentRenamedLabel: tt.agentRenamedTitle.trim() } : {}),
|
||||
customLabel: tt.customTitle,
|
||||
color: tt.color,
|
||||
sortOrder: tt.sortOrder,
|
||||
|
||||
@@ -666,6 +666,9 @@ export function projectWorktreeTabModelReconciliation(
|
||||
? { quickCommandLabel: tab.quickCommandLabel.trim() }
|
||||
: {}),
|
||||
...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}),
|
||||
...(tab.agentRenamedTitle?.trim()
|
||||
? { agentRenamedLabel: tab.agentRenamedTitle.trim() }
|
||||
: {}),
|
||||
customLabel: tab.customTitle,
|
||||
color: tab.color,
|
||||
sortOrder: tab.sortOrder,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
worktreeWorkspaceKey
|
||||
} from '../../../../shared/workspace-scope'
|
||||
import { deriveGeneratedTabTitle } from '../../../../shared/agent-tab-title'
|
||||
import { scheduleAgentRenamedTabTitleRefresh } from '@/lib/agent-session-rename-refresh'
|
||||
import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature'
|
||||
import {
|
||||
isTerminalLeafId,
|
||||
@@ -346,6 +347,25 @@ function updateUnifiedTerminalLabel(
|
||||
return unifiedTabs.map((entry, index) => (index === unifiedIndex ? { ...entry, label } : entry))
|
||||
}
|
||||
|
||||
function updateUnifiedTerminalAgentRenamedLabel(
|
||||
unifiedTabs: Tab[],
|
||||
terminalTabId: string,
|
||||
agentRenamedLabel: string | null
|
||||
): Tab[] | null {
|
||||
const unifiedIndex = unifiedTabs.findIndex(
|
||||
(entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId
|
||||
)
|
||||
if (
|
||||
unifiedIndex === -1 ||
|
||||
(unifiedTabs[unifiedIndex]?.agentRenamedLabel ?? null) === agentRenamedLabel
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return unifiedTabs.map((entry, index) =>
|
||||
index === unifiedIndex ? { ...entry, agentRenamedLabel } : entry
|
||||
)
|
||||
}
|
||||
|
||||
function updateUnifiedTerminalGeneratedLabel(
|
||||
unifiedTabs: Tab[],
|
||||
terminalTabId: string,
|
||||
@@ -467,6 +487,33 @@ function getRemoteConnectionIdForWorktree(
|
||||
return repo?.connectionId?.trim() || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask main whether the tab's new live title is a deliberate in-agent rename.
|
||||
* Only matters while generated titles are on: with the setting off the live
|
||||
* title already outranks everything below the manual/quick-command labels.
|
||||
*/
|
||||
function requestAgentRenamedTabTitle(get: () => AppState, tabId: string, liveTitle: string): void {
|
||||
const state = get()
|
||||
if (state.settings?.tabAutoGenerateTitle !== true) {
|
||||
return
|
||||
}
|
||||
const ownerWorktreeId = getTerminalTabOwnerWorktreeId(state.tabsByWorktree, tabId)
|
||||
if (!ownerWorktreeId) {
|
||||
return
|
||||
}
|
||||
const transcriptPaths = Object.entries(state.agentStatusByPaneKey).flatMap(([paneKey, entry]) => {
|
||||
const transcriptPath = entry?.providerSession?.transcriptPath?.trim()
|
||||
return transcriptPath && getTabIdFromPaneKey(paneKey) === tabId ? [transcriptPath] : []
|
||||
})
|
||||
scheduleAgentRenamedTabTitleRefresh({
|
||||
tabId,
|
||||
liveTitle,
|
||||
transcriptPaths,
|
||||
connectionId: getRemoteConnectionIdForWorktree(state, ownerWorktreeId),
|
||||
apply: (agentRenamedTitle) => get().setTabAgentRenamedTitle(tabId, agentRenamedTitle)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveTerminalStopRuntimeEnvironmentId(
|
||||
state: Pick<AppState, 'repos' | 'settings' | 'worktreesByRepo'>,
|
||||
worktreeId: string
|
||||
@@ -687,6 +734,8 @@ export type TerminalSlice = {
|
||||
prompt: string,
|
||||
options?: { replaceExistingGeneratedTitle?: boolean }
|
||||
) => void
|
||||
/** Record the agent's own deliberate `/rename` so it outranks the generated title. */
|
||||
setTabAgentRenamedTitle: (tabId: string, agentRenamedTitle: string | null) => void
|
||||
clearTabLaunchAgent: (tabId: string) => void
|
||||
setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void
|
||||
clearRuntimePaneTitle: (tabId: string, paneId: number) => void
|
||||
@@ -1982,7 +2031,44 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
}))
|
||||
},
|
||||
|
||||
setTabAgentRenamedTitle: (tabId, agentRenamedTitle) => {
|
||||
set((s) => {
|
||||
const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId)
|
||||
if (!ownerWorktreeId) {
|
||||
return s
|
||||
}
|
||||
const tabs = s.tabsByWorktree[ownerWorktreeId] ?? []
|
||||
const currentTab = tabs.find((tab) => tab.id === tabId)
|
||||
if (!currentTab || (currentTab.agentRenamedTitle ?? null) === agentRenamedTitle) {
|
||||
return s
|
||||
}
|
||||
const unifiedTabsWithRenamedLabel = updateUnifiedTerminalAgentRenamedLabel(
|
||||
s.unifiedTabsByWorktree[ownerWorktreeId] ?? [],
|
||||
tabId,
|
||||
agentRenamedTitle
|
||||
)
|
||||
scheduleRuntimeGraphSync()
|
||||
return {
|
||||
tabsByWorktree: {
|
||||
...s.tabsByWorktree,
|
||||
[ownerWorktreeId]: tabs.map((tab) =>
|
||||
tab.id === tabId ? { ...tab, agentRenamedTitle } : tab
|
||||
)
|
||||
},
|
||||
...(unifiedTabsWithRenamedLabel
|
||||
? {
|
||||
unifiedTabsByWorktree: {
|
||||
...s.unifiedTabsByWorktree,
|
||||
[ownerWorktreeId]: unifiedTabsWithRenamedLabel
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
updateTabTitle: (tabId, title) => {
|
||||
let changedLiveTitle: string | null = null
|
||||
set((s) => {
|
||||
// Why: update only the owner to preserve selector equality for background worktrees.
|
||||
const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId)
|
||||
@@ -2040,6 +2126,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
}
|
||||
: tab
|
||||
)
|
||||
changedLiveTitle = nextTitle
|
||||
scheduleRuntimeGraphSync()
|
||||
const nextTabsByWorktree = { ...s.tabsByWorktree, [ownerWorktreeId]: ownerTabs }
|
||||
// Why: title changes affect sorting, except active-worktree remount side effects.
|
||||
@@ -2055,6 +2142,9 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
}
|
||||
return nextState
|
||||
})
|
||||
if (changedLiveTitle) {
|
||||
requestAgentRenamedTabTitle(get, tabId, changedLiveTitle)
|
||||
}
|
||||
},
|
||||
|
||||
setGeneratedTabTitleFromAgentPrompt: (paneKey, prompt, options) => {
|
||||
|
||||
@@ -31,6 +31,24 @@ describe('getAgentRowConversationName', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers a deliberate in-agent rename over the generated title', () => {
|
||||
const tab = makeTab({
|
||||
generatedTitle: 'What is 2 2',
|
||||
title: '✳ billing-fix',
|
||||
agentRenamedTitle: 'billing-fix'
|
||||
})
|
||||
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('billing-fix')
|
||||
})
|
||||
|
||||
it('keeps the generated title ahead of the agent auto-generated summary', () => {
|
||||
const tab = makeTab({
|
||||
generatedTitle: 'What is 2 2',
|
||||
title: '✳ Answer simple arithmetic question',
|
||||
agentRenamedTitle: 'billing-fix'
|
||||
})
|
||||
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('What is 2 2')
|
||||
})
|
||||
|
||||
it('uses the generated title only when generated titles are enabled', () => {
|
||||
const tab = makeTab({ generatedTitle: 'Fix intake flow', title: '✳ Investigate replay bug' })
|
||||
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Fix intake flow')
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Resolves the stable "conversation name" an agent row can show instead of the
|
||||
// live last-message preview. Sources, in the same precedence the tab bar uses
|
||||
// (tab-title-resolution.ts): manual rename → quick-command label → OpenCode's
|
||||
// semantic session title → Orca's generated title → the agent-set live title.
|
||||
// (tab-title-resolution.ts): manual rename → quick-command label → the agent's
|
||||
// own deliberate `/rename` → OpenCode's semantic session title → Orca's
|
||||
// generated title → the agent-set live title.
|
||||
// Live titles are accepted only when they carry a real name — pure status,
|
||||
// identity-echo, and spinner/cwd titles yield null so callers keep the
|
||||
// last-message label.
|
||||
import type { AgentType } from './agent-status-types'
|
||||
import { isAgentRenamedTerminalTitle } from './agent-session-rename-title'
|
||||
import { isClaudeManagementTitle } from './agent-title-core'
|
||||
import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
|
||||
import { formatAgentTypeLabel } from './agent-type-label'
|
||||
@@ -15,7 +17,12 @@ import type { TerminalTab } from './types'
|
||||
|
||||
export type ConversationNameTab = Pick<
|
||||
TerminalTab,
|
||||
'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title' | 'defaultTitle'
|
||||
| 'customTitle'
|
||||
| 'quickCommandLabel'
|
||||
| 'generatedTitle'
|
||||
| 'title'
|
||||
| 'defaultTitle'
|
||||
| 'agentRenamedTitle'
|
||||
>
|
||||
|
||||
// Why: synthetic status titles ("Codex ready", "Cursor - action required") are
|
||||
@@ -124,6 +131,9 @@ export function getAgentRowConversationName(
|
||||
return quickCommandLabel
|
||||
}
|
||||
const liveTitle = tab.title?.trim() ?? ''
|
||||
if (isAgentRenamedTerminalTitle(liveTitle, tab.agentRenamedTitle)) {
|
||||
return stripLeadingAgentTitleDecorationOrEmpty(liveTitle) || liveTitle
|
||||
}
|
||||
if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) {
|
||||
return liveTitle
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isAgentRenamedTerminalTitle,
|
||||
readClaudeSessionRenamedTitle
|
||||
} from './agent-session-rename-title'
|
||||
|
||||
// Records copied from a real Claude transcript for a session that answered one
|
||||
// prompt and was then renamed with `/rename billing-fix`.
|
||||
const AI_TITLE_LINE = JSON.stringify({
|
||||
type: 'ai-title',
|
||||
aiTitle: 'Answer simple arithmetic question',
|
||||
sessionId: 'ac1a3f53-82b1-41ab-8db8-028c1fc15c48'
|
||||
})
|
||||
const CUSTOM_TITLE_LINE = JSON.stringify({
|
||||
type: 'custom-title',
|
||||
customTitle: 'billing-fix',
|
||||
sessionId: 'ac1a3f53-82b1-41ab-8db8-028c1fc15c48'
|
||||
})
|
||||
|
||||
describe('readClaudeSessionRenamedTitle', () => {
|
||||
it('reads the deliberate rename and ignores auto-generated titles', () => {
|
||||
expect(readClaudeSessionRenamedTitle([AI_TITLE_LINE, CUSTOM_TITLE_LINE])).toBe('billing-fix')
|
||||
})
|
||||
|
||||
it('returns null for a session that was never renamed', () => {
|
||||
expect(readClaudeSessionRenamedTitle([AI_TITLE_LINE])).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the newest rename', () => {
|
||||
const laterRename = JSON.stringify({ type: 'custom-title', customTitle: 'intake-flow' })
|
||||
expect(readClaudeSessionRenamedTitle([CUSTOM_TITLE_LINE, AI_TITLE_LINE, laterRename])).toBe(
|
||||
'intake-flow'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a cleared rename as no rename', () => {
|
||||
const cleared = JSON.stringify({ type: 'custom-title', customTitle: ' ' })
|
||||
expect(readClaudeSessionRenamedTitle([CUSTOM_TITLE_LINE, cleared])).toBeNull()
|
||||
})
|
||||
|
||||
it('skips truncated and unrelated lines', () => {
|
||||
const truncated = CUSTOM_TITLE_LINE.slice(10)
|
||||
expect(readClaudeSessionRenamedTitle([truncated, 'not json', '', CUSTOM_TITLE_LINE])).toBe(
|
||||
'billing-fix'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a message that merely quotes the record type', () => {
|
||||
const userTurn = JSON.stringify({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'what does "custom-title" mean?' }
|
||||
})
|
||||
expect(readClaudeSessionRenamedTitle([userTurn])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAgentRenamedTerminalTitle', () => {
|
||||
it('matches the live OSC title through the agent status glyph', () => {
|
||||
expect(isAgentRenamedTerminalTitle('✳ billing-fix', 'billing-fix')).toBe(true)
|
||||
expect(isAgentRenamedTerminalTitle('⠂ billing-fix', 'billing-fix')).toBe(true)
|
||||
expect(isAgentRenamedTerminalTitle('billing-fix', 'billing-fix')).toBe(true)
|
||||
})
|
||||
|
||||
it('stops matching once the agent moves its title elsewhere', () => {
|
||||
expect(isAgentRenamedTerminalTitle('✳ Fix the intake flow', 'billing-fix')).toBe(false)
|
||||
})
|
||||
|
||||
it('is false without a recorded rename', () => {
|
||||
expect(isAgentRenamedTerminalTitle('✳ billing-fix', null)).toBe(false)
|
||||
expect(isAgentRenamedTerminalTitle('', 'billing-fix')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
// Why: Claude publishes a deliberate `/rename` and its auto-generated task
|
||||
// summaries on the same OSC title channel, so the live title alone cannot say
|
||||
// which one arrived. Only the transcript separates them — `custom-title` for
|
||||
// the rename, `ai-title` for the summary. Orca records the latest deliberate
|
||||
// rename on the tab so title resolution can rank it above the first-prompt
|
||||
// generated title, the same rung `isMeaningfulOpenCodeTerminalTitle` occupies.
|
||||
import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
|
||||
|
||||
/** Cheap prefilter for transcript readers before they buffer or parse a line. */
|
||||
export const CLAUDE_CUSTOM_TITLE_RECORD_MARKER = '"custom-title"'
|
||||
|
||||
/**
|
||||
* The session's current deliberate rename from Claude transcript lines, or null
|
||||
* when none survives. Later records win; an empty `customTitle` is Claude
|
||||
* clearing the name, which must fall back to Orca's generated title.
|
||||
*/
|
||||
export function readClaudeSessionRenamedTitle(lines: Iterable<string>): string | null {
|
||||
let renamedTitle: string | null = null
|
||||
for (const line of lines) {
|
||||
// Why: transcripts are mostly large message records; skip the JSON parse
|
||||
// unless the line can carry the marker.
|
||||
if (!line.includes(CLAUDE_CUSTOM_TITLE_RECORD_MARKER)) {
|
||||
continue
|
||||
}
|
||||
let record: unknown
|
||||
try {
|
||||
record = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!record || typeof record !== 'object') {
|
||||
continue
|
||||
}
|
||||
const { type, customTitle } = record as { type?: unknown; customTitle?: unknown }
|
||||
if (type !== 'custom-title') {
|
||||
continue
|
||||
}
|
||||
renamedTitle = typeof customTitle === 'string' ? customTitle.trim() || null : null
|
||||
}
|
||||
return renamedTitle
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the pane's live OSC title still carries the recorded deliberate
|
||||
* rename. Matching (rather than substituting the recorded name) keeps the label
|
||||
* self-correcting: once the agent moves the title elsewhere, the rename stops
|
||||
* outranking the generated title.
|
||||
*/
|
||||
export function isAgentRenamedTerminalTitle(
|
||||
liveTitle: string | null | undefined,
|
||||
agentRenamedTitle: string | null | undefined
|
||||
): boolean {
|
||||
const renamedTitle = agentRenamedTitle?.trim()
|
||||
const title = liveTitle?.trim()
|
||||
if (!renamedTitle || !title) {
|
||||
return false
|
||||
}
|
||||
// Why: the live title carries the agent's status glyph ("✳ billing-fix").
|
||||
return title === renamedTitle || stripLeadingAgentTitleDecorationOrEmpty(title) === renamedTitle
|
||||
}
|
||||
@@ -48,6 +48,61 @@ describe('tab title resolution', () => {
|
||||
).toBe('Refactor auth')
|
||||
})
|
||||
|
||||
it('lets a deliberate in-agent rename win after the first prompt', () => {
|
||||
expect(
|
||||
resolveTerminalTabTitle(
|
||||
{
|
||||
customTitle: null,
|
||||
generatedTitle: 'What is 2 2',
|
||||
title: '✳ billing-fix',
|
||||
agentRenamedTitle: 'billing-fix'
|
||||
},
|
||||
true
|
||||
)
|
||||
).toBe('✳ billing-fix')
|
||||
})
|
||||
|
||||
it('keeps generated titles ahead of the agent auto-generated summary', () => {
|
||||
expect(
|
||||
resolveTerminalTabTitle(
|
||||
{
|
||||
customTitle: null,
|
||||
generatedTitle: 'What is 2 2',
|
||||
title: '✳ Answer simple arithmetic question',
|
||||
agentRenamedTitle: 'billing-fix'
|
||||
},
|
||||
true
|
||||
)
|
||||
).toBe('What is 2 2')
|
||||
})
|
||||
|
||||
it('keeps manual and quick command titles ahead of an in-agent rename', () => {
|
||||
expect(
|
||||
resolveTerminalTabTitle(
|
||||
{
|
||||
customTitle: 'Payments',
|
||||
quickCommandLabel: 'Run tests',
|
||||
generatedTitle: 'What is 2 2',
|
||||
title: '✳ billing-fix',
|
||||
agentRenamedTitle: 'billing-fix'
|
||||
},
|
||||
true
|
||||
)
|
||||
).toBe('Payments')
|
||||
expect(
|
||||
resolveTerminalTabTitle(
|
||||
{
|
||||
customTitle: null,
|
||||
quickCommandLabel: 'Run tests',
|
||||
generatedTitle: 'What is 2 2',
|
||||
title: '✳ billing-fix',
|
||||
agentRenamedTitle: 'billing-fix'
|
||||
},
|
||||
true
|
||||
)
|
||||
).toBe('Run tests')
|
||||
})
|
||||
|
||||
it('places quick command labels between manual and generated titles', () => {
|
||||
expect(
|
||||
resolveTerminalTabTitle(
|
||||
@@ -109,6 +164,31 @@ describe('tab title resolution', () => {
|
||||
).toBe('OC | Native Stable Session')
|
||||
})
|
||||
|
||||
it('lets a deliberate in-agent rename win for unified labels', () => {
|
||||
expect(
|
||||
resolveUnifiedTabLabel(
|
||||
{
|
||||
customLabel: null,
|
||||
generatedLabel: 'What is 2 2',
|
||||
label: '✳ billing-fix',
|
||||
agentRenamedLabel: 'billing-fix'
|
||||
},
|
||||
true
|
||||
)
|
||||
).toBe('✳ billing-fix')
|
||||
expect(
|
||||
resolveUnifiedTabLabel(
|
||||
{
|
||||
customLabel: null,
|
||||
generatedLabel: 'What is 2 2',
|
||||
label: '✳ Answer simple arithmetic question',
|
||||
agentRenamedLabel: 'billing-fix'
|
||||
},
|
||||
true
|
||||
)
|
||||
).toBe('What is 2 2')
|
||||
})
|
||||
|
||||
it('keeps manual and quick command labels ahead of native OpenCode labels', () => {
|
||||
expect(
|
||||
resolveUnifiedTabLabel(
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Tab, TerminalTab } from './types'
|
||||
import { isAgentRenamedTerminalTitle } from './agent-session-rename-title'
|
||||
import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title'
|
||||
|
||||
export function resolveTerminalTabTitle(
|
||||
tab: Pick<TerminalTab, 'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title'>,
|
||||
tab: Pick<
|
||||
TerminalTab,
|
||||
'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title' | 'agentRenamedTitle'
|
||||
>,
|
||||
generatedTitlesEnabled: boolean,
|
||||
fallback = ''
|
||||
): string {
|
||||
@@ -10,6 +14,7 @@ export function resolveTerminalTabTitle(
|
||||
return (
|
||||
tab.customTitle?.trim() ||
|
||||
tab.quickCommandLabel?.trim() ||
|
||||
(isAgentRenamedTerminalTitle(liveTitle, tab.agentRenamedTitle) ? liveTitle : '') ||
|
||||
(isMeaningfulOpenCodeTerminalTitle(liveTitle) ? liveTitle : '') ||
|
||||
(generatedTitlesEnabled ? tab.generatedTitle?.trim() : '') ||
|
||||
liveTitle ||
|
||||
@@ -18,7 +23,12 @@ export function resolveTerminalTabTitle(
|
||||
}
|
||||
|
||||
export function resolveUnifiedTabLabel(
|
||||
tab: Pick<Tab, 'customLabel' | 'quickCommandLabel' | 'generatedLabel' | 'label'> | undefined,
|
||||
tab:
|
||||
| Pick<
|
||||
Tab,
|
||||
'customLabel' | 'quickCommandLabel' | 'generatedLabel' | 'label' | 'agentRenamedLabel'
|
||||
>
|
||||
| undefined,
|
||||
generatedTitlesEnabled: boolean,
|
||||
fallback = ''
|
||||
): string {
|
||||
@@ -26,6 +36,7 @@ export function resolveUnifiedTabLabel(
|
||||
return (
|
||||
tab?.customLabel?.trim() ||
|
||||
tab?.quickCommandLabel?.trim() ||
|
||||
(isAgentRenamedTerminalTitle(liveLabel, tab?.agentRenamedLabel) ? liveLabel : '') ||
|
||||
(isMeaningfulOpenCodeTerminalTitle(liveLabel) ? liveLabel : '') ||
|
||||
(generatedTitlesEnabled ? tab?.generatedLabel?.trim() : '') ||
|
||||
liveLabel ||
|
||||
|
||||
@@ -840,6 +840,8 @@ export type Tab = {
|
||||
label: string // display title (auto-derived from PTY or filename)
|
||||
generatedLabel?: string | null
|
||||
quickCommandLabel?: string | null
|
||||
/** Mirrors `TerminalTab.agentRenamedTitle` for unified-tab label resolution. */
|
||||
agentRenamedLabel?: string | null
|
||||
customLabel: string | null
|
||||
color: string | null
|
||||
sortOrder: number
|
||||
@@ -881,6 +883,11 @@ export type TerminalTab = {
|
||||
generatedTitle?: string | null
|
||||
/** Stable label from the tab-bar Quick Command that created this terminal. */
|
||||
quickCommandLabel?: string | null
|
||||
/** Latest name the agent's own rename command set for this session (Claude's
|
||||
* `/rename`). Distinct from `customTitle`, which is the user renaming the tab
|
||||
* inside Orca: this one only outranks `generatedTitle` while the agent's live
|
||||
* title still carries it. See agent-session-rename-title.ts. */
|
||||
agentRenamedTitle?: string | null
|
||||
customTitle: string | null
|
||||
color: string | null
|
||||
/** Pinned tabs survive "close others"; host-persisted for remote servers. */
|
||||
|
||||
@@ -73,6 +73,7 @@ const terminalTabSchema = z.object({
|
||||
defaultTitle: z.string().optional(),
|
||||
generatedTitle: z.string().nullable().optional(),
|
||||
quickCommandLabel: z.string().nullable().optional(),
|
||||
agentRenamedTitle: z.string().nullable().optional(),
|
||||
customTitle: z.string().nullable(),
|
||||
color: z.string().nullable(),
|
||||
isPinned: z.boolean().optional(),
|
||||
@@ -113,6 +114,7 @@ const tabSchema = z.object({
|
||||
label: z.string(),
|
||||
generatedLabel: z.string().nullable().optional(),
|
||||
quickCommandLabel: z.string().nullable().optional(),
|
||||
agentRenamedLabel: z.string().nullable().optional(),
|
||||
customLabel: z.string().nullable(),
|
||||
color: z.string().nullable(),
|
||||
sortOrder: z.number(),
|
||||
|
||||
Reference in New Issue
Block a user