entry.paneKey === paneKey
)
const liveRecoveryWorktreeId =
- entry.state === 'done' && !retainsPiRecoveryIdentity
+ entry.state === 'done' && !retainsResumableRecoveryIdentity
? null
: (entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey))
const liveRecoveryRecord = liveRecoveryWorktreeId
? sleepingRecordFromEntry({
state: s,
- // Why: a completed Pi turn leaves the TUI session alive — keep resume identity active without representing done as pending work.
- entry: retainsPiRecoveryIdentity
+ // Why: a completed resumable-agent turn leaves the TUI session alive — keep resume identity active without representing done as pending work.
+ entry: retainsResumableRecoveryIdentity
? { ...entry, state: 'working', prompt: '', lastAssistantMessage: undefined }
: entry,
worktreeId: liveRecoveryWorktreeId,
diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts
index 2f72e4b6276..43d18c5d9f0 100644
--- a/src/renderer/src/store/slices/store-cascades.test.ts
+++ b/src/renderer/src/store/slices/store-cascades.test.ts
@@ -3210,7 +3210,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
const state = store.getState()
expect(state.suppressedPtyExitIds['pty-agent']).toBeUndefined()
- expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined()
+ // Why: a done resumable agent retains its origin:'live' recovery anchor (#9454), so a failed shutdown rolls back to it, not to undefined — and must not commit a worktree-sleep record.
+ expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({
+ origin: 'live',
+ agent: 'claude',
+ providerSession: { key: 'session_id', id: 'sess-rollback-1' }
+ })
expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined()
})
@@ -3349,7 +3354,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
const state = store.getState()
expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-agent', 'pty-shell'])
- expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined()
+ // Why: done resumable agent keeps its origin:'live' anchor (#9454); a failed kill rolls back to it, not undefined, and never commits worktree-sleep.
+ expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({
+ origin: 'live',
+ agent: 'codex',
+ providerSession: { key: 'session_id', id: 'target-session' }
+ })
expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined()
expect(state.suppressedPtyExitIds['pty-agent']).toBeUndefined()
expect(mockRestorePtyDataHandlersAfterFailedShutdown).toHaveBeenCalledWith(handlerSnapshots)
@@ -3651,7 +3661,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
])
expect(state.suppressedPtyExitIds['remote:env-1@@terminal-1']).toBeUndefined()
expect(state.suppressedPtyExitIds['terminal-1']).toBeUndefined()
- expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined()
+ // Why: done resumable agent keeps its origin:'live' anchor (#9454); a failed target-only stop rolls back to it, not undefined, and never commits worktree-sleep.
+ expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({
+ origin: 'live',
+ agent: 'codex',
+ providerSession: { key: 'session_id', id: 'target-session' }
+ })
expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined()
})
@@ -4497,7 +4512,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
})
).rejects.toThrow('terminal_liveness_unavailable')
- expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined()
+ // Why: done resumable agent keeps its origin:'live' anchor (#9454); an inconclusive stop rolls back to it, not undefined, and never commits worktree-sleep.
+ expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({
+ origin: 'live',
+ agent: 'codex',
+ providerSession: { key: 'session_id', id: 'live-session' }
+ })
expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined()
expect(store.getState().suppressedPtyExitIds['pty-1']).toBeUndefined()
expect(mockApi.pty.kill).not.toHaveBeenCalled()
@@ -4551,7 +4571,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
})
).rejects.toThrow('exact_terminal_stop_unverified')
- expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined()
+ // Why: done resumable agent keeps its origin:'live' anchor (#9454); an unverified stop rolls back to it, not undefined, and never commits worktree-sleep.
+ expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({
+ origin: 'live',
+ agent: 'codex',
+ providerSession: { key: 'session_id', id: 'live-session' }
+ })
expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined()
expect(store.getState().suppressedPtyExitIds['pty-1']).toBeUndefined()
expect(mockApi.pty.kill).not.toHaveBeenCalled()
@@ -5033,7 +5058,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
})
).rejects.toThrow('stop failed')
- expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined()
+ // Why: done resumable agent keeps its origin:'live' anchor (#9454); a failed stop rolls back to it, not undefined, and never commits worktree-sleep.
+ expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({
+ origin: 'live',
+ agent: 'codex',
+ providerSession: { key: 'session_id', id: 'live-session' }
+ })
expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined()
expect(mockUnregisterPtyDataHandlers).not.toHaveBeenCalledWith(['pty-1'])
expect(mockApi.pty.kill).not.toHaveBeenCalled()
From a9d21b4d388516ca620f2ab51371decbdb897433 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Thu, 23 Jul 2026 17:04:01 -0700
Subject: [PATCH 06/17] docs: update Android APK to 0.0.32 (#10245)
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index aa4ee905281..0c8fb045410 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [Download APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
+- **Android:** [Download APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
---
From 34e0233cf52bc74f137a2896f17e9ea1346ed8f5 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 23 Jul 2026 17:07:25 -0700
Subject: [PATCH 07/17] feat(agent-rows): label sidebar and dashboard rows with
conversation names (#9989)
* feat(agent-rows): opt-in conversation-name labels for agent rows
Sidebar worktree-card and dashboard agent rows always show the last
message sent to the agent, so rows relabel on every turn and a 'continue'
prompt becomes the row's name. Add an opt-in Agents setting that labels
rows with the conversation name instead, resolved with the tab bar's
precedence: manual rename, quick-command label, OpenCode session title,
generated title (behind its existing setting), then the agent-set live
title. Live titles count only when they carry a real name - status
decoration is stripped, and pure status, identity-echo, spinner+cwd, and
placeholder titles fall back to the last-message label. Subagent child
rows keep their own descriptions.
Locale note: sync:localization-catalog also restored parity for keys
already missing on main (add-host, sleep-worktree copy).
* fix(agent-rows): read the live tab so late renames and titles surface
Row data patches live entries in place and keeps the tab snapshot from
row creation, so a rename or agent-set title landing after the row was
built never reached the conversation-name resolver. Select the current
tab from the store in the hook; retained rows without a live tab keep
the snapshot fallback.
* fix(agent-rows): reject status labels and bound lookup work
Reject native provider/status titles and Windows/UNC cwd frames so conversation-name mode falls back to the user's last message instead of relabeling rows with identity or path text. Keep default-off and subagent rows off the tab map, and share a WeakMap-backed tab index across mounted rows to avoid repeated linear scans on store writes.
* feat(agent-rows): make conversation names the default
Always prefer a usable conversation name for sidebar and dashboard agent rows, falling back to the last message when the resolver rejects a title. Remove the preference, settings UI, search copy, and catalog entries. Keep subagent labels unchanged and reject generic Terminal N placeholders so partial tab snapshots cannot hide meaningful prompts.
* chore(i18n): sync catalog after main merge
* fix(agent-rows): preserve same-tab child labels
---
.../dashboard/DashboardAgentRow.tsx | 4 +-
.../use-agent-row-conversation-name.test.ts | 156 ++++++++++++++++++
.../use-agent-row-conversation-name.ts | 48 ++++++
....lineage-agent-expansion-coupling.test.tsx | 5 +-
.../worktree-card-compact-agent-row.tsx | 11 +-
.../agent-row-conversation-name.test.ts | 130 +++++++++++++++
src/shared/agent-row-conversation-name.ts | 143 ++++++++++++++++
7 files changed, 491 insertions(+), 6 deletions(-)
create mode 100644 src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts
create mode 100644 src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts
create mode 100644 src/shared/agent-row-conversation-name.test.ts
create mode 100644 src/shared/agent-row-conversation-name.ts
diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
index e6a47533118..60724a27dcf 100644
--- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
+++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx
@@ -11,6 +11,7 @@ import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
+import { useAgentRowConversationName } from './use-agent-row-conversation-name'
// Why: narrow the dashboard's rollup states to shared dot states, defaulting unknowns to 'idle' so a row never crashes.
function asDotState(state: AgentStatusState | 'idle'): AgentDotState {
@@ -155,7 +156,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
)
const startedAt = agent.startedAt > 0 ? agent.startedAt : null
const doneAt = lastEnteredDoneAt(agent)
- const prompt = getAgentRowPrimaryText(agent.entry)
+ const conversationName = useAgentRowConversationName(agent)
+ const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry)
// Why: prompt is '' when unknown, so fall back to the state label to keep the row labeled.
const displayLabel = prompt || agentStateLabel(asDotState(agent.state))
const model = agent.entry.model?.trim() ?? ''
diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts
new file mode 100644
index 00000000000..a56d0c24cfd
--- /dev/null
+++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts
@@ -0,0 +1,156 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { AppState } from '@/store/types'
+import { useAgentRowConversationName } from './use-agent-row-conversation-name'
+import type { DashboardAgentRow } from './useDashboardData'
+
+const storeState = vi.hoisted(() => ({
+ current: { settings: {}, tabsByWorktree: {} } as {
+ settings: Record
+ tabsByWorktree: Record
+ }
+}))
+
+// Why: the mocked selector makes the hook a pure function, so tests can call it
+// directly without mounting a component.
+vi.mock('@/store', () => ({
+ useAppStore: (selector: (state: AppState) => unknown) =>
+ selector(storeState.current as unknown as AppState)
+}))
+
+function makeAgent(overrides: Partial = {}): DashboardAgentRow {
+ return {
+ paneKey: 'tab-1:leaf-1',
+ entry: { prompt: 'fix the sidebar' },
+ tab: { id: 'tab-1', worktreeId: 'wt-1', customTitle: 'Patient sync spike', title: '' },
+ agentType: 'claude',
+ state: 'working',
+ startedAt: 0,
+ ...overrides
+ } as DashboardAgentRow
+}
+
+beforeEach(() => {
+ storeState.current = { settings: {}, tabsByWorktree: {} }
+})
+
+describe('useAgentRowConversationName', () => {
+ it('returns the conversation name by default', () => {
+ expect(useAgentRowConversationName(makeAgent())).toBe('Patient sync spike')
+ })
+
+ it('ignores a retired stored opt-out value', () => {
+ storeState.current = { settings: { agentRowsUseConversationName: false }, tabsByWorktree: {} }
+ expect(useAgentRowConversationName(makeAgent())).toBe('Patient sync spike')
+ })
+
+ it('never reads the parent tab for subagent child rows', () => {
+ const tabsByWorktree = new Proxy(
+ {},
+ {
+ get: () => {
+ throw new Error('subagent rows must not read the parent tab')
+ }
+ }
+ )
+ storeState.current = { settings: {}, tabsByWorktree }
+ expect(useAgentRowConversationName(makeAgent({ rowSource: 'subagent' }))).toBeNull()
+ })
+
+ it('does not inherit a same-tab lineage parent conversation name', () => {
+ const tabsByWorktree = new Proxy(
+ {},
+ {
+ get: () => {
+ throw new Error('same-tab child rows must not read the parent tab')
+ }
+ }
+ )
+ storeState.current = { settings: {}, tabsByWorktree }
+ expect(
+ useAgentRowConversationName(
+ makeAgent({
+ entry: {
+ prompt: 'child prompt',
+ orchestration: {
+ parentPaneKey: 'tab-1:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
+ }
+ },
+ lineage: { depth: 1, isFirstSibling: true, isLastSibling: true, childCount: 0 }
+ } as Partial)
+ )
+ ).toBeNull()
+ })
+
+ it('uses a lineage child conversation name when it owns a separate tab', () => {
+ const agent = makeAgent({
+ entry: {
+ prompt: 'child prompt',
+ orchestration: {
+ parentPaneKey: 'parent-tab:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
+ }
+ },
+ lineage: { depth: 1, isFirstSibling: true, isLastSibling: true, childCount: 0 }
+ } as Partial)
+ expect(useAgentRowConversationName(agent)).toBe('Patient sync spike')
+ })
+
+ it('indexes one immutable tab array once across rows', () => {
+ let tabReads = 0
+ const tabs = new Proxy(
+ [
+ { id: 'tab-1', worktreeId: 'wt-1', customTitle: 'First name', title: '' },
+ { id: 'tab-2', worktreeId: 'wt-1', customTitle: 'Second name', title: '' }
+ ],
+ {
+ get: (target, property, receiver) => {
+ if (typeof property === 'string' && /^\d+$/.test(property)) {
+ tabReads += 1
+ }
+ return Reflect.get(target, property, receiver)
+ }
+ }
+ )
+ storeState.current = {
+ settings: {},
+ tabsByWorktree: { 'wt-1': tabs }
+ }
+
+ expect(useAgentRowConversationName(makeAgent())).toBe('First name')
+ const readsAfterFirstRow = tabReads
+ expect(
+ useAgentRowConversationName(
+ makeAgent({
+ paneKey: 'tab-2:leaf-1',
+ tab: { id: 'tab-2', worktreeId: 'wt-1', customTitle: null, title: '' }
+ } as Partial)
+ )
+ ).toBe('Second name')
+ expect(readsAfterFirstRow).toBeGreaterThan(0)
+ expect(tabReads).toBe(readsAfterFirstRow)
+ })
+
+ it('prefers the live store tab over the stale row snapshot', () => {
+ storeState.current = {
+ settings: {},
+ // Why: row data patches entries in place and keeps the creation-time tab
+ // snapshot; a rename landing after that must still surface.
+ tabsByWorktree: {
+ 'wt-1': [{ id: 'tab-1', worktreeId: 'wt-1', customTitle: 'Renamed later', title: '' }]
+ }
+ }
+ expect(useAgentRowConversationName(makeAgent())).toBe('Renamed later')
+ })
+
+ it('honors the generated-titles setting for generated names', () => {
+ const agent = makeAgent({
+ tab: { customTitle: null, title: '', generatedTitle: 'Fix intake flow' }
+ } as Partial)
+ storeState.current = { settings: {}, tabsByWorktree: {} }
+ expect(useAgentRowConversationName(agent)).toBeNull()
+ storeState.current = {
+ settings: { tabAutoGenerateTitle: true },
+ tabsByWorktree: {}
+ }
+ expect(useAgentRowConversationName(agent)).toBe('Fix intake flow')
+ })
+})
diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts
new file mode 100644
index 00000000000..820fea5da0b
--- /dev/null
+++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.ts
@@ -0,0 +1,48 @@
+import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name'
+import { parsePaneKey } from '../../../../shared/stable-pane-id'
+import { useAppStore } from '@/store'
+import type { AppState } from '@/store/types'
+import type { DashboardAgentRow } from './useDashboardData'
+
+type WorktreeTabs = NonNullable
+
+const tabIndexByTabs = new WeakMap>()
+
+function getIndexedTab(
+ tabs: WorktreeTabs | undefined,
+ tabId: string
+): WorktreeTabs[number] | undefined {
+ if (!tabs) {
+ return undefined
+ }
+ let tabIndex = tabIndexByTabs.get(tabs)
+ if (!tabIndex) {
+ tabIndex = new Map(tabs.map((tab) => [tab.id, tab]))
+ tabIndexByTabs.set(tabs, tabIndex)
+ }
+ return tabIndex.get(tabId)
+}
+
+/** The row's conversation name, or null when nothing usable exists. */
+export function useAgentRowConversationName(agent: DashboardAgentRow): string | null {
+ const parentPaneKey = agent.entry.orchestration?.parentPaneKey
+ const usesParentTab =
+ agent.lineage?.depth === 1 &&
+ parentPaneKey !== undefined &&
+ parsePaneKey(parentPaneKey)?.tabId === agent.tab.id
+ const cannotOwnTabName = agent.rowSource === 'subagent' || usesParentTab
+ const generatedTitlesEnabled = useAppStore(
+ (s) => !cannotOwnTabName && s.settings?.tabAutoGenerateTitle === true
+ )
+ const liveTab = useAppStore((s) =>
+ cannotOwnTabName
+ ? undefined
+ : getIndexedTab(s.tabsByWorktree[agent.tab.worktreeId], agent.tab.id)
+ )
+ // Why: synthetic and same-tab child rows do not own the parent tab's name.
+ if (cannotOwnTabName) {
+ return null
+ }
+ // Why: retained row snapshots need a fallback after their live tab disappears.
+ return getAgentRowConversationName(liveTab ?? agent.tab, agent.agentType, generatedTitlesEnabled)
+}
diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx
index 6190a6acd5a..82630b66d8b 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx
@@ -474,8 +474,9 @@ describe('WorktreeCard agent-list <-> child-worktrees expansion coupling', () =>
setAgentLineageState({ agentActivityDisplayMode: 'full' })
const { container } = await renderWorktreeList()
- // Sanity: real agent rows rendered (parent + its lineage child agent).
- expect(container.textContent).toContain('PARENT_AGENT_PROMPT')
+ // Same-tab lineage children must not inherit the parent's conversation name.
+ expect(container.textContent).toContain('Parent Terminal')
+ expect(container.textContent).not.toContain('PARENT_AGENT_PROMPT')
expect(container.textContent).toContain('CHILD_AGENT_PROMPT')
// Both controls exist and are independent DOM elements.
diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx
index 7669abb32c2..b95765881b4 100644
--- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx
+++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'
import { getAgentDotState } from './worktree-card-agent-summary'
import { translate } from '@/i18n/i18n'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
+import { useAgentRowConversationName } from '@/components/dashboard/use-agent-row-conversation-name'
import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp'
import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer'
@@ -27,8 +28,11 @@ function formatShortTimeAgo(ts: number, now: number): string {
return `${Math.floor(hours / 24)}d`
}
-function getCompactAgentPrimary(agent: DashboardAgentRowData): string {
- const prompt = getAgentRowPrimaryText(agent.entry)
+function getCompactAgentPrimary(
+ agent: DashboardAgentRowData,
+ conversationName: string | null
+): string {
+ const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry)
return prompt || agentStateLabel(getAgentDotState(agent))
}
@@ -116,7 +120,8 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
// "?" glyph. Nesting under the parent already conveys identity.
const hideIcon = hideIdentityIcon || agent.rowSource === 'subagent'
const dotState = getAgentDotState(agent)
- const primary = getCompactAgentPrimary(agent)
+ const conversationName = useAgentRowConversationName(agent)
+ const primary = getCompactAgentPrimary(agent, conversationName)
const isLineageChild = agent.lineage?.depth === 1
const secondary = getCompactAgentSecondary(agent)
const model = agent.entry.model?.trim() ?? ''
diff --git a/src/shared/agent-row-conversation-name.test.ts b/src/shared/agent-row-conversation-name.test.ts
new file mode 100644
index 00000000000..d838ff53dcf
--- /dev/null
+++ b/src/shared/agent-row-conversation-name.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from 'vitest'
+import {
+ getAgentRowConversationName,
+ type ConversationNameTab
+} from './agent-row-conversation-name'
+
+function makeTab(overrides: Partial = {}): ConversationNameTab {
+ return { customTitle: null, title: '', ...overrides }
+}
+
+describe('getAgentRowConversationName', () => {
+ it('prefers the manual tab rename over every other source', () => {
+ const tab = makeTab({
+ customTitle: 'Patient sync spike',
+ quickCommandLabel: 'Run tests',
+ generatedTitle: 'Fix intake flow',
+ title: '✳ Investigate replay bug'
+ })
+ expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Patient sync spike')
+ })
+
+ it('falls back to the quick-command label before titles', () => {
+ const tab = makeTab({ quickCommandLabel: 'Run tests', title: '✳ Investigate replay bug' })
+ expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Run tests')
+ })
+
+ it('keeps OpenCode semantic session titles whole', () => {
+ const tab = makeTab({ title: 'OC | build the release pipeline' })
+ expect(getAgentRowConversationName(tab, 'opencode', false)).toBe(
+ 'OC | build the release pipeline'
+ )
+ })
+
+ it('uses the generated title only when generated titles are enabled', () => {
+ const tab = makeTab({ generatedTitle: 'Fix intake flow', title: '✳ Investigate replay bug' })
+ expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Fix intake flow')
+ expect(getAgentRowConversationName(tab, 'claude', false)).toBe('Investigate replay bug')
+ })
+
+ it('strips leading status decoration from agent-set titles', () => {
+ expect(
+ getAgentRowConversationName(makeTab({ title: '✳ Fix patient intake flow' }), 'claude', false)
+ ).toBe('Fix patient intake flow')
+ expect(
+ getAgentRowConversationName(makeTab({ title: '⠋ Refactor replay guard' }), 'codex', false)
+ ).toBe('Refactor replay guard')
+ })
+
+ it('rejects spinner+cwd titles instead of surfacing paths as names', () => {
+ expect(
+ getAgentRowConversationName(makeTab({ title: '⠋ ~/orca/workspaces' }), 'codex', false)
+ ).toBeNull()
+ expect(
+ getAgentRowConversationName(makeTab({ title: '/Users/dev/repo' }), 'codex', false)
+ ).toBeNull()
+ expect(
+ getAgentRowConversationName(makeTab({ title: 'C:\\repos\\orca' }), 'codex', false)
+ ).toBeNull()
+ expect(
+ getAgentRowConversationName(makeTab({ title: 'orca/workspaces' }), 'codex', false)
+ ).toBeNull()
+ expect(
+ getAgentRowConversationName(
+ makeTab({ title: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }),
+ 'codex',
+ false
+ )
+ ).toBeNull()
+ expect(
+ getAgentRowConversationName(makeTab({ title: 'repos\\orca' }), 'codex', false)
+ ).toBeNull()
+ })
+
+ it('accepts multi-word titles that merely contain a slash', () => {
+ expect(
+ getAgentRowConversationName(makeTab({ title: 'Fix a/b toggle in settings' }), 'codex', false)
+ ).toBe('Fix a/b toggle in settings')
+ })
+
+ it('rejects synthetic status titles', () => {
+ expect(
+ getAgentRowConversationName(makeTab({ title: 'Codex ready' }), 'codex', false)
+ ).toBeNull()
+ expect(
+ getAgentRowConversationName(makeTab({ title: 'Codex - action required' }), 'codex', false)
+ ).toBeNull()
+ expect(
+ getAgentRowConversationName(makeTab({ title: 'Cursor Agent' }), 'cursor', false)
+ ).toBeNull()
+ })
+
+ it('rejects identity-echo, management, and placeholder titles', () => {
+ expect(getAgentRowConversationName(makeTab({ title: 'Claude' }), 'claude', false)).toBeNull()
+ expect(
+ getAgentRowConversationName(makeTab({ title: '✳ Claude Code' }), 'claude', false)
+ ).toBeNull()
+ expect(
+ getAgentRowConversationName(
+ makeTab({ title: 'Claude Code - action required' }),
+ 'claude',
+ false
+ )
+ ).toBeNull()
+ expect(
+ getAgentRowConversationName(makeTab({ title: '✦ Gemini CLI' }), 'gemini', false)
+ ).toBeNull()
+ expect(
+ getAgentRowConversationName(makeTab({ title: '◇ Ready (orca)' }), 'gemini', false)
+ ).toBeNull()
+ expect(
+ getAgentRowConversationName(makeTab({ title: 'claude agents' }), 'claude', false)
+ ).toBeNull()
+ expect(getAgentRowConversationName(makeTab({ title: 'Agent' }), 'claude', false)).toBeNull()
+ })
+
+ it('rejects empty, glyph-only, and default terminal titles', () => {
+ expect(getAgentRowConversationName(makeTab(), 'claude', false)).toBeNull()
+ expect(getAgentRowConversationName(makeTab({ title: '✳' }), 'claude', false)).toBeNull()
+ expect(
+ getAgentRowConversationName(makeTab({ title: 'Terminal 1' }), 'claude', false)
+ ).toBeNull()
+ expect(
+ getAgentRowConversationName(
+ makeTab({ title: 'Terminal 2', defaultTitle: 'Terminal 2' }),
+ 'claude',
+ false
+ )
+ ).toBeNull()
+ })
+})
diff --git a/src/shared/agent-row-conversation-name.ts b/src/shared/agent-row-conversation-name.ts
new file mode 100644
index 00000000000..95e5dfcfb28
--- /dev/null
+++ b/src/shared/agent-row-conversation-name.ts
@@ -0,0 +1,143 @@
+// Resolves the stable "conversation name" an agent row can show instead of the
+// live last-message preview. Sources, in the same precedence the tab bar uses
+// (tab-title-resolution.ts): manual rename → quick-command label → OpenCode's
+// semantic session title → Orca's generated title → the agent-set live title.
+// Live titles are accepted only when they carry a real name — pure status,
+// identity-echo, and spinner/cwd titles yield null so callers keep the
+// last-message label.
+import type { AgentType } from './agent-status-types'
+import { isClaudeManagementTitle } from './agent-title-core'
+import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
+import { formatAgentTypeLabel } from './agent-type-label'
+import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title'
+import { SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title'
+import type { TerminalTab } from './types'
+
+export type ConversationNameTab = Pick<
+ TerminalTab,
+ 'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title' | 'defaultTitle'
+>
+
+// Why: synthetic status titles ("Codex ready", "Cursor - action required") are
+// state, not names. Precomputed once; the profile table is a module constant.
+const SYNTHETIC_STATUS_TITLES_LOWER: ReadonlySet = new Set(
+ Object.values(SYNTHETIC_AGENT_TITLE_PROFILES).flatMap((profile) => [
+ profile.workingLabel.toLowerCase(),
+ profile.permissionLabel.toLowerCase(),
+ profile.idleLabel.toLowerCase()
+ ])
+)
+
+// Why: retained rows without a live tab synthesize `title: 'Agent'`
+// (worktree-agent-row-fallback-tab.ts); it is a placeholder, not a name.
+const FALLBACK_TAB_TITLE_LOWER = 'agent'
+
+const AGENT_IDENTITY_ALIASES_LOWER: Readonly> = {
+ claude: ['claude code'],
+ gemini: ['gemini cli']
+}
+
+const STATUS_WITH_CONTEXT_RE = /^(?:ready|idle|done)(?:\s+\([^)]*\))?$/i
+const DEFAULT_TERMINAL_TITLE_RE = /^terminal \d+$/i
+
+function isIdentityStatusTitle(titleLower: string, identityLower: string): boolean {
+ return (
+ titleLower === identityLower ||
+ titleLower === `${identityLower} ready` ||
+ titleLower === `${identityLower} idle` ||
+ titleLower === `${identityLower} done` ||
+ titleLower === `${identityLower} working` ||
+ titleLower === `${identityLower} thinking` ||
+ titleLower === `${identityLower} running` ||
+ titleLower === `${identityLower} - action required`
+ )
+}
+
+function isAgentIdentityStatusTitle(
+ titleLower: string,
+ agentType: AgentType | null | undefined,
+ agentTypeLabelLower: string
+): boolean {
+ if (isIdentityStatusTitle(titleLower, agentTypeLabelLower)) {
+ return true
+ }
+ return (
+ AGENT_IDENTITY_ALIASES_LOWER[agentType ?? '']?.some((identity) =>
+ isIdentityStatusTitle(titleLower, identity)
+ ) ?? false
+ )
+}
+
+function isCwdLikeTitle(title: string): boolean {
+ // Hook-less agents over SSH surface spinner+cwd titles (#8711); once the
+ // spinner is stripped, what remains is a path, not a conversation name.
+ if (/^(?:~|[\\/]|[A-Za-z]:[\\/])/.test(title)) {
+ return true
+ }
+ // A single path-ish token ("orca/workspaces") is still a cwd, not a name.
+ return !/\s/.test(title) && /[\\/]/.test(title)
+}
+
+function conversationNameFromLiveTitle(
+ liveTitle: string,
+ agentType: AgentType | null | undefined,
+ agentTypeLabelLower: string,
+ defaultTitle: string | undefined
+): string | null {
+ const stripped = stripLeadingAgentTitleDecorationOrEmpty(liveTitle.trim()).trim()
+ if (!stripped) {
+ return null
+ }
+ const lower = stripped.toLowerCase()
+ if (
+ SYNTHETIC_STATUS_TITLES_LOWER.has(lower) ||
+ lower === FALLBACK_TAB_TITLE_LOWER ||
+ isAgentIdentityStatusTitle(lower, agentType, agentTypeLabelLower) ||
+ STATUS_WITH_CONTEXT_RE.test(stripped) ||
+ DEFAULT_TERMINAL_TITLE_RE.test(stripped) ||
+ isClaudeManagementTitle(stripped) ||
+ isCwdLikeTitle(stripped)
+ ) {
+ return null
+ }
+ if (defaultTitle && stripped === defaultTitle.trim()) {
+ return null
+ }
+ return stripped
+}
+
+/**
+ * The conversation name for an agent row, or null when no usable name exists
+ * and the caller should keep its last-message label.
+ */
+export function getAgentRowConversationName(
+ tab: ConversationNameTab,
+ agentType: AgentType | null | undefined,
+ generatedTitlesEnabled: boolean
+): string | null {
+ const customTitle = tab.customTitle?.trim()
+ if (customTitle) {
+ return customTitle
+ }
+ const quickCommandLabel = tab.quickCommandLabel?.trim()
+ if (quickCommandLabel) {
+ return quickCommandLabel
+ }
+ const liveTitle = tab.title?.trim() ?? ''
+ if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) {
+ return liveTitle
+ }
+ const generatedTitle = generatedTitlesEnabled ? tab.generatedTitle?.trim() : ''
+ if (generatedTitle) {
+ return generatedTitle
+ }
+ if (!liveTitle) {
+ return null
+ }
+ return conversationNameFromLiveTitle(
+ liveTitle,
+ agentType,
+ formatAgentTypeLabel(agentType).toLowerCase(),
+ tab.defaultTitle
+ )
+}
From f26ea6a4a521fb0b05759d05768df8418afd4f3f Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 23 Jul 2026 17:07:27 -0700
Subject: [PATCH 08/17] fix(terminal): keep SSH reconnect banner above the
xterm canvas (#10242)
Portal the non-blocking reconnect banner into each pane container so WebGL
compositing cannot paint terminal text through it, raise z-index above
pane-local chrome, and use a fully opaque card background.
---
.../components/terminal-pane/TerminalPane.tsx | 27 ++++++++++++-------
.../TerminalSshReconnectOverlay.test.tsx | 2 +-
.../TerminalSshReconnectOverlay.tsx | 5 ++--
3 files changed, 21 insertions(+), 13 deletions(-)
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
index 3c556118d18..a7543e8df83 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -2896,16 +2896,23 @@ export default function TerminalPane({
onRestartDaemon={() => daemonActions.setPending('restart')}
/>
)}
- {showSshReconnectOverlay && sshReconnectTargetId && sshReconnectStatus ? (
-
- ) : null}
+ {/* Why: portal into the pane so the banner stacks above the xterm canvas (sibling mount painted under WebGL). */}
+ {showSshReconnectOverlay && sshReconnectTargetId && sshReconnectStatus
+ ? managedPanes.map((pane) =>
+ createPortal(
+ ,
+ pane.container,
+ `ssh-reconnect-${pane.id}`
+ )
+ )
+ : null}
{isActive && (
{
expect(screen.getByText(/This terminal is waiting for devbox/)).toBeInTheDocument()
expect(screen.getByRole('status')).toBeInTheDocument()
const banner = container.querySelector('[data-terminal-ssh-reconnect-banner="disconnected"]')
- expect(banner).toHaveClass('inset-x-3', 'bottom-3')
+ expect(banner).toHaveClass('inset-x-3', 'bottom-3', 'z-40')
expect(banner).not.toHaveClass('inset-0', 'bg-background/75')
await user.click(screen.getByRole('button', { name: 'Connect' }))
diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx
index a90e16c2a04..b0506a28a37 100644
--- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx
@@ -136,13 +136,14 @@ export function TerminalSshReconnectOverlay({
}
}, [isConnecting, mountedRef, setSshConnectionState, sshOwnerEnvironmentId, targetId])
+ // Why: z-40 clears pane-local chrome (focus rim z-30); bg-card is fully opaque so terminal text cannot paint through.
return (
From 43f626574b7b509036afc97b423550709304f942 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 24 Jul 2026 00:34:31 +0000
Subject: [PATCH 09/17] Update README downloads badge
---
docs/assets/readme-downloads.svg | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index 790c0df3df0..4d25959e53c 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
- |