diff --git a/chat/app.js b/chat/app.js index d44ac43..31850d6 100644 --- a/chat/app.js +++ b/chat/app.js @@ -524,6 +524,34 @@ class ChatApp { return this.signInPolicy; } + /** 'tickets' or 'zkapi'; 'tickets' on a build without payment modes. */ + getPaymentMode(session = undefined) { + if (typeof this.runtime?.getMode !== 'function') return 'tickets'; + try { + return this.runtime.getMode(session) === 'zkapi' ? 'zkapi' : 'tickets'; + } catch { + return 'tickets'; + } + } + + hasPaymentModes() { + return typeof this.runtime?.changeMode === 'function'; + } + + async changePaymentMode(mode) { + if (!this.hasPaymentModes()) throw new Error('This build has one payment mode.'); + await this.runtime.changeMode(mode); + } + + /** + * Sign-in is a Tickets requirement, not a page requirement: zkAPI pays + * from a private balance and needs no account. So the policy applies + * only while the payment mode is Tickets. + */ + signInRequiredNow() { + return this.signInPolicy.required === true && this.getPaymentMode() !== 'zkapi'; + } + /** * On a host that requires sign-in, a page that loads without an unlocked * account (a refresh after Log out, a new tab, a mode that needs an @@ -531,7 +559,7 @@ class ChatApp { * landing page: that page is for a first visit only. */ async openSignInIfRequired() { - if (!this.signInPolicy.required || !this.accountModal) return false; + if (!this.signInRequiredNow() || !this.accountModal) return false; const state = await accountService.waitForAuthBootstrap(); if (state?.accountId && state.status === 'unlocked') return false; this.accountModal.open?.(); @@ -642,9 +670,17 @@ class ChatApp { ), registerShortageHandler: handler => this.registerTicketShortageHandler(handler) }), + payments: Object.freeze({ + // 'tickets' or 'zkapi'. Hosts use it to keep ticket + // onboarding (the Welcome offers) on the Tickets side. + getMode: () => this.getPaymentMode(), + available: () => this.hasPaymentModes(), + setMode: mode => this.changePaymentMode(mode) + }), ui: Object.freeze({ persistNavigationForReturn: () => { saveNavigationSelection(this.state.currentSessionId); }, openAccount: () => this.accountModal?.open?.(), + openDeleteAccount: () => this.settingsDialog?.openDeleteAccount?.(), closeWelcome: () => this.welcomePanel?.close?.(), closeAccount: () => this.accountModal?.handleCloseAttempt?.(), ensureTicketStatusVisible: () => this.rightPanel?.show?.(), @@ -868,6 +904,7 @@ class ChatApp { if (this.getPendingSend(null) || this.featureOperations?.size) throw new Error('Wait for the message to finish sending before changing payment mode.'); this.inferenceService.setDefaultBackendId(backendId); await this.refreshBackendPresentation(null); + this.askToSignInForBackend(backendId); return { sessionId: null, backendId }; } const session = this.state.sessionsById.get(sessionId); @@ -901,12 +938,23 @@ class ChatApp { Object.assign(session, stagedSession); this.clearMemoryApiOverrideContent(sessionId); await this.refreshBackendPresentation(session); + this.askToSignInForBackend(backendId); return { sessionId, backendId }; } finally { this.endSessionMutation(sessionId, reservation); } } + /** + * Switching a signed-out chat to Tickets asks for the account Tickets + * needs. The dialog offers "Use zkAPI instead", which undoes the switch. + */ + askToSignInForBackend(backendId) { + if (backendId === 'zkapi' || !this.signInPolicy.required) return; + if (accountService.getState()?.accountId) return; + this.accountModal?.open?.(); + } + async refreshBackendPresentation(session) { if (this.state.currentSessionId !== (session?.id || null)) return; this.cachedModelDisplayMetadata = this.inferenceService.getCachedModels(session); @@ -2593,6 +2641,7 @@ class ChatApp { const route = this.features.accounts ? await routeAuthenticationIntent({ accountService, accountModal: this.accountModal, + changePaymentMode: this.hasPaymentModes() ? mode => this.changePaymentMode(mode) : null, locationImpl: window.location, historyImpl: window.history }) : null; @@ -4925,7 +4974,7 @@ class ChatApp { // Signed out on a host that requires sign-in: the answer is the Log // in dialog, not a ticket shortage (which would open the Welcome // offers over a page that cannot buy anything). - if (this.signInPolicy.required && !accountService.getState()?.accountId) { + if (this.signInRequiredNow() && !accountService.getState()?.accountId) { this.accountModal?.open?.(); return false; } diff --git a/chat/application/authIntent.js b/chat/application/authIntent.js index 5b2c9e8..314337a 100644 --- a/chat/application/authIntent.js +++ b/chat/application/authIntent.js @@ -1,8 +1,12 @@ const GOOGLE_AUTH_INTENT = 'google'; const USERNAME_AUTH_INTENT = 'username'; +// Not a sign-in: the landing page's zkAPI entry. The chat opens in zkAPI +// mode (no account needed) and offers to fund the private balance. +const ZKAPI_INTENT = 'zkapi'; const AUTHENTICATION_INTENTS = new Set([ GOOGLE_AUTH_INTENT, - USERNAME_AUTH_INTENT + USERNAME_AUTH_INTENT, + ZKAPI_INTENT ]); function normalizeUsername(value) { @@ -78,11 +82,28 @@ export function clearAuthenticationIntent( export async function routeAuthenticationIntent({ accountService, accountModal, + changePaymentMode = null, locationImpl = globalThis.location, historyImpl = globalThis.history }) { const intent = getAuthenticationIntent(locationImpl); if (!intent) return Object.freeze({ handled: false, action: 'none' }); + if (intent === ZKAPI_INTENT) { + clearAuthenticationIntent(locationImpl, historyImpl); + if (typeof changePaymentMode !== 'function') { + // A build without zkAPI: the arrival is an ordinary visit. + return Object.freeze({ handled: false, action: 'none' }); + } + await accountService.waitForAuthBootstrap(); + try { + // Selecting zkAPI also runs the send preflight, which opens the + // funding dialog when the private balance is empty. + await changePaymentMode(ZKAPI_INTENT); + } catch (error) { + console.warn('zkAPI could not be selected on arrival:', error); + } + return Object.freeze({ handled: true, action: 'zkapi' }); + } const username = intent === USERNAME_AUTH_INTENT ? getUsernameAuthenticationValue(locationImpl) : null; diff --git a/chat/components/AccountModal.js b/chat/components/AccountModal.js index 9516570..d55e60d 100644 --- a/chat/components/AccountModal.js +++ b/chat/components/AccountModal.js @@ -92,10 +92,28 @@ class AccountModal { const nav = document.getElementById('account-nav'); if (this.menuOpen && !nav?.contains(event.target)) this.closeAccountMenu(); }; + // A press on the dimmed page outside the card is a close, the same + // as the X or Escape (and refused in the same cases). + this.onOverlayPointerDown = event => { + if (this.isOpen && event.target === this.overlay) this.handleCloseAttempt(); + }; + this.overlay?.addEventListener?.('pointerdown', this.onOverlayPointerDown); this.accountUnsubscribe = this.accountService.subscribe(state => { + const previous = this.accountState || {}; this.accountState = state; this.updateTabIndicator(); + // The session ended under this tab (Log out or deletion in + // another tab, an expired session). On the Tickets side that is + // a signed-out page: show Log in rather than leave a dead chat. + if ( + previous.sessionVerified === true && state?.sessionVerified !== true && + !this.isOpen && !this.loggingOut && + this.app?.signInRequiredNow?.() === true + ) { + this.open(); + return; + } if ( this.isOpen && !this.shouldSuppressAuthenticationExitRender(state) && @@ -322,7 +340,9 @@ class AccountModal { ? 'Finish account setup' : needsEncryptionUnlock ? 'Unlock encrypted data' - : 'Account'; + : this.app?.getSignInPolicy?.()?.required === true + ? 'Log in' + : 'Account'; if (identityLabel) identityLabel.textContent = identityText; if (bootstrapStatus) { bootstrapStatus.textContent = isAuthResolving ? 'Restoring account' : ''; @@ -515,6 +535,9 @@ class AccountModal { */ mustStaySignedIn() { if (this.app?.getSignInPolicy?.()?.required !== true) return false; + // With zkAPI in the build there is always a way to chat without an + // account, so the dialog is an offer with a close button, not a wall. + if (this.app?.hasPaymentModes?.() === true) return false; const state = this.accountState || {}; return !(state.accountId && state.status === 'unlocked'); } @@ -1260,6 +1283,12 @@ class AccountModal { // if it does, the dimmed page stays until the new page paints. if (this.app?.notifyLoggedOut?.() === true) return; this.loggingOut = false; + if (this.app?.getPaymentMode?.() === 'zkapi') { + // Logged out of the mode that needs no account: nothing to ask. + this.close(); + this.app?.showToast?.('Logged out', 'success'); + return; + } this.render(); this.app?.showToast?.('Logged out', 'success'); } @@ -2303,6 +2332,7 @@ class AccountModal { this.clearAnimationTimeouts(); this.closeAccountMenu(); document.removeEventListener?.('pointerdown', this.onDocumentPointerDown); + this.overlay?.removeEventListener?.('pointerdown', this.onOverlayPointerDown); if (this.accountUnsubscribe) { this.accountUnsubscribe(); this.accountUnsubscribe = null; diff --git a/chat/components/ChatInput.js b/chat/components/ChatInput.js index e8e1813..02ee730 100644 --- a/chat/components/ChatInput.js +++ b/chat/components/ChatInput.js @@ -1897,9 +1897,21 @@ export default class ChatInput { toggle.classList.toggle('switch-active', enabled); toggle.classList.toggle('switch-inactive', !enabled); // The row label names the switch and aria-checked carries its state; - // a native title on top of that showed two tooltips at once. + // a native title on top of that showed two tooltips at once. The + // bubble says what the switch does (the markup's description, kept + // on first sight), not its state, which the switch already shows. toggle.removeAttribute('title'); - toggle.dataset.tooltip = enabled ? enabledTitle : disabledTitle; + if (!toggle.dataset.description && toggle.dataset.tooltip) toggle.dataset.description = toggle.dataset.tooltip; + toggle.dataset.tooltip = toggle.dataset.description || (enabled ? enabledTitle : disabledTitle); + } + + /** Dims a gear row and puts the reason in its switch's bubble while the + * feature is out in this payment mode; restores the description after. */ + markSwitchAvailability(toggle, available, reason) { + if (!toggle) return; + toggle.closest?.('.settings-row')?.classList?.toggle?.('is-disabled', !available); + toggle.dataset.featureUnavailable = String(!available); + if (!available) toggle.dataset.tooltip = reason; } refreshMemorySettingsUI() { @@ -1916,10 +1928,7 @@ export default class ChatInput { if (featureToggle) { featureToggle.disabled = !memorySupported; featureToggle.setAttribute('aria-disabled', String(!memorySupported)); - if (!memorySupported) { - featureToggle.title = memoryUnavailableReason; - featureToggle.dataset.tooltip = memoryUnavailableReason; - } + this.markSwitchAvailability(featureToggle, memorySupported, memoryUnavailableReason); } const memoryAutoIncludeToggle = document.getElementById('memory-auto-include-toggle'); @@ -1933,6 +1942,7 @@ export default class ChatInput { if (memoryAutoIncludeToggle) { memoryAutoIncludeToggle.disabled = !memoryFeatureEnabled; memoryAutoIncludeToggle.setAttribute('aria-disabled', String(!memoryFeatureEnabled)); + this.markSwitchAvailability(memoryAutoIncludeToggle, memoryFeatureEnabled, memoryUnavailableReason); } if (this.scrubberModelSelect) { @@ -1956,6 +1966,7 @@ export default class ChatInput { this.memoryAgentModelSelect.title = memoryFeatureEnabled ? 'Memory agent model' : memoryUnavailableReason; + this.memoryAgentModelSelect.closest?.('.settings-row')?.classList?.toggle?.('is-disabled', !memoryFeatureEnabled); } document.querySelectorAll('[data-memory-requires-feature]').forEach((element) => { @@ -2186,7 +2197,8 @@ export default class ChatInput { button.dataset.tooltip = button.disabled ? this.getFeatureUnavailableReason('council') : (isParallel ? 'Parallel' : 'Chat'); - button.title = button.dataset.tooltip; + // The app tooltip says it; a native title would say it twice. + button.removeAttribute('title'); button.tabIndex = button.disabled ? -1 : 0; }); @@ -2198,11 +2210,8 @@ export default class ChatInput { memoryButton.setAttribute('aria-disabled', String(!memoryFeatureEnabled)); memoryButton.classList.toggle('memory-active', memoryEnabled); memoryButton.classList.toggle('memory-disabled', !memoryFeatureEnabled); - memoryButton.title = memoryFeatureEnabled - ? (memoryEnabled - ? 'Auto-attach memory is on. Double-click to open memory.' - : 'Auto-attach memory is off. Double-click to open memory.') - : this.getMemoryUnavailableReason(); + // The button carries its own hover card; no native title on top of it. + memoryButton.removeAttribute('title'); const tooltipText = memoryButton.querySelector('[data-memory-tooltip-text]'); const tooltipDetail = memoryButton.querySelector('[data-memory-tooltip-detail]'); @@ -2752,15 +2761,30 @@ export default class ChatInput { councilReviewToggle.setAttribute('aria-checked', String(isCouncilReviewEnabled)); councilReviewToggle.classList.toggle('switch-active', isCouncilReviewEnabled); councilReviewToggle.classList.toggle('switch-inactive', !isCouncilReviewEnabled); - councilReviewToggle.title = isCouncilReviewEnabled - ? 'Council review is on' - : 'Council review is off'; + // The bubble describes the switch; aria-checked carries its state. + councilReviewToggle.removeAttribute('title'); } if (councilReviewModelRow) { // The row is always there, dimmed while review is off: turning the // switch on must not grow the panel under the pointer. - councilReviewModelRow.classList.toggle('is-disabled', !isCouncilReviewEnabled); + councilReviewModelRow.classList.toggle('is-disabled', !isCouncilReviewEnabled || !councilSupported); + } + + // In zkAPI mode the Council rows dim and the switch's bubble says + // why, so a greyed switch never reads as "off" when it is "not here". + const councilReviewRow = document.getElementById('council-review-row'); + if (councilReviewRow) councilReviewRow.classList.toggle('is-disabled', !councilSupported); + if (councilReviewToggle) { + if (councilSupported) { + if (councilReviewToggle.dataset.availableTooltip) { + councilReviewToggle.dataset.tooltip = councilReviewToggle.dataset.availableTooltip; + delete councilReviewToggle.dataset.availableTooltip; + } + } else if (!councilReviewToggle.dataset.availableTooltip) { + councilReviewToggle.dataset.availableTooltip = councilReviewToggle.dataset.tooltip || ''; + councilReviewToggle.dataset.tooltip = this.getFeatureUnavailableReason('council'); + } } if (councilReviewModelSelect) { @@ -2818,6 +2842,8 @@ export default class ChatInput { control.dataset.featureUnavailable = String(!councilSupported); control.setAttribute('aria-disabled', String(control.disabled)); } + // The switch has an app bubble; a native title would say it twice. + if (councilReviewToggle && !councilSupported) councilReviewToggle.removeAttribute('title'); } escapeOptionValue(value) { @@ -2892,6 +2918,7 @@ export default class ChatInput { */ formatThemeName(theme) { if (!theme) return ''; + if (theme === 'purple') return 'EF purple'; return theme.charAt(0).toUpperCase() + theme.slice(1); } diff --git a/chat/components/SettingsDialog.js b/chat/components/SettingsDialog.js index 1987e67..9aeb1c2 100644 --- a/chat/components/SettingsDialog.js +++ b/chat/components/SettingsDialog.js @@ -1,11 +1,9 @@ /** - * Settings dialog: what you set once and leave. Data controls, Appearance, - * feedback, and the account actions (Log out, Delete account). The gear on - * the composer keeps what changes between one prompt and the next. - * - * The markup lives in index.html (#settings-dialog); the Appearance - * controls keep their ids, so ChatInput binds them as before. This class - * only opens, closes, and routes the data-action buttons. + * Data controls, Appearance and Share feedback live in the composer gear for + * every mode, so a zkAPI user without an account finds them too; this class + * runs the gear's data actions (export, import). What remains account-bound + * — Delete account — is a row in the commercial Billing dialog, which opens + * it straight into its confirmation over the #settings-dialog overlay. */ import { exportChats, exportAllData } from '../services/globalExport.js'; @@ -55,10 +53,76 @@ class SettingsDialog { this.overlay.addEventListener('pointerdown', this.onOverlayPointerDown); this.overlay.addEventListener('click', this.onClick); } - document.getElementById('account-preferences-menu-item')?.addEventListener('click', () => { - this.app.accountModal?.closeAccountMenu?.(); - this.open(); - }); + // The gear's Data controls: exports ask first (a download should not + // be a surprise), on the dialog's backdrop; imports open their pickers. + this.dataSection = document.getElementById('data-management-section'); + this.onDataClick = event => this.handleDataClick(event); + this.dataSection?.addEventListener('click', this.onDataClick); + } + + /** From Billing's account row: only the confirmation, over the backdrop. */ + openDeleteAccount(returnFocusEl = null) { + if (!this.overlay) return; + this.open(returnFocusEl); + this.dialog?.setAttribute?.('hidden', ''); + this.standaloneConfirm = true; + this.showDeleteConfirm(); + } + + /** The gear is a z-100 popover; the confirmation lives on the dialog's + * backdrop beneath it, so the gear closes first. */ + closeGearMenu() { + this.app.elements?.settingsMenu?.classList?.add?.('hidden'); + this.app.elements?.settingsBtn?.classList?.remove?.('tooltip-disabled'); + this.app.chatInput?.closeSettingsMenu?.(); + } + + /** Puts the gear back the way it was: the card stepped in front of it, + * it did not replace it. The gear button's own handler positions it. */ + reopenGearMenu() { + const menu = this.app.elements?.settingsMenu; + const button = this.app.elements?.settingsBtn; + if (!menu || !button?.click) return; + const reopen = () => { if (menu.classList?.contains?.('hidden')) button.click(); }; + // After the current click has finished bubbling, so the document's + // outside-click handler does not close what was just reopened. + if (typeof setTimeout === 'function') setTimeout(reopen, 0); else reopen(); + } + + /** From the gear: only the export confirmation, over the backdrop. */ + openExportConfirm(action, returnFocusEl = null) { + if (!this.overlay) return; + this.closeGearMenu(); + this.reopenGearOnClose = true; + this.open(returnFocusEl); + this.dialog?.setAttribute?.('hidden', ''); + this.standaloneConfirm = true; + this.showExportConfirm(action); + } + + async handleDataClick(event) { + const button = event.target.closest?.('button[data-action]'); + if (!button || !this.dataSection?.contains(button)) return; + event.stopPropagation(); + switch (button.dataset.action) { + case 'export-chats': + case 'export-all-data': + case 'export-memory': + this.openExportConfirm(button.dataset.action, button); + break; + case 'import-data': + document.getElementById('global-import-input')?.click?.(); + break; + case 'import-memory': + document.getElementById('memory-import-input')?.click?.(); + break; + case 'import-history': + this.closeGearMenu(); + this.app.chatHistoryImportModal?.open?.(); + break; + default: + break; + } } open(returnFocusEl = null) { @@ -74,11 +138,17 @@ class SettingsDialog { if (!this.isOpen || !this.overlay) return; this.isOpen = false; this.dismissDeleteConfirm(); + this.dialog?.removeAttribute?.('hidden'); + this.standaloneConfirm = false; this.overlay.classList.add('hidden'); document.removeEventListener('keydown', this.onKeydown); const target = this.returnFocusEl; this.returnFocusEl = null; if (target?.focus && document.contains?.(target)) target.focus({ preventScroll: true }); + if (this.reopenGearOnClose) { + this.reopenGearOnClose = false; + this.reopenGearMenu(); + } } handleKeydown(event) { @@ -268,6 +338,8 @@ class SettingsDialog { const target = this.confirmReturnFocus; this.confirmReturnFocus = null; target?.focus?.(); + // Opened from the account menu there is nothing behind the card. + if (this.standaloneConfirm && this.isOpen) this.close(); } /** diff --git a/chat/index.html b/chat/index.html index e48558d..71fb578 100644 --- a/chat/index.html +++ b/chat/index.html @@ -12,7 +12,7 @@ const storageKey = 'oa-theme-preference'; const stored = localStorage.getItem(storageKey); const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - const preference = stored === 'light' || stored === 'dark' ? stored : 'system'; + const preference = stored === 'light' || stored === 'dark' || stored === 'purple' ? stored : 'system'; const effectiveTheme = preference === 'dark' || (preference === 'system' && prefersDark) ? 'dark' : 'light'; const root = document.documentElement; @@ -26,6 +26,7 @@ root.classList.add('theme-light'); root.classList.remove('dark', 'theme-dark'); } + root.classList.toggle('theme-purple', preference === 'purple'); } catch (error) { console.warn('Unable to apply stored theme preference before hydration:', error); } @@ -491,12 +492,6 @@