diff --git a/mobile/src/components/MobileLoadErrorState.tsx b/mobile/src/components/MobileLoadErrorState.tsx
new file mode 100644
index 00000000000..79ce0b24ea2
--- /dev/null
+++ b/mobile/src/components/MobileLoadErrorState.tsx
@@ -0,0 +1,51 @@
+import { Pressable, StyleSheet, Text, View } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+/** A failed load: the message with its Retry button centered under it as one group. */
+export function MobileLoadErrorState({
+ message,
+ onRetry
+}: {
+ message: string
+ onRetry?: (() => void) | null
+}) {
+ return (
+
+ {message}
+ {onRetry ? (
+
+ Retry
+
+ ) : null}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ state: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: spacing.md,
+ padding: spacing.xl
+ },
+ errorText: {
+ color: colors.statusRed,
+ fontSize: typography.bodySize,
+ textAlign: 'center'
+ },
+ retryButton: {
+ minHeight: 36,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radii.button,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.borderSubtle,
+ paddingHorizontal: spacing.lg
+ },
+ retryText: {
+ color: colors.textPrimary,
+ fontSize: typography.bodySize,
+ fontWeight: '600'
+ }
+})
diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx
index 28edfb94f71..0fe234abdb6 100644
--- a/mobile/src/files/MobileFileExplorerPanel.tsx
+++ b/mobile/src/files/MobileFileExplorerPanel.tsx
@@ -34,6 +34,7 @@ import { fileDirectoryRead, legacyFileListRead } from './mobile-file-explorer-op
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
import { MobileFileExplorerRow } from './mobile-file-explorer-row'
import { navigateToMobileFilePreview } from './mobile-file-preview-navigation'
+import { MobileLoadErrorState } from '../components/MobileLoadErrorState'
export function MobileFileExplorerPanel(props: {
hostId: string
@@ -337,14 +338,7 @@ export function MobileFileExplorerPanel(props: {
) : error ? (
-
- {error}
- {rootRetry ? (
-
- Retry
-
- ) : null}
-
+
) : rows.length === 0 ? (
No files found
diff --git a/mobile/src/files/MobileFileMediaPreview.tsx b/mobile/src/files/MobileFileMediaPreview.tsx
index a648127d256..e2ab44eeac5 100644
--- a/mobile/src/files/MobileFileMediaPreview.tsx
+++ b/mobile/src/files/MobileFileMediaPreview.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
-import { ActivityIndicator, Pressable, Text, View } from 'react-native'
+import { ActivityIndicator, Text, View } from 'react-native'
import { colors } from '../theme/mobile-theme'
import type { MobileFilePreviewRpcSender } from './mobile-file-preview-operations'
import type { MobileFileMedia } from './mobile-file-media'
@@ -8,6 +8,7 @@ import { createMobileMediaSink } from './mobile-media-preview-cache'
import { MobileMediaPlayback } from './MobileMediaPlayback'
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
import { formatPreviewByteLength } from './mobile-file-preview-response'
+import { MobileLoadErrorState } from '../components/MobileLoadErrorState'
type State = { uri: string } | { error: string } | { bytes: number; total: number }
type Props = { media: MobileFileMedia; client: MobileFilePreviewRpcSender | null; title: string }
@@ -62,18 +63,7 @@ function MediaDownload({ media, client, title }: Props) {
return
}
if ('error' in state) {
- return (
-
- {state.error}
- setAttempt(attempt + 1)}
- accessibilityRole="button"
- >
- Retry
-
-
- )
+ return setAttempt(attempt + 1)} />
}
return (
diff --git a/mobile/src/files/MobileFilePreviewBody.tsx b/mobile/src/files/MobileFilePreviewBody.tsx
index bbfc51b4c95..c01a37cc4d9 100644
--- a/mobile/src/files/MobileFilePreviewBody.tsx
+++ b/mobile/src/files/MobileFilePreviewBody.tsx
@@ -1,6 +1,6 @@
import { MobileFileMediaPreview } from './MobileFileMediaPreview'
import type { MobileFilePreviewRpcSender } from './mobile-file-preview-operations'
-import { ActivityIndicator, Image, Pressable, ScrollView, Text, View } from 'react-native'
+import { ActivityIndicator, Image, ScrollView, Text, View } from 'react-native'
import { colors } from '../theme/mobile-theme'
import type { MobileFilePreviewResult } from './mobile-file-preview-request'
import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview'
@@ -8,6 +8,7 @@ import { MobileFilePreviewEditableSource } from './MobileFilePreviewEditableSour
import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText'
import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
+import { MobileLoadErrorState } from '../components/MobileLoadErrorState'
type Props = {
preview: MobileFilePreviewResult
@@ -36,16 +37,7 @@ export function MobileFilePreviewBody({ preview, ...options }: Props) {
)
}
if (preview.status === 'error' || preview.status === 'waiting') {
- return (
-
- {preview.message}
- {options.onRetry ? (
-
- Retry
-
- ) : null}
-
- )
+ return
}
if (preview.status === 'empty') {
return options.editable ? (
diff --git a/mobile/src/files/mobile-file-explorer-styles.ts b/mobile/src/files/mobile-file-explorer-styles.ts
index 5df9710be10..c284dd10155 100644
--- a/mobile/src/files/mobile-file-explorer-styles.ts
+++ b/mobile/src/files/mobile-file-explorer-styles.ts
@@ -119,24 +119,5 @@ export const fileExplorerStyles = StyleSheet.create({
emptyText: {
color: colors.textSecondary,
fontSize: typography.bodySize
- },
- errorText: {
- color: colors.statusRed,
- fontSize: typography.bodySize,
- textAlign: 'center'
- },
- retryButton: {
- minHeight: 36,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button,
- borderWidth: StyleSheet.hairlineWidth,
- borderColor: colors.borderSubtle,
- paddingHorizontal: spacing.lg
- },
- retryText: {
- color: colors.textPrimary,
- fontSize: typography.bodySize,
- fontWeight: '600'
}
})
diff --git a/mobile/src/files/mobile-file-preview-styles.ts b/mobile/src/files/mobile-file-preview-styles.ts
index 5b0df61caa5..40dc5a5c3c8 100644
--- a/mobile/src/files/mobile-file-preview-styles.ts
+++ b/mobile/src/files/mobile-file-preview-styles.ts
@@ -59,20 +59,6 @@ export const filePreviewStyles = StyleSheet.create({
fontSize: typography.bodySize,
textAlign: 'center'
},
- retryButton: {
- minHeight: 36,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button,
- borderWidth: StyleSheet.hairlineWidth,
- borderColor: colors.borderSubtle,
- paddingHorizontal: spacing.lg
- },
- retryText: {
- color: colors.textPrimary,
- fontSize: typography.bodySize,
- fontWeight: '600'
- },
saveButton: {
width: 36,
height: 36,
diff --git a/mobile/src/session/MobileMarkdownReader.tsx b/mobile/src/session/MobileMarkdownReader.tsx
index a84599ded3a..07ade79af60 100644
--- a/mobile/src/session/MobileMarkdownReader.tsx
+++ b/mobile/src/session/MobileMarkdownReader.tsx
@@ -12,6 +12,7 @@ import {
} from './markdown-floating-actions-layout'
import type { MarkdownDocState } from './mobile-session-route-types'
import { styles } from './mobile-session-styles'
+import { MobileLoadErrorState } from '../components/MobileLoadErrorState'
type Props = {
documentId: string
@@ -48,15 +49,7 @@ export function MobileMarkdownReader({
)
}
if (doc.status === 'error') {
- return (
-
- {doc.message}
-
-
- Retry
-
-
- )
+ return
}
const statusText = doc.saveError
diff --git a/mobile/src/session/MobileSessionFileReader.tsx b/mobile/src/session/MobileSessionFileReader.tsx
index 43add744377..4c764eafbd1 100644
--- a/mobile/src/session/MobileSessionFileReader.tsx
+++ b/mobile/src/session/MobileSessionFileReader.tsx
@@ -33,6 +33,7 @@ import type {
} from './mobile-session-route-types'
import { DiffLineRow } from './MobileSessionDiffLineRow'
import { sendableMobileDiffComments } from './mobile-diff-comments'
+import { MobileLoadErrorState } from '../components/MobileLoadErrorState'
export function FileReader({
doc,
@@ -181,11 +182,7 @@ export function FileReader({
)
}
if (doc.status === 'error') {
- return (
-
- {doc.message}
-
- )
+ return
}
if (doc.kind === 'diff') {
diff --git a/mobile/src/session/MobileSessionMarkdownReader.test.tsx b/mobile/src/session/MobileSessionMarkdownReader.test.tsx
index 9903c5b7cc4..fc297fe9d22 100644
--- a/mobile/src/session/MobileSessionMarkdownReader.test.tsx
+++ b/mobile/src/session/MobileSessionMarkdownReader.test.tsx
@@ -8,7 +8,8 @@ vi.mock('react-native', () => ({
View: 'View',
Text: 'Text',
Pressable: 'Pressable',
- ActivityIndicator: 'ActivityIndicator'
+ ActivityIndicator: 'ActivityIndicator',
+ StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }
}))
vi.mock('lucide-react-native', () => ({ RefreshCw: 'RefreshCw' }))
vi.mock('../components/MobileRichMarkdownEditor', () => ({
diff --git a/mobile/src/session/MobileSessionMarkdownReader.tsx b/mobile/src/session/MobileSessionMarkdownReader.tsx
index 82f20e7beb1..630af05b40e 100644
--- a/mobile/src/session/MobileSessionMarkdownReader.tsx
+++ b/mobile/src/session/MobileSessionMarkdownReader.tsx
@@ -7,6 +7,7 @@ import { markdownReaderStatusText } from './mobile-markdown-reader-status'
import { colors, spacing } from '../theme/mobile-theme'
import { styles } from './mobile-session-styles'
import type { MarkdownDocState } from './mobile-session-route-types'
+import { MobileLoadErrorState } from '../components/MobileLoadErrorState'
export function MarkdownReader({
documentId,
@@ -38,15 +39,7 @@ export function MarkdownReader({
)
}
if (doc.status === 'error') {
- return (
-
- {doc.message}
-
-
- Retry
-
-
- )
+ return
}
const statusText = markdownReaderStatusText(doc)
diff --git a/mobile/src/session/mobile-session-review-comment-styles.ts b/mobile/src/session/mobile-session-review-comment-styles.ts
index a3b80615446..bce974aa48e 100644
--- a/mobile/src/session/mobile-session-review-comment-styles.ts
+++ b/mobile/src/session/mobile-session-review-comment-styles.ts
@@ -100,26 +100,9 @@ export const mobileSessionReviewCommentStyles = StyleSheet.create({
fontSize: typography.metaSize,
fontWeight: '700'
},
- markdownRefreshButton: {
- alignSelf: 'flex-start',
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs,
- backgroundColor: colors.bgRaised,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- borderRadius: radii.button,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.xs
- },
markdownButtonDisabled: {
opacity: 0.45
},
- markdownRefreshText: {
- color: colors.textPrimary,
- fontSize: 13,
- fontWeight: '600'
- },
markdownFloatingBar: {
position: 'absolute',
left: spacing.md,
diff --git a/mobile/src/session/session-markdown-source-copy.test.tsx b/mobile/src/session/session-markdown-source-copy.test.tsx
index a5fea94e998..1b6b6da58db 100644
--- a/mobile/src/session/session-markdown-source-copy.test.tsx
+++ b/mobile/src/session/session-markdown-source-copy.test.tsx
@@ -19,6 +19,7 @@ vi.mock('react-native', () => ({
Text: 'Text',
Pressable: 'Pressable',
ActivityIndicator: 'ActivityIndicator',
+ StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 },
Platform: { OS: 'ios' },
Keyboard: { dismiss: vi.fn() }
}))
diff --git a/src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts b/src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts
index f9aa5340123..7eec2945195 100644
--- a/src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts
+++ b/src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts
@@ -15,10 +15,6 @@ import {
setupWindow
} from './mobile-markdown-bridge-test-harness'
-vi.mock('@/components/tab-bar/group-tab-order', () => ({
- getActiveTabNavOrder: () => [{ type: 'editor', id: '/repo/README.md', tabId: 'tab-md' }]
-}))
-
vi.mock('@/lib/connection-context', () => ({
getConnectionIdForFile: () => null
}))
diff --git a/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts b/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts
index 1d7b41f9105..994b6844df6 100644
--- a/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts
+++ b/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts
@@ -1,6 +1,7 @@
import { expect, vi } from 'vitest'
import type { RuntimeMobileMarkdownRequest } from '../../../shared/mobile-markdown-document'
import { useAppStore } from '../store'
+import type { Tab } from '../../../shared/tab-types'
type WindowStub = {
addEventListener: Window['addEventListener']
@@ -73,6 +74,25 @@ export function resetEditorState(): void {
}
]
} as never)
+ useAppStore.setState({
+ unifiedTabsByWorktree: { 'wt-1': [markdownUnifiedTab('tab-md', '/repo/README.md', 'group-1')] }
+ })
+}
+
+/** The desktop tab strip entry a phone names a markdown tab by. */
+export function markdownUnifiedTab(id: string, fileId: string, groupId: string): Tab {
+ return {
+ id,
+ entityId: fileId,
+ groupId,
+ worktreeId: 'wt-1',
+ contentType: 'editor',
+ label: fileId,
+ customLabel: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 0
+ }
}
export function openMarkdownFile(): void {
diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts
index 8597316794f..a374218eb2e 100644
--- a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts
+++ b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts
@@ -11,16 +11,13 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { attachMobileMarkdownBridge } from './mobile-markdown-bridge'
import {
cleanupMobileMarkdownBridgeHarness,
+ markdownUnifiedTab,
openMarkdownFile,
resetEditorState,
sendRequest,
setupWindow
} from './mobile-markdown-bridge-test-harness'
-vi.mock('@/components/tab-bar/group-tab-order', () => ({
- getActiveTabNavOrder: () => [{ type: 'editor', id: '/repo/README.md', tabId: 'tab-md' }]
-}))
-
vi.mock('@/lib/connection-context', () => ({
getConnectionIdForFile: () => null
}))
@@ -114,6 +111,72 @@ describe('mobile markdown bridge', () => {
}
})
+ it('reads a markdown tab that sits in a split group the desktop is not focused on', async () => {
+ openMarkdownFile()
+ useAppStore.setState({
+ unifiedTabsByWorktree: {
+ 'wt-1': [markdownUnifiedTab('tab-md', '/repo/README.md', 'group-split')]
+ },
+ groupsByWorktree: {
+ 'wt-1': [
+ { id: 'group-main', worktreeId: 'wt-1', activeTabId: null, tabOrder: [] },
+ {
+ id: 'group-split',
+ worktreeId: 'wt-1',
+ activeTabId: 'tab-md',
+ tabOrder: ['tab-md']
+ }
+ ]
+ },
+ activeGroupIdByWorktree: { 'wt-1': 'group-main' }
+ })
+ setupWindow({ readFile: vi.fn().mockResolvedValue({ content: '# split', isBinary: false }) })
+ const detach = attachMobileMarkdownBridge()
+
+ try {
+ const response = await sendRequest({
+ id: 'read-split',
+ operation: 'read',
+ worktreeId: 'wt-1',
+ tabId: 'tab-md'
+ })
+
+ expect(response).toMatchObject({ ok: true, result: { content: '# split' } })
+ } finally {
+ detach()
+ }
+ })
+
+ it('reads the requested tab when the same file is open in two groups', async () => {
+ openMarkdownFile()
+ useAppStore.setState({
+ unifiedTabsByWorktree: {
+ 'wt-1': [
+ markdownUnifiedTab('tab-main', '/repo/README.md', 'group-main'),
+ markdownUnifiedTab('tab-split', '/repo/README.md', 'group-split')
+ ]
+ }
+ })
+ setupWindow({ readFile: vi.fn().mockResolvedValue({ content: '# both', isBinary: false }) })
+ const detach = attachMobileMarkdownBridge()
+
+ try {
+ const response = await sendRequest({
+ id: 'read-duplicate',
+ operation: 'read',
+ worktreeId: 'wt-1',
+ tabId: 'tab-split'
+ })
+
+ expect(response).toMatchObject({
+ ok: true,
+ result: { tabId: 'tab-split', filePath: '/repo/README.md', content: '# both' }
+ })
+ } finally {
+ detach()
+ }
+ })
+
it('rejects save when a clean file changed after mobile read', async () => {
openMarkdownFile()
const writeFile = vi.fn().mockResolvedValue(undefined)
diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts
index ccfb7029663..5dd45942342 100644
--- a/src/renderer/src/runtime/mobile-markdown-bridge.ts
+++ b/src/renderer/src/runtime/mobile-markdown-bridge.ts
@@ -1,4 +1,3 @@
-import { getActiveTabNavOrder } from '@/components/tab-bar/group-tab-order'
import {
ORCA_EDITOR_FILE_SAVED_EVENT,
requestEditorFileSave,
@@ -188,13 +187,13 @@ function resolveMarkdownTarget(
tabId: string
): { tab: OpenFile; sourceFile: OpenFile } {
const state = useAppStore.getState()
- const orderItem = getActiveTabNavOrder(state, worktreeId).find(
- (item) => item.type === 'editor' && (item.tabId === tabId || item.id === tabId)
- )
- const tabFileId = orderItem?.type === 'editor' ? orderItem.id : tabId
- const tab = state.openFiles.find(
- (file) => file.worktreeId === worktreeId && (file.id === tabFileId || file.id === tabId)
+ // Why every group: the phone lists tabs from all split groups, not just the desktop's focused one.
+ const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find(
+ (candidate) => candidate.id === tabId && candidate.contentType === 'editor'
)
+ // Hosts publish the file id when a file has no unified tab.
+ const fileId = unifiedTab?.entityId ?? tabId
+ const tab = state.openFiles.find((file) => file.worktreeId === worktreeId && file.id === fileId)
if (!tab || !isMarkdownTab(tab)) {
throw new Error('tab_not_found')
}