diff --git a/src/main/runtime/mobile-session-editor-projection.test.ts b/src/main/runtime/mobile-session-editor-projection.test.ts index c3417c56f08..19674d4af17 100644 --- a/src/main/runtime/mobile-session-editor-projection.test.ts +++ b/src/main/runtime/mobile-session-editor-projection.test.ts @@ -73,6 +73,23 @@ describe('headless editor tabs under an owned id', () => { expect(retired?.tabGroups?.[WT]?.[0]?.tabOrder).toEqual([]) }) + it('does not list a row mirrored from a paired host', () => { + const session = ownedSession() + session.openFilesByWorktree = { + [WT]: [ + { + filePath: FILE, + relativePath: 'notes.ts', + worktreeId: WT, + language: 'typescript', + runtimeEnvironmentId: 'peer', + mirroredFromRuntimeSession: true + } + ] + } + expect(buildHeadlessMobileSessionEditorTabs(WT, session)).toEqual([]) + }) + it('shows it as the active file', () => { expect(buildHeadlessMobileSessionEditorTabs(WT, activeOwnedSession())).toEqual([ expect.objectContaining({ id: 'tab-1', isActive: true }) diff --git a/src/main/runtime/mobile-session-editor-projection.ts b/src/main/runtime/mobile-session-editor-projection.ts index 51e9d73899b..4eae813867a 100644 --- a/src/main/runtime/mobile-session-editor-projection.ts +++ b/src/main/runtime/mobile-session-editor-projection.ts @@ -86,9 +86,10 @@ export function buildHeadlessMobileSessionEditorTabs( worktreeId: string, session: WorkspaceSessionState ): HeadlessEditorTab[] { - return (session.openFilesByWorktree?.[worktreeId] ?? []).map((file) => - editorTab(session, worktreeId, file) - ) + // A row mirrored from a paired host is that host's to publish, as in the window's publication. + return (session.openFilesByWorktree?.[worktreeId] ?? []) + .filter((file) => file.mirroredFromRuntimeSession !== true) + .map((file) => editorTab(session, worktreeId, file)) } /** The record the phone's tab names: two owners can hold one path, so by the id it was listed under. */ diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index f5bfd9e8639..280e9b15dd3 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -142,6 +142,7 @@ export function buildEditorSessionData( language: f.language, isPreview: f.isPreview || undefined, runtimeEnvironmentId: f.runtimeEnvironmentId, + ...(f.mirroredFromRuntimeSession === true ? { mirroredFromRuntimeSession: true } : {}), externalSshTargetId: f.externalSshTargetId, // Why: persist readOnly only when true; absence is the writable default on restore. ...(f.readOnly === true ? { readOnly: true } : {}), @@ -159,10 +160,7 @@ export function buildEditorSessionData( const activeFileEntries: [string, string][] = [] for (const [worktreeId, fileId] of Object.entries(activeFileIdByWorktree)) { - if (!fileId) { - continue - } - if (editFileIdsByWorktree[worktreeId]?.has(fileId)) { + if (fileId && editFileIdsByWorktree[worktreeId]?.has(fileId)) { activeFileEntries.push([worktreeId, fileId]) } } diff --git a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts index 8797b917501..d76c7c011c1 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts @@ -59,6 +59,7 @@ function referenceOpenFilesProjection(openFiles: AppState['openFiles']): string mode: file.mode, diffSource: file.diffSource, isDirty: file.isDirty, + mirroredFromRuntimeSession: file.mirroredFromRuntimeSession, isUntitled: file.isUntitled, deleteUntouchedOnClose: file.deleteUntouchedOnClose, markdownPreviewSourceFileId: file.markdownPreviewSourceFileId @@ -423,6 +424,7 @@ describe('open-files and browser projections', () => { const openFileCases: AppState['openFiles'][] = [ [] as unknown as AppState['openFiles'], [makeOpenFile(0)] as unknown as AppState['openFiles'], + [makeOpenFile(0, { mirroredFromRuntimeSession: true })], [makeOpenFile(0, { isDirty: true })] as unknown as AppState['openFiles'], [ makeOpenFile(0, { isDirty: true, diffSource: 'working' }), diff --git a/src/renderer/src/runtime/sync-runtime-graph/mirrored-editor-tab-persistence.test.ts b/src/renderer/src/runtime/sync-runtime-graph/mirrored-editor-tab-persistence.test.ts new file mode 100644 index 00000000000..6cf1b2bae05 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mirrored-editor-tab-persistence.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { parseWorkspaceSessionSalvaging } from '../../../../shared/workspace-session-salvage' +import { UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH } from '../../../../shared/runtime-types' +import { buildEditorSessionData } from '../../lib/workspace-session' +import { buildHydratedEditorSessionState } from '../../store/slices/editor/actions/hydrated-editor-session-state' +import { createTestStore, makeWorktree } from '../../store/slices/store-test-helpers' +import { createStoreSessionMockApi } from '../../store/slices/store-session-test-harness' +import { applyWebSessionTabsSnapshot } from '../web-session-tabs-sync/snapshot-api' +import { + ENV, + NOW, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from '../web-session-tabs-sync-test-harness' +import { buildMobileSessionTabSnapshots } from './mobile-session-snapshots' + +vi.mock('../../store', () => ({ useAppStore: { setState: vi.fn() } })) +createStoreSessionMockApi() +beforeEach(resetWebSessionTabsSyncTestState) + +it('does not republish a mirrored editor after persistence and hydration', () => { + const snapshot = makeSnapshot([ + { + type: 'file', + id: 'host-file-tab', + title: 'app.ts', + filePath: '/repo/app.ts', + relativePath: 'app.ts', + language: 'typescript', + mode: 'edit', + isDirty: false, + isActive: true + } + ]) + const state = makeState() + const applied = { ...state, ...applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW) } + const persisted = buildEditorSessionData(applied.openFiles, {}, {}, {}, {}) + const restored = createTestStore() + restored.setState({ + repos: [{ id: 'repo', path: '/repo', displayName: 'Repo', badgeColor: '', addedAt: 0 }], + worktreesByRepo: { repo: [makeWorktree({ id: WT, repoId: 'repo', path: '/worktree' })] }, + activeWorktreeId: WT + }) + const loaded = parseWorkspaceSessionSalvaging({ + activeRepoId: 'repo', + activeWorktreeId: WT, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...persisted + }) + if (!loaded.ok) { + throw new Error(loaded.error) + } + expect(loaded.droppedCount).toBe(0) + expect(loaded.value.openFilesByWorktree?.[WT]?.[0]).toHaveProperty( + 'mirroredFromRuntimeSession', + true + ) + restored.getState().hydrateEditorSession(loaded.value) + + expect(restored.getState().openFiles).toHaveLength(1) + expect(restored.getState().openFiles[0]?.id).not.toBe('/repo/app.ts') + expect(buildMobileSessionTabSnapshots(restored.getState(), false).flatMap((s) => s.tabs)).toEqual( + [] + ) + expect(persisted.openFilesByWorktree?.[WT]?.[0]).toHaveProperty( + 'mirroredFromRuntimeSession', + true + ) + expect(restored.getState().openFiles[0]).toHaveProperty('mirroredFromRuntimeSession', true) + + restored.setState(applyWebSessionTabsSnapshot(restored.getState(), snapshot, ENV, NOW + 1)) + expect(buildMobileSessionTabSnapshots(restored.getState(), false).flatMap((s) => s.tabs)).toEqual( + [] + ) + + for (const flag of [false, undefined]) { + const local = buildEditorSessionData( + applied.openFiles.map((file) => ({ ...file, mirroredFromRuntimeSession: flag })), + {}, + {}, + {}, + {} + ) + expect(local.openFilesByWorktree?.[WT]?.[0]).not.toHaveProperty('mirroredFromRuntimeSession') + } +}) + +it('keeps a restored dirty mirror until the host publishes the worktree', () => { + const persisted = buildEditorSessionData( + [ + { + id: 'host-file', + filePath: '/repo/app.ts', + relativePath: 'app.ts', + worktreeId: WT, + language: 'typescript', + mode: 'edit', + isDirty: true, + runtimeEnvironmentId: ENV, + mirroredFromRuntimeSession: true + } + ], + { 'host-file': 'unsaved client edits' }, + {}, + {}, + {} + ) + const restored = buildHydratedEditorSessionState( + { + activeRepoId: 'repo', + activeWorktreeId: WT, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...persisted + }, + new Set([WT]), + WT + ).state + const state = makeState(restored) + const unpublished = makeSnapshot([], { + publicationEpoch: UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH, + snapshotVersion: 0 + }) + const held = { ...state, ...applyWebSessionTabsSnapshot(state, unpublished, ENV, NOW) } + expect( + buildEditorSessionData(held.openFiles, held.editorDrafts ?? {}, {}, {}, {}) + .openFilesByWorktree?.[WT]?.[0]?.dirtyDraftContent + ).toBe('unsaved client edits') + + const closed = { + ...held, + ...applyWebSessionTabsSnapshot(held, makeSnapshot([]), ENV, NOW + 1) + } + expect(closed.openFiles).toEqual([]) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph/mirrored-editor-tab-republication.test.ts b/src/renderer/src/runtime/sync-runtime-graph/mirrored-editor-tab-republication.test.ts new file mode 100644 index 00000000000..c6a664b1611 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mirrored-editor-tab-republication.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeState as makePublicationState } from '../sync-runtime-graph-test-harness' +import { applyWebSessionTabsSnapshot } from '../web-session-tabs-sync/snapshot-api' +import { + ENV, + NOW, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from '../web-session-tabs-sync-test-harness' +import { buildMobileSessionTabSnapshots } from './mobile-session-snapshots' +import { getRuntimeMobileSessionSyncKey, runtimeMobileSessionSyncKeysEqual } from './sync-key' +import type { RuntimeMobileSessionFileTab } from '../../../../shared/runtime-types' + +vi.mock('../../store', () => ({ useAppStore: { setState: vi.fn() } })) + +describe('mirrored editor tab republication', () => { + beforeEach(resetWebSessionTabsSyncTestState) + + function mirrorHostTab(tab: RuntimeMobileSessionFileTab) { + const state = makeState() + const patch = applyWebSessionTabsSnapshot(state, makeSnapshot([tab]), ENV, NOW) + return makePublicationState({ ...state, ...patch }) + } + + const fileTab: RuntimeMobileSessionFileTab = { + type: 'file', + id: 'host-file-tab', + title: 'app.ts', + filePath: '/repo/app.ts', + relativePath: 'app.ts', + language: 'typescript', + mode: 'edit', + isDirty: false, + isActive: true + } + + it('does not republish a file mirrored from a host snapshot and caches the empty snapshot', () => { + const clientState = mirrorHostTab(fileTab) + expect(clientState.openFiles).toMatchObject([ + { worktreeId: WT, runtimeEnvironmentId: ENV, mirroredFromRuntimeSession: true } + ]) + expect(clientState.unifiedTabsByWorktree[WT]).toMatchObject([ + { id: 'host-file-tab', contentType: 'editor' } + ]) + const snapshots = buildMobileSessionTabSnapshots(clientState, false) + expect(snapshots).toMatchObject([{ worktree: WT, tabs: [] }]) + expect(buildMobileSessionTabSnapshots(clientState, false)[0]).toBe(snapshots[0]) + }) + + it('keeps a genuinely local file in the same worktree and retracts it after close', () => { + const mirroredState = mirrorHostTab(fileTab) + const clientState = { + ...mirroredState, + openFiles: [ + ...mirroredState.openFiles, + { + id: 'local-file', + filePath: '/repo/local.ts', + relativePath: 'local.ts', + worktreeId: WT, + language: 'typescript', + mode: 'edit' as const, + isDirty: false + } + ] + } + const snapshots = buildMobileSessionTabSnapshots(clientState, false) + expect(snapshots).toHaveLength(1) + expect(snapshots[0]?.tabs).toMatchObject([ + { type: 'file', id: 'local-file', filePath: '/repo/local.ts' } + ]) + expect(snapshots[0]?.tabGroups?.flatMap((group) => group.tabOrder)).not.toContain( + 'host-file-tab' + ) + expect(buildMobileSessionTabSnapshots(mirroredState, false)).toMatchObject([ + { worktree: WT, tabs: [] } + ]) + expect(buildMobileSessionTabSnapshots(clientState, false)[0]?.snapshotVersion).toBeGreaterThan( + snapshots[0]!.snapshotVersion + ) + }) + + it('filters mirrors without unified tabs or groups', () => { + const clientState = mirrorHostTab(fileTab) + expect( + buildMobileSessionTabSnapshots( + makePublicationState({ openFiles: clientState.openFiles }), + false + ) + ).toMatchObject([{ worktree: WT, tabs: [] }]) + }) + + it('resyncs when only the mirror ownership flag changes', () => { + const mirroredState = mirrorHostTab(fileTab) + const ownedState = { + ...mirroredState, + openFiles: mirroredState.openFiles.map((file) => ({ + ...file, + mirroredFromRuntimeSession: false + })) + } + const ownedKey = getRuntimeMobileSessionSyncKey(ownedState, undefined, undefined, false) + const mirroredKey = getRuntimeMobileSessionSyncKey(mirroredState, ownedState, ownedKey, false) + expect(runtimeMobileSessionSyncKeysEqual(ownedKey, mirroredKey)).toBe(false) + expect(buildMobileSessionTabSnapshots(ownedState, false)[0]?.tabs).toMatchObject([ + { type: 'file', id: 'host-file-tab' } + ]) + expect(buildMobileSessionTabSnapshots(mirroredState, false)).toMatchObject([ + { worktree: WT, tabs: [] } + ]) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts index 2550795f83e..ac154ba0873 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts @@ -78,6 +78,10 @@ export function isMobileFileDiffSource( } export function isMobilePublishableOpenFile(file: AppState['openFiles'][number]): boolean { + // A tab mirrored from a paired host is that host's; republishing it echoes it back to the host. + if (file.mirroredFromRuntimeSession === true) { + return false + } // Combined diff tabs use display labels as paths and need the desktop renderer. if ( file.diffSource === 'combined-all' || diff --git a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts index 44a1606b056..935417df872 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts @@ -109,6 +109,7 @@ export function buildRuntimeMobileOpenFilesProjection(openFiles: AppState['openF mode: file.mode, diffSource: file.diffSource, isDirty: file.isDirty, + mirroredFromRuntimeSession: file.mirroredFromRuntimeSession, isUntitled: file.isUntitled, deleteUntouchedOnClose: file.deleteUntouchedOnClose, markdownPreviewSourceFileId: file.markdownPreviewSourceFileId diff --git a/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts index ccca65f73c8..0ec291df910 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts @@ -638,11 +638,15 @@ describe('applyWebSessionTabsSnapshot host-invariant editor dedup', () => { buildOwnedEditorFileId('/repo/README.md', WT, null), '/repo/README.md' ] + // An older peer republishes its mirror as its own row; this build no longer publishes mirrors. + const olderPeerFile: OpenFile = { ...ownedFile, mirroredFromRuntimeSession: undefined } const published = buildMobileSessionTabSnapshots( makePublisherState({ - openFiles: [ownedFile], + openFiles: [olderPeerFile], unifiedTabsByWorktree: { - [WT]: ids.flatMap((id) => stateWithVariant(id, ownedFile).unifiedTabsByWorktree[WT] ?? []) + [WT]: ids.flatMap( + (id) => stateWithVariant(id, olderPeerFile).unifiedTabsByWorktree[WT] ?? [] + ) } }) )[0] diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts index 914edaa3259..ffdaee0d228 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts @@ -10,7 +10,8 @@ import { buildMirroredEditorTabs } from './tab-builders' import { buildMirroredAgentTabs, isReadyBrowserTab, isReadyEditorTab } from './terminal-surfaces' import { hostSnapshotAffirmsAgentSessions, - hostSnapshotAffirmsClientHostedPages + hostSnapshotAffirmsClientHostedPages, + hostSnapshotAffirmsWorktreeContents } from '../host-session-snapshot-authority' import type { prepareWebSessionTabsSnapshotBase } from './apply-preparation-base' import type { OpenFile } from '../../store/slices/editor' @@ -143,8 +144,11 @@ export function prepareWebSessionTabsSnapshotBrowser( ...new Map(mirroredEditorTabs.map((entry) => [entry.file.id, entry.file])).values() ] const mirroredFileRecords = new Set(mirroredOpenFiles) + // A host that has not published this worktree yet proves nothing about its editor tabs. + const reconcilesEditorFiles = + reconcilesNonAgentTabs && hostSnapshotAffirmsWorktreeContents(snapshot) const removedEditorFileIds = new Set( - (reconcilesNonAgentTabs ? worktreeOpenFiles : []) + (reconcilesEditorFiles ? worktreeOpenFiles : []) .filter( (file) => file.runtimeEnvironmentId === environmentId && diff --git a/src/renderer/src/store/slices/editor/actions/hydrated-editor-session-state.ts b/src/renderer/src/store/slices/editor/actions/hydrated-editor-session-state.ts index 2bd5f3c07cd..67493006c9c 100644 --- a/src/renderer/src/store/slices/editor/actions/hydrated-editor-session-state.ts +++ b/src/renderer/src/store/slices/editor/actions/hydrated-editor-session-state.ts @@ -107,6 +107,7 @@ export function buildHydratedEditorSessionState( isDirty: !isReadOnly && pf.dirtyDraftContent !== undefined, isPreview: pf.isPreview, runtimeEnvironmentId: pf.runtimeEnvironmentId, + mirroredFromRuntimeSession: pf.mirroredFromRuntimeSession, externalSshTargetId: pf.externalSshTargetId, ...(isReadOnly ? { readOnly: true } : {}), ...(isReadOnly && pf.liveTail === true ? { liveTail: true } : {}), diff --git a/src/shared/workspace-layout/workspace-layout-disk-fields.ts b/src/shared/workspace-layout/workspace-layout-disk-fields.ts index 407aaae28b6..f15a20e498d 100644 --- a/src/shared/workspace-layout/workspace-layout-disk-fields.ts +++ b/src/shared/workspace-layout/workspace-layout-disk-fields.ts @@ -200,6 +200,7 @@ const FILE = { language: own('file', 'language'), isPreview: tab('isPreview'), runtimeEnvironmentId: own('file', 'runtimeEnvironmentId'), + mirroredFromRuntimeSession: own('file', 'mirroredFromRuntimeSession'), externalSshTargetId: own('file', 'externalSshTargetId'), dirtyDraftContent: own('editorDraft', 'dirtyDraftContent'), lastKnownDiskSignature: own('editorDraft', 'lastKnownDiskSignature'), diff --git a/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts b/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts index befad920128..0453e218367 100644 --- a/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts +++ b/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts @@ -126,6 +126,7 @@ function populate(loaded: LoadedWorkspaceLayout): LoadedWorkspaceLayout { fill(loaded, 'tab.terminal.forceHostRuntime', true) fill(loaded, 'file.externalSshTargetId', 'box') fill(loaded, 'file.runtimeEnvironmentId', 'env') + fill(loaded, 'file.mirroredFromRuntimeSession', true) fill(loaded, 'file.readOnly', true) fill(loaded, 'file.liveTail', true) // A focused and an expanded pane that is not the first, so a fallback is visible. diff --git a/src/shared/workspace-session-editor-schema.ts b/src/shared/workspace-session-editor-schema.ts index 730c3c8e621..84c23b739a0 100644 --- a/src/shared/workspace-session-editor-schema.ts +++ b/src/shared/workspace-session-editor-schema.ts @@ -10,6 +10,7 @@ export const persistedOpenFileSchema = z.object({ language: z.string(), isPreview: z.boolean().optional(), runtimeEnvironmentId: z.string().nullable().optional(), + mirroredFromRuntimeSession: z.literal(true).optional(), externalSshTargetId: z.string().trim().min(1).optional(), dirtyDraftContent: z.string().optional(), lastKnownDiskSignature: z.string().optional(), diff --git a/src/shared/workspace-session-state-types.ts b/src/shared/workspace-session-state-types.ts index 6b109e7651d..1340b80fa88 100644 --- a/src/shared/workspace-session-state-types.ts +++ b/src/shared/workspace-session-state-types.ts @@ -21,6 +21,7 @@ export type PersistedOpenFile = { language: string isPreview?: boolean runtimeEnvironmentId?: string | null + mirroredFromRuntimeSession?: true /** SSH target that owns an absolute path outside the worktree. */ externalSshTargetId?: string /** Unsaved editor buffer captured for hot exit; presence restores the tab dirty. */