mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user