feat(webview-accounts): faster + clearer cold opens (#1233 #1284) (#1285)

This commit is contained in:
oxoxDev
2026-05-06 13:09:17 -07:00
committed by GitHub
parent fa03cc31b4
commit 44c8b834fb
15 changed files with 933 additions and 51 deletions
Generated
+1 -1
View File
@@ -4511,7 +4511,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openhuman"
version = "0.53.17"
version = "0.53.18"
dependencies = [
"aes-gcm",
"anyhow",
+2 -2
View File
@@ -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",
+75 -4
View File
@@ -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<R: Runtime>(app: AppHandle<R>, 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<R: Runtime>(app: AppHandle<R>, 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)",
);
}
}
+5
View File
@@ -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,
+233 -16
View File
@@ -631,6 +631,14 @@ pub struct WebviewAccountsState {
/// "Manage your Google Account" from the avatar menu) are passed
/// through unchanged.
gmeet_awaiting_handoff: Mutex<HashSet<String>>,
/// 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<HashSet<String>>,
}
/// 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<String>,
pub bounds: Option<Bounds>,
/// 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<String>,
}
#[derive(Debug, Deserialize)]
@@ -1367,6 +1402,37 @@ pub(crate) fn emit_load_finished<R: Runtime>(
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<R: Runtime>(
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<R: Runtime>(
// 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<R: Runtime>(
// 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<R: Runtime>(
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<R: Runtime>(
app: AppHandle<R>,
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<R: Runtime>(
app: AppHandle<R>,
@@ -2378,6 +2542,13 @@ pub async fn webview_account_close<R: Runtime>(
.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<R: Runtime>(
.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());
}
}
+94 -16
View File
@@ -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<AccountProvider, string> = {
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 (
<span
data-testid={`webview-loading-hint-${accountId}`}
className="text-[11px] font-medium text-stone-400">
{text}
</span>
);
};
/**
* 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<AccountProvider, string> = {
* 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<HTMLDivElement | null>(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 ? (
<div
data-testid={`webview-loading-${accountId}`}
className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-stone-500"
role="status"
aria-live="polite"
aria-label="Loading account">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-stone-300 border-t-stone-600" />
<span className="text-xs font-medium tracking-wide">{`Loading ${providerName}...`}</span>
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}>
<ProviderIcon
provider={provider}
className={`h-12 w-12 ${isLoading ? '' : 'opacity-70'}`}
/>
<span
className={`text-xs font-medium tracking-wide ${isLoading ? '' : 'text-stone-500'}`}>
{isLoading ? `Loading ${providerName}...` : providerName}
</span>
{isLoading ? (
<div
data-testid={`webview-loading-${accountId}`}
className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-stone-300 border-t-stone-600" />
{/* 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. */}
<LoadingPhaseHint key={accountId} accountId={accountId} />
</div>
) : null}
</div>
) : null}
@@ -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(
<Provider store={store}>
<WebviewHost accountId={ACCOUNT_ID} provider="slack" />
</Provider>
);
}
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();
});
});
@@ -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<Account> & { 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 }) => (
<Provider store={store}>{children}</Provider>
);
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<string, Account | undefined> = 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<AccountStatus>(['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');
});
});
@@ -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<string, Account | undefined>;
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.
}, []);
}
+47 -11
View File
@@ -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 = ({
<button
onClick={onClick}
onContextMenu={onContextMenu}
className={`group relative flex h-11 w-11 items-center justify-center rounded-xl transition-all ${
// Issue #1284 — `hover:z-50` lifts the entire button (and its tooltip
// child) above sibling rail buttons during hover. Without it, the
// `hover:scale-105` transform on a non-active button establishes its
// own stacking context that traps the tooltip's `z-50` inside it,
// and a later sibling button (next in DOM order) paints over the
// tooltip rectangle. Belt-and-suspenders for the active-button case
// too, where ring-2 + bg-primary-50 don't transform but the lifted
// z still helps tooltips render cleanly above neighbours.
className={`group relative flex h-11 w-11 items-center justify-center rounded-xl transition-all hover:z-50 ${
active ? 'bg-primary-50 ring-2 ring-primary-500' : 'hover:bg-stone-100 hover:scale-105'
}`}
aria-label={tooltip}>
@@ -63,7 +77,15 @@ const RailButton = ({
{badge > 99 ? '99+' : badge}
</span>
) : null}
<span className="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-stone-900 px-2 py-1 text-xs text-white opacity-0 shadow-md transition-opacity group-hover:opacity-100 z-50">
{/* Issue #1284 — tooltip sits BELOW the icon (`top-full`) so it stays
inside the HTML-only rail region. The native CEF webview is
composited above the HTML layer to the right of the rail, so a
right-anchored tooltip is hidden behind the webview the moment a
provider is open and DOM z-index can't lift it. Below-icon keeps
the tooltip near the cursor and never blocks the icon being
hovered (it briefly overlays the next icon down, which clears as
soon as the user moves the cursor). */}
<span className="pointer-events-none absolute left-1/2 top-full mt-1 -translate-x-1/2 whitespace-nowrap rounded-md bg-stone-900 px-2 py-1 text-xs text-white opacity-0 shadow-md transition-opacity group-hover:opacity-100 z-50">
{tooltip}
</span>
</button>
@@ -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<AccountProvider>(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 = () => {
<button
onClick={() => setAddOpen(true)}
className="group relative mt-2 flex h-11 w-11 items-center justify-center rounded-xl border border-dashed border-stone-300 text-stone-400 hover:bg-stone-50 hover:text-stone-600"
className="group relative mt-2 flex h-11 w-11 items-center justify-center rounded-xl border border-dashed border-stone-300 text-stone-400 hover:z-50 hover:bg-stone-50 hover:text-stone-600"
aria-label="Add app">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-stone-900 px-2 py-1 text-xs text-white opacity-0 shadow-md transition-opacity group-hover:opacity-100 z-50">
{/* Issue #1284 — see RailButton for why the tooltip sits below
the icon instead of to the right. */}
<span className="pointer-events-none absolute left-1/2 top-full mt-1 -translate-x-1/2 whitespace-nowrap rounded-md bg-stone-900 px-2 py-1 text-xs text-white opacity-0 shadow-md transition-opacity group-hover:opacity-100 z-50">
Add app
</span>
</button>
@@ -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();
});
});
+34
View File
@@ -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<void> {
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
+27
View File
@@ -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<string | null>) {
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<string | null>) {
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;
+1 -1
View File
@@ -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);
+8
View File
@@ -41,6 +41,14 @@ export interface AccountsState {
accounts: Record<string, Account>;
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<string, IngestedMessage[]>;
unread: Record<string, number>;
logs: Record<string, AccountLogEntry[]>;