mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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. */
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
+114
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user