mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2450,8 +2450,7 @@
|
||||
"tabStrip": {
|
||||
"checkingCapabilities": "デスクトップの対応機能を確認中です。少し待ってから再試行してください。",
|
||||
"newTab": "新規タブ",
|
||||
"quickCommandsUpdateRequired": "クイックコマンドの利用にはデスクトップの更新が必要です。",
|
||||
"tabStatus": "{{title}}、{{status}}"
|
||||
"quickCommandsUpdateRequired": "クイックコマンドの利用にはデスクトップの更新が必要です。"
|
||||
},
|
||||
"terminalActionSheetActions": {
|
||||
"clearTerminal": "ターミナルをクリア",
|
||||
|
||||
@@ -2450,8 +2450,7 @@
|
||||
"tabStrip": {
|
||||
"checkingCapabilities": "데스크톱에서 지원하는 기능을 확인 중입니다. 잠시 후 다시 시도하세요.",
|
||||
"newTab": "새 탭",
|
||||
"quickCommandsUpdateRequired": "빠른 명령을 사용하려면 데스크톱 업데이트가 필요합니다.",
|
||||
"tabStatus": "{{title}}, {{status}}"
|
||||
"quickCommandsUpdateRequired": "빠른 명령을 사용하려면 데스크톱 업데이트가 필요합니다."
|
||||
},
|
||||
"terminalActionSheetActions": {
|
||||
"clearTerminal": "터미널 지우기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2594,8 +2594,7 @@
|
||||
"tabStrip": {
|
||||
"checkingCapabilities": "Проверяем возможности Orca на компьютере — повторите попытку чуть позже",
|
||||
"newTab": "Новая вкладка",
|
||||
"quickCommandsUpdateRequired": "Для быстрых команд обновите Orca на компьютере",
|
||||
"tabStatus": "{{title}}, {{status}}"
|
||||
"quickCommandsUpdateRequired": "Для быстрых команд обновите Orca на компьютере"
|
||||
},
|
||||
"terminalActionSheetActions": {
|
||||
"clearTerminal": "Очистить терминал",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2450,8 +2450,7 @@
|
||||
"tabStrip": {
|
||||
"checkingCapabilities": "正在檢查桌面版支援的功能,請稍後再試",
|
||||
"newTab": "新分頁",
|
||||
"quickCommandsUpdateRequired": "需更新桌面版才能使用快速命令",
|
||||
"tabStatus": "{{title}},{{status}}"
|
||||
"quickCommandsUpdateRequired": "需更新桌面版才能使用快速命令"
|
||||
},
|
||||
"terminalActionSheetActions": {
|
||||
"clearTerminal": "清除終端機",
|
||||
|
||||
@@ -2450,8 +2450,7 @@
|
||||
"tabStrip": {
|
||||
"checkingCapabilities": "正在检查桌面支持的功能,请稍后重试",
|
||||
"newTab": "新标签页",
|
||||
"quickCommandsUpdateRequired": "使用快捷命令需更新桌面应用",
|
||||
"tabStatus": "{{title}},{{status}}"
|
||||
"quickCommandsUpdateRequired": "使用快捷命令需更新桌面应用"
|
||||
},
|
||||
"terminalActionSheetActions": {
|
||||
"clearTerminal": "清空终端",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user