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..190ac1c 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,11 +427,17 @@ 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) + workspaceContextStatusController.render() } const toPullRequestNumber = value => { @@ -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, + isLoadingRepositories, + }) => { githubAiContextState.writableRepositories = Array.isArray(repositories) ? [...repositories] : [] + workspaceContextStatusController.syncWritableRepositoriesState({ + token: githubAiContextState.token, + isLoadingRepositories, + }) + 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..9dac925 100644 --- a/src/index.html +++ b/src/index.html @@ -343,6 +343,15 @@

+ +
typeof token === 'string' && token.trim().length > 0 + +const createWorkspaceContextStatusController = ({ + statusNode, + toNonEmptyWorkspaceText, + getWorkspacePrTitle, + getWorkspaceHeadBranch, + getWorkspaceScopeMarker, + getActiveWorkspaceRecordId, + getWorkspaceRepositoryFullName, + getSelectedRepositoryFullName, +}) => { + let hasValidatedGitHubPat = false + let hasCompletedRepositoryLoad = 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 + hasCompletedRepositoryLoad = false + } else if (hasCompletedRepositoryLoad) { + hasValidatedGitHubPat = true + } + + render() + } + + const syncWritableRepositoriesState = ({ token, isLoadingRepositories = false }) => { + if (!isLoadingRepositories) { + hasCompletedRepositoryLoad = true + } + + if (hasTokenValue(token) && !isLoadingRepositories) { + 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/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() } 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);