feat(mobile): show only the open tab, with a tab count and a sheet of every tab (#40592)

* feat(mobile): show the open tab as one pill with a tabs sheet

The phone copied desktop's tab strip, which fits about three tabs cut to
~95 pt and hides a tab that needs you behind an edge dot. The bar now shows
only the tab you're in, at full width, with the tab count. Tapping it opens
a sheet of every tab grouped by what needs you, with each tab's state and
last activity; swiping it steps to the next tab. The + sheet shows agents as
a grid above Terminal, Browser and Markdown note, and the header names the
branch instead of the tab count.

* feat(mobile): pair the tab count with + and open tab actions from the pill

The count and its chevron inside the pill read like a menu and hid the
"another tab needs you" dot. The count now shares a capsule with +, the
way phone browsers put the tab-count button beside new tab, and tapping
the pill opens the tab's own actions.

* feat(mobile): give the tab count its own button beside +

The shared capsule made each half a small target. The count is now a
round nav button like +, lined up under the header's buttons.

* feat(mobile): draw the tab bar as the strip's square cells

The rounded pill and round buttons read as a different control from the
strip people know. The open tab is now the strip's raised, underlined cell
from the screen edge, with the count and + as square cells in the same
row, which also gives them 46x44 pt targets.

* fix(mobile): keep a hairline after the tab bar's last cell in the top bar
This commit is contained in:
Kelvin Amoaba
2026-11-10 15:32:40 +00:00
committed by GitHub
parent b07b3d318a
commit 12e809c755
31 changed files with 1091 additions and 810 deletions
+9 -2
View File
@@ -21,7 +21,14 @@ function oneLine(text: string | null | undefined): string {
return text?.replace(/\s+/g, ' ').trim() ?? ''
}
function lastMessage(agent: RuntimeWorktreeAgentRow): string {
// A worktree agent row or a session tab's AgentStatusEntry, which leaves these optional.
type AgentPreviewSource = Pick<RuntimeWorktreeAgentRow, 'prompt'> & {
lastAssistantMessage?: string | null
toolName?: string | null
toolInput?: string | null
}
function lastMessage(agent: AgentPreviewSource): string {
const message = agent.lastAssistantMessage
return message ? oneLine(withoutNativeChatVisualDirectiveLines(message)) : ''
}
@@ -31,7 +38,7 @@ function lastMessage(agent: RuntimeWorktreeAgentRow): string {
* (`group` null) shows no tool, since the tool call is long over.
*/
export function agentListPreview(
agent: RuntimeWorktreeAgentRow,
agent: AgentPreviewSource,
group: AgentStatusGroup | null,
title: string
): string {
+7 -2
View File
@@ -84,6 +84,8 @@ export function SwipeRowActions({
})
// Web has no worklet plugin; mapper hooks need this explicit dependency list.
const rowStyle = useAnimatedStyle(() => ({ transform: [{ translateX: offset.value }] }), [offset])
// Hidden at rest, so the action can't show through a rounded group's antialiased corners.
const actionsStyle = useAnimatedStyle(() => ({ opacity: offset.value === 0 ? 0 : 1 }), [offset])
// A tap on an open row closes it rather than pressing the row.
const guardPress = (onPress: () => void) => () => {
// The click a drag release produces belongs to the drag, not to the row.
@@ -124,8 +126,10 @@ export function SwipeRowActions({
}
return (
<View>
{leading ? renderAction(leading, 'leading') : null}
{trailing ? renderAction(trailing, 'trailing') : null}
<Animated.View pointerEvents="box-none" style={[styles.actions, actionsStyle]}>
{leading ? renderAction(leading, 'leading') : null}
{trailing ? renderAction(trailing, 'trailing') : null}
</Animated.View>
{/* pan-y keeps the page scrolling vertically over the row on the web. */}
<GestureDetector gesture={pan} touchAction="pan-y">
<Animated.View style={rowStyle}>{children(guardPress)}</Animated.View>
@@ -144,6 +148,7 @@ const styles = themedStyleSheet(() => ({
justifyContent: 'center',
gap: 2
},
actions: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 },
leading: { left: 0 },
trailing: { right: 0 },
actionPressed: { opacity: feedback.pressedOpacity },
+1 -2
View File
@@ -2454,8 +2454,7 @@
"tabStrip": {
"checkingCapabilities": "Desktop-Funktionen werden geprüft – gleich erneut versuchen",
"newTab": "Neuen Tab erstellen",
"quickCommandsUpdateRequired": "Desktop-Update für Schnellbefehle erforderlich",
"tabStatus": "{{title}}, {{status}}"
"quickCommandsUpdateRequired": "Desktop-Update für Schnellbefehle erforderlich"
},
"terminalActionSheetActions": {
"clearTerminal": "Terminal leeren",
+25 -4
View File
@@ -2182,10 +2182,10 @@
"renameTerminal": "Rename Terminal",
"renameTerminalFailed": "Couldn't rename the terminal. {{reason}}",
"terminalName": "Terminal name",
"allowAutoClose": "Allow Auto-Close"
"allowAutoClose": "Allow Auto-Close",
"agents": "Agents"
},
"tabStrip": {
"tabStatus": "{{title}}, {{status}}",
"newTab": "New tab",
"quickCommandsUpdateRequired": "Desktop update required for quick commands",
"checkingCapabilities": "Checking desktop capabilities — try again in a moment"
@@ -2887,8 +2887,6 @@
},
"presentation": {
"loadingTabs": "Loading tabs",
"oneTab": "1 tab",
"tabCount": "{{tabCount}} tabs",
"tapToRetry": "{{status}} — tap to retry"
},
"documentReaders": {
@@ -2926,6 +2924,29 @@
"skip": "Skip",
"stop": "Stop",
"yes": "Yes"
},
"tabsSheet": {
"needsYou": "Needs you",
"working": "Working",
"everythingElse": "Everything else",
"open": "Open",
"closeTab": "Close {{title}}"
},
"tabRows": {
"browser": "Browser",
"markdown": "Markdown note",
"file": "File",
"chat": "Chat",
"terminal": "Terminal"
},
"tabBar": {
"oneTab": "1 tab",
"tabCount": "{{count}} tabs",
"anotherNeedsYou": "another tab needs you",
"actionsHint": "Shows actions for this tab",
"next": "Next tab",
"previous": "Previous tab",
"hint": "Shows all tabs"
}
},
"chat": {
+1 -2
View File
@@ -2524,8 +2524,7 @@
"tabStrip": {
"checkingCapabilities": "Comprobando las funciones del escritorio. Inténtalo de nuevo en un momento.",
"newTab": "Nueva pestaña",
"quickCommandsUpdateRequired": "Actualiza el escritorio para usar los comandos rápidos.",
"tabStatus": "{{title}}, {{status}}"
"quickCommandsUpdateRequired": "Actualiza el escritorio para usar los comandos rápidos."
},
"terminalActionSheetActions": {
"clearTerminal": "Limpiar terminal",
+1 -2
View File
@@ -2524,8 +2524,7 @@
"tabStrip": {
"checkingCapabilities": "Vérification des fonctionnalités de l'ordinateur — réessayez dans un instant",
"newTab": "Nouvel onglet",
"quickCommandsUpdateRequired": "Mettez à jour Orca sur l'ordinateur pour utiliser les commandes rapides",
"tabStatus": "{{title}}, {{status}}"
"quickCommandsUpdateRequired": "Mettez à jour Orca sur l'ordinateur pour utiliser les commandes rapides"
},
"terminalActionSheetActions": {
"clearTerminal": "Effacer le terminal",
+1 -2
View File
@@ -2450,8 +2450,7 @@
"tabStrip": {
"checkingCapabilities": "デスクトップの対応機能を確認中です。少し待ってから再試行してください。",
"newTab": "新規タブ",
"quickCommandsUpdateRequired": "クイックコマンドの利用にはデスクトップの更新が必要です。",
"tabStatus": "{{title}}、{{status}}"
"quickCommandsUpdateRequired": "クイックコマンドの利用にはデスクトップの更新が必要です。"
},
"terminalActionSheetActions": {
"clearTerminal": "ターミナルをクリア",
+1 -2
View File
@@ -2450,8 +2450,7 @@
"tabStrip": {
"checkingCapabilities": "데스크톱에서 지원하는 기능을 확인 중입니다. 잠시 후 다시 시도하세요.",
"newTab": "새 탭",
"quickCommandsUpdateRequired": "빠른 명령을 사용하려면 데스크톱 업데이트가 필요합니다.",
"tabStatus": "{{title}}, {{status}}"
"quickCommandsUpdateRequired": "빠른 명령을 사용하려면 데스크톱 업데이트가 필요합니다."
},
"terminalActionSheetActions": {
"clearTerminal": "터미널 지우기",
+1 -2
View File
@@ -2524,8 +2524,7 @@
"tabStrip": {
"checkingCapabilities": "Verificando os recursos do desktop — tente novamente em instantes",
"newTab": "Nova aba",
"quickCommandsUpdateRequired": "Atualize o desktop para usar comandos rápidos",
"tabStatus": "{{title}}, {{status}}"
"quickCommandsUpdateRequired": "Atualize o desktop para usar comandos rápidos"
},
"terminalActionSheetActions": {
"clearTerminal": "Limpar Terminal",
+1 -2
View File
@@ -2594,8 +2594,7 @@
"tabStrip": {
"checkingCapabilities": "Проверяем возможности Orca на компьютере — повторите попытку чуть позже",
"newTab": "Новая вкладка",
"quickCommandsUpdateRequired": "Для быстрых команд обновите Orca на компьютере",
"tabStatus": "{{title}}, {{status}}"
"quickCommandsUpdateRequired": "Для быстрых команд обновите Orca на компьютере"
},
"terminalActionSheetActions": {
"clearTerminal": "Очистить терминал",
+1 -2
View File
@@ -2454,8 +2454,7 @@
"tabStrip": {
"checkingCapabilities": "Masaüstü özellikleri kontrol ediliyor — birazdan tekrar deneyin",
"newTab": "Yeni sekme",
"quickCommandsUpdateRequired": "Hızlı komutlar için masaüstü uygulaması güncellenmeli",
"tabStatus": "{{title}}, {{status}}"
"quickCommandsUpdateRequired": "Hızlı komutlar için masaüstü uygulaması güncellenmeli"
},
"terminalActionSheetActions": {
"clearTerminal": "Terminali Temizle",
+1 -2
View File
@@ -2450,8 +2450,7 @@
"tabStrip": {
"checkingCapabilities": "正在檢查桌面版支援的功能,請稍後再試",
"newTab": "新分頁",
"quickCommandsUpdateRequired": "需更新桌面版才能使用快速命令",
"tabStatus": "{{title}},{{status}}"
"quickCommandsUpdateRequired": "需更新桌面版才能使用快速命令"
},
"terminalActionSheetActions": {
"clearTerminal": "清除終端機",
+1 -2
View File
@@ -2450,8 +2450,7 @@
"tabStrip": {
"checkingCapabilities": "正在检查桌面支持的功能,请稍后重试",
"newTab": "新标签页",
"quickCommandsUpdateRequired": "使用快捷命令需更新桌面应用",
"tabStatus": "{{title}},{{status}}"
"quickCommandsUpdateRequired": "使用快捷命令需更新桌面应用"
},
"terminalActionSheetActions": {
"clearTerminal": "清空终端",
+3 -3
View File
@@ -6,7 +6,7 @@ import { NavBar, NavBarRefreshLine } from '../components/nav-bar/NavBar'
import { useRefreshHint } from '../query/refresh-hint'
import { NavBarButton } from '../components/nav-bar/NavBarButton'
import { MobileSessionHeaderMoreMenu } from './MobileSessionHeaderMoreMenu'
import { MobileSessionTabStrip } from './tabs/MobileSessionTabStrip'
import { MobileSessionTabBar } from './tabs/MobileSessionTabBar'
import { MobileSessionTitleBlock } from './MobileSessionTitleBlock'
import type { SessionTitleCard } from './tabs/use-session-title-card'
import { styles } from './mobile-session-styles'
@@ -58,11 +58,11 @@ export function MobileSessionHeader({
// Why: a sideways phone is ~390pt tall, so the tabs share the top bar instead of taking a second row.
const hasTabs = visibleTabs.length > 0
const tabsInTopBar = (layout.isLandscapePhone || sideRail) && hasTabs
// Bar buttons otherwise leave less than two readable tabs on a narrow header.
// Bar buttons otherwise leave the tab bar too narrow to read on a narrow header.
const actionsCollapsed = tabsInTopBar && (headerWidth ?? layout.width) < 600
const sourceControlInBar = !isFloatingWorkspaceRoute && !actionsCollapsed
const tabStrip = hasTabs ? (
<MobileSessionTabStrip
<MobileSessionTabBar
controller={controller}
inTopBar={tabsInTopBar}
actionsCollapsed={actionsCollapsed}
+29 -55
View File
@@ -2,7 +2,7 @@ import { Platform } from 'react-native'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree/id'
import { useClipboardWriter } from '../platform/clipboard'
import { Copy, FileText, Globe, RefreshCw, SquareTerminal } from 'lucide-react-native'
import { Copy, FileText, RefreshCw } from 'lucide-react-native'
import { MobileSessionTerminalLayoutSheet } from '../terminal/session/MobileSessionTerminalLayoutSheet'
import { QuickCommandsSheet } from '../terminal/quick-commands/QuickCommandsSheet'
import { triggerSuccess, triggerError } from '../platform/haptics'
@@ -19,6 +19,7 @@ import { isTerminalPhoneDisplayMode } from '../terminal/session/terminal-phone-d
import type { MobileSessionController } from './use-mobile-session-controller'
import { useMobileAllowAutoCloseActions } from './tabs/mobile-worker-auto-close'
import { MobileAgentSessionTabActionSheet } from './tabs/MobileAgentSessionTabActionSheet'
import { MobileNewTabSheet } from './tabs/MobileNewTabSheet'
export function MobileSessionSheets({ controller }: { controller: MobileSessionController }) {
const t = useMobileTranslation()
@@ -90,7 +91,8 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
bulkCloseActions,
bulkCloseConfirm,
closeWithBulkActions,
createTabAgentActions,
createTabAgentStatusRow,
createTabAgentOptions,
sendDiffNotesAgentActions
} = controller
const clipboard = useClipboardWriter()
@@ -115,61 +117,33 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
agentPromptMaxLength={controller.quickCommandAgentPromptMaxLength}
onLaunch={launchQuickCommand}
/>
<ActionSheetModal
<MobileNewTabSheet
visible={showCreateTabDrawer}
title={t('session.sheets.newTab', 'New Tab')}
actions={[
...createTabAgentActions,
{
id: 'terminal',
label: t('session.sheets.terminal', 'Terminal'),
icon: SquareTerminal,
onPress: () => {
setShowCreateTabDrawer(false)
void handleCreateTerminal()
}
},
// Why: browser/markdown creation resolve a real worktree on the host
// (browser.tabCreate, files.createFile); the floating sentinel is
// terminal-only over RPC, so those options hide there.
...(isFloatingWorkspaceRoute
? []
: [
{
id: 'browser',
label: t('session.sheets.browser', 'Browser'),
icon: Globe,
closeBeforePress: true,
onPress: () => {
if (browserScreencastSupported !== true) {
showToast(
t(
'session.sheets.browserUpdateRequired',
'Desktop update required for mobile browser streaming'
),
1600
)
return
}
setShowCreateBrowserModal(true)
}
},
...(onPairedHost
? [
{
id: 'markdownNote',
label: t('session.sheets.markdownNote', 'Markdown Note'),
icon: FileText,
onPress: () => {
setShowCreateTabDrawer(false)
void handleCreateMarkdownNote()
}
}
]
: [])
])
]}
workspaceName={worktreeName}
agentOptions={createTabAgentOptions}
agentStatusRow={createTabAgentStatusRow}
// Why: browser/markdown creation resolve a real worktree on the host
// (browser.tabCreate, files.createFile); the floating sentinel is
// terminal-only over RPC, so those options hide there.
canCreateBrowser={!isFloatingWorkspaceRoute}
canCreateMarkdownNote={!isFloatingWorkspaceRoute && onPairedHost}
onClose={() => setShowCreateTabDrawer(false)}
onCreateAgent={(agent) => void handleCreateTerminal(agent)}
onCreateTerminal={() => void handleCreateTerminal()}
onCreateBrowser={() => {
if (browserScreencastSupported !== true) {
showToast(
t(
'session.sheets.browserUpdateRequired',
'Desktop update required for mobile browser streaming'
),
1600
)
return
}
setShowCreateBrowserModal(true)
}}
onCreateMarkdownNote={() => void handleCreateMarkdownNote()}
/>
<ActionSheetModal
visible={pendingDiffNotesDelivery !== null}
@@ -58,108 +58,6 @@ export const mobileSessionFrameStyles = themedStyleSheet(() => ({
color: colors.textSecondary,
fontSize: typography.metaSize
},
tabBar: {
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: colors.borderSubtle
},
// Cancels the top bar's vertical padding so tab borders run the bar's full height.
tabBarInTopBar: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'stretch',
marginVertical: -spacing.xs,
marginLeft: spacing.md,
borderLeftWidth: 1,
borderLeftColor: colors.borderSubtle
},
tabScrollFrame: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'stretch'
},
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
},
// Opaque like the chrome behind it: see the chat page scroll (#36553).
tabScroll: {
flex: 1,
maxHeight: 44,
backgroundColor: colors.bgPanel
},
tabScrollInTopBar: {
alignSelf: 'stretch',
maxHeight: 44
},
tabContent: {
paddingLeft: spacing.md,
paddingRight: spacing.sm
},
tabContentInTopBar: {
paddingLeft: 0
},
tab: {
minWidth: 44,
maxWidth: 128,
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 2,
borderBottomColor: 'transparent',
borderRightWidth: 1,
borderRightColor: colors.borderSubtle
},
tabFirst: {
borderLeftWidth: 1,
borderLeftColor: colors.borderSubtle
},
tabActive: {
borderBottomColor: colors.textSecondary,
backgroundColor: colors.bgRaised
},
tabPressed: {
backgroundColor: colors.translucentFill
},
tabLabelRow: {
maxWidth: '100%',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
// Prevent web SVG shrink before the title ellipsizes.
tabIcon: {
flexShrink: 0
},
tabText: {
flexShrink: 1,
color: colors.textSecondary,
fontSize: 13
},
tabTextActive: {
color: colors.textPrimary
},
newTerminalButtonPressed: {
backgroundColor: colors.bgRaised
},
@@ -0,0 +1,183 @@
import { useRef } from 'react'
import { Pressable, Text, View } from 'react-native'
import { FileText, Globe, SquareTerminal } from 'lucide-react-native'
import type { TuiAgent } from '../../../../src/shared/agent-launch/catalog/tui-agent'
import { ActionSheetContent, type ActionSheetAction } from '../../components/ActionSheetModal'
import { BottomDrawer } from '../../components/bottom-drawer/BottomDrawer'
import {
LIST_LEADING_INSET,
ListGroup,
ListRow,
SectionHeader
} from '../../components/list/ListGroup'
import { MobileAgentIcon } from '../../components/MobileAgentIcon'
import { SheetHeader } from '../../components/nav-bar/SheetHeader'
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
import { colors, radii, spacing } from '../../theme/mobile-theme'
import { themedStyleSheet } from '../../theme/themed-styles'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import type { MobileNewTabAgentOption } from '../agent-launch/mobile-new-tab-agent-options'
const AGENT_GRID_COLUMNS = 3
type Props = {
visible: boolean
workspaceName: string
agentOptions: readonly MobileNewTabAgentOption[]
/** Loading, none, other-server or error; shown instead of the agent grid. */
agentStatusRow: ActionSheetAction | null
canCreateBrowser: boolean
canCreateMarkdownNote: boolean
onClose: () => void
onCreateAgent: (agent: TuiAgent) => void
onCreateTerminal: () => void
/** Runs after the sheet has gone, so it may open another modal. */
onCreateBrowser: () => void
onCreateMarkdownNote: () => void
}
export function MobileNewTabSheet({
visible,
workspaceName,
agentOptions,
agentStatusRow,
canCreateBrowser,
canCreateMarkdownNote,
onClose,
onCreateAgent,
onCreateTerminal,
onCreateBrowser,
onCreateMarkdownNote
}: Props) {
useMobileColorScheme()
const t = useMobileTranslation()
// Wait for the iOS sheet window to unmount before presenting another native modal.
const afterCloseRef = useRef<(() => void) | null>(null)
const closeThen = (action: () => void) => {
afterCloseRef.current = action
onClose()
}
const closeAndRun = (action: () => void) => {
onClose()
action()
}
const showAgentGrid = agentStatusRow === null && agentOptions.length > 0
// Short last rows keep empty slots so every tile stays a third of the row wide.
const agentRows: { options: MobileNewTabAgentOption[]; emptySlots: string[] }[] = []
for (let start = 0; start < agentOptions.length; start += AGENT_GRID_COLUMNS) {
const options = agentOptions.slice(start, start + AGENT_GRID_COLUMNS)
const emptySlots: string[] = []
for (let column = options.length; column < AGENT_GRID_COLUMNS; column += 1) {
emptySlots.push(`empty:${column}`)
}
agentRows.push({ options, emptySlots })
}
const iconColor = colors.textSecondary
return (
<BottomDrawer
visible={visible}
onClose={onClose}
onAfterClose={() => {
const run = afterCloseRef.current
afterCloseRef.current = null
run?.()
}}
dragContentToDismiss
>
<SheetHeader
title={t('session.sheets.newTab', 'New Tab')}
{...(workspaceName ? { subtitle: workspaceName } : {})}
onClose={onClose}
/>
{showAgentGrid || agentStatusRow ? (
<SectionHeader title={t('session.sheets.agents', 'Agents')} first />
) : null}
{agentStatusRow ? <ActionSheetContent actions={[agentStatusRow]} /> : null}
{showAgentGrid ? (
<View style={styles.grid}>
{agentRows.map((row) => (
<View key={row.options.map((option) => option.agent).join(':')} style={styles.gridRow}>
{row.options.map((option) => (
<Pressable
key={option.agent}
onPress={() => closeAndRun(() => onCreateAgent(option.agent))}
accessibilityRole="button"
accessibilityLabel={option.label}
style={styles.tile}
>
{({ pressed }) => (
<>
{pressed ? <View style={styles.tilePressed} /> : null}
<MobileAgentIcon agentId={option.agent} size={22} />
<Text style={styles.tileLabel} numberOfLines={1}>
{option.label}
</Text>
</>
)}
</Pressable>
))}
{row.emptySlots.map((slot) => (
<View key={slot} style={styles.tileSpacer} />
))}
</View>
))}
</View>
) : null}
<ListGroup separatorInset={LIST_LEADING_INSET} style={styles.actions}>
<ListRow
key="terminal"
title={t('session.sheets.terminal', 'Terminal')}
leading={<SquareTerminal size={16} color={iconColor} />}
onPress={() => closeAndRun(onCreateTerminal)}
/>
{canCreateBrowser ? (
<ListRow
key="browser"
title={t('session.sheets.browser', 'Browser')}
leading={<Globe size={16} color={iconColor} />}
navigates
onPress={() => closeThen(onCreateBrowser)}
/>
) : null}
{canCreateMarkdownNote ? (
<ListRow
key="markdownNote"
title={t('session.sheets.markdownNote', 'Markdown Note')}
leading={<FileText size={16} color={iconColor} />}
onPress={() => closeAndRun(onCreateMarkdownNote)}
/>
) : null}
</ListGroup>
</BottomDrawer>
)
}
const styles = themedStyleSheet(() => ({
grid: { gap: spacing.sm },
gridRow: { flexDirection: 'row', gap: spacing.sm },
tile: {
flex: 1,
minWidth: 0,
height: 78,
borderRadius: radii.surface,
backgroundColor: colors.sheetGroup,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm
},
// Same wash as a pressed ListRow, laid over the tile so its own fill stays underneath.
tilePressed: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: colors.translucentFill
},
tileLabel: { fontSize: 13.5, fontWeight: '600', color: colors.textPrimary, maxWidth: '100%' },
// Padded like a tile: flex shares space after padding, so a bare spacer would widen its row's tiles.
tileSpacer: { flex: 1, paddingHorizontal: spacing.sm },
actions: { marginTop: spacing.lg }
}))
@@ -0,0 +1,361 @@
import { useMemo, useRef, useState } from 'react'
import { Pressable, Text, View } from 'react-native'
import { Plus } from 'lucide-react-native'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, {
Easing,
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withSequence,
withTiming
} from 'react-native-reanimated'
import { triggerMediumImpact, triggerSelection } from '../../platform/haptics'
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
import { AgentStateDot } from '../../components/AgentStateDot'
import { ArrivalFade } from '../../components/ArrivalFade'
import { useDrawerReducedMotion } from '../../components/bottom-drawer/bottom-drawer-constants'
import { SHEET_CURVE } from '../../components/sheet-motion-values'
import { useReducedMotionEnabled } from '../../platform/use-reduced-motion'
import { useKeyboardPersistingTaps } from '../../platform/keyboard-persisting-taps'
import { useNow } from '../../hooks/use-now'
import { agentStateLabel, type AgentDotState } from '../../worktree/agent-row-display'
import { colors, feedback, radii, spacing } from '../../theme/mobile-theme'
import { themedStyleSheet } from '../../theme/themed-styles'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import { buildMobileSessionTabRow, type MobileSessionTabRow } from './mobile-session-tab-rows'
import { MobileSessionTabMark } from './MobileSessionTabMark'
import { MobileSessionTabsSheet } from './MobileSessionTabsSheet'
import type { MobileSessionController } from '../use-mobile-session-controller'
const SWIPE_SETTLE_MS = 250
// A tab arriving from the side starts this far out, as a share of the tab cell's width.
const SWIPE_ENTRY_SHARE = 0.4
const FLICK_VELOCITY = 600
// With no tab on that side, the tab gives only this share of the drag.
const EDGE_RESISTANCE = 0.25
const DRAG_CLICK_WINDOW_MS = 400
// A tab drawn before it had any agent state (from the phone's store) fades its dot in when one lands.
function TabBarDot({ tabId, state }: { tabId: string; state: AgentDotState | undefined }) {
const [startedEmpty] = useState({ tabId, empty: state === undefined })
if (startedEmpty.tabId === tabId && startedEmpty.empty) {
return <ArrivingTabBarDot state={state} />
}
return state && state !== 'idle' ? <AgentStateDot state={state} /> : null
}
// Mounted with the bare tab, so the motion preference is read before the dot arrives.
function ArrivingTabBarDot({ state }: { state: AgentDotState | undefined }) {
const reducedMotion = useReducedMotionEnabled()
if (!state || state === 'idle') {
return null
}
return (
<ArrivalFade animate={reducedMotion === false}>
<AgentStateDot state={state} />
</ArrivalFade>
)
}
/** The tab you're in as one cell: tap for its actions, swipe for the next one; the count shows every tab, + adds one. */
export function MobileSessionTabBar({
controller,
inTopBar,
actionsCollapsed = false
}: {
controller: MobileSessionController
inTopBar: boolean
actionsCollapsed?: boolean
}) {
useMobileColorScheme()
const t = useMobileTranslation()
const {
activeSessionTabId,
connState,
createTabBusy,
handleOpenNewTab,
switchSessionTab,
openSessionTabActionSheetAfterKeyboardDismiss,
visibleTabs,
terminals
} = controller
// Why: the first tap must reach the bar with the keyboard open instead of only dismissing it (#5106).
const keepsKeyboard = useKeyboardPersistingTaps('handled')
const now = useNow(60_000)
const rows = useMemo(
() => visibleTabs.map((tab) => buildMobileSessionTabRow(tab, terminals, now)),
[visibleTabs, terminals, now]
)
const activeIndex = rows.findIndex((row) => row.tab.id === activeSessionTabId)
const active: MobileSessionTabRow | undefined = rows[activeIndex]
const previous = activeIndex > 0 ? rows[activeIndex - 1] : undefined
const next = activeIndex !== -1 ? rows[activeIndex + 1] : undefined
const othersNeedYou = rows.some(
(row) => row.tab.id !== activeSessionTabId && row.group === 'needs-you'
)
const [sheetOpen, setSheetOpen] = useState(false)
const reducedMotion = useDrawerReducedMotion()
const offset = useSharedValue(0)
const width = useSharedValue(0)
// On the web, releasing a mouse drag also clicks the tab.
const dragEndedAtRef = useRef(0)
const settle = (to: number) =>
withTiming(
to,
reducedMotion
? { duration: 0, reduceMotion: ReduceMotion.Never }
: { duration: SWIPE_SETTLE_MS, easing: Easing.bezier(...SHEET_CURVE) }
)
const step = (target: MobileSessionTabRow | undefined, direction: 1 | -1) => {
if (!target) {
offset.value = settle(0)
return
}
triggerSelection()
switchSessionTab(target.tab)
offset.value = withSequence(
withTiming(-direction * width.value * SWIPE_ENTRY_SHARE, { duration: 0 }),
settle(0)
)
}
// Callbacks run on JS: the session page is a web document, and stepping switches React state.
const pan = Gesture.Pan()
.runOnJS(true)
.enabled(rows.length > 1)
.activeOffsetX([-12, 12])
.failOffsetY([-10, 10])
.onUpdate((event) => {
const target = event.translationX > 0 ? previous : next
offset.value = target ? event.translationX : event.translationX * EDGE_RESISTANCE
})
.onEnd((event) => {
// oxlint-disable-next-line react/purity -- runs when the drag ends, never during render.
dragEndedAtRef.current = Date.now()
const direction = event.translationX > 0 ? 1 : -1
const flicked =
Math.abs(event.velocityX) > FLICK_VELOCITY && Math.sign(event.velocityX) === direction
if (Math.abs(event.translationX) > width.value / 2 || flicked) {
step(direction === 1 ? previous : next, direction)
return
}
offset.value = settle(0)
})
// Web has no worklet plugin; mapper hooks need this explicit dependency list.
const contentStyle = useAnimatedStyle(
() => ({
transform: [{ translateX: offset.value }],
opacity: 1 - Math.min(Math.abs(offset.value) / Math.max(width.value, 1), 1) * 0.6
}),
[offset, width]
)
const status = active?.dotState && active.dotState !== 'idle' ? active.dotState : undefined
const countLabel =
rows.length === 1
? t('session.tabBar.oneTab', '1 tab')
: t('session.tabBar.tabCount', '{{count}} tabs', { count: rows.length })
const tabLabel = [active?.title, status ? agentStateLabel(status) : null]
.filter(Boolean)
.join(', ')
const countAccessibilityLabel = [
countLabel,
othersNeedYou ? t('session.tabBar.anotherNeedsYou', 'another tab needs you') : null
]
.filter(Boolean)
.join(', ')
const createDisabled = createTabBusy || connState !== 'connected'
const openTabActions = () => {
// The click a drag release produces on the web belongs to the drag.
if (!active || Date.now() - dragEndedAtRef.current < DRAG_CLICK_WINDOW_MS) {
return
}
openSessionTabActionSheetAfterKeyboardDismiss(active.tab)
}
return (
<View ref={keepsKeyboard} style={inTopBar ? styles.barInTopBar : styles.bar}>
{/* pan-y keeps vertical touches with the page on the web. */}
<GestureDetector gesture={pan} touchAction="pan-y">
<View
style={styles.tabCell}
onLayout={(event) => {
width.value = event.nativeEvent.layout.width
}}
>
<Pressable
style={({ pressed }) => [styles.tabPress, pressed && styles.cellPressed]}
onPress={openTabActions}
onLongPress={() => {
triggerMediumImpact()
openTabActions()
}}
delayLongPress={400}
disabled={!active}
accessibilityRole="button"
accessibilityLabel={tabLabel || countLabel}
accessibilityHint={t('session.tabBar.actionsHint', 'Shows actions for this tab')}
accessibilityActions={[
...(next ? [{ name: 'nextTab', label: t('session.tabBar.next', 'Next tab') }] : []),
...(previous
? [{ name: 'previousTab', label: t('session.tabBar.previous', 'Previous tab') }]
: [])
]}
onAccessibilityAction={(event) => {
const name = event.nativeEvent.actionName
if (name === 'nextTab') {
step(next, -1)
} else if (name === 'previousTab') {
step(previous, 1)
}
}}
>
{/* Only the contents slide, so the cell stays flush with the screen edge. */}
<Animated.View style={[styles.tabContent, contentStyle]}>
{active ? (
<>
<TabBarDot tabId={active.tab.id} state={active.dotState} />
<View style={styles.mark}>
<MobileSessionTabMark row={active} size={14} />
</View>
</>
) : null}
<Text style={styles.title} numberOfLines={1}>
{active?.title ?? countLabel}
</Text>
</Animated.View>
</Pressable>
</View>
</GestureDetector>
<Pressable
style={({ pressed }) => [
styles.sideCell,
actionsCollapsed && !inTopBar && styles.lastCell,
pressed && styles.cellPressed
]}
onPress={() => setSheetOpen(true)}
accessibilityRole="button"
accessibilityLabel={countAccessibilityLabel}
accessibilityHint={t('session.tabBar.hint', 'Shows all tabs')}
>
<View style={styles.countBox}>
<Text style={styles.countText}>{rows.length}</Text>
</View>
{othersNeedYou ? <View style={styles.alert} /> : null}
</Pressable>
{/* A collapsed bar's + lives in the header's ⋯ menu. */}
{!actionsCollapsed ? (
<Pressable
style={({ pressed }) => [
styles.sideCell,
!inTopBar && styles.lastCell,
pressed && styles.cellPressed,
createDisabled && styles.cellDisabled
]}
disabled={createDisabled}
onPress={handleOpenNewTab}
accessibilityRole="button"
accessibilityLabel={t('session.tabStrip.newTab', 'New tab')}
accessibilityState={{ disabled: createDisabled }}
>
<Plus size={18} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
) : null}
<MobileSessionTabsSheet
controller={controller}
rows={rows}
visible={sheetOpen}
onClose={() => setSheetOpen(false)}
/>
</View>
)
}
const styles = themedStyleSheet(() => ({
bar: {
height: 44,
flexDirection: 'row',
alignItems: 'stretch',
borderTopWidth: 1,
borderTopColor: colors.borderSubtle
},
// Cancels the top bar's vertical padding so the cells run the bar's full height.
barInTopBar: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'stretch',
alignSelf: 'stretch',
marginVertical: -spacing.xs,
marginLeft: spacing.md,
borderLeftWidth: 1,
borderLeftColor: colors.borderSubtle
},
// The open tab, drawn like the strip's active tab: raised, with a line under it.
tabCell: {
flex: 1,
minWidth: 0,
backgroundColor: colors.bgRaised,
borderBottomWidth: 2,
borderBottomColor: colors.textSecondary,
borderRightWidth: 1,
borderRightColor: colors.borderSubtle,
overflow: 'hidden'
},
tabPress: {
flex: 1,
justifyContent: 'center',
paddingLeft: spacing.lg,
paddingRight: spacing.sm
},
tabContent: { flexDirection: 'row', alignItems: 'center', gap: 6 },
// Keeps web SVGs from shrinking before the title ellipsizes.
mark: { flexShrink: 0 },
title: {
flexShrink: 1,
minWidth: 0,
fontSize: 14,
fontWeight: '600',
color: colors.textPrimary
},
sideCell: {
width: 46,
alignItems: 'center',
justifyContent: 'center',
borderRightWidth: 1,
borderRightColor: colors.borderSubtle
},
// At the screen edge; in the top bar the hairline stays to part the cells from the round buttons.
lastCell: { borderRightWidth: 0 },
cellPressed: { backgroundColor: colors.translucentFill },
cellDisabled: { opacity: feedback.disabledOpacity },
countBox: {
minWidth: 19,
height: 19,
paddingHorizontal: 3,
borderRadius: 6,
borderWidth: 1.8,
borderColor: colors.textPrimary,
alignItems: 'center',
justifyContent: 'center'
},
countText: {
fontSize: 11,
fontWeight: '700',
color: colors.textPrimary,
fontVariant: ['tabular-nums']
},
// Another tab needs you: a red dot on the count's corner, ringed in the bar's colour.
alert: {
position: 'absolute',
top: 8,
right: 9,
width: 9,
height: 9,
borderRadius: radii.pill,
backgroundColor: colors.statusRed,
borderWidth: 2,
borderColor: colors.bgPanel
}
}))
@@ -0,0 +1,28 @@
import { File, FileText, Globe, SquareTerminal } from 'lucide-react-native'
import { MobileAgentIcon } from '../../components/MobileAgentIcon'
import { colors } from '../../theme/mobile-theme'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import type { MobileSessionTabRow } from './mobile-session-tab-rows'
/** The tab's agent icon, else an icon for what kind of tab it is. */
export function MobileSessionTabMark({
row,
size
}: {
row: Pick<MobileSessionTabRow, 'tab' | 'agentId'>
size: number
}) {
useMobileColorScheme()
if (row.agentId) {
return <MobileAgentIcon agentId={row.agentId} size={size} />
}
const Icon =
row.tab.type === 'browser'
? Globe
: row.tab.type === 'markdown'
? FileText
: row.tab.type === 'file'
? File
: SquareTerminal
return <Icon size={size} color={colors.textSecondary} strokeWidth={2.1} />
}
@@ -1,327 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, ScrollView, Pressable, StyleSheet } from 'react-native'
import { File, FileText, Globe, Plus, SquareTerminal } from 'lucide-react-native'
import { triggerMediumImpact } from '../../platform/haptics'
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
import { MobileAgentIcon } from '../../components/MobileAgentIcon'
import { AgentStateDot } from '../../components/AgentStateDot'
import { ArrivalFade } from '../../components/ArrivalFade'
import { useReducedMotionEnabled } from '../../platform/use-reduced-motion'
import { useNow } from '../../hooks/use-now'
import {
agentDotState,
agentStateLabel,
type AgentDotState
} from '../../worktree/agent-row-display'
import {
getMobileTerminalTabHostAgentIdentity,
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
} from '../../terminal/session/mobile-terminal-tab-agent'
import { colors, spacing } from '../../theme/mobile-theme'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import { NavBarButton } from '../../components/nav-bar/NavBarButton'
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
// A tab drawn before it had any agent state (a strip from the phone's store) fades its dot in when one lands.
function SessionTabAgentDot({ state }: { state: AgentDotState | undefined }) {
const [startedEmpty] = useState(state === undefined)
if (startedEmpty) {
return <ArrivingSessionTabAgentDot state={state} />
}
return state && state !== 'idle' ? <AgentStateDot state={state} /> : null
}
// Mounted with the bare tab, so the motion preference is read before the dot arrives.
function ArrivingSessionTabAgentDot({ state }: { state: AgentDotState | undefined }) {
const reducedMotion = useReducedMotionEnabled()
if (!state || state === 'idle') {
return null
}
return (
<ArrivalFade animate={reducedMotion === false}>
<AgentStateDot state={state} />
</ArrivalFade>
)
}
export function MobileSessionTabStrip({
controller,
inTopBar,
actionsCollapsed = false
}: {
controller: MobileSessionController
inTopBar: boolean
actionsCollapsed?: boolean
}) {
useMobileColorScheme()
const t = useMobileTranslation()
const {
activeSessionTabId,
activeSessionTabIdRef,
tabStripRef,
tabStripOffsetRef,
tabStripViewportWidthRef,
tabStripContentWidthRef,
tabLayoutsRef,
connState,
createTabBusy,
handleOpenNewTab,
scrollActiveTabIntoView,
holdTabStripReveal,
cancelHeldTabStripReveal,
switchSessionTab,
openSessionTabActionSheetAfterKeyboardDismiss,
setRenameTarget,
activeHandleRef,
visibleTabs,
terminals
} = 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) {
tabStripOffsetRef.current = 0
tabStripViewportWidthRef.current = 0
tabStripContentWidthRef.current = 0
tabLayoutsRef.current.clear()
}
tabStripRef.current = view
},
[
tabStripRef,
tabStripOffsetRef,
tabStripViewportWidthRef,
tabStripContentWidthRef,
tabLayoutsRef
]
)
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). */}
<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((tab, index) => {
const tabState = tabStates.get(tab.id)
const hostAgentIdentity = getMobileTerminalTabHostAgentIdentity(tab, terminals)
const title = getMobileSessionTabTitle(tab, hostAgentIdentity)
return (
<Pressable
key={tab.id}
style={({ pressed }) => [
styles.tab,
index === 0 && !inTopBar && styles.tabFirst,
tab.id === activeSessionTabId && styles.tabActive,
pressed && styles.tabPressed
]}
onLayout={(e) => {
const { x, width } = e.nativeEvent.layout
tabLayoutsRef.current.set(tab.id, { x, width })
if (tab.id === activeSessionTabIdRef.current) {
scrollActiveTabIntoView(tab.id, false)
}
refreshHiddenTabs()
}}
onPress={() => pressTab(tab)}
accessibilityRole="tab"
accessibilityLabel={
tabState && tabState !== 'idle'
? t('session.tabStrip.tabStatus', '{{title}}, {{status}}', {
title,
status: agentStateLabel(tabState)
})
: title
}
accessibilityState={{ selected: tab.id === activeSessionTabId }}
onLongPress={() => {
triggerMediumImpact()
openSessionTabActionSheetAfterKeyboardDismiss(tab)
}}
delayLongPress={400}
>
<View style={styles.tabLabelRow}>
<SessionTabAgentDot state={tabState} />
<View style={styles.tabIcon}>
{tab.type === 'browser' && (
<Globe size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{tab.type === 'markdown' && (
<FileText size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{tab.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{tab.type === 'agent-session' && (
<MobileAgentIcon agentId={tab.agent} size={13} />
)}
{tab.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(tab, hostAgentIdentity)
return agentId ? (
<MobileAgentIcon agentId={agentId} size={13} />
) : (
<SquareTerminal
size={13}
color={colors.textSecondary}
strokeWidth={2.1}
/>
)
})()}
</View>
<Text
style={[styles.tabText, tab.id === activeSessionTabId && styles.tabTextActive]}
numberOfLines={1}
>
{title}
</Text>
</View>
</Pressable>
)
})}
</ScrollView>
{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>
{/* Terminal layout and Quick commands live in the header's ⋯ menu. */}
{!actionsCollapsed ? (
<View style={newTabStyles.slot}>
<NavBarButton
disabled={createTabBusy || connState !== 'connected'}
onPress={handleOpenNewTab}
accessibilityLabel={t('session.tabStrip.newTab', 'New tab')}
icon={Plus}
/>
</View>
) : null}
</View>
)
}
// The right inset matches the nav bar's, so the + lines up with the ⋯ above it.
const newTabStyles = StyleSheet.create({
slot: {
paddingLeft: spacing.sm,
paddingRight: spacing.md
}
})
@@ -0,0 +1,220 @@
import { Fragment, useRef, useState } from 'react'
import { Pressable, Text, View } from 'react-native'
import { Plus, X } from 'lucide-react-native'
import { BottomDrawer } from '../../components/bottom-drawer/BottomDrawer'
import { ListGroup, SectionHeader } from '../../components/list/ListGroup'
import { NavBarButton } from '../../components/nav-bar/NavBarButton'
import { SheetHeader } from '../../components/nav-bar/SheetHeader'
import { SwipeRowActions } from '../../components/SwipeRowActions'
import { AgentStateDot } from '../../components/AgentStateDot'
import { useMobileTranslation } from '../../i18n/use-mobile-translation'
import { useNow } from '../../hooks/use-now'
import { formatTimeAgo } from '../../worktree/agent-row-display'
import { colors, radii, spacing } from '../../theme/mobile-theme'
import { themedStyleSheet } from '../../theme/themed-styles'
import { useMobileColorScheme } from '../../theme/use-mobile-color-scheme'
import type { MobileSessionTabGroup, MobileSessionTabRow } from './mobile-session-tab-rows'
import { MobileSessionTabMark } from './MobileSessionTabMark'
import type { MobileSessionController } from '../use-mobile-session-controller'
const TILE_SIZE = 34
const TILE_DOT_RING = 2
const GROUP_ORDER: readonly MobileSessionTabGroup[] = ['needs-you', 'working', 'other']
/** Every tab in the workspace, what needs you first; tap one to go there, swipe one to close it. */
export function MobileSessionTabsSheet({
controller,
rows,
visible,
onClose
}: {
controller: MobileSessionController
rows: readonly MobileSessionTabRow[]
visible: boolean
onClose: () => void
}) {
useMobileColorScheme()
const t = useMobileTranslation()
const now = useNow(60_000)
const {
activeSessionTabId,
worktreeName,
switchSessionTab,
handleCloseSessionTab,
handleOpenNewTab,
createTabBusy,
connState
} = controller
// Sections hold still while the sheet is open, so a row never moves under a finger.
const [frozenGroups, setFrozenGroups] = useState<ReadonlyMap<string, MobileSessionTabGroup>>()
if (visible && frozenGroups === undefined) {
setFrozenGroups(new Map(rows.map((row) => [row.tab.id, row.group])))
}
const afterCloseRef = useRef<(() => void) | null>(null)
const sections = GROUP_ORDER.map((group) => ({
group,
rows: rows.filter((row) => (frozenGroups?.get(row.tab.id) ?? row.group) === group)
})).filter((section) => section.rows.length > 0)
const sectionTitles: Record<MobileSessionTabGroup, string> = {
'needs-you': t('session.tabsSheet.needsYou', 'Needs you'),
working: t('session.tabsSheet.working', 'Working'),
other: t('session.tabsSheet.everythingElse', 'Everything else')
}
const title =
rows.length === 1
? t('session.tabBar.oneTab', '1 tab')
: t('session.tabBar.tabCount', '{{count}} tabs', { count: rows.length })
const renderRow = (row: MobileSessionTabRow) => {
const current = row.tab.id === activeSessionTabId
const badge = row.dotState && row.dotState !== 'idle' ? row.dotState : undefined
const time = current
? t('session.tabsSheet.open', 'Open')
: row.timeAt !== null
? formatTimeAgo(row.timeAt, now)
: ''
return (
<SwipeRowActions
key={row.tab.id}
trailing={{
label: t('common.close', 'Close'),
accessibilityLabel: t('session.tabsSheet.closeTab', 'Close {{title}}', {
title: row.title
}),
icon: X,
background: colors.statusRed,
foreground: colors.onStatusRed,
onPress: () => void handleCloseSessionTab(row.tab)
}}
>
{(guardPress) => (
<Pressable
accessibilityRole="button"
accessibilityLabel={[row.title, row.detail, time].filter(Boolean).join(', ')}
accessibilityState={{ selected: current }}
style={styles.row}
onPress={guardPress(() => {
if (!current) {
switchSessionTab(row.tab)
}
onClose()
})}
>
{({ pressed }) => (
<>
{current || pressed ? <View pointerEvents="none" style={styles.wash} /> : null}
<View style={styles.tile}>
<MobileSessionTabMark row={row} size={17} />
{badge ? (
<View style={styles.tileBadge}>
<AgentStateDot state={badge} />
</View>
) : null}
</View>
<View style={styles.text}>
<Text style={styles.title} numberOfLines={1}>
{row.title}
</Text>
<Text style={styles.detail} numberOfLines={1}>
{row.detail}
</Text>
</View>
{time ? (
<Text style={[styles.time, current && styles.timeCurrent]}>{time}</Text>
) : null}
</>
)}
</Pressable>
)}
</SwipeRowActions>
)
}
return (
<BottomDrawer
visible={visible}
onClose={onClose}
onAfterClose={() => {
setFrozenGroups(undefined)
const afterClose = afterCloseRef.current
afterCloseRef.current = null
afterClose?.()
}}
>
<View>
<SheetHeader title={title} subtitle={worktreeName || undefined} />
<View style={styles.newTab}>
<NavBarButton
icon={Plus}
disabled={createTabBusy || connState !== 'connected'}
accessibilityLabel={t('session.tabStrip.newTab', 'New tab')}
onPress={() => {
// The new-tab sheet is its own drawer; open it once this one is gone.
afterCloseRef.current = handleOpenNewTab
onClose()
}}
/>
</View>
</View>
<View style={styles.body}>
{sections.map((section, index) => (
<Fragment key={section.group}>
{sections.length > 1 ? (
<SectionHeader title={sectionTitles[section.group]} first={index === 0} />
) : null}
<ListGroup separatorInset={spacing.lg + TILE_SIZE + spacing.md}>
{section.rows.map(renderRow)}
</ListGroup>
</Fragment>
))}
</View>
</BottomDrawer>
)
}
const styles = themedStyleSheet(() => ({
// Over the sheet header's empty trailing side, centred in its 52pt row.
newTab: { position: 'absolute', top: 8, right: spacing.xs },
body: { paddingBottom: spacing.lg },
// Opaque so a swipe's action stays hidden until the row slides off it.
row: {
minHeight: 60,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.sheetGroup
},
// The tab you're in, or a pressed row, washed like a picked segment on a sheet group.
wash: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: colors.translucentFill
},
tile: {
width: TILE_SIZE,
height: TILE_SIZE,
borderRadius: radii.tile,
backgroundColor: colors.translucentFill,
alignItems: 'center',
justifyContent: 'center'
},
// The state dot on the tile's corner, ringed in the group's colour.
tileBadge: {
position: 'absolute',
top: -4,
right: -4,
padding: TILE_DOT_RING,
borderRadius: radii.pill,
backgroundColor: colors.sheetGroup
},
text: { flex: 1, minWidth: 0, gap: 2 },
title: { fontSize: 15, fontWeight: '600', color: colors.textPrimary },
detail: { fontSize: 13, color: colors.textSecondary },
time: { fontSize: 12, color: colors.textMuted, fontVariant: ['tabular-nums'] },
timeCurrent: { fontWeight: '600', color: colors.accentBlue }
}))
@@ -0,0 +1,121 @@
import { agentListPreview } from '../../agents/agent-list-row-text'
import { agentStatusGroup } from '../../agents/agent-list-rows'
import { translate } from '../../i18n/mobile-i18n'
import {
getMobileSessionTabTitle,
getMobileTerminalTabHostAgentIdentity,
resolveMobileTerminalTabAgentId
} from '../../terminal/session/mobile-terminal-tab-agent'
import {
agentDotState,
agentRowTimeAt,
agentStateLabel,
type AgentDotState
} from '../../worktree/agent-row-display'
import type { MobileSessionTab } from './mobile-session-tabs'
export type MobileSessionTabGroup = 'needs-you' | 'working' | 'other'
export type MobileSessionTabRow = {
tab: MobileSessionTab
title: string
/** The agent whose icon marks the tab; null draws the tab kind's icon. */
agentId: string | null
dotState: AgentDotState | undefined
group: MobileSessionTabGroup
/** The second line: the state, then what the tab is doing or showing. */
detail: string
timeAt: number | null
}
export function mobileSessionTabDotState(
tab: MobileSessionTab,
now: number
): AgentDotState | undefined {
// 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) {
return agentDotState(tab.agentStatus, now)
}
if (tab.type === 'agent-session' && tab.chatStatus) {
return agentDotState(tab.chatStatus, now)
}
return undefined
}
function tabGroup(state: AgentDotState | undefined): MobileSessionTabGroup {
if (!state) {
return 'other'
}
const group = agentStatusGroup(state)
return group === 'needs-you' || group === 'working' ? group : 'other'
}
function tabContent(
tab: MobileSessionTab,
title: string,
dotState: AgentDotState | undefined
): string {
switch (tab.type) {
case 'terminal': {
const status = tab.agentStatus
if (!status || !dotState) {
return ''
}
return agentListPreview(status, agentStatusGroup(dotState), title)
}
case 'browser':
return tab.url === title ? '' : tab.url
case 'markdown':
case 'file':
return tab.relativePath === title ? '' : tab.relativePath
case 'agent-session':
return ''
}
}
function tabKindLabel(tab: MobileSessionTab): string {
switch (tab.type) {
case 'browser':
return translate('session.tabRows.browser', 'Browser')
case 'markdown':
return translate('session.tabRows.markdown', 'Markdown note')
case 'file':
return translate('session.tabRows.file', 'File')
case 'agent-session':
return translate('session.tabRows.chat', 'Chat')
case 'terminal':
return translate('session.tabRows.terminal', 'Terminal')
}
}
export function buildMobileSessionTabRow(
tab: MobileSessionTab,
terminals: readonly { handle: string; agentIdentity?: string }[],
now: number
): MobileSessionTabRow {
const hostAgentIdentity = getMobileTerminalTabHostAgentIdentity(tab, terminals)
const title = getMobileSessionTabTitle(tab, hostAgentIdentity)
const dotState = mobileSessionTabDotState(tab, now)
const stateWord = dotState && dotState !== 'idle' ? agentStateLabel(dotState) : ''
const content = tabContent(tab, title, dotState)
const timeAt =
tab.type === 'terminal' && tab.agentStatus && !tab.agentStatus.restoredUnconfirmed
? agentRowTimeAt(tab.agentStatus)
: tab.type === 'agent-session' && tab.chatStatus
? tab.chatStatus.updatedAt
: null
return {
tab,
title,
agentId:
tab.type === 'agent-session'
? tab.agent
: tab.type === 'terminal'
? resolveMobileTerminalTabAgentId(tab, hostAgentIdentity)
: null,
dotState,
group: tabGroup(dotState),
detail: [stateWord, content].filter(Boolean).join(' · ') || tabKindLabel(tab),
timeAt
}
}
@@ -1,5 +1,3 @@
import type { AgentDotState } from '../../worktree/agent-row-display'
export type TabStripScrollInput = {
tabX: number
tabWidth: number
@@ -36,98 +34,3 @@ export function resolveTabStripScrollOffset({
return Math.min(Math.max(0, nextOffset), maxOffset)
}
type TabLayouts = ReadonlyMap<string, { x: number; width: number }>
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
})
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 while revealing as much of its neighbor as fits within 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[] }
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
}
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
}
@@ -52,6 +52,7 @@ export function useMobileSessionFoundation() {
name: worktreeName,
path: worktreePath,
hostId: worktreeHostId,
branch: worktreeBranch,
resolution: worktreeResolution
} = useLiveWorktreeName({
client,
@@ -115,6 +116,7 @@ export function useMobileSessionFoundation() {
worktreeName,
worktreePath,
worktreeHostId,
worktreeBranch,
worktreeResolution,
isWideLayout,
activePanel,
@@ -7,7 +7,7 @@ import {
shouldShowSessionHeaderChecksAction,
panelRouteDescriptor
} from './session-panel-host'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import type { ActionSheetAction } from '../components/ActionSheetModal'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import {
useWorkspaceExecutionHost,
@@ -49,127 +49,81 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati
quickCommandsSupported,
showToast
} = scope
const otherRuntimeAgentRow = {
id: 'otherServerAgents',
label: t('session.usePanelRouteActions.otherServerAgents', 'Agents on Another Server'),
hint: t('session.usePanelRouteActions.pairServerHint', 'Pair that server directly'),
icon: Bot,
disabled: true,
onPress: () => {}
}
const createTabAgentActions =
const agentStatusRow = (errorHint: string): ActionSheetAction | null =>
createTabAgentLoadState === 'loading'
? [
{
id: 'detectingAgents',
label: t('session.usePanelRouteActions.detectingAgents', 'Detecting Agents'),
icon: Bot,
disabled: true,
loading: true,
onPress: () => {}
}
]
? {
id: 'detectingAgents',
label: t('session.usePanelRouteActions.detectingAgents', 'Detecting Agents'),
icon: Bot,
disabled: true,
loading: true,
onPress: () => {}
}
: createTabAgentOptions.length > 0
? createTabAgentOptions.map((option) => ({
id: `agent:${option.agent}`,
label: option.label,
renderIcon: () => <MobileAgentIcon agentId={option.agent} size={16} />,
onPress: () => {
setShowCreateTabDrawer(false)
void handleCreateTerminal(option.agent)
}
}))
? null
: createTabAgentLoadState === 'loaded'
? [
{
id: 'noEnabledAgents',
label: t('session.usePanelRouteActions.noEnabledAgents', 'No Enabled Agents'),
? {
id: 'noEnabledAgents',
label: t('session.usePanelRouteActions.noEnabledAgents', 'No Enabled Agents'),
icon: Bot,
disabled: true,
onPress: () => {}
}
: createTabAgentLoadState === 'other-runtime'
? {
id: 'otherServerAgents',
label: t(
'session.usePanelRouteActions.otherServerAgents',
'Agents on Another Server'
),
hint: t('session.usePanelRouteActions.pairServerHint', 'Pair that server directly'),
icon: Bot,
disabled: true,
onPress: () => {}
}
]
: createTabAgentLoadState === 'other-runtime'
? [otherRuntimeAgentRow]
: createTabAgentLoadState === 'error'
? [
{
id: 'presetsUnavailable',
label: t(
'session.usePanelRouteActions.presetsUnavailable',
'Agent Presets Unavailable'
),
hint: t(
'session.usePanelRouteActions.checkHostHint',
'Check the host connection'
),
icon: Bot,
disabled: true,
onPress: () => {}
}
]
: []
const sendDiffNotesAgentActions =
pendingDiffNotesDelivery === null
? []
: createTabAgentLoadState === 'loading'
? [
{
id: 'detectingAgents',
label: t('session.usePanelRouteActions.detectingAgents', 'Detecting Agents'),
icon: Bot,
disabled: true,
loading: true,
onPress: () => {}
}
]
: createTabAgentOptions.length > 0
? createTabAgentOptions.map((option) => ({
id: `agent:${option.agent}`,
label: option.label,
hint: t('session.usePanelRouteActions.newSessionHint', 'New agent session'),
icon: Bot,
onPress: () => {
const delivery = pendingDiffNotesDelivery
setPendingDiffNotesDelivery(null)
if (!delivery) {
return
}
void sendDiffNotesToNewAgent(delivery, async () => {
await handleCreateTerminal(option.agent, {
initialPrompt: delivery.prompt,
onPromptSent: () => void clearDeliveredDiffComments(delivery.comments)
})
})
}
}))
: createTabAgentLoadState === 'loaded'
? [
{
id: 'noEnabledAgents',
label: t('session.usePanelRouteActions.noEnabledAgents', 'No Enabled Agents'),
? {
id: 'presetsUnavailable',
label: t(
'session.usePanelRouteActions.presetsUnavailable',
'Agent Presets Unavailable'
),
hint: errorHint,
icon: Bot,
disabled: true,
onPress: () => {}
}
]
: createTabAgentLoadState === 'other-runtime'
? [otherRuntimeAgentRow]
: createTabAgentLoadState === 'error'
? [
{
id: 'presetsUnavailable',
label: t(
'session.usePanelRouteActions.presetsUnavailable',
'Agent Presets Unavailable'
),
hint: t('session.usePanelRouteActions.copyNotesHint', 'Copy notes instead'),
icon: Bot,
disabled: true,
onPress: () => {}
}
]
: []
: null
const createTabAgentStatusRow = agentStatusRow(
t('session.usePanelRouteActions.checkHostHint', 'Check the host connection')
)
const sendDiffNotesStatusRow = agentStatusRow(
t('session.usePanelRouteActions.copyNotesHint', 'Copy notes instead')
)
const sendDiffNotesAgentActions: ActionSheetAction[] =
pendingDiffNotesDelivery === null
? []
: sendDiffNotesStatusRow
? [sendDiffNotesStatusRow]
: createTabAgentOptions.map((option) => ({
id: `agent:${option.agent}`,
label: option.label,
hint: t('session.usePanelRouteActions.newSessionHint', 'New agent session'),
icon: Bot,
onPress: () => {
const delivery = pendingDiffNotesDelivery
setPendingDiffNotesDelivery(null)
if (!delivery) {
return
}
void sendDiffNotesToNewAgent(delivery, async () => {
await handleCreateTerminal(option.agent, {
initialPrompt: delivery.prompt,
onPromptSent: () => void clearDeliveredDiffComments(delivery.comments)
})
})
}
}))
const handleSessionContentRowLayout = useCallback((event: LayoutChangeEvent) => {
const width = Math.round(event.nativeEvent.layout.width)
@@ -235,7 +189,7 @@ export function useMobileSessionPanelRouteActions(scope: MobileSessionPresentati
)
}
return {
createTabAgentActions,
createTabAgentStatusRow,
sendDiffNotesAgentActions,
handleSessionContentRowLayout,
handlePanelTap,
@@ -76,11 +76,8 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel)
presentedConnState === 'connected'
? showLoadingState && visibleTabs.length === 0
? t('session.presentation.loadingTabs', 'Loading tabs')
: visibleTabs.length === 1
? t('session.presentation.oneTab', '1 tab')
: t('session.presentation.tabCount', '{{tabCount}} tabs', {
tabCount: visibleTabs.length
})
: // The tab count lives in the tab bar, so the header names the branch instead.
(scope.worktreeBranch ?? MOBILE_SESSION_STATUS_LABELS.connected)
: showConnectionRetry
? t('session.presentation.tapToRetry', '{{status}} — tap to retry', {
status: verdictDisplayLabel(connectionVerdict)
@@ -1,7 +1,7 @@
import { useState, useRef, useMemo } from 'react'
import { useTerminalSplitLayout } from '../terminal/session/use-terminal-split-layout'
import { createLiveInputCapture } from '../terminal/session/live-input-capture'
import { Animated, type ScrollView } from 'react-native'
import { Animated } from 'react-native'
import type { MobileTerminalLinkOpenMode } from '../storage/preferences'
import type { TerminalKeyboardAvoidanceMetrics } from '../terminal/terminal-webview-contract'
import {
@@ -46,11 +46,6 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
const activeSessionTabIdRef = useRef<string | null>(null)
// Preserve an explicit phone tab pick while a host snapshot is transiently incomplete.
const selectedSessionTabIdRef = useRef<string | null>(null)
const tabStripRef = useRef<ScrollView>(null)
const tabStripOffsetRef = useRef(0)
const tabStripViewportWidthRef = useRef(0)
const tabStripContentWidthRef = useRef(0)
const tabLayoutsRef = useRef<Map<string, { x: number; width: number }>>(new Map())
const [markdownDocs, setMarkdownDocs] = useState<Map<string, MarkdownDocState>>(new Map())
const markdownDocsRef = useRef<Map<string, MarkdownDocState>>(new Map())
const [fileDocs, setFileDocs] = useState<Map<string, FileDocState>>(new Map())
@@ -134,11 +129,6 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
setActiveSessionTabId,
activeSessionTabIdRef,
selectedSessionTabIdRef,
tabStripRef,
tabStripOffsetRef,
tabStripViewportWidthRef,
tabStripContentWidthRef,
tabLayoutsRef,
markdownDocs,
setMarkdownDocs,
markdownDocsRef,
@@ -1,10 +1,9 @@
import { useEffect, useCallback, useRef } from 'react'
import { useEffect, useCallback } from 'react'
import { currentSoftKeyboardHeight, subscribeSoftKeyboard } from '../../platform/keyboard-occlusion'
import { useTerminalViewportRefit } from '../terminal-viewport-refit'
import { removeCustomKey, type CustomKey } from '../terminal-custom-keys'
import { useWorkspaceExecutionHost } from '../../navigation/workspace-execution-host'
import { writeLastVisitedWorktree } from '../../worktree/last-visited-worktree-by-host'
import { resolveTabStripScrollOffset } from '../../session/tabs/tab-strip-scroll'
import type { MobileSessionLifecycleModel } from '../../session/use-mobile-session-lifecycle'
export type MobileSessionKeyboardScope = Pick<
@@ -12,7 +11,6 @@ export type MobileSessionKeyboardScope = Pick<
| 'activeHandleRef'
| 'visibleTerminalHandlesRef'
| 'terminalPaneViewportsRef'
| 'activeSessionTabId'
| 'clientRef'
| 'connState'
| 'deviceTokenRef'
@@ -22,11 +20,6 @@ export type MobileSessionKeyboardScope = Pick<
| 'setShowCustomKeyModal'
| 'showNativeChatRef'
| 'subscribeToTerminal'
| 'tabLayoutsRef'
| 'tabStripContentWidthRef'
| 'tabStripOffsetRef'
| 'tabStripRef'
| 'tabStripViewportWidthRef'
| 'terminalFrameRef'
| 'terminalRefs'
| 'terminals'
@@ -45,12 +38,6 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope)
connState,
terminals,
terminalTextScale,
activeSessionTabId,
tabStripRef,
tabStripOffsetRef,
tabStripViewportWidthRef,
tabStripContentWidthRef,
tabLayoutsRef,
setCustomKeys,
setShowCustomKeyModal,
deviceTokenRef,
@@ -98,55 +85,6 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope)
)
}, [notifyKeyboardVisibility])
const heldTabStripRevealRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const cancelHeldTabStripReveal = useCallback(() => {
if (heldTabStripRevealRef.current) {
clearTimeout(heldTabStripRevealRef.current)
heldTabStripRevealRef.current = null
}
}, [])
useEffect(() => cancelHeldTabStripReveal, [cancelHeldTabStripReveal])
// Hold strip movement through double-tap detection so its second tap reaches the same tab.
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 || heldTabStripRevealRef.current) {
return
}
const layout = tabLayoutsRef.current.get(tabId)
if (!layout) {
return
}
const nextOffset = resolveTabStripScrollOffset({
tabX: layout.x,
tabWidth: layout.width,
viewportWidth: tabStripViewportWidthRef.current,
contentWidth: tabStripContentWidthRef.current,
currentOffset: tabStripOffsetRef.current
})
if (nextOffset !== tabStripOffsetRef.current) {
tabStripOffsetRef.current = nextOffset
tabStripRef.current?.scrollTo({ x: nextOffset, animated })
}
}, [])
useEffect(() => {
const id = requestAnimationFrame(() => scrollActiveTabIntoView(activeSessionTabId, true))
return () => cancelAnimationFrame(id)
}, [activeSessionTabId, scrollActiveTabIntoView])
const executionHost = useWorkspaceExecutionHost()
useEffect(() => {
if (hostId && worktreeId) {
@@ -171,9 +109,6 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope)
notifyTerminalFrameWidth,
notifyKeyboardVisibility,
notifyTerminalCellBoxChange,
scrollActiveTabIntoView,
holdTabStripReveal,
cancelHeldTabStripReveal,
handleDeleteCustomKey,
handleManageShortcuts
}
+1 -1
View File
@@ -88,7 +88,7 @@ export function agentDisplayLabel(row: RuntimeWorktreeAgentRow, now: number): st
// Mirror desktop lastEnteredDoneAt: date main-agent failures independently while subagents remain active.
export function agentRowTimeAt(
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'mainAgent' | 'stateStartedAt'>
row: AgentRowVerdictSource & Pick<RuntimeWorktreeAgentRow, 'stateStartedAt'>
): number {
if (row.state !== 'done' && row.mainAgent && agentVerdictDisplayMark(row) === 'failed') {
return row.mainAgent.stateStartedAt
@@ -14,6 +14,7 @@ export const sessionWorktreeRecordSchema = z
repo: salvagedOptional('repo', z.string().nullable()),
path: salvagedOptional('path', z.string()),
hostId: salvagedOptional('hostId', z.string()),
branch: salvagedOptional('branch', z.string().nullable()),
diffComments: z.unknown().optional()
})
)
@@ -22,6 +23,11 @@ export const sessionWorktreeRecordSchema = z
(
reply
):
| (WorktreeDisplayNameSource & { path?: string; hostId?: string; diffComments?: unknown })
| (WorktreeDisplayNameSource & {
path?: string
hostId?: string
branch?: string | null
diffComments?: unknown
})
| undefined => reply.worktree
)
+16 -3
View File
@@ -30,6 +30,8 @@ export type LiveWorktreeName = {
/** Where the worktree lives on its host; null until the host's record arrives. */
path: string | null
hostId: string | null
/** The checked-out branch without refs/heads/; null for folders, detached heads and until the record arrives. */
branch: string | null
resolution: WorktreeShowResolution
}
@@ -48,6 +50,7 @@ export function useLiveWorktreeName({
name: string
path?: string | null
hostId?: string | null
branch?: string | null
}>(() => ({ worktreeId, name: routeNameHint }))
// Key verdicts by route so a previous workspace’s deletion cannot escape into the new one.
const [resolved, setResolved] = useState<{
@@ -125,13 +128,16 @@ export function useLiveWorktreeName({
if (liveName) {
const path = worktree?.path || null
const hostId = worktree?.hostId ?? null
// Match desktop formatBranchName by hiding the refs/heads/ prefix.
const branch = worktree?.branch?.replace(/^refs\/heads\//, '') || null
setWorktreeName((current) =>
current.worktreeId === worktreeId &&
current.name === liveName &&
current.path === path &&
current.hostId === hostId
current.hostId === hostId &&
current.branch === branch
? current
: { worktreeId, name: liveName, path, hostId }
: { worktreeId, name: liveName, path, hostId, branch }
)
}
hasSuccessfulRefresh = true
@@ -197,12 +203,19 @@ export function useLiveWorktreeName({
)
if (isFloatingWorkspace) {
return { name: getFloatingWorkspaceTitle(), path: null, hostId: 'local', resolution: 'unknown' }
return {
name: getFloatingWorkspaceTitle(),
path: null,
hostId: 'local',
branch: null,
resolution: 'unknown'
}
}
return {
name: worktreeName.worktreeId === worktreeId ? worktreeName.name : routeNameHint,
path: worktreeName.worktreeId === worktreeId ? (worktreeName.path ?? null) : null,
hostId: worktreeName.worktreeId === worktreeId ? (worktreeName.hostId ?? null) : null,
branch: worktreeName.worktreeId === worktreeId ? (worktreeName.branch ?? null) : null,
resolution: resolved.worktreeId === worktreeId ? resolved.resolution : 'unknown'
}
}