Show a details card when tapping phone session tabs (#36395)

* feat(mobile): tap the session title to see its full name, path and host

A sideways phone caps the title at 160pt beside the tabs, so long worktree names were cut off with no way to read them. Tapping the title now opens a card under the top bar, built like desktop's tab hover card, with the full name, the worktree's path and the host. It fades and slides in over 150ms (fade only with Reduce Motion), and any tap outside closes it.

* fix(mobile): keep the worktree-name reset effect unchanged; type-only View import; lazy card animation value

* fix(mobile): preserve title card bounds and execution host identity

* test(mobile): complete title card platform mocks

* fix(mobile): separate detail card from title trigger

* style(mobile): apply pinned formatter wrapping

* fix(mobile): reopen session details during the closing animation

* style(mobile): wrap the session title imports

* Keep session title activation on one target through card exit

* Host session details above page navigation and retain input scope

* Keep the title target clear as its layout changes

* Clarify title-card types and observer cleanup for checks

* Keep mobile title-card requests and visual exits under one owner

* Make the existing mobile title an explicit web click target

* Keep mobile title details nonmodal through their visual exit

* Follow the active mobile palette in session title details

* Use array syntax for title card motion fixture

* Localize the mobile workspace details hint

* test(mobile): cover title card release to a higher sheet

* Refresh measured mobile web chunk envelope

* Show session tab details on phone taps

* Keep phone tab details current through layout and handoff

* Refresh cached phone tab footer colors with appearance

* Use checked fixture state and top-level test owner types

* Supply app visibility in the title card Surface fixture

* fix(mobile): keep empty terminal detail titles readable
This commit is contained in:
Brennan Benson
2026-10-30 17:57:28 -07:00
committed by GitHub
parent 5cf236730f
commit 852391da0f
18 changed files with 1569 additions and 291 deletions
@@ -0,0 +1,236 @@
import { useCallback, useLayoutEffect, useReducer, useRef, useState, type RefObject } from 'react'
import { useWindowDimensions, type LayoutChangeEvent, type View } from 'react-native'
import type { DetailCardAnchor } from './AnchoredDetailCard'
import { useBackClaim } from '../navigation/use-back-claim'
import { useWindowSafeAreaInsets } from '../layout/side-safe-area'
export type DetailCardSource = {
key: string
anchorRef: RefObject<Pick<View, 'measureInWindow'> | null>
barRef: RefObject<View | null>
openerRef: RefObject<View | null>
geometry: 'dismiss' | 'refresh'
}
type Request = { id: number; source: DetailCardSource }
type Visual = { anchor: DetailCardAnchor; target: 0 | 1; motion: number; source: DetailCardSource }
type State = { serial: number; request: Request | null; visual: Visual | null }
type Action =
| { type: 'toggle'; eligible: boolean; source: DetailCardSource }
| { type: 'close'; request?: number }
| { type: 'remove'; key: string }
| { type: 'refresh'; source?: DetailCardSource }
| { type: 'geometry' }
| { type: 'measured'; request: number; anchor: DetailCardAnchor }
| { type: 'settled'; motion: number }
function reduce(state: State, action: Action): State {
const { visual, request, serial } = state
const source = request?.source ?? (visual?.target === 1 ? visual.source : null)
switch (action.type) {
case 'toggle':
if (!action.eligible) {
return state
}
return source?.key === action.source.key
? reduce(state, { type: 'close' })
: { ...state, serial: serial + 1, request: { id: serial + 1, source: action.source } }
case 'geometry':
return source?.geometry === 'refresh'
? reduce(state, { type: 'refresh' })
: reduce(state, { type: 'close' })
case 'refresh':
if (
!source ||
(!action.source && source.geometry !== 'refresh') ||
(action.source && action.source.key !== source.key)
) {
return state
}
return {
...state,
serial: serial + 1,
request: { id: serial + 1, source: action.source ?? source }
}
case 'remove':
return source?.key === action.key
? reduce(state, { type: 'close' })
: visual?.source.key === action.key
? { ...state, visual: null }
: state
case 'close':
if (action.request !== undefined && action.request !== request?.id) {
return state
}
return visual?.target === 1
? {
...state,
request: null,
serial: serial + 1,
visual: { ...visual, target: 0, motion: serial + 1 }
}
: { ...state, request: null }
case 'measured':
if (action.request !== request?.id) {
return state
}
return {
serial: serial + 1,
request: null,
visual: {
anchor: action.anchor,
source: request.source,
target: 1,
// Reposition an open card without restarting its entry animation.
motion:
visual?.target === 1 && visual.source.key === request.source.key
? visual.motion
: serial + 1
}
}
case 'settled':
return visual?.target === 0 && visual.motion === action.motion
? { ...state, visual: null }
: state
}
}
export function useAnchoredDetailCard(placement: string, obscured: boolean) {
const layerRef = useRef<View>(null)
const contentRef = useRef<View>(null)
const backRef = useRef<View>(null)
const boundaryRefs = useRef<unknown[]>([])
const sources = useRef(new Map<string, DetailCardSource>())
const [state, dispatch] = useReducer(reduce, { serial: 0, request: null, visual: null })
const [size, setSize] = useState({ width: 0, height: 0 })
const window = useWindowDimensions()
const insets = useWindowSafeAreaInsets()
const geometry = [
window.width,
insets.top,
insets.left,
insets.right,
placement,
size.width
].join(':')
const dismiss = useCallback(() => dispatch({ type: 'close' }), [])
const removeSource = useCallback((key: string) => dispatch({ type: 'remove', key }), [])
const onSettled = useCallback((motion: number) => dispatch({ type: 'settled', motion }), [])
const toggleSource = useCallback(
(source: DetailCardSource) => dispatch({ type: 'toggle', source, eligible: !obscured }),
[obscured]
)
const refreshSource = useCallback(
(source?: DetailCardSource) => dispatch({ type: 'refresh', source }),
[]
)
const onAnchorLayout = useCallback(() => dispatch({ type: 'geometry' }), [])
const registerSource = useCallback(
(source: DetailCardSource) => {
sources.current.set(source.key, source)
refreshSource(source)
return () => {
if (sources.current.get(source.key) === source) {
sources.current.delete(source.key)
refreshSource(source)
}
}
},
[refreshSource]
)
useLayoutEffect(() => {
if (obscured) {
dismiss()
} else {
onAnchorLayout()
}
}, [dismiss, onAnchorLayout, geometry, obscured])
const { request, visual } = state
useLayoutEffect(() => {
if (!request || obscured) {
return
}
const { source, id } = request
const layer = layerRef.current
const anchor = source.anchorRef.current
const bar = source.barRef.current
if (!layer || !anchor || !bar || sources.current.get(source.key) !== source) {
// A tab may be between placements during rotation; attachment resumes its request.
if (source.geometry === 'dismiss') {
dispatch({ type: 'close', request: id })
}
return
}
let disposed = false
layer.measureInWindow((rootX, rootY) => {
anchor.measureInWindow((x, y, width, height) => {
bar.measureInWindow((_x, barY, _width, barHeight) => {
if (
!disposed &&
sources.current.get(source.key) === source &&
source.anchorRef.current === anchor
) {
dispatch({
type: 'measured',
request: id,
anchor: {
trigger: { x: x - rootX, y: y - rootY, width, height },
top: barY + barHeight - rootY,
origin: { x: rootX, y: rootY }
}
})
}
})
})
})
return () => {
disposed = true
}
}, [request, obscured])
const onLayout = useCallback((event: LayoutChangeEvent) => setSize(event.nativeEvent.layout), [])
const requestedOpen = request !== null || visual?.target === 1
const closeFirst = () => {
if (!requestedOpen || obscured) {
return false
}
dismiss()
return true
}
useBackClaim(requestedOpen && !obscured ? closeFirst : null)
const origin = visual?.anchor.origin ?? { x: 0, y: 0 }
return {
layerRef,
contentRef,
backRef,
boundaryRefs,
closeFirst,
active: visual?.target === 1 && !obscured,
visual,
requestedOpen,
requestedKey: request?.source.key ?? (visual?.target === 1 ? visual.source.key : null),
present: visual !== null,
dismiss,
removeSource,
onSettled,
onLayout,
onAnchorLayout,
toggleSource,
refreshSource,
registerSource,
bounds: {
left: Math.max(0, insets.left - origin.x),
top: Math.max(0, insets.top - origin.y),
right: Math.min(size.width, window.width - insets.right - origin.x),
bottom: Math.min(size.height, window.height - insets.bottom - origin.y)
}
}
}
export type AnchoredDetailCardController = ReturnType<typeof useAnchoredDetailCard>
export function useDetailCardSource(
controller: Pick<AnchoredDetailCardController, 'registerSource'>,
source: DetailCardSource
) {
const { registerSource } = controller
useLayoutEffect(() => registerSource(source), [registerSource, source])
}
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { act, useRef } from 'react'
import { Fragment, act, useRef } from 'react'
import { createRoot } from 'react-dom/client'
import { expect, it, vi } from 'vitest'
import {
@@ -22,10 +22,21 @@ it('keeps outside actions and finite focus order while releasing a retained visu
const renderer = createRoot(root)
const dismiss = vi.fn()
const action = vi.fn()
function Scope({ active, obscured = false }: { active: boolean; obscured?: boolean }) {
function Scope({
active,
obscured = false,
source = 'title'
}: {
active: boolean
obscured?: boolean
source?: 'title' | 'tab1' | 'tab2'
}) {
const contentRef = useRef<HTMLDivElement>(null)
const boundaryRefs = useRef<unknown[]>([])
const openerRef = useRef<HTMLButtonElement>(null)
const titleRef = useRef<HTMLButtonElement>(null)
const tab1Ref = useRef<HTMLButtonElement>(null)
const tab2Ref = useRef<HTMLButtonElement>(null)
const openerRef = source === 'title' ? titleRef : source === 'tab1' ? tab1Ref : tab2Ref
const backRef = useRef<HTMLButtonElement>(null)
useDetailCardScope({ active, obscured, openerRef, backRef, contentRef, boundaryRefs, dismiss })
return (
@@ -34,14 +45,40 @@ it('keeps outside actions and finite focus order while releasing a retained visu
<button ref={backRef} id="back">
Back
</button>
<DetailCardFocusBoundary refs={boundaryRefs} index={0} active={active} />
<button ref={openerRef} id="opener">
<DetailCardFocusBoundary
refs={boundaryRefs}
index={0}
active={active && source === 'title'}
/>
<button ref={titleRef} id="opener">
Title
</button>
<DetailCardFocusBoundary refs={boundaryRefs} index={1} active={active} />
<DetailCardFocusBoundary
refs={boundaryRefs}
index={1}
active={active && source === 'title'}
/>
<button id="files" onClick={action}>
Files
</button>
{(['tab1', 'tab2'] as const).map((id) => (
<Fragment key={id}>
<DetailCardFocusBoundary
refs={boundaryRefs}
index={0}
active={active && source === id}
/>
<button id={id} ref={id === 'tab1' ? tab1Ref : tab2Ref}>
{id}
</button>
<DetailCardFocusBoundary
refs={boundaryRefs}
index={1}
active={active && source === id}
/>
</Fragment>
))}
<button id="more">More</button>
<DetailCardLayer>
<DetailCardFocusBoundary refs={boundaryRefs} index={2} active={active} />
<div ref={contentRef} id="card" tabIndex={-1} aria-hidden={!active}>
@@ -54,11 +91,11 @@ it('keeps outside actions and finite focus order while releasing a retained visu
</>
)
}
const render = (active: boolean, obscured = false) =>
const render = (active: boolean, obscured = false, source: 'title' | 'tab1' | 'tab2' = 'title') =>
act(() =>
renderer.render(
<DetailCardHostProvider>
<Scope active={active} obscured={obscured} />
<Scope active={active} obscured={obscured} source={source} />
</DetailCardHostProvider>
)
)
@@ -129,6 +166,23 @@ it('keeps outside actions and finite focus order while releasing a retained visu
render(false, true)
expect(node('card').inert).toBe(true)
expect(node('opener')).toBe(opener)
render(true)
render(true, false, 'tab1')
key('keydown', 'Tab')
focusBoundary('tab1', false)
expect(document.activeElement).toBe(node('scroll'))
render(true, false, 'tab2')
key('keydown', 'Tab', true)
focusBoundary('card', true)
expect(document.activeElement).toBe(node('tab2'))
key('keydown', 'Tab')
focusBoundary('card', false)
expect(document.activeElement).toBe(node('more'))
render(false, false, 'tab2')
render(true, false, 'tab2')
const outsideActions = action.mock.calls.length
node('files').click()
expect(action).toHaveBeenCalledTimes(outsideActions + 1)
act(() => renderer.unmount())
expect(host?.isConnected).toBe(false)
} finally {
+3
View File
@@ -2130,6 +2130,9 @@
"terminalRecords": {
"defaultTitle": "Terminal"
},
"tabCard": {
"editor": "Editor"
},
"terminalTabAgent": {
"newBrowser": "New Browser",
"browser": "Browser",
@@ -28,12 +28,13 @@ vi.mock('../transport/use-workspace-client', () => ({
WorkspaceServerContext: createContext(undefined)
}))
vi.mock('../components/AnchoredDetailCard', () => ({ AnchoredDetailCard: () => null }))
vi.mock('../session/MobileSessionTabCardFooter', () => ({ MobileSessionTabCardFooter: () => null }))
vi.mock('../layout/responsive-layout', () => ({
useResponsiveLayout: () => ({ isLandscapePhone: false })
}))
vi.mock('../layout/use-shell-side-rail', () => ({ useShellSideRail: () => false }))
vi.mock('../session/use-session-title-card', () => ({
useSessionTitleCard: () => ({ visual: null })
useSessionTitleCard: () => ({ active: false, visual: null, tabId: null, missingTab: false })
}))
vi.mock('../session/MobileSessionHeader', () => ({ MobileSessionHeader: () => null }))
vi.mock('../session/MobileSessionContentRow', () => ({ MobileSessionContentRow: () => null }))
@@ -76,6 +76,7 @@ export function MobileSessionHeader({
<MobileSessionTabStrip
controller={controller}
inTopBar={tabsInTopBar}
details={details.tabDetails}
actionsCollapsed={actionsCollapsed}
/>
) : null
+48 -11
View File
@@ -1,4 +1,4 @@
import { useContext } from 'react'
import { useContext, useMemo } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { styles } from './mobile-session-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
@@ -15,6 +15,10 @@ import { useResponsiveLayout } from '../layout/responsive-layout'
import { WorkspaceServerContext } from '../transport/use-workspace-client'
import { useSessionTitleCard } from './use-session-title-card'
import { useDetailCardScope } from '../components/use-detail-card-scope'
import { useNow } from '../hooks/use-now'
import { getMobileTerminalTabHostAgentIdentity } from './mobile-terminal-tab-agent'
import { mobileSessionTabCardContent } from './mobile-session-tab-card-content'
import { MobileSessionTabCardFooter } from './MobileSessionTabCardFooter'
import { useShellSideRail } from '../layout/use-shell-side-rail'
export function MobileSessionSurface({ controller }: { controller: MobileSessionController }) {
@@ -28,9 +32,7 @@ export function MobileSessionSurface({ controller }: { controller: MobileSession
const besideTabs = (layout.isLandscapePhone || sideRail) && hasTabs
const titleVisible =
!sideRail || !hasTabs || controller.showConnectionRetry || controller.connectionEscalated
// The compact Duo header removes the opener, so its card must yield with it.
const obscured = Boolean(
!titleVisible ||
controller.showHeaderMoreActions ||
controller.showCreateTabDrawer ||
controller.showTerminalLayout ||
@@ -50,8 +52,38 @@ export function MobileSessionSurface({ controller }: { controller: MobileSession
controller.showDictationSetup ||
controller.deleteKeyTarget != null
)
const details = useSessionTitleCard(besideTabs, obscured)
useDetailCardScope({ ...details, obscured })
const details = useSessionTitleCard(besideTabs, obscured, controller.visibleTabs, titleVisible)
const missingSource = details.missingTab || details.missingTitle
const tab = useMemo(
() =>
details.tabId
? controller.visibleTabs.find((candidate) => candidate.id === details.tabId)
: undefined,
[controller.visibleTabs, details.tabId]
)
const hostAgentIdentity = useMemo(
() => (tab ? getMobileTerminalTabHostAgentIdentity(tab, controller.terminals) : undefined),
[tab, controller.terminals]
)
const now = useNow(60_000, details.active && tab != null)
const tabContent = useMemo(
() => (tab ? mobileSessionTabCardContent(tab, hostAgentIdentity, now) : null),
[tab, hostAgentIdentity, now, t]
)
const footer = useMemo(
() => (tabContent ? <MobileSessionTabCardFooter content={tabContent} /> : null),
[tabContent]
)
const contentRow = useMemo(
() => <MobileSessionContentRow controller={controller} />,
[controller]
)
useDetailCardScope({
...details,
openerRef: details.scopeOpenerRef,
active: details.active && !missingSource,
obscured: obscured || missingSource
})
// Only a proven local record belongs to this route's desktop or server.
const hostName =
controller.worktreeHostId === 'local' ? (server ? server.name : controller.hostName) : null
@@ -60,7 +92,7 @@ export function MobileSessionSurface({ controller }: { controller: MobileSession
<View style={styles.kavInner}>
<MobileSessionHeader controller={controller} details={details} />
<MobileWorkerAutoCloseStrip controller={controller} />
<MobileSessionContentRow controller={controller} />
{contentRow}
</View>
<DetailCardLayer>
<View
@@ -76,13 +108,18 @@ export function MobileSessionSurface({ controller }: { controller: MobileSession
bounds={details.bounds}
contentRef={details.contentRef}
boundaryRefs={details.boundaryRefs}
active={details.active}
obscured={obscured}
active={details.active && !missingSource}
obscured={obscured || missingSource}
onSettled={details.onSettled}
title={controller.worktreeName || t('session.header.terminalTitle', 'Terminal')}
detail={controller.worktreePath}
title={
tabContent?.title ??
(controller.worktreeName || t('session.header.terminalTitle', 'Terminal'))
}
detail={tabContent ? tabContent.detail : controller.worktreePath}
footer={
hostName ? (
tabContent ? (
footer
) : hostName ? (
<>
<StatusDot state={controller.connState} />
<Text style={styles.sessionMetaText}>{hostName}</Text>
+99
View File
@@ -0,0 +1,99 @@
import { useMemo, useRef, type RefObject } from 'react'
import { View, Text, Pressable, type LayoutChangeEvent } from 'react-native'
import { File, FileText, Globe, SquareTerminal } from 'lucide-react-native'
import { DetailCardFocusBoundary } from '../components/detail-card-host'
import { useDetailCardSource, type DetailCardSource } from '../components/use-anchored-detail-card'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import { AgentStateDot } from '../components/AgentStateDot'
import { agentStateLabel, type AgentDotState } from '../worktree/agent-row-display'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-session-styles'
import type { SessionTitleCard } from './use-session-title-card'
import type { MobileSessionTab as SessionTab } from './mobile-session-route-types'
export function MobileSessionTab({
tab,
first,
active,
title,
tabState,
agentId,
details,
barRef,
onLayout,
onPress,
onLongPress
}: {
tab: SessionTab
first: boolean
active: boolean
title: string
tabState?: AgentDotState
agentId: string | null
details: SessionTitleCard['tabDetails']
barRef: RefObject<View | null>
onLayout: (event: LayoutChangeEvent) => void
onPress: (source: DetailCardSource) => void
onLongPress: () => void
}) {
const t = useMobileTranslation()
const openerRef = useRef<View>(null)
const source = useMemo<DetailCardSource>(
() => ({ key: `tab:${tab.id}`, anchorRef: openerRef, openerRef, barRef, geometry: 'refresh' }),
[tab.id, barRef]
)
useDetailCardSource(details, source)
const ownsCard = details.activeKey === source.key
return (
<>
<DetailCardFocusBoundary refs={details.boundaryRefs} index={0} active={ownsCard} />
<Pressable
ref={openerRef}
style={[styles.tab, first && styles.tabFirst, active && styles.tabActive]}
onLayout={(event) => {
onLayout(event)
details.refreshSource()
}}
onPress={() => onPress(source)}
accessibilityRole="tab"
aria-expanded={details.requestedKey === source.key}
accessibilityLabel={
tabState && tabState !== 'idle'
? t('session.tabStrip.tabStatus', '{{title}}, {{status}}', {
title,
status: agentStateLabel(tabState)
})
: title
}
accessibilityState={{ selected: active }}
onLongPress={onLongPress}
delayLongPress={400}
>
<View style={styles.tabLabelRow}>
{tabState && tabState !== 'idle' ? <AgentStateDot state={tabState} /> : null}
<View style={styles.tabIcon}>
{tab.type === 'browser' && (
<Globe size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{tab.type === 'markdown' && (
<FileText size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{tab.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{agentId ? (
<MobileAgentIcon agentId={agentId} size={13} />
) : tab.type === 'terminal' ? (
<SquareTerminal size={13} color={colors.textSecondary} strokeWidth={2.1} />
) : null}
</View>
<Text style={[styles.tabText, active && styles.tabTextActive]} numberOfLines={1}>
{title}
</Text>
</View>
</Pressable>
<DetailCardFocusBoundary refs={details.boundaryRefs} index={1} active={ownsCard} />
</>
)
}
@@ -0,0 +1,35 @@
import { Text } from 'react-native'
import { File, FileText, Globe, SquareTerminal } from 'lucide-react-native'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import { AgentStateDot } from '../components/AgentStateDot'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-session-styles'
import type { mobileSessionTabCardContent } from './mobile-session-tab-card-content'
export function MobileSessionTabCardFooter({
content
}: {
content: ReturnType<typeof mobileSessionTabCardContent>
}) {
useMobileColorScheme()
const Icon =
content.kind === 'browser'
? Globe
: content.kind === 'markdown'
? FileText
: content.kind === 'file'
? File
: SquareTerminal
return (
<>
{content.state ? <AgentStateDot state={content.state} /> : null}
{content.agentId ? (
<MobileAgentIcon agentId={content.agentId} size={16} />
) : (
<Icon size={16} color={colors.textSecondary} />
)}
<Text style={styles.sessionMetaText}>{content.programName}</Text>
</>
)
}
+674 -40
View File
@@ -1,34 +1,184 @@
import { createElement, useRef, useState } from 'react'
import { createContext, createElement, useMemo, useRef, useState } from 'react'
import { Text } from 'react-native'
import { File, FileText, Globe, SquareTerminal } from 'lucide-react-native'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import { AgentStateDot } from '../components/AgentStateDot'
import { activateMobileUiLocale, getMobileUiLocale } from '../i18n/mobile-i18n'
import {
applyMobileColorScheme,
getMobileColorScheme,
paletteForScheme
} from '../theme/mobile-theme'
import { mobileSessionFrameStyles } from './mobile-session-frame-styles'
import { MobileSessionTabCardFooter } from './MobileSessionTabCardFooter'
import { MobileSessionTitleBlock } from './MobileSessionTitleBlock'
import type { MobileSessionHeaderIconButton } from './MobileSessionHeaderIconButton'
import type * as TabModule from './MobileSessionTab'
import type * as FooterModule from './MobileSessionTabCardFooter'
import type * as TabContentModule from './mobile-session-tab-card-content'
import { act, create, type ReactTestInstance } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
type StripWork = {
tabs: number
content: number
footer: number
rows: number
ids: number
records: number
measures: number
landscape: boolean
sideRail: boolean
back: (() => boolean) | null
removeAppState: ReturnType<typeof vi.fn>
}
const work = vi.hoisted((): StripWork => ({
tabs: 0,
content: 0,
footer: 0,
rows: 0,
ids: 0,
records: 0,
measures: 0,
landscape: false,
sideRail: false,
back: null,
removeAppState: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'active', addEventListener: () => ({ remove: work.removeAppState }) },
StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} },
Platform: { OS: 'web', select: () => undefined },
Easing: { out: () => () => 0, cubic: () => 0 },
Animated: {
View: 'AnimatedView',
Value: class {
value = 0
stopAnimation(read?: (value: number) => void) {
read?.(this.value)
}
interpolate() {
return this
}
},
timing: (value: { value: number }, config: { toValue: number }) => ({
start: () => {
value.value = config.toValue
}
})
},
View: 'View',
Text: 'Text',
ScrollView: 'ScrollView',
Pressable: 'Pressable'
Pressable: 'Pressable',
useWindowDimensions: () => ({ width: 800, height: 600 })
}))
vi.mock('lucide-react-native', () => ({
File: 'Icon',
FileText: 'Icon',
Globe: 'Icon',
File: 'FileIcon',
FileText: 'MarkdownIcon',
Globe: 'BrowserIcon',
ChevronLeft: 'Icon',
Folder: 'Icon',
GitBranch: 'Icon',
MoreHorizontal: 'Icon',
PanelLeftOpen: 'Icon',
Plus: 'Icon',
SquareTerminal: 'Icon',
SquareTerminal: 'TerminalIcon',
Columns2: 'Icon',
Rows2: 'Icon',
Check: 'Icon',
LayoutGrid: 'Icon'
SquareChevronRight: 'Icon'
}))
vi.mock('../navigation/use-back-claim', () => ({
useBackClaim: (claim: (() => boolean) | null) => {
work.back = claim
}
}))
vi.mock('../layout/side-safe-area', () => ({
useWindowSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 })
}))
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: () => {} }))
vi.mock('../hooks/use-now', () => ({ useNow: () => 0 }))
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: () => null }))
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
vi.mock('../components/AgentStateDot', () => ({ AgentStateDot: () => null }))
vi.mock('./MobileSessionHeaderIconButton', () => ({ MobileSessionHeaderIconButton: () => null }))
vi.mock('../components/MobileAgentIcon', () => ({
MobileAgentIcon: (props: Parameters<typeof MobileAgentIcon>[0]) =>
createElement('AgentIcon', props)
}))
vi.mock('../components/AgentStateDot', () => ({
AgentStateDot: (props: Parameters<typeof AgentStateDot>[0]) => createElement('StateDot', props)
}))
vi.mock('./MobileSessionHeaderIconButton', () => ({
MobileSessionHeaderIconButton: (props: Parameters<typeof MobileSessionHeaderIconButton>[0]) =>
createElement('HeaderButton', props)
}))
vi.mock('./QuickCommandsTabButton', () => ({ QuickCommandsTabButton: () => null }))
vi.mock('./mobile-session-styles', () => ({ styles: {} }))
vi.mock('../platform/keyboard-persisting-taps', () => ({ useKeyboardPersistingTaps: () => null }))
vi.mock('./mobile-session-styles', async () => {
const { mobileSessionFrameStyles } = await import('./mobile-session-frame-styles')
return {
styles: {
sessionTopBar: 'top-bar',
get sessionMetaText() {
return mobileSessionFrameStyles.sessionMetaText
}
}
}
})
vi.mock('react-native-safe-area-context', () => ({ SafeAreaView: 'SafeAreaView' }))
vi.mock('../platform/use-reduced-motion', () => ({ useReducedMotionEnabled: () => true }))
vi.mock('../layout/host-sidebar-reveal', () => ({ useHostSidebarReveal: () => null }))
vi.mock('../layout/use-top-chrome-space', () => ({ useTopChromeSpace: () => ({}) }))
vi.mock('../layout/use-shell-side-rail', () => ({ useShellSideRail: () => work.sideRail }))
vi.mock('./MobileSessionHeaderMoreActionsSheet', () => ({
MobileSessionHeaderMoreActionsSheet: () => null
}))
vi.mock('../layout/responsive-layout', () => ({
useResponsiveLayout: () => ({ isLandscapePhone: work.landscape, width: 800 })
}))
vi.mock('../transport/use-workspace-client', () => ({
WorkspaceServerContext: createContext(undefined)
}))
vi.mock('./MobileSessionAccessoryKeys', () => ({ MobileSessionAccessoryKeys: () => null }))
vi.mock('./MobileSessionCommandDock', () => ({ sessionCommandDockShown: () => false }))
vi.mock('./MobileSessionContentRow', () => ({
MobileSessionContentRow: () => {
work.rows++
return null
}
}))
vi.mock('./MobileSessionSheets', () => ({ MobileSessionSheets: () => null }))
vi.mock('./MobileWorkerAutoCloseStrip', () => ({ MobileWorkerAutoCloseStrip: () => null }))
vi.mock('./MobileSessionTab', async (original) => {
const actual = await original<typeof TabModule>()
return {
MobileSessionTab: (props: Parameters<typeof actual.MobileSessionTab>[0]) => {
work.tabs++
return createElement(actual.MobileSessionTab, props)
}
}
})
vi.mock('./MobileSessionTabCardFooter', async (original) => {
const actual = await original<typeof FooterModule>()
return {
MobileSessionTabCardFooter: (
props: Parameters<typeof actual.MobileSessionTabCardFooter>[0]
) => {
work.footer++
return createElement(actual.MobileSessionTabCardFooter, props)
}
}
})
vi.mock('./mobile-session-tab-card-content', async (original) => {
const actual = await original<typeof TabContentModule>()
return {
mobileSessionTabCardContent: (
...args: Parameters<typeof actual.mobileSessionTabCardContent>
) => {
work.content++
return actual.mobileSessionTabCardContent(...args)
}
}
})
vi.mock('../platform/keyboard-persisting-taps', () => ({
useKeyboardPersistingTaps: () => () => {}
}))
vi.mock('../platform/keyboard-occlusion', () => ({
useSoftKeyboard: () => ({ height: 0, visible: false })
}))
@@ -37,7 +187,9 @@ vi.mock('../terminal/terminal-viewport-refit', () => ({
}))
vi.mock('../worktree/last-visited-worktree-repo', () => ({ writeLastVisitedWorktree: () => {} }))
import { MobileSessionTabStrip } from './MobileSessionTabStrip'
import { MobileSessionSurface } from './MobileSessionSurface'
import { AnchoredDetailCard } from '../components/AnchoredDetailCard'
import type { MobileSessionTabStrip } from './MobileSessionTabStrip'
import { useMobileSessionKeyboardState } from './use-mobile-session-keyboard-state'
type Controller = Parameters<typeof MobileSessionTabStrip>[0]['controller']
@@ -49,16 +201,28 @@ const visibleTabs = TAB_IDS.map((id) => ({
type: 'terminal' as const,
id,
terminal: `term-${id}`,
title: id
title: id,
isActive: id === 'a'
}))
function mountStrip(
terminals: Pick<Controller['terminals'][number], 'handle' | 'agentIdentity'>[] = []
terminals: Pick<Controller['terminals'][number], 'handle' | 'agentIdentity'>[] = [],
initialTabs: Controller['visibleTabs'] = visibleTabs
) {
const scrollTo = vi.fn()
const setRenameTarget = vi.fn()
const offsetRef = { current: 0 }
function Harness() {
let queued = false
const measurements: (() => void)[] = []
let suppliedTabs = initialTabs
const positions = new Map(initialTabs.map((tab, index) => [tab.id, index * TAB_WIDTH]))
function Harness({ inTopBar = false, removed = '' }: { inTopBar?: boolean; removed?: string }) {
const [renameOpen, setRenameOpen] = useState(false)
work.landscape = inTopBar
const tabs = useMemo(
() => suppliedTabs.filter((tab) => tab.id !== removed),
[removed, suppliedTabs]
)
const [activeSessionTabId, setActiveSessionTabId] = useState('a')
const activeSessionTabIdRef = useRef(activeSessionTabId)
activeSessionTabIdRef.current = activeSessionTabId
@@ -77,25 +241,74 @@ function mountStrip(
...strip,
...keyboard,
connState: 'connected',
presentedConnState: 'connected',
showConnectionRetry: false,
connectionEscalated: false,
worktreeName: 'workspace',
bulkCloseConfirm: { visible: false },
isFloatingWorkspaceRoute: true,
renameTarget: renameOpen ? { handle: 'term', title: 'rename', isActive: true } : null,
activeSessionTabIdRef,
activeHandleRef: { current: null },
switchSessionTab: (tab: { id: string }) => setActiveSessionTabId(tab.id),
setRenameTarget,
visibleTabs,
setRenameTarget: (target) => {
setRenameTarget(target)
setRenameOpen(true)
},
visibleTabs: tabs,
terminals
})
return createElement(MobileSessionTabStrip, { controller, inTopBar: false })
return createElement(MobileSessionSurface, { controller })
}
let tree: ReturnType<typeof create> | undefined
act(() => {
tree = create(createElement(Harness), { createNodeMock: () => ({ scrollTo }) })
tree = create(createElement(Harness), {
createNodeMock: (node) => {
const props = node.props
if (!props || typeof props !== 'object') {
throw new Error('expected host props')
}
const label =
'accessibilityLabel' in props && typeof props.accessibilityLabel === 'string'
? props.accessibilityLabel
: ''
const layer = 'pointerEvents' in props && props.pointerEvents === 'box-none'
return {
scrollTo,
measureInWindow: (
done: (x: number, y: number, width: number, height: number) => void
) => {
work.measures++
const complete = () => {
if ('accessibilityRole' in props && props.accessibilityRole === 'tab') {
done((positions.get(label) ?? 0) - offsetRef.current, 50, 100, 40)
} else if (layer) {
done(0, 0, 800, 600)
} else if ('style' in props && props.style === 'top-bar') {
done(0, 10, 800, 40)
} else {
done(0, 50, 300, 40)
}
}
if (queued && layer) {
measurements.push(complete)
} else {
complete()
}
}
}
}
})
})
const root = tree!.root
const tabs = root.findAll((node) => node.props.accessibilityRole === 'tab')
const strip = root.find((node) => node.props.horizontal === true)
act(() => {
root
.findByProps({ pointerEvents: 'box-none' })
.props.onLayout({ nativeEvent: { layout: { width: 800, height: 600 } } })
strip.props.onLayout({ nativeEvent: { layout: { width: 300 } } })
strip.props.onContentSizeChange(TAB_IDS.length * TAB_WIDTH)
strip.props.onContentSizeChange(initialTabs.length * TAB_WIDTH)
tabs.forEach((tab: ReactTestInstance, index: number) =>
tab.props.onLayout({ nativeEvent: { layout: { x: index * TAB_WIDTH, width: TAB_WIDTH } } })
)
@@ -106,12 +319,50 @@ function mountStrip(
act(() => {
vi.advanceTimersByTime(ms)
})
return { scrollTo, setRenameTarget, tapAt, wait, tabs }
return {
scrollTo,
setRenameTarget,
tapAt,
wait,
tabs,
root,
positions,
card: () => root.findByType(AnchoredDetailCard),
queue: () => {
queued = true
},
flush: () =>
act(() => {
queued = false
measurements.splice(0).forEach((done) => done())
}),
updateTabs: (next: Controller['visibleTabs']) => {
suppliedTabs = next
act(() => tree!.update(createElement(Harness)))
},
rotate: (removed = '') =>
act(() => tree!.update(createElement(Harness, { inTopBar: true, removed }))),
unmount: () => act(() => tree!.unmount())
}
}
describe('MobileSessionTabStrip edge taps', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1000)
Object.assign(work, {
tabs: 0,
content: 0,
footer: 0,
rows: 0,
ids: 0,
records: 0,
measures: 0,
landscape: false,
sideRail: false,
back: null
})
work.removeAppState.mockClear()
vi.stubGlobal('requestAnimationFrame', (cb: (at: number) => void) =>
setTimeout(() => cb(Date.now()), 16)
)
@@ -122,29 +373,412 @@ describe('MobileSessionTabStrip edge taps', () => {
vi.unstubAllGlobals()
})
it('uses the host identity while retaining tab gestures', () => {
const strip = mountStrip([{ handle: 'term-c', agentIdentity: 'cursor' }])
expect(strip.tabs[2].findAll((node) => node.props.agentId === 'cursor')).toHaveLength(1)
strip.tapAt(295)
strip.wait(100)
strip.tapAt(295)
expect(strip.setRenameTarget).toHaveBeenCalledWith(
expect.objectContaining({ handle: 'term-c' })
)
strip.wait(1000)
expect(strip.scrollTo.mock.calls).toEqual([[{ x: 112, animated: true }]])
it('opens tab details in the compact Duo header while its title is absent', () => {
work.sideRail = true
const strip = mountStrip()
expect(strip.root.findAllByType(MobileSessionTitleBlock)).toHaveLength(0)
strip.tapAt(50)
expect(strip.card().props.active).toBe(true)
expect(strip.card().props.title).toBe('a')
expect(strip.card().props.obscured).toBe(false)
act(() => expect(work.back?.()).toBe(true))
expect(strip.card().props.active).toBe(false)
expect(work.back).toBeNull()
act(() => strip.card().props.onSettled(strip.card().props.motion))
expect(strip.root.findAllByType(AnchoredDetailCard)).toHaveLength(0)
strip.unmount()
})
it('renames the tab a double-tap started on, then reveals its neighbour', () => {
const strip = mountStrip()
it('uses the host identity while retaining tab gestures', () => {
const strip = mountStrip([{ handle: 'term-c', agentIdentity: 'cursor' }])
expect(strip.tabs[2].findByType(MobileAgentIcon).props.agentId).toBe('cursor')
strip.tapAt(295)
strip.wait(100)
expect(strip.scrollTo).not.toHaveBeenCalled()
strip.tapAt(295)
expect(strip.setRenameTarget).toHaveBeenCalledWith(
expect.objectContaining({ handle: 'term-c' })
)
strip.wait(1000)
expect(strip.scrollTo.mock.calls).toEqual([[{ x: 112, animated: true }]])
strip.unmount()
})
it('keeps one card through selection, reveal, movement, reparenting and stale results', () => {
const strip = mountStrip()
const scroll = () => strip.root.find((node) => node.props.horizontal === true)
const press = (id: string) =>
act(() =>
strip.root.findByProps({ accessibilityRole: 'tab', accessibilityLabel: id }).props.onPress()
)
act(() =>
strip.root
.findByProps({ accessibilityHint: 'Shows the full workspace name and where it is' })
.props.onPress()
)
expect(strip.card().props.source.key).toBe('title')
strip.tapAt(295)
expect(strip.root.findAllByType(AnchoredDetailCard)).toHaveLength(1)
expect(strip.tabs.map((tab) => tab.props.accessibilityState.selected)).toEqual([
false,
false,
true,
false,
false
])
expect(strip.card().props.source.key).toBe('tab:c')
const entryMotion = strip.card().props.motion
strip.wait(400)
act(() => scroll().props.onScroll({ nativeEvent: { contentOffset: { x: 112 } } }))
expect(strip.card().props).toMatchObject({
active: true,
motion: entryMotion,
anchor: { trigger: { x: 88 }, top: 90 }
})
strip.positions.set('c', 240)
act(() =>
strip.root
.findByProps({ accessibilityRole: 'tab', accessibilityLabel: 'c' })
.props.onLayout({ nativeEvent: { layout: { x: 240, width: 100 } } })
)
expect(strip.card().props.anchor.trigger.x).toBe(128)
strip.queue()
act(() => scroll().props.onContentSizeChange(600))
strip.rotate()
strip.flush()
expect(strip.card().props).toMatchObject({
active: true,
source: { key: 'tab:c' },
anchor: { top: 50 }
})
strip.wait(400)
press('b')
expect(strip.card().props.source.key).toBe('tab:b')
strip.wait(400)
press('b')
const exiting = strip.card().props.motion
expect(strip.card().props.active).toBe(false)
press('a')
act(() => strip.card().props.onSettled(exiting))
expect(strip.card().props).toMatchObject({ active: true, source: { key: 'tab:a' } })
strip.queue()
act(() => scroll().props.onContentSizeChange(650))
act(() => scroll().props.onScrollBeginDrag())
strip.flush()
expect(strip.card().props.active).toBe(false)
strip.wait(400)
press('c')
strip.queue()
act(() => scroll().props.onContentSizeChange(700))
strip.rotate('c')
strip.flush()
expect(strip.card().props.active).toBe(false)
strip.wait(400)
press('b')
strip.queue()
act(() => scroll().props.onContentSizeChange(750))
strip.wait(100)
press('b')
strip.flush()
expect(strip.setRenameTarget).toHaveBeenCalledWith(
expect.objectContaining({ handle: 'term-b' })
)
expect(strip.card().props.active).toBe(false)
strip.unmount()
})
it('reanchors B on sibling-only resize events and preserves B when old A disappears', () => {
const strip = mountStrip([], visibleTabs.slice(0, 3))
strip.rotate()
const tab = (id: string) =>
strip.root.findByProps({ accessibilityRole: 'tab', accessibilityLabel: id })
const layout = (id: string, x: number, width: number) =>
act(() => tab(id).props.onLayout({ nativeEvent: { layout: { x, width } } }))
layout('a', 0, 80)
layout('b', 80, 80)
layout('c', 160, 120)
strip.positions.set('b', 80)
act(() => tab('b').props.onPress())
const motion = strip.card().props.motion
strip.positions.set('b', 120)
// RNWeb observes changed sizes of A/C; position-only B and the total width emit nothing.
layout('a', 0, 120)
layout('c', 200, 80)
expect(strip.card().props).toMatchObject({ motion, anchor: { trigger: { x: 120 } } })
act(() => tab('a').props.onPress())
strip.wait(400)
strip.queue()
act(() => tab('b').props.onPress())
strip.rotate('a')
expect(strip.root.findAllByType(AnchoredDetailCard)).toHaveLength(0)
expect(work.back).not.toBeNull()
strip.flush()
expect(strip.card().props).toMatchObject({ active: true, source: { key: 'tab:b' } })
strip.unmount()
})
it('keeps an empty terminal heading readable as its open row and locale change', async () => {
const originalLocale = getMobileUiLocale()
const empty = { ...visibleTabs[0], title: '' }
const strip = mountStrip([], [empty])
try {
strip.tapAt(50)
const source = strip.card().props.source
const motion = strip.card().props.motion
const now = Date.now()
const heading = () => strip.card().findAllByType(Text)[0].props.children
expect(heading()).toBe('Terminal')
for (const title of ['First full shell title', '', 'Next full shell title']) {
const row = { ...empty, title }
strip.updateTabs([row])
expect(heading()).toBe(title || 'Terminal')
expect(row.title).toBe(title)
expect(strip.card().props.active).toBe(true)
expect(strip.card().props.source).toBe(source)
expect(strip.card().props.motion).toBe(motion)
expect(Date.now()).toBe(now)
if (title === '') {
await act(async () => activateMobileUiLocale('ja'))
expect(heading()).toBe('ターミナル')
expect(row.title).toBe('')
expect(Date.now()).toBe(now)
await act(async () => activateMobileUiLocale(originalLocale))
}
}
expect(empty.title).toBe('')
} finally {
strip.unmount()
await act(async () => activateMobileUiLocale(originalLocale))
}
})
it('renders representative per-kind content through Surface and the real footer', () => {
const samples: {
tab: Controller['visibleTabs'][number]
title: string
program: string
icon: typeof File | typeof MobileAgentIcon
agentId?: string
state?: string
detail?: string
}[] = [
{
tab: {
...visibleTabs[0],
title: '✳ Fix tabs',
agentStatus: {
state: 'working',
agentType: 'claude',
paneKey: 't:l',
prompt: '',
updatedAt: 1000,
stateStartedAt: 1000,
stateHistory: []
}
},
title: 'Fix tabs',
program: 'Claude',
icon: MobileAgentIcon,
agentId: 'claude',
state: 'working'
},
{
tab: { ...visibleTabs[0], title: ' Full shell name · work ' },
title: ' Full shell name · work ',
program: 'Terminal',
icon: SquareTerminal
},
{
tab: {
type: 'agent-session',
id: 'a',
title: '✳ Chat',
agent: 'codex',
sessionId: 's',
isActive: true
},
title: 'Chat',
program: 'Codex',
icon: MobileAgentIcon,
agentId: 'codex'
},
...(['file', 'markdown'] as const).map((type) => ({
tab: {
type,
id: 'a',
title: 'note.md',
filePath: '/docs/note.md',
relativePath: 'docs/note.md',
isDirty: false,
isActive: true,
documentVersion: '1'
},
title: 'note.md',
detail: 'docs/note.md',
program: 'Editor',
icon: type === 'file' ? File : FileText
})),
{
tab: {
type: 'browser',
id: 'a',
title: 'Search',
url: 'https://example.com/search',
browserWorkspaceId: 'w',
browserPageId: 'p',
loading: false,
canGoBack: false,
canGoForward: false,
isActive: true
},
title: 'Search',
program: 'example.com/search',
icon: Globe
}
]
for (const sample of samples) {
const strip = mountStrip([], [sample.tab])
strip.tapAt(50)
const card = strip.card()
const text = card.findAllByType(Text).map((node) => node.props.children)
expect(text).toEqual(
sample.detail
? [sample.title, sample.detail, sample.program]
: [sample.title, sample.program]
)
const icons = card.findAllByType(sample.icon)
expect(icons).toHaveLength(1)
if (sample.agentId) {
expect(icons[0].props.agentId).toBe(sample.agentId)
}
expect(card.findAllByType(AgentStateDot).map((node) => node.props.state)).toEqual(
sample.state ? [sample.state] : []
)
strip.unmount()
}
})
it('repaints the cached footer immediately on dark-light-dark appearance changes', () => {
const originalScheme = getMobileColorScheme()
let strip: ReturnType<typeof mountStrip> | undefined
try {
act(() => applyMobileColorScheme('dark'))
strip = mountStrip([], [{ ...visibleTabs[0], title: 'Shell' }])
strip.tapAt(50)
const card = strip.card()
const footerElement = card.props.footer
const content = card.findByType(MobileSessionTabCardFooter).props.content
const anchor = card.props.anchor
const now = Date.now()
for (const scheme of ['dark', 'light', 'dark'] as const) {
act(() => applyMobileColorScheme(scheme))
expect(card.props.footer).toBe(footerElement)
expect(card.findByType(MobileSessionTabCardFooter).props.content).toBe(content)
expect(card.props.anchor).toBe(anchor)
expect(Date.now()).toBe(now)
expect(card.findByType(SquareTerminal).props.color).toBe(
paletteForScheme(scheme).textSecondary
)
const texts = card.findAllByType(Text)
expect(texts.map((text) => text.props.children)).toEqual(['Shell', 'Terminal'])
expect(texts[1].props.style).toBe(mobileSessionFrameStyles.sessionMetaText)
expect(texts[1].props.style.color).toBe(paletteForScheme(scheme).textSecondary)
}
} finally {
strip?.unmount()
act(() => applyMobileColorScheme(originalScheme))
}
})
it.each([20, 200])('bounds real refresh/measurement work with %i tabs and records', (size) => {
const tabs = Array.from({ length: size }, (_, i) => ({
...visibleTabs[0],
get id() {
work.ids++
return `tab-${i}`
},
title: `tab-${i}`,
terminal: `term-${i}`
}))
const terminals = tabs.map((_, i) => ({
get handle() {
work.records++
return `term-${i}`
},
agentIdentity: 'cursor'
}))
const strip = mountStrip(terminals, tabs)
const reset = () =>
Object.assign(work, {
tabs: 0,
content: 0,
footer: 0,
rows: 0,
ids: 0,
records: 0,
measures: 0
})
const scroll = (x: number) =>
act(() =>
strip.root
.find((node) => node.props.horizontal === true)
.props.onScroll({ nativeEvent: { contentOffset: { x } } })
)
strip.wait(16) // Settle the keyboard mount RAF before the clock baseline.
// Settle the existing hidden-edge transition before measuring unchanged-edge work.
scroll(10)
scroll(10)
reset()
expect(vi.getTimerCount()).toBe(1) // Existing strip clock only, no closed-card clock.
strip.wait(60_000)
expect(work.content).toBe(0)
expect(work.footer).toBe(0)
expect(work.rows).toBe(0)
expect(work.records).toBe(0)
expect(work.tabs).toBe(size) // Pre-existing strip clock, linear in visible tabs.
expect(work.ids).toBeLessThanOrEqual(8 * size)
strip.tapAt(150)
strip.wait(400)
expect(vi.getTimerCount()).toBe(2)
const motion = strip.card().props.motion
for (const x of [10, 11, 12]) {
strip.queue()
reset()
scroll(x)
expect(work).toMatchObject({
tabs: 0,
content: 0,
footer: 0,
rows: 0,
ids: 0,
records: 0,
measures: 1
})
strip.flush() // Measurement settlement has its own act turn.
expect(work).toMatchObject({
tabs: 0,
content: 0,
footer: 0,
rows: 0,
ids: 0,
records: 0,
measures: 3
})
expect(strip.card().props).toMatchObject({ motion, anchor: { trigger: { x: 100 - x } } })
expect(strip.card().findByProps({ role: 'dialog' }).props.style[1].left).toBe(100 - x)
}
reset()
strip.wait(60_000)
expect(work).toMatchObject({ content: 1, footer: 1, rows: 0, records: 0, tabs: size })
expect(work.ids).toBeLessThanOrEqual(8 * size)
strip.updateTabs(
tabs.map((tab, i) => (i === 1 ? { ...tab, title: 'Renamed current tab' } : tab))
)
expect(strip.card().findAllByProps({ children: 'Renamed current tab' })).toHaveLength(1)
act(() => strip.root.find((node) => node.props.horizontal === true).props.onScrollBeginDrag())
expect(vi.getTimerCount()).toBe(1)
strip.unmount()
expect(work.back).toBeNull()
expect(work.removeAppState).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
})
+67 -69
View File
@@ -1,18 +1,16 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, ScrollView, Pressable } from 'react-native'
import { File, FileText, Globe, Plus, SquareTerminal, Columns2 } from 'lucide-react-native'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { View, ScrollView } from 'react-native'
import { Plus, Columns2 } from 'lucide-react-native'
import { triggerMediumImpact } from '../platform/haptics'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import { AgentStateDot } from '../components/AgentStateDot'
import { useNow } from '../hooks/use-now'
import { agentDotState, agentStateLabel, type AgentDotState } from '../worktree/agent-row-display'
import { agentDotState, type AgentDotState } from '../worktree/agent-row-display'
import {
getMobileTerminalTabHostAgentIdentity,
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
} from './mobile-terminal-tab-agent'
import { colors } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { MobileSessionHeaderIconButton } from './MobileSessionHeaderIconButton'
import { QuickCommandsTabButton } from './QuickCommandsTabButton'
@@ -26,15 +24,20 @@ import { styles } from './mobile-session-styles'
import { useKeyboardPersistingTaps } from '../platform/keyboard-persisting-taps'
import type { MobileSessionController } from './use-mobile-session-controller'
import { MobileSessionTab } from './MobileSessionTab'
import type { SessionTitleCard } from './use-session-title-card'
const DOUBLE_TAP_RENAME_MS = 300
export function MobileSessionTabStrip({
export const MobileSessionTabStrip = memo(function MobileSessionTabStrip({
controller,
inTopBar,
details,
actionsCollapsed = false
}: {
controller: MobileSessionController
inTopBar: boolean
details: SessionTitleCard['tabDetails']
actionsCollapsed?: boolean
}) {
useMobileColorScheme()
@@ -64,7 +67,17 @@ export function MobileSessionTabStrip({
setShowTerminalLayout,
terminals
} = controller
const stripBarRef = useRef<View>(null)
const tabBarRef = inTopBar ? details.barRef : stripBarRef
const { dismiss, toggleSource, refreshSource } = details
const tabBarKeepsKeyboard = useKeyboardPersistingTaps('handled')
const attachTabBar = useCallback(
(view: View | null) => {
stripBarRef.current = view
tabBarKeepsKeyboard(view)
},
[tabBarKeepsKeyboard]
)
const lastTabTapRef = useRef<{ id: string; at: number } | null>(null)
const now = useNow(60_000)
const tabStates = useMemo(() => {
@@ -79,6 +92,22 @@ export function MobileSessionTabStrip({
}
return states
}, [visibleTabs, now])
const tabLabels = useMemo(
() =>
visibleTabs.map((tab) => {
const identity = getMobileTerminalTabHostAgentIdentity(tab, terminals)
return {
title: getMobileSessionTabTitle(tab, identity),
agentId:
tab.type === 'agent-session'
? tab.agent
: tab.type === 'terminal'
? resolveMobileTerminalTabAgentId(tab, identity)
: null
}
}),
[visibleTabs, terminals, t]
)
const [hiddenTabs, setHiddenTabs] = useState<TabStripHiddenTabs>({ left: [], right: [] })
const refreshHiddenTabs = () => {
const next = resolveTabStripHiddenTabs({
@@ -97,7 +126,7 @@ export function MobileSessionTabStrip({
const leftHint = resolveTabStripEdgeHint(hiddenTabs.left, tabStates)
const rightHint = resolveTabStripEdgeHint(hiddenTabs.right, tabStates)
const pressTab = useCallback(
(t: (typeof visibleTabs)[number]) => {
(t: (typeof visibleTabs)[number], source: Parameters<typeof toggleSource>[0]) => {
const at = Date.now()
const lastTap = lastTabTapRef.current
lastTabTapRef.current = { id: t.id, at }
@@ -108,6 +137,7 @@ export function MobileSessionTabStrip({
typeof t.terminal === 'string'
) {
lastTabTapRef.current = null
dismiss()
triggerMediumImpact()
setRenameTarget({
handle: t.terminal,
@@ -130,9 +160,12 @@ export function MobileSessionTabStrip({
holdTabStripReveal(nextOffset, DOUBLE_TAP_RENAME_MS)
}
switchSessionTab(t)
toggleSource(source)
},
[
activeHandleRef,
dismiss,
toggleSource,
cancelHeldTabStripReveal,
holdTabStripReveal,
setRenameTarget,
@@ -164,7 +197,11 @@ export function MobileSessionTabStrip({
]
)
return (
<View ref={tabBarKeepsKeyboard} style={inTopBar ? styles.tabBarInTopBar : styles.tabBar}>
<View
ref={attachTabBar}
onLayout={() => refreshSource()}
style={inTopBar ? styles.tabBarInTopBar : styles.tabBar}
>
{/* Why: tab taps must register on first press with the keyboard open instead of being eaten by dismissal (#5106). */}
<View style={styles.tabScrollFrame}>
<ScrollView
@@ -175,34 +212,42 @@ export function MobileSessionTabStrip({
contentContainerStyle={[styles.tabContent, inTopBar && styles.tabContentInTopBar]}
keyboardShouldPersistTaps="handled"
scrollEventThrottle={16}
onScrollBeginDrag={cancelHeldTabStripReveal}
onScrollBeginDrag={() => {
cancelHeldTabStripReveal()
dismiss()
}}
onScroll={(e) => {
tabStripOffsetRef.current = e.nativeEvent.contentOffset.x
refreshHiddenTabs()
refreshSource()
}}
onLayout={(e) => {
tabStripViewportWidthRef.current = e.nativeEvent.layout.width
scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
refreshHiddenTabs()
refreshSource()
}}
onContentSizeChange={(width) => {
tabStripContentWidthRef.current = width
scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
refreshHiddenTabs()
refreshSource()
}}
>
{visibleTabs.map((tab, index) => {
const tabState = tabStates.get(tab.id)
const hostAgentIdentity = getMobileTerminalTabHostAgentIdentity(tab, terminals)
const title = getMobileSessionTabTitle(tab, hostAgentIdentity)
const { title, agentId } = tabLabels[index]
return (
<Pressable
<MobileSessionTab
key={tab.id}
style={[
styles.tab,
index === 0 && !inTopBar && styles.tabFirst,
tab.id === activeSessionTabId && styles.tabActive
]}
tab={tab}
first={index === 0 && !inTopBar}
active={tab.id === activeSessionTabId}
title={title}
tabState={tabState}
agentId={agentId}
details={details}
barRef={tabBarRef}
onLayout={(e) => {
const { x, width } = e.nativeEvent.layout
tabLayoutsRef.current.set(tab.id, { x, width })
@@ -211,60 +256,13 @@ export function MobileSessionTabStrip({
}
refreshHiddenTabs()
}}
onPress={() => pressTab(tab)}
accessibilityRole="tab"
accessibilityLabel={
tabState && tabState !== 'idle'
? t('session.tabStrip.tabStatus', '{{title}}, {{status}}', {
title,
status: agentStateLabel(tabState)
})
: title
}
accessibilityState={{ selected: tab.id === activeSessionTabId }}
onPress={(source) => pressTab(tab, source)}
onLongPress={() => {
dismiss()
triggerMediumImpact()
openSessionTabActionSheetAfterKeyboardDismiss(tab)
}}
delayLongPress={400}
>
<View style={styles.tabLabelRow}>
{tabState && tabState !== 'idle' ? <AgentStateDot state={tabState} /> : null}
<View style={styles.tabIcon}>
{tab.type === 'browser' && (
<Globe size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{tab.type === 'markdown' && (
<FileText size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{tab.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{tab.type === 'agent-session' && (
<MobileAgentIcon agentId={tab.agent} size={13} />
)}
{tab.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(tab, hostAgentIdentity)
return agentId ? (
<MobileAgentIcon agentId={agentId} size={13} />
) : (
<SquareTerminal
size={13}
color={colors.textSecondary}
strokeWidth={2.1}
/>
)
})()}
</View>
<Text
style={[styles.tabText, tab.id === activeSessionTabId && styles.tabTextActive]}
numberOfLines={1}
>
{title}
</Text>
</View>
</Pressable>
/>
)
})}
</ScrollView>
@@ -303,4 +301,4 @@ export function MobileSessionTabStrip({
) : null}
</View>
)
}
})
@@ -1,11 +1,12 @@
import { createContext } from 'react'
import { Animated, View } from 'react-native'
import { createContext, useMemo, useRef } from 'react'
import { Animated, Pressable, View } from 'react-native'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { assert, expect, it, vi } from 'vitest'
import { MobileSessionHeader } from './MobileSessionHeader'
import { MobileSessionSurface } from './MobileSessionSurface'
import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
import type { MobileSessionController } from './use-mobile-session-controller'
import { useDetailCardSource, type DetailCardSource } from '../components/use-anchored-detail-card'
import { useSessionTitleCard } from './use-session-title-card'
import { AnchoredDetailCard } from '../components/AnchoredDetailCard'
@@ -35,6 +36,7 @@ vi.mock('react-native', () => {
}
}
return {
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
View: 'View',
Text: 'Text',
Pressable: 'Pressable',
@@ -101,6 +103,7 @@ vi.mock('./MobileSessionHeaderMoreActionsSheet', () => ({
}))
vi.mock('./MobileSessionContentRow', () => ({ MobileSessionContentRow: () => null }))
vi.mock('./MobileSessionSheets', () => ({ MobileSessionSheets: () => null }))
vi.mock('./MobileSessionTabCardFooter', () => ({ MobileSessionTabCardFooter: () => null }))
vi.mock('./MobileWorkerAutoCloseStrip', () => ({ MobileWorkerAutoCloseStrip: () => null }))
vi.mock('../transport/use-workspace-client', () => ({
WorkspaceServerContext: createContext(null)
@@ -119,9 +122,26 @@ const headerFields: Partial<MobileSessionController> = {
const header = headerFields as MobileSessionController
function Fixture() {
const details = useSessionTitleCard(true, false)
const details = useSessionTitleCard(true, false, [{ id: 'back' }])
const tabRef = useRef<View>(null)
const source = useMemo<DetailCardSource>(
() => ({
key: 'tab:back',
anchorRef: tabRef,
openerRef: tabRef,
barRef: details.barRef,
geometry: 'refresh'
}),
[details.barRef]
)
useDetailCardSource(details, source)
return (
<View>
<Pressable
ref={tabRef}
accessibilityLabel="Tab details"
onPress={() => details.toggleSource(source)}
/>
<MobileSessionHeader controller={header} details={details} />
<View ref={details.layerRef} testID="layer" onLayout={details.onLayout}>
{details.visual ? (
@@ -261,6 +281,67 @@ it('preserves title identity and current request across queued results and Back'
act(() => renderer.unmount())
})
it.each([false, true])(
'closes pending and measured tab details before leaving, rail=%s',
(rail) => {
overlay.width = 667
overlay.height = 375
overlay.measure = null
leave.mockClear()
let renderer: ReactTestRenderer | undefined
act(() => {
renderer = create(<Screen rail={rail} />, {
createNodeMock: (node) => ({
measureInWindow: (done: (x: number, y: number, w: number, h: number) => void) => {
const props = node.props
if (
props &&
typeof props === 'object' &&
'testID' in props &&
props.testID === 'layer'
) {
overlay.measure = () => done(0, 0, 667, 375)
} else {
done(80, 20, 40, 45)
}
}
})
})
})
assert(renderer)
const mounted = renderer
const root = mounted.root
const tap = () =>
act(() => root.findByProps({ accessibilityLabel: 'Tab details' }).props.onPress())
const back = () =>
act(() =>
(rail
? root.findByProps({
accessibilityRole: 'button',
accessibilityLabel: 'Back to worktrees'
})
: root.findByProps({ hitSlop: 4 })
).props.onPress()
)
tap()
const readMeasurement = (): (() => void) | null => overlay.measure
const stale = readMeasurement()
back()
act(() => stale?.())
expect(root.findAllByType(AnchoredDetailCard)).toHaveLength(0)
expect(leave).not.toHaveBeenCalled()
tap()
act(() => overlay.measure?.())
expect(root.findByType(AnchoredDetailCard).props.active).toBe(true)
act(() => overlay.motions.at(-1)?.()())
back()
expect(root.findByType(AnchoredDetailCard).props.active).toBe(false)
expect(leave).not.toHaveBeenCalled()
back()
expect(leave).toHaveBeenCalledTimes(1)
act(() => mounted.unmount())
}
)
it('releases an open card and pending measurements when the connected Duo header removes its title', () => {
overlay.rail = true
const pending: (() => void)[] = []
@@ -27,7 +27,7 @@ export function MobileSessionTitleBlock({
const t = useMobileTranslation()
return (
<>
<DetailCardFocusBoundary refs={details.boundaryRefs} index={0} active={details.active} />
<DetailCardFocusBoundary refs={details.boundaryRefs} index={0} active={details.titleActive} />
<Fragment key="title">
<Pressable
style={[
@@ -37,7 +37,7 @@ export function MobileSessionTitleBlock({
ref={details.openerRef}
onLayout={details.onAnchorLayout}
onPress={details.toggle}
aria-expanded={details.requestedOpen}
aria-expanded={details.titleRequestedOpen}
accessibilityRole="button"
accessibilityHint={t(
'session.header.workspaceDetailsHint',
@@ -71,7 +71,7 @@ export function MobileSessionTitleBlock({
</Pressable>
</Pressable>
</Fragment>
<DetailCardFocusBoundary refs={details.boundaryRefs} index={1} active={details.active} />
<DetailCardFocusBoundary refs={details.boundaryRefs} index={1} active={details.titleActive} />
</>
)
}
@@ -54,7 +54,12 @@ vi.mock('../layout/responsive-layout', () => ({
}))
vi.mock('../layout/use-top-chrome-space', () => ({ useTopChromeSpace: () => ({}) }))
vi.mock('../layout/use-shell-side-rail', () => ({ useShellSideRail: () => true }))
vi.mock('../platform/keyboard-persisting-taps', () => ({ useKeyboardPersistingTaps: () => null }))
vi.mock('../platform/keyboard-persisting-taps', () => ({
useKeyboardPersistingTaps: () => () => {}
}))
vi.mock('../layout/side-safe-area', () => ({
useWindowSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 })
}))
import { MobileSessionHeader } from './MobileSessionHeader'
import { MobileSessionTerminalLayoutSheet } from './MobileSessionTerminalLayoutSheet'
@@ -107,9 +112,20 @@ function mountHeader(overrides: Partial<MobileSessionController> = {}) {
showChecksAction: true,
activePanel: null
})
const details = mountFixture<SessionTitleCard>({
besideTabs: true,
const tabDetails: SessionTitleCard['tabDetails'] = {
barRef: useRef(null),
boundaryRefs: useRef([]),
dismiss: () => {},
refreshSource: () => {},
registerSource: () => () => {},
toggleSource: () => {},
activeKey: undefined,
requestedKey: null
}
const details = mountFixture<SessionTitleCard>({
tabDetails,
besideTabs: true,
barRef: tabDetails.barRef,
backRef: useRef(null),
openerRef: useRef(null),
titleRef: useRef(null),
@@ -0,0 +1,76 @@
import { expect, it } from 'vitest'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../src/shared/agent-status-freshness'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { MobileSessionTab } from './mobile-session-route-types'
import { mobileSessionTabCardContent } from './mobile-session-tab-card-content'
it('preserves glyph-only agent titles and derives the kind-aware empty chat label', () => {
const terminal: Extract<MobileSessionTab, { type: 'terminal' }> = {
type: 'terminal',
id: 't',
title: '✳',
launchAgent: 'claude',
terminal: 'pty',
isActive: true
}
const title = (tab: MobileSessionTab) => mobileSessionTabCardContent(tab, undefined, 1000).title
expect(title(terminal)).toBe('✳')
expect(
title({
type: 'agent-session',
id: 'c',
title: '',
agent: 'codex',
sessionId: 's',
isActive: true
})
).toBe('Chat')
})
it('hides unconfirmed and stale status and handles redacted and blank browser URLs', () => {
const agentStatus: AgentStatusEntry = {
state: 'working',
agentType: 'claude',
paneKey: 't:l',
prompt: '',
updatedAt: 1000,
stateStartedAt: 1000,
stateHistory: []
}
const terminal: Extract<MobileSessionTab, { type: 'terminal' }> = {
type: 'terminal',
id: 't',
title: '✳ Fix the tab title',
terminal: 'pty',
isActive: true,
agentStatus
}
const content = (tab: MobileSessionTab) => mobileSessionTabCardContent(tab, undefined, 1000)
expect(
content({ ...terminal, agentStatus: { ...agentStatus, restoredUnconfirmed: true } }).state
).toBeNull()
expect(
mobileSessionTabCardContent(terminal, undefined, 1001 + AGENT_STATUS_STALE_AFTER_MS).state
).toBeNull()
const browser: Extract<MobileSessionTab, { type: 'browser' }> = {
browserWorkspaceId: 'w',
browserPageId: 'p',
loading: false,
canGoBack: false,
canGoForward: false,
type: 'browser',
id: 'b',
title: 'Search results',
url: 'https://kagi.com/search?token=private&q=orca',
isActive: true
}
expect(content(browser)).toMatchObject({
title: 'Search results',
programName: 'kagi.com/search?q=orca',
agentId: null
})
expect(content({ ...browser, title: '', url: 'about:blank' })).toMatchObject({
title: 'New Browser',
programName: 'Browser'
})
})
@@ -0,0 +1,80 @@
import { stripLeadingAgentTitleDecoration } from '../../../src/shared/agent-title-decoration'
import { redactKagiSessionToken } from '../../../src/shared/browser-url'
import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names'
import { isTuiAgent } from '../../../src/shared/tui-agent-config'
import { isBlankBrowserUrl } from '../browser/browser-url'
import { translate } from '../i18n/mobile-i18n'
import { agentDotState, type AgentDotState } from '../worktree/agent-row-display'
import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
} from './mobile-terminal-tab-agent'
import type { MobileSessionTab } from './mobile-session-route-types'
export function mobileSessionTabCardContent(
tab: MobileSessionTab,
hostAgentIdentity: string | undefined,
now: number
) {
const agentId =
tab.type === 'agent-session'
? tab.agent
: tab.type === 'terminal'
? resolveMobileTerminalTabAgentId(tab, hostAgentIdentity)
: null
const status =
tab.type === 'agent-session'
? tab.chatStatus
: tab.type === 'terminal' && !tab.agentStatus?.restoredUnconfirmed
? tab.agentStatus
: null
const dot = status ? agentDotState(status, now) : null
const state: AgentDotState | null = dot === 'idle' || dot === 'unconfirmed' ? null : dot
if (tab.type === 'file' || tab.type === 'markdown') {
return {
title: tab.relativePath.split(/[\\/]/).at(-1) || tab.title,
detail: tab.relativePath,
programName: translate('session.tabCard.editor', 'Editor'),
agentId: null,
state: null,
kind: tab.type
}
}
if (tab.type === 'browser') {
const blank = isBlankBrowserUrl(tab.url)
const url = redactKagiSessionToken(tab.url)
let label = url
try {
const parsed = new URL(url)
label = `${parsed.host}${parsed.pathname === '/' ? '' : parsed.pathname}${parsed.search}${parsed.hash}`
} catch {
/* Non-URL labels remain readable. */
}
return {
title:
tab.title && tab.title !== tab.url && !isBlankBrowserUrl(tab.title)
? tab.title
: blank
? translate('session.terminalTabAgent.newBrowser', 'New Browser')
: label,
detail: null,
programName: blank ? translate('session.terminalTabAgent.browser', 'Browser') : label,
agentId: null,
state: null,
kind: tab.type
}
}
const title = agentId ? stripLeadingAgentTitleDecoration(tab.title) : tab.title
return {
title: title === '' ? getMobileSessionTabTitle(tab, hostAgentIdentity) : title,
detail: null,
programName: agentId
? isTuiAgent(agentId)
? TUI_AGENT_DISPLAY_NAMES[agentId]
: agentId
: translate('session.terminalTabAgent.terminal', 'Terminal'),
agentId,
state,
kind: tab.type
}
}
@@ -58,11 +58,14 @@ vi.mock('../transport/use-workspace-client', () => ({
WorkspaceServerContext: createContext(undefined)
}))
vi.mock('../components/AnchoredDetailCard', () => ({ AnchoredDetailCard: () => null }))
vi.mock('./MobileSessionTabCardFooter', () => ({ MobileSessionTabCardFooter: () => null }))
vi.mock('../layout/responsive-layout', () => ({
useResponsiveLayout: () => ({ isLandscapePhone: false })
}))
vi.mock('../layout/use-shell-side-rail', () => ({ useShellSideRail: () => false }))
vi.mock('./use-session-title-card', () => ({ useSessionTitleCard: () => ({ anchor: null }) }))
vi.mock('./use-session-title-card', () => ({
useSessionTitleCard: () => ({ active: false, visual: null, tabId: null, missingTab: false })
}))
vi.mock('./MobileSessionHeader', () => ({ MobileSessionHeader: () => null }))
vi.mock('./MobileSessionContentRow', () => ({ MobileSessionContentRow: () => null }))
vi.mock('./MobileSessionSheets', () => ({ MobileSessionSheets: () => null }))
@@ -45,7 +45,12 @@ vi.mock('expo-router', async () => {
const { useEffect } = await import('react')
return { useFocusEffect: (effect: () => () => void) => useEffect(effect, [effect]) }
})
vi.mock('../platform/keyboard-persisting-taps', () => ({ useKeyboardPersistingTaps: () => null }))
vi.mock('../platform/keyboard-persisting-taps', () => ({
useKeyboardPersistingTaps: () => () => {}
}))
vi.mock('../layout/side-safe-area', () => ({
useWindowSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 })
}))
vi.mock('../navigation/workspace-execution-host', () => ({ useWorkspaceExecutionHost: () => ({}) }))
vi.mock('../browser/MobileBrowserPane', () => ({ MobileBrowserPane: () => null }))
vi.mock('./MobileNativeChatOverlay', () => ({ MobileNativeChatOverlay: () => null }))
@@ -77,6 +82,16 @@ it('lets the split menu override sidebar width and rotation without remounting p
let setKeyboard!: (height: number) => void
function Harness() {
const preference = useTerminalSplitLayout()
const details: Parameters<typeof MobileSessionTabStrip>[0]['details'] = {
barRef: useRef(null),
boundaryRefs: useRef([]),
dismiss: () => {},
refreshSource: () => {},
registerSource: () => () => {},
toggleSource: () => {},
activeKey: undefined,
requestedKey: null
}
const [split, setSplit] = useState(true)
const [showTerminalLayout, setShowTerminalLayout] = useState(false)
const [active, setActive] = useState('b')
@@ -120,7 +135,7 @@ it('lets the split menu override sidebar width and rotation without remounting p
return createElement(
'Screen',
null,
createElement(MobileSessionTabStrip, { controller, inTopBar: false }),
createElement(MobileSessionTabStrip, { controller, inTopBar: false, details }),
createElement(MobileSessionTerminalLayoutSheet, {
controller,
visible: showTerminalLayout,
+59 -150
View File
@@ -1,165 +1,74 @@
import { useCallback, useLayoutEffect, useReducer, useRef, useState } from 'react'
import type { ComponentRef } from 'react'
import { type Text, useWindowDimensions, type LayoutChangeEvent, type View } from 'react-native'
import type { DetailCardAnchor } from '../components/AnchoredDetailCard'
import { useBackClaim } from '../navigation/use-back-claim'
import { useWindowSafeAreaInsets } from '../layout/side-safe-area'
import { useCallback, useLayoutEffect, useMemo, useRef, type ComponentRef } from 'react'
import type { Text, View } from 'react-native'
import {
useAnchoredDetailCard,
useDetailCardSource,
type DetailCardSource
} from '../components/use-anchored-detail-card'
type State = {
serial: number
request: number | null
visual: { anchor: DetailCardAnchor; target: 0 | 1; motion: number } | null
}
type Action =
| { type: 'toggle'; eligible: boolean }
| { type: 'close'; request?: number }
| { type: 'measured'; request: number; anchor: DetailCardAnchor }
| { type: 'settled'; motion: number }
function reduce(state: State, action: Action): State {
const { visual, request, serial } = state
switch (action.type) {
case 'toggle':
if (request === null && visual?.target !== 1) {
return action.eligible ? { ...state, serial: serial + 1, request: serial + 1 } : state
}
return reduce(state, { type: 'close' })
case 'close':
if (action.request !== undefined && action.request !== request) {
return state
}
return visual?.target === 1
? {
...state,
request: null,
serial: serial + 1,
visual: { ...visual, target: 0, motion: serial + 1 }
}
: { ...state, request: null }
case 'measured': {
if (action.request !== request) {
return state
}
const { anchor } = action
return {
serial: serial + 1,
request: null,
visual: { anchor, target: 1, motion: serial + 1 }
}
}
case 'settled':
return visual?.target === 0 && visual.motion === action.motion
? { ...state, visual: null }
: state
}
}
export function useSessionTitleCard(besideTabs: boolean, obscured: boolean) {
const layerRef = useRef<View>(null)
export function useSessionTitleCard(
besideTabs: boolean,
obscured: boolean,
tabs: readonly { id: string }[] = [],
titleVisible = true
) {
const barRef = useRef<View>(null)
const titleRef = useRef<ComponentRef<typeof Text>>(null)
const openerRef = useRef<View>(null)
const contentRef = useRef<View>(null)
const backRef = useRef<View>(null)
const boundaryRefs = useRef<unknown[]>([])
const [state, dispatch] = useReducer(reduce, {
serial: 0,
request: null,
visual: null
})
const [size, setSize] = useState({ width: 0, height: 0 })
const window = useWindowDimensions()
const insets = useWindowSafeAreaInsets()
const { top, left, right } = insets
const generation = useRef(0)
const geometry = [window.width, top, left, right, besideTabs, size.width].join(':')
const dismiss = useCallback(() => dispatch({ type: 'close' }), [])
const onSettled = useCallback((motion: number) => dispatch({ type: 'settled', motion }), [])
const toggle = useCallback(() => dispatch({ type: 'toggle', eligible: !obscured }), [obscured])
const onAnchorLayout = useCallback(() => {
generation.current += 1
dismiss()
}, [dismiss])
const card = useAnchoredDetailCard(String(besideTabs), obscured)
const source = useMemo<DetailCardSource>(
() => ({ key: 'title', anchorRef: titleRef, barRef, openerRef, geometry: 'dismiss' }),
[]
)
useDetailCardSource(card, source)
const { toggleSource } = card
const toggle = useCallback(() => toggleSource(source), [toggleSource, source])
const tabKey = card.visual?.source.key
const tabId = tabKey?.startsWith('tab:') ? tabKey.slice(4) : null
const requestedTabId = card.requestedKey?.startsWith('tab:') ? card.requestedKey.slice(4) : null
const tabIds = useMemo(() => new Set(tabs.map((tab) => tab.id)), [tabs])
const missingTab = Boolean(tabId && !tabIds.has(tabId))
const missingTitle = !titleVisible && tabKey === 'title'
const missingRequest = Boolean(
(requestedTabId && !tabIds.has(requestedTabId)) ||
(!titleVisible && card.requestedKey === 'title')
)
const missingKey = missingRequest ? card.requestedKey : missingTab || missingTitle ? tabKey : null
const { removeSource, dismiss, refreshSource, registerSource, boundaryRefs } = card
useLayoutEffect(() => {
dismiss()
return () => {
generation.current += 1
if (missingKey) {
removeSource(missingKey)
}
}, [dismiss, geometry, obscured])
const request = state.request
useLayoutEffect(() => {
if (request === null || obscured) {
return
}
const layer = layerRef.current
const title = titleRef.current
const bar = barRef.current
if (!layer || !title || !bar) {
dispatch({ type: 'close', request })
return
}
let disposed = false
const current = generation.current
layer.measureInWindow((rootX, rootY) => {
title.measureInWindow((x, y, width, height) => {
bar.measureInWindow((_x, barY, _width, barHeight) => {
if (!disposed && current === generation.current) {
dispatch({
type: 'measured',
request,
anchor: {
trigger: { x: x - rootX, y: y - rootY, width, height },
top: barY + barHeight - rootY,
origin: { x: rootX, y: rootY }
}
})
}
})
})
})
return () => {
disposed = true
}
}, [request, geometry, obscured])
const onLayout = useCallback((event: LayoutChangeEvent) => {
setSize(event.nativeEvent.layout)
}, [])
const { visual } = state
const requestedOpen = request !== null || visual?.target === 1
const closeFirst = () => {
if (!requestedOpen || obscured) {
return false
}
dismiss()
return true
}
useBackClaim(requestedOpen && !obscured ? closeFirst : null)
const origin = visual?.anchor.origin ?? { x: 0, y: 0 }
}, [missingKey, removeSource])
const activeKey = card.active ? card.visual?.source.key : null
const { requestedKey } = card
const tabDetails = useMemo(
() => ({
barRef,
boundaryRefs,
dismiss,
refreshSource,
registerSource,
toggleSource,
activeKey,
requestedKey
}),
[boundaryRefs, dismiss, refreshSource, registerSource, toggleSource, activeKey, requestedKey]
)
return {
layerRef,
tabId,
tabDetails,
missingTab,
missingTitle,
...card,
barRef,
titleRef,
openerRef,
contentRef,
backRef,
boundaryRefs,
closeFirst,
active: visual?.target === 1 && !obscured,
visual,
requestedOpen,
present: visual !== null,
besideTabs,
dismiss,
onSettled,
onLayout,
onAnchorLayout,
toggle,
bounds: {
left: Math.max(0, insets.left - origin.x),
top: Math.max(0, insets.top - origin.y),
right: Math.min(size.width, window.width - insets.right - origin.x),
bottom: Math.min(size.height, window.height - insets.bottom - origin.y)
}
scopeOpenerRef: card.visual?.source.openerRef ?? openerRef,
titleActive: card.active && card.visual?.source.key === 'title',
titleRequestedOpen: card.requestedKey === 'title'
}
}