diff --git a/src/main/runtime/orca-runtime-mobile-markdown-tab-routes.test.ts b/src/main/runtime/orca-runtime-mobile-markdown-tab-routes.test.ts index bc17398e0c9..c2df94fb9d7 100644 --- a/src/main/runtime/orca-runtime-mobile-markdown-tab-routes.test.ts +++ b/src/main/runtime/orca-runtime-mobile-markdown-tab-routes.test.ts @@ -29,7 +29,7 @@ vi.mock('./files/chat-scratch-file-root', async (importOriginal) => ({ const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') await import('./orca-runtime-test-lifecycle.spec') -const { TEST_WORKTREE_ID, makeRuntimeStoreWithWorkspaceSession } = +const { TEST_REPO_ID, TEST_WORKTREE_ID, makeRuntimeStoreWithWorkspaceSession, store } = await import('./orca-runtime-test-fixtures.spec') type Runtime = InstanceType @@ -454,7 +454,7 @@ describe("a phone's Markdown tab with a desktop window attached", () => { ): RuntimeMobileSessionMarkdownTab { return { type: 'markdown', - id: 'tab-readme', + id: readme, title: 'README.md', filePath: readme, relativePath: 'README.md', @@ -470,12 +470,52 @@ describe("a phone's Markdown tab with a desktop window attached", () => { } } + /** As the window saves it: each open editor also holds its unified tab (id = path) and group. */ + function withSavedEditorTabs(session: WorkspaceSessionState): WorkspaceSessionState { + const files = session.openFilesByWorktree?.[TEST_WORKTREE_ID] ?? [] + const tabs = files.map((file, sortOrder) => ({ + id: file.filePath, + entityId: file.filePath, + groupId: 'group-1', + worktreeId: TEST_WORKTREE_ID, + contentType: 'editor' as const, + label: file.relativePath, + customLabel: null, + color: null, + sortOrder, + createdAt: 1 + })) + return { + ...session, + unifiedTabs: { [TEST_WORKTREE_ID]: tabs }, + tabGroups: { + [TEST_WORKTREE_ID]: [ + { + id: 'group-1', + worktreeId: TEST_WORKTREE_ID, + activeTabId: tabs[0]?.id ?? null, + tabOrder: tabs.map((tab) => tab.id) + } + ] + }, + activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-1' } + } + } + + // Why the host ids: like the real Store, the fixture lists its partitions, which main's layout reads. function runtimeWithWindowTabs( tabs: RuntimeMobileSessionMarkdownTab[], - session = sessionWithReadme() + session = sessionWithReadme(), + ownerHostId: 'local' | 'ssh:ssh-1' = 'local' ): Runtime { - const harness = makeRuntimeStoreWithWorkspaceSession(session) - const runtime = new OrcaRuntimeService(harness.runtimeStore) + const harness = makeRuntimeStoreWithWorkspaceSession(withSavedEditorTabs(session), ownerHostId) + const repo = { ...store.getRepos()[0]!, executionHostId: ownerHostId } + const runtime = new OrcaRuntimeService({ + ...harness.runtimeStore, + getRepos: () => [repo], + getRepo: (id: string) => (id === TEST_REPO_ID ? repo : undefined), + getWorkspaceSessionHostIds: () => [ownerHostId] + }) runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [], @@ -499,9 +539,9 @@ describe("a phone's Markdown tab with a desktop window attached", () => { const runtime = runtimeWithWindowTabs([windowTab({ isDirty: true })]) await expect( - runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, 'tab-readme') + runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, readme) ).resolves.toMatchObject({ - tabId: 'tab-readme', + tabId: readme, content: '# Notes\n', isDirty: true, source: 'file', @@ -561,9 +601,11 @@ describe("a phone's Markdown tab with a desktop window attached", () => { it('reads an untitled tab read-only and never saves it', async () => { const runtime = runtimeWithWindowTabs([windowTab({ isUntitled: true, isDirty: true })]) + // The phone lists the workspace before it reads a tab. + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) await expect( - runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, 'tab-readme') + runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, readme) ).resolves.toMatchObject({ content: '# Notes\n', isDirty: true, @@ -573,7 +615,7 @@ describe("a phone's Markdown tab with a desktop window attached", () => { await expect( runtime.saveMobileMarkdownTab( `id:${TEST_WORKTREE_ID}`, - 'tab-readme', + readme, hashMarkdownContent('# Notes\n'), '# Phone\n' ) @@ -611,6 +653,25 @@ describe("a phone's Markdown tab with a desktop window attached", () => { ).rejects.toThrow('unsupported_untitled') expect(existsSync(untitled)).toBe(false) }) + it('keeps an SSH-hosted untitled tab read-only, refusing the save before reaching the host', async () => { + const runtime = runtimeWithWindowTabs( + [windowTab({ isUntitled: true, isDirty: true })], + sessionWithReadme(), + 'ssh:ssh-1' + ) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(listed.tabs.find((tab) => tab.id === readme)).toMatchObject({ isUntitled: true }) + await expect( + runtime.saveMobileMarkdownTab( + `id:${TEST_WORKTREE_ID}`, + readme, + hashMarkdownContent('# Notes\n'), + '# Phone\n' + ) + ).rejects.toThrow('unsupported_untitled') + expect(await readFile(readme, 'utf8')).toBe('# Notes\n') + }) }) describe("a phone's Markdown tab with no window, on a desktop's untitled document", () => { diff --git a/src/main/runtime/session-tabs/main-session-tab-rows.test.ts b/src/main/runtime/session-tabs/main-session-tab-rows.test.ts index 2625f7d9899..9a2e2ac35aa 100644 --- a/src/main/runtime/session-tabs/main-session-tab-rows.test.ts +++ b/src/main/runtime/session-tabs/main-session-tab-rows.test.ts @@ -302,8 +302,8 @@ describe('session.tabs rows from main (layout-owner build)', () => { expect(merged).toMatchObject({ activeGroupId: 'g2', activeTabId: 'b1' }) }) - it("takes a markdown edit view's dirty state from the window", () => { - const markdown = (isDirty: boolean) => ({ + it("takes a markdown edit view's dirty state and untitled flag from the window", () => { + const markdown = (isDirty: boolean, isUntitled?: true) => ({ type: 'markdown' as const, id: 'm1', title: 'README.md', @@ -316,14 +316,16 @@ describe('session.tabs rows from main (layout-owner build)', () => { sourceRelativePath: 'README.md', documentVersion: 'file:/wt/README.md', isDirty, - isActive: false + isActive: false, + ...(isUntitled ? { isUntitled } : {}) }) const merged = withMainSessionTabRows( - windowSnapshot([markdown(true)], ['m1'], 'm1'), + windowSnapshot([markdown(true, true)], ['m1'], 'm1'), mainProjection([markdown(false)], ['m1'], 'm1'), NONE_EXITED ) - expect(merged.tabs[0]).toMatchObject({ id: 'm1', isDirty: true }) + // Why: the saved session never records untitled, and the phone keeps such a tab read-only. + expect(merged.tabs[0]).toMatchObject({ id: 'm1', isDirty: true, isUntitled: true }) }) }) diff --git a/src/main/runtime/session-tabs/main-session-tab-rows.ts b/src/main/runtime/session-tabs/main-session-tab-rows.ts index 6957346686e..4f146f0eabe 100644 --- a/src/main/runtime/session-tabs/main-session-tab-rows.ts +++ b/src/main/runtime/session-tabs/main-session-tab-rows.ts @@ -2,7 +2,8 @@ // layout (projectSessionTabs). The window's live publication supplies what the user sees: selection, // the focused pane, dirty state and live terminal titles (main's copy of the view lags a debounced // save), the rows main's layout does not list yet, and the facts only the window has (terminal -// theme, agent status, launch drafts, a markdown view's mode, file id and draft version). +// theme, agent status, launch drafts, a markdown view's mode, file id, draft version and untitled +// flag). import type { RuntimeMobileSessionSnapshotTab } from '../../../shared/runtime-mobile-session-tab-contracts' import { orderIdOf, placeInMainGroups, withStrayIn } from './main-session-tab-group-placement' @@ -91,7 +92,9 @@ function withWindowFacts(row: Row, windowRow: Row | undefined, rules: MergeRules isDirty: windowRow.isDirty, mode: windowRow.mode, sourceFileId: windowRow.sourceFileId, - documentVersion: windowRow.documentVersion + documentVersion: windowRow.documentVersion, + // The saved session never records untitled; a phone save must not make its file. + ...(windowRow.isUntitled ? { isUntitled: true as const } : {}) } } if (row.type === 'file' && windowRow?.type === 'file') {