diff --git a/config/vitest.config.ts b/config/vitest.config.ts index bee38dc32c9..54f816309f8 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -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'] } }) diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index 505a5722bd4..e7ef7ca8c90 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -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() + }) }) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 790723239a7..189016471ef 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -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 } + }) } } }, diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts new file mode 100644 index 00000000000..20c0d99ada1 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -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 } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 23c84b7be9d..547ff5933f9 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -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, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cf1f063ec8a..82e6a0dcd7d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(() => { diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts index 0fad460e7cb..4af60cbaab4 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts @@ -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' diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts index 02e1d1b006d..58d11e303c5 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.ts @@ -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 { diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts new file mode 100644 index 00000000000..7c7731bd4a0 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -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 { + 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)) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index f6dbc362c8c..7d14f4d21af 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -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 { 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) diff --git a/src/shared/mobile-markdown-document.ts b/src/shared/mobile-markdown-document.ts index e1b196330a6..45fc1eb4ac1 100644 --- a/src/shared/mobile-markdown-document.ts +++ b/src/shared/mobile-markdown-document.ts @@ -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 {