From 46492f819c1dd93108dd5907c6c8f9e47f65855a Mon Sep 17 00:00:00 2001 From: KCM Date: Fri, 1 May 2026 15:41:17 -0500 Subject: [PATCH 1/2] feat: workspace status bar. --- playwright/github-byot-ai.spec.ts | 10 ++ src/app.js | 114 +++++++++--------- src/index.html | 7 ++ .../workspace-context-status-controller.js | 93 ++++++++++++++ .../workspace-record-applied-handler.js | 60 +++++++++ src/styles/layout-shell.css | 30 +++++ 6 files changed, 260 insertions(+), 54 deletions(-) create mode 100644 src/modules/app-core/workspace-context-status-controller.js create mode 100644 src/modules/app-core/workspace-record-applied-handler.js diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index b3413b3..8b41a06 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -48,6 +48,16 @@ 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 }) => { + await waitForAppReady(page) + + const workspaceContextStatus = page.locator('#workspace-context-status') + await expect(workspaceContextStatus).toBeHidden() + + await connectByotWithSingleRepo(page) + await expect(workspaceContextStatus).toBeVisible() +}) + test('BYOT controls render with default app entry', async ({ page }) => { await waitForAppReady(page, appEntryPath) diff --git a/src/app.js b/src/app.js index 529bec8..9a4e234 100644 --- a/src/app.js +++ b/src/app.js @@ -49,6 +49,8 @@ import { createPersistedActivePrContextGetter } from './modules/app-core/persist import { createWorkspacePrSessionHandoffController } from './modules/app-core/workspace-pr-session-handoff-controller.js' import { persistClosedPrContextRecords } from './modules/app-core/pr-context-records.js' import { createPrContextStateChangeHandler } from './modules/app-core/pr-context-state-change-handler.js' +import { createWorkspaceContextStatusController } from './modules/app-core/workspace-context-status-controller.js' +import { createWorkspaceRecordAppliedHandler } from './modules/app-core/workspace-record-applied-handler.js' import { createDiagnosticsUiController } from './modules/diagnostics/diagnostics-ui.js' import { createGitHubChatDrawer } from './modules/github/chat-drawer/drawer.js' import { createGitHubByotControls } from './modules/github/byot-controls.js' @@ -165,6 +167,7 @@ const appThemeButtons = document.querySelectorAll('[data-app-theme]') const workspaceTabsShell = document.getElementById('workspace-tabs-shell') const workspaceTabsStrip = document.getElementById('workspace-tabs-strip') const workspaceTabAddWrap = document.getElementById('workspace-tab-add-wrap') +const workspaceContextStatus = document.getElementById('workspace-context-status') const workspaceTabAddButton = document.getElementById('workspace-tab-add') const workspaceTabAddMenu = document.getElementById('workspace-tab-add-menu') const workspaceTabAddModule = document.getElementById('workspace-tab-add-module') @@ -424,12 +427,18 @@ let workspacePrNumber = null let workspaceRepositoryFullName = '' let workspaceScopeMarker = 'local' let hasObservedActivePrContextInSession = false +let workspaceContextStatusController = { + render: () => {}, + renderForRepositoryChange: () => {}, + syncTokenState: () => {}, + syncWritableRepositoriesState: () => {}, +} const toWorkspaceScopeMarker = value => (value === 'repository' ? 'repository' : 'local') - -const setWorkspaceScopeMarker = nextScope => { - workspaceScopeMarker = toWorkspaceScopeMarker(nextScope) -} +const setWorkspaceScopeMarker = nextScope => ( + (workspaceScopeMarker = toWorkspaceScopeMarker(nextScope)), + workspaceContextStatusController.render() +) const toPullRequestNumber = value => { if (typeof value === 'number' && Number.isFinite(value) && value > 0) { @@ -445,6 +454,7 @@ const setActiveWorkspaceRecordId = nextValue => { workspaceRepositoryFullName = '' workspaceScopeMarker = 'local' } + workspaceContextStatusController.render() } let chatDrawerController = { @@ -509,17 +519,28 @@ const byotControls = createGitHubByotControls({ prDrawerController.setSelectedRepository(repository) hasObservedActivePrContextInSession = false prDrawerController.syncRepositories() + workspaceContextStatusController.renderForRepositoryChange() }, - onWritableRepositoriesChange: ({ repositories, selectedRepository }) => { + onWritableRepositoriesChange: ({ + repositories, + selectedRepository, + placeholderLabel, + }) => { githubAiContextState.writableRepositories = Array.isArray(repositories) ? [...repositories] : [] + workspaceContextStatusController.syncWritableRepositoriesState({ + token: githubAiContextState.token, + placeholderLabel, + }) + if (selectedRepository || githubAiContextState.selectedRepository) { githubAiContextState.selectedRepository = selectedRepository ?? null chatDrawerController.setSelectedRepository(selectedRepository) prDrawerController.setSelectedRepository(selectedRepository) prDrawerController.syncRepositories() + workspaceContextStatusController.renderForRepositoryChange() return } @@ -535,6 +556,8 @@ const byotControls = createGitHubByotControls({ prDrawerController.setSelectedRepository(synchronizedRepository) prDrawerController.syncRepositories() } + + workspaceContextStatusController.renderForRepositoryChange() }, onTokenDeleteRequest: onConfirm => { confirmAction({ @@ -546,6 +569,7 @@ const byotControls = createGitHubByotControls({ }, onTokenChange: token => { githubAiContextState.token = token + workspaceContextStatusController.syncTokenState(token) prContextUi.syncAiChatTokenVisibility(token) chatDrawerController.setToken(token) prDrawerController.setToken(token) @@ -575,6 +599,19 @@ const getCurrentSelectedRepositoryFullName = () => { return '' } +workspaceContextStatusController = createWorkspaceContextStatusController({ + statusNode: workspaceContextStatus, + toNonEmptyWorkspaceText, + getWorkspacePrTitle: () => githubPrTitle?.value, + getWorkspaceHeadBranch: () => githubPrHeadBranch?.value, + getWorkspaceScopeMarker: () => workspaceScopeMarker, + getActiveWorkspaceRecordId: () => activeWorkspaceRecordId, + getWorkspaceRepositoryFullName: () => workspaceRepositoryFullName, + getSelectedRepositoryFullName: getCurrentSelectedRepositoryFullName, +}) + +workspaceContextStatusController.render() + const getPersistedActivePrContext = createPersistedActivePrContextGetter({ getCurrentSelectedRepositoryFullName, getWorkspacePrContextState: () => workspacePrContextState, @@ -733,6 +770,20 @@ const reconcileWorkspaceTabsWithEditorSync = ({ tabTargets } = {}) => const buildWorkspaceRecordSnapshot = ({ recordId } = {}) => workspaceSyncController.buildWorkspaceRecordSnapshot({ recordId }) +const setWorkspaceRepositoryFullName = value => { + workspaceRepositoryFullName = toNonEmptyWorkspaceText(value) + workspaceContextStatusController.render() +} +const onWorkspaceRecordApplied = createWorkspaceRecordAppliedHandler({ + getPrDrawerController: () => prDrawerController, + setWorkspaceRepositoryFullName, + byotControls, + getGithubPrBodyValue: () => + typeof githubPrBody?.value === 'string' ? githubPrBody.value : '', + normalizeRenderMode, + getStyleModeValue: () => styleMode.value, +}) + const { workspaceSaveController, listLocalContextRecords, @@ -821,49 +872,7 @@ const { getWorkspaceTabByKind, makeUniqueTabPath, createWorkspaceTabId, - onWorkspaceRecordApplied: workspace => { - if (!workspace || typeof workspace !== 'object') { - return - } - - prDrawerController.clearSelectedRepositoryActivePrContext({ resetForm: false }) - - const nextWorkspaceRepositoryFullName = - typeof workspace.repo === 'string' ? workspace.repo.trim() : '' - if (nextWorkspaceRepositoryFullName) { - workspaceRepositoryFullName = nextWorkspaceRepositoryFullName - byotControls.setSelectedRepository(nextWorkspaceRepositoryFullName) - } - - const state = - typeof workspace.prContextState === 'string' - ? workspace.prContextState.trim().toLowerCase() - : '' - const shouldHydratePrContext = state === 'active' - if (!shouldHydratePrContext) { - return - } - - prDrawerController.hydrateActivePrContext( - { - baseBranch: typeof workspace.base === 'string' ? workspace.base : '', - headBranch: typeof workspace.head === 'string' ? workspace.head : '', - prTitle: typeof workspace.prTitle === 'string' ? workspace.prTitle : '', - prBody: typeof githubPrBody?.value === 'string' ? githubPrBody.value : '', - pullRequestNumber: - typeof workspace.prNumber === 'number' && Number.isFinite(workspace.prNumber) - ? workspace.prNumber - : null, - pullRequestUrl: '', - renderMode: normalizeRenderMode(workspace.renderMode), - styleMode: styleMode.value, - }, - { - repositoryFullName: - typeof workspace.repo === 'string' ? workspace.repo.trim() : '', - }, - ) - }, + onWorkspaceRecordApplied, }) const { syncActiveWorkspaceRepositoryScope, forkWorkspaceFromCurrentState } = @@ -886,9 +895,7 @@ const { syncActiveWorkspaceRepositoryScope, forkWorkspaceFromCurrentState } = setActiveWorkspaceRecordId, setActiveWorkspaceCreatedAt: value => (activeWorkspaceCreatedAt = value), getWorkspaceRepositoryFullName: () => workspaceRepositoryFullName, - setWorkspaceRepositoryFullName: value => { - workspaceRepositoryFullName = toNonEmptyWorkspaceText(value) - }, + setWorkspaceRepositoryFullName, setWorkspaceScopeMarker, setHeadBranchValue: value => { if (githubPrHeadBranch) { @@ -949,14 +956,13 @@ const setWorkspacePrContextState = nextState => { if (typeof nextState !== 'string' || !nextState.trim()) { return } - workspacePrContextState = nextState.trim() + workspaceContextStatusController.render() } const setWorkspacePrNumber = nextValue => { workspacePrNumber = toPullRequestNumber(nextValue) } - const persistWorkspacePrContextState = nextState => { setWorkspacePrContextState(nextState) queueWorkspaceSave({ preserveRecordId: true }) @@ -1019,7 +1025,7 @@ const onPrContextStateChange = createPrContextStateChangeHandler({ parsePullRequestNumberFromUrl, getCurrentSelectedRepositoryFullName, getWorkspaceRepositoryFullName: () => workspaceRepositoryFullName, - setWorkspaceRepositoryFullName: value => (workspaceRepositoryFullName = value), + setWorkspaceRepositoryFullName, getWorkspacePrContextState: () => workspacePrContextState, getHasObservedActivePrContextInSession: () => hasObservedActivePrContextInSession, setHasObservedActivePrContextInSession: value => diff --git a/src/index.html b/src/index.html index 423d5c2..97d0ab5 100644 --- a/src/index.html +++ b/src/index.html @@ -343,6 +343,13 @@

+ +
+ typeof placeholderLabel === 'string' && + placeholderLabel.trim().toLowerCase() === 'loading writable repositories...' + +const hasTokenValue = token => typeof token === 'string' && token.trim().length > 0 + +const createWorkspaceContextStatusController = ({ + statusNode, + toNonEmptyWorkspaceText, + getWorkspacePrTitle, + getWorkspaceHeadBranch, + getWorkspaceScopeMarker, + getActiveWorkspaceRecordId, + getWorkspaceRepositoryFullName, + getSelectedRepositoryFullName, +}) => { + let hasValidatedGitHubPat = false + const appGrid = + statusNode instanceof HTMLElement ? statusNode.closest('.app-grid') : null + + const getWorkspaceName = () => { + const prTitle = toNonEmptyWorkspaceText(getWorkspacePrTitle?.()) + if (prTitle) { + return prTitle + } + + const headBranch = toNonEmptyWorkspaceText(getWorkspaceHeadBranch?.()) + if (headBranch) { + return headBranch + } + + return toNonEmptyWorkspaceText(getActiveWorkspaceRecordId?.()) || 'unknown' + } + + const render = () => { + if (!(statusNode instanceof HTMLElement)) { + return + } + + if (appGrid instanceof HTMLElement) { + appGrid.classList.toggle( + 'app-grid--workspace-context-visible', + hasValidatedGitHubPat, + ) + } + + statusNode.toggleAttribute('hidden', !hasValidatedGitHubPat) + if (!hasValidatedGitHubPat) { + return + } + + const workspaceName = getWorkspaceName() + const workspaceScope = + toNonEmptyWorkspaceText(getWorkspaceScopeMarker?.()).toLowerCase() || 'local' + const repository = + workspaceScope === 'local' + ? 'local' + : toNonEmptyWorkspaceText(getWorkspaceRepositoryFullName?.()) || + toNonEmptyWorkspaceText(getSelectedRepositoryFullName?.()) || + 'unknown' + + statusNode.textContent = `${workspaceName} • ${repository}` + } + + const renderForRepositoryChange = () => { + render() + } + + const syncTokenState = token => { + if (!hasTokenValue(token)) { + hasValidatedGitHubPat = false + } + + render() + } + + const syncWritableRepositoriesState = ({ token, placeholderLabel }) => { + if (hasTokenValue(token) && !isValidationLoadingPlaceholder(placeholderLabel)) { + hasValidatedGitHubPat = true + } + + render() + } + + return { + render, + renderForRepositoryChange, + syncTokenState, + syncWritableRepositoriesState, + } +} + +export { createWorkspaceContextStatusController } diff --git a/src/modules/app-core/workspace-record-applied-handler.js b/src/modules/app-core/workspace-record-applied-handler.js new file mode 100644 index 0000000..210b25d --- /dev/null +++ b/src/modules/app-core/workspace-record-applied-handler.js @@ -0,0 +1,60 @@ +const createWorkspaceRecordAppliedHandler = ({ + getPrDrawerController, + setWorkspaceRepositoryFullName, + byotControls, + getGithubPrBodyValue, + normalizeRenderMode, + getStyleModeValue, +}) => { + const onWorkspaceRecordApplied = workspace => { + if (!workspace || typeof workspace !== 'object') { + return + } + + const prDrawerController = getPrDrawerController?.() + prDrawerController?.clearSelectedRepositoryActivePrContext({ resetForm: false }) + + const nextWorkspaceRepositoryFullName = + typeof workspace.repo === 'string' ? workspace.repo.trim() : '' + if (nextWorkspaceRepositoryFullName) { + setWorkspaceRepositoryFullName(nextWorkspaceRepositoryFullName) + byotControls?.setSelectedRepository(nextWorkspaceRepositoryFullName) + } else { + setWorkspaceRepositoryFullName('') + byotControls?.clearSelectedRepositoryPreference?.() + } + + const state = + typeof workspace.prContextState === 'string' + ? workspace.prContextState.trim().toLowerCase() + : '' + const shouldHydratePrContext = state === 'active' + if (!shouldHydratePrContext || !prDrawerController) { + return + } + + prDrawerController.hydrateActivePrContext( + { + baseBranch: typeof workspace.base === 'string' ? workspace.base : '', + headBranch: typeof workspace.head === 'string' ? workspace.head : '', + prTitle: typeof workspace.prTitle === 'string' ? workspace.prTitle : '', + prBody: getGithubPrBodyValue?.() || '', + pullRequestNumber: + typeof workspace.prNumber === 'number' && Number.isFinite(workspace.prNumber) + ? workspace.prNumber + : null, + pullRequestUrl: '', + renderMode: normalizeRenderMode(workspace.renderMode), + styleMode: getStyleModeValue?.() || '', + }, + { + repositoryFullName: + typeof workspace.repo === 'string' ? workspace.repo.trim() : '', + }, + ) + } + + return onWorkspaceRecordApplied +} + +export { createWorkspaceRecordAppliedHandler } diff --git a/src/styles/layout-shell.css b/src/styles/layout-shell.css index c155581..3229703 100644 --- a/src/styles/layout-shell.css +++ b/src/styles/layout-shell.css @@ -122,6 +122,15 @@ 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; } @@ -140,6 +149,27 @@ border-bottom: 1px solid var(--border-subtle); } +.app-grid-workspace-context-status { + grid-area: workspace-context; + min-width: 0; + width: 100%; + margin-top: -10px; + margin-bottom: -10px; + padding: 1px 2px 0; + color: color-mix(in srgb, #60a5fa 84%, var(--panel-text)); + font-weight: 500; + font-size: 12px; + letter-spacing: 0.01em; + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.app-grid-workspace-context-status[hidden] { + display: none; +} + .panels-stack--editors { display: grid; grid-template-columns: minmax(320px, 1fr); From 079cc2f364fd6466433ebab317a6fc66c6a6f9fa Mon Sep 17 00:00:00 2001 From: KCM Date: Fri, 1 May 2026 16:18:24 -0500 Subject: [PATCH 2/2] refactor: address pr comments. --- src/app.js | 10 +++++----- src/index.html | 2 ++ .../workspace-context-status-controller.js | 16 ++++++++++------ src/modules/github/byot-controls.js | 17 +++++++++++++---- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/app.js b/src/app.js index 9a4e234..190ac1c 100644 --- a/src/app.js +++ b/src/app.js @@ -435,10 +435,10 @@ let workspaceContextStatusController = { } const toWorkspaceScopeMarker = value => (value === 'repository' ? 'repository' : 'local') -const setWorkspaceScopeMarker = nextScope => ( - (workspaceScopeMarker = toWorkspaceScopeMarker(nextScope)), +const setWorkspaceScopeMarker = nextScope => { + workspaceScopeMarker = toWorkspaceScopeMarker(nextScope) workspaceContextStatusController.render() -) +} const toPullRequestNumber = value => { if (typeof value === 'number' && Number.isFinite(value) && value > 0) { @@ -524,7 +524,7 @@ const byotControls = createGitHubByotControls({ onWritableRepositoriesChange: ({ repositories, selectedRepository, - placeholderLabel, + isLoadingRepositories, }) => { githubAiContextState.writableRepositories = Array.isArray(repositories) ? [...repositories] @@ -532,7 +532,7 @@ const byotControls = createGitHubByotControls({ workspaceContextStatusController.syncWritableRepositoriesState({ token: githubAiContextState.token, - placeholderLabel, + isLoadingRepositories, }) if (selectedRepository || githubAiContextState.selectedRepository) { diff --git a/src/index.html b/src/index.html index 97d0ab5..9dac925 100644 --- a/src/index.html +++ b/src/index.html @@ -347,7 +347,9 @@

class="app-grid-workspace-context-status" id="workspace-context-status" hidden + role="status" aria-live="polite" + aria-atomic="true" >

diff --git a/src/modules/app-core/workspace-context-status-controller.js b/src/modules/app-core/workspace-context-status-controller.js index 28a3363..1c976a8 100644 --- a/src/modules/app-core/workspace-context-status-controller.js +++ b/src/modules/app-core/workspace-context-status-controller.js @@ -1,7 +1,3 @@ -const isValidationLoadingPlaceholder = placeholderLabel => - typeof placeholderLabel === 'string' && - placeholderLabel.trim().toLowerCase() === 'loading writable repositories...' - const hasTokenValue = token => typeof token === 'string' && token.trim().length > 0 const createWorkspaceContextStatusController = ({ @@ -15,6 +11,7 @@ const createWorkspaceContextStatusController = ({ getSelectedRepositoryFullName, }) => { let hasValidatedGitHubPat = false + let hasCompletedRepositoryLoad = false const appGrid = statusNode instanceof HTMLElement ? statusNode.closest('.app-grid') : null @@ -69,13 +66,20 @@ const createWorkspaceContextStatusController = ({ const syncTokenState = token => { if (!hasTokenValue(token)) { hasValidatedGitHubPat = false + hasCompletedRepositoryLoad = false + } else if (hasCompletedRepositoryLoad) { + hasValidatedGitHubPat = true } render() } - const syncWritableRepositoriesState = ({ token, placeholderLabel }) => { - if (hasTokenValue(token) && !isValidationLoadingPlaceholder(placeholderLabel)) { + const syncWritableRepositoriesState = ({ token, isLoadingRepositories = false }) => { + if (!isLoadingRepositories) { + hasCompletedRepositoryLoad = true + } + + if (hasTokenValue(token) && !isLoadingRepositories) { hasValidatedGitHubPat = true } diff --git a/src/modules/github/byot-controls.js b/src/modules/github/byot-controls.js index dcbab6c..2c46aad 100644 --- a/src/modules/github/byot-controls.js +++ b/src/modules/github/byot-controls.js @@ -153,7 +153,7 @@ export const createGitHubByotControls = ({ updateTokenAddButtonState() } - const clearRepoOptions = placeholderLabel => { + const clearRepoOptions = (placeholderLabel, { isLoadingRepositories = false } = {}) => { if (repoSelect instanceof HTMLSelectElement) { repoSelect.replaceChildren( createDefaultRepoOption({ @@ -169,6 +169,7 @@ export const createGitHubByotControls = ({ repositories: [], selectedRepository: null, placeholderLabel, + isLoadingRepositories, }) } } @@ -181,7 +182,10 @@ export const createGitHubByotControls = ({ return writableRepos.find(repo => repo.fullName === lastSelectedRepository) ?? null } - const emitWritableRepositories = ({ placeholderLabel = '' } = {}) => { + const emitWritableRepositories = ({ + placeholderLabel = '', + isLoadingRepositories = false, + } = {}) => { if (typeof onWritableRepositoriesChange !== 'function') { return } @@ -190,6 +194,7 @@ export const createGitHubByotControls = ({ repositories: [...writableRepos], selectedRepository: getSelectedRepositoryObject(), placeholderLabel, + isLoadingRepositories, }) } @@ -302,7 +307,9 @@ export const createGitHubByotControls = ({ clearAddButtonResetTimer() setTokenAddButtonState('loading') - clearRepoOptions('Loading writable repositories...') + clearRepoOptions('Loading writable repositories...', { + isLoadingRepositories: true, + }) setStatus('Loading writable repositories from GitHub...', 'pending') try { @@ -373,7 +380,9 @@ export const createGitHubByotControls = ({ tokenInput.setAttribute('aria-label', 'GitHub token') setTokenFieldLockState(false) updateTokenActionVisibility() - clearRepoOptions('Connect a token to load repositories') + clearRepoOptions('Connect a token to load repositories', { + isLoadingRepositories: false, + }) updateTokenAddButtonState() }