From 841d9dc0e06871ab6bee94e98eb4b8e43da9c004 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 19 Apr 2026 21:40:51 -0500 Subject: [PATCH 1/2] fix: edited and push sync across reloads. --- playwright/github-pr-drawer.spec.ts | 651 ++++++++++++++++++ src/app.js | 15 +- .../app-core/workspace-sync-controller.js | 53 +- src/modules/workspace/workspace-tab-shape.js | 15 +- 4 files changed, 713 insertions(+), 21 deletions(-) diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index ab9b910..ae7fe33 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -2783,6 +2783,34 @@ test('Active PR context push commit uses Git Database API atomic path by default page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), ).toContainText('Commit pushed to develop/open-pr-test (develop/pr/2).') + await expect + .poll( + async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { + headBranch: 'develop/open-pr-test', + }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + return tabs.every(tab => tab?.isDirty === false) + }, + { timeout: 10_000 }, + ) + .toBe(true) + + await expect( + page + .getByRole('listitem', { name: 'Workspace tab App.tsx' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(0) + await expect( + page + .getByRole('listitem', { name: 'Workspace tab app.css' }) + .locator('.workspace-tab__dirty-indicator'), + ).toHaveCount(0) + await expect(page.locator('#component-dirty-status')).toBeHidden() + await expect(page.locator('#styles-dirty-status')).toBeHidden() + expect(createRefRequestCount).toBe(0) expect(pullRequestRequestCount).toBe(0) expect(treeRequests).toHaveLength(1) @@ -2794,6 +2822,230 @@ test('Active PR context push commit uses Git Database API atomic path by default expect(contentsPutRequests).toHaveLength(0) }) +test('Open PR uses module tab paths when stale target file paths collide', async ({ + page, +}) => { + const treeRequests: Array> = [] + const commitRequests: Array> = [] + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + 'knightedcodemonkey/develop': ['main', 'release'], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ref: 'refs/heads/main', + object: { type: 'commit', sha: 'abc123mainsha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits/abc123mainsha', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'abc123mainsha', + tree: { sha: 'base-tree-sha' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/trees', + async route => { + treeRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'push-tree-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/commits', + async route => { + commitRequests.push(route.request().postDataJSON() as Record) + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ sha: 'new-commit-sha' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs/**', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-stale-target-paths' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/git/refs', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-stale-target-paths' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls', + async route => { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + number: 333, + html_url: 'https://github.com/knightedcodemonkey/develop/pull/333', + }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + const localBoopSource = 'export const Boop = () =>

boop boop boop

\n' + const localBeepSource = 'export const Beep = () =>

beep beep beep

\n' + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName: 'knightedcodemonkey/develop', + headBranch: 'develop/open-pr-stale-target-paths', + }), + repo: 'knightedcodemonkey/develop', + base: 'main', + head: 'develop/open-pr-stale-target-paths', + prTitle: 'Open PR with stale module target paths', + prNumber: null, + prContextState: 'inactive', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: + "import '../styles/app.css'\nimport { Boop } from './boop.js'\nimport { Beep } from './beep.js'\n\nexport const App = () => (\n <>\n \n \n \n)\n", + targetPrFilePath: 'src/components/App.tsx', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: 'p { margin: 0; color: blue; }\n', + targetPrFilePath: 'src/styles/app.css', + }, + { + id: 'module-boop', + name: 'boop.tsx', + path: 'src/components/boop.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: localBoopSource, + targetPrFilePath: 'src/components/App.tsx', + }, + { + id: 'module-beep', + name: 'beep.tsx', + path: 'src/components/beep.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: localBeepSource, + targetPrFilePath: 'src/components/App.tsx', + }, + ], + activeTabId: 'component', + }, + ]) + + await connectByotWithSingleRepo(page) + await openMostRecentStoredWorkspaceContext(page) + await ensureOpenPrDrawerOpen(page) + + const commitMessage = 'chore: open pr with stale module target path metadata' + await page.getByLabel('Head').fill('develop/open-pr-stale-target-paths') + await page.getByLabel('PR title').fill('Open PR keeps module paths and content') + await page.getByLabel('Commit message').fill(commitMessage) + await submitOpenPrAndConfirm(page) + + await expect( + page.getByRole('status', { name: 'Open pull request status', includeHidden: true }), + ).toContainText( + 'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/333', + ) + + expect(treeRequests).toHaveLength(1) + const treePayload = treeRequests[0]?.tree as Array> + expect(treePayload?.length).toBe(4) + + const componentBlob = treePayload?.find(file => file.path === 'src/components/App.tsx') + const stylesBlob = treePayload?.find(file => file.path === 'src/styles/app.css') + const boopBlob = treePayload?.find(file => file.path === 'src/components/boop.tsx') + const beepBlob = treePayload?.find(file => file.path === 'src/components/beep.tsx') + + expect(componentBlob?.content).toEqual(expect.any(String)) + expect(stylesBlob?.content).toEqual(expect.any(String)) + expect(boopBlob?.content).toBe(localBoopSource) + expect(beepBlob?.content).toBe(localBeepSource) + + expect(commitRequests).toHaveLength(1) + expect(commitRequests[0]?.message).toBe(commitMessage) +}) + test('Reloaded active PR context from URL metadata keeps Push mode and status reference', async ({ page, }) => { @@ -3317,6 +3569,405 @@ test('Reloaded active PR context syncs editor content from GitHub branch and res .toBe(true) }) +test('Reloaded active PR context sync does not overwrite non-primary module tabs', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const headBranch = 'develop/open-pr-test' + const remoteComponentSource = 'export const App = () =>
Synced App
' + const remoteStylesSource = '.synced-app-styles { color: cyan; }' + const localBoopSource = 'export const Boop = () =>

Boop local module

\n' + const localBeepSource = 'export const Beep = () =>

Beep local module

\n' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', headBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: headBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const request = route.request() + const method = request.method() + const url = new URL(request.url()) + const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '') + const ref = url.searchParams.get('ref') + + if (method !== 'GET' || ref !== headBranch) { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + if (path === 'src/components/App.tsx') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'component-sha', + content: Buffer.from(remoteComponentSource, 'utf8').toString('base64'), + }), + }) + return + } + + if (path === 'src/styles/app.css') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'styles-sha', + content: Buffer.from(remoteStylesSource, 'utf8').toString('base64'), + }), + }) + return + } + + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch, + }), + repo: repositoryFullName, + base: 'main', + head: headBranch, + prTitle: 'Existing PR context from storage', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Local App
\n', + targetPrFilePath: 'src/components/App.tsx', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: '.local-app-styles { color: magenta; }\n', + targetPrFilePath: 'src/styles/app.css', + }, + { + id: 'module-boop', + name: 'boop.tsx', + path: 'src/components/boop.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: localBoopSource, + targetPrFilePath: 'src/components/boop.tsx', + }, + { + id: 'module-beep', + name: 'beep.tsx', + path: 'src/components/beep.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: localBeepSource, + targetPrFilePath: 'src/components/beep.tsx', + }, + ], + activeTabId: 'component', + }, + ]) + + await page.evaluate(repo => { + localStorage.setItem('knighted:develop:github-pat', 'github_pat_fake_chat_1234567890') + localStorage.setItem('knighted:develop:github-repository', repo) + }, repositoryFullName) + + await waitForAppReady(page, `${appEntryPath}`) + + await expect + .poll( + async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { headBranch }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + + const entryTab = tabs.find(tab => tab?.id === 'component') + const boopTab = tabs.find(tab => tab?.id === 'module-boop') + const beepTab = tabs.find(tab => tab?.id === 'module-beep') + + return { + entryContent: typeof entryTab?.content === 'string' ? entryTab.content : '', + entryTargetPath: + typeof entryTab?.targetPrFilePath === 'string' + ? entryTab.targetPrFilePath + : '', + boopContent: typeof boopTab?.content === 'string' ? boopTab.content : '', + boopTargetPath: + typeof boopTab?.targetPrFilePath === 'string' ? boopTab.targetPrFilePath : '', + beepContent: typeof beepTab?.content === 'string' ? beepTab.content : '', + beepTargetPath: + typeof beepTab?.targetPrFilePath === 'string' ? beepTab.targetPrFilePath : '', + } + }, + { timeout: 10_000 }, + ) + .toEqual({ + entryContent: remoteComponentSource, + entryTargetPath: 'src/components/App.tsx', + boopContent: localBoopSource, + boopTargetPath: 'src/components/boop.tsx', + beepContent: localBeepSource, + beepTargetPath: 'src/components/beep.tsx', + }) +}) + +test('Reloaded active PR context sync does not overwrite non-primary tabs with stale target path collisions', async ({ + page, +}) => { + const repositoryFullName = 'knightedcodemonkey/develop' + const headBranch = 'develop/open-pr-test' + const remoteComponentSource = 'export const App = () =>
Synced App
' + const remoteStylesSource = '.synced-app-styles { color: cyan; }' + const localBoopSource = 'export const Boop = () =>

Boop local module

\n' + const localBeepSource = 'export const Beep = () =>

Beep local module

\n' + + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 11, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: repositoryFullName, + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await mockRepositoryBranches(page, { + [repositoryFullName]: ['main', 'release', headBranch], + }) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/pulls/2', + async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + number: 2, + state: 'open', + title: 'Existing PR context from storage', + html_url: 'https://github.com/knightedcodemonkey/develop/pull/2', + head: { ref: headBranch }, + base: { ref: 'main' }, + }), + }) + }, + ) + + await page.route( + 'https://api.github.com/repos/knightedcodemonkey/develop/contents/**', + async route => { + const request = route.request() + const method = request.method() + const url = new URL(request.url()) + const path = decodeURIComponent(url.pathname.split('/contents/')[1] ?? '') + const ref = url.searchParams.get('ref') + + if (method !== 'GET' || ref !== headBranch) { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + return + } + + if (path === 'src/components/App.tsx') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'component-sha', + content: Buffer.from(remoteComponentSource, 'utf8').toString('base64'), + }), + }) + return + } + + if (path === 'src/styles/app.css') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sha: 'styles-sha', + content: Buffer.from(remoteStylesSource, 'utf8').toString('base64'), + }), + }) + return + } + + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ message: 'Not Found' }), + }) + }, + ) + + await waitForAppReady(page, `${appEntryPath}`) + + await seedLocalWorkspaceContexts(page, [ + { + id: buildWorkspaceRecordId({ + repositoryFullName, + headBranch, + }), + repo: repositoryFullName, + base: 'main', + head: headBranch, + prTitle: 'Existing PR context from storage', + prNumber: 2, + prContextState: 'active', + renderMode: 'react', + tabs: [ + { + id: 'component', + name: 'App.tsx', + path: 'src/components/App.tsx', + language: 'javascript-jsx', + role: 'entry', + isActive: true, + content: 'export const App = () =>
Local App
\n', + targetPrFilePath: 'src/components/App.tsx', + }, + { + id: 'styles', + name: 'app.css', + path: 'src/styles/app.css', + language: 'css', + role: 'module', + isActive: false, + content: '.local-app-styles { color: magenta; }\n', + targetPrFilePath: 'src/styles/app.css', + }, + { + id: 'module-boop', + name: 'boop.tsx', + path: 'src/components/boop.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: localBoopSource, + targetPrFilePath: 'src/components/App.tsx', + }, + { + id: 'module-beep', + name: 'beep.tsx', + path: 'src/components/beep.tsx', + language: 'javascript-jsx', + role: 'module', + isActive: false, + content: localBeepSource, + targetPrFilePath: 'src/components/App.tsx', + }, + ], + activeTabId: 'component', + }, + ]) + + await page.evaluate(repo => { + localStorage.setItem('knighted:develop:github-pat', 'github_pat_fake_chat_1234567890') + localStorage.setItem('knighted:develop:github-repository', repo) + }, repositoryFullName) + + await waitForAppReady(page, `${appEntryPath}`) + + await expect + .poll( + async () => { + const workspaceRecord = await getWorkspaceTabsRecord(page, { headBranch }) + const tabs = Array.isArray(workspaceRecord?.tabs) + ? (workspaceRecord.tabs as Array>) + : [] + + const entryTab = tabs.find(tab => tab?.id === 'component') + const boopTab = tabs.find(tab => tab?.id === 'module-boop') + const beepTab = tabs.find(tab => tab?.id === 'module-beep') + + return { + entryContent: typeof entryTab?.content === 'string' ? entryTab.content : '', + boopContent: typeof boopTab?.content === 'string' ? boopTab.content : '', + beepContent: typeof beepTab?.content === 'string' ? beepTab.content : '', + } + }, + { timeout: 10_000 }, + ) + .toEqual({ + entryContent: remoteComponentSource, + boopContent: localBoopSource, + beepContent: localBeepSource, + }) +}) + test('Reloaded active PR context falls back to css style mode for unsupported value', async ({ page, }) => { diff --git a/src/app.js b/src/app.js index 59d298f..a217ba6 100644 --- a/src/app.js +++ b/src/app.js @@ -830,7 +830,14 @@ const normalizeWorkspaceEditorsTrailingNewlineAfterPublish = const reconcileWorkspaceTabsWithPushUpdates = fileUpdates => { normalizeWorkspaceEditorsTrailingNewlineAfterPublish({ fileUpdates }) - return workspaceSyncController.reconcileWorkspaceTabsWithPushUpdates(fileUpdates) + const updatedCount = + workspaceSyncController.reconcileWorkspaceTabsWithPushUpdates(fileUpdates) + + if (updatedCount > 0) { + editedIndicatorVisibilityController.refreshIndicators() + } + + return updatedCount } const setWorkspacePrContextState = nextState => { @@ -977,7 +984,13 @@ const githubWorkflows = createGitHubWorkflowsSetup({ }, getActivePrEditorSyncKey: () => githubAiContextState.activePrEditorSyncKey, syncFromActiveContext: ({ tabTargets }) => { + const activeTabIdBeforeSync = workspaceTabsState.getActiveTabId() reconcileWorkspaceTabsWithEditorSync({ tabTargets }) + const activeTabAfterSync = + workspaceTabsState.getTab(activeTabIdBeforeSync) ?? getActiveWorkspaceTab() + if (activeTabAfterSync) { + loadWorkspaceTabIntoEditor(activeTabAfterSync) + } }, formatActivePrReference, githubPrContextClose, diff --git a/src/modules/app-core/workspace-sync-controller.js b/src/modules/app-core/workspace-sync-controller.js index 3439e6d..2970f5e 100644 --- a/src/modules/app-core/workspace-sync-controller.js +++ b/src/modules/app-core/workspace-sync-controller.js @@ -22,6 +22,7 @@ const createWorkspaceSyncController = ({ const activeTabId = workspaceTabsState.getActiveTabId() return workspaceTabsState.getTabs().map(tab => { const currentPath = toNonEmptyWorkspaceText(tab.path) + const isPrimaryEditorTab = tab?.id === 'component' || tab?.id === 'styles' const currentContent = tab.id === activeTabId @@ -32,8 +33,10 @@ const createWorkspaceSyncController = ({ ? tab.content : '' - const targetPrFilePath = - getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(currentPath) || null + const normalizedPath = normalizeWorkspacePathValue(currentPath) + const targetPrFilePath = isPrimaryEditorTab + ? normalizedPath || null + : normalizedPath || getTabTargetPrFilePath(tab) || null return { ...tab, @@ -71,10 +74,11 @@ const createWorkspaceSyncController = ({ let updatedTabCount = 0 const activeTabId = workspaceTabsState.getActiveTabId() const nextTabs = workspaceTabsState.getTabs().map(tab => { - const candidatePaths = [ - getTabTargetPrFilePath(tab), - normalizeWorkspacePathValue(tab.path), - ].filter(Boolean) + const isPrimaryEditorTab = tab?.id === 'component' || tab?.id === 'styles' + const normalizedPath = normalizeWorkspacePathValue(tab.path) + const candidatePaths = isPrimaryEditorTab + ? [normalizedPath, getTabTargetPrFilePath(tab)].filter(Boolean) + : [normalizedPath].filter(Boolean) const matchedPath = candidatePaths.find(path => updatesByPath.has(path)) if (!matchedPath) { @@ -86,7 +90,7 @@ const createWorkspaceSyncController = ({ return { ...tab, - targetPrFilePath: matchedPath, + targetPrFilePath: normalizedPath || (isPrimaryEditorTab ? matchedPath : null), syncedContent: typeof tab?.content === 'string' ? tab.content : '', isDirty: false, syncedAt: now, @@ -111,6 +115,12 @@ const createWorkspaceSyncController = ({ options?.includeAllWorkspaceFiles === true || options?.includeAll === true const snapshotTabs = buildWorkspaceTabsSnapshot() const dedupedByPath = new Map() + const primaryTabPaths = new Set( + snapshotTabs + .filter(tab => tab?.id === 'component' || tab?.id === 'styles') + .map(tab => normalizeWorkspacePathValue(tab?.path)) + .filter(Boolean), + ) for (const tab of snapshotTabs) { const shouldCommitTab = includeAllWorkspaceFiles @@ -120,12 +130,19 @@ const createWorkspaceSyncController = ({ continue } - const path = - getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(tab?.path) || '' + const isPrimaryEditorTab = tab?.id === 'component' || tab?.id === 'styles' + const normalizedPath = normalizeWorkspacePathValue(tab?.path) + const path = isPrimaryEditorTab + ? normalizedPath || getTabTargetPrFilePath(tab) || '' + : normalizedPath || getTabTargetPrFilePath(tab) || '' if (!path) { continue } + if (!isPrimaryEditorTab && primaryTabPaths.has(path)) { + continue + } + dedupedByPath.set(path, { path, content: typeof tab?.content === 'string' ? tab.content : '', @@ -139,11 +156,16 @@ const createWorkspaceSyncController = ({ const getEditorSyncTargets = () => { const tabTargets = [] + const primaryTabIdByKind = { + component: 'component', + styles: 'styles', + } for (const kind of ['component', 'styles']) { - const tab = getWorkspaceTabByKind(kind) + const primaryTabId = primaryTabIdByKind[kind] + const tab = workspaceTabsState.getTab(primaryTabId) ?? getWorkspaceTabByKind(kind) const path = - getTabTargetPrFilePath(tab) || normalizeWorkspacePathValue(tab?.path) || '' + normalizeWorkspacePathValue(tab?.path) || getTabTargetPrFilePath(tab) || '' if (!path) { continue @@ -180,6 +202,11 @@ const createWorkspaceSyncController = ({ const stylesSource = getCssSource() const nextTabs = workspaceTabsState.getTabs().map(tab => { + const isPrimaryEditorTab = tab?.id === 'component' || tab?.id === 'styles' + if (!isPrimaryEditorTab) { + return tab + } + const tabKind = getTabKind(tab) const expectedPath = targetsByKind.get(tabKind) if (!expectedPath) { @@ -187,11 +214,11 @@ const createWorkspaceSyncController = ({ } const candidatePaths = [ - getTabTargetPrFilePath(tab), normalizeWorkspacePathValue(tab.path), + getTabTargetPrFilePath(tab), ].filter(Boolean) const matchedPath = candidatePaths.find(path => path === expectedPath) - if (!matchedPath && tabKind !== 'component' && tabKind !== 'styles') { + if (!matchedPath) { return tab } diff --git a/src/modules/workspace/workspace-tab-shape.js b/src/modules/workspace/workspace-tab-shape.js index ee8e664..046c7d9 100644 --- a/src/modules/workspace/workspace-tab-shape.js +++ b/src/modules/workspace/workspace-tab-shape.js @@ -37,6 +37,7 @@ const createEnsureWorkspaceTabsShape = const normalizedEntryPath = normalizeEntryTabPath(tab.path, { preferredFileName: tab.name, }) + const normalizedEntryTargetPath = normalizeWorkspacePathValue(normalizedEntryPath) return { ...tab, role: 'entry', @@ -44,9 +45,7 @@ const createEnsureWorkspaceTabsShape = content: typeof tab?.content === 'string' ? tab.content : '', path: normalizedEntryPath, name: getPathFileName(normalizedEntryPath) || defaultComponentTabName, - targetPrFilePath: - getTabTargetPrFilePath(tab) || - normalizeWorkspacePathValue(normalizedEntryPath), + targetPrFilePath: normalizedEntryTargetPath || null, isDirty: Boolean(tab?.isDirty), syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), @@ -61,6 +60,8 @@ const createEnsureWorkspaceTabsShape = const normalizedStylesPath = toNonEmptyWorkspaceText(tab.path) || defaultStylesTabPath const normalizedStylesNameInput = toNonEmptyWorkspaceText(tab.name) + const normalizedStylesTargetPath = + normalizeWorkspacePathValue(normalizedStylesPath) return { ...tab, language: isStyleTabLanguage(tab.language) ? tab.language : 'css', @@ -72,9 +73,7 @@ const createEnsureWorkspaceTabsShape = normalizedStylesNameInput.toLowerCase() === 'styles' ? getPathFileName(normalizedStylesPath) || defaultStylesTabName : normalizedStylesNameInput, - targetPrFilePath: - getTabTargetPrFilePath(tab) || - normalizeWorkspacePathValue(normalizedStylesPath), + targetPrFilePath: normalizedStylesTargetPath || null, isDirty: Boolean(tab?.isDirty), syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), @@ -86,6 +85,7 @@ const createEnsureWorkspaceTabsShape = } const nextPath = toNonEmptyWorkspaceText(tab?.path) + const normalizedModuleTargetPath = normalizeWorkspacePathValue(nextPath) const nextContent = typeof tab?.content === 'string' ? tab.content : '' return { ...tab, @@ -94,7 +94,8 @@ const createEnsureWorkspaceTabsShape = path: nextPath, content: nextContent, name: toNonEmptyWorkspaceText(tab?.name) || getPathFileName(nextPath) || tab?.id, - targetPrFilePath: getTabTargetPrFilePath(tab) || null, + targetPrFilePath: + normalizedModuleTargetPath || getTabTargetPrFilePath(tab) || null, isDirty: Boolean(tab?.isDirty), syncedAt: toWorkspaceSyncTimestamp(tab?.syncedAt), lastSyncedRemoteSha: toWorkspaceSyncSha(tab?.lastSyncedRemoteSha), From f27c12e1825ba71cdd404377015b2718c3810536 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 19 Apr 2026 21:52:46 -0500 Subject: [PATCH 2/2] refactor: address pr comments. --- playwright/github-pr-drawer.spec.ts | 6 +++++- src/modules/app-core/workspace-sync-controller.js | 4 +--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/playwright/github-pr-drawer.spec.ts b/playwright/github-pr-drawer.spec.ts index ae7fe33..23f036a 100644 --- a/playwright/github-pr-drawer.spec.ts +++ b/playwright/github-pr-drawer.spec.ts @@ -2792,7 +2792,11 @@ test('Active PR context push commit uses Git Database API atomic path by default const tabs = Array.isArray(workspaceRecord?.tabs) ? (workspaceRecord.tabs as Array>) : [] - return tabs.every(tab => tab?.isDirty === false) + const tabIds = new Set( + tabs.map(tab => (typeof tab?.id === 'string' ? tab.id : '')).filter(Boolean), + ) + const hasPrimaryTabs = tabIds.has('component') && tabIds.has('styles') + return hasPrimaryTabs && tabs.every(tab => tab?.isDirty === false) }, { timeout: 10_000 }, ) diff --git a/src/modules/app-core/workspace-sync-controller.js b/src/modules/app-core/workspace-sync-controller.js index 2970f5e..51be597 100644 --- a/src/modules/app-core/workspace-sync-controller.js +++ b/src/modules/app-core/workspace-sync-controller.js @@ -132,9 +132,7 @@ const createWorkspaceSyncController = ({ const isPrimaryEditorTab = tab?.id === 'component' || tab?.id === 'styles' const normalizedPath = normalizeWorkspacePathValue(tab?.path) - const path = isPrimaryEditorTab - ? normalizedPath || getTabTargetPrFilePath(tab) || '' - : normalizedPath || getTabTargetPrFilePath(tab) || '' + const path = normalizedPath || getTabTargetPrFilePath(tab) || '' if (!path) { continue }