fix(mobile): truncate oversize markdown reads instead of failing them (#23676)

* fix(mobile): truncate oversize markdown reads instead of failing them

The desktop bridge refused markdown over a private 512 KiB cap with
file_too_large, which reached the phone as a generic runtime_error that
the reader discarded, so a 632 KB file showed "Couldn't load markdown".
Reads now return a UTF-8-boundary prefix under one shared 2 MiB budget,
marked truncated with the full byteLength and read-only. The phone shows
the truncation like file tabs do and maps refusal codes to real copy, so
an older desktop's refusal reads "File too large for mobile preview".

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin that a truncated markdown read is never editable

The read budget sits above the edit budget, so every truncated document is
already read-only as file_too_large. Pin that ordering so a future budget
change cannot make a prefix editable, and name the constant as the markdown
preview budget, separate from the file preview's own.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): refuse saves from a truncated markdown read

A phone holding a truncated prefix must not write it back; the 256 KiB
save guard refuses it as file_too_large before any version check. The
shared budget test shrinks to the ordering it pins and names the bridge
test as the behavioural pin.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): tighten markdown truncation types and measure once

The read truncation measures the document once. The disk fallback drops
its truncated-only read-only text, which the status line never showed,
and both truncation fields are optional there. A markdown doc's flag is
only ever true, and the schema comment names the hook, not line numbers.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): drop a stale disk-fallback comment

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* chore: retrigger CI after #23675 landed on main

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo Hong
2026-09-28 16:58:15 -04:00
committed by GitHub
parent 409462a319
commit fe34acda3b
14 changed files with 306 additions and 48 deletions
@@ -3,6 +3,7 @@ import { View, Text, Pressable, ActivityIndicator } from 'react-native'
import { RefreshCw } from 'lucide-react-native'
import { MobileRichMarkdownEditor } from '../components/MobileRichMarkdownEditor'
import { resolveMarkdownFloatingActionsBottom } from './markdown-floating-actions-layout'
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'
@@ -48,13 +49,7 @@ export function MarkdownReader({
)
}
const statusText = doc.saveError
? doc.saveError
: doc.readOnlyReason
? 'Read only'
: doc.stale
? 'Changed on desktop'
: null
const statusText = markdownReaderStatusText(doc)
const showRefresh = (doc.stale && !doc.isDirty) || !doc.editable
const showCopy = doc.saveError || !doc.editable
const showSave = doc.isDirty || doc.saving
@@ -75,17 +75,20 @@ describe('buildMarkdownDiskFallbackDoc', () => {
})
})
it('warns when the disk read is truncated', () => {
it('marks a truncated disk read so the status line names it', () => {
expect(
buildMarkdownDiskFallbackDoc({
content: '# Partial',
truncated: true,
byteLength: 700_000,
tabIsDirty: true
})
).toMatchObject({
editable: false,
stale: true,
readOnlyReason: 'File too large for mobile preview'
readOnlyReason: 'Desktop has unsaved changes. Showing disk content.',
truncated: true,
byteLength: 700_000
})
})
})
@@ -1,4 +1,5 @@
import type { RpcFailure } from '../transport/types'
import type { MarkdownDocState } from './mobile-session-route-types'
const RENDERER_UNAVAILABLE = 'renderer_unavailable'
@@ -9,26 +10,24 @@ export function shouldReadMarkdownFromDiskAfterReadTabFailure(response: RpcFailu
)
}
// `truncated` is optional because the preview reader salvages it: an absent flag reads as not
// truncated here, which is the branch main took for a reply that omitted it.
export function buildMarkdownDiskFallbackDoc(args: {
content: string
truncated: boolean | undefined
truncated?: boolean
byteLength?: number
tabIsDirty: boolean
}) {
const readOnlyReason = args.truncated
? 'File too large for mobile preview'
: args.tabIsDirty
? 'Desktop has unsaved changes. Showing disk content.'
: 'Editing needs Orca desktop running.'
}): Extract<MarkdownDocState, { status: 'ready' }> {
const readOnlyReason = args.tabIsDirty
? 'Desktop has unsaved changes. Showing disk content.'
: 'Editing needs Orca desktop running.'
return {
status: 'ready' as const,
status: 'ready',
content: args.content,
localContent: args.content,
baseVersion: '',
isDirty: false,
editable: false,
stale: args.tabIsDirty,
readOnlyReason
readOnlyReason,
...(args.truncated ? { truncated: true, byteLength: args.byteLength } : {})
}
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { markdownReaderStatusText } from './mobile-markdown-reader-status'
const readOnlyDoc = {
status: 'ready' as const,
content: '# head',
localContent: '# head',
baseVersion: 'v1',
isDirty: false,
editable: false,
readOnlyReason: 'file_too_large'
}
describe('markdownReaderStatusText', () => {
it('names a truncated preview and the full file size the way file tabs do', () => {
expect(
markdownReaderStatusText({ ...readOnlyDoc, truncated: true, byteLength: 3_000_000 })
).toBe('Preview truncated. File size: 2.9 MB.')
})
it('keeps read-only, stale, and save-error states as before', () => {
expect(markdownReaderStatusText(readOnlyDoc)).toBe('Read only')
expect(
markdownReaderStatusText({ ...readOnlyDoc, readOnlyReason: undefined, stale: true })
).toBe('Changed on desktop')
expect(markdownReaderStatusText({ ...readOnlyDoc, saveError: 'Save failed' })).toBe(
'Save failed'
)
})
})
@@ -0,0 +1,20 @@
import { formatPreviewByteLength } from '../files/mobile-file-preview-response'
import type { MarkdownDocState } from './mobile-session-route-types'
/** The markdown floating bar's status line; a truncated preview reads like a file tab's note. */
export function markdownReaderStatusText(
doc: Extract<MarkdownDocState, { status: 'ready' }>
): string | null {
if (doc.saveError) {
return doc.saveError
}
if (doc.truncated) {
return doc.byteLength === undefined
? 'Preview truncated.'
: `Preview truncated. File size: ${formatPreviewByteLength(doc.byteLength)}.`
}
if (doc.readOnlyReason) {
return 'Read only'
}
return doc.stale ? 'Changed on desktop' : null
}
@@ -137,7 +137,8 @@ const HEAD_CALLBACK_IDENTITY_SHA256 =
// Again when an init took one options object.
// Again when the frame's layout became one `notifyTerminalFrame`.
// Again when the init option took the message's name, `initialData`.
const HEAD_CALLBACK_BODY_SHA256 = '4848e925f478f1656f26031c1bdebbb9f2da60811c7e91cfd732fc9da9764079'
// Again when the document readers mapped refusal codes through one function and kept truncation.
const HEAD_CALLBACK_BODY_SHA256 = 'ff818790399c8532ead38d047d072caf715070b21311538932da2dc811312b06'
// Refreshed for the startup effect: both `worktree.activate` sends became `worktreeActivate`, and
// the sleeping-agent check reads that operation's verdict instead of the reply envelope. Refreshed
// again when the reporter took the reply and interpreted it itself, retiring the hand-built
@@ -203,8 +204,9 @@ const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f342345
// 530 -> 532, and the host-JSX hash: the tab bar and the accessory bar take a ref that gives the
// page `keyboardShouldPersistTaps` ('handled', 'always'), which react-native-web ignores. Natively
// the ref is undefined. 532 -> 531: the live input's reopen flag reads the host OS, not an 'android' literal.
// 531 -> 529: the markdown status line moved to `markdownReaderStatusText`.
const HEAD_RUNTIME_STRING_SHA256 =
'ab8cc43940d3a3e0fdb8df3bdc178b9b61e51c7ccd55a944933862a3c290e8e3'
'4ab2f316f60c234480615136c02273675543f24d653eb76a62b76f6bc986d985'
// Moved by both of the dock's fields: their refs, and the live one's submit handler, are the seam's now;
// their keyboard type and remount key read the host OS.
// Moved again when the terminal frame kept its laid-out width unrounded, for every fit.
@@ -654,7 +656,7 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
expect(strings).toHaveLength(531)
expect(strings).toHaveLength(529)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(125)
@@ -91,6 +91,8 @@ export type MarkdownDocState =
saving?: boolean
saveError?: string
readOnlyReason?: string
truncated?: true
byteLength?: number
}
| { status: 'error'; message: string }
@@ -106,17 +106,20 @@ export const sessionWorktreeRecordSchema = z
/**
* A markdown tab's document, read the same way on load and on save.
*
* `content`, `version` and `isDirty` are required: use-mobile-session-document-readers.ts:38-45
* publishes all three into the tab's ready state with no guard, so a reply missing one rendered
* `undefined` in the editor and saved against an undefined base version.
* `editable` and `readOnlyReason` are guarded on the same lines and stay optional.
* `content`, `version` and `isDirty` are required: `readMarkdownTab` in
* `useMobileSessionDocumentReaders` publishes all three into the tab's ready state with no guard,
* so a reply missing one rendered `undefined` in the editor and saved against an undefined base
* version. `editable` and `readOnlyReason` are guarded there and stay optional, as are
* `truncated` and `byteLength`, which only a host that truncates oversize documents sends.
*/
export const markdownTabDocumentSchema = z.looseObject({
content: z.string(),
version: z.string(),
isDirty: z.boolean(),
editable: salvagedOptional('editable', z.boolean()),
readOnlyReason: salvagedOptional('readOnlyReason', z.string())
readOnlyReason: salvagedOptional('readOnlyReason', z.string()),
truncated: salvagedOptional('truncated', z.boolean()),
byteLength: salvagedOptional('byteLength', z.number())
})
/**
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest'
import { hookMount, performHookAction } from '../test-support/rpc-recording/hook-mount'
import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
import type { RpcResponse } from '../transport/types'
import type { MarkdownDocState } from './mobile-session-route-types'
import { useMobileSessionDocumentReaders } from './use-mobile-session-document-readers'
const META = { runtimeId: 'runtime-1' }
async function readMarkdown(reply: RpcResponse): Promise<MarkdownDocState | undefined> {
let docs = new Map<string, MarkdownDocState>()
let readers: ReturnType<typeof useMobileSessionDocumentReaders> | undefined
const hook = hookMount(() => {
readers = useMobileSessionDocumentReaders(
mountFixture<Parameters<typeof useMobileSessionDocumentReaders>[0]>({
worktreeId: 'wt-1',
client: { sendRequest: vi.fn(async (): Promise<RpcResponse> => reply) },
setMarkdownDocs: (update) => {
docs = typeof update === 'function' ? update(docs) : update
},
setFileDocs: () => {}
})
)
})
hook.mount()
await performHookAction(() =>
readers?.readMarkdownTab(
mountFixture<Parameters<typeof readers.readMarkdownTab>[0]>({
type: 'markdown',
id: 'tab-md',
relativePath: 'README.md',
isDirty: false
})
)
)
hook.unmount()
return docs.get('tab-md')
}
function readTabResult(extra: Record<string, unknown> = {}): RpcResponse {
return {
id: 'frame-1',
ok: true,
result: {
tabId: 'tab-md',
content: '# head',
version: 'content:6:0',
isDirty: false,
editable: false,
readOnlyReason: 'file_too_large',
...extra
},
_meta: META
}
}
describe('useMobileSessionDocumentReaders markdown reads', () => {
it('shows an older desktop refusing an oversize file as too large', async () => {
const doc = await readMarkdown({
id: 'frame-1',
ok: false,
error: { code: 'runtime_error', message: 'file_too_large' },
_meta: META
})
expect(doc).toEqual({ status: 'error', message: 'File too large for mobile preview' })
})
it('keeps the generic message for an unknown refusal', async () => {
const doc = await readMarkdown({
id: 'frame-1',
ok: false,
error: { code: 'tab_not_found', message: 'No such tab' },
_meta: META
})
expect(doc).toEqual({ status: 'error', message: "Couldn't load markdown" })
})
it('carries a truncated prefix and the full size into the document', async () => {
const doc = await readMarkdown(readTabResult({ truncated: true, byteLength: 3_000_000 }))
expect(doc).toMatchObject({
status: 'ready',
content: '# head',
editable: false,
truncated: true,
byteLength: 3_000_000
})
})
it('adds no truncation fields for a whole document', async () => {
const doc = await readMarkdown(readTabResult())
expect(doc).toMatchObject({ status: 'ready', content: '# head' })
expect(doc).not.toHaveProperty('truncated')
expect(doc).not.toHaveProperty('byteLength')
})
})
@@ -34,7 +34,10 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati
isDirty: false,
editable: result.editable === true,
stale: result.isDirty,
readOnlyReason: result.readOnlyReason
readOnlyReason: result.readOnlyReason,
...(result.truncated === true
? { truncated: true, byteLength: result.byteLength }
: {})
})
)
return
@@ -59,15 +62,16 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati
buildMarkdownDiskFallbackDoc({
content: fileResult.content,
truncated: fileResult.truncated,
byteLength: fileResult.byteLength,
tabIsDirty: tab.isDirty
})
)
)
} catch {
} catch (err) {
setMarkdownDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'error',
message: "Couldn't load markdown"
message: documentReadErrorMessage(err, "Couldn't load markdown")
})
)
}
@@ -89,15 +93,12 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati
})
setFileDocs((prev) => new Map(prev).set(tab.id, doc))
} catch (err) {
const message = err instanceof Error ? err.message : ''
const previewMessage =
message === 'binary_file'
? 'Binary preview unavailable'
: message === 'file_too_large'
? 'File too large for mobile preview'
: tab.diffSource === 'staged' || tab.diffSource === 'unstaged'
? "Couldn't load diff preview"
: "Couldn't load file preview"
const previewMessage = documentReadErrorMessage(
err,
tab.diffSource === 'staged' || tab.diffSource === 'unstaged'
? "Couldn't load diff preview"
: "Couldn't load file preview"
)
setFileDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'error',
@@ -114,5 +115,17 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati
}
}
// Why: older desktops refuse oversize markdown as a bare runtime_error whose message is the code.
function documentReadErrorMessage(err: unknown, fallback: string): string {
const message = err instanceof Error ? err.message : ''
if (message === 'binary_file') {
return 'Binary preview unavailable'
}
if (message === 'file_too_large') {
return 'File too large for mobile preview'
}
return fallback
}
export type MobileSessionDocumentReadersModel = MobileSessionTabApplicationModel &
ReturnType<typeof useMobileSessionDocumentReaders>
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
hashMarkdownContent,
MOBILE_MARKDOWN_EDIT_MAX_BYTES
MOBILE_MARKDOWN_EDIT_MAX_BYTES,
MOBILE_MARKDOWN_READ_MAX_BYTES
} from '../../../shared/mobile-markdown-document'
import { attachEditorAutosaveController } from '../components/editor/editor-autosave-controller'
import { registerPendingEditorFlush } from '../components/editor/editor-pending-flush'
@@ -157,4 +158,69 @@ describe('mobile markdown bridge', () => {
detach()
}
})
it('reads a markdown file between the edit and read budgets whole, as read-only', async () => {
openMarkdownFile()
const content = 'a'.repeat(632 * 1024)
setupWindow({ readFile: vi.fn().mockResolvedValue({ content, isBinary: false }) })
const detach = attachMobileMarkdownBridge()
try {
const response = await sendRequest({
id: 'read-632k',
operation: 'read',
worktreeId: 'wt-1',
tabId: 'tab-md'
})
expect(response).toMatchObject({
ok: true,
result: { content, editable: false, readOnlyReason: 'file_too_large' }
})
expect(response).not.toHaveProperty('result.truncated')
} finally {
detach()
}
})
it('truncates a markdown file over the read budget on a UTF-8 boundary', async () => {
openMarkdownFile()
// The 3-byte euro sign straddles the budget, so the cut must drop it whole.
const prefix = 'a'.repeat(MOBILE_MARKDOWN_READ_MAX_BYTES - 1)
const content = `${prefix}\u20actail`
setupWindow({ readFile: vi.fn().mockResolvedValue({ content, isBinary: false }) })
const detach = attachMobileMarkdownBridge()
try {
const response = await sendRequest({
id: 'read-over-budget',
operation: 'read',
worktreeId: 'wt-1',
tabId: 'tab-md'
})
expect(response).toMatchObject({
ok: true,
result: {
editable: false,
readOnlyReason: 'file_too_large',
truncated: true,
byteLength: MOBILE_MARKDOWN_READ_MAX_BYTES - 1 + 3 + 4,
version: hashMarkdownContent(prefix)
}
})
expect(response).toHaveProperty('result.content', prefix)
const save = await sendRequest({
id: 'save-truncated',
operation: 'save',
worktreeId: 'wt-1',
tabId: 'tab-md',
baseVersion: hashMarkdownContent(prefix),
content: prefix
})
expect(save).toMatchObject({ ok: false, error: 'file_too_large' })
} finally {
detach()
}
})
})
@@ -15,13 +15,13 @@ import {
hashMarkdownContent,
isMarkdownContentByteLengthOverLimit,
MOBILE_MARKDOWN_EDIT_MAX_BYTES,
truncateMobileMarkdownRead,
type RuntimeMarkdownReadTabResult,
type RuntimeMarkdownSaveTabResult,
type RuntimeMobileMarkdownRequest,
type RuntimeMobileMarkdownResponse
} from '../../../shared/mobile-markdown-document'
const MOBILE_MARKDOWN_READ_MAX_BYTES = 512 * 1024
const saveQueues = new Map<string, Promise<void>>()
type FileContent = {
@@ -67,16 +67,18 @@ async function readMobileMarkdownTab(
flushEditorState(target.sourceFile.id)
const { content, source } = await readCurrentContent(target.sourceFile)
const readOnlyReason = getReadOnlyReason(target.tab, target.sourceFile, content)
const preview = truncateMobileMarkdownRead(content)
return {
tabId,
filePath: target.sourceFile.filePath,
relativePath: target.sourceFile.relativePath,
content,
content: preview.content,
isDirty: target.sourceFile.isDirty || source === 'draft',
version: hashMarkdownContent(content),
version: hashMarkdownContent(preview.content),
source,
editable: readOnlyReason === undefined,
...(readOnlyReason ? { readOnlyReason } : {})
...(readOnlyReason ? { readOnlyReason } : {}),
...(preview.truncated ? { truncated: true, byteLength: preview.byteLength } : {})
}
}
@@ -252,9 +254,6 @@ async function readFileContent(file: OpenFile): Promise<string> {
if (result.isBinary) {
throw new Error('binary_file')
}
if (isMarkdownContentByteLengthOverLimit(result.content, MOBILE_MARKDOWN_READ_MAX_BYTES)) {
throw new Error('file_too_large')
}
return result.content
}
@@ -3,6 +3,7 @@ import {
hashMarkdownContent,
isMarkdownContentByteLengthOverLimit,
MOBILE_MARKDOWN_EDIT_MAX_BYTES,
MOBILE_MARKDOWN_READ_MAX_BYTES,
utf8ByteLength
} from './mobile-markdown-document'
@@ -45,4 +46,9 @@ describe('mobile markdown document byte accounting', () => {
it('keeps content hashes prefixed with exact byte length', () => {
expect(hashMarkdownContent('😀')).toMatch(/^content:4:/)
})
it('keeps the read budget above the edit budget', () => {
// Behavioural pin for truncated => not editable: the bridge's over-budget read test.
expect(MOBILE_MARKDOWN_READ_MAX_BYTES).toBeGreaterThan(MOBILE_MARKDOWN_EDIT_MAX_BYTES)
})
})
+25
View File
@@ -1,6 +1,11 @@
import { getClipboardTextByteLength, isClipboardTextByteLengthOverLimit } from './clipboard-text'
import { clampUtf8TextPrefix } from './utf8-byte-limits'
export const MOBILE_MARKDOWN_EDIT_MAX_BYTES = 256 * 1024
/** Markdown preview budget; the file preview keeps its own. Above it a mobile read returns a
* UTF-8-boundary prefix marked `truncated`, never a refusal. Sized like the 2 MiB terminal
* snapshot; the relay splice frame cap is 8 MiB. */
export const MOBILE_MARKDOWN_READ_MAX_BYTES = 2 * 1024 * 1024
export type RuntimeMarkdownReadOnlyReason =
| 'unsupported_preview'
@@ -46,6 +51,10 @@ export type RuntimeMarkdownReadTabResult = {
source: 'draft' | 'file'
editable: boolean
readOnlyReason?: RuntimeMarkdownReadOnlyReason
/** Present only when `content` is a prefix; older phones ignore both fields. */
truncated?: boolean
/** The full document's UTF-8 size, sent with `truncated`. */
byteLength?: number
}
export type RuntimeMarkdownSaveTabResult = {
@@ -71,3 +80,19 @@ export function isMarkdownContentByteLengthOverLimit(content: string, maxBytes:
export function utf8ByteLength(content: string): number {
return getClipboardTextByteLength(content)
}
export function truncateMobileMarkdownRead(
content: string
):
| { content: string; truncated: false }
| { content: string; truncated: true; byteLength: number } {
const byteLength = utf8ByteLength(content)
if (byteLength <= MOBILE_MARKDOWN_READ_MAX_BYTES) {
return { content, truncated: false }
}
return {
content: clampUtf8TextPrefix(content, MOBILE_MARKDOWN_READ_MAX_BYTES),
truncated: true,
byteLength
}
}