Harden mobile tab and markdown sync

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-12 17:26:49 -04:00
co-authored by Orca
parent c4c8723e1e
commit ef2fa08c24
11 changed files with 268 additions and 9 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ export default defineConfig({
},
test: {
environment: 'node',
include: ['src/**/*.test.ts', 'src/**/*.test.tsx']
include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'mobile/src/**/*.test.ts']
}
})
+23
View File
@@ -108,4 +108,27 @@ describe('mobile rpc-client connection timeout', () => {
client.close()
})
it('sends session tabs unsubscribe when a session tab stream is disposed', () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
const unsubscribe = client.subscribe(
'session.tabs.subscribe',
{ worktree: 'id:wt-1' },
() => {}
)
unsubscribe()
expect(
socket.sent.some((payload) => payload.includes('"method":"session.tabs.unsubscribe"'))
).toBe(true)
expect(socket.sent.some((payload) => payload.includes('"worktree":"id:wt-1"'))).toBe(true)
client.close()
})
})
+12
View File
@@ -893,6 +893,18 @@ export function connect(
...(clientId ? { client: { id: clientId } } : {})
}
})
} else if (
stream?.method === 'session.tabs.subscribe' &&
stream.params &&
typeof stream.params === 'object' &&
typeof (stream.params as { worktree?: unknown }).worktree === 'string'
) {
sendEncrypted({
id: nextId(),
deviceToken,
method: 'session.tabs.unsubscribe',
params: { worktree: (stream.params as { worktree: string }).worktree }
})
}
}
},
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { SESSION_TAB_METHODS } from './session-tabs'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('session tab RPC methods', () => {
it('unsubscribes a session tabs stream using the resolved worktree id and connection id', async () => {
const cleanupSubscription = vi.fn()
const runtime = {
getRuntimeId: () => 'test-runtime',
listMobileSessionTabs: vi.fn().mockResolvedValue({
worktree: 'wt-1',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}),
cleanupSubscription
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const messages: string[] = []
await dispatcher.dispatchStreaming(
makeRequest('session.tabs.unsubscribe', { worktree: 'id:wt-1' }),
(message) => messages.push(message),
{ connectionId: 'conn-1' }
)
expect(cleanupSubscription).toHaveBeenCalledWith('session.tabs:conn-1:wt-1')
expect(JSON.parse(messages[0]!)).toMatchObject({
ok: true,
result: { unsubscribed: true }
})
})
})
@@ -52,11 +52,21 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
subscriptionId,
() => {
unsubscribe()
emit({ type: 'end' })
},
connectionId
)
}
}),
defineMethod({
name: 'session.tabs.unsubscribe',
params: WorktreeTabSelector,
handler: async (params, { runtime, connectionId }) => {
const snapshot = await runtime.listMobileSessionTabs(params.worktree)
runtime.cleanupSubscription(`session.tabs:${connectionId ?? 'local'}:${snapshot.worktree}`)
return { unsubscribed: true }
}
}),
defineMethod({
name: 'markdown.readTab',
params: ActivateTab,
+28
View File
@@ -43,6 +43,8 @@ import { useEditorExternalWatch } from './hooks/useEditorExternalWatch'
import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent'
import { useUnreadDockBadge } from './hooks/useUnreadDockBadge'
import {
getRuntimeMobileSessionSyncKey,
scheduleRuntimeGraphSync,
setRuntimeGraphStoreStateGetter,
setRuntimeGraphSyncEnabled
} from './runtime/sync-runtime-graph'
@@ -403,6 +405,32 @@ function App(): React.JSX.Element {
}
}, [])
useEffect(() => {
let previousKey = getRuntimeMobileSessionSyncKey(useAppStore.getState())
return useAppStore.subscribe((state, previousState) => {
if (
state.tabsByWorktree === previousState.tabsByWorktree &&
state.groupsByWorktree === previousState.groupsByWorktree &&
state.activeGroupIdByWorktree === previousState.activeGroupIdByWorktree &&
state.unifiedTabsByWorktree === previousState.unifiedTabsByWorktree &&
state.tabBarOrderByWorktree === previousState.tabBarOrderByWorktree &&
state.activeFileId === previousState.activeFileId &&
state.activeFileIdByWorktree === previousState.activeFileIdByWorktree &&
state.openFiles === previousState.openFiles &&
state.editorDrafts === previousState.editorDrafts &&
state.activeTabId === previousState.activeTabId
) {
return
}
const nextKey = getRuntimeMobileSessionSyncKey(state)
if (nextKey === previousKey) {
return
}
previousKey = nextKey
scheduleRuntimeGraphSync()
})
}, [])
useEffect(() => registerUpdaterBeforeUnloadBypass(), [])
useEffect(() => {
@@ -251,6 +251,43 @@ describe('mobile markdown bridge', () => {
}
})
it('restores the previous desktop draft when save verification fails after write', async () => {
openMarkdownFile()
const state = useAppStore.getState()
state.setEditorDraft('/repo/README.md', 'desktop draft')
state.markFileDirty('/repo/README.md', true)
let diskContent = 'desktop draft'
const readFile = vi.fn().mockImplementation(async () => ({
content: 'verified mismatch',
isBinary: false
}))
const writeFile = vi.fn().mockImplementation(async ({ content }) => {
diskContent = content
})
setupWindow({ readFile, writeFile })
const detachBridge = attachMobileMarkdownBridge()
const detachAutosave = attachEditorAutosaveController(useAppStore as never)
try {
const response = await sendRequest({
id: 'save-verify-fail',
operation: 'save',
worktreeId: 'wt-1',
tabId: 'tab-md',
baseVersion: hashMarkdownContent('desktop draft'),
content: 'mobile edit'
})
expect(response).toMatchObject({ id: 'save-verify-fail', ok: false })
expect(diskContent).toBe('mobile edit')
expect(useAppStore.getState().editorDrafts['/repo/README.md']).toBe('desktop draft')
expect(useAppStore.getState().openFiles[0]?.isDirty).toBe(true)
} finally {
detachAutosave()
detachBridge()
}
})
it('serializes saves so duplicate base versions do not both write', async () => {
openMarkdownFile()
let diskContent = 'original'
@@ -145,11 +145,12 @@ function restoreFailedMobileSaveDraft(
previousDirty: boolean
): void {
const state = useAppStore.getState()
if (state.editorDrafts[fileId] !== injectedContent) {
const currentDraft = state.editorDrafts[fileId]
if (currentDraft !== undefined && currentDraft !== injectedContent) {
return
}
// Why: mobile save injects a desktop draft only to reuse the editor save path.
// If that save fails, put the desktop editor back exactly as it was before.
// If save or verification fails, put the desktop editor back exactly as it was before.
if (previousDraft === undefined) {
state.clearEditorDraft(fileId)
} else {
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { getRuntimeMobileSessionSyncKey } from './sync-runtime-graph'
import type { AppState } from '../store/types'
function makeState(overrides: Partial<AppState> = {}): AppState {
return {
tabsByWorktree: {},
groupsByWorktree: {},
activeGroupIdByWorktree: {},
unifiedTabsByWorktree: {},
tabBarOrderByWorktree: {},
activeFileId: null,
activeFileIdByWorktree: {},
openFiles: [],
editorDrafts: {},
activeTabId: null,
...overrides
} as AppState
}
describe('getRuntimeMobileSessionSyncKey', () => {
it('changes when mobile markdown tab state changes', () => {
const base = makeState({
openFiles: [
{
id: '/repo/README.md',
filePath: '/repo/README.md',
relativePath: 'README.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit',
isDirty: false
}
]
})
const cleanKey = getRuntimeMobileSessionSyncKey(base)
const dirtyKey = getRuntimeMobileSessionSyncKey(
makeState({
...base,
openFiles: [{ ...base.openFiles[0]!, isDirty: true }],
editorDrafts: { '/repo/README.md': '# draft' }
})
)
const activatedKey = getRuntimeMobileSessionSyncKey(
makeState({ ...base, activeFileId: '/repo/README.md' })
)
expect(dirtyKey).not.toBe(cleanKey)
expect(activatedKey).not.toBe(cleanKey)
})
it('changes when legacy tab bar order changes', () => {
const base = makeState({
tabBarOrderByWorktree: { 'wt-1': ['term-1', '/repo/README.md'] }
})
const reordered = getRuntimeMobileSessionSyncKey(
makeState({
...base,
tabBarOrderByWorktree: { 'wt-1': ['/repo/README.md', 'term-1'] }
})
)
expect(reordered).not.toBe(getRuntimeMobileSessionSyncKey(base))
})
})
+42 -2
View File
@@ -64,6 +64,45 @@ export function scheduleRuntimeGraphSync(): void {
})
}
export function getRuntimeMobileSessionSyncKey(state: AppState): string {
return JSON.stringify({
tabsByWorktree: Object.fromEntries(
Object.entries(state.tabsByWorktree).map(([worktreeId, tabs]) => [
worktreeId,
tabs.map((tab) => ({
id: tab.id,
title: tab.title,
customTitle: tab.customTitle,
active: state.activeTabId === tab.id
}))
])
),
groupsByWorktree: state.groupsByWorktree,
activeGroupIdByWorktree: state.activeGroupIdByWorktree,
unifiedTabsByWorktree: state.unifiedTabsByWorktree,
tabBarOrderByWorktree: state.tabBarOrderByWorktree,
activeFileId: state.activeFileId,
activeFileIdByWorktree: state.activeFileIdByWorktree,
openFiles: state.openFiles.map((file) => ({
id: file.id,
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
language: file.language,
mode: file.mode,
isDirty: file.isDirty,
isUntitled: file.isUntitled,
markdownPreviewSourceFileId: file.markdownPreviewSourceFileId
})),
editorDrafts: Object.fromEntries(
Object.entries(state.editorDrafts).map(([fileId, content]) => [
fileId,
stableHashString(content)
])
)
})
}
async function syncRuntimeGraph(): Promise<void> {
if (!syncEnabled || !getStoreState) {
return
@@ -233,11 +272,12 @@ function buildMobileMarkdownTab(
sourceFileId: sourceFile.id,
sourceFilePath: sourceFile.filePath,
sourceRelativePath: sourceFile.relativePath,
documentVersion: draftContent !== undefined ? hashString(draftContent) : `file:${sourceFile.id}`
documentVersion:
draftContent !== undefined ? stableHashString(draftContent) : `file:${sourceFile.id}`
}
}
function hashString(value: string): string {
function stableHashString(value: string): string {
let hash = 2166136261
for (let i = 0; i < value.length; i += 1) {
hash ^= value.charCodeAt(i)
+4 -4
View File
@@ -54,12 +54,12 @@ export type RuntimeMarkdownSaveTabResult = {
}
export function hashMarkdownContent(content: string): string {
let hash = 2166136261
let hash = 0xcbf29ce484222325n
for (let i = 0; i < content.length; i += 1) {
hash ^= content.charCodeAt(i)
hash = Math.imul(hash, 16777619)
hash ^= BigInt(content.charCodeAt(i))
hash = BigInt.asUintN(64, hash * 0x100000001b3n)
}
return `content:${utf8ByteLength(content)}:${(hash >>> 0).toString(16)}`
return `content:${utf8ByteLength(content)}:${hash.toString(16).padStart(16, '0')}`
}
export function utf8ByteLength(content: string): number {