Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,21 @@ describe('rowConversationName', () => {
'Redis cache strategy'
)
})

it('publishes a generated title only for the split pane that produced it', () => {
const paneTitles = { 1: '\u2733 Linear work log', 2: '\u2733 Redis cache strategy' }
const tab = {
...TAB,
aiVaultTitle: null,
generatedTitle: 'Fix intake flow',
generatedTitlePaneKey: makePaneKey(TAB_ID, LEAF_A)
}

expect(
rowConversationName({ ...row(LEAF_A, 'session-a'), tab }, true, LAYOUT, paneTitles)
).toBe('Fix intake flow')
expect(
rowConversationName({ ...row(LEAF_B, 'session-b'), tab }, true, LAYOUT, paneTitles)
).toBe('Redis cache strategy')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export function rowConversationName(
row.agentType,
generatedTitlesEnabled,
paneLiveTitle,
row.entry.providerSession?.id
row.entry.providerSession?.id,
row.paneKey
) ?? undefined
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,26 @@ describe('useAgentRowConversationName', () => {
expect(useAgentRowConversationName(sessionB)).toBe('Redis cache strategy')
})

it('gives a generated title only to the pane whose prompt produced it', () => {
setSplitStore('\u2733 Linear work log')
storeState.current.settings = { tabAutoGenerateTitle: true }
storeState.current.tabsByWorktree['wt-1'][0] = {
id: 'tab-1',
worktreeId: 'wt-1',
customTitle: null,
title: '\u2733 Linear work log',
generatedTitle: 'Fix intake flow',
generatedTitlePaneKey: `tab-1:${LEAF_A}`
}

expect(useAgentRowConversationName(splitRow(LEAF_A, '\u2733 Linear work log'))).toBe(
'Fix intake flow'
)
expect(useAgentRowConversationName(splitRow(LEAF_B, '\u2733 Linear work log'))).toBe(
'Redis cache strategy'
)
})

it('does not rename the sibling row when the other pane is clicked', () => {
// Clicking pane B re-syncs the tab title to B's; both rows must be unmoved.
setSplitStore('\u2733 Redis cache strategy')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string |
agent.agentType,
generatedTitlesEnabled,
paneLiveTitle,
agent.entry.providerSession?.id
agent.entry.providerSession?.id,
agent.paneKey
)
}
19 changes: 19 additions & 0 deletions src/renderer/src/lib/activity-thread-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,25 @@ describe('getActivityThreadTaskTitle', () => {
).toBe('Refactor auth middleware')
})

it("names a split pane from its own prompt, not a sibling pane's generated title", () => {
const splitTab = { ...tab, generatedTitlePaneKey: 'tab-1:pane-a' }
const entry = { prompt: 'Design a Redis cache strategy', stateHistory: [] }
expect(
getActivityThreadTaskTitle({
entry: { ...entry, paneKey: 'tab-1:pane-b' },
tab: splitTab,
generatedTitlesEnabled: true
})
).toBe('Design a Redis cache strategy')
expect(
getActivityThreadTaskTitle({
entry: { ...entry, paneKey: 'tab-1:pane-a' },
tab: splitTab,
generatedTitlesEnabled: true
})
).toBe('Refactor auth middleware')
})

it('ignores terse live prompts and uses generated title or history', () => {
expect(
getActivityThreadTaskTitle({
Expand Down
13 changes: 10 additions & 3 deletions src/renderer/src/lib/activity-thread-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
} from '../../../shared/agent-status-types'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { Worktree } from '../../../shared/worktree/types'
import { isGeneratedTabTitleOwnedByPane } from '../../../shared/agent-tab-title'
import {
getAgentRowPrimaryText,
isOrcaDispatchPrompt,
Expand Down Expand Up @@ -95,8 +96,11 @@ export function getActivityThreadWorkspaceTitle(

/** Stable task identity for Activity sidebar rows — not the latest follow-up turn. */
export function getActivityThreadTaskTitle(args: {
entry: Pick<AgentStatusEntry, 'orchestration' | 'prompt' | 'stateHistory'>
tab: Pick<TerminalTab, 'customTitle' | 'generatedTitle' | 'title' | 'defaultTitle'>
entry: Pick<AgentStatusEntry, 'orchestration' | 'prompt' | 'stateHistory'> & { paneKey?: string }
tab: Pick<
TerminalTab,
'customTitle' | 'generatedTitle' | 'generatedTitlePaneKey' | 'title' | 'defaultTitle'
>
generatedTitlesEnabled: boolean
}): string {
const customTitle = args.tab.customTitle?.trim()
Expand All @@ -111,7 +115,10 @@ export function getActivityThreadTaskTitle(args: {

// Why: respect the user's tabAutoGenerateTitle setting — a disabled generated
// title must not resurface here (mirrors resolveTerminalTabTitle's gate).
const generatedTitle = args.generatedTitlesEnabled ? args.tab.generatedTitle?.trim() : ''
const generatedTitle =
args.generatedTitlesEnabled && isGeneratedTabTitleOwnedByPane(args.tab, args.entry.paneKey)
? args.tab.generatedTitle?.trim()
: ''
if (generatedTitle) {
return generatedTitle
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { buildMirroredTerminalTabs } from './terminal-build'
import { toWebTerminalSurfaceTabId } from '../web-terminal-surface-id'

const HOST_TAB = 'host-tab-1'
const SNAPSHOT: RuntimeMobileSessionTabsResult = {
worktree: 'repo-1::worktree-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: null,
activeTabType: null,
tabs: [
{
type: 'terminal',
id: 'surface-1',
parentTabId: HOST_TAB,
leafId: 'leaf-1',
title: 'Terminal',
status: 'ready',
terminal: 'handle-1',
isActive: true
}
]
}

function makeExisting(id: string): TerminalTab {
return {
id,
ptyId: null,
worktreeId: 'repo-1::worktree-1',
title: 'Terminal',
generatedTitle: 'Fix intake flow',
generatedTitlePaneKey: `${id}:leaf-1`,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}

describe('buildMirroredTerminalTabs generated title', () => {
function rebuild(existing: TerminalTab, lookupId: string): TerminalTab | undefined {
const [mirrored] = buildMirroredTerminalTabs(
SNAPSHOT,
'env-1',
new Map([[lookupId, existing]]),
{},
0,
1_000
)
return mirrored?.tab
}

it('keeps the client-local generated title and its source pane across a host rebuild', () => {
const localTabId = toWebTerminalSurfaceTabId(HOST_TAB)
expect(rebuild(makeExisting(localTabId), localTabId)).toMatchObject({
generatedTitle: 'Fix intake flow',
generatedTitlePaneKey: `${localTabId}:leaf-1`
})
})

it('drops a source pane recorded under another tab id so the title is not hidden', () => {
const tab = rebuild(makeExisting(HOST_TAB), HOST_TAB)
expect(tab?.generatedTitle).toBe('Fix intake flow')
expect(tab && 'generatedTitlePaneKey' in tab).toBe(false)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean {
a.quickCommandLabel === b.quickCommandLabel &&
a.startupCwd === b.startupCwd &&
a.generatedTitle === b.generatedTitle &&
a.generatedTitlePaneKey === b.generatedTitlePaneKey &&
a.aiVaultTitle?.agent === b.aiVaultTitle?.agent &&
a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId &&
a.aiVaultTitle?.title === b.aiVaultTitle?.title &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ export function buildMirroredTerminalTabs(
// Why: the host transport carries no generated title, so rebuilding the tab
// without this dropped the client's agent-prompt label on every snapshot.
...(existing?.generatedTitle ? { generatedTitle: existing.generatedTitle } : {}),
// Why: the source pane key embeds the tab id, so a record found under another id would hide the title.
...(existing?.generatedTitle && existing.id === localTabId && existing.generatedTitlePaneKey
? { generatedTitlePaneKey: existing.generatedTitlePaneKey }
: {}),
...(existing?.aiVaultTitle ? { aiVaultTitle: existing.aiVaultTitle } : {}),
// Why: the recovery ledger is client-local and the host carries none, so
// rebuilding without it would restore this tab's remount allowance on
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'

const WORKTREE_ID = 'repo1::/path/wt1'
const LEAF_A = '11111111-1111-4111-8111-111111111111'
const LEAF_B = '22222222-2222-4222-8222-222222222222'
const DISPATCH_PROMPT = `You are working inside Orca, a multi-agent IDE. You are a dispatched worker.
Your task ID is: task-1

=== CLI COMMANDS ===
orca orchestration send --to term_parent

=== TASK ===
Implement the detailed worker instructions that should not be the short label`

function seedSplitTab(store: ReturnType<typeof createTestStore>): string {
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: true },
worktreesByRepo: {
repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })]
}
})
return store.getState().createTab(WORKTREE_ID).id
}

function generatedTitleOf(store: ReturnType<typeof createTestStore>) {
const { generatedTitle, generatedTitlePaneKey } = store.getState().tabsByWorktree[WORKTREE_ID][0]
return { generatedTitle, generatedTitlePaneKey }
}

describe('generated tab title source pane', () => {
afterEach(() => {
vi.useRealTimers()
})

it('records the pane whose prompt produced the title and keeps it when a sibling starts', () => {
vi.useFakeTimers()
const store = createTestStore()
const tabId = seedSplitTab(store)

store.getState().setAgentStatus(makePaneKey(tabId, LEAF_A), {
state: 'working',
prompt: 'Fix the patient intake flow in the portal',
agentType: 'claude'
})
store.getState().setAgentStatus(makePaneKey(tabId, LEAF_B), {
state: 'working',
prompt: 'Design a Redis cache strategy for sessions',
agentType: 'codex'
})

expect(generatedTitleOf(store)).toEqual({
generatedTitle: 'Fix the patient intake flow in the',
generatedTitlePaneKey: makePaneKey(tabId, LEAF_A)
})
})

it('moves the source to the pane whose dispatch replaces the title', () => {
vi.useFakeTimers()
const store = createTestStore()
const tabId = seedSplitTab(store)
const paneB = makePaneKey(tabId, LEAF_B)

store.getState().setAgentStatus(makePaneKey(tabId, LEAF_A), {
state: 'working',
prompt: 'Fix the patient intake flow in the portal',
agentType: 'claude'
})
store.getState().setAgentStatus(paneB, {
state: 'working',
prompt: DISPATCH_PROMPT,
agentType: 'codex'
})
store.getState().setRuntimeAgentOrchestrationByPaneKey({
[paneB]: { taskId: 'task-1', dispatchId: 'ctx-1', displayName: 'Better worker label' }
})

expect(generatedTitleOf(store)).toEqual({
generatedTitle: 'Better worker label',
generatedTitlePaneKey: paneB
})
})
})
6 changes: 5 additions & 1 deletion src/renderer/src/store/slices/terminal-tab-title-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,11 @@ export function applyGeneratedTabTitleUpdates(
if (!generatedTitle || existingGeneratedTitle === generatedTitle) {
continue
}
updateStageTabs(stage, tabIndexes, (tab) => ({ ...tab, generatedTitle }))
updateStageTabs(stage, tabIndexes, (tab) => ({
...tab,
generatedTitle,
generatedTitlePaneKey: paneKey
}))
updateStageUnifiedLabel(stage, tabId, 'generatedLabel', generatedTitle)
}
return finishTitleStages(state, stages)
Expand Down
25 changes: 25 additions & 0 deletions src/shared/agent-row-conversation-name.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,31 @@ describe('getAgentRowConversationName', () => {
expect(getAgentRowConversationName(generated, 'claude', true, null)).toBe('Fix intake flow')
})

it('gives a generated title only to the pane whose prompt produced it', () => {
const tab = makeTab({
generatedTitle: 'Fix intake flow',
generatedTitlePaneKey: 'tab-1:pane-a'
})
expect(getAgentRowConversationName(tab, 'claude', true, null, undefined, 'tab-1:pane-a')).toBe(
'Fix intake flow'
)
expect(
getAgentRowConversationName(
tab,
'claude',
true,
'✳ Redis cache strategy',
undefined,
'tab-1:pane-b'
)
).toBe('Redis cache strategy')
expect(
getAgentRowConversationName(tab, 'claude', true, null, undefined, 'tab-1:pane-b')
).toBeNull()
// A caller that names no pane keeps today's tab-wide behavior, like an omitted pane title.
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Fix intake flow')
})

it('strips leading status decoration from agent-set titles', () => {
expect(
getAgentRowConversationName(makeTab({ title: '✳ Fix patient intake flow' }), 'claude', false)
Expand Down
17 changes: 14 additions & 3 deletions src/shared/agent-row-conversation-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,21 @@
import type { AgentType } from './agent-status-types'
import { isClaudeManagementTitle } from './agent-title-core'
import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
import { isGeneratedTabTitleOwnedByPane } from './agent-tab-title'
import { formatAgentTypeLabel } from './agent-type-label'
import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title'
import { SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title'
import type { TerminalTab } from './terminal-tab-types'

export type ConversationNameTab = Pick<
TerminalTab,
'customTitle' | 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title' | 'defaultTitle'
| 'customTitle'
| 'quickCommandLabel'
| 'aiVaultTitle'
| 'generatedTitle'
| 'generatedTitlePaneKey'
| 'title'
| 'defaultTitle'
>

// Why: synthetic status titles ("Codex ready", "Cursor - action required") are
Expand Down Expand Up @@ -121,7 +128,8 @@ export function getAgentRowConversationName(
// single-pane tab) keeps the tab title. Tab-owned names above are unaffected:
// the user gave those to the whole tab and they do not flip on focus.
paneLiveTitle?: string | null,
providerSessionId?: string
providerSessionId?: string,
paneKey?: string
): string | null {
const customTitle = tab.customTitle?.trim()
if (customTitle) {
Expand All @@ -147,7 +155,10 @@ export function getAgentRowConversationName(
) {
return providerTitle
}
const generatedTitle = generatedTitlesEnabled ? tab.generatedTitle?.trim() : ''
const generatedTitle =
generatedTitlesEnabled && isGeneratedTabTitleOwnedByPane(tab, paneKey)
? tab.generatedTitle?.trim()
: ''
if (generatedTitle) {
return generatedTitle
}
Expand Down
Loading
Loading