mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(mobile): tab strip shows terminal and chat agent state, reveals the next tab, renames on double-tap (#31372)
This commit is contained in:
@@ -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 }]])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user