diff --git a/mobile/src/components/use-anchored-detail-card.ts b/mobile/src/components/use-anchored-detail-card.ts new file mode 100644 index 00000000000..b01c81ff508 --- /dev/null +++ b/mobile/src/components/use-anchored-detail-card.ts @@ -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 | null> + barRef: RefObject + openerRef: RefObject + 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(null) + const contentRef = useRef(null) + const backRef = useRef(null) + const boundaryRefs = useRef([]) + const sources = useRef(new Map()) + 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 + +export function useDetailCardSource( + controller: Pick, + source: DetailCardSource +) { + const { registerSource } = controller + useLayoutEffect(() => registerSource(source), [registerSource, source]) +} diff --git a/mobile/src/components/use-detail-card-scope.web.test.tsx b/mobile/src/components/use-detail-card-scope.web.test.tsx index f9454b551a8..dfa35282c0e 100644 --- a/mobile/src/components/use-detail-card-scope.web.test.tsx +++ b/mobile/src/components/use-detail-card-scope.web.test.tsx @@ -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(null) const boundaryRefs = useRef([]) - const openerRef = useRef(null) + const titleRef = useRef(null) + const tab1Ref = useRef(null) + const tab2Ref = useRef(null) + const openerRef = source === 'title' ? titleRef : source === 'tab1' ? tab1Ref : tab2Ref const backRef = useRef(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 - - - + + {(['tab1', 'tab2'] as const).map((id) => ( + + + + + + ))} +
@@ -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( - + ) ) @@ -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 { diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 50be65a0259..b18d99cedf5 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -2130,6 +2130,9 @@ "terminalRecords": { "defaultTitle": "Terminal" }, + "tabCard": { + "editor": "Editor" + }, "terminalTabAgent": { "newBrowser": "New Browser", "browser": "Browser", diff --git a/mobile/src/mobile-web-shell/host-area-in-page-cleanup.test.tsx b/mobile/src/mobile-web-shell/host-area-in-page-cleanup.test.tsx index 51871338173..0856ae9a375 100644 --- a/mobile/src/mobile-web-shell/host-area-in-page-cleanup.test.tsx +++ b/mobile/src/mobile-web-shell/host-area-in-page-cleanup.test.tsx @@ -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 })) diff --git a/mobile/src/session/MobileSessionHeader.tsx b/mobile/src/session/MobileSessionHeader.tsx index beaf70f3a5b..ec0d8858d7f 100644 --- a/mobile/src/session/MobileSessionHeader.tsx +++ b/mobile/src/session/MobileSessionHeader.tsx @@ -76,6 +76,7 @@ export function MobileSessionHeader({ ) : null diff --git a/mobile/src/session/MobileSessionSurface.tsx b/mobile/src/session/MobileSessionSurface.tsx index 3a0c35bd2e5..921b285edf7 100644 --- a/mobile/src/session/MobileSessionSurface.tsx +++ b/mobile/src/session/MobileSessionSurface.tsx @@ -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 ? : null), + [tabContent] + ) + const contentRow = useMemo( + () => , + [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 - + {contentRow} {hostName} diff --git a/mobile/src/session/MobileSessionTab.tsx b/mobile/src/session/MobileSessionTab.tsx new file mode 100644 index 00000000000..91dbe2ed4f0 --- /dev/null +++ b/mobile/src/session/MobileSessionTab.tsx @@ -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 + onLayout: (event: LayoutChangeEvent) => void + onPress: (source: DetailCardSource) => void + onLongPress: () => void +}) { + const t = useMobileTranslation() + const openerRef = useRef(null) + const source = useMemo( + () => ({ key: `tab:${tab.id}`, anchorRef: openerRef, openerRef, barRef, geometry: 'refresh' }), + [tab.id, barRef] + ) + useDetailCardSource(details, source) + const ownsCard = details.activeKey === source.key + return ( + <> + + { + 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} + > + + {tabState && tabState !== 'idle' ? : null} + + {tab.type === 'browser' && ( + + )} + {tab.type === 'markdown' && ( + + )} + {tab.type === 'file' && ( + + )} + {agentId ? ( + + ) : tab.type === 'terminal' ? ( + + ) : null} + + + {title} + + + + + + ) +} diff --git a/mobile/src/session/MobileSessionTabCardFooter.tsx b/mobile/src/session/MobileSessionTabCardFooter.tsx new file mode 100644 index 00000000000..a7607d0a7ad --- /dev/null +++ b/mobile/src/session/MobileSessionTabCardFooter.tsx @@ -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 +}) { + useMobileColorScheme() + const Icon = + content.kind === 'browser' + ? Globe + : content.kind === 'markdown' + ? FileText + : content.kind === 'file' + ? File + : SquareTerminal + return ( + <> + {content.state ? : null} + {content.agentId ? ( + + ) : ( + + )} + {content.programName} + + ) +} diff --git a/mobile/src/session/MobileSessionTabStrip.test.tsx b/mobile/src/session/MobileSessionTabStrip.test.tsx index a641e462a34..f2eeb36b372 100644 --- a/mobile/src/session/MobileSessionTabStrip.test.tsx +++ b/mobile/src/session/MobileSessionTabStrip.test.tsx @@ -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 +} + +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[0]) => + createElement('AgentIcon', props) +})) +vi.mock('../components/AgentStateDot', () => ({ + AgentStateDot: (props: Parameters[0]) => createElement('StateDot', props) +})) +vi.mock('./MobileSessionHeaderIconButton', () => ({ + MobileSessionHeaderIconButton: (props: Parameters[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() + return { + MobileSessionTab: (props: Parameters[0]) => { + work.tabs++ + return createElement(actual.MobileSessionTab, props) + } + } +}) +vi.mock('./MobileSessionTabCardFooter', async (original) => { + const actual = await original() + return { + MobileSessionTabCardFooter: ( + props: Parameters[0] + ) => { + work.footer++ + return createElement(actual.MobileSessionTabCardFooter, props) + } + } +}) +vi.mock('./mobile-session-tab-card-content', async (original) => { + const actual = await original() + return { + mobileSessionTabCardContent: ( + ...args: Parameters + ) => { + 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[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[] = [] + terminals: Pick[] = [], + 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 | 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 | 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) }) }) diff --git a/mobile/src/session/MobileSessionTabStrip.tsx b/mobile/src/session/MobileSessionTabStrip.tsx index 8c6a5fc9397..39916d93531 100644 --- a/mobile/src/session/MobileSessionTabStrip.tsx +++ b/mobile/src/session/MobileSessionTabStrip.tsx @@ -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(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({ 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[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 ( - + 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). */} { + 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 ( - { 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} - > - - {tabState && tabState !== 'idle' ? : null} - - {tab.type === 'browser' && ( - - )} - {tab.type === 'markdown' && ( - - )} - {tab.type === 'file' && ( - - )} - {tab.type === 'agent-session' && ( - - )} - {tab.type === 'terminal' && - (() => { - const agentId = resolveMobileTerminalTabAgentId(tab, hostAgentIdentity) - return agentId ? ( - - ) : ( - - ) - })()} - - - {title} - - - + /> ) })} @@ -303,4 +301,4 @@ export function MobileSessionTabStrip({ ) : null} ) -} +}) diff --git a/mobile/src/session/MobileSessionTitleBlock.test.tsx b/mobile/src/session/MobileSessionTitleBlock.test.tsx index 0608ef551ff..3e267536776 100644 --- a/mobile/src/session/MobileSessionTitleBlock.test.tsx +++ b/mobile/src/session/MobileSessionTitleBlock.test.tsx @@ -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 = { const header = headerFields as MobileSessionController function Fixture() { - const details = useSessionTitleCard(true, false) + const details = useSessionTitleCard(true, false, [{ id: 'back' }]) + const tabRef = useRef(null) + const source = useMemo( + () => ({ + key: 'tab:back', + anchorRef: tabRef, + openerRef: tabRef, + barRef: details.barRef, + geometry: 'refresh' + }), + [details.barRef] + ) + useDetailCardSource(details, source) return ( + details.toggleSource(source)} + /> {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(, { + 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)[] = [] diff --git a/mobile/src/session/MobileSessionTitleBlock.tsx b/mobile/src/session/MobileSessionTitleBlock.tsx index 332760bf153..276a4f7f3ad 100644 --- a/mobile/src/session/MobileSessionTitleBlock.tsx +++ b/mobile/src/session/MobileSessionTitleBlock.tsx @@ -27,7 +27,7 @@ export function MobileSessionTitleBlock({ const t = useMobileTranslation() return ( <> - + - + ) } diff --git a/mobile/src/session/mobile-session-header-overflow.test.tsx b/mobile/src/session/mobile-session-header-overflow.test.tsx index 4646917026e..7b13e3565a3 100644 --- a/mobile/src/session/mobile-session-header-overflow.test.tsx +++ b/mobile/src/session/mobile-session-header-overflow.test.tsx @@ -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 = {}) { showChecksAction: true, activePanel: null }) - const details = mountFixture({ - besideTabs: true, + const tabDetails: SessionTitleCard['tabDetails'] = { barRef: useRef(null), + boundaryRefs: useRef([]), + dismiss: () => {}, + refreshSource: () => {}, + registerSource: () => () => {}, + toggleSource: () => {}, + activeKey: undefined, + requestedKey: null + } + const details = mountFixture({ + tabDetails, + besideTabs: true, + barRef: tabDetails.barRef, backRef: useRef(null), openerRef: useRef(null), titleRef: useRef(null), diff --git a/mobile/src/session/mobile-session-tab-card-content.test.ts b/mobile/src/session/mobile-session-tab-card-content.test.ts new file mode 100644 index 00000000000..c4a9f259527 --- /dev/null +++ b/mobile/src/session/mobile-session-tab-card-content.test.ts @@ -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 = { + 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 = { + 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 = { + 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' + }) +}) diff --git a/mobile/src/session/mobile-session-tab-card-content.ts b/mobile/src/session/mobile-session-tab-card-content.ts new file mode 100644 index 00000000000..e350b10544c --- /dev/null +++ b/mobile/src/session/mobile-session-tab-card-content.ts @@ -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 + } +} diff --git a/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx b/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx index 59bba5b7b2e..1f35da9edce 100644 --- a/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx +++ b/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx @@ -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 })) diff --git a/mobile/src/session/mobile-terminal-layout-choice.test.tsx b/mobile/src/session/mobile-terminal-layout-choice.test.tsx index 9bc80b6164c..89053e4cb86 100644 --- a/mobile/src/session/mobile-terminal-layout-choice.test.tsx +++ b/mobile/src/session/mobile-terminal-layout-choice.test.tsx @@ -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[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, diff --git a/mobile/src/session/use-session-title-card.ts b/mobile/src/session/use-session-title-card.ts index ed7b162d9a8..c2f6a6166da 100644 --- a/mobile/src/session/use-session-title-card.ts +++ b/mobile/src/session/use-session-title-card.ts @@ -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(null) +export function useSessionTitleCard( + besideTabs: boolean, + obscured: boolean, + tabs: readonly { id: string }[] = [], + titleVisible = true +) { const barRef = useRef(null) const titleRef = useRef>(null) const openerRef = useRef(null) - const contentRef = useRef(null) - const backRef = useRef(null) - const boundaryRefs = useRef([]) - 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( + () => ({ 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' } }