diff --git a/mobile/src/session/MobileSessionTabStrip.test.tsx b/mobile/src/session/MobileSessionTabStrip.test.tsx new file mode 100644 index 00000000000..67b9c09217f --- /dev/null +++ b/mobile/src/session/MobileSessionTabStrip.test.tsx @@ -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[0]['controller'] +type KeyboardScope = Parameters[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({ ...strip, terminals: [], setKeyboardHeight: () => {} }) + ) + const controller = mountFixture({ + ...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 | 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 }]]) + }) +}) diff --git a/mobile/src/session/MobileSessionTabStrip.tsx b/mobile/src/session/MobileSessionTabStrip.tsx index 2c4f1a0ab9e..33935c9e969 100644 --- a/mobile/src/session/MobileSessionTabStrip.tsx +++ b/mobile/src/session/MobileSessionTabStrip.tsx @@ -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() + 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({ 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 ( {/* Why: tab taps must register on first press with the keyboard open instead of being eaten by dismissal (#5106). */} - { - 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) => ( - { - 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} - > - - - {t.type === 'browser' && ( - - )} - {t.type === 'markdown' && ( - - )} - {t.type === 'file' && ( - - )} - {t.type === 'agent-session' && } - {t.type === 'terminal' && - (() => { - const agentId = resolveMobileTerminalTabAgentId(t) - return agentId ? ( - - ) : ( - - ) - })()} - - + { + 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 ( + { + 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)} - - - - ))} - + + {tabState && tabState !== 'idle' ? : null} + + {t.type === 'browser' && ( + + )} + {t.type === 'markdown' && ( + + )} + {t.type === 'file' && ( + + )} + {t.type === 'agent-session' && } + {t.type === 'terminal' && + (() => { + const agentId = resolveMobileTerminalTabAgentId(t) + return agentId ? ( + + ) : ( + + ) + })()} + + + {title} + + + + ) + })} + + {leftHint ? ( + + + + ) : null} + {rightHint ? ( + + + + ) : null} + {/* Why: pinned outside the scroll strip so the new-agent button stays reachable however far the tabs scroll. */} ({ + 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') + }) +}) diff --git a/mobile/src/session/mobile-session-tab-status-clock.ts b/mobile/src/session/mobile-session-tab-status-clock.ts new file mode 100644 index 00000000000..6fdce16e125 --- /dev/null +++ b/mobile/src/session/mobile-session-tab-status-clock.ts @@ -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 } + }) +} diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index efd368411f4..3ec9c2463de 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -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 & { handle: string }): TerminalRecord => ({ diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts index f03a31acf41..028cae53b1f 100644 --- a/mobile/src/session/mobile-terminal-records.ts +++ b/mobile/src/session/mobile-terminal-records.ts @@ -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) + ) } } diff --git a/mobile/src/session/tab-strip-scroll.test.ts b/mobile/src/session/tab-strip-scroll.test.ts index fab517a3155..e7afae05d47 100644 --- a/mobile/src/session/tab-strip-scroll.test.ts +++ b/mobile/src/session/tab-strip-scroll.test.ts @@ -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() + }) +}) diff --git a/mobile/src/session/tab-strip-scroll.ts b/mobile/src/session/tab-strip-scroll.ts index 275b9ed5f57..05ce33b5859 100644 --- a/mobile/src/session/tab-strip-scroll.ts +++ b/mobile/src/session/tab-strip-scroll.ts @@ -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 + +/** 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> = { + 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 +): 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 +} diff --git a/mobile/src/session/use-mobile-session-keyboard-state.ts b/mobile/src/session/use-mobile-session-keyboard-state.ts index 72a04cf790b..b314860b8c6 100644 --- a/mobile/src/session/use-mobile-session-keyboard-state.ts +++ b/mobile/src/session/use-mobile-session-keyboard-state.ts @@ -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 | 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 } diff --git a/mobile/src/session/use-mobile-session-tab-application.ts b/mobile/src/session/use-mobile-session-tab-application.ts index 2d6ad33de08..e43e3201001 100644 --- a/mobile/src/session/use-mobile-session-tab-application.ts +++ b/mobile/src/session/use-mobile-session-tab-application.ts @@ -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[] = [] diff --git a/mobile/src/session/use-mobile-session-tab-reconciliation.ts b/mobile/src/session/use-mobile-session-tab-reconciliation.ts index 5007ac579dd..9fedca7d028 100644 --- a/mobile/src/session/use-mobile-session-tab-reconciliation.ts +++ b/mobile/src/session/use-mobile-session-tab-reconciliation.ts @@ -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(() => { diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index 89fef0a476a..c6e3d5d8c03 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -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', () => { diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index ef17ab39895..7b8ef6e92e6 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -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 +// A session tab's AgentStatusEntry leaves `interrupted` optional; worktree rows always send it. +type AgentRowVerdictSource = Pick & { + 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 & + Parameters[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 diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index 4ab04465aaf..dfbbeaf32c3 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -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) diff --git a/src/main/runtime/runtime-mobile-structured-tab-status.test.ts b/src/main/runtime/runtime-mobile-structured-tab-status.test.ts new file mode 100644 index 00000000000..b8e9d6b2b23 --- /dev/null +++ b/src/main/runtime/runtime-mobile-structured-tab-status.test.ts @@ -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') + }) +}) diff --git a/src/main/runtime/runtime-mobile-structured-tab-status.ts b/src/main/runtime/runtime-mobile-structured-tab-status.ts new file mode 100644 index 00000000000..7b0936a01c9 --- /dev/null +++ b/src/main/runtime/runtime-mobile-structured-tab-status.ts @@ -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 } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts index 4296ff5fcca..b0f79a397cd 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -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[], diff --git a/src/shared/agent-status-freshness.ts b/src/shared/agent-status-freshness.ts index c6d91a303df..3d8fcef3637 100644 --- a/src/shared/agent-status-freshness.ts +++ b/src/shared/agent-status-freshness.ts @@ -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( + 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< diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts index afc7f72de5d..49e90282132 100644 --- a/src/shared/runtime-mobile-session-tab-contracts.ts +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -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 diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 0cfcc0aa268..d5819f54ba2 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -116,6 +116,7 @@ export type { RuntimeDesktopWindowStatus, RuntimeGraphStatus, RuntimeMobileSessionAgentTab, + RuntimeMobileSessionAgentTabStatus, RuntimeMobileSessionBrowserTab, RuntimeMobileSessionClientTab, RuntimeMobileSessionCreateTerminalResult, diff --git a/tests/e2e/cross-version-wire/release-checkout-tree.ts b/tests/e2e/cross-version-wire/release-checkout-tree.ts index fdfee479d93..c7fb4135ce4 100644 --- a/tests/e2e/cross-version-wire/release-checkout-tree.ts +++ b/tests/e2e/cross-version-wire/release-checkout-tree.ts @@ -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', diff --git a/tests/e2e/cross-version-wire/release-checkout.ts b/tests/e2e/cross-version-wire/release-checkout.ts index e3ea64469b0..78b167bcf0d 100644 --- a/tests/e2e/cross-version-wire/release-checkout.ts +++ b/tests/e2e/cross-version-wire/release-checkout.ts @@ -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> { 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.