From 44c8b834fbcb9af81acbbea23f885be1de6f0651 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Thu, 7 May 2026 01:39:17 +0530 Subject: [PATCH] feat(webview-accounts): faster + clearer cold opens (#1233 #1284) (#1285) --- Cargo.lock | 2 +- app/src-tauri/Cargo.lock | 4 +- app/src-tauri/src/cdp/session.rs | 79 +++++- app/src-tauri/src/lib.rs | 5 + app/src-tauri/src/webview_accounts/mod.rs | 249 ++++++++++++++++-- app/src/components/accounts/WebviewHost.tsx | 110 ++++++-- .../accounts/__tests__/WebviewHost.test.tsx | 127 +++++++++ .../usePrewarmMostRecentAccount.test.tsx | 149 +++++++++++ app/src/hooks/usePrewarmMostRecentAccount.ts | 69 +++++ app/src/pages/Accounts.tsx | 58 +++- .../webviewAccountService.prewarm.test.ts | 61 +++++ app/src/services/webviewAccountService.ts | 34 +++ app/src/store/accountsSlice.ts | 27 ++ app/src/store/index.ts | 2 +- app/src/types/accounts.ts | 8 + 15 files changed, 933 insertions(+), 51 deletions(-) create mode 100644 app/src/components/accounts/__tests__/WebviewHost.test.tsx create mode 100644 app/src/hooks/__tests__/usePrewarmMostRecentAccount.test.tsx create mode 100644 app/src/hooks/usePrewarmMostRecentAccount.ts create mode 100644 app/src/services/__tests__/webviewAccountService.prewarm.test.ts diff --git a/Cargo.lock b/Cargo.lock index 9660c17e6..f6791834d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4511,7 +4511,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openhuman" -version = "0.53.17" +version = "0.53.18" dependencies = [ "aes-gcm", "anyhow", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 547fc43ca..525ac7e40 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.53.17" +version = "0.53.18" dependencies = [ "anyhow", "async-trait", @@ -4468,7 +4468,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.53.17" +version = "0.53.18" dependencies = [ "aes-gcm", "anyhow", diff --git a/app/src-tauri/src/cdp/session.rs b/app/src-tauri/src/cdp/session.rs index 75e52cf9c..6d1b02f62 100644 --- a/app/src-tauri/src/cdp/session.rs +++ b/app/src-tauri/src/cdp/session.rs @@ -29,6 +29,20 @@ use crate::webview_accounts::emit_load_finished; /// 500ms. const ATTACH_BACKOFF: Duration = Duration::from_secs(2); +/// Retry schedule used on the very first attach pass after the webview is +/// spawned. The target usually appears almost immediately, but the CEF +/// browser host can take a few hundred ms on cold start. We try at t=0 +/// (in case the target is already up — common after the CEF prewarm), then +/// escalate quickly so the worst case before the [`ATTACH_BACKOFF`] kicks +/// in is ~600ms — saving ~500ms on the warm path versus the previous fixed +/// `sleep(500ms)`. Issue #1233. +const INITIAL_ATTACH_SCHEDULE: [Duration; 4] = [ + Duration::from_millis(0), + Duration::from_millis(50), + Duration::from_millis(150), + Duration::from_millis(400), +]; + /// Watchdog budget before we synthesise a `webview-account:load` event with /// `state: "timeout"` so the frontend can switch from an empty loading state /// to explicit retry/help UI on flaky networks. Matches issue #867. @@ -161,10 +175,44 @@ async fn run_session_forever(app: AppHandle, account_id: String, real_url, placeholder_marker(&account_id) ); - // Let the webview's target appear in CDP before we start hammering - // `/json/version`. The placeholder URL is trivial so this is quick. - sleep(Duration::from_millis(500)).await; + // Issue #1233 — first-pass retry schedule replaces the previous fixed + // `sleep(500ms)` warmup. Try at t=0 (often succeeds when the target was + // already up via CEF prewarm), then escalate quickly. Each schedule slot + // sleeps THEN tries, so a target up at t≈0ms attaches without waiting + // for the old 500ms grace. + // + // The steady-state reconnect loop below sleeps `ATTACH_BACKOFF` BEFORE + // each attempt. That ordering matters: it means an exhausted initial + // schedule (all four attach attempts failed) gets a proper 2s backoff + // before the fifth attempt, instead of the original "drop straight in + // and try immediately" bug that effectively fired five back-to-back + // attaches in <1s and then waited 2s. After a successful session that + // ends cleanly we also wait the backoff before reconnecting so we + // don't tight-loop against a target that just torched its renderer. + for (idx, delay) in INITIAL_ATTACH_SCHEDULE.iter().enumerate() { + sleep(*delay).await; + match run_session_cycle(&app, &account_id, &real_url).await { + Ok(()) => { + log::info!( + "[cdp-session][{}] initial session ended cleanly attempt={} reconnecting", + account_id, + idx + ); + break; + } + Err(e) => { + log::debug!( + "[cdp-session][{}] initial attach attempt={} delay={:?} failed: {}", + account_id, + idx, + delay, + e + ); + } + } + } loop { + sleep(ATTACH_BACKOFF).await; match run_session_cycle(&app, &account_id, &real_url).await { Ok(()) => { log::info!( @@ -176,7 +224,6 @@ async fn run_session_forever(app: AppHandle, account_id: String, log::debug!("[cdp-session][{}] cycle failed: {}", account_id, e); } } - sleep(ATTACH_BACKOFF).await; } } @@ -535,4 +582,28 @@ mod tests { "acct-42" )); } + + /// Issue #1233 — initial attach retry schedule must finish well under + /// the previous fixed 500ms warmup so the warm path saves wall-clock + /// on cold opens. Locked at 4 attempts summing to ≤ 600ms. + #[test] + fn initial_attach_schedule_under_600ms_total() { + let total: Duration = INITIAL_ATTACH_SCHEDULE.iter().sum(); + assert_eq!( + INITIAL_ATTACH_SCHEDULE.len(), + 4, + "schedule should have 4 attempts; got {:?}", + INITIAL_ATTACH_SCHEDULE + ); + assert!( + total <= Duration::from_millis(600), + "schedule total {:?} exceeds 600ms budget", + total + ); + assert_eq!( + INITIAL_ATTACH_SCHEDULE[0], + Duration::ZERO, + "first attempt must run immediately (CEF prewarm hits)", + ); + } } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 28844ad17..fe79d2e94 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1426,6 +1426,7 @@ pub fn run() { width: 900.0, height: 700.0, }), + prewarm: false, }; match webview_accounts::webview_account_open( app_handle.clone(), @@ -1469,6 +1470,7 @@ pub fn run() { width: 900.0, height: 700.0, }), + prewarm: false, }; match webview_accounts::webview_account_open( app_handle.clone(), @@ -1512,6 +1514,7 @@ pub fn run() { width: 900.0, height: 700.0, }), + prewarm: false, }; match webview_accounts::webview_account_open( app_handle.clone(), @@ -1570,6 +1573,7 @@ pub fn run() { width: w, height: h, }), + prewarm: false, }; match webview_accounts::webview_account_open( app_handle.clone(), @@ -1627,6 +1631,7 @@ pub fn run() { register_dictation_hotkey, unregister_dictation_hotkey, webview_accounts::webview_account_open, + webview_accounts::webview_account_prewarm, webview_accounts::webview_account_close, webview_accounts::webview_account_purge, webview_accounts::webview_account_bounds, diff --git a/app/src-tauri/src/webview_accounts/mod.rs b/app/src-tauri/src/webview_accounts/mod.rs index 4c7cbb307..5fd20d6ac 100644 --- a/app/src-tauri/src/webview_accounts/mod.rs +++ b/app/src-tauri/src/webview_accounts/mod.rs @@ -631,6 +631,14 @@ pub struct WebviewAccountsState { /// "Manage your Google Account" from the avatar menu) are passed /// through unchanged. gmeet_awaiting_handoff: Mutex>, + /// account_ids spawned via `webview_account_prewarm` that have not yet + /// been opened by the user. Issue #1233 — emit_load_finished suppresses + /// `webview-account:load` events for these so the React UI never sees + /// load/timeout signals for an account it didn't ask to open. The flag + /// is cleared on the first user-initiated `webview_account_open` + /// (warm-reopen branch) and on close/purge so subsequent reopens flow + /// through the normal cold-load lifecycle. + prewarm_accounts: Mutex>, } /// Threshold and window for the gmeet workspace-marketing rewrite loop @@ -781,6 +789,12 @@ impl WebviewAccountsState { if let Ok(mut g) = self.gmeet_awaiting_handoff.lock() { g.clear(); } + // Issue #1233 — clear prewarm flags so a relaunch can't suppress + // load events for accounts that were prewarmed in the previous + // session. + if let Ok(mut g) = self.prewarm_accounts.lock() { + g.clear(); + } self.inner .lock() .ok() @@ -1281,6 +1295,27 @@ pub struct OpenArgs { /// Optional URL override (debug tooling) — falls back to `provider_url`. pub url: Option, pub bounds: Option, + /// Issue #1233 — when true, spawn the webview off-screen and route the + /// load through the prewarm-suppression path. The full handler/scanner/ + /// notification setup is identical to a normal cold open; only the + /// initial position and the load-event emit are different. Defaults + /// to false so the field is forwards-compatible with frontends that + /// don't pass it. + #[serde(default)] + pub prewarm: bool, +} + +/// Issue #1233 — args for the background `webview_account_prewarm` command. +/// No bounds — prewarm always spawns at a fixed off-screen 1×1 rect; the +/// user-initiated open later supplies the visible rect via the warm-reopen +/// branch in `webview_account_open`. +#[derive(Debug, Deserialize)] +pub struct PrewarmArgs { + pub account_id: String, + pub provider: String, + /// Optional URL override (debug tooling) — falls back to `provider_url`. + #[serde(default)] + pub url: Option, } #[derive(Debug, Deserialize)] @@ -1367,6 +1402,37 @@ pub(crate) fn emit_load_finished( return; }; + // Issue #1233 — accounts in prewarm mode have no React UI listening + // for their load events; the user hasn't clicked the rail icon yet. + // Suppress emit + reveal so the prewarm cycle finishes silently. The + // page is still painted in the off-screen 1×1 webview so the eventual + // user click hits the warm-reopen branch and emits `state:"reused"`. + if app_state + .prewarm_accounts + .lock() + .unwrap() + .contains(account_id) + { + log::info!( + "[webview-accounts][{}] prewarm load suppressed state={} url={}", + account_id, + state, + redact_url_for_log(url) + ); + // Mark the account as loaded so any later signals from the same + // cold-load (native on_page_load + CDP Page.loadEventFired both + // arriving) don't double-fire if the prewarm flag flips off in + // between. + if state != "timeout" { + app_state + .loaded_accounts + .lock() + .unwrap() + .insert(account_id.to_string()); + } + return; + } + if state == "timeout" { // If we've already observed a terminal load, ignore late watchdogs. let already_loaded = app_state @@ -1633,6 +1699,36 @@ pub async fn webview_account_open( if let Some(existing_label) = map.get(&args.account_id).cloned() { drop(map); if let Some(existing) = app.get_webview(&existing_label) { + // Issue #1233 — when this is a prewarm call landing on an + // already-prewarmed account, do nothing: the webview is + // already off-screen, the CDP session is already attached, + // and the prewarm flag should stay set so the eventual + // user-initiated open can promote it. Just return the label. + if args.prewarm { + log::debug!( + "[webview-accounts] prewarm idempotent skip: account={} already warm label={}", + args.account_id, + existing_label + ); + return Ok(existing_label); + } + // Issue #1233 — a prewarmed webview is reaching its first + // user-initiated open. Clear the prewarm flag BEFORE we + // resize/reveal so any in-flight CDP load event still + // racing toward `emit_load_finished` flows through the + // normal path instead of being silently suppressed. + let was_prewarmed = state + .prewarm_accounts + .lock() + .unwrap() + .remove(&args.account_id); + if was_prewarmed { + log::info!( + "[webview-accounts] prewarm hit account={} label={} — promoting to live", + args.account_id, + existing_label + ); + } if let Some(b) = args.bounds { let _ = existing.set_position(LogicalPosition::new(b.x, b.y)); let _ = existing.set_size(LogicalSize::new(b.width, b.height)); @@ -2092,8 +2188,6 @@ pub async fn webview_account_open( // placeholder's loading spinner is not covered by the native CEF subview. // `webview_account_reveal` (invoked from the frontend after the load event // arrives, or by the 15 s watchdog) moves it back to `bounds` + shows it. - // We use positive coords well below the parent window rather than large - // negative values so multi-monitor layouts stay well-defined. // // Warm-open reuse (when a webview already exists for this account) earlier // in this function returns before we get here, so existing webviews keep @@ -2111,23 +2205,48 @@ pub async fn webview_account_open( // that repaint edge case while still keeping the webview visually // hidden (1 px under the overlay) during load. // - // Warm-open reuse returned earlier in this function, so this only - // affects the first cold spawn. - let initial_size = if skip_cdp_for_debug { - LogicalSize::new(bounds.width, bounds.height) + // Issue #1233 — when `args.prewarm == true`, the frontend has not asked + // for a visible rect (the user hasn't clicked the rail icon yet). Spawn + // the webview at a fixed off-screen position with size 1×1 so it never + // paints anywhere on screen until the eventual user-initiated open + // promotes it via the warm-reopen branch above. + let (initial_position, initial_size) = if args.prewarm { + ( + LogicalPosition::new(PREWARM_OFFSCREEN_X, PREWARM_OFFSCREEN_Y), + LogicalSize::new(1.0, 1.0), + ) + } else if skip_cdp_for_debug { + ( + LogicalPosition::new(bounds.x, bounds.y), + LogicalSize::new(bounds.width, bounds.height), + ) } else { - LogicalSize::new(1.0, 1.0) + ( + LogicalPosition::new(bounds.x, bounds.y), + LogicalSize::new(1.0, 1.0), + ) }; - let initial_position = LogicalPosition::new(bounds.x, bounds.y); - // Remember the bounds the frontend wanted so `webview_account_reveal` has a - // rect to restore to even if the frontend's bounds cache is empty (e.g. - // after a page reload races the load event). - state - .requested_bounds - .lock() - .unwrap() - .insert(args.account_id.clone(), bounds); + // Issue #1233 — only remember `requested_bounds` for non-prewarm opens. + // Prewarm doesn't have a visible rect to restore to; the user-initiated + // open later supplies the bounds via the warm-reopen branch. + if !args.prewarm { + state + .requested_bounds + .lock() + .unwrap() + .insert(args.account_id.clone(), bounds); + } + // Issue #1233 — mark the account as prewarmed BEFORE add_child so the + // load-event suppression in `emit_load_finished` is in place by the time + // the CDP session or native on_page_load fires. + if args.prewarm { + state + .prewarm_accounts + .lock() + .unwrap() + .insert(args.account_id.clone()); + } // Defensive reset: if a prior close/purge was raced by a stale emit we // could still have the account marked as "already loaded". Clear here so // the fresh spawn is allowed to fire the first event again. @@ -2319,6 +2438,51 @@ pub async fn webview_account_open( Ok(label) } +/// Off-screen position used for the prewarmed webview. Same magnitude as +/// the [`super::lib::CEF_PREWARM_LABEL`] warmup placeholder so the native +/// view is well outside any plausible monitor layout. Issue #1233. +pub(crate) const PREWARM_OFFSCREEN_X: f64 = -20_000.0; +pub(crate) const PREWARM_OFFSCREEN_Y: f64 = -20_000.0; + +/// Issue #1233 — spawn a hidden 1×1 webview for `account_id` so its CEF +/// profile and provider page are warm before the user clicks the rail icon. +/// On the user's first click, the existing `webview_account_open` warm-reopen +/// branch reuses the prewarmed webview and emits `state:"reused"` so the React +/// loading overlay never has to wait for a cold load. +/// +/// Implemented as a thin delegate to `webview_account_open` with +/// `prewarm: true`. Sharing the cold-open code path means the prewarmed +/// webview gets the full handler suite (`on_navigation`, `on_new_window`, +/// `on_page_load`), the per-provider scanner bootstrap, and the CEF +/// notification registration — none of which can be retroactively wired +/// when the warm-reopen branch later returns early. +/// +/// Idempotent — calling for an already-warm account is a no-op. Best-effort — +/// the frontend can safely fire-and-forget; on failure the worst case is a +/// normal cold open later. +#[tauri::command] +pub async fn webview_account_prewarm( + app: AppHandle, + state: tauri::State<'_, WebviewAccountsState>, + args: PrewarmArgs, +) -> Result<(), String> { + log::info!( + "[webview-accounts] prewarm account_id={} provider={}", + args.account_id, + args.provider + ); + let open_args = OpenArgs { + account_id: args.account_id, + provider: args.provider, + url: args.url, + bounds: None, + prewarm: true, + }; + webview_account_open(app, state, open_args) + .await + .map(|_| ()) +} + #[tauri::command] pub async fn webview_account_close( app: AppHandle, @@ -2378,6 +2542,13 @@ pub async fn webview_account_close( .lock() .unwrap() .remove(&args.account_id); + // Issue #1233 — drop the prewarm flag too so a future prewarm dispatch + // for the same id can re-attempt cleanly. + state + .prewarm_accounts + .lock() + .unwrap() + .remove(&args.account_id); // Drop any gmeet workspace-rewrite counter for this label — labels are // reused on reopen, so a stale entry from a closed-mid-loop session // would saturate the next fresh open's window. @@ -2445,6 +2616,12 @@ pub async fn webview_account_purge( .lock() .unwrap() .remove(&args.account_id); + // Issue #1233 — drop the prewarm flag too on purge. + state + .prewarm_accounts + .lock() + .unwrap() + .remove(&args.account_id); if let Some(label) = label_opt.as_ref() { state.clear_gmeet_marketing_rewrite(label); // Drop any pending handoff flag for this label so a stale entry @@ -3678,4 +3855,44 @@ mod tests { // `myaccount.google.com` visit after relaunch. assert!(!state.take_awaiting_gmeet_handoff("acct_test")); } + + // ── prewarm bookkeeping (issue #1233) ────────────────── + + /// Default state must include an empty `prewarm_accounts` set so + /// fresh boots never spuriously suppress load events. + #[test] + fn prewarm_accounts_default_is_empty() { + let state = WebviewAccountsState::default(); + assert!(state.prewarm_accounts.lock().unwrap().is_empty()); + } + + /// Inserting an id into `prewarm_accounts` and then removing it should + /// leave the set empty — covers the warm-reopen path where the user's + /// first click promotes the prewarmed webview to live. + #[test] + fn prewarm_accounts_insert_then_remove_clears() { + let state = WebviewAccountsState::default(); + state + .prewarm_accounts + .lock() + .unwrap() + .insert("acct-1".to_string()); + assert!(state.prewarm_accounts.lock().unwrap().contains("acct-1")); + state.prewarm_accounts.lock().unwrap().remove("acct-1"); + assert!(!state.prewarm_accounts.lock().unwrap().contains("acct-1")); + } + + /// `drain_for_shutdown` must not leak prewarm flags either — otherwise + /// a relaunch could spuriously suppress the very first cold open. + #[test] + fn prewarm_flag_cleared_by_drain_for_shutdown() { + let state = WebviewAccountsState::default(); + state + .prewarm_accounts + .lock() + .unwrap() + .insert("acct-warm".to_string()); + let _ = state.drain_for_shutdown(); + assert!(state.prewarm_accounts.lock().unwrap().is_empty()); + } } diff --git a/app/src/components/accounts/WebviewHost.tsx b/app/src/components/accounts/WebviewHost.tsx index 487bcf6be..33257bff1 100644 --- a/app/src/components/accounts/WebviewHost.tsx +++ b/app/src/components/accounts/WebviewHost.tsx @@ -1,5 +1,5 @@ import debug from 'debug'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { hideWebviewAccount, @@ -9,6 +9,7 @@ import { } from '../../services/webviewAccountService'; import { useAppSelector } from '../../store/hooks'; import type { AccountProvider, AccountStatus } from '../../types/accounts'; +import { ProviderIcon } from './providerIcons'; const log = debug('webview-accounts:host'); @@ -30,6 +31,49 @@ const PROVIDER_COPY: Record = { browserscan: 'BrowserScan', }; +// Phase-hint thresholds for slow loads. Most cold opens finish well under +// 5s; the hints only render when something is actually taking a while so +// the wording never feels patronising on the happy path. +const PHASE_HINT_AT_MS = 5_000; +const PHASE_HINT_LATE_MS = 10_000; + +/** + * Counter-driven phase hint that escalates after 5s/10s of loading. + * + * Lives in its own component so the elapsed counter resets purely via + * mount/unmount: `WebviewHost` only renders this child while the account + * is in a loading state, so flipping out of `'loading'` unmounts it and + * the next loading run starts fresh from zero. Keeps `WebviewHost`'s + * effects free of synchronous `setState` calls (lint rule + * `react-hooks/set-state-in-effect`) while preserving deterministic + * fake-timer behaviour for tests — counter is incremented by an interval + * tick rather than diffing `Date.now()`. + */ +const LoadingPhaseHint = ({ accountId }: { accountId: string }) => { + const [elapsedMs, setElapsedMs] = useState(0); + useEffect(() => { + const tickMs = 500; + const id = window.setInterval(() => { + setElapsedMs(prev => prev + tickMs); + }, tickMs); + return () => window.clearInterval(id); + }, []); + const text = + elapsedMs >= PHASE_HINT_LATE_MS + ? 'Almost ready...' + : elapsedMs >= PHASE_HINT_AT_MS + ? 'Restoring session...' + : null; + if (!text) return null; + return ( + + {text} + + ); +}; + /** * Reserves a rectangular slot in the React layout that the native child * webview is glued to. We measure the placeholder's bounding rect and @@ -41,6 +85,12 @@ const PROVIDER_COPY: Record = { * the React loading overlay below isn't covered by an empty native view. The * overlay is dismissed when the `webview-account:load` event flips the account * status out of `pending`/`loading`. + * + * Issue #1233 — to eliminate the perceived blank-screen gap before the + * webview paints, the host always renders a branded placeholder (provider + * icon + name) immediately on mount, with a spinner overlay while the + * account is in a loading state. After 5s/10s the spinner adds a phase + * hint so the user gets feedback that something is still happening. */ const WebviewHost = ({ accountId, provider }: WebviewHostProps) => { const ref = useRef(null); @@ -49,13 +99,12 @@ const WebviewHost = ({ accountId, provider }: WebviewHostProps) => { ); const openedRef = useRef(false); const status = useAppSelector(s => s.accounts.accounts[accountId]?.status); - // Only render the spinner when the account is *actively* loading. We used - // to also treat `status === undefined` as loading, but that meant a host - // mounted for an account that's not in the store (e.g. a render race with - // `addAccount`) would spin forever. The brief microtask between mount and - // the `setAccountStatus('pending')` dispatch in `openWebviewAccount` is - // visually indistinguishable from no overlay, so this is safe. - const isLoading = status !== undefined && LOADING_STATUSES.has(status); + // Treat an unknown account status as "still loading" so the spinner is + // visible from frame 1, even before the openWebviewAccount thunk has + // dispatched setAccountStatus('pending'). The status flips out of the + // loading set on the first 'open'/'timeout'/'closed' transition, so the + // overlay never sticks beyond the actual load. + const isLoading = status === undefined || LOADING_STATUSES.has(status); const isTimeout = status === 'timeout'; const providerName = PROVIDER_COPY[provider] ?? 'app'; @@ -139,15 +188,44 @@ const WebviewHost = ({ accountId, provider }: WebviewHostProps) => { ref={ref} className="relative h-full w-full overflow-hidden rounded-2xl border border-stone-200/70 bg-stone-100 shadow-soft" aria-label={`webview host for account ${accountId}`}> - {isLoading ? ( + {/* Branded placeholder + (optional) loading overlay collapsed into a + single absolute container so we never paint two stacked / offset + flex columns when the spinner is on top of the placeholder. + - Placeholder always rendered (icon + provider name) so the host + area is never a blank stone-100 rectangle. + - When loading: spinner + "Loading {Provider}..." appended below + the same icon, plus the elapsed phase hint past 5s/10s. + - Native CEF view composites above this on reveal, so the + placeholder is only visible during the loading window. */} + {!isTimeout ? (
-
- {`Loading ${providerName}...`} + data-testid={`webview-placeholder-${accountId}`} + className={`pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 ${ + isLoading ? 'text-stone-500' : 'text-stone-400' + }`} + role={isLoading ? 'status' : undefined} + aria-live={isLoading ? 'polite' : undefined} + aria-label={isLoading ? 'Loading account' : undefined}> + + + {isLoading ? `Loading ${providerName}...` : providerName} + + {isLoading ? ( +
+
+ {/* Issue #1233 — `key={accountId}` forces React to unmount the + hint when the user switches between two still-loading + accounts so the elapsed counter doesn't carry the + previous account's progress into the new one. */} + +
+ ) : null}
) : null} diff --git a/app/src/components/accounts/__tests__/WebviewHost.test.tsx b/app/src/components/accounts/__tests__/WebviewHost.test.tsx new file mode 100644 index 000000000..ba539890b --- /dev/null +++ b/app/src/components/accounts/__tests__/WebviewHost.test.tsx @@ -0,0 +1,127 @@ +import { act, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { store } from '../../../store'; +import { addAccount, resetAccountsState, setAccountStatus } from '../../../store/accountsSlice'; +import WebviewHost from '../WebviewHost'; + +// The host component reaches into the webviewAccountService for openWebview / +// hideWebview / setBounds helpers. Stub them so we don't drag the Tauri IPC +// graph (and its Meet/core-RPC siblings) into a unit test. +vi.mock('../../../services/webviewAccountService', () => ({ + hideWebviewAccount: vi.fn().mockResolvedValue(undefined), + openWebviewAccount: vi.fn().mockResolvedValue(undefined), + retryWebviewAccountLoad: vi.fn().mockResolvedValue(undefined), + setWebviewAccountBounds: vi.fn().mockResolvedValue(undefined), +})); + +const ACCOUNT_ID = 'acct-host-1'; + +function renderHost(): void { + render( + + + + ); +} + +function seedAccount(status: 'pending' | 'loading' | 'open' | 'timeout' | 'closed'): void { + store.dispatch(resetAccountsState()); + store.dispatch( + addAccount({ + id: ACCOUNT_ID, + provider: 'slack', + label: 'Slack', + createdAt: new Date().toISOString(), + status, + }) + ); +} + +describe('WebviewHost — issue #1233 loading UX', () => { + beforeEach(() => { + vi.useFakeTimers(); + store.dispatch(resetAccountsState()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('shows the branded placeholder immediately on mount even when status is unknown', () => { + // No account in the store at all — host must still render the + // placeholder so the area is never visually blank. + renderHost(); + expect(screen.getByTestId(`webview-placeholder-${ACCOUNT_ID}`)).toBeInTheDocument(); + }); + + it('shows the loading spinner from frame 1 when status is unknown', () => { + renderHost(); + expect(screen.getByTestId(`webview-loading-${ACCOUNT_ID}`)).toBeInTheDocument(); + }); + + it('shows the loading spinner while account status is `pending`', () => { + seedAccount('pending'); + renderHost(); + expect(screen.getByTestId(`webview-loading-${ACCOUNT_ID}`)).toBeInTheDocument(); + }); + + it('hides the spinner once status flips to `open`', () => { + seedAccount('open'); + renderHost(); + expect(screen.queryByTestId(`webview-loading-${ACCOUNT_ID}`)).not.toBeInTheDocument(); + // Placeholder remains so layout area is never blank during the + // brief frame between native reveal and CEF first paint. + expect(screen.getByTestId(`webview-placeholder-${ACCOUNT_ID}`)).toBeInTheDocument(); + }); + + it('shows the timeout overlay when status is `timeout`', () => { + seedAccount('timeout'); + renderHost(); + expect(screen.getByTestId(`webview-timeout-${ACCOUNT_ID}`)).toBeInTheDocument(); + expect(screen.queryByTestId(`webview-loading-${ACCOUNT_ID}`)).not.toBeInTheDocument(); + }); + + it('renders the phase hint after 5s of loading and escalates after 10s', () => { + seedAccount('loading'); + renderHost(); + + // Frame 1: no hint yet. + expect(screen.queryByTestId(`webview-loading-hint-${ACCOUNT_ID}`)).not.toBeInTheDocument(); + + // Past the 5s threshold the hint appears. + act(() => { + vi.advanceTimersByTime(5_500); + }); + expect(screen.getByTestId(`webview-loading-hint-${ACCOUNT_ID}`)).toHaveTextContent( + /restoring session/i + ); + + // Past the 10s threshold the hint upgrades to the late copy. + act(() => { + vi.advanceTimersByTime(5_000); + }); + expect(screen.getByTestId(`webview-loading-hint-${ACCOUNT_ID}`)).toHaveTextContent( + /almost ready/i + ); + }); + + it('clears the elapsed timer when the account flips to open', () => { + seedAccount('loading'); + renderHost(); + + act(() => { + vi.advanceTimersByTime(7_000); + }); + expect(screen.getByTestId(`webview-loading-hint-${ACCOUNT_ID}`)).toBeInTheDocument(); + + // Warm-reopen path flips the account to `open`. The placeholder stays, + // but the loading overlay (and its hint) must be gone. + act(() => { + store.dispatch(setAccountStatus({ accountId: ACCOUNT_ID, status: 'open' })); + }); + expect(screen.queryByTestId(`webview-loading-${ACCOUNT_ID}`)).not.toBeInTheDocument(); + expect(screen.queryByTestId(`webview-loading-hint-${ACCOUNT_ID}`)).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/hooks/__tests__/usePrewarmMostRecentAccount.test.tsx b/app/src/hooks/__tests__/usePrewarmMostRecentAccount.test.tsx new file mode 100644 index 000000000..a13ccac2c --- /dev/null +++ b/app/src/hooks/__tests__/usePrewarmMostRecentAccount.test.tsx @@ -0,0 +1,149 @@ +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { Provider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { prewarmWebviewAccount } from '../../services/webviewAccountService'; +import { store } from '../../store'; +import { + addAccount, + resetAccountsState, + setActiveAccount, + setLastActiveAccount, +} from '../../store/accountsSlice'; +import type { Account, AccountStatus } from '../../types/accounts'; +import { PREWARM_MAX_ACCOUNTS, usePrewarmMostRecentAccount } from '../usePrewarmMostRecentAccount'; + +vi.mock('../../services/webviewAccountService', () => ({ + prewarmWebviewAccount: vi.fn().mockResolvedValue(undefined), +})); + +function makeAccount( + overrides: Partial & { id: string; provider: Account['provider'] } +): Account { + return { + label: overrides.id, + createdAt: '2026-01-01T00:00:00Z', + status: 'closed' as AccountStatus, + ...overrides, + }; +} + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +function seedStore(opts: { + accounts: Account[]; + activeAccountId: string | null; + mruAccountId: string | null; +}): void { + store.dispatch(resetAccountsState()); + for (const acct of opts.accounts) { + store.dispatch(addAccount(acct)); + } + store.dispatch(setActiveAccount(opts.activeAccountId)); + store.dispatch(setLastActiveAccount(opts.mruAccountId)); +} + +function renderPrewarmHook(args: { + accounts: Account[]; + activeAccountId: string | null; + mruAccountId: string | null; +}): void { + seedStore(args); + const accountsById: Record = Object.fromEntries( + args.accounts.map(a => [a.id, a]) + ); + renderHook( + () => + usePrewarmMostRecentAccount({ + accounts: args.accounts, + accountsById, + activeAccountId: args.activeAccountId, + }), + { wrapper } + ); +} + +describe('usePrewarmMostRecentAccount (issue #1233)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + store.dispatch(resetAccountsState()); + vi.restoreAllMocks(); + }); + + it('prewarms the MRU account when conditions are met', () => { + renderPrewarmHook({ + accounts: [makeAccount({ id: 'acct-1', provider: 'slack', status: 'closed' })], + activeAccountId: null, + mruAccountId: 'acct-1', + }); + expect(prewarmWebviewAccount).toHaveBeenCalledTimes(1); + expect(prewarmWebviewAccount).toHaveBeenCalledWith('acct-1', 'slack'); + }); + + it('does nothing when no MRU id is in the store', () => { + renderPrewarmHook({ + accounts: [makeAccount({ id: 'acct-1', provider: 'slack' })], + activeAccountId: null, + mruAccountId: null, + }); + expect(prewarmWebviewAccount).not.toHaveBeenCalled(); + }); + + it('does nothing when the accounts list is empty', () => { + renderPrewarmHook({ accounts: [], activeAccountId: null, mruAccountId: 'acct-1' }); + expect(prewarmWebviewAccount).not.toHaveBeenCalled(); + }); + + it('does nothing when accounts.length exceeds PREWARM_MAX_ACCOUNTS', () => { + const tooMany: Account[] = Array.from({ length: PREWARM_MAX_ACCOUNTS + 1 }, (_, i) => + makeAccount({ id: `acct-${i}`, provider: 'slack', status: 'closed' }) + ); + renderPrewarmHook({ accounts: tooMany, activeAccountId: null, mruAccountId: 'acct-0' }); + expect(prewarmWebviewAccount).not.toHaveBeenCalled(); + }); + + it('does nothing when the MRU account is no longer in the store', () => { + renderPrewarmHook({ + accounts: [makeAccount({ id: 'acct-1', provider: 'telegram' })], + activeAccountId: null, + mruAccountId: 'acct-removed', + }); + expect(prewarmWebviewAccount).not.toHaveBeenCalled(); + }); + + it('does nothing when the MRU account is already the active one', () => { + renderPrewarmHook({ + accounts: [makeAccount({ id: 'acct-1', provider: 'slack' })], + activeAccountId: 'acct-1', + mruAccountId: 'acct-1', + }); + expect(prewarmWebviewAccount).not.toHaveBeenCalled(); + }); + + it.each(['pending', 'loading', 'open'])( + 'does nothing when the MRU account is already in status %s', + status => { + renderPrewarmHook({ + accounts: [makeAccount({ id: 'acct-1', provider: 'slack', status })], + activeAccountId: null, + mruAccountId: 'acct-1', + }); + expect(prewarmWebviewAccount).not.toHaveBeenCalled(); + } + ); + + it('still prewarms when the MRU account is in status timeout', () => { + renderPrewarmHook({ + accounts: [makeAccount({ id: 'acct-1', provider: 'slack', status: 'timeout' })], + activeAccountId: null, + mruAccountId: 'acct-1', + }); + expect(prewarmWebviewAccount).toHaveBeenCalledWith('acct-1', 'slack'); + }); +}); diff --git a/app/src/hooks/usePrewarmMostRecentAccount.ts b/app/src/hooks/usePrewarmMostRecentAccount.ts new file mode 100644 index 000000000..d0bc35ff6 --- /dev/null +++ b/app/src/hooks/usePrewarmMostRecentAccount.ts @@ -0,0 +1,69 @@ +import { useEffect } from 'react'; + +import { prewarmWebviewAccount } from '../services/webviewAccountService'; +import { selectLastActiveAccountId } from '../store/accountsSlice'; +import { useAppSelector } from '../store/hooks'; +import type { Account } from '../types/accounts'; + +/** + * Cap on `accounts.length` for which the MRU prewarm runs. Power users + * with many accounts skip prewarm so the spawn cost stays bounded — the + * prewarmed webview reserves a CEF process + provider profile, and we + * don't want a 20-account user to have all 20 warming on launch. + */ +export const PREWARM_MAX_ACCOUNTS = 5; + +interface UsePrewarmMostRecentAccountArgs { + accounts: Account[]; + accountsById: Record; + activeAccountId: string | null; +} + +/** + * Issue #1233 — fire-and-forget prewarm of the most-recently-active account + * once on mount of the Accounts page. The prewarmed webview is spawned + * off-screen with the full handler / scanner / notification setup, so the + * eventual user click hits the warm-reopen branch in + * `webview_account_open` and emits `state:"reused"` instead of paying the + * cold-load wait. + * + * The MRU id is read from the persisted Redux store + * (`selectLastActiveAccountId`) — same single source of truth the rest of + * Accounts uses, no separate `localStorage` channel. + * + * Skips when: + * - no MRU id in store (first run) + * - the user has more than `PREWARM_MAX_ACCOUNTS` accounts (bound the + * spawn cost on power users) + * - the MRU account is the currently active one (no point prewarming + * what's already on screen) + * - the MRU account is already pending / loading / open (live or + * in-flight) + * + * Runs exactly once per mount on purpose: the Tauri command itself is + * idempotent server-side, but re-firing on every Redux churn would just + * generate noise in the logs. + */ +export function usePrewarmMostRecentAccount({ + accounts, + accountsById, + activeAccountId, +}: UsePrewarmMostRecentAccountArgs): void { + const mruId = useAppSelector(selectLastActiveAccountId); + useEffect(() => { + if (!mruId) return; + if (accounts.length === 0 || accounts.length > PREWARM_MAX_ACCOUNTS) return; + const acct = accountsById[mruId]; + if (!acct) return; + if (acct.id === activeAccountId) return; + if (acct.status === 'open' || acct.status === 'loading' || acct.status === 'pending') { + return; + } + void prewarmWebviewAccount(acct.id, acct.provider); + // Mount-only by design — see docstring. Snapshotting deps captured at + // first render keeps the prewarm a single fire even when the parent + // re-renders for unrelated reasons (resize, status flip on another + // account, etc.). Rule isn't enforced in this repo's ESLint config so + // the prose comment carries the intent. + }, []); +} diff --git a/app/src/pages/Accounts.tsx b/app/src/pages/Accounts.tsx index e5037d72d..f5f614502 100644 --- a/app/src/pages/Accounts.tsx +++ b/app/src/pages/Accounts.tsx @@ -8,13 +8,19 @@ import WebviewHost from '../components/accounts/WebviewHost'; // import { isWelcomeLocked } from '../lib/coreState/store'; // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // import { useCoreState } from '../providers/CoreStateProvider'; +import { usePrewarmMostRecentAccount } from '../hooks/usePrewarmMostRecentAccount'; import { hideWebviewAccount, purgeWebviewAccount, showWebviewAccount, startWebviewAccountService, } from '../services/webviewAccountService'; -import { addAccount, removeAccount, setActiveAccount } from '../store/accountsSlice'; +import { + addAccount, + removeAccount, + setActiveAccount, + setLastActiveAccount, +} from '../store/accountsSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { fetchRespondQueue } from '../store/providerSurfaceSlice'; import type { Account, AccountProvider, ProviderDescriptor } from '../types/accounts'; @@ -53,7 +59,15 @@ const RailButton = ({ @@ -97,6 +119,16 @@ const Accounts = () => { startWebviewAccountService(); }, []); + // Issue #1233 — prewarm the MRU account once on mount so its CEF profile + // and provider page are warm before the user actually clicks the rail. + // Skipped for power users with many accounts to bound the spawn cost. + // The accounts array snapshot is captured by the hook at first render. + const accounts: Account[] = useMemo( + () => order.map(id => accountsById[id]).filter((a): a is Account => Boolean(a)), + [order, accountsById] + ); + usePrewarmMostRecentAccount({ accounts, accountsById, activeAccountId }); + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // Welcome lockdown (#883) — force the Agent pane while the welcome // conversation is in progress so the user cannot jump to a connected @@ -116,11 +148,6 @@ const Accounts = () => { return () => window.clearInterval(id); }, [dispatch]); - const accounts: Account[] = useMemo( - () => order.map(id => accountsById[id]).filter((a): a is Account => Boolean(a)), - [order, accountsById] - ); - const connectedProviders = useMemo( () => new Set(accounts.map(a => a.provider)), [accounts] @@ -164,10 +191,17 @@ const Accounts = () => { }; dispatch(addAccount(acct)); dispatch(setActiveAccount(id)); + // Issue #1233 — record this real-account selection in the persisted + // MRU pointer so the next session can prewarm it. Agent selections + // never reach this code path (separate `selectAgent` callback below). + dispatch(setLastActiveAccount(id)); }; const selectAgent = () => dispatch(setActiveAccount(AGENT_ID)); - const selectAccount = (id: string) => dispatch(setActiveAccount(id)); + const selectAccount = (id: string) => { + dispatch(setActiveAccount(id)); + dispatch(setLastActiveAccount(id)); + }; const openContextMenu = (accountId: string, e: React.MouseEvent) => { e.preventDefault(); @@ -223,12 +257,14 @@ const Accounts = () => { diff --git a/app/src/services/__tests__/webviewAccountService.prewarm.test.ts b/app/src/services/__tests__/webviewAccountService.prewarm.test.ts new file mode 100644 index 000000000..883e2b8c9 --- /dev/null +++ b/app/src/services/__tests__/webviewAccountService.prewarm.test.ts @@ -0,0 +1,61 @@ +import { invoke } from '@tauri-apps/api/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { prewarmWebviewAccount } from '../webviewAccountService'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), + isTauri: vi.fn().mockReturnValue(true), +})); + +vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(async () => () => undefined) })); + +vi.mock('../api/threadApi', () => ({ threadApi: { createNewThread: vi.fn() } })); +vi.mock('../chatService', () => ({ chatSend: vi.fn() })); +vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() })); +vi.mock('../notificationService', () => ({ ingestNotification: vi.fn() })); + +const ACCOUNT_ID = 'acct-prewarm-1'; + +describe('prewarmWebviewAccount (issue #1233)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('invokes the webview_account_prewarm Tauri command with snake_case args', async () => { + vi.mocked(invoke).mockResolvedValueOnce(undefined); + + await prewarmWebviewAccount(ACCOUNT_ID, 'slack'); + + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith('webview_account_prewarm', { + args: { account_id: ACCOUNT_ID, provider: 'slack' }, + }); + }); + + it('swallows backend errors so the caller never has to handle them', async () => { + // Suppress the error log in test output. + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.mocked(invoke).mockRejectedValueOnce(new Error('add_child failed')); + + // Must not throw — prewarm is best-effort. + await expect(prewarmWebviewAccount(ACCOUNT_ID, 'telegram')).resolves.toBeUndefined(); + expect(invoke).toHaveBeenCalledWith('webview_account_prewarm', { + args: { account_id: ACCOUNT_ID, provider: 'telegram' }, + }); + errSpy.mockRestore(); + }); + + it('is a no-op when not running inside Tauri', async () => { + const { isTauri } = await import('@tauri-apps/api/core'); + vi.mocked(isTauri).mockReturnValueOnce(false); + + await prewarmWebviewAccount(ACCOUNT_ID, 'google-meet'); + + expect(invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/services/webviewAccountService.ts b/app/src/services/webviewAccountService.ts index 5f8e2d20a..91d34c3b2 100644 --- a/app/src/services/webviewAccountService.ts +++ b/app/src/services/webviewAccountService.ts @@ -872,6 +872,40 @@ export async function retryWebviewAccountLoad( await openWebviewAccount({ accountId, provider, bounds }); } +/** + * Spawn a hidden webview for an account so its CEF profile and provider + * page are warm by the time the user actually clicks the rail icon. + * + * Rust spawns the prewarm webview off-screen at 1×1, attaches CDP, navigates + * to the real provider URL, and registers it in the same `inner` map as a + * regular open. When the user later clicks the account, `webview_account_open` + * hits the warm-reopen branch and emits `state:"reused"` synchronously — no + * cold spinner. + * + * Idempotent — calling again for an already-warm account is a Rust-side no-op. + * Best-effort — any error is logged and swallowed; the worst case is a normal + * cold open later. + */ +export async function prewarmWebviewAccount( + accountId: string, + provider: AccountProvider +): Promise { + if (!isTauri()) return; + log('[webview-accounts] prewarm dispatch account=%s provider=%s', accountId, provider); + try { + await invoke('webview_account_prewarm', { args: { account_id: accountId, provider } }); + } catch (err) { + // Don't surface to the user — prewarm failure means we fall back to the + // normal cold-open path on click. Logged for diagnosis. + errLog( + '[webview-accounts] prewarm failed account=%s provider=%s: %o', + accountId, + provider, + err + ); + } +} + export async function setWebviewAccountBounds( accountId: string, bounds: WebviewAccountBounds diff --git a/app/src/store/accountsSlice.ts b/app/src/store/accountsSlice.ts index 0c2c32347..90c8218ba 100644 --- a/app/src/store/accountsSlice.ts +++ b/app/src/store/accountsSlice.ts @@ -16,6 +16,7 @@ const initialState: AccountsState = { accounts: {}, order: [], activeAccountId: null, + lastActiveAccountId: null, messages: {}, unread: {}, logs: {}, @@ -47,12 +48,33 @@ const accountsSlice = createSlice({ if (state.activeAccountId === accountId) { state.activeAccountId = state.order[0] ?? null; } + // Issue #1233 — drop the MRU pointer if the deleted account was the + // last-active one, otherwise the next session would try to prewarm a + // gone account, hit the `accountsById[mruId]` undefined branch, and + // silently no-op. Replace it with whatever's still in `order` + // (matches `activeAccountId`'s fallback above) so the prewarm has a + // real candidate. + if (state.lastActiveAccountId === accountId) { + state.lastActiveAccountId = state.order[0] ?? null; + } }, setActiveAccount(state, action: PayloadAction) { state.activeAccountId = action.payload; }, + /** + * Issue #1233 — record the most-recently-activated non-agent account + * id. Persisted via the `lastActiveAccountId` whitelist entry in + * `store/index.ts` so it survives across sessions and drives the + * Accounts-mount prewarm. The agent pseudo-id is filtered out at the + * dispatch site, not here, because this slice has no knowledge of + * the agent constant. + */ + setLastActiveAccount(state, action: PayloadAction) { + state.lastActiveAccountId = action.payload; + }, + setAccountStatus( state, action: PayloadAction<{ accountId: string; status: AccountStatus; lastError?: string }> @@ -115,6 +137,7 @@ export const { addAccount, removeAccount, setActiveAccount, + setLastActiveAccount, setAccountStatus, appendMessages, appendLog, @@ -123,4 +146,8 @@ export const { resetAccountsState, } = accountsSlice.actions; +/** Issue #1233 — selector for the persisted MRU account id. */ +export const selectLastActiveAccountId = (state: { accounts: AccountsState }): string | null => + state.accounts.lastActiveAccountId; + export default accountsSlice.reducer; diff --git a/app/src/store/index.ts b/app/src/store/index.ts index d0f82740f..3592ff9d6 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -41,7 +41,7 @@ const persistedChannelConnectionsReducer = persistReducer( const accountsPersistConfig = { key: 'accounts', storage, - whitelist: ['accounts', 'order', 'activeAccountId'], + whitelist: ['accounts', 'order', 'activeAccountId', 'lastActiveAccountId'], }; const persistedAccountsReducer = persistReducer(accountsPersistConfig, accountsReducer); diff --git a/app/src/types/accounts.ts b/app/src/types/accounts.ts index c0028880d..042f91890 100644 --- a/app/src/types/accounts.ts +++ b/app/src/types/accounts.ts @@ -41,6 +41,14 @@ export interface AccountsState { accounts: Record; order: string[]; activeAccountId: string | null; + /** + * Issue #1233 — most-recently-active non-agent account id, persisted + * across sessions. Drives the on-mount prewarm of `Accounts.tsx` so the + * first user click hits the warm-reopen branch instead of paying a + * cold load. Updated on rail click + new-account pick. `null` until the + * user activates a real (non-agent) account at least once. + */ + lastActiveAccountId: string | null; messages: Record; unread: Record; logs: Record;