diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index c39315633..81b1dee8c 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -118,7 +118,7 @@ import {
shouldRecoverTerminalOnAppStateChange
} from '../../../../src/terminal/terminal-foreground-recovery'
import { MobileBrowserPane } from '../../../../src/browser/MobileBrowserPane'
-import { isBlankBrowserUrl, normalizeBrowserUrl } from '../../../../src/browser/browser-url'
+import { normalizeBrowserUrl } from '../../../../src/browser/browser-url'
import { StatusDot } from '../../../../src/components/StatusDot'
import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon'
@@ -153,6 +153,10 @@ import {
mobileSessionTabsEqual,
terminalRecordsEqual
} from '../../../../src/session/mobile-terminal-records'
+import {
+ getMobileSessionTabTitle,
+ resolveMobileTerminalTabAgentId
+} from '../../../../src/session/mobile-terminal-tab-agent'
import {
buildMobileNewTabAgentOptions,
type MobileNewTabAgentOption,
@@ -290,26 +294,6 @@ function getActiveTabIdForHandle(
)
}
-function getMobileSessionTabTitle(tab: MobileSessionTab): string {
- if (tab.type === 'browser') {
- const title = tab.title.trim()
- if (title && !isBlankBrowserUrl(title)) {
- return title
- }
- if (isBlankBrowserUrl(tab.url)) {
- return 'New Browser'
- }
- return 'Browser'
- }
- if (tab.type === 'markdown') {
- return tab.title || 'Markdown'
- }
- if (tab.type === 'file') {
- return tab.title || 'File'
- }
- return tab.title || 'Terminal'
-}
-
function MarkdownReader({
documentId,
doc,
@@ -4693,6 +4677,11 @@ export default function SessionScreen() {
{t.type === 'file' && (
)}
+ {t.type === 'terminal' &&
+ (() => {
+ const agentId = resolveMobileTerminalTabAgentId(t)
+ return agentId ? : null
+ })()}
{
+ return {
+ type: 'terminal',
+ id: options.id ?? 'tab-1',
+ title,
+ terminal: 'pty-1',
+ isActive: true,
+ ...(options.launchAgent ? { launchAgent: options.launchAgent } : {}),
+ ...(options.agentType === undefined ? {} : { agentStatus: agentStatus(options.agentType) })
+ }
+}
+
+describe('resolveMobileTerminalTabAgentId', () => {
+ it('uses hook-reported agent identity before title fallback', () => {
+ expect(resolveMobileTerminalTabAgentId(terminalTab('Terminal', { agentType: 'codex' }))).toBe(
+ 'codex'
+ )
+ })
+
+ it('uses host launch identity before title fallback', () => {
+ expect(
+ resolveMobileTerminalTabAgentId(terminalTab('Terminal', { launchAgent: 'claude' }))
+ ).toBe('claude')
+ })
+
+ it('keeps hook identity authoritative over launch identity', () => {
+ expect(
+ resolveMobileTerminalTabAgentId(
+ terminalTab('Terminal', { agentType: 'codex', launchAgent: 'claude' })
+ )
+ ).toBe('codex')
+ })
+
+ it('falls back to explicit terminal titles when hook identity is unavailable', () => {
+ expect(resolveMobileTerminalTabAgentId(terminalTab('✦ Gemini CLI'))).toBe('gemini')
+ })
+
+ it('does not let title fallback override launch identity', () => {
+ expect(
+ resolveMobileTerminalTabAgentId(terminalTab('Codex ready', { launchAgent: 'claude' }))
+ ).toBe('claude')
+ })
+
+ it('treats unknown hook identity as unavailable', () => {
+ expect(
+ resolveMobileTerminalTabAgentId(terminalTab('✳ investigating', { agentType: 'unknown' }))
+ ).toBeNull()
+ expect(
+ resolveMobileTerminalTabAgentId(terminalTab('Codex ready', { agentType: 'unknown' }))
+ ).toBe('codex')
+ })
+})
+
+describe('getMobileSessionTabTitle', () => {
+ it('strips leading agent decorations when an icon is shown', () => {
+ expect(getMobileSessionTabTitle(terminalTab('✦ Gemini CLI'))).toBe('Gemini CLI')
+ })
+
+ it('falls back for glyph-only agent titles on mobile', () => {
+ expect(getMobileSessionTabTitle(terminalTab('✳', { agentType: 'claude' }))).toBe('Terminal')
+ })
+
+ it('strips decorations for launch-owned terminal tabs before hooks arrive', () => {
+ expect(getMobileSessionTabTitle(terminalTab('✳ working', { launchAgent: 'claude' }))).toBe(
+ 'working'
+ )
+ })
+
+ it('keeps generic status titles unstripped when no agent identity is known', () => {
+ expect(getMobileSessionTabTitle(terminalTab('✳ investigating'))).toBe('✳ investigating')
+ })
+
+ it('preserves browser title fallbacks after moving the helper out of the route', () => {
+ const blankBrowserTab: Extract = {
+ type: 'browser',
+ id: 'browser-1',
+ title: '',
+ url: 'about:blank',
+ browserWorkspaceId: 'browser-workspace-1',
+ browserPageId: null,
+ loading: false,
+ canGoBack: false,
+ canGoForward: false,
+ isActive: true
+ }
+
+ expect(getMobileSessionTabTitle(blankBrowserTab)).toBe('New Browser')
+ })
+})
diff --git a/mobile/src/session/mobile-terminal-tab-agent.ts b/mobile/src/session/mobile-terminal-tab-agent.ts
new file mode 100644
index 000000000..1ca19521b
--- /dev/null
+++ b/mobile/src/session/mobile-terminal-tab-agent.ts
@@ -0,0 +1,57 @@
+import { stripLeadingAgentTitleDecorationOrEmpty } from '../../../src/shared/agent-title-decoration'
+import { resolveExplicitTerminalTitleAgentType } from '../../../src/shared/terminal-title-agent-type'
+import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
+import type { TuiAgent } from '../../../src/shared/types'
+import { isBlankBrowserUrl } from '../browser/browser-url'
+import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
+
+// Why: tab identity + title cleaning uses the same shared glyph/label maps as
+// desktop, so the two platforms do not drift on which titles identify agents.
+
+/**
+ * Resolve which coding agent a mobile terminal tab is running, for its tab
+ * icon. Hook-reported `agentType` is the authoritative signal; the OSC title is
+ * the fallback for sessions without hook status or a host launch identity.
+ * Returns null when no agent is identified (plain shell / unknown), so the tab
+ * keeps its text-only label.
+ */
+export function resolveMobileTerminalTabAgentId(tab: {
+ title: string
+ agentStatus?: AgentStatusEntry | null
+ launchAgent?: TuiAgent
+}): string | null {
+ const hookAgentType = tab.agentStatus?.agentType?.trim()
+ if (hookAgentType && hookAgentType !== 'unknown') {
+ return hookAgentType
+ }
+ if (tab.launchAgent) {
+ return tab.launchAgent
+ }
+ return resolveExplicitTerminalTitleAgentType(tab.title)
+}
+
+export function getMobileSessionTabTitle(tab: MobileSessionTab): string {
+ if (tab.type === 'browser') {
+ const title = tab.title.trim()
+ if (title && !isBlankBrowserUrl(title)) {
+ return title
+ }
+ if (isBlankBrowserUrl(tab.url)) {
+ return 'New Browser'
+ }
+ return 'Browser'
+ }
+ if (tab.type === 'markdown') {
+ return tab.title || 'Markdown'
+ }
+ if (tab.type === 'file') {
+ return tab.title || 'File'
+ }
+ // Why: strip the leading agent status glyph (✳ etc.) once the tab shows the
+ // provider icon. Mobile falls back for glyph-only titles because iOS can
+ // render the bare status glyph as a stray colored box beside the icon.
+ if (resolveMobileTerminalTabAgentId(tab)) {
+ return stripLeadingAgentTitleDecorationOrEmpty(tab.title) || 'Terminal'
+ }
+ return tab.title || 'Terminal'
+}
diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx
index be0d43c82..e19f12580 100644
--- a/src/renderer/src/components/tab-bar/SortableTab.tsx
+++ b/src/renderer/src/components/tab-bar/SortableTab.tsx
@@ -3,7 +3,7 @@ import { useSortable } from '@dnd-kit/sortable'
import { X, Minimize2, Pin } from 'lucide-react'
import { ShellIcon } from './shell-icons'
import { AgentIcon } from '@/lib/agent-catalog'
-import { stripLeadingAgentTitleDecoration } from '@/lib/agent-title-decoration'
+import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration'
import { useTabAgent } from '@/lib/use-tab-agent'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx
index 920470534..975adc0e5 100644
--- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx
+++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx
@@ -85,7 +85,7 @@ vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: ({ agent }: { agent: string }) =>
}))
-vi.mock('@/lib/agent-title-decoration', () => ({
+vi.mock('../../../../shared/agent-title-decoration', () => ({
stripLeadingAgentTitleDecoration: (title: string) =>
title.replace(/^(?:[✳✦⏲◇✋⠀-⣿]+|[.*]\s)\s*/, '').trimStart() || title
}))
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 153a12e36..ef4c2d4ee 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -2314,11 +2314,15 @@ export function connectPanePty(
let reassertingPtySizeOnResume = false
const reassertPtySizeOnResume = (): void => {
const ptyId = transport.getPtyId()
- // forwardPtyResize re-checks the visibility/mobile gates at send time, so
- // here we only need the cheap pre-hop early-outs. Skip remote-runtime PTYs:
- // their resize goes through a separate viewport channel (not pty:resize), so
- // the local ptySizes map getSize reads is never populated for them.
- if (disposed || reassertingPtySizeOnResume || !ptyId || isRemoteRuntimePtyId(ptyId)) {
+ // Skip parked/mobile-driven PTYs before the async size read: their drift
+ // from desktop xterm dims is intentional until mobile hands control back.
+ if (
+ disposed ||
+ reassertingPtySizeOnResume ||
+ !ptyId ||
+ isRemoteRuntimePtyId(ptyId) ||
+ shouldSuppressDesktopPtyResize()
+ ) {
return
}
reassertingPtySizeOnResume = true
diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts
index dd3fc5d68..d1a97b33a 100644
--- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts
+++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts
@@ -1,6 +1,6 @@
import { useCallback } from 'react'
import { useAppStore } from '@/store'
-import { resolveExplicitTerminalTitleAgentType } from '@/lib/terminal-title-agent-type'
+import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type'
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
import { playDesktopNotificationSound } from '@/lib/desktop-notification-sound'
import { buildAgentNotificationId } from '../../../../shared/agent-notification-id'
diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts
index 657103c54..0b24f252e 100644
--- a/src/renderer/src/lib/use-tab-agent.ts
+++ b/src/renderer/src/lib/use-tab-agent.ts
@@ -11,10 +11,10 @@ import {
resolveSiblingCompletedTabAgent,
resolveSiblingTabAgent
} from './tab-agent'
-import { resolveExplicitTerminalTitleAgentType } from './terminal-title-agent-type'
+import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type'
import type { TerminalTab, TuiAgent } from '../../../shared/types'
-export { resolveExplicitTerminalTitleAgentType as resolveTabAgentFromTitle } from './terminal-title-agent-type'
+export { resolveExplicitTerminalTitleAgentType as resolveTabAgentFromTitle } from '../../../shared/terminal-title-agent-type'
const HELPER_FOREGROUND_RETRY_DELAYS_MS = [250, 1250, 3500, 750] as const
diff --git a/src/renderer/src/lib/agent-title-decoration.test.ts b/src/shared/agent-title-decoration.test.ts
similarity index 74%
rename from src/renderer/src/lib/agent-title-decoration.test.ts
rename to src/shared/agent-title-decoration.test.ts
index f8f1a725a..a87426c83 100644
--- a/src/renderer/src/lib/agent-title-decoration.test.ts
+++ b/src/shared/agent-title-decoration.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
-import { stripLeadingAgentTitleDecoration } from './agent-title-decoration'
+import {
+ stripLeadingAgentTitleDecoration,
+ stripLeadingAgentTitleDecorationOrEmpty
+} from './agent-title-decoration'
describe('stripLeadingAgentTitleDecoration', () => {
it("strips Claude's ✳ idle glyph", () => {
@@ -24,4 +27,9 @@ describe('stripLeadingAgentTitleDecoration', () => {
expect(stripLeadingAgentTitleDecoration('✳')).toBe('✳')
expect(stripLeadingAgentTitleDecoration('✳ ')).toBe('✳ ')
})
+
+ it('can strip to empty when the caller supplies its own fallback label', () => {
+ expect(stripLeadingAgentTitleDecorationOrEmpty('✳')).toBe('')
+ expect(stripLeadingAgentTitleDecorationOrEmpty('✳ ')).toBe('')
+ })
})
diff --git a/src/renderer/src/lib/agent-title-decoration.ts b/src/shared/agent-title-decoration.ts
similarity index 79%
rename from src/renderer/src/lib/agent-title-decoration.ts
rename to src/shared/agent-title-decoration.ts
index ff564d004..bc05badf8 100644
--- a/src/renderer/src/lib/agent-title-decoration.ts
+++ b/src/shared/agent-title-decoration.ts
@@ -7,8 +7,12 @@ const LEADING_AGENT_TITLE_DECORATION_RE =
// eslint-disable-next-line no-control-regex -- intentional unicode status-glyph ranges
/^(?:[✳✦⏲◇✋⠀-⣿]+|[.*]\s)\s*/
+export function stripLeadingAgentTitleDecorationOrEmpty(title: string): string {
+ return title.replace(LEADING_AGENT_TITLE_DECORATION_RE, '').trimStart()
+}
+
export function stripLeadingAgentTitleDecoration(title: string): string {
- const stripped = title.replace(LEADING_AGENT_TITLE_DECORATION_RE, '').trimStart()
+ const stripped = stripLeadingAgentTitleDecorationOrEmpty(title)
// Why: never return empty — a title that is *only* a status glyph should keep
// its original text rather than collapse to a blank tab label.
return stripped.length > 0 ? stripped : title
diff --git a/src/renderer/src/lib/terminal-title-agent-type.test.ts b/src/shared/terminal-title-agent-type.test.ts
similarity index 100%
rename from src/renderer/src/lib/terminal-title-agent-type.test.ts
rename to src/shared/terminal-title-agent-type.test.ts
diff --git a/src/renderer/src/lib/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts
similarity index 94%
rename from src/renderer/src/lib/terminal-title-agent-type.ts
rename to src/shared/terminal-title-agent-type.ts
index 3cc09ffe8..6e453e5cb 100644
--- a/src/renderer/src/lib/terminal-title-agent-type.ts
+++ b/src/shared/terminal-title-agent-type.ts
@@ -1,5 +1,5 @@
-import { getAgentLabel, titleHasAgentName } from '../../../shared/agent-detection'
-import type { TuiAgent } from '../../../shared/types'
+import { getAgentLabel, titleHasAgentName } from './agent-detection'
+import type { TuiAgent } from './types'
// Maps getAgentLabel()'s product labels to TuiAgent ids — the fallback for
// agents whose foreground PROCESS name isn't self-identifying (Claude Code runs