mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
Generated
+1
-1
@@ -4511,7 +4511,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openhuman"
|
||||
version = "0.53.17"
|
||||
version = "0.53.18"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
Reference in New Issue
Block a user