fix(mobile): prevent markdown and file tab reload and screen flicker on retap and revisit (#30033)

* fix(mobile): prevent markdown and file tab reload and screen flicker on retap and revisit

* fix(mobile): preserve newer saves and refresh document metadata

* test(mobile): supply current document state in recorded reads

---------

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
zhangxaochen
2026-10-17 01:35:34 -07:00
committed by GitHub
co-authored by Neil
parent e6b7c77e1a
commit 92999b0e5f
9 changed files with 837 additions and 54 deletions
@@ -223,4 +223,67 @@ describe('mobile file preview lifetime', () => {
}
}
)
it('keeps a ready preview mounted across a revisit and a failed refresh', async () => {
const session = previewSession()
const tab = fileTab('revisit')
const ready: FileDocState = {
status: 'ready',
kind: 'file',
content: 'Same',
truncated: false,
byteLength: 4
}
try {
session.open(tab, ready)
const unchanged = session.read(tab)
expect(session.docs().get(tab.id)).toBe(ready)
session.reply(0, textReply('Same'))
await unchanged
expect(session.docs().get(tab.id)).toBe(ready)
const failed = session.read(tab)
session.reply(1, refusedReply())
await failed
expect(session.docs().get(tab.id)).toBe(ready)
const changed = session.read(tab)
session.reply(2, textReply('Changed'))
await changed
expect(session.docs().get(tab.id)).toMatchObject({
status: 'ready',
kind: 'file',
content: 'Changed'
})
} finally {
session.dispose()
}
})
it('keeps the newer file reread when an older one finishes later', async () => {
const session = previewSession()
const tab = fileTab('raced')
const ready: FileDocState = {
status: 'ready',
kind: 'file',
content: 'Cached',
truncated: false,
byteLength: 6
}
try {
session.open(tab, ready)
const older = session.read(tab)
const newer = session.read(tab)
expect(session.docs().get(tab.id)).toBe(ready)
session.reply(1, textReply('New content'))
await newer
const current = session.docs().get(tab.id)
session.reply(0, textReply('Old content'))
await older
expect(session.docs().get(tab.id)).toBe(current)
expect(current).toMatchObject({ content: 'New content' })
} finally {
session.dispose()
}
})
})
@@ -37,6 +37,11 @@ function mountDocuments(readReply: () => Promise<RpcResponse>) {
readers = useMobileSessionDocumentReaders(
mountFixture<ReaderScope>({
worktreeId: 'folder-workspace',
markdownDocsRef: {
get current() {
return docs
}
},
client,
setMarkdownDocs: setDocs,
setFileDocs: () => {}
@@ -0,0 +1,17 @@
import type { FileDocState, MarkdownDocState, MobileSessionTab } from './mobile-session-route-types'
/** Retap of the open markdown or file tab is not a reload; refresh is a separate control. */
export function isCachedActiveDocumentRetap(
tab: MobileSessionTab,
activeSessionTabId: string | null,
docs: {
markdownDocs: ReadonlyMap<string, MarkdownDocState>
fileDocs: ReadonlyMap<string, FileDocState>
}
): boolean {
if (tab.id !== activeSessionTabId || (tab.type !== 'markdown' && tab.type !== 'file')) {
return false
}
const cached = tab.type === 'markdown' ? docs.markdownDocs.get(tab.id) : docs.fileDocs.get(tab.id)
return cached !== undefined && cached.status !== 'error'
}
@@ -0,0 +1,177 @@
import type { MobileFileMedia } from '../files/mobile-file-media'
import type { MobileDiffLine } from './mobile-diff-lines'
import type {
FileDocState,
MarkdownDocState,
ReadyFileDocState
} from './mobile-session-route-types'
export function claimDocumentRead(sequences: Map<string, number>, tabId: string): number {
const seq = (sequences.get(tabId) ?? 0) + 1
sequences.set(tabId, seq)
return seq
}
export function documentReadIsCurrent(
sequences: Map<string, number>,
tabId: string,
seq: number
): boolean {
return sequences.get(tabId) === seq
}
/** A dirty ready doc is a discard and must reload. A clean one stays up and refreshes behind it. */
export function keepMarkdownVisibleDuringRead(current: MarkdownDocState | undefined): boolean {
return current?.status === 'ready' && !current.isDirty
}
export function keepFileVisibleDuringRead(current: FileDocState | undefined): boolean {
return current?.status === 'ready'
}
export function installDocumentReadPlaceholder<Doc extends { status: string }>(
prev: Map<string, Doc>,
tabId: string,
loading: Doc,
keepVisible: (current: Doc | undefined) => boolean
): Map<string, Doc> {
if (keepVisible(prev.get(tabId))) {
return prev
}
const next = new Map(prev)
next.set(tabId, loading)
return next
}
export function commitDocumentRead<Doc extends { status: string }>(
prev: Map<string, Doc>,
tabId: string,
loading: Doc,
nextDoc: Doc,
settle: (current: Doc, loading: Doc, nextDoc: Doc) => Doc | undefined
): Map<string, Doc> {
const current = prev.get(tabId)
if (!current) {
return prev
}
const settled = settle(current, loading, nextDoc)
if (!settled || settled === current) {
return prev
}
const next = new Map(prev)
next.set(tabId, settled)
return next
}
/** Edits and saves made during a reread win; unchanged content and metadata keep the view. */
export function settleMarkdownRead(
current: MarkdownDocState,
loading: MarkdownDocState,
nextDoc: MarkdownDocState,
visibleAtStart: MarkdownDocState | undefined
): MarkdownDocState | undefined {
if (current.status === 'ready') {
if (
current !== visibleAtStart ||
current.isDirty ||
current.saving ||
nextDoc.status !== 'ready'
) {
return undefined
}
if (
(
[
'content',
'localContent',
'baseVersion',
'isDirty',
'editable',
'stale',
'readOnlyReason',
'truncated',
'byteLength',
'saving',
'saveError'
] as const
).every((field) => current[field] === nextDoc[field])
) {
return undefined
}
return nextDoc
}
if (current !== loading) {
return undefined
}
return nextDoc
}
export function settleFileRead(
current: FileDocState,
loading: FileDocState,
nextDoc: FileDocState
): FileDocState | undefined {
if (current.status === 'ready') {
if (nextDoc.status !== 'ready' || readyFileDocsMatch(current, nextDoc)) {
return undefined
}
return nextDoc
}
if (current !== loading) {
return undefined
}
return nextDoc
}
function readyFileDocsMatch(current: ReadyFileDocState, next: FileDocState): boolean {
if (next.status !== 'ready' || current.kind !== next.kind) {
return false
}
switch (current.kind) {
case 'file':
return (
next.kind === 'file' &&
current.content === next.content &&
current.truncated === next.truncated &&
current.byteLength === next.byteLength
)
case 'html':
return next.kind === 'html' && current.content === next.content
case 'image':
return next.kind === 'image' && current.dataUri === next.dataUri
case 'media':
return next.kind === 'media' && sameMedia(current.media, next.media)
case 'diff':
return (
next.kind === 'diff' &&
current.truncated === next.truncated &&
sameDiffLines(current.lines, next.lines)
)
}
}
function sameMedia(current: MobileFileMedia, next: MobileFileMedia): boolean {
return (
current.worktreeId === next.worktreeId &&
current.relativePath === next.relativePath &&
current.mimeType === next.mimeType
)
}
function sameDiffLines(
current: readonly MobileDiffLine[],
next: readonly MobileDiffLine[]
): boolean {
if (current.length !== next.length) {
return false
}
return current.every((line, index) => {
const other = next[index]
return (
line.kind === other?.kind &&
line.text === other?.text &&
line.oldLineNumber === other?.oldLineNumber &&
line.newLineNumber === other?.newLineNumber
)
})
}
@@ -14,6 +14,11 @@ async function readMarkdown(reply: RpcResponse): Promise<MarkdownDocState | unde
readers = useMobileSessionDocumentReaders(
mountFixture<Parameters<typeof useMobileSessionDocumentReaders>[0]>({
worktreeId: 'wt-1',
markdownDocsRef: {
get current() {
return docs
}
},
client: { sendRequest: vi.fn(async (): Promise<RpcResponse> => reply) },
setMarkdownDocs: (update) => {
docs = typeof update === 'function' ? update(docs) : update
@@ -93,3 +98,305 @@ describe('useMobileSessionDocumentReaders markdown reads', () => {
expect(doc).not.toHaveProperty('byteLength')
})
})
function readyMarkdown(
content: string,
extra: Partial<Extract<MarkdownDocState, { status: 'ready' }>> = {}
): Extract<MarkdownDocState, { status: 'ready' }> {
return {
status: 'ready',
content,
localContent: content,
baseVersion: 'v1',
isDirty: false,
editable: true,
stale: false,
...extra
}
}
function deferredReply() {
let resolve: (reply: RpcResponse) => void = () => {}
const promise = new Promise<RpcResponse>((done) => {
resolve = done
})
return { promise, resolve }
}
function mountMarkdownReads(
initial: Map<string, MarkdownDocState>,
sendRequest: (method: string) => Promise<RpcResponse>
) {
let docs = new Map(initial)
const published: MarkdownDocState[] = []
let readers: ReturnType<typeof useMobileSessionDocumentReaders> | undefined
const hook = hookMount(() => {
readers = useMobileSessionDocumentReaders(
mountFixture<Parameters<typeof useMobileSessionDocumentReaders>[0]>({
worktreeId: 'wt-1',
markdownDocsRef: {
get current() {
return docs
}
},
client: { sendRequest: vi.fn(sendRequest) },
setMarkdownDocs: (update) => {
const next = typeof update === 'function' ? update(docs) : update
const doc = next.get('tab-md')
if (doc && doc !== docs.get('tab-md')) {
published.push(doc)
}
docs = next
},
setFileDocs: () => {}
})
)
})
hook.mount()
const tab = mountFixture<Parameters<NonNullable<typeof readers>['readMarkdownTab']>[0]>({
type: 'markdown',
id: 'tab-md',
relativePath: 'README.md',
isDirty: false
})
return {
published,
get docs() {
return docs
},
read: () => performHookAction(() => readers?.readMarkdownTab(tab)),
unmount: hook.unmount
}
}
describe('useMobileSessionDocumentReaders markdown revalidation', () => {
it.each([
{
reply: { editable: false, readOnlyReason: 'host_read_only' },
expected: { editable: false, readOnlyReason: 'host_read_only' }
},
{ reply: { isDirty: true }, expected: { stale: true } },
{
reply: { truncated: true, byteLength: 3_000_000 },
expected: { truncated: true, byteLength: 3_000_000 }
}
])(
'applies metadata changes with unchanged content and version: $expected',
async ({ reply, expected }) => {
const initial = readyMarkdown('# head')
const harness = mountMarkdownReads(new Map([['tab-md', initial]]), async () =>
readTabResult({
content: '# head',
version: 'v1',
editable: true,
readOnlyReason: undefined,
...reply
})
)
try {
await harness.read()
expect(harness.docs.get('tab-md')).toMatchObject(expected)
} finally {
harness.unmount()
}
}
)
it('does not overwrite a phone save that finishes during a background read', async () => {
const initial = readyMarkdown('# head')
const pending = deferredReply()
const harness = mountMarkdownReads(new Map([['tab-md', initial]]), () => pending.promise)
try {
const reading = harness.read()
const saved = readyMarkdown('saved phone draft', { baseVersion: 'v3' })
harness.docs.set('tab-md', saved)
pending.resolve(readTabResult({ content: '# older server text', version: 'v2' }))
await reading
expect(harness.docs.get('tab-md')).toBe(saved)
} finally {
harness.unmount()
}
})
it('keeps a clean document mounted when the reread is unchanged', async () => {
const initial = readyMarkdown('# head')
const harness = mountMarkdownReads(new Map([['tab-md', initial]]), async () =>
readTabResult({ content: '# head', version: 'v1', editable: true, readOnlyReason: undefined })
)
try {
await harness.read()
expect(harness.published).toEqual([])
expect(harness.docs.get('tab-md')).toBe(initial)
} finally {
harness.unmount()
}
})
it('updates a clean document in place when desktop content changed', async () => {
const initial = readyMarkdown('# head')
const harness = mountMarkdownReads(new Map([['tab-md', initial]]), async () =>
readTabResult({ content: '# next', version: 'v2' })
)
try {
await harness.read()
expect(harness.published.map((doc) => doc.status)).toEqual(['ready'])
expect(harness.docs.get('tab-md')).toMatchObject({
status: 'ready',
content: '# next',
localContent: '# next',
baseVersion: 'v2',
isDirty: false
})
} finally {
harness.unmount()
}
})
it('keeps the visible document when a reread fails', async () => {
const initial = readyMarkdown('# head')
const harness = mountMarkdownReads(new Map([['tab-md', initial]]), async () => ({
id: 'frame-1',
ok: false,
error: { code: 'tab_not_found', message: 'No such tab' },
_meta: META
}))
try {
await harness.read()
expect(harness.published).toEqual([])
expect(harness.docs.get('tab-md')).toBe(initial)
} finally {
harness.unmount()
}
})
it('drops a reread that loses to an edit made while it was in flight', async () => {
const initial = readyMarkdown('# head')
const pending = deferredReply()
const harness = mountMarkdownReads(new Map([['tab-md', initial]]), () => pending.promise)
try {
const reading = harness.read()
const current = harness.docs.get('tab-md')
if (current?.status !== 'ready') {
throw new Error('Expected the clean document to stay visible')
}
harness.docs.set('tab-md', { ...current, localContent: 'phone draft', isDirty: true })
pending.resolve(readTabResult({ content: '# server', version: 'v2' }))
await reading
expect(harness.docs.get('tab-md')).toMatchObject({
status: 'ready',
content: '# head',
localContent: 'phone draft',
isDirty: true
})
expect(harness.published.every((doc) => doc.status !== 'loading')).toBe(true)
} finally {
harness.unmount()
}
})
it('reloads a dirty document instead of keeping the draft', async () => {
const initial = readyMarkdown('# head', { localContent: 'phone draft', isDirty: true })
const harness = mountMarkdownReads(new Map([['tab-md', initial]]), async () =>
readTabResult({ content: '# server', version: 'v2' })
)
try {
await harness.read()
expect(harness.published.map((doc) => doc.status)).toEqual(['loading', 'ready'])
expect(harness.docs.get('tab-md')).toMatchObject({
status: 'ready',
content: '# server',
localContent: '# server',
isDirty: false
})
} finally {
harness.unmount()
}
})
it('keeps the newer reread when an older one finishes later', async () => {
const initial = readyMarkdown('# head')
const older = deferredReply()
let calls = 0
const harness = mountMarkdownReads(new Map([['tab-md', initial]]), () => {
calls += 1
return calls === 1
? older.promise
: Promise.resolve(readTabResult({ content: '# new', version: 'v2' }))
})
try {
const first = harness.read()
const second = harness.read()
await second
const current = harness.docs.get('tab-md')
older.resolve(readTabResult({ content: '# old', version: 'v0' }))
await first
expect(harness.docs.get('tab-md')).toBe(current)
expect(current).toMatchObject({ content: '# new', baseVersion: 'v2' })
expect(harness.published.every((doc) => doc.status !== 'loading')).toBe(true)
} finally {
harness.unmount()
}
})
it('updates from disk without unmounting when the desktop renderer is down', async () => {
const initial = readyMarkdown('# head')
const harness = mountMarkdownReads(new Map([['tab-md', initial]]), async (method) =>
method === 'files.read'
? {
id: 'frame-2',
ok: true,
result: { content: '# disk' },
_meta: META
}
: {
id: 'frame-1',
ok: false,
error: { code: 'runtime_error', message: 'renderer_unavailable' },
_meta: META
}
)
try {
await harness.read()
expect(harness.published.map((doc) => doc.status)).toEqual(['ready'])
expect(harness.docs.get('tab-md')).toMatchObject({
status: 'ready',
content: '# disk',
editable: false
})
} finally {
harness.unmount()
}
})
it('keeps the visible document when disk fallback fails too', async () => {
const initial = readyMarkdown('# head')
const harness = mountMarkdownReads(new Map([['tab-md', initial]]), async () => ({
id: 'frame-1',
ok: false,
error: { code: 'runtime_error', message: 'renderer_unavailable' },
_meta: META
}))
try {
await harness.read()
expect(harness.published).toEqual([])
expect(harness.docs.get('tab-md')).toBe(initial)
} finally {
harness.unmount()
}
})
it('does not restore a document closed while a reread was in flight', async () => {
const initial = readyMarkdown('# head')
const pending = deferredReply()
const harness = mountMarkdownReads(new Map([['tab-md', initial]]), () => pending.promise)
try {
const reading = harness.read()
harness.docs.delete('tab-md')
pending.resolve(readTabResult({ content: '# late', version: 'v2' }))
await reading
expect(harness.docs.has('tab-md')).toBe(false)
} finally {
harness.unmount()
}
})
})
@@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { useCallback, useRef } from 'react'
import type { RpcFailure } from '../transport/types'
import { resolveMobileFileTabDoc } from '../files/mobile-file-tab-doc'
import { filePreviewTextRead } from '../files/mobile-file-preview-operations'
@@ -7,18 +7,50 @@ import {
buildMarkdownDiskFallbackDoc,
shouldReadMarkdownFromDiskAfterReadTabFailure
} from './mobile-markdown-disk-fallback'
import type { MobileSessionTab } from './mobile-session-route-types'
import {
claimDocumentRead,
commitDocumentRead,
documentReadIsCurrent,
installDocumentReadPlaceholder,
keepFileVisibleDuringRead,
keepMarkdownVisibleDuringRead,
settleFileRead,
settleMarkdownRead
} from './mobile-session-document-read-settlement'
import type { FileDocState, MarkdownDocState, MobileSessionTab } from './mobile-session-route-types'
import type { MobileSessionTabApplicationModel } from './use-mobile-session-tab-application'
export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicationModel) {
const { worktreeId, client, setMarkdownDocs, setFileDocs } = scope
const { worktreeId, client, markdownDocsRef, setMarkdownDocs, setFileDocs } = scope
const markdownReadSeqRef = useRef(new Map<string, number>())
const fileReadSeqRef = useRef(new Map<string, number>())
const readMarkdownTab = useCallback(
async (tab: Extract<MobileSessionTab, { type: 'markdown' }>) => {
if (!client) {
return
}
const loading = { status: 'loading' } as const
setMarkdownDocs((prev) => new Map(prev).set(tab.id, loading))
const seq = claimDocumentRead(markdownReadSeqRef.current, tab.id)
const visibleAtStart = markdownDocsRef.current.get(tab.id)
// Why: swapping a visible document for `loading` unmounts the WebView and drops the caret.
const loading: MarkdownDocState = { status: 'loading' }
setMarkdownDocs((prev) =>
installDocumentReadPlaceholder(prev, tab.id, loading, keepMarkdownVisibleDuringRead)
)
const publish = (nextDoc: MarkdownDocState) => {
setMarkdownDocs((prev) => {
if (!documentReadIsCurrent(markdownReadSeqRef.current, tab.id, seq)) {
return prev
}
return commitDocumentRead(
prev,
tab.id,
loading,
nextDoc,
(current, placeholder, result) =>
settleMarkdownRead(current, placeholder, result, visibleAtStart)
)
})
}
try {
const response = await markdownTabRead.request(client, {
worktree: `id:${worktreeId}`,
@@ -26,23 +58,17 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati
})
if (response.ok) {
const result = markdownTabRead.interpret(response)
setMarkdownDocs((prev) =>
prev.get(tab.id) === loading
? new Map(prev).set(tab.id, {
status: 'ready',
content: result.content,
localContent: result.content,
baseVersion: result.version,
isDirty: false,
editable: result.editable === true,
stale: result.isDirty,
readOnlyReason: result.readOnlyReason,
...(result.truncated === true
? { truncated: true, byteLength: result.byteLength }
: {})
})
: prev
)
publish({
status: 'ready',
content: result.content,
localContent: result.content,
baseVersion: result.version,
isDirty: false,
editable: result.editable === true,
stale: result.isDirty,
readOnlyReason: result.readOnlyReason,
...(result.truncated === true ? { truncated: true, byteLength: result.byteLength } : {})
})
return
}
if (!shouldReadMarkdownFromDiskAfterReadTabFailure(response as RpcFailure)) {
@@ -59,28 +85,19 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati
throw new Error('Unable to read markdown')
}
const fileResult = fallback.value
setMarkdownDocs((prev) =>
prev.get(tab.id) === loading
? new Map(prev).set(
tab.id,
buildMarkdownDiskFallbackDoc({
content: fileResult.content,
truncated: fileResult.truncated,
byteLength: fileResult.byteLength,
tabIsDirty: tab.isDirty
})
)
: prev
publish(
buildMarkdownDiskFallbackDoc({
content: fileResult.content,
truncated: fileResult.truncated,
byteLength: fileResult.byteLength,
tabIsDirty: tab.isDirty
})
)
} catch (err) {
setMarkdownDocs((prev) =>
prev.get(tab.id) === loading
? new Map(prev).set(tab.id, {
status: 'error',
message: documentReadErrorMessage(err, "Couldn't load markdown")
})
: prev
)
publish({
status: 'error',
message: documentReadErrorMessage(err, "Couldn't load markdown")
})
}
},
[client, worktreeId]
@@ -91,18 +108,26 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati
if (!client) {
return
}
const loading = { status: 'loading' } as const
setFileDocs((prev) => new Map(prev).set(tab.id, loading))
const seq = claimDocumentRead(fileReadSeqRef.current, tab.id)
const loading: FileDocState = { status: 'loading' }
setFileDocs((prev) =>
installDocumentReadPlaceholder(prev, tab.id, loading, keepFileVisibleDuringRead)
)
const publish = (nextDoc: FileDocState) => {
setFileDocs((prev) => {
if (!documentReadIsCurrent(fileReadSeqRef.current, tab.id, seq)) {
return prev
}
return commitDocumentRead(prev, tab.id, loading, nextDoc, settleFileRead)
})
}
try {
const doc = await resolveMobileFileTabDoc(client, {
worktreeId,
relativePath: tab.relativePath,
diffSource: tab.diffSource
})
// Closed tabs and newer reads release ownership of this reply.
setFileDocs((prev) =>
prev.get(tab.id) === loading ? new Map(prev).set(tab.id, doc) : prev
)
publish(doc)
} catch (err) {
const previewMessage = documentReadErrorMessage(
err,
@@ -110,11 +135,7 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati
? "Couldn't load diff preview"
: "Couldn't load file preview"
)
setFileDocs((prev) =>
prev.get(tab.id) === loading
? new Map(prev).set(tab.id, { status: 'error', message: previewMessage })
: prev
)
publish({ status: 'error', message: previewMessage })
}
},
[client, worktreeId]
@@ -0,0 +1,172 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../platform/haptics', () => ({
triggerSelection: vi.fn()
}))
import { hookMount, performHookAction } from '../test-support/rpc-recording/hook-mount'
import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape'
import { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics'
import type { FileDocState, MarkdownDocState, MobileSessionTab } from './mobile-session-route-types'
import { isCachedActiveDocumentRetap } from './mobile-session-active-document-retap'
import { useMobileSessionTabSwitching } from './use-mobile-session-tab-switching'
const markdownTab: MobileSessionTab = {
type: 'markdown',
id: 'md',
title: 'Notes',
filePath: '/Notes.md',
relativePath: 'Notes.md',
isDirty: false,
isActive: true,
documentVersion: 'v1'
}
const fileTab: MobileSessionTab = {
type: 'file',
id: 'file',
title: 'Main',
filePath: '/Main.ts',
relativePath: 'Main.ts',
isDirty: false,
isActive: true
}
const readyMarkdown: MarkdownDocState = {
status: 'ready',
content: '# Notes',
localContent: '# Notes',
baseVersion: 'v1',
isDirty: false,
editable: true
}
function mountSwitching(args: {
activeSessionTabId: string | null
markdownDocs?: Map<string, MarkdownDocState>
fileDocs?: Map<string, FileDocState>
}) {
const readMarkdownTab = vi.fn()
const readFileTab = vi.fn()
const setActiveSessionTabId = vi.fn()
let switching: ReturnType<typeof useMobileSessionTabSwitching> | undefined
const hook = hookMount(() => {
switching = useMobileSessionTabSwitching(
mountFixture<Parameters<typeof useMobileSessionTabSwitching>[0]>({
activeSessionTabId: args.activeSessionTabId,
markdownDocs: args.markdownDocs ?? new Map<string, MarkdownDocState>(),
fileDocs: args.fileDocs ?? new Map<string, FileDocState>(),
readMarkdownTab,
readFileTab,
setActiveSessionTabId,
setActiveHandle: vi.fn(),
switchSessionTabRef: { current: null },
terminalDiagnosticsRef: { current: new MobileTerminalDiagnostics() },
pendingSelectionRef: { current: null },
activeSessionTabTypeRef: { current: null },
activeHandleRef: { current: null },
client: null,
sessionTabs: []
})
)
})
hook.mount()
return {
readMarkdownTab,
readFileTab,
setActiveSessionTabId,
switchTo: (tab: MobileSessionTab) => {
performHookAction(() => switching?.switchSessionTab(tab))
},
unmount: hook.unmount
}
}
describe('isCachedActiveDocumentRetap', () => {
it('ignores a retap only while the open markdown or file document is already on screen', () => {
const docs = {
markdownDocs: new Map<string, MarkdownDocState>([['md', readyMarkdown]]),
fileDocs: new Map<string, FileDocState>([
['file', { status: 'ready', kind: 'html', content: '<p>Hi</p>' }]
])
}
expect(isCachedActiveDocumentRetap(markdownTab, 'md', docs)).toBe(true)
expect(isCachedActiveDocumentRetap(fileTab, 'file', docs)).toBe(true)
expect(isCachedActiveDocumentRetap(markdownTab, 'other', docs)).toBe(false)
expect(
isCachedActiveDocumentRetap(markdownTab, 'md', {
...docs,
markdownDocs: new Map([['md', { status: 'error', message: 'Missing' }]])
})
).toBe(false)
expect(
isCachedActiveDocumentRetap(
{
type: 'terminal',
id: 'term',
title: 'Shell',
terminal: 'pty-1',
isActive: true
},
'term',
docs
)
).toBe(false)
})
})
describe('useMobileSessionTabSwitching active document retap', () => {
it('does not reload the markdown tab that is already showing', () => {
const harness = mountSwitching({
activeSessionTabId: 'md',
markdownDocs: new Map([['md', readyMarkdown]])
})
try {
harness.switchTo(markdownTab)
expect(harness.readMarkdownTab).not.toHaveBeenCalled()
expect(harness.setActiveSessionTabId).not.toHaveBeenCalled()
} finally {
harness.unmount()
}
})
it('does not reload a file tab that is already showing', () => {
const harness = mountSwitching({
activeSessionTabId: 'file',
fileDocs: new Map([['file', { status: 'loading' }]])
})
try {
harness.switchTo(fileTab)
expect(harness.readFileTab).not.toHaveBeenCalled()
expect(harness.setActiveSessionTabId).not.toHaveBeenCalled()
} finally {
harness.unmount()
}
})
it('rereads a cached markdown tab when switching back to it', () => {
const harness = mountSwitching({
activeSessionTabId: 'file',
markdownDocs: new Map([['md', readyMarkdown]])
})
try {
harness.switchTo(markdownTab)
expect(harness.readMarkdownTab).toHaveBeenCalledExactlyOnceWith(markdownTab)
expect(harness.setActiveSessionTabId).toHaveBeenCalledExactlyOnceWith('md')
} finally {
harness.unmount()
}
})
it('rereads the open markdown tab after its document failed', () => {
const harness = mountSwitching({
activeSessionTabId: 'md',
markdownDocs: new Map([['md', { status: 'error', message: 'Missing' }]])
})
try {
harness.switchTo(markdownTab)
expect(harness.readMarkdownTab).toHaveBeenCalledOnce()
} finally {
harness.unmount()
}
})
})
@@ -1,5 +1,6 @@
import { useCallback } from 'react'
import { triggerSelection } from '../platform/haptics'
import { isCachedActiveDocumentRetap } from './mobile-session-active-document-retap'
import { activateMobileSessionTab } from './mobile-session-tab-activation'
import type { MobileSessionTab } from './mobile-session-route-types'
import type { MobileSessionKeyboardStateModel } from './use-mobile-session-keyboard-state'
@@ -12,7 +13,9 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo
defaultTerminalHandlesToLiveInput,
setActiveHandle,
setActiveSessionTabId,
activeSessionTabId,
markdownDocs,
fileDocs,
terminalUnsubsRef,
terminalDiagnosticsRef,
activeHandleRef,
@@ -72,6 +75,9 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo
const switchSessionTab = useCallback(
(tab: MobileSessionTab) => {
if (isCachedActiveDocumentRetap(tab, activeSessionTabId, { markdownDocs, fileDocs })) {
return
}
if (tab.type === 'terminal') {
if (typeof tab.terminal === 'string') {
switchTab(tab.terminal)
@@ -137,7 +143,17 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo
// Why: tab list lacks a reliable version for desktop clean saves; re-read on revisit unless the phone has a draft.
void readMarkdownTab(tab)
},
[client, markdownDocs, readFileTab, readMarkdownTab, switchTab, unsubscribeTerminal, worktreeId]
[
activeSessionTabId,
client,
fileDocs,
markdownDocs,
readFileTab,
readMarkdownTab,
switchTab,
unsubscribeTerminal,
worktreeId
]
)
// Ref to latest switchSessionTab so fetchSessionTabs can activate a synced browser tab without a dependency cycle.
switchSessionTabRef.current = switchSessionTab
@@ -37,6 +37,11 @@ export function sessionScreenReadMountAdapters(
mountFixture<Parameters<typeof useReaders>[0]>({
worktreeId: WORKSPACE,
client,
markdownDocsRef: {
get current() {
return markdownDocs
}
},
setMarkdownDocs: (update) => {
markdownDocs = typeof update === 'function' ? update(markdownDocs) : update
},