From 0227f7e26c4df4b5ebc68909668115b75002e988 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 3 May 2026 16:02:45 -0500 Subject: [PATCH 1/2] feat: show workspace context status by default, allow rename of local workspace. --- playwright/github-byot-ai.spec.ts | 91 ++++++++++++++++++- .../github-pr-drawer/open-pr-create.spec.ts | 4 +- src/app.js | 19 +++- src/index.html | 4 +- src/modules/app-core/github-workflows.js | 79 ++++++++++++++++ .../workspace-context-status-controller.js | 48 ++++++---- .../workspace/workspaces-drawer/drawer.js | 63 ++++++++++--- src/styles/layout-shell.css | 12 +-- 8 files changed, 271 insertions(+), 49 deletions(-) diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index 7c2a65d..55868a0 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -303,16 +303,103 @@ test('chat becomes available after token connect', async ({ page }) => { await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible() }) -test('workspace context status is visible only after PAT connect', async ({ page }) => { +test('workspace context status stays visible without PAT and after PAT connect', async ({ + page, +}) => { await waitForAppReady(page) const workspaceContextStatus = page.locator('#workspace-context-status') - await expect(workspaceContextStatus).toBeHidden() + await expect(workspaceContextStatus).toBeVisible() + await expect(workspaceContextStatus).toContainText('local') await connectByotWithSingleRepo(page) await expect(workspaceContextStatus).toBeVisible() }) +test('Local workspace can be renamed from Workspaces drawer', async ({ page }) => { + const sourceWorkspaceId = 'local_workspace_rename_source' + const targetWorkspaceId = 'local_workspace_rename_target' + const originalTitle = 'Local rename original title' + const renamedTitle = 'Local rename updated title' + + await waitForAppReady(page) + + await seedLocalWorkspaceContexts(page, [ + { + id: sourceWorkspaceId, + repo: '', + workspaceScope: 'local', + head: 'feat/local-rename-source', + prTitle: originalTitle, + prContextState: 'inactive', + tabs: [ + { + id: 'component', + path: 'src/component.tsx', + language: 'tsx', + role: 'component', + content: 'export const App = () =>
rename source
', + order: 0, + source: 'workspace', + dirty: false, + }, + ], + activeTabId: 'component', + }, + { + id: targetWorkspaceId, + repo: '', + workspaceScope: 'local', + head: 'feat/local-rename-target', + prTitle: 'Local rename target title', + prContextState: 'inactive', + tabs: [ + { + id: 'component', + path: 'src/component.tsx', + language: 'tsx', + role: 'component', + content: 'export const App = () =>
rename target
', + order: 0, + source: 'workspace', + dirty: false, + }, + ], + activeTabId: 'component', + }, + ]) + + const workspacesToggle = page.getByRole('button', { + name: 'Workspaces', + exact: true, + }) + await workspacesToggle.click() + + const workspaceSelect = page.getByLabel('Stored workspace') + const renameButton = page.getByRole('button', { name: 'Rename', exact: true }) + + await workspaceSelect.selectOption(sourceWorkspaceId) + await expect(workspaceSelect).toHaveValue(sourceWorkspaceId) + await expect(renameButton).toBeEnabled() + + page.once('dialog', async dialog => { + expect(dialog.type()).toBe('prompt') + expect(dialog.defaultValue()).toBe(originalTitle) + await dialog.accept(renamedTitle) + }) + + await renameButton.click() + await expect(page.locator('#workspaces-status')).toContainText('Renamed workspace.') + + const records = await getAllWorkspaceRecords(page) + const renamedRecord = records.find(record => record?.id === sourceWorkspaceId) + + expect(renamedRecord).toBeTruthy() + expect(typeof renamedRecord?.prTitle === 'string' ? renamedRecord.prTitle : '').toBe( + renamedTitle, + ) +}) + test('BYOT controls render with default app entry', async ({ page }) => { await waitForAppReady(page, appEntryPath) diff --git a/playwright/github-pr-drawer/open-pr-create.spec.ts b/playwright/github-pr-drawer/open-pr-create.spec.ts index 64c59ad..47fd39d 100644 --- a/playwright/github-pr-drawer/open-pr-create.spec.ts +++ b/playwright/github-pr-drawer/open-pr-create.spec.ts @@ -934,7 +934,7 @@ test('Workspaces repository selector filters contexts and keeps local-only conte await selectWorkspacesRepositoryFilter(page, '__local__') const localLabels = await getLocalContextOptionLabels(page) expect(localLabels).toContain('Select a stored workspace') - expect(localLabels).toContain('local:Alpha local context') + expect(localLabels).toContain('Alpha local context') expect(localLabels).not.toContain('Alpha active context') }) @@ -1312,7 +1312,7 @@ test('Switching Workspaces repository scope to Local keeps inactive record repo await expect .poll(async () => { const localLabels = await getLocalContextOptionLabels(page) - return localLabels.includes('local:feat/component-v8zw') + return localLabels.includes('feat/component-v8zw') }) .toBe(true) diff --git a/src/app.js b/src/app.js index fb1155d..3f1a7be 100644 --- a/src/app.js +++ b/src/app.js @@ -150,6 +150,7 @@ const workspacesInitialize = document.getElementById('workspaces-initialize') const workspacesNew = document.getElementById('workspaces-new') const workspacesSelect = document.getElementById('workspaces-select') const workspacesOpen = document.getElementById('workspaces-open') +const workspacesRename = document.getElementById('workspaces-rename') const workspacesRemove = document.getElementById('workspaces-remove') const componentPrSyncIcon = document.getElementById('component-pr-sync-icon') const componentPrSyncIconPath = document.getElementById('component-pr-sync-icon-path') @@ -425,6 +426,8 @@ let workspacePrContextState = 'inactive' let workspacePrNumber = null let workspaceRepositoryFullName = '' let workspaceScopeMarker = 'local' +let activeWorkspacePersistedPrTitle = '' +let activeWorkspacePersistedHeadBranch = '' let hasObservedActivePrContextInSession = false let workspaceContextStatusController = { render: () => {}, @@ -450,6 +453,8 @@ const toPullRequestNumber = value => { const setActiveWorkspaceRecordId = nextValue => { activeWorkspaceRecordId = toNonEmptyWorkspaceText(nextValue) if (!activeWorkspaceRecordId) { + activeWorkspacePersistedPrTitle = '' + activeWorkspacePersistedHeadBranch = '' workspaceRepositoryFullName = '' workspaceScopeMarker = 'local' } @@ -607,6 +612,8 @@ workspaceContextStatusController = createWorkspaceContextStatusController({ toNonEmptyWorkspaceText, getWorkspacePrTitle: () => githubPrTitle?.value, getWorkspaceHeadBranch: () => githubPrHeadBranch?.value, + getActiveWorkspacePersistedPrTitle: () => activeWorkspacePersistedPrTitle, + getActiveWorkspacePersistedHeadBranch: () => activeWorkspacePersistedHeadBranch, getWorkspaceScopeMarker: () => workspaceScopeMarker, getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, getWorkspaceRepositoryFullName: () => workspaceRepositoryFullName, @@ -787,6 +794,15 @@ const onWorkspaceRecordApplied = createWorkspaceRecordAppliedHandler({ getStyleModeValue: () => styleMode.value, }) +const onWorkspaceRecordAppliedWithStatusMetadata = workspace => { + if (workspace && typeof workspace === 'object') { + activeWorkspacePersistedPrTitle = toNonEmptyWorkspaceText(workspace.prTitle) + activeWorkspacePersistedHeadBranch = toNonEmptyWorkspaceText(workspace.head) + } + + onWorkspaceRecordApplied(workspace) +} + const { workspaceSaveController, listLocalContextRecords, @@ -878,7 +894,7 @@ const { getWorkspaceTabByKind, makeUniqueTabPath, createWorkspaceTabId, - onWorkspaceRecordApplied, + onWorkspaceRecordApplied: onWorkspaceRecordAppliedWithStatusMetadata, }) const { syncActiveWorkspaceRepositoryScope, forkWorkspaceFromCurrentState } = @@ -1116,6 +1132,7 @@ const githubWorkflows = createGitHubWorkflowsSetup({ workspacesNew, workspacesSelect, workspacesOpen, + workspacesRename, workspacesRemove, }, workspace: { diff --git a/src/index.html b/src/index.html index 2119a49..9fd480d 100644 --- a/src/index.html +++ b/src/index.html @@ -350,7 +350,6 @@

+ diff --git a/src/modules/app-core/github-workflows.js b/src/modules/app-core/github-workflows.js index 14592c9..ec55a0f 100644 --- a/src/modules/app-core/github-workflows.js +++ b/src/modules/app-core/github-workflows.js @@ -46,6 +46,7 @@ const initializeGitHubWorkflows = ({ workspacesNew, workspacesSelect, workspacesOpen, + workspacesRename, workspacesRemove, workspaceStorage, getActiveWorkspaceRecordId, @@ -121,6 +122,8 @@ const initializeGitHubWorkflows = ({ const toSafeRepositoryFullName = value => typeof value === 'string' ? value.trim() : '' + const toSafeWorkspaceText = value => (typeof value === 'string' ? value.trim() : '') + const shouldApplyActivePrEditorSync = ({ repository, activeContext }) => { const syncedContextKey = getActivePrContextSyncKey(activeContext) const currentSyncKey = getActivePrEditorSyncKey() @@ -425,6 +428,7 @@ const initializeGitHubWorkflows = ({ newButton: workspacesNew, selectInput: workspacesSelect, openButton: workspacesOpen, + renameButton: workspacesRename, removeButton: workspacesRemove, getRepositoryFilterOptions: () => getCurrentWritableRepositories().map(repository => ({ @@ -527,6 +531,81 @@ const initializeGitHubWorkflows = ({ return false } }, + onRenameSelected: async workspaceId => { + try { + const record = await workspaceStorage.getWorkspaceById(workspaceId) + if (!record) { + await refreshLocalContextOptions() + workspacesDrawerController?.setStatus( + 'Stored workspace no longer exists.', + 'error', + ) + return false + } + + const workspaceScope = toSafeWorkspaceText(record.workspaceScope).toLowerCase() + const isLocalWorkspace = + workspaceScope === 'local' || + (!workspaceScope && !toSafeWorkspaceText(record.repo)) + if (!isLocalWorkspace) { + workspacesDrawerController?.setStatus( + 'Only Local workspaces can be renamed here.', + 'error', + ) + return false + } + + if (getActiveWorkspaceRecordId() === workspaceId) { + workspacesDrawerController?.setStatus( + 'Open a different workspace before renaming this one.', + 'error', + ) + return false + } + + const currentWorkspaceName = + toSafeWorkspaceText(record.prTitle) || + toSafeWorkspaceText(record.head) || + toSafeWorkspaceText(record.id) || + 'workspace' + const promptedWorkspaceName = window.prompt( + 'Rename local workspace', + currentWorkspaceName, + ) + + if (promptedWorkspaceName === null) { + return false + } + + const nextWorkspaceName = promptedWorkspaceName.trim() + if (!nextWorkspaceName) { + workspacesDrawerController?.setStatus( + 'Workspace name cannot be empty.', + 'error', + ) + return false + } + + if (nextWorkspaceName === currentWorkspaceName) { + return false + } + + await workspaceStorage.upsertWorkspace({ + ...record, + prTitle: nextWorkspaceName, + lastModified: Date.now(), + }) + + await refreshLocalContextOptions() + return true + } catch { + workspacesDrawerController?.setStatus( + 'Could not rename stored workspace.', + 'error', + ) + return false + } + }, onRemoveSelected: async workspaceId => { confirmAction({ title: 'Remove stored workspace?', diff --git a/src/modules/app-core/workspace-context-status-controller.js b/src/modules/app-core/workspace-context-status-controller.js index 1c976a8..81893dc 100644 --- a/src/modules/app-core/workspace-context-status-controller.js +++ b/src/modules/app-core/workspace-context-status-controller.js @@ -5,6 +5,8 @@ const createWorkspaceContextStatusController = ({ toNonEmptyWorkspaceText, getWorkspacePrTitle, getWorkspaceHeadBranch, + getActiveWorkspacePersistedPrTitle, + getActiveWorkspacePersistedHeadBranch, getWorkspaceScopeMarker, getActiveWorkspaceRecordId, getWorkspaceRepositoryFullName, @@ -12,10 +14,27 @@ const createWorkspaceContextStatusController = ({ }) => { let hasValidatedGitHubPat = false let hasCompletedRepositoryLoad = false - const appGrid = - statusNode instanceof HTMLElement ? statusNode.closest('.app-grid') : null const getWorkspaceName = () => { + const workspaceScope = + toNonEmptyWorkspaceText(getWorkspaceScopeMarker?.()).toLowerCase() || 'local' + + if (workspaceScope === 'local') { + const persistedPrTitle = toNonEmptyWorkspaceText( + getActiveWorkspacePersistedPrTitle?.(), + ) + if (persistedPrTitle) { + return persistedPrTitle + } + + const persistedHeadBranch = toNonEmptyWorkspaceText( + getActiveWorkspacePersistedHeadBranch?.(), + ) + if (persistedHeadBranch) { + return persistedHeadBranch + } + } + const prTitle = toNonEmptyWorkspaceText(getWorkspacePrTitle?.()) if (prTitle) { return prTitle @@ -34,27 +53,18 @@ const createWorkspaceContextStatusController = ({ return } - if (appGrid instanceof HTMLElement) { - appGrid.classList.toggle( - 'app-grid--workspace-context-visible', - hasValidatedGitHubPat, - ) - } - - statusNode.toggleAttribute('hidden', !hasValidatedGitHubPat) - if (!hasValidatedGitHubPat) { - return - } + statusNode.removeAttribute('hidden') const workspaceName = getWorkspaceName() const workspaceScope = toNonEmptyWorkspaceText(getWorkspaceScopeMarker?.()).toLowerCase() || 'local' - const repository = - workspaceScope === 'local' - ? 'local' - : toNonEmptyWorkspaceText(getWorkspaceRepositoryFullName?.()) || - toNonEmptyWorkspaceText(getSelectedRepositoryFullName?.()) || - 'unknown' + const shouldShowRepositoryContext = + hasValidatedGitHubPat && workspaceScope !== 'local' + const repository = shouldShowRepositoryContext + ? toNonEmptyWorkspaceText(getWorkspaceRepositoryFullName?.()) || + toNonEmptyWorkspaceText(getSelectedRepositoryFullName?.()) || + 'unknown' + : 'local' statusNode.textContent = `${workspaceName} • ${repository}` } diff --git a/src/modules/workspace/workspaces-drawer/drawer.js b/src/modules/workspace/workspaces-drawer/drawer.js index 339ef1a..08f985e 100644 --- a/src/modules/workspace/workspaces-drawer/drawer.js +++ b/src/modules/workspace/workspaces-drawer/drawer.js @@ -26,22 +26,18 @@ const toSafeWorkspaceScope = workspace => { : localWorkspaceScopeValue } -const toWorkspaceLabel = (workspace, { forceLocalPrefix = false } = {}) => { - const isLocalScoped = - forceLocalPrefix || toSafeWorkspaceScope(workspace) === localWorkspaceScopeValue - +const toWorkspaceLabel = workspace => { const hasTitle = toSafeText(workspace?.prTitle) if (hasTitle) { - return isLocalScoped ? `local:${hasTitle}` : hasTitle + return hasTitle } const hasHead = toSafeText(workspace?.head) if (hasHead) { - return isLocalScoped ? `local:${hasHead}` : hasHead + return hasHead } - const fallbackLabel = toSafeText(workspace?.id) || 'workspace' - return isLocalScoped ? `local:${fallbackLabel}` : fallbackLabel + return toSafeText(workspace?.id) || 'workspace' } export const createWorkspacesDrawer = ({ @@ -55,6 +51,7 @@ export const createWorkspacesDrawer = ({ newButton, selectInput, openButton, + renameButton, removeButton, getDrawerSide, getRepositoryFilterOptions, @@ -64,6 +61,7 @@ export const createWorkspacesDrawer = ({ onInitializeWorkspace, onCreateWorkspace, onOpenSelected, + onRenameSelected, onRemoveSelected, } = {}) => { let open = false @@ -152,15 +150,28 @@ export const createWorkspacesDrawer = ({ const activeWorkspaceId = typeof getActiveWorkspaceId === 'function' ? toSafeText(getActiveWorkspaceId()) : '' const hasSelection = normalizedSelectedId.length > 0 + const selectedEntry = entries.find( + entry => toSafeText(entry?.id) === normalizedSelectedId, + ) + const selectedWorkspaceScope = toSafeWorkspaceScope(selectedEntry) + const isSelectedLocalWorkspace = + hasSelection && selectedWorkspaceScope === localWorkspaceScopeValue const isSelectedWorkspaceActive = hasSelection && Boolean(activeWorkspaceId) && normalizedSelectedId === activeWorkspaceId + const canRenameWorkspace = + typeof onRenameSelected === 'function' && + isSelectedLocalWorkspace && + !isSelectedWorkspaceActive const canCreateWorkspace = typeof onCreateWorkspace === 'function' const canInitializeWorkspace = typeof onInitializeWorkspace === 'function' const hasStoredWorkspaces = currentUiState === drawerUiState.localWithWorkspaces || currentUiState === drawerUiState.repositoryWithWorkspaces + const isLocalUiState = + currentUiState === drawerUiState.localWithWorkspaces || + currentUiState === drawerUiState.localEmpty const showInitialize = currentUiState === drawerUiState.repositoryEmpty const showNewWorkspace = !showInitialize @@ -190,6 +201,11 @@ export const createWorkspacesDrawer = ({ openButton.disabled = !hasSelection } + if (renameButton instanceof HTMLButtonElement) { + renameButton.toggleAttribute('hidden', !isLocalUiState) + renameButton.disabled = !canRenameWorkspace + } + if (removeButton instanceof HTMLButtonElement) { removeButton.disabled = !hasSelection || isSelectedWorkspaceActive } @@ -229,12 +245,7 @@ export const createWorkspacesDrawer = ({ for (const entry of filteredEntries) { const option = document.createElement('option') option.value = toSafeText(entry.id) - const shouldPrefixAsLocal = - normalizedRepositoryFilter === localRepositoryFilterValue && - shouldRenderAsLocalEntry(entry) - option.textContent = toWorkspaceLabel(entry, { - forceLocalPrefix: shouldPrefixAsLocal, - }) + option.textContent = toWorkspaceLabel(entry) option.selected = option.value === selectedId selectInput.append(option) } @@ -521,6 +532,30 @@ export const createWorkspacesDrawer = ({ setStatus('Loaded workspace.', 'neutral') }) + renameButton?.addEventListener('click', async () => { + const id = toSafeText(selectedId) + if (!id || typeof onRenameSelected !== 'function') { + return + } + + selectedId = id + + let renamed = false + try { + renamed = await onRenameSelected(id) + } catch { + setStatus('Could not rename stored workspace.', 'error') + return + } + + if (!renamed) { + return + } + + await refresh({ preserveSelection: true }) + setStatus('Renamed workspace.', 'neutral') + }) + removeButton?.addEventListener('click', async () => { const id = toSafeText(selectedId) if (!id || typeof onRemoveSelected !== 'function') { diff --git a/src/styles/layout-shell.css b/src/styles/layout-shell.css index 2b5a292..fa1983c 100644 --- a/src/styles/layout-shell.css +++ b/src/styles/layout-shell.css @@ -110,10 +110,11 @@ --expanded-panel-min-height: 360px; display: grid; grid-template-columns: repeat(2, minmax(320px, 1fr)); - grid-template-rows: auto auto minmax(0, 1fr); + grid-template-rows: auto auto auto minmax(0, 1fr); grid-template-areas: 'layout-controls layout-controls' 'workspace-tabs workspace-tabs' + 'workspace-context workspace-context' 'editors preview'; gap: 18px; padding: 24px; @@ -122,15 +123,6 @@ overflow: hidden; } -.app-grid.app-grid--workspace-context-visible { - grid-template-rows: auto auto auto minmax(0, 1fr); - grid-template-areas: - 'layout-controls layout-controls' - 'workspace-tabs workspace-tabs' - 'workspace-context workspace-context' - 'editors preview'; -} - .panels-stack { min-width: 0; } From 05577a214327bf46c3a6a5f2beb2f344d4eb0d31 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 3 May 2026 16:23:58 -0500 Subject: [PATCH 2/2] refactor: address PR comments. --- .../workspace-context-status-controller.js | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/src/modules/app-core/workspace-context-status-controller.js b/src/modules/app-core/workspace-context-status-controller.js index 81893dc..dc493f3 100644 --- a/src/modules/app-core/workspace-context-status-controller.js +++ b/src/modules/app-core/workspace-context-status-controller.js @@ -1,5 +1,3 @@ -const hasTokenValue = token => typeof token === 'string' && token.trim().length > 0 - const createWorkspaceContextStatusController = ({ statusNode, toNonEmptyWorkspaceText, @@ -12,9 +10,6 @@ const createWorkspaceContextStatusController = ({ getWorkspaceRepositoryFullName, getSelectedRepositoryFullName, }) => { - let hasValidatedGitHubPat = false - let hasCompletedRepositoryLoad = false - const getWorkspaceName = () => { const workspaceScope = toNonEmptyWorkspaceText(getWorkspaceScopeMarker?.()).toLowerCase() || 'local' @@ -58,13 +53,12 @@ const createWorkspaceContextStatusController = ({ const workspaceName = getWorkspaceName() const workspaceScope = toNonEmptyWorkspaceText(getWorkspaceScopeMarker?.()).toLowerCase() || 'local' - const shouldShowRepositoryContext = - hasValidatedGitHubPat && workspaceScope !== 'local' - const repository = shouldShowRepositoryContext - ? toNonEmptyWorkspaceText(getWorkspaceRepositoryFullName?.()) || - toNonEmptyWorkspaceText(getSelectedRepositoryFullName?.()) || - 'unknown' - : 'local' + const repository = + workspaceScope === 'local' + ? 'local' + : toNonEmptyWorkspaceText(getWorkspaceRepositoryFullName?.()) || + toNonEmptyWorkspaceText(getSelectedRepositoryFullName?.()) || + 'unknown' statusNode.textContent = `${workspaceName} • ${repository}` } @@ -74,25 +68,13 @@ const createWorkspaceContextStatusController = ({ } const syncTokenState = token => { - if (!hasTokenValue(token)) { - hasValidatedGitHubPat = false - hasCompletedRepositoryLoad = false - } else if (hasCompletedRepositoryLoad) { - hasValidatedGitHubPat = true - } - + void token render() } const syncWritableRepositoriesState = ({ token, isLoadingRepositories = false }) => { - if (!isLoadingRepositories) { - hasCompletedRepositoryLoad = true - } - - if (hasTokenValue(token) && !isLoadingRepositories) { - hasValidatedGitHubPat = true - } - + void token + void isLoadingRepositories render() }