feat(mobile): tab strip shows terminal and chat agent state, reveals the next tab, renames on double-tap (#31372)

This commit is contained in:
Kelvin Amoaba
2026-10-21 18:27:30 +00:00
committed by GitHub
parent cdb032e83f
commit 22154de422
24 changed files with 910 additions and 141 deletions
@@ -0,0 +1,132 @@
import { createElement, useRef, useState } from 'react'
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'
vi.mock('react-native', () => ({
View: 'View',
Text: 'Text',
ScrollView: 'ScrollView',
Pressable: 'Pressable'
}))
vi.mock('lucide-react-native', () => ({
File: 'Icon',
FileText: 'Icon',
Globe: 'Icon',
Plus: 'Icon',
SquareTerminal: 'Icon'
}))
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: () => {} }))
vi.mock('../hooks/use-now', () => ({ useNow: () => 0 }))
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
vi.mock('../components/AgentStateDot', () => ({ AgentStateDot: () => null }))
vi.mock('./MobileSessionHeaderIconButton', () => ({ MobileSessionHeaderIconButton: () => null }))
vi.mock('./QuickCommandsTabButton', () => ({ QuickCommandsTabButton: () => null }))
vi.mock('./mobile-session-styles', () => ({ styles: {} }))
vi.mock('../platform/keyboard-persisting-taps', () => ({ useKeyboardPersistingTaps: () => null }))
vi.mock('../platform/keyboard-occlusion', () => ({
useSoftKeyboard: () => ({ height: 0, visible: false })
}))
vi.mock('../terminal/terminal-viewport-refit', () => ({
useTerminalViewportRefit: () => ({ notifyKeyboardVisibility: () => {} })
}))
vi.mock('../worktree/last-visited-worktree-repo', () => ({ writeLastVisitedWorktree: () => {} }))
import { MobileSessionTabStrip } from './MobileSessionTabStrip'
import { useMobileSessionKeyboardState } from './use-mobile-session-keyboard-state'
type Controller = Parameters<typeof MobileSessionTabStrip>[0]['controller']
type KeyboardScope = Parameters<typeof useMobileSessionKeyboardState>[0]
const TAB_IDS = ['a', 'b', 'c', 'd', 'e']
const TAB_WIDTH = 100
const visibleTabs = TAB_IDS.map((id) => ({
type: 'terminal' as const,
id,
terminal: `term-${id}`,
title: id
}))
// The real activation reveal runs, so switching tabs scrolls the strip as it does in the app.
function mountStrip() {
const scrollTo = vi.fn()
const setRenameTarget = vi.fn()
const offsetRef = { current: 0 }
function Harness() {
const [activeSessionTabId, setActiveSessionTabId] = useState('a')
const activeSessionTabIdRef = useRef(activeSessionTabId)
activeSessionTabIdRef.current = activeSessionTabId
const strip = {
activeSessionTabId,
tabStripRef: useRef(null),
tabStripOffsetRef: offsetRef,
tabStripViewportWidthRef: useRef(0),
tabStripContentWidthRef: useRef(0),
tabLayoutsRef: useRef(new Map())
}
const keyboard = useMobileSessionKeyboardState(
mountFixture<KeyboardScope>({ ...strip, terminals: [], setKeyboardHeight: () => {} })
)
const controller = mountFixture<Controller>({
...strip,
...keyboard,
connState: 'connected',
activeSessionTabIdRef,
activeHandleRef: { current: null },
switchSessionTab: (tab: { id: string }) => setActiveSessionTabId(tab.id),
setRenameTarget,
visibleTabs
})
return createElement(MobileSessionTabStrip, { controller, inTopBar: false })
}
let tree: ReturnType<typeof create> | undefined
act(() => {
tree = create(createElement(Harness), { createNodeMock: () => ({ scrollTo }) })
})
const root = tree!.root
const tabs = root.findAll((node) => node.props.accessibilityRole === 'tab')
const strip = root.find((node) => node.props.horizontal === true)
// A 300px strip with tab c cut off at its right edge.
act(() => {
strip.props.onLayout({ nativeEvent: { layout: { width: 300 } } })
strip.props.onContentSizeChange(TAB_IDS.length * TAB_WIDTH)
tabs.forEach((tab: ReactTestInstance, index: number) =>
tab.props.onLayout({ nativeEvent: { layout: { x: index * TAB_WIDTH, width: TAB_WIDTH } } })
)
})
// Presses whichever tab sits under this screen x after any scroll so far.
const tapAt = (screenX: number) =>
act(() => tabs[Math.floor((screenX + offsetRef.current) / TAB_WIDTH)].props.onPress())
const wait = (ms: number) =>
act(() => {
vi.advanceTimersByTime(ms)
})
return { scrollTo, setRenameTarget, tapAt, wait }
}
describe('MobileSessionTabStrip edge taps', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('requestAnimationFrame', (cb: (at: number) => void) =>
setTimeout(() => cb(Date.now()), 16)
)
vi.stubGlobal('cancelAnimationFrame', clearTimeout)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('renames the tab a double-tap started on, then reveals its neighbour', () => {
const strip = mountStrip()
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 }]])
})
})
+200 -77
View File
@@ -1,8 +1,11 @@
import { useCallback } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, ScrollView, Pressable } from 'react-native'
import { File, FileText, Globe, Plus, SquareTerminal } from 'lucide-react-native'
import { triggerMediumImpact } from '../platform/haptics'
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 {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
@@ -10,10 +13,18 @@ import {
import { colors } from '../theme/mobile-theme'
import { MobileSessionHeaderIconButton } from './MobileSessionHeaderIconButton'
import { QuickCommandsTabButton } from './QuickCommandsTabButton'
import {
resolveTabStripEdgeHint,
resolveTabStripHiddenTabs,
resolveTabStripTapOffset,
type TabStripHiddenTabs
} from './tab-strip-scroll'
import { styles } from './mobile-session-styles'
import { useKeyboardPersistingTaps } from '../platform/keyboard-persisting-taps'
import type { MobileSessionController } from './use-mobile-session-controller'
const DOUBLE_TAP_RENAME_MS = 300
export function MobileSessionTabStrip({
controller,
inTopBar
@@ -39,11 +50,95 @@ export function MobileSessionTabStrip({
quickCommandsSupported,
showToast,
scrollActiveTabIntoView,
holdTabStripReveal,
cancelHeldTabStripReveal,
switchSessionTab,
openSessionTabActionSheetAfterKeyboardDismiss,
setRenameTarget,
activeHandleRef,
visibleTabs
} = controller
const tabBarKeepsKeyboard = useKeyboardPersistingTaps('handled')
const lastTabTapRef = useRef<{ id: string; at: number } | null>(null)
const now = useNow(60_000)
const tabStates = useMemo(() => {
const states = new Map<string, AgentDotState>()
for (const tab of visibleTabs) {
// Why: a status restored across a host restart is unconfirmed, so like desktop it shows no live state.
if (tab.type === 'terminal' && tab.agentStatus && !tab.agentStatus.restoredUnconfirmed) {
states.set(tab.id, agentDotState(tab.agentStatus, now))
} else if (tab.type === 'agent-session' && tab.chatStatus) {
states.set(tab.id, agentDotState(tab.chatStatus, now))
}
}
return states
}, [visibleTabs, now])
const [hiddenTabs, setHiddenTabs] = useState<TabStripHiddenTabs>({ left: [], right: [] })
const refreshHiddenTabs = () => {
const next = resolveTabStripHiddenTabs({
layouts: tabLayoutsRef.current,
viewportWidth: tabStripViewportWidthRef.current,
currentOffset: tabStripOffsetRef.current
})
// Why: scroll fires every frame; re-render only when a tab crosses an edge.
setHiddenTabs((prev) =>
prev.left.join('\n') === next.left.join('\n') &&
prev.right.join('\n') === next.right.join('\n')
? prev
: next
)
}
const leftHint = resolveTabStripEdgeHint(hiddenTabs.left, tabStates)
const rightHint = resolveTabStripEdgeHint(hiddenTabs.right, tabStates)
const pressTab = useCallback(
(t: (typeof visibleTabs)[number]) => {
const at = Date.now()
const lastTap = lastTabTapRef.current
lastTabTapRef.current = { id: t.id, at }
if (
lastTap?.id === t.id &&
at - lastTap.at < DOUBLE_TAP_RENAME_MS &&
t.type === 'terminal' &&
typeof t.terminal === 'string'
) {
lastTabTapRef.current = null
triggerMediumImpact()
setRenameTarget({
handle: t.terminal,
title: t.title,
isActive: t.terminal === activeHandleRef.current
})
// The first tap's held reveal still runs once the window closes.
return
}
cancelHeldTabStripReveal()
const nextOffset = resolveTabStripTapOffset({
tabId: t.id,
tabs: visibleTabs,
layouts: tabLayoutsRef.current,
viewportWidth: tabStripViewportWidthRef.current,
contentWidth: tabStripContentWidthRef.current,
currentOffset: tabStripOffsetRef.current
})
if (nextOffset !== tabStripOffsetRef.current) {
holdTabStripReveal(nextOffset, DOUBLE_TAP_RENAME_MS)
}
switchSessionTab(t)
},
[
activeHandleRef,
cancelHeldTabStripReveal,
holdTabStripReveal,
setRenameTarget,
switchSessionTab,
tabLayoutsRef,
tabStripContentWidthRef,
tabStripOffsetRef,
tabStripRef,
tabStripViewportWidthRef,
visibleTabs
]
)
const attachTabStrip = useCallback(
(view: ScrollView | null) => {
if (view) {
@@ -65,83 +160,111 @@ export function MobileSessionTabStrip({
return (
<View ref={tabBarKeepsKeyboard} 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). */}
<ScrollView
ref={attachTabStrip}
horizontal
showsHorizontalScrollIndicator={false}
style={[styles.tabScroll, inTopBar && styles.tabScrollInTopBar]}
contentContainerStyle={[styles.tabContent, inTopBar && styles.tabContentInTopBar]}
keyboardShouldPersistTaps="handled"
scrollEventThrottle={16}
onScroll={(e) => {
tabStripOffsetRef.current = e.nativeEvent.contentOffset.x
}}
onLayout={(e) => {
tabStripViewportWidthRef.current = e.nativeEvent.layout.width
scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
}}
onContentSizeChange={(width) => {
tabStripContentWidthRef.current = width
scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
}}
>
{visibleTabs.map((t, index) => (
<Pressable
key={t.id}
style={[
styles.tab,
index === 0 && !inTopBar && styles.tabFirst,
t.id === activeSessionTabId && styles.tabActive
]}
onLayout={(e) => {
const { x, width } = e.nativeEvent.layout
tabLayoutsRef.current.set(t.id, { x, width })
if (t.id === activeSessionTabIdRef.current) {
scrollActiveTabIntoView(t.id, false)
}
}}
onPress={() => switchSessionTab(t)}
accessibilityRole="tab"
accessibilityLabel={getMobileSessionTabTitle(t)}
accessibilityState={{ selected: t.id === activeSessionTabId }}
onLongPress={() => {
triggerMediumImpact()
openSessionTabActionSheetAfterKeyboardDismiss(t)
}}
delayLongPress={400}
>
<View style={styles.tabLabelRow}>
<View style={styles.tabIcon}>
{t.type === 'browser' && (
<Globe size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'markdown' && (
<FileText size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'agent-session' && <MobileAgentIcon agentId={t.agent} size={13} />}
{t.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(t)
return agentId ? (
<MobileAgentIcon agentId={agentId} size={13} />
) : (
<SquareTerminal size={13} color={colors.textSecondary} strokeWidth={2.1} />
)
})()}
</View>
<Text
style={[styles.tabText, t.id === activeSessionTabId && styles.tabTextActive]}
numberOfLines={1}
<View style={styles.tabScrollFrame}>
<ScrollView
ref={attachTabStrip}
horizontal
showsHorizontalScrollIndicator={false}
style={[styles.tabScroll, inTopBar && styles.tabScrollInTopBar]}
contentContainerStyle={[styles.tabContent, inTopBar && styles.tabContentInTopBar]}
keyboardShouldPersistTaps="handled"
scrollEventThrottle={16}
onScrollBeginDrag={cancelHeldTabStripReveal}
onScroll={(e) => {
tabStripOffsetRef.current = e.nativeEvent.contentOffset.x
refreshHiddenTabs()
}}
onLayout={(e) => {
tabStripViewportWidthRef.current = e.nativeEvent.layout.width
scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
refreshHiddenTabs()
}}
onContentSizeChange={(width) => {
tabStripContentWidthRef.current = width
scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
refreshHiddenTabs()
}}
>
{visibleTabs.map((t, index) => {
const tabState = tabStates.get(t.id)
const title = getMobileSessionTabTitle(t)
return (
<Pressable
key={t.id}
style={[
styles.tab,
index === 0 && !inTopBar && styles.tabFirst,
t.id === activeSessionTabId && styles.tabActive
]}
onLayout={(e) => {
const { x, width } = e.nativeEvent.layout
tabLayoutsRef.current.set(t.id, { x, width })
if (t.id === activeSessionTabIdRef.current) {
scrollActiveTabIntoView(t.id, false)
}
refreshHiddenTabs()
}}
onPress={() => pressTab(t)}
accessibilityRole="tab"
accessibilityLabel={
tabState && tabState !== 'idle' ? `${title}, ${agentStateLabel(tabState)}` : title
}
accessibilityState={{ selected: t.id === activeSessionTabId }}
onLongPress={() => {
triggerMediumImpact()
openSessionTabActionSheetAfterKeyboardDismiss(t)
}}
delayLongPress={400}
>
{getMobileSessionTabTitle(t)}
</Text>
</View>
</Pressable>
))}
</ScrollView>
<View style={styles.tabLabelRow}>
{tabState && tabState !== 'idle' ? <AgentStateDot state={tabState} /> : null}
<View style={styles.tabIcon}>
{t.type === 'browser' && (
<Globe size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'markdown' && (
<FileText size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'agent-session' && <MobileAgentIcon agentId={t.agent} size={13} />}
{t.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(t)
return agentId ? (
<MobileAgentIcon agentId={agentId} size={13} />
) : (
<SquareTerminal
size={13}
color={colors.textSecondary}
strokeWidth={2.1}
/>
)
})()}
</View>
<Text
style={[styles.tabText, t.id === activeSessionTabId && styles.tabTextActive]}
numberOfLines={1}
>
{title}
</Text>
</View>
</Pressable>
)
})}
</ScrollView>
{leftHint ? (
<View pointerEvents="none" style={[styles.tabEdgeHint, styles.tabEdgeHintLeft]}>
<AgentStateDot state={leftHint} />
</View>
) : null}
{rightHint ? (
<View pointerEvents="none" style={[styles.tabEdgeHint, styles.tabEdgeHintRight]}>
<AgentStateDot state={rightHint} />
</View>
) : null}
</View>
{/* Why: pinned outside the scroll strip so the new-agent button stays reachable however far the tabs scroll. */}
<MobileSessionHeaderIconButton
disabled={creating || creatingBrowser || creatingMarkdown || connState !== 'connected'}
@@ -98,6 +98,33 @@ export const mobileSessionFrameStyles = StyleSheet.create({
borderLeftWidth: 1,
borderLeftColor: colors.borderSubtle
},
// Hosts the edge hints over the strip's ends; stretches so the strip keeps its old sizing.
tabScrollFrame: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'stretch'
},
// Marks a working or blocked tab scrolled out of view on that side.
tabEdgeHint: {
position: 'absolute',
top: 0,
bottom: 0,
justifyContent: 'center',
paddingHorizontal: spacing.xs,
backgroundColor: colors.bgPanel
},
tabEdgeHintLeft: {
left: 0,
borderRightWidth: 1,
borderRightColor: colors.borderSubtle
},
tabEdgeHintRight: {
right: 0,
borderLeftWidth: 1,
borderLeftColor: colors.borderSubtle
},
tabScroll: {
flex: 1,
maxHeight: 44
@@ -3,6 +3,8 @@ import type { StructuredAgentId } from '../../../src/shared/agent-session-provid
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { RuntimeMobileSessionAgentTabStatus } from '../../../src/shared/runtime-types'
import type { MirroredEvidenceReceipt } from '../../../src/shared/agent-status-observation'
import type { MobileBrowserTab } from '../browser/MobileBrowserPane'
import type { MobileTerminalTheme } from '../terminal/terminal-webview-contract'
import type { MobileDiffLine } from './mobile-diff-lines'
@@ -38,6 +40,7 @@ export type MobileSessionTab =
title: string
sessionId: string
agent: StructuredAgentId
chatStatus?: RuntimeMobileSessionAgentTabStatus & MirroredEvidenceReceipt
isActive: boolean
}
| {
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { agentDotState } from '../worktree/agent-row-display'
import type { MobileSessionTab } from './mobile-session-route-types'
import {
withSessionTabStatusReceipts,
withoutSessionTabHostOwnership
} from './mobile-session-tab-status-clock'
const MINUTE = 60 * 1000
const PHONE_NOW = 1_800_000_000_000
const terminal = (hostStamp: number): MobileSessionTab => ({
type: 'terminal',
id: 'term::leaf',
title: 'claude',
terminal: 'pty-1',
isActive: true,
agentStatus: {
state: 'working',
prompt: '',
paneKey: 'term:leaf',
stateHistory: [],
stateStartedAt: hostStamp,
updatedAt: hostStamp,
evidenceObservedAt: hostStamp
}
})
const chat = (structuredHostOwned: boolean): MobileSessionTab => ({
type: 'agent-session',
id: 'agent-session:s1',
title: 'Claude',
sessionId: 's1',
agent: 'claude',
isActive: false,
chatStatus: {
state: 'working',
updatedAt: PHONE_NOW - 2 * 60 * MINUTE,
...(structuredHostOwned ? { structuredHostOwned: true as const } : {})
}
})
function dot(tab: MobileSessionTab | undefined, now: number) {
if (tab?.type === 'terminal' && tab.agentStatus) {
return agentDotState(tab.agentStatus, now)
}
return tab?.type === 'agent-session' && tab.chatStatus ? agentDotState(tab.chatStatus, now) : null
}
describe('session tab status on the phone clock', () => {
it.each([
['an hour behind', -60 * MINUTE],
['an hour ahead', 60 * MINUTE]
])('ages a host %s from when the phone saw the evidence', (_label, skew) => {
const first = withSessionTabStatusReceipts([terminal(PHONE_NOW + skew)], [], PHONE_NOW)
expect(dot(first[0], PHONE_NOW)).toBe('working')
// A replay of the same evidence is a repaint, so the window keeps its first start.
const replay = withSessionTabStatusReceipts(
[terminal(PHONE_NOW + skew)],
first,
PHONE_NOW + 20 * MINUTE
)
expect(dot(replay[0], PHONE_NOW + 29 * MINUTE)).toBe('working')
expect(dot(replay[0], PHONE_NOW + 31 * MINUTE)).toBe('idle')
})
it('keeps a held chat working only while the phone hears from its host', () => {
const connected = withSessionTabStatusReceipts([chat(true)], [], PHONE_NOW)
expect(dot(connected[0], PHONE_NOW + 60 * MINUTE)).toBe('working')
const disconnected = withoutSessionTabHostOwnership(connected)
expect(dot(disconnected[0], PHONE_NOW + 29 * MINUTE)).toBe('working')
expect(dot(disconnected[0], PHONE_NOW + 31 * MINUTE)).toBe('idle')
const reconnected = withSessionTabStatusReceipts(
[chat(true)],
disconnected,
PHONE_NOW + 31 * MINUTE
)
expect(dot(reconnected[0], PHONE_NOW + 90 * MINUTE)).toBe('working')
})
})
@@ -0,0 +1,39 @@
import { withMirroredEvidenceReceipt } from '../../../src/shared/agent-status-freshness'
import type { MobileSessionTab } from './mobile-session-route-types'
/** Host stamps are on the host's clock, so a tab's status ages from when this phone first saw its
* evidence, by desktop's receipt rule for status mirrored from another machine. */
export function withSessionTabStatusReceipts(
tabs: MobileSessionTab[],
previous: readonly MobileSessionTab[],
now: number
): MobileSessionTab[] {
const previousById = new Map(previous.map((tab) => [tab.id, tab]))
return tabs.map((tab) => {
const prior = previousById.get(tab.id)
if (tab.type === 'terminal' && tab.agentStatus) {
const known = prior?.type === 'terminal' ? (prior.agentStatus ?? undefined) : undefined
return { ...tab, agentStatus: withMirroredEvidenceReceipt(tab.agentStatus, known, now) }
}
if (tab.type === 'agent-session' && tab.chatStatus) {
const known = prior?.type === 'agent-session' ? prior.chatStatus : undefined
return { ...tab, chatStatus: withMirroredEvidenceReceipt(tab.chatStatus, known, now) }
}
return tab
})
}
/** With contact lost, only a live host vouches for a chat it holds: drop that exemption so the
* last state ages, as desktop's revokeAgentSessionStatusLive does. A new snapshot restores it. */
export function withoutSessionTabHostOwnership(tabs: MobileSessionTab[]): MobileSessionTab[] {
if (!tabs.some((tab) => tab.type === 'agent-session' && tab.chatStatus?.structuredHostOwned)) {
return tabs
}
return tabs.map((tab) => {
if (tab.type !== 'agent-session' || !tab.chatStatus?.structuredHostOwned) {
return tab
}
const { structuredHostOwned: _owned, ...chatStatus } = tab.chatStatus
return { ...tab, chatStatus }
})
}
@@ -194,6 +194,10 @@ describe('mobile terminal records', () => {
expect(mobileSessionTabsEqual([base], [{ ...base }])).toBe(true)
expect(mobileSessionTabsEqual([base], [{ ...base, sessionId: 'session-2' }])).toBe(false)
// A status-only frame must still reach the tab strip's dot.
expect(
mobileSessionTabsEqual([base], [{ ...base, chatStatus: { state: 'working', updatedAt: 1 } }])
).toBe(false)
})
const record = (over: Partial<TerminalRecord> & { handle: string }): TerminalRecord => ({
@@ -68,6 +68,7 @@ type MobileSessionTabLike =
title?: string
sessionId?: string
agent?: string
chatStatus?: unknown
isActive?: boolean
}
@@ -161,7 +162,12 @@ function mobileSessionTabEqual(
a.canGoForward === b.canGoForward
)
case 'agent-session':
return b.type === 'agent-session' && a.sessionId === b.sessionId && a.agent === b.agent
return (
b.type === 'agent-session' &&
a.sessionId === b.sessionId &&
a.agent === b.agent &&
JSON.stringify(a.chatStatus ?? null) === JSON.stringify(b.chatStatus ?? null)
)
}
}
+54 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { resolveTabStripScrollOffset } from './tab-strip-scroll'
import {
resolveTabStripEdgeHint,
resolveTabStripHiddenTabs,
resolveTabStripScrollOffset,
resolveTabStripTapOffset
} from './tab-strip-scroll'
describe('resolveTabStripScrollOffset', () => {
it('keeps the offset when the active tab is already fully visible', () => {
@@ -64,3 +69,51 @@ describe('resolveTabStripScrollOffset', () => {
).toBe(0)
})
})
const tabs = Array.from({ length: 10 }, (_, i) => ({ id: String(i) }))
const layouts = new Map(tabs.map((tab, i) => [tab.id, { x: i * 100, width: 100 }]))
describe('resolveTabStripTapOffset', () => {
const tap = (tabId: string, currentOffset: number, viewportWidth = 300) =>
resolveTabStripTapOffset({
tabId,
tabs,
layouts,
viewportWidth,
contentWidth: 1000,
currentOffset
})
it('brings the next tab fully into view when the tapped tab sits at the right edge', () => {
expect(tap('2', 0)).toBe(112)
})
it('brings the previous tab fully into view when the tapped tab sits at the left edge', () => {
expect(tap('5', 500)).toBe(388)
})
it('leaves the strip alone when the tapped tab is comfortably visible', () => {
expect(tap('1', 70)).toBe(70)
})
it('waits for layout instead of jumping', () => {
expect(tap('missing', 150)).toBe(150)
expect(tap('3', 150, 0)).toBe(150)
})
})
describe('tab strip edge hints', () => {
it('marks each edge with the most urgent hidden tab, ignoring visible and finished ones', () => {
const hidden = resolveTabStripHiddenTabs({ layouts, viewportWidth: 300, currentOffset: 300 })
const tabStates = new Map([
['0', 'working'],
['1', 'done'],
['4', 'blocked'],
['8', 'working'],
['9', 'waiting']
] as const)
expect(resolveTabStripEdgeHint(hidden.left, tabStates)).toBe('working')
expect(resolveTabStripEdgeHint(hidden.right, tabStates)).toBe('waiting')
expect(resolveTabStripEdgeHint(['1'], tabStates)).toBeNull()
})
})
+101
View File
@@ -1,3 +1,5 @@
import type { AgentDotState } from '../worktree/agent-row-display'
export type TabStripScrollInput = {
tabX: number
tabWidth: number
@@ -38,3 +40,102 @@ export function resolveTabStripScrollOffset({
return Math.min(Math.max(0, nextOffset), maxOffset)
}
type TabLayouts = ReadonlyMap<string, { x: number; width: number }>
/** Reveal the neighbor on the tapped edge without waiting for host activation. */
export function resolveTabStripTapOffset(args: {
tabId: string
tabs: readonly { id: string }[]
layouts: TabLayouts
viewportWidth: number
contentWidth: number
currentOffset: number
}): number {
const { tabs, layouts, viewportWidth, contentWidth, currentOffset } = args
const index = tabs.findIndex((tab) => tab.id === args.tabId)
const selected = layouts.get(args.tabId)
if (index === -1 || !selected || viewportWidth <= 0) {
return currentOffset
}
const selectedOnly = resolveTabStripScrollOffset({
tabX: selected.x,
tabWidth: selected.width,
viewportWidth,
contentWidth,
currentOffset
})
// A comfortably visible tab leaves the strip where it is; only edge tabs pull a neighbor in.
if (selectedOnly === currentOffset) {
return currentOffset
}
const left = selected.x + selected.width / 2 < currentOffset + viewportWidth / 2
const neighbor = tabs[index + (left ? -1 : 1)]
const layout = neighbor ? layouts.get(neighbor.id) : undefined
let start = selected.x
let end = selected.x + selected.width
if (layout) {
const gap = left ? selected.x - (layout.x + layout.width) : layout.x - end
// Keep the tapped tab whole; show as much of its neighbor as fits inside both margins.
const peek = Math.min(layout.width, Math.max(0, viewportWidth - 24 - selected.width - gap))
if (left) {
start -= gap + peek
} else {
end += gap + peek
}
}
return resolveTabStripScrollOffset({
tabX: start,
tabWidth: end - start,
viewportWidth,
contentWidth,
currentOffset
})
}
export type TabStripHiddenTabs = { left: readonly string[]; right: readonly string[] }
/** Tabs scrolled mostly out of view on each side. */
export function resolveTabStripHiddenTabs(args: {
layouts: TabLayouts
viewportWidth: number
currentOffset: number
}): TabStripHiddenTabs {
const left: string[] = []
const right: string[] = []
if (args.viewportWidth > 0) {
for (const [tabId, layout] of args.layouts) {
const center = layout.x + layout.width / 2
if (center < args.currentOffset) {
left.push(tabId)
} else if (center > args.currentOffset + args.viewportWidth) {
right.push(tabId)
}
}
}
return { left, right }
}
// Lower ranks win when several hidden tabs share an edge; other states get no edge hint.
const EDGE_HINT_RANK: Partial<Record<AgentDotState, number>> = {
blocked: 0,
waiting: 0,
working: 1,
monitoring: 1
}
/** The most urgent working or blocked state among the given hidden tabs. */
export function resolveTabStripEdgeHint(
tabIds: readonly string[],
tabStates: ReadonlyMap<string, AgentDotState>
): AgentDotState | null {
let hint: AgentDotState | null = null
for (const tabId of tabIds) {
const state = tabStates.get(tabId)
const rank = state ? EDGE_HINT_RANK[state] : undefined
if (state && rank !== undefined && (hint === null || rank < (EDGE_HINT_RANK[hint] ?? 0))) {
hint = state
}
}
return hint
}
@@ -1,4 +1,4 @@
import { useEffect, useCallback } from 'react'
import { useEffect, useCallback, useRef } from 'react'
import { useSoftKeyboard } from '../platform/keyboard-occlusion'
import { useTerminalViewportRefit } from '../terminal/terminal-viewport-refit'
import { removeCustomKey, type CustomKey } from '../terminal/terminal-custom-keys'
@@ -101,8 +101,32 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope)
setKeyboardHeight(softKeyboard.height)
}, [setKeyboardHeight, softKeyboard.height])
const heldTabStripRevealRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const cancelHeldTabStripReveal = useCallback(() => {
if (heldTabStripRevealRef.current) {
clearTimeout(heldTabStripRevealRef.current)
heldTabStripRevealRef.current = null
}
}, [])
useEffect(() => cancelHeldTabStripReveal, [cancelHeldTabStripReveal])
// Why: a double-tap's second tap must land on the tab the first one hit, so a tap's reveal waits
// out that window and every other strip scroll, activation's included, holds until it runs.
const holdTabStripReveal = useCallback(
(offset: number, delayMs: number) => {
cancelHeldTabStripReveal()
heldTabStripRevealRef.current = setTimeout(() => {
heldTabStripRevealRef.current = null
if (offset !== tabStripOffsetRef.current) {
tabStripOffsetRef.current = offset
tabStripRef.current?.scrollTo({ x: offset, animated: true })
}
}, delayMs)
},
[cancelHeldTabStripReveal]
)
const scrollActiveTabIntoView = useCallback((tabId: string | null, animated: boolean) => {
if (!tabId) {
if (!tabId || heldTabStripRevealRef.current) {
return
}
const layout = tabLayoutsRef.current.get(tabId)
@@ -153,6 +177,8 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope)
notifyKeyboardVisibility,
notifyTerminalCellBoxChange,
scrollActiveTabIntoView,
holdTabStripReveal,
cancelHeldTabStripReveal,
handleDeleteCustomKey,
handleManageShortcuts
}
@@ -22,6 +22,7 @@ import { getActiveTabIdForHandle } from './mobile-session-route-helpers'
import { resolveActiveSessionTab } from './active-session-tab'
import { activateMobileSessionTab } from './mobile-session-tab-activation'
import { releaseTerminalCreateLock } from './terminal-create-lock'
import { withSessionTabStatusReceipts } from './mobile-session-tab-status-clock'
import type { MobileSessionTab, SessionTabsResult } from './mobile-session-route-types'
import type { MobileSessionTerminalListModel } from './use-mobile-session-terminal-list'
@@ -63,10 +64,11 @@ export function useMobileSessionTabApplication(scope: MobileSessionTerminalListM
return { accepted: false }
}
const applicationRevision = ++appliedSessionTabsRevisionRef.current
let nextTabs = applyClosedTabTombstones(
result.tabs,
closedTabTombstonesRef.current,
Date.now()
const now = Date.now()
let nextTabs = withSessionTabStatusReceipts(
applyClosedTabTombstones(result.tabs, closedTabTombstonesRef.current, now),
sessionTabsRef.current,
now
)
const presentTabIds = new Set(nextTabs.map((tab) => tab.id))
const orphanedDraftTabs: MobileSessionTab[] = []
@@ -15,6 +15,7 @@ import { PendingTerminalHandleRecoveryContextCache } from './pending-terminal-ha
import { hasConnectedTerminalAbsentFromSessionTabs } from './mobile-terminal-records'
import type { MobileSessionTab, SessionTabsResult } from './mobile-session-route-types'
import type { MobileSessionMarkdownActionsModel } from './use-mobile-session-markdown-actions'
import { withoutSessionTabHostOwnership } from './mobile-session-tab-status-clock'
export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownActionsModel) {
const {
@@ -22,6 +23,7 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc
client,
connState,
sessionTabsRef,
setSessionTabs,
activeSessionTabIdRef,
terminalsRef,
appliedSessionTabsRevisionRef,
@@ -148,6 +150,17 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc
terminalGestureInputInFlightRef.current.clear()
}, [connState])
useEffect(() => {
if (connState === 'connected') {
return
}
const revoked = withoutSessionTabHostOwnership(sessionTabsRef.current)
if (revoked !== sessionTabsRef.current) {
sessionTabsRef.current = revoked
setSessionTabs(revoked)
}
}, [connState, sessionTabsRef, setSessionTabs])
const hostQueryReplyInputSupportedRef = useRef(false)
useEffect(() => {
@@ -7,6 +7,9 @@ import {
import { AGENT_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome'
import {
AGENT_STATUS_STALE_AFTER_MS,
isAgentStatusFresh
} from '../../../src/shared/agent-status-freshness'
import {
agentDisplayLabel,
agentDotState,
agentIdentityLabel,
@@ -145,6 +148,18 @@ describe('agentDotState', () => {
agentDotState(row({ state: 'done', updatedAt: 0, ...mainAgentDone('interruption') }), stale)
).toBe('failed')
})
// The host advances updatedAt for a newer neutral title but keeps the evidence's own time.
it('ages a status by its evidence, not by a later title renewal, as desktop does', () => {
const now = 100 * 60 * 1000
const renewed = {
state: 'blocked' as const,
updatedAt: now,
evidenceObservedAt: now - 31 * 60 * 1000
}
expect(isAgentStatusFresh(renewed, now)).toBe(false)
expect(agentDotState(renewed, now)).toBe('idle')
})
})
describe('agentRowTimeAt', () => {
+14 -19
View File
@@ -1,11 +1,6 @@
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import type { AgentTurnOutcome } from '../../../src/shared/agent-turn-outcome'
// Mirrors the desktop AGENT_STATUS_STALE_AFTER_MS (src/shared/agent-status-types.ts:
// 30 min). Defined locally rather than imported because a runtime-value import
// from a root .ts breaks mobile's vitest transform (no tsconfig in the
// mobile-only checkout); root type-only imports stay fine.
export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000
import { isAgentStatusFresh } from '../../../src/shared/agent-status-freshness'
// Mirrors the desktop AgentStateDot vocabulary. The wire `state` is the agent
// status state; 'blocked'/'waiting' read as attention states, 'done' as
@@ -21,11 +16,13 @@ export type AgentDotState =
| 'failed'
| 'unconfirmed'
type AgentRowVerdictSource = Pick<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'mainAgent'>
// A session tab's AgentStatusEntry leaves `interrupted` optional; worktree rows always send it.
type AgentRowVerdictSource = Pick<RuntimeWorktreeAgentRow, 'state' | 'mainAgent'> & {
interrupted?: boolean
}
// Mirrors AGENT_TURN_OUTCOMES (src/shared/agent-turn-outcome.ts), which mobile cannot import as a
// value (see AGENT_STATUS_STALE_AFTER_MS above). The rows arrive unparsed, so a newer host's arm
// has to read as no verdict here.
// Mirrors AGENT_TURN_OUTCOMES (src/shared/agent-turn-outcome.ts). The rows arrive unparsed, so a
// newer host's arm has to read as no verdict here.
const AGENT_TURN_OUTCOMES: readonly AgentTurnOutcome[] = [
'success',
'failure',
@@ -71,10 +68,9 @@ export function agentRowVerdictMark(
}
export function agentDotState(
row: Pick<
RuntimeWorktreeAgentRow,
'state' | 'workingMode' | 'interrupted' | 'mainAgent' | 'updatedAt'
>,
row: AgentRowVerdictSource &
Pick<RuntimeWorktreeAgentRow, 'workingMode'> &
Parameters<typeof isAgentStatusFresh>[0],
now: number
): AgentDotState {
const mark = agentRowVerdictMark(row)
@@ -87,9 +83,9 @@ export function agentDotState(
// Why: an agent that exits without a final report would otherwise read as
// active forever. Decay a stale active state to idle, matching desktop's
// renderer-side staleness decay (worktree-agent-rows.ts).
return now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS ? 'idle' : row.state
return isAgentStatusFresh(row, now) ? row.state : 'idle'
case 'working':
if (now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS) {
if (!isAgentStatusFresh(row, now)) {
return 'idle'
}
return row.workingMode === 'monitoring' ? 'monitoring' : 'working'
@@ -123,14 +119,13 @@ export function agentStateLabel(state: AgentDotState): string {
}
}
// Mirrors NATIVE_CHAT_TURN_STATUS_COPY.stopping. The cross-version suite copies this file alone
// (`importWorkingTreeModuleCopy`), so a value import from another file would not resolve there.
// Mirrors NATIVE_CHAT_TURN_STATUS_COPY.stopping.
const STOPPING_LABEL = 'Stopping…'
// Primary row text: "Stopping…" while the host says a person's Stop is ending the turn, else the
// agent's last message, then the user prompt, then a human-readable state label so a row is never
// blank. Matches the desktop DashboardAgentRow displayLabel fallback chain. The Stopping test
// mirrors agentStopDisplayStatus (src/shared), inline for the same single-file copy.
// mirrors agentStopDisplayStatus (src/shared).
export function agentDisplayLabel(row: RuntimeWorktreeAgentRow, now: number): string {
if (row.state === 'working' && row.mainAgent?.stopping === true) {
return STOPPING_LABEL
@@ -18,6 +18,7 @@ import {
selectRuntimeHookAgentRowForPane
} from './runtime-mobile-agent-status-projection'
import { finalizeRuntimeMobileSessionTabsResult } from './runtime-mobile-session-result-finalization'
import { withStructuredChatStatus } from './runtime-mobile-structured-tab-status'
import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract'
import {
getLatestAgentCandidateTitle,
@@ -110,7 +111,7 @@ export function projectRuntimeMobileSessionTabs(
continue
}
if (tab.type === 'agent-session') {
tabs.push(tab)
tabs.push(withStructuredChatStatus(tab, snapshot.worktree, getHookRowsForPane))
continue
}
const syncedTab = host.tabs.get(tab.parentTabId)
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest'
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
import { AgentHookServer } from '../agent-hooks/server'
import { projectRuntimeMobileSessionTabs } from './runtime-mobile-session-projection'
import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 }))
}))
const WORKTREE_ID = 'repo-1::/workspace/app'
const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
function projectChatTab(store: AgentHookServer, worktree: string) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a chat tab reads only the live browser tabs and the pane's status rows; the rest finalizes the result.
const host = {
getLiveBrowserTabs: () => new Map(),
getProviderSessionRows: (paneKey: string) => store.getStatusSnapshotForPane(paneKey),
sanitizeGroups: () => [],
pruneGroupLayout: () => null,
collectTabIds: () => new Set()
} as unknown as RuntimeMobileSessionProjectionHost
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: one chat tab is the only snapshot field this projection path reads.
const snapshot = {
worktree,
publicationEpoch: 'epoch',
tabs: [
{
type: 'agent-session',
id: `agent-session:${SESSION}`,
title: 'Claude',
sessionId: SESSION,
agent: 'claude',
isActive: true
}
]
} as RuntimeMobileSessionTabsSnapshot
return projectRuntimeMobileSessionTabs(snapshot, host).tabs[0]
}
describe('a chat tab on the phone', () => {
it("carries the host's structured row for its session, and only in that workspace", () => {
const store = new AgentHookServer()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fields a structured status summary needs for ingest.
const summary = {
sessionId: SESSION,
workspaceId: WORKTREE_ID,
agent: 'claude',
status: 'working',
latestPrompt: 'ship the thing',
updatedAt: 1_757_030_400_000,
hostExecutionOwned: true
} as AgentSessionStatusSummary
store.ingestStructuredStatus(
summary,
makeStructuredAgentStatusSubject(
{
executionHostId: 'local',
wslDistro: null,
workspaceId: WORKTREE_ID,
workspaceKind: 'git-worktree'
},
SESSION
)
)
expect(projectChatTab(store, WORKTREE_ID)).toMatchObject({
type: 'agent-session',
chatStatus: { state: 'working', structuredHostOwned: true }
})
expect(projectChatTab(store, 'repo-1::/workspace/other')).not.toHaveProperty('chatStatus')
})
})
@@ -0,0 +1,45 @@
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
import { agentVerdictFields } from '../../shared/agent-main-agent-verdict'
import type {
RuntimeMobileSessionAgentTab,
RuntimeMobileSessionAgentTabStatus
} from '../../shared/runtime-types'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionTabId
} from '../../shared/structured-agent-session-projection'
/** A chat tab with the state it shows: the host's own structured row for its session, as desktop draws it. */
export function withStructuredChatStatus(
tab: RuntimeMobileSessionAgentTab,
worktreeId: string,
getRowsForPane: (paneKey: string) => readonly AgentStatusIpcPayload[]
): RuntimeMobileSessionAgentTab {
const paneKey = structuredAgentSessionPaneKey(
structuredAgentSessionTabId(tab.sessionId),
tab.sessionId
)
let row: AgentStatusIpcPayload | undefined
for (const candidate of getRowsForPane(paneKey)) {
if (
candidate.worktreeId === worktreeId &&
candidate.restoredUnconfirmed !== true &&
candidate.providerSessionOnly !== true &&
(!row || candidate.receivedAt > row.receivedAt)
) {
row = candidate
}
}
if (!row) {
return tab
}
const chatStatus: RuntimeMobileSessionAgentTabStatus = {
state: row.state,
...(row.workingMode ? { workingMode: row.workingMode } : {}),
...agentVerdictFields(row),
updatedAt: row.receivedAt,
...(row.evidenceObservedAt !== undefined ? { evidenceObservedAt: row.evidenceObservedAt } : {}),
...(row.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {})
}
return { ...tab, chatStatus }
}
@@ -1,7 +1,5 @@
import {
agentStatusAuthorityObservedAt,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { withMirroredEvidenceReceipt } from '../../../../shared/agent-status-freshness'
import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time'
import { agentMainAgentVerdict } from '../../../../shared/agent-main-agent-verdict'
import { normalizeCompatibleAgentStatusEntryForOwner } from '../../../../shared/agent-title-owner'
@@ -33,33 +31,6 @@ import {
writableWebSessionTabsRecord
} from './state-equality-core'
/**
* Stamp this replica's own receipt clock on a row mirrored from another host.
*
* The host's `updatedAt` / `evidenceObservedAt` are its wall clock, so decaying a mirrored row
* against `rendererNow - hostClock` is off by the two machines' skew: a host running fast keeps
* every remote row permanently fresh, a host running slow decays them on arrival. Both sides of
* the subtraction have to come from one machine, and the receipt is the only clock the replica
* owns. See THE DECAY RULE in shared/agent-status-observation.ts.
*
* A snapshot that repeats an observation already seen is a repaint, not new evidence, so the
* first receipt is carried forward — otherwise the window would restart on every publish and
* a quiet pane would never decay. Only a row this renderer stamped itself is comparable, so
* the carry-forward requires a previous stamp rather than trusting a cross-machine timestamp.
*/
function withMirroredEvidenceReceipt(
entry: AgentStatusEntry,
existing: AgentStatusEntry | undefined,
now: number
): AgentStatusEntry {
const receivedAt =
existing?.mirroredEvidenceReceivedAt !== undefined &&
agentStatusAuthorityObservedAt(existing) === agentStatusAuthorityObservedAt(entry)
? existing.mirroredEvidenceReceivedAt
: now
return { ...entry, mirroredEvidenceReceivedAt: receivedAt }
}
export function buildMirroredAgentStatusPatch(
state: WebSessionTabsSyncState,
currentTerminalTabs: readonly TerminalTab[],
+32
View File
@@ -32,6 +32,38 @@ export function agentStatusEvidenceObservedAt(
return entry.mirroredEvidenceReceivedAt ?? agentStatusAuthorityObservedAt(entry)
}
type MirroredEvidenceClocks = Pick<
AgentStatusEntry,
'updatedAt' | 'evidenceObservedAt' | 'mirroredEvidenceReceivedAt'
>
/**
* Stamp this replica's own receipt clock on a row mirrored from another host.
*
* The host's `updatedAt` / `evidenceObservedAt` are its wall clock, so decaying a mirrored row
* against `replicaNow - hostClock` is off by the two machines' skew: a host running fast keeps
* every remote row permanently fresh, a host running slow decays them on arrival. Both sides of
* the subtraction have to come from one machine, and the receipt is the only clock the replica
* owns. See THE DECAY RULE in shared/agent-status-observation.ts.
*
* A snapshot that repeats an observation already seen is a repaint, not new evidence, so the
* first receipt is carried forward — otherwise the window would restart on every publish and
* a quiet pane would never decay. Only a row this replica stamped itself is comparable, so
* the carry-forward requires a previous stamp rather than trusting a cross-machine timestamp.
*/
export function withMirroredEvidenceReceipt<T extends MirroredEvidenceClocks>(
entry: T,
existing: MirroredEvidenceClocks | undefined,
now: number
): T {
const receivedAt =
existing?.mirroredEvidenceReceivedAt !== undefined &&
agentStatusAuthorityObservedAt(existing) === agentStatusAuthorityObservedAt(entry)
? existing.mirroredEvidenceReceivedAt
: now
return { ...entry, mirroredEvidenceReceivedAt: receivedAt }
}
export function isFreshNonDoneAgentStatus(
entry:
| Pick<
@@ -110,11 +110,24 @@ export type RuntimeMobileSessionAgentTab = {
/** An agent the host registered. Beyond Claude and Codex, published only to clients advertising
* the registered-agents capability. */
agent: StructuredAgentId
/** The host's structured status row for this chat. Optional: old hosts never send it. */
chatStatus?: RuntimeMobileSessionAgentTabStatus
color?: string | null
isPinned?: boolean
isActive: boolean
}
export type RuntimeMobileSessionAgentTabStatus = Pick<
AgentStatusEntry,
| 'state'
| 'workingMode'
| 'interrupted'
| 'mainAgent'
| 'updatedAt'
| 'evidenceObservedAt'
| 'structuredHostOwned'
>
export type RuntimeMobileSessionSnapshotTab =
| RuntimeMobileSessionTerminalTab
| RuntimeMobileSessionMarkdownTab
+1
View File
@@ -116,6 +116,7 @@ export type {
RuntimeDesktopWindowStatus,
RuntimeGraphStatus,
RuntimeMobileSessionAgentTab,
RuntimeMobileSessionAgentTabStatus,
RuntimeMobileSessionBrowserTab,
RuntimeMobileSessionClientTab,
RuntimeMobileSessionCreateTerminalResult,
@@ -14,7 +14,7 @@ const CHECKOUT_MAX_OUTPUT_BYTES = 1024 * 1024
// Why: the wire endpoints only need the runtime RPC host, the renderer client, and
// the shared codec, plus a release's relay that an app update leaves running. Skipping cli keeps a cold CI
// extraction a few seconds.
// The phone's `worktree ps` row reader is one self-contained file, so it rides along alone.
// The phone's `worktree ps` row reader rides along alone; it imports only from src/shared.
// Workspace packages the release declares are added per release (releaseWorkspaceArchivePaths).
const ARCHIVE_PATHS = [
'src/main',
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process'
import { copyFile, mkdir, rename } from 'node:fs/promises'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { selectLatestStableReleaseTag } from '../../../config/scripts/stable-release-tags.mjs'
import {
@@ -129,20 +129,29 @@ export function importReleaseCheckoutModule(
}
/**
* Import a copy of a self-contained working-tree module placed under the cache root.
* Import a copy of a working-tree module placed under the cache root.
*
* Why: vite transforms a file against its nearest tsconfig, and `mobile/tsconfig.json` extends
* Expo's, which the root-only CI lane does not install. The copy gets the root tsconfig, as the
* release checkout's copy does. A relative runtime import would not resolve from the copy.
* release checkout's copy does. Its relative imports are pinned to the working-tree files they name.
*/
export async function importWorkingTreeModuleCopy(
rootRelativePath: string,
cacheRoot: string = DEFAULT_CACHE_ROOT
): Promise<Record<string, unknown>> {
const copy = checkoutModulePath(join(cacheRoot, 'working-tree'), rootRelativePath)
const original = checkoutModulePath(REPO_ROOT, rootRelativePath)
const staged = `${copy}.${process.pid}.tmp`
await mkdir(dirname(copy), { recursive: true })
await copyFile(checkoutModulePath(REPO_ROOT, rootRelativePath), staged)
const source = await readFile(original, 'utf8')
await writeFile(
staged,
source.replace(
/(\bfrom\s*)(['"])(\.\.?\/[^'"]+)\2/g,
(_match, from: string, quote: string, specifier: string) =>
`${from}${quote}${resolve(dirname(original), specifier).split('\\').join('/')}${quote}`
)
)
// Rename so a concurrent run never imports a half-written copy.
await rename(staged, copy)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a module namespace is a plain record of its exports; callers narrow each export they read.