Show coding agent icons on mobile terminal tabs (#6792)
* Show coding agent icons on mobile terminal tabs Move agent title decoration and terminal title parsing utilities from the desktop renderer to shared code for reuse on mobile. * Extract agent title stripping and terminal agent resolution to shared * Implement mobile agent identity resolution and title-cleaning helpers * Render agent icons on mobile terminal tabs when an agent is active * Strip leading status glyphs from tab titles when showing an icon * Suppress PTY resize on resume for mobile-driven terminals Avoid reasserting the PTY size on resume if desktop resizing is suppressed. This prevents overriding the intentional drift from desktop dimensions for parked or mobile-driven terminals.
This commit is contained in:
parent
53e2582cbe
commit
7caa582841
|
|
@ -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' && (
|
||||
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
)}
|
||||
{t.type === 'terminal' &&
|
||||
(() => {
|
||||
const agentId = resolveMobileTerminalTabAgentId(t)
|
||||
return agentId ? <MobileAgentIcon agentId={agentId} size={13} /> : null
|
||||
})()}
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
MobileSyntaxSegment
|
||||
} from '../../../../src/session/mobile-file-syntax'
|
||||
import type { TerminalRecord } from '../../../../src/session/mobile-terminal-records'
|
||||
import type { DiffComment } from '../../../../../src/shared/types'
|
||||
import type { DiffComment, TuiAgent } from '../../../../../src/shared/types'
|
||||
import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types'
|
||||
|
||||
export type Terminal = TerminalRecord
|
||||
|
|
@ -23,6 +23,7 @@ export type MobileSessionTab =
|
|||
status?: 'pending-handle' | 'ready'
|
||||
terminal: string | null
|
||||
agentStatus?: AgentStatusEntry | null
|
||||
launchAgent?: TuiAgent
|
||||
terminalTheme?: MobileTerminalTheme
|
||||
isActive: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
|
||||
import {
|
||||
getMobileSessionTabTitle,
|
||||
resolveMobileTerminalTabAgentId
|
||||
} from './mobile-terminal-tab-agent'
|
||||
|
||||
function agentStatus(agentType: string | undefined): AgentStatusEntry {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
updatedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
...(agentType ? { agentType } : {}),
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
|
||||
function terminalTab(
|
||||
title: string,
|
||||
options: { agentType?: string; id?: string; launchAgent?: TuiAgent } = {}
|
||||
): Extract<MobileSessionTab, { type: 'terminal' }> {
|
||||
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<MobileSessionTab, { type: 'browser' }> = {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ vi.mock('@/lib/agent-catalog', () => ({
|
|||
AgentIcon: ({ agent }: { agent: string }) => <span data-agent-catalog-icon={agent} />
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-title-decoration', () => ({
|
||||
vi.mock('../../../../shared/agent-title-decoration', () => ({
|
||||
stripLeadingAgentTitleDecoration: (title: string) =>
|
||||
title.replace(/^(?:[✳✦⏲◇✋⠀-⣿]+|[.*]\s)\s*/, '').trimStart() || title
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue