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