mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user