Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
bb458c1
zkAPI needs no account: sign-in is a Tickets requirement
dsobhani8 Sep 9, 2026
2e19b62
One tooltip everywhere: the Billing card, and never a native title on…
dsobhani8 Sep 9, 2026
0fd9b6f
Sign-in dialog: a plain close instead of a zkAPI paragraph; footer sa…
dsobhani8 Sep 9, 2026
cc45771
Purple: a fourth app theme in Ethereum's hue
dsobhani8 Sep 9, 2026
9f21a21
zkAPI: Appearance and chat export in the composer gear
dsobhani8 Sep 9, 2026
16195f0
Private balance modal on the OA dialog frame
dsobhani8 Sep 9, 2026
2ac4ced
Appearance lives in the composer gear for every mode; Purple is "EF p…
dsobhani8 Sep 9, 2026
96b961e
Private balance: feedback round — toasts for progress, rows for withd…
dsobhani8 Sep 9, 2026
2fb23d3
Sign-in dialog: closing keeps the chosen mode; backdrop closes; a ses…
dsobhani8 Sep 9, 2026
699de30
Test: the payment history no longer offers "Add a new private balance"
dsobhani8 Sep 9, 2026
1640b42
Build: version the zkapi.css link by content
dsobhani8 Sep 9, 2026
2c0a388
Private balance: the deposit is the figure; dialog buttons keep their…
dsobhani8 Sep 9, 2026
4b0271f
The gear holds Data controls and feedback for every mode; the Account…
dsobhani8 Sep 9, 2026
1ee113d
Withdraw view tidied; Delete account leaves the menu for Billing; mod…
dsobhani8 Sep 9, 2026
8b90471
Gear: Appearance, Data controls and feedback first; Export / Import a…
dsobhani8 Sep 9, 2026
66e8e72
Right panel: a pending deposit reads "Deposit in progress"
dsobhani8 Sep 9, 2026
21ba52a
Tooltips at 12px; the Tickets / zkAPI control has none
dsobhani8 Sep 9, 2026
0262320
Private balance: a step smaller throughout
dsobhani8 Sep 9, 2026
3d3a7d8
Log out in one tab ends the session in every tab
dsobhani8 Sep 9, 2026
4eeca25
Share feedback: a row hover
dsobhani8 Sep 9, 2026
8d25e9d
Private-access phases narrate in the toast; the panel pill spins; no …
dsobhani8 Sep 9, 2026
b58dc01
Test: the panel badge spins during wallet work and keeps its label fo…
dsobhani8 Sep 9, 2026
8778b81
EF purple dialogs sit in the theme; the busy pill is neutral; wallet …
dsobhani8 Sep 9, 2026
38a3f99
The balance bar is switch blue; Council review says why it is out in …
dsobhani8 Sep 9, 2026
a40f742
Exports from the gear ask first
dsobhani8 Sep 9, 2026
271611f
The Council review switch bubbles like the Parallel icon in zkAPI mode
dsobhani8 Sep 9, 2026
c3ddad3
Wallet setup steps read as steps
dsobhani8 Sep 9, 2026
cc5cee7
Indexer lag and a resumable deposit are explained in plain words
dsobhani8 Sep 9, 2026
5009b22
The export card sits in front of the gear; the Council bubble shows; …
dsobhani8 Sep 9, 2026
7d2fb34
Every gear switch has a bubble that says what it does, or why it is out
dsobhani8 Sep 9, 2026
e653725
The gear comes back after the export card
dsobhani8 Sep 9, 2026
fa9ebbf
Use confirmed Sepolia token balances during funding
mingyech Sep 9, 2026
8341a53
Clarify saved deposit recovery when wallet approval is pending
mingyech Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions chat/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -524,14 +524,42 @@ 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
* account) gets the Log in or sign up dialog at once, rather than a
* 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?.();
Expand Down Expand Up @@ -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?.(),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
23 changes: 22 additions & 1 deletion chat/application/authIntent.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
32 changes: 31 additions & 1 deletion chat/components/AccountModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) &&
Expand Down Expand Up @@ -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' : '';
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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;
Expand Down
59 changes: 43 additions & 16 deletions chat/components/ChatInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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');
Expand All @@ -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) {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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;
});

Expand All @@ -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]');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}

Expand Down
Loading