diff --git a/mobile/src/agents/agent-list-row-text.ts b/mobile/src/agents/agent-list-row-text.ts index c3e9d8ef8b7..194a77e7616 100644 --- a/mobile/src/agents/agent-list-row-text.ts +++ b/mobile/src/agents/agent-list-row-text.ts @@ -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 & { + 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 { diff --git a/mobile/src/components/SwipeRowActions.tsx b/mobile/src/components/SwipeRowActions.tsx index 704077f636d..b070de4ff38 100644 --- a/mobile/src/components/SwipeRowActions.tsx +++ b/mobile/src/components/SwipeRowActions.tsx @@ -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 ( - {leading ? renderAction(leading, 'leading') : null} - {trailing ? renderAction(trailing, 'trailing') : null} + + {leading ? renderAction(leading, 'leading') : null} + {trailing ? renderAction(trailing, 'trailing') : null} + {/* pan-y keeps the page scrolling vertically over the row on the web. */} {children(guardPress)} @@ -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 }, diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 4ace38e5fa1..c8c64c2cf93 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -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", diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index dd1db03f89e..5100a3cb94a 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -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": { diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index f994d2c966b..78f372f2264 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -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", diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json index 966264702ec..20d2d3b5828 100644 --- a/mobile/src/i18n/locales/fr.json +++ b/mobile/src/i18n/locales/fr.json @@ -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", diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index 39364412640..c6ee2e0fa4f 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -2450,8 +2450,7 @@ "tabStrip": { "checkingCapabilities": "デスクトップの対応機能を確認中です。少し待ってから再試行してください。", "newTab": "新規タブ", - "quickCommandsUpdateRequired": "クイックコマンドの利用にはデスクトップの更新が必要です。", - "tabStatus": "{{title}}、{{status}}" + "quickCommandsUpdateRequired": "クイックコマンドの利用にはデスクトップの更新が必要です。" }, "terminalActionSheetActions": { "clearTerminal": "ターミナルをクリア", diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index c8304776585..2b51f3ce5ae 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -2450,8 +2450,7 @@ "tabStrip": { "checkingCapabilities": "데스크톱에서 지원하는 기능을 확인 중입니다. 잠시 후 다시 시도하세요.", "newTab": "새 탭", - "quickCommandsUpdateRequired": "빠른 명령을 사용하려면 데스크톱 업데이트가 필요합니다.", - "tabStatus": "{{title}}, {{status}}" + "quickCommandsUpdateRequired": "빠른 명령을 사용하려면 데스크톱 업데이트가 필요합니다." }, "terminalActionSheetActions": { "clearTerminal": "터미널 지우기", diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json index c065a24a76f..0d4a2150210 100644 --- a/mobile/src/i18n/locales/pt.json +++ b/mobile/src/i18n/locales/pt.json @@ -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", diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json index 1333beff214..cec7d8ced57 100644 --- a/mobile/src/i18n/locales/ru.json +++ b/mobile/src/i18n/locales/ru.json @@ -2594,8 +2594,7 @@ "tabStrip": { "checkingCapabilities": "Проверяем возможности Orca на компьютере — повторите попытку чуть позже", "newTab": "Новая вкладка", - "quickCommandsUpdateRequired": "Для быстрых команд обновите Orca на компьютере", - "tabStatus": "{{title}}, {{status}}" + "quickCommandsUpdateRequired": "Для быстрых команд обновите Orca на компьютере" }, "terminalActionSheetActions": { "clearTerminal": "Очистить терминал", diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json index 8c58ec1fe2e..81be23db055 100644 --- a/mobile/src/i18n/locales/tr.json +++ b/mobile/src/i18n/locales/tr.json @@ -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", diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json index 3e4a1c25efc..535fb5d378a 100644 --- a/mobile/src/i18n/locales/zh-Hant.json +++ b/mobile/src/i18n/locales/zh-Hant.json @@ -2450,8 +2450,7 @@ "tabStrip": { "checkingCapabilities": "正在檢查桌面版支援的功能,請稍後再試", "newTab": "新分頁", - "quickCommandsUpdateRequired": "需更新桌面版才能使用快速命令", - "tabStatus": "{{title}},{{status}}" + "quickCommandsUpdateRequired": "需更新桌面版才能使用快速命令" }, "terminalActionSheetActions": { "clearTerminal": "清除終端機", diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index 820e95b80a3..f845b0e146d 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -2450,8 +2450,7 @@ "tabStrip": { "checkingCapabilities": "正在检查桌面支持的功能,请稍后重试", "newTab": "新标签页", - "quickCommandsUpdateRequired": "使用快捷命令需更新桌面应用", - "tabStatus": "{{title}},{{status}}" + "quickCommandsUpdateRequired": "使用快捷命令需更新桌面应用" }, "terminalActionSheetActions": { "clearTerminal": "清空终端", diff --git a/mobile/src/session/MobileSessionHeader.tsx b/mobile/src/session/MobileSessionHeader.tsx index c7593258e42..9020e7d7486 100644 --- a/mobile/src/session/MobileSessionHeader.tsx +++ b/mobile/src/session/MobileSessionHeader.tsx @@ -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 ? ( - - { - 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()} /> ({ 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 }, diff --git a/mobile/src/session/tabs/MobileNewTabSheet.tsx b/mobile/src/session/tabs/MobileNewTabSheet.tsx new file mode 100644 index 00000000000..0f7730ddd75 --- /dev/null +++ b/mobile/src/session/tabs/MobileNewTabSheet.tsx @@ -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 ( + { + const run = afterCloseRef.current + afterCloseRef.current = null + run?.() + }} + dragContentToDismiss + > + + {showAgentGrid || agentStatusRow ? ( + + ) : null} + {agentStatusRow ? : null} + {showAgentGrid ? ( + + {agentRows.map((row) => ( + option.agent).join(':')} style={styles.gridRow}> + {row.options.map((option) => ( + closeAndRun(() => onCreateAgent(option.agent))} + accessibilityRole="button" + accessibilityLabel={option.label} + style={styles.tile} + > + {({ pressed }) => ( + <> + {pressed ? : null} + + + {option.label} + + + )} + + ))} + {row.emptySlots.map((slot) => ( + + ))} + + ))} + + ) : null} + + } + onPress={() => closeAndRun(onCreateTerminal)} + /> + {canCreateBrowser ? ( + } + navigates + onPress={() => closeThen(onCreateBrowser)} + /> + ) : null} + {canCreateMarkdownNote ? ( + } + onPress={() => closeAndRun(onCreateMarkdownNote)} + /> + ) : null} + + + ) +} + +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 } +})) diff --git a/mobile/src/session/tabs/MobileSessionTabBar.tsx b/mobile/src/session/tabs/MobileSessionTabBar.tsx new file mode 100644 index 00000000000..a9aeb494c8e --- /dev/null +++ b/mobile/src/session/tabs/MobileSessionTabBar.tsx @@ -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 + } + return state && state !== 'idle' ? : 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 ( + + + + ) +} + +/** 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 ( + + {/* pan-y keeps vertical touches with the page on the web. */} + + { + width.value = event.nativeEvent.layout.width + }} + > + [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. */} + + {active ? ( + <> + + + + + + ) : null} + + {active?.title ?? countLabel} + + + + + + [ + styles.sideCell, + actionsCollapsed && !inTopBar && styles.lastCell, + pressed && styles.cellPressed + ]} + onPress={() => setSheetOpen(true)} + accessibilityRole="button" + accessibilityLabel={countAccessibilityLabel} + accessibilityHint={t('session.tabBar.hint', 'Shows all tabs')} + > + + {rows.length} + + {othersNeedYou ? : null} + + {/* A collapsed bar's + lives in the header's ⋯ menu. */} + {!actionsCollapsed ? ( + [ + 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 }} + > + + + ) : null} + setSheetOpen(false)} + /> + + ) +} + +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 + } +})) diff --git a/mobile/src/session/tabs/MobileSessionTabMark.tsx b/mobile/src/session/tabs/MobileSessionTabMark.tsx new file mode 100644 index 00000000000..67a1d0b3895 --- /dev/null +++ b/mobile/src/session/tabs/MobileSessionTabMark.tsx @@ -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 + size: number +}) { + useMobileColorScheme() + if (row.agentId) { + return + } + const Icon = + row.tab.type === 'browser' + ? Globe + : row.tab.type === 'markdown' + ? FileText + : row.tab.type === 'file' + ? File + : SquareTerminal + return +} diff --git a/mobile/src/session/tabs/MobileSessionTabStrip.tsx b/mobile/src/session/tabs/MobileSessionTabStrip.tsx deleted file mode 100644 index 6455c9bf6f8..00000000000 --- a/mobile/src/session/tabs/MobileSessionTabStrip.tsx +++ /dev/null @@ -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 - } - return state && state !== 'idle' ? : 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 ( - - - - ) -} - -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() - for (const tab of visibleTabs) { - // Why: a status restored across a host restart is unconfirmed, so like desktop it shows no live state. - if (tab.type === 'terminal' && tab.agentStatus && !tab.agentStatus.restoredUnconfirmed) { - states.set(tab.id, agentDotState(tab.agentStatus, now)) - } else if (tab.type === 'agent-session' && tab.chatStatus) { - states.set(tab.id, agentDotState(tab.chatStatus, now)) - } - } - return states - }, [visibleTabs, now]) - const [hiddenTabs, setHiddenTabs] = useState({ left: [], right: [] }) - const refreshHiddenTabs = () => { - const next = resolveTabStripHiddenTabs({ - layouts: tabLayoutsRef.current, - viewportWidth: tabStripViewportWidthRef.current, - currentOffset: tabStripOffsetRef.current - }) - // Why: scroll fires every frame; re-render only when a tab crosses an edge. - setHiddenTabs((prev) => - prev.left.join('\n') === next.left.join('\n') && - prev.right.join('\n') === next.right.join('\n') - ? prev - : next - ) - } - const leftHint = resolveTabStripEdgeHint(hiddenTabs.left, tabStates) - const rightHint = resolveTabStripEdgeHint(hiddenTabs.right, tabStates) - const pressTab = useCallback( - (t: (typeof visibleTabs)[number]) => { - const at = Date.now() - const lastTap = lastTabTapRef.current - lastTabTapRef.current = { id: t.id, at } - if ( - lastTap?.id === t.id && - at - lastTap.at < DOUBLE_TAP_RENAME_MS && - t.type === 'terminal' && - typeof t.terminal === 'string' - ) { - lastTabTapRef.current = null - triggerMediumImpact() - setRenameTarget({ - handle: t.terminal, - title: t.title, - isActive: t.terminal === activeHandleRef.current - }) - // The first tap's held reveal still runs once the window closes. - return - } - cancelHeldTabStripReveal() - const nextOffset = resolveTabStripTapOffset({ - tabId: t.id, - tabs: visibleTabs, - layouts: tabLayoutsRef.current, - viewportWidth: tabStripViewportWidthRef.current, - contentWidth: tabStripContentWidthRef.current, - currentOffset: tabStripOffsetRef.current - }) - if (nextOffset !== tabStripOffsetRef.current) { - holdTabStripReveal(nextOffset, DOUBLE_TAP_RENAME_MS) - } - switchSessionTab(t) - }, - [ - activeHandleRef, - cancelHeldTabStripReveal, - holdTabStripReveal, - setRenameTarget, - switchSessionTab, - tabLayoutsRef, - tabStripContentWidthRef, - tabStripOffsetRef, - tabStripRef, - tabStripViewportWidthRef, - visibleTabs - ] - ) - const attachTabStrip = useCallback( - (view: ScrollView | null) => { - if (view) { - tabStripOffsetRef.current = 0 - tabStripViewportWidthRef.current = 0 - tabStripContentWidthRef.current = 0 - tabLayoutsRef.current.clear() - } - tabStripRef.current = view - }, - [ - tabStripRef, - tabStripOffsetRef, - tabStripViewportWidthRef, - tabStripContentWidthRef, - tabLayoutsRef - ] - ) - return ( - - {/* Why: tab taps must register on first press with the keyboard open instead of being eaten by dismissal (#5106). */} - - { - tabStripOffsetRef.current = e.nativeEvent.contentOffset.x - 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 ( - [ - 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} - > - - - - {tab.type === 'browser' && ( - - )} - {tab.type === 'markdown' && ( - - )} - {tab.type === 'file' && ( - - )} - {tab.type === 'agent-session' && ( - - )} - {tab.type === 'terminal' && - (() => { - const agentId = resolveMobileTerminalTabAgentId(tab, hostAgentIdentity) - return agentId ? ( - - ) : ( - - ) - })()} - - - {title} - - - - ) - })} - - {leftHint ? ( - - - - ) : null} - {rightHint ? ( - - - - ) : null} - - {/* Terminal layout and Quick commands live in the header's ⋯ menu. */} - {!actionsCollapsed ? ( - - - - ) : null} - - ) -} - -// 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 - } -}) diff --git a/mobile/src/session/tabs/MobileSessionTabsSheet.tsx b/mobile/src/session/tabs/MobileSessionTabsSheet.tsx new file mode 100644 index 00000000000..f8cd5cd1981 --- /dev/null +++ b/mobile/src/session/tabs/MobileSessionTabsSheet.tsx @@ -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>() + 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 = { + '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 ( + void handleCloseSessionTab(row.tab) + }} + > + {(guardPress) => ( + { + if (!current) { + switchSessionTab(row.tab) + } + onClose() + })} + > + {({ pressed }) => ( + <> + {current || pressed ? : null} + + + {badge ? ( + + + + ) : null} + + + + {row.title} + + + {row.detail} + + + {time ? ( + {time} + ) : null} + + )} + + )} + + ) + } + + return ( + { + setFrozenGroups(undefined) + const afterClose = afterCloseRef.current + afterCloseRef.current = null + afterClose?.() + }} + > + + + + { + // The new-tab sheet is its own drawer; open it once this one is gone. + afterCloseRef.current = handleOpenNewTab + onClose() + }} + /> + + + + {sections.map((section, index) => ( + + {sections.length > 1 ? ( + + ) : null} + + {section.rows.map(renderRow)} + + + ))} + + + ) +} + +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 } +})) diff --git a/mobile/src/session/tabs/mobile-session-tab-rows.ts b/mobile/src/session/tabs/mobile-session-tab-rows.ts new file mode 100644 index 00000000000..1b2854512f6 --- /dev/null +++ b/mobile/src/session/tabs/mobile-session-tab-rows.ts @@ -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 + } +} diff --git a/mobile/src/session/tabs/tab-strip-scroll.ts b/mobile/src/session/tabs/tab-strip-scroll.ts index 9b9dd9ce99b..e7d6c85e416 100644 --- a/mobile/src/session/tabs/tab-strip-scroll.ts +++ b/mobile/src/session/tabs/tab-strip-scroll.ts @@ -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 - -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> = { - blocked: 0, - waiting: 0, - working: 1, - monitoring: 1 -} - -export function resolveTabStripEdgeHint( - tabIds: readonly string[], - tabStates: ReadonlyMap -): AgentDotState | null { - let hint: AgentDotState | null = null - for (const tabId of tabIds) { - const state = tabStates.get(tabId) - const rank = state ? EDGE_HINT_RANK[state] : undefined - if (state && rank !== undefined && (hint === null || rank < (EDGE_HINT_RANK[hint] ?? 0))) { - hint = state - } - } - return hint -} diff --git a/mobile/src/session/use-mobile-session-foundation.ts b/mobile/src/session/use-mobile-session-foundation.ts index 6ecb5ae9870..cdc21bbabb0 100644 --- a/mobile/src/session/use-mobile-session-foundation.ts +++ b/mobile/src/session/use-mobile-session-foundation.ts @@ -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, diff --git a/mobile/src/session/use-mobile-session-panel-route-actions.tsx b/mobile/src/session/use-mobile-session-panel-route-actions.tsx index 6cd74e382eb..c4ae529e4a8 100644 --- a/mobile/src/session/use-mobile-session-panel-route-actions.tsx +++ b/mobile/src/session/use-mobile-session-panel-route-actions.tsx @@ -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: () => , - 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, diff --git a/mobile/src/session/use-mobile-session-presentation.ts b/mobile/src/session/use-mobile-session-presentation.ts index 374e1db46e8..8f4b3c23331 100644 --- a/mobile/src/session/use-mobile-session-presentation.ts +++ b/mobile/src/session/use-mobile-session-presentation.ts @@ -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) diff --git a/mobile/src/session/use-mobile-session-screen-state.ts b/mobile/src/session/use-mobile-session-screen-state.ts index bde653c4045..45fd4a9d886 100644 --- a/mobile/src/session/use-mobile-session-screen-state.ts +++ b/mobile/src/session/use-mobile-session-screen-state.ts @@ -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(null) // Preserve an explicit phone tab pick while a host snapshot is transiently incomplete. const selectedSessionTabIdRef = useRef(null) - const tabStripRef = useRef(null) - const tabStripOffsetRef = useRef(0) - const tabStripViewportWidthRef = useRef(0) - const tabStripContentWidthRef = useRef(0) - const tabLayoutsRef = useRef>(new Map()) const [markdownDocs, setMarkdownDocs] = useState>(new Map()) const markdownDocsRef = useRef>(new Map()) const [fileDocs, setFileDocs] = useState>(new Map()) @@ -134,11 +129,6 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) setActiveSessionTabId, activeSessionTabIdRef, selectedSessionTabIdRef, - tabStripRef, - tabStripOffsetRef, - tabStripViewportWidthRef, - tabStripContentWidthRef, - tabLayoutsRef, markdownDocs, setMarkdownDocs, markdownDocsRef, diff --git a/mobile/src/terminal/session/use-mobile-session-keyboard-state.ts b/mobile/src/terminal/session/use-mobile-session-keyboard-state.ts index ec532d01bf5..785f282bb4f 100644 --- a/mobile/src/terminal/session/use-mobile-session-keyboard-state.ts +++ b/mobile/src/terminal/session/use-mobile-session-keyboard-state.ts @@ -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 | 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 } diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index 5969f45afc0..5fc384ec91d 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -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 + row: AgentRowVerdictSource & Pick ): number { if (row.state !== 'done' && row.mainAgent && agentVerdictDisplayMark(row) === 'failed') { return row.mainAgent.stateStartedAt diff --git a/mobile/src/worktree/session-worktree-record-reply-schema.ts b/mobile/src/worktree/session-worktree-record-reply-schema.ts index c5b807e093c..6ac8c10d1b8 100644 --- a/mobile/src/worktree/session-worktree-record-reply-schema.ts +++ b/mobile/src/worktree/session-worktree-record-reply-schema.ts @@ -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 ) diff --git a/mobile/src/worktree/use-live-worktree-name.ts b/mobile/src/worktree/use-live-worktree-name.ts index 4501b3a1922..3a09dcb5650 100644 --- a/mobile/src/worktree/use-live-worktree-name.ts +++ b/mobile/src/worktree/use-live-worktree-name.ts @@ -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' } }