fix(session-tabs): a phone keeps a desktop's untitled markdown tab read-only in a workspace main owns (dark) (#31667)

With the layout-owner switch on, session.tabs takes main's markdown edit row and copies the
window's view facts onto it, but not the untitled flag, which the saved session never records.
The phone then offered to save an untitled document as an ordinary file. Carry the flag too.

The route tests now use a realistic store: it lists its host partitions and saves each open
editor's unified tab and group, as the window does.
This commit is contained in:
Jinwoo Hong
2026-10-22 00:16:04 -04:00
committed by GitHub
parent 301059fb77
commit d44ee0ece4
3 changed files with 82 additions and 16 deletions
@@ -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<typeof OrcaRuntimeService>
@@ -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", () => {
@@ -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 })
})
})
@@ -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') {