fix(runtime): stop paired desktops republishing mirrored editor tabs (#33259)

* fix(runtime): stop paired desktops republishing mirrored editor tabs

When two desktops pair both ways, B mirrored A's editor tabs and then
published them in its own session inventory as if B had opened them. A
then applied B's copy, which took over A's own tab.

isMobilePublishableOpenFile now skips rows marked
mirroredFromRuntimeSession, the marker is part of the open-files sync
projection so a flip republishes, and the marker is saved with the
session and restored so a relaunched client does not republish its
restored mirrors before the host's first snapshot.

Based on #25877 by BearMett.

Co-authored-by: BearMett <agomdole@gmail.com>

* fix(runtime): keep restored mirrors through unpublished frames and out of headless publication

A restored mirror now carries its marker, so an unpublished host frame
must not cull it (and its recovered draft). The windowless publication
skips persisted mirrored rows for the same reason the window does.

Co-authored-by: BearMett <agomdole@gmail.com>

---------

Co-authored-by: BearMett <agomdole@gmail.com>
This commit is contained in:
Neil
2026-10-24 14:28:33 -07:00
committed by GitHub
co-authored by BearMett
parent 976fa11e81
commit 917899bf8d
15 changed files with 301 additions and 11 deletions
@@ -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 })
@@ -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. */
+2 -4
View File
@@ -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])
}
}
@@ -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' }),
@@ -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([])
})
@@ -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: [] }
])
})
})
@@ -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' ||
@@ -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
@@ -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]
@@ -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 &&
@@ -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 } : {}),
@@ -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'),
@@ -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.
@@ -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(),
@@ -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. */