Skip to content

feat(accounts): loading overlay for first-time webview opens (#867)#887

Merged
senamakel merged 9 commits into
tinyhumansai:mainfrom
oxoxDev:feat/867-webview-loading-overlay
Apr 24, 2026
Merged

feat(accounts): loading overlay for first-time webview opens (#867)#887
senamakel merged 9 commits into
tinyhumansai:mainfrom
oxoxDev:feat/867-webview-loading-overlay

Conversation

@oxoxDev
Copy link
Copy Markdown
Contributor

@oxoxDev oxoxDev commented Apr 24, 2026

Summary

  • Show a spinner overlay inside the account slot during the first-time webview load so the user isn't staring at a blank CEF frame for 1-3s.
  • Spawn CEF child webviews at 1×1 so the React overlay isn't covered, grow them back to requested bounds once the page is painted.
  • Triple-signal reveal: native WebviewBuilder::on_page_load, CDP Page.loadEventFired, and a 15s watchdog — dedup server-side via loaded_accounts, first signal wins.
  • New AccountStatus = 'loading' variant and webview-account:load Tauri event ({account_id, state: 'finished' | 'timeout' | 'reused', url}).

Problem

Clicking into an account (Telegram, WhatsApp, LinkedIn, Slack, …) for the first time shows an empty webview for several seconds while CEF loads the provider page. There is no indication that anything is happening, and earlier in-session attempts at a React overlay, Webview::hide(), or off-screen spawn all hit one of: the CEF NSView paints over the WKWebView layer (z-index can't reach across native layers), set_visible races NSView allocation, or the native page-loaded signal doesn't propagate for CDP-driven navigations.

Solution

Rust

  • webview_account_open spawns the CEF subview at 1×1 at the requested position (behind the React spinner) and stores the requested rect in WebviewAccountsState.requested_bounds.
  • A new helper emit_load_finished grows the webview back to full bounds + calls show(), then emits webview-account:load. Dedup via WebviewAccountsState.loaded_accounts means the three signal sources converge to exactly one event per cold open.
  • cdp::session now enables the Page domain before navigating and fires emit_load_finished from its pump_events callback when Page.loadEventFired arrives. A detached 15s watchdog guarantees the overlay is released even if CDP is quiet.
  • WebviewBuilder::on_page_load is wired as the third signal; a data: URL filter keeps the placeholder from triggering it.
  • Warm re-opens emit a synthetic state=reused event so the frontend flips straight to open without the loading cycle.
  • New webview_account_reveal Tauri command is kept as a belt-and-braces idempotent call — Rust already did the reveal, this just reapplies the latest measured rect if it diverged during load.

Frontend

  • AccountStatus gets a new 'loading' state sitting between 'pending' and 'open'.
  • webviewAccountService caches last bounds per account (updated on every resize) so the webview-account:load handler can issue the idempotent reveal without a second round-trip. Bounds invokes are skipped while loading so nothing drags the webview back on-screen.
  • WebviewHost renders a centered spinner overlay when the account status is 'pending' | 'loading', carrying a webview-loading-{id} testid.

Why 1×1 instead of off-screen

An earlier attempt parked the webview off-screen (e.g. y + parent_height + 2000). CEF loaded the page fine, but after set_position moved it back the NSView contents sometimes stayed blank until a reload forced a repaint. Keeping position stable and only toggling size sidesteps the repaint edge case.

Submission Checklist

  • Unit tests — Vitest app/src/services/__tests__/webviewAccountService.loadListener.test.ts covers the listener flow (pending → loading → open, bounds cache, finished/timeout/reused branches, unmounted-account no-op).
  • E2E / integration — Deferred; requires a provider-stub harness. Tracked as follow-up. Manually verified on Telegram, WhatsApp, and Slack: spinner visible for the full load window, no blank frame, webview snaps into place when the page paints.
  • Doc comments/// on emit_load_finished, webview_account_reveal, new state fields; JSDoc-ish comments on the frontend module-local lastBoundsByAccount / loadingAccounts maps and the AccountStatus lifecycle.
  • Inline comments — Notes on the 1×1 vs off-screen tradeoff, dedup rationale, and the data: URL filter in on_page_load.

Impact

  • Platform: CEF (shipped runtime) only. wry fallback keeps existing behavior — no loading overlay, no regressions.
  • Performance: one extra Page.enable call per cold open, a detached 15s tokio::spawn that exits immediately once the first signal fires (emit_load_finished dedup), and one set_size + show per cold open. Negligible.
  • Compatibility: 'loading' is an additive AccountStatus variant; existing reducers treat unknown statuses safely (verified in Vitest). webview-account:load is a new event; no existing listeners to conflict with.
  • Security: no new JS injection (respects CLAUDE.md's "no new JS for CEF child webviews" rule). The data: placeholder check on on_page_load guards against leaking frontend events for the internal CDP bootstrap URL.

Related

Summary by CodeRabbit

  • New Features

    • Loading overlay and new "loading" status while webviews initialize; frontend reveal can resize/position using latest requested bounds.
    • Frontend event emitted once per open to signal load outcome (finished/timeout/reused).
  • Bug Fixes

    • 15s watchdog timeout and deduplication ensure a single load outcome per account.
    • More reliable restore of size/position and cleanup on close/purge.
  • Tests

    • Deterministic tests for load events, reveal, and bounds behavior.

oxoxDev and others added 5 commits April 24, 2026 19:31
Drives the frontend loading overlay from the Rust side. `spawn_session`
now spins a detached watchdog that emits `webview-account:load` with
`state=timeout` after 15s so the UI never hangs on a flaky network.
The per-account CDP session enables the Page domain before navigating
and re-uses its pump_events loop as a belt-and-braces fallback to the
native on_page_load handler — the server-side dedup in
`emit_load_finished` keeps both signals idempotent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mansai#867)

Fixes the first-open blank-frame flash: the CEF subview now spawns at
1×1 at the caller's requested position so the React loading spinner in
`WebviewHost` is not covered by an empty native view. Once the native
`on_page_load`, CDP `Page.loadEventFired`, or 15s watchdog fires,
`emit_load_finished` grows the webview back to its requested bounds
server-side. Dedup via `loaded_accounts` keeps the three signals
idempotent and `requested_bounds` stores the rect for replay across
reconnects. Warm re-opens emit a synthetic `state=reused` event so the
frontend can flip straight to `open` without the loading cycle.

Adds `webview_account_reveal` as a belt-and-braces frontend-driven
reveal path — redundant once the server-side reveal runs but useful if
the measured bounds diverge from the ones cached at open time.

Closes tinyhumansai#867.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…umansai#867)

Adds `'loading'` to `AccountStatus` between `'pending'` and `'open'` and
wires the frontend to the Rust-side `webview-account:load` event. The
service caches per-account bounds on every resize so the load handler
can invoke `webview_account_reveal` with the most recent rect; bounds
invokes are skipped while an account is still loading so the off-screen
CEF subview doesn't get dragged back on-screen over the React spinner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…yhumansai#867)

Renders a centered spinner inside the placeholder div while the account
status is `pending` or `loading`. The native CEF subview is parked at
1×1 by Rust during this window so the overlay is unobstructed — no
z-index fight with the WKWebView layer. Carries a `webview-loading-{id}`
testid so E2E regressions can assert the lifecycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vitest coverage for the `webview-account:load` listener — mocks Tauri
`listen`/`invoke`, drives synthetic events, and asserts:

- `openWebviewAccount` transitions pending → loading
- `finished` and `timeout` both trigger `webview_account_reveal` with
  the cached bounds and flip status to `open`
- Resize during loading updates the cache but skips the bounds invoke
- `reused` (warm re-open) path is treated as a successful reveal
- Events for unmounted accounts are a no-op

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@oxoxDev oxoxDev requested a review from a team April 24, 2026 14:05
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 2026

📝 Walkthrough

Walkthrough

Spawned CDP sessions now return a SpawnedSession containing both the CDP loop and a watchdog. The CDP loop subscribes to Page.loadEventFired (emitting a single webview-account:load on first finish) and the watchdog emits a timeout-based webview-account:load. Frontend/Redux gains a loading status and a listener that completes the reveal using cached bounds.

Changes

Cohort / File(s) Summary
CDP session & API surface
app/src-tauri/src/cdp/session.rs, app/src-tauri/src/cdp/mod.rs
spawn_session now returns SpawnedSession { session, watchdog }; CDP loop threads AppHandle, enables Page, listens for Page.loadEventFired and emits webview-account:load; re-exported SpawnedSession.
Webview coordination (Rust)
app/src-tauri/src/webview_accounts/mod.rs
Adds per-account load bookkeeping (loaded_accounts, load_watchdogs, requested_bounds), emit_load_finished (dedupe, restore bounds, show, emit event), CEF cold-open offscreen sizing, abort/refresh session+watchdog on reopen/close, and new webview_account_reveal command.
Tauri glue / permissions
app/src-tauri/src/lib.rs, app/src-tauri/permissions/allow-core-process.toml
Registers webview_account_reveal command and allows it + webview_account_purge in permission set.
Frontend: UI host
app/src/components/accounts/WebviewHost.tsx
Reads account status from Redux, introduces isLoading for `'pending'
Frontend: service & types
app/src/services/webviewAccountService.ts, app/src/types/accounts.ts
Adds webview-account:load listener, caches per-account bounds during off-screen load, introduces 'loading' AccountStatus, transitions to 'loading' after open, and reveals on first load event; cleanup clears caches.
Tests
app/src/services/__tests__/webviewAccountService.loadListener.test.ts
Adds tests for listener behaviors, synthetic event wiring, and bounds/reveal interactions.

Sequence Diagram(s)

sequenceDiagram
  participant FE as Frontend (React)
  participant Svc as webviewAccountService
  participant App as Tauri App
  participant CDP as CDP Session
  participant WD as Watchdog

  FE->>Svc: openWebviewAccount(accountId, bounds)
  Svc->>App: invoke webview_account_open(...)
  App->>CDP: spawn_session(accountId, url) -> returns SpawnedSession(session, watchdog)
  Note right of CDP: session runs run_session_cycle...\nPage.enable + navigate
  CDP-->>Svc: (none yet) -- first paint waits for load event
  par Page finishes
    CDP->>App: emit webview-account:load {state:"finished", bounds?}
  and Timeout expires
    WD->>App: emit webview-account:load {state:"timeout"}
  end
  App->>Svc: frontend receives webview-account:load
  Svc->>FE: dispatch open + invoke webview_account_reveal(bounds)
  FE->>App: (optional) setWebviewAccountBounds + show
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

"I hopped and watched the page take flight,
A tiny timeout woofed, another fired 'finished' bright.
Bounds remembered, overlay lifted with cheer,
One signal only — the reveal is here! 🐇✨"

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main feature: adding a loading overlay for first-time webview opens, matching the core objective of issue #867.
Linked Issues check ✅ Passed The PR comprehensively addresses all five primary objectives from issue #867: provides a reliable page-loaded signal via CDP Page.loadEventFired with a native on_page_load handler and 15s watchdog fallback; implements off-screen spawn + coordinated reveal strategy; deduplicates signals via loaded_accounts; and includes test coverage for the listener flow.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the objectives: Rust-side spawn/reveal coordination, CDP session watchdog, frontend status tracking, listener implementation, and permission updates. No unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
app/src/services/webviewAccountService.ts (1)

146-160: Consider clearing module-level Maps on service stop.

lastBoundsByAccount and loadingAccounts are not cleared in stopWebviewAccountService(). If the service is stopped and restarted (e.g., hot reload in development), stale entries could cause unexpected behavior.

♻️ Suggested addition to stopWebviewAccountService
 export function stopWebviewAccountService(): void {
   if (unlisten) {
     unlisten();
     unlisten = null;
   }
   if (unlistenNotifyClick) {
     unlistenNotifyClick();
     unlistenNotifyClick = null;
   }
   if (unlistenLoad) {
     unlistenLoad();
     unlistenLoad = null;
   }
+  lastBoundsByAccount.clear();
+  loadingAccounts.clear();
   started = false;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/services/webviewAccountService.ts` around lines 146 - 160,
stopWebviewAccountService currently tears down listeners but leaves module-level
Maps lastBoundsByAccount and loadingAccounts populated, which can leak stale
state across restarts; update stopWebviewAccountService to clear both Maps (call
their clear() methods or reassign new Map instances) after removing listeners so
lastBoundsByAccount and loadingAccounts are emptied when the service stops,
ensuring a clean state on subsequent starts.
app/src-tauri/src/cdp/session.rs (1)

86-94: Watchdog task runs even after account close — consider cancellation.

The detached watchdog continues running even if the account is closed/purged before the 15s timeout. While emit_load_finished deduplicates, the task still holds cloned AppHandle and account_id references unnecessarily.

This is a minor resource concern since the task is short-lived (15s max) and the emit is no-op after close, but for cleaner resource management you could store the JoinHandle and abort it in webview_account_close/webview_account_purge alongside the CDP session handle.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src-tauri/src/cdp/session.rs` around lines 86 - 94, The watchdog spawned
with tokio::spawn (sleep(LOAD_TIMEOUT) -> emit_load_finished) keeps cloned
AppHandle and account_id alive even after account closure; change it to capture
and store the JoinHandle when creating the watchdog (instead of detaching),
associate it with the CDP session state, and on webview_account_close and
webview_account_purge call join_handle.abort() (or await join_handle with
timeout) to cancel the task; update the code paths that create the watchdog to
return/store the JoinHandle and the cleanup paths in webview_account_close /
webview_account_purge to abort the stored handle so the task does not hold
resources after account removal.
app/src/services/__tests__/webviewAccountService.loadListener.test.ts (1)

56-62: Consider using vi.waitFor or multiple ticks for async draining.

The await Promise.resolve() at line 61 drains a single microtask. If handleWebviewAccountLoad triggers multiple chained promises (e.g., the invoke call internally), some assertions might flake.

Consider using await vi.waitFor(() => expect(...)) for assertions that depend on async side effects, or add a small explicit wait:

await new Promise(r => setTimeout(r, 0));

This is a minor robustness concern given the current implementation works.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/services/__tests__/webviewAccountService.loadListener.test.ts` around
lines 56 - 62, The test helper fireLoadEvent currently uses a single microtask
drain (await Promise.resolve()) which can flake if handleWebviewAccountLoad
triggers chained async work (e.g., internal invoke calls); replace the
microtask-only drain with a more robust wait: either use vi.waitFor in tests
when asserting side effects that depend on async work, or change the final await
in fireLoadEvent to await a next macrotask tick (e.g., await new Promise(r =>
setTimeout(r, 0))) so that chained promises and any invoke() completions have
time to settle before assertions run.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/src-tauri/src/cdp/session.rs`:
- Around line 86-94: The watchdog spawned with tokio::spawn (sleep(LOAD_TIMEOUT)
-> emit_load_finished) keeps cloned AppHandle and account_id alive even after
account closure; change it to capture and store the JoinHandle when creating the
watchdog (instead of detaching), associate it with the CDP session state, and on
webview_account_close and webview_account_purge call join_handle.abort() (or
await join_handle with timeout) to cancel the task; update the code paths that
create the watchdog to return/store the JoinHandle and the cleanup paths in
webview_account_close / webview_account_purge to abort the stored handle so the
task does not hold resources after account removal.

In `@app/src/services/__tests__/webviewAccountService.loadListener.test.ts`:
- Around line 56-62: The test helper fireLoadEvent currently uses a single
microtask drain (await Promise.resolve()) which can flake if
handleWebviewAccountLoad triggers chained async work (e.g., internal invoke
calls); replace the microtask-only drain with a more robust wait: either use
vi.waitFor in tests when asserting side effects that depend on async work, or
change the final await in fireLoadEvent to await a next macrotask tick (e.g.,
await new Promise(r => setTimeout(r, 0))) so that chained promises and any
invoke() completions have time to settle before assertions run.

In `@app/src/services/webviewAccountService.ts`:
- Around line 146-160: stopWebviewAccountService currently tears down listeners
but leaves module-level Maps lastBoundsByAccount and loadingAccounts populated,
which can leak stale state across restarts; update stopWebviewAccountService to
clear both Maps (call their clear() methods or reassign new Map instances) after
removing listeners so lastBoundsByAccount and loadingAccounts are emptied when
the service stops, ensuring a clean state on subsequent starts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b4119932-84db-4a64-82d8-d473e5f73f41

📥 Commits

Reviewing files that changed from the base of the PR and between 208c276 and bb375df.

📒 Files selected for processing (7)
  • app/src-tauri/src/cdp/session.rs
  • app/src-tauri/src/lib.rs
  • app/src-tauri/src/webview_accounts/mod.rs
  • app/src/components/accounts/WebviewHost.tsx
  • app/src/services/__tests__/webviewAccountService.loadListener.test.ts
  • app/src/services/webviewAccountService.ts
  • app/src/types/accounts.ts

Copy link
Copy Markdown
Contributor

@graycyrus graycyrus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this is a well-structured implementation. The triple-signal dedup (native on_page_load + CDP Page.loadEventFired + 15 s watchdog), the 1x1 spawn rationale, and the server-side set_size in emit_load_finished (avoiding the blank-frame flash a round-trip IPC approach would have) are all solid design choices. Unit test coverage is meaningfully better than average for this area — the resize-during-load race and warm-reopen short-circuit are both exercised.

Two major items and three minor ones noted inline, all project-pattern specific:

  • major: Detached watchdog handle leaks across close/purge, can cause a subsequent reopen to permanently swallow its load signal (cdp/session.rs)
  • major: stopWebviewAccountService does not clear module-level maps; stale loadingAccounts entries survive a renderer restart and silently drop bounds updates (webviewAccountService.ts)
  • minor: isLoading is true on undefined status, showing an eternal spinner for accounts that have never been opened (WebviewHost.tsx)
  • minor: Full provider URLs (including OAuth codes / auth fragments) logged at info level (webview_accounts/mod.rs)
  • minor: Double vi.clearAllMocks() in beforeEach makes the mock config fragile (loadListener.test.ts)

@@ -62,11 +69,33 @@
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] The watchdog task is spawned detached — its JoinHandle is immediately dropped. If the account is closed and purged within the 15-second window, the watchdog still fires emit_load_finished after close/purge have already cleaned up loaded_accounts and requested_bounds. The dedup guard in emit_load_finished inserts a fresh entry into loaded_accounts at that point, so a subsequent real reopen of the same account ID would silently swallow its first genuine load signal and the spinner would never dismiss.

Store the watchdog handle so it can be aborted in close/purge alongside the session handle. Simplest path: change spawn_session to return a tuple and add a watchdog_tasks map to WebviewAccountsState:

pub fn spawn_session<R: Runtime>(
    app: AppHandle<R>,
    account_id: String,
    real_url: String,
) -> (JoinHandle<()>, JoinHandle<()>) { // (session, watchdog)
    let watchdog = {
        let app = app.clone();
        let id = account_id.clone();
        let url = real_url.clone();
        tokio::spawn(async move {
            sleep(LOAD_TIMEOUT).await;
            emit_load_finished(&app, &id, "timeout", &url);
        })
    };
    let session = tokio::spawn(async move {
        run_session_forever(app, account_id, real_url).await
    });
    (session, watchdog)
}

Abort both handles in webview_account_close / webview_account_purge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 81178f5. spawn_session now returns SpawnedSession { session, watchdog }; webview_account_open stores both in WebviewAccountsState, and load_watchdogs is aborted in close + purge alongside cdp_sessions.

/// brief blank frame that would otherwise sit between the load event and
/// the frontend's reveal call.
pub(crate) fn emit_load_finished<R: Runtime>(
app: &AppHandle<R>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] The full provider URL (including query string and fragment) is logged at info level in emit_load_finished. Some providers embed auth material in URLs — Telegram's #tgWebAppData fragment can carry session tokens, and OAuth callbacks land with ?code=.... Either log at debug level or strip query/fragment before logging:

let safe_url = url.split(|c: char| c == '?' || c == '#').next().unwrap_or(url);
log::info!("[webview-accounts][{}] load event state={} url={}", account_id, state, safe_url);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 81178f5. New redact_url_for_log strips query + fragment via Url::parse (with split(['?', '#']) fallback). Full URL still flows over the Tauri event for in-process consumers.

}
try {
// Rust emits `webview-account:load` from three independent signals
// (native `on_page_load`, CDP `Page.loadEventFired`, 15 s watchdog).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] loadingAccounts and lastBoundsByAccount are module-level singletons that outlive the service lifecycle. stopWebviewAccountService removes the Tauri listener but does not clear these maps. If the service restarts (renderer HMR reload, or in tests as papered over by calling closeWebviewAccount in beforeEach), stale loadingAccounts entries cause setWebviewAccountBounds to silently skip the Rust invoke for accounts that were loading when the service was stopped. In production this means a renderer reload while an account is loading leaves the webview stuck at 1x1 forever.

Add these two lines to stopWebviewAccountService:

lastBoundsByAccount.clear();
loadingAccounts.clear();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in c3b56f8. stopWebviewAccountService clears lastBoundsByAccount + loadingAccounts after listener teardown.

provider: AccountProvider;
}

const LOADING_STATUSES: ReadonlySet<AccountStatus> = new Set(['pending', 'loading']);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] isLoading is true when status === undefined, which means any account that has never been opened renders the spinner indefinitely. If WebviewHost is ever rendered before openWebviewAccount is called — pre-mounted route, Storybook story, test — it will spin forever with no dismissal path.

Restrict to accounts actively in flight:

const isLoading = status === "pending" || status === "loading";

When status is undefined the webview has not been opened yet — an empty placeholder is more correct than a spinning loader.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in c3b56f8. isLoading is now status !== undefined && LOADING_STATUSES.has(status) — undefined-status hosts no longer render the spinner.

startWebviewAccountService,
stopWebviewAccountService,
} from '../webviewAccountService';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] beforeEach calls vi.clearAllMocks() twice: once before closeWebviewAccount and once after. The first clear wipes the invoke mock's resolved-value configuration, so closeWebviewAccount's internal invoke('webview_account_close', ...) call runs against the bare default (resolves undefined — fine today). If the default ever changes to reject, beforeEach will throw and every test will fail with a confusing error. Consider a single vi.clearAllMocks() at the end of setup, or call vi.mocked(invoke).mockResolvedValue(undefined) explicitly after the first clear.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 676d5ec. Dropped redundant vi.clearAllMocks(); fireLoadEvent now drains a macrotask (setTimeout(0)) so chained .then/.catch on the internal invoke() settle before assertions. 7/7 still pass.

@Al629176
Copy link
Copy Markdown
Contributor

Reviews are damn nice @graycyrus

oxoxDev and others added 4 commits April 24, 2026 21:35
…tinyhumansai#867)

Two review fixes for tinyhumansai#887:

- The 15s load-event watchdog was spawned detached, so a close or purge
  within the watchdog window left the task running until it fired
  `webview-account:load{state:"timeout"}` for a now-vanished account.
  Worst case: the same account_id was reused (e.g. across a restart
  with persisted ids) before the timer expired, and the stale watchdog
  flipped the freshly-spawned account to `'open'` before its page had
  loaded. Fix: `spawn_session` now returns both the session and the
  watchdog `JoinHandle` (`SpawnedSession`); the watchdog handle is
  stored in `WebviewAccountsState.load_watchdogs` and aborted in
  `webview_account_close` / `webview_account_purge` alongside the CDP
  session task.

- `emit_load_finished` was logging the full URL at info, which for
  some providers (Telegram WebApp `#tgWebAppData=…`, OAuth callbacks)
  contains auth material that ends up in the long-lived shell log.
  Strip query and fragment via the new `redact_url_for_log` helper
  before logging; the full URL still flows over the Tauri event so
  any in-process consumer that needs it has access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nyhumansai#867)

Two review fixes for tinyhumansai#887:

- `stopWebviewAccountService` removed the Tauri listeners but left the
  module-level `lastBoundsByAccount` and `loadingAccounts` Maps
  populated. A subsequent start (HMR or shutdown→restart) would see
  stale entries and silently drop bounds updates because
  `loadingAccounts.has(...)` was true for accounts whose webviews had
  long since been destroyed. Clear both Maps as part of stop.

- `WebviewHost.isLoading` was true on `status === undefined`, so a
  host rendered for an account not in the store (e.g. a render race
  with `addAccount`) would spin forever. Tighten the guard to
  `status !== undefined && (pending|loading)` — the brief microtask
  between mount and the `setAccountStatus('pending')` dispatch is
  visually indistinguishable from no overlay, so this is safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nsai#867)

Two review fixes for tinyhumansai#887:

- `beforeEach` was calling `vi.clearAllMocks()` twice — once before
  `closeWebviewAccount` and once after. The first clear wiped the
  default `mockResolvedValue(undefined)` config so any test that
  relied on `closeWebviewAccount` actually round-tripping through
  the mocked `invoke` would have been brittle. Drop the redundant
  clear; rely on `stopWebviewAccountService` (now clearing the
  module-level Maps) for state teardown.

- `fireLoadEvent` drained a single microtask via
  `await Promise.resolve()`, which was sufficient for the current
  handler but would miss chained `.then()` / `.catch()` on the
  internal `invoke()` promise. Switch to a macrotask drain
  (`setTimeout(0)`) so future handler changes don't silently flake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sibility, rustdoc order

- allow-core-process.toml: add webview_account_reveal and webview_account_purge
  to the allowed-commands list (ACL gap reported by reviewer)
- webviewAccountService.ts: move setAccountStatus('open') into .finally() so
  it fires after webview_account_reveal settles rather than immediately
- webview_accounts/mod.rs: drop unnecessary pub(crate) on load_watchdogs field
- cdp/mod.rs: resolve merge conflict — keep placeholder_url + SpawnedSession exports
- cdp/session.rs: move rustdoc comment to sit directly above spawn_session fn,
  above the SpawnedSession struct definition it documents
if method == "Page.loadEventFired" {
emit_load_finished(&cb_app, &cb_account_id, "finished", &cb_real_url);
}
})
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open question: should Page.loadEventFired filter on real_url prefix before calling emit_load_finished?

The current callback fires emit_load_finished on any Page.loadEventFired event regardless of which URL triggered it. During a typical provider login flow the webview may traverse several OAuth/SSO redirects (e.g. accounts.google.com → oauth2.example.com → provider.com) before reaching the real provider URL. Each redirect fires Page.loadEventFired, so the "finished" signal can arrive while the webview is still on an interim OAuth page — triggering a premature reveal of an incomplete (still loading) webview.

A guard like:

if method == "Page.loadEventFired" {
    // Only signal finished when the webview has reached the actual provider domain,
    // not on OAuth / SSO interim redirects.
    if cb_target_url.starts_with(&cb_real_url) {
        emit_load_finished(&cb_app, &cb_account_id, "finished", &cb_real_url);
    }
}

would require tracking the current target URL (e.g. via Page.frameNavigated events or a shared Arc<Mutex<String>> updated by the navigation handler) and comparing against real_url before emitting.

Trade-off: the watchdog fires after 15 s anyway, so a premature reveal is recoverable. But for providers with long OAuth flows (LinkedIn, Google Meet) this could be a visible UX regression — the spinner disappears while the page is still mid-redirect.

Is intentional that the reveal can fire on OAuth interims, or should we add the URL prefix check?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this is intentional, and the origin filter as proposed would actually break the cold-login UX. Flow:

  1. User opens an account for the first time (not logged in)
  2. Page.navigate(mail.google.com) → 302 → accounts.google.com/login
  3. loadEventFired fires for accounts.google.com — with the starts_with(real_url) guard we'd reject it
  4. Webview stays at 1×1 for the entire OAuth flow, so the user can't see or interact with the login form
  5. Stuck until the 15 s watchdog fires, at which point mail.google.com hasn't been reached yet either

So the current "reveal on first loadEventFired" behaviour is load-bearing for first-login: the user needs to see whatever CEF painted first (login form, consent screen, interim redirect) to interact with it. A reveal that lands on an OAuth page is a correct user state, not a premature one.

The silent-SSO-hop case you're flagging — an already-logged-in user seeing a brief flash of accounts.google.com before the instant redirect to mail.google.com — is real, but:

  • Typical duration is <200 ms on a warm connection
  • loaded_accounts dedup locks the first fire, so subsequent navigations are free to render instantly without re-triggering reveal
  • The native on_page_load handler usually wins the race on CEF (verified in the spike logs), so the CDP path is belt-and-braces, not primary

If we do want to remove that flash in follow-up, the right shape is probably "filter out same-origin redirects within the first N ms" via Page.frameNavigated + a freshness window, not a hard origin gate — but that's a polish follow-up, not a blocker for this PR.

Happy to open a follow-up issue tracking the silent-SSO flash if you'd like.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
app/src/services/webviewAccountService.ts (3)

170-203: Consider adding success logging after reveal settles.

The handler logs on entry (line 176) and on error (line 195), but there's no log when the reveal succeeds or when the 'open' status is dispatched. Per the coding guidelines, logging at exit points aids debugging. A brief log in .finally() or after the else-branch dispatch would complete the trace.

Suggested addition
       .finally(() => {
         store.dispatch(setAccountStatus({ accountId, status: 'open' }));
+        log('load complete account=%s → open', accountId);
       });
   } else {
     store.dispatch(setAccountStatus({ accountId, status: 'open' }));
+    log('load complete (no cached bounds) account=%s → open', accountId);
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/services/webviewAccountService.ts` around lines 170 - 203, Add an
exit/ success log to handleWebviewAccountLoad so we trace successful reveals and
state dispatches: when invoking invoke('webview_account_reveal', ...) add a
success/info log in the .finally() alongside the existing
store.dispatch(setAccountStatus({ accountId, status: 'open' })) to indicate
reveal settled and status set, and add the same info log immediately after the
else-branch dispatch for the case where bounds is falsy; reference
lastBoundsByAccount, loadingAccounts, invoke, and setAccountStatus to locate the
spots to change.

62-69: The | string fallback weakens the typed union.

Line 67's state: 'finished' | 'timeout' | 'reused' | string collapses to just string at the type level, losing narrowing and autocomplete for known states. If forward-compat is needed, handle unknown states explicitly in the handler instead.

Suggested fix
 interface WebviewAccountLoadPayload {
   account_id: string;
   // `'finished'` — native `on_page_load` or CDP `Page.loadEventFired` fired
   // `'timeout'`  — 15 s watchdog elapsed; reveal anyway so spinner isn't stuck
   // `'reused'`   — warm re-open of already-loaded account; reveal synchronously
-  state: 'finished' | 'timeout' | 'reused' | string;
+  state: 'finished' | 'timeout' | 'reused';
   url: string;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/services/webviewAccountService.ts` around lines 62 - 69, The
WebviewAccountLoadPayload type currently weakens the union by ending with "|
string"; change the declaration so state is strictly 'finished' | 'timeout' |
'reused' (remove the trailing "| string"), and update any consumers/handlers
that read WebviewAccountLoadPayload.state to explicitly handle unexpected values
via a default/fallback branch (e.g., an explicit else/default case or a runtime
typeof/string check) so unknown states are handled safely rather than relying on
the type system to permit arbitrary strings.

1-976: Consider splitting this service into submodules.

The file is now ~976 lines, exceeding the ~500-line guideline. The Google Meet transcript pipeline (lines 454-798), notification bypass helpers (lines 906-975), and the core webview lifecycle logic are largely independent. Extracting them into co-located submodules (e.g., webviewAccountService/meetTranscript.ts) would improve maintainability.

This is a pre-existing concern and doesn't need to block this PR — flagging for future cleanup. As per coding guidelines: "Source files should be ≤ ~500 lines; split components and modules when growing."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/services/webviewAccountService.ts` around lines 1 - 976, This file is
too large; split independent concerns into submodules: extract the Google Meet
transcript pipeline (types MeetCaptionRow, MeetCallStartedPayload,
MeetCaptionsPayload, MeetCallEndedPayload, CaptionSnapshot, MeetingSession,
TranscriptSegment, MAX_MEET_SNAPSHOTS, activeMeetings, and functions
handleMeetCallStarted, handleMeetCaptions, handleMeetCallEnded,
flushMeetingSession, collapseToSegments, renderTranscript,
handoffToOrchestrator, and any helpers they use) into a new module (e.g.,
meetTranscript) that exports the handler functions and flushMeetingIfAny if
needed, then import and call those from webviewAccountService; likewise move the
notification bypass helpers (setAccountMuted, setGlobalDnd, getBypassPrefs,
setFocusedAccount) into a notificationBypass module and import them back; ensure
all references (activeMeetings usage, flushMeetingIfAny,
MEET_ORCHESTRATOR_MODEL,
threadApi/chatSend/callCoreRpc/store.dispatch/log/errLog) are either passed in
or imported, and preserve existing semantics and exported signatures so callers
(openWebviewAccount, closeWebviewAccount, purgeWebviewAccount, etc.) keep
working.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/src/services/webviewAccountService.ts`:
- Around line 170-203: Add an exit/ success log to handleWebviewAccountLoad so
we trace successful reveals and state dispatches: when invoking
invoke('webview_account_reveal', ...) add a success/info log in the .finally()
alongside the existing store.dispatch(setAccountStatus({ accountId, status:
'open' })) to indicate reveal settled and status set, and add the same info log
immediately after the else-branch dispatch for the case where bounds is falsy;
reference lastBoundsByAccount, loadingAccounts, invoke, and setAccountStatus to
locate the spots to change.
- Around line 62-69: The WebviewAccountLoadPayload type currently weakens the
union by ending with "| string"; change the declaration so state is strictly
'finished' | 'timeout' | 'reused' (remove the trailing "| string"), and update
any consumers/handlers that read WebviewAccountLoadPayload.state to explicitly
handle unexpected values via a default/fallback branch (e.g., an explicit
else/default case or a runtime typeof/string check) so unknown states are
handled safely rather than relying on the type system to permit arbitrary
strings.
- Around line 1-976: This file is too large; split independent concerns into
submodules: extract the Google Meet transcript pipeline (types MeetCaptionRow,
MeetCallStartedPayload, MeetCaptionsPayload, MeetCallEndedPayload,
CaptionSnapshot, MeetingSession, TranscriptSegment, MAX_MEET_SNAPSHOTS,
activeMeetings, and functions handleMeetCallStarted, handleMeetCaptions,
handleMeetCallEnded, flushMeetingSession, collapseToSegments, renderTranscript,
handoffToOrchestrator, and any helpers they use) into a new module (e.g.,
meetTranscript) that exports the handler functions and flushMeetingIfAny if
needed, then import and call those from webviewAccountService; likewise move the
notification bypass helpers (setAccountMuted, setGlobalDnd, getBypassPrefs,
setFocusedAccount) into a notificationBypass module and import them back; ensure
all references (activeMeetings usage, flushMeetingIfAny,
MEET_ORCHESTRATOR_MODEL,
threadApi/chatSend/callCoreRpc/store.dispatch/log/errLog) are either passed in
or imported, and preserve existing semantics and exported signatures so callers
(openWebviewAccount, closeWebviewAccount, purgeWebviewAccount, etc.) keep
working.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cb62eb6d-8194-490b-bb87-439e8b989769

📥 Commits

Reviewing files that changed from the base of the PR and between 676d5ec and 854e06a.

📒 Files selected for processing (7)
  • app/src-tauri/permissions/allow-core-process.toml
  • app/src-tauri/src/cdp/mod.rs
  • app/src-tauri/src/cdp/session.rs
  • app/src-tauri/src/lib.rs
  • app/src-tauri/src/webview_accounts/mod.rs
  • app/src/services/webviewAccountService.ts
  • app/src/types/accounts.ts
✅ Files skipped from review due to trivial changes (3)
  • app/src-tauri/permissions/allow-core-process.toml
  • app/src-tauri/src/lib.rs
  • app/src-tauri/src/webview_accounts/mod.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • app/src/types/accounts.ts
  • app/src-tauri/src/cdp/mod.rs
  • app/src-tauri/src/cdp/session.rs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Show loading overlay over account webviews during first-time load

4 participants