Skip to content

Fix desktop app stuck in skeleton loading state - #453

Merged
2witstudios merged 15 commits into
masterfrom
claude/fix-desktop-loading-state-YmI79
Feb 6, 2026
Merged

2witstudios merged 15 commits into
masterfrom
claude/fix-desktop-loading-state-YmI79

Conversation

@2witstudios

@2witstudios 2witstudios commented Feb 6, 2026 •

Copy link
Copy Markdown
Owner

The desktop app was getting stuck showing an infinite skeleton loader when
loading pages. Root cause: auth endpoints used by desktop (mobile login,
device refresh, desktop OAuth exchange) were not setting session cookies.
The Next.js middleware requires a session cookie for page route requests,
so without one, page loads would fail silently and the SWR fetch would
hang without surfacing an error.

Changes

Core fix: Session cookies for desktop auth endpoints

  • Set session cookie in mobile login endpoint when platform is 'desktop'
  • Set session cookie in device refresh endpoint when platform is 'desktop'
  • Set session cookie in desktop OAuth exchange endpoint
  • Set session cookie in Google OAuth exchange when platform is 'desktop'
  • Add credentials: 'include' to desktop login fetch for cookie handling

Cookie-expiry deadlock fix

  • Add device/mobile/desktop auth routes to middleware public routes list
    so device refresh can recover from expired cookies (these endpoints
    authenticate via body tokens, not session cookies)
  • Propagate OAuth exchange cookie to BrowserWindow session via
    session.defaultSession.cookies.set() (Node.js main-process fetch
    doesn't share cookies with the renderer)

Loading UX improvements

  • Add 15s fetch timeout and SWR error retry config (3 retries, 2s delay)
  • Add error state UI with retry button in CenterPanel
  • Add loading timeout (12s) with "Taking longer than expected" hint
  • Fix loadingTimedOut not resetting when Retry is clicked (handleRetry wrapper)
  • Show loading skeleton during SWR retry windows (prevents "Page not found"
    during transient failures)
  • Use shadcn Button component instead of raw <button> elements

Desktop startup improvements

  • Disable backgroundThrottling during startup, re-enable after ready-to-show
  • Restore desktop active tab route on startup (useTabSync)
  • Warm auth-fetch session cache from loadSession to eliminate redundant IPC
  • Logout now fully resets tab state including pinned tabs

Service worker hardening

  • Skip _next/ in service worker to prevent stale bundle mismatches
  • Add 503 fallback for RSC fetch failures (offline/timeout resilience)
  • Robust Electron service worker cleanup

Code quality (CodeRabbit review feedback)

  • Re-read store state after healing/restore in useTabSync
  • Remove unnecessary typeof window check in useEffect
  • Add null guard for sessionClaims on web device refresh path
  • Add isValidating to SWR mock and retry() test coverage
  • Add desktop restore edge case tests (one-shot guard, deep-link skip)
  • Add clarifying comment for retry vs invalidateTree distinction

How to validate

  1. Desktop app loads without stuck skeleton
  2. OAuth login on desktop sets cookie and navigates to dashboard
  3. After >7 days idle, device refresh recovers without signin redirect
  4. Transient network failures show loading skeleton during retries, not "Page not found"
  5. All 506 unit tests pass, CI green

https://claude.ai/code/session_011dazNWCw89QmzeT8QGejAB

Summary by CodeRabbit

  • New Features

    • Faster desktop startup by preventing background throttling during initial render.
    • Desktop auth flows now set session cookies for smoother sign-in and middleware compatibility.
    • Loading timeout UI with "Taking longer than expected…" and Retry.
    • One-time restoration of the previously active tab on desktop startup.
    • Service worker updated for improved runtime asset handling and offline/online strategies.
  • Bug Fixes

    • Prevented serving stale runtime assets from the service worker.

claude and others added 6 commits February 6, 2026 17:28
The desktop app was getting stuck showing an infinite skeleton loader when
loading pages. Root cause: auth endpoints used by desktop (mobile login,
device refresh, desktop OAuth exchange, Google OAuth exchange) were not
setting session cookies. The Next.js middleware requires a session cookie
for page route requests, so without one, page loads would fail silently
and the SWR fetch would hang without surfacing an error.

Changes:
- Set session cookie in mobile login endpoint when platform is 'desktop'
- Set session cookie in device refresh endpoint when platform is 'desktop'
- Set session cookie in desktop OAuth exchange endpoint
- Set session cookie in Google OAuth exchange when platform is 'desktop'
- Add credentials: 'include' to desktop login fetch for cookie handling
- Add 15s fetch timeout and SWR error retry config to usePageTree
- Add error state UI with retry button in CenterPanel
- Add loading timeout (12s) with "Taking longer than expected" hint

https://claude.ai/code/session_011dazNWCw89QmzeT8QGejAB
… IPC

On desktop, loadSession() retrieves the session token via Electron IPC
(reading encrypted file from disk), but this doesn't populate the
auth-fetch module's session cache. When usePageTree fires shortly after
and calls fetchWithAuth, getSessionFromElectron() does another IPC call
to read the same token again. This adds 50-500ms+ of latency to the
first page tree fetch.

Add warmSessionCache() to AuthFetch that pre-populates the 5-second
session cache. Call it from loadSession() after successfully retrieving
the desktop session token (both direct read and post-refresh paths).
This ensures fetchWithAuth gets a cache hit instead of a redundant IPC.

https://claude.ai/code/session_011dazNWCw89QmzeT8QGejAB
The desktop app creates the BrowserWindow with `show: false` and defers
showing until `ready-to-show`. During this hidden phase, Chromium's
default backgroundThrottling throttles MessageChannel and rAF, which
React 18's scheduler relies on for flushing state updates.

When SWR data arrives during this throttled window, React queues the
re-render but the scheduler never fires it. The skeleton persists even
though data is in the cache. Clicking sometimes fixes it because
discrete events force React to synchronously flush pending updates.

Setting backgroundThrottling: false ensures timers and scheduling work
at full speed regardless of window visibility state. This is appropriate
for a primary desktop app window.

https://claude.ai/code/session_011dazNWCw89QmzeT8QGejAB
The service worker had two issues causing problems for the desktop app:

1. RSC flight data requests (same URL, different Accept header) fell through
   to the cache-first fallthrough at line 78, serving stale component trees
   after web deploys. Fixed by detecting RSC/prefetch headers and bypassing
   the cache entirely for those requests.

2. The "everything else" fallthrough used cache-first, which could serve
   stale dynamic content. Changed to network-first.

3. In Electron, the persistent profile means the SW cache persists across
   web deployments indefinitely. Since offline support provides no value
   for a remote-only desktop app, skip SW registration entirely in Electron
   and clean up any existing SW registrations and caches.

Also bumped cache version to v2 to force cleanup of stale v1 caches on
next SW activation for web users.

https://claude.ai/code/session_011dazNWCw89QmzeT8QGejAB
Cherry-picked from codex/fix-desktop-sw-chunk-cache (2ec4405).
Resolved conflict with our SW fix (a386ce2) — kept both _next/ bypass
and RSC header detection as defense-in-depth.

https://claude.ai/code/session_011dazNWCw89QmzeT8QGejAB
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Feb 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@2witstudios has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 0 minutes and 9 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Adds desktop-specific session-cookie propagation and BrowserWindow startup tweaks; revamps service worker caching and registration for Electron vs web; introduces page-tree fetch timeouts, isValidating + retry controls with UI fallbacks; implements one-time desktop tab-restore and auth cache-warming/cleanup.

Changes

Cohort / File(s) Summary
Desktop Main / Window
apps/desktop/src/main/index.ts
Set backgroundThrottling: false on start, re-enable on ready-to-show; after OAuth exchange write sessionToken into BrowserWindow cookie jar via session.defaultSession.cookies.set.
Web API — Desktop Cookie Propagation
apps/web/src/app/api/auth/desktop/exchange/route.ts, apps/web/src/app/api/auth/device/refresh/route.ts, apps/web/src/app/api/auth/mobile/login/route.ts, apps/web/src/app/api/auth/mobile/oauth/google/exchange/route.ts
Introduce createSessionCookie usage and include Set-Cookie for desktop flows; consolidate rate-limit headers and add session validation guard in device refresh.
Service Worker & Registration
apps/web/public/sw.js, apps/web/src/app/layout.tsx
Bump cache names (v1→v2), change caching strategy to network-first for many assets, bypass /_next/ runtime and RSC headers; desktop path unregisters SW and deletes Pagespace caches.
Page Tree Fetch + UI Retry
apps/web/src/hooks/usePageTree.ts, apps/web/src/components/layout/middle-content/CenterPanel.tsx
Add 15s AbortController timeout and timeout-specific errors; expose isValidating and retry(); configure SWR retries; add loading-timeout UI and error overlay with retry.
Tab Sync / Desktop Restore
apps/web/src/hooks/useTabSync.ts, apps/web/src/hooks/useAuth.ts
Add one-time desktop bootstrap that replaces route with active tab path when starting on /dashboard; heal missing activeTab; add credentials: 'include' to desktop login and clear persisted tabs on logout.
Auth Cache Warming
apps/web/src/lib/auth/auth-fetch.ts, apps/web/src/stores/useAuthStore.ts
Add warmSessionCache(token) method and wrapper; dynamically call to pre-warm session cache when tokens are set or found.
Tests
apps/web/src/hooks/__tests__/usePageTree.test.ts, apps/web/src/hooks/__tests__/useTabSync.test.ts
Update mocks to include isValidating, assert AbortSignal usage, and add desktop bootstrap restore tests (router.replace mocks).
Misc / Hooks
apps/web/src/hooks/usePageTree.ts, apps/web/src/hooks/useAuth.ts
Expose isValidating/retry from hook; add fetch timeout with status-aware errors; add logout cleanup dynamic import to clear tabs.

Sequence Diagram(s)

sequenceDiagram
  participant DesktopApp as Desktop App (main)
  participant BrowserWin as BrowserWindow (renderer)
  participant AuthServer as Web API (auth exchange)
  participant CookieStore as Electron Cookie Store

  DesktopApp->>BrowserWin: open OAuth window (backgroundThrottling: false)
  BrowserWin->>AuthServer: navigate to OAuth provider / callback
  AuthServer-->>BrowserWin: redirect with auth code -> exchange
  BrowserWin->>AuthServer: POST /api/auth/desktop/exchange (code)
  AuthServer-->>BrowserWin: 200 + body + Set-Cookie (createSessionCookie)
  BrowserWin->>DesktopApp: signal success (sessionToken)
  DesktopApp->>CookieStore: session.defaultSession.cookies.set(cookie with sessionToken)
  DesktopApp->>BrowserWin: show main window (ready-to-show)
  DesktopApp->>BrowserWin: webContents.setBackgroundThrottling(true)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hopped through windows, set cookies with care,
I nudged off throttling and cleared stale cache lair,
Tabs find their paths when desktop takes flight,
Timeouts say "Retry!" — the UI feels light,
thump — a rabbit's patch, all cozy and fair.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix desktop app stuck in skeleton loading state' accurately summarizes the main issue and solution addressed across multiple auth endpoints and loading state management components.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/fix-desktop-loading-state-YmI79

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

P1: CenterPanel error screen now only shows when no stale data exists.
SWR keeps `data` populated during failed revalidations, so the previous
`isError && !isValidating` check would blank the page even when cached
tree data was available to display.

P2: Logout now clears persisted tab state via closeAllTabs(), preventing
desktop startup from restoring stale or inaccessible routes from a
previous session or different user.

P3: Removed `revalidateOnFocus: false` from usePageTree SWR config.
Focus revalidation keeps breadcrumbs and tree metadata fresh after
app focus/tab switches. The editing guard (isPaused) already prevents
revalidation during active document editing.

Test: Updated usePageTree fetchAndMergeChildren assertion to expect
AbortSignal option from the timeout-enabled fetcher.

https://claude.ai/code/session_011dazNWCw89QmzeT8QGejAB

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/stores/useAuthStore.ts (1)

559-566: ⚠️ Potential issue | 🟡 Minor

Note: email is persisted to localStorage via Zustand.

The partialize function persists user.email to localStorage. Depending on your GDPR/CCPA posture, storing PII in unencrypted browser storage may warrant a review. This isn't introduced by this PR, so flagging for awareness only.

🤖 Fix all issues with AI agents
In `@apps/web/src/components/layout/middle-content/CenterPanel.tsx`:
- Around line 41-48: The timeout overlay's state isn't reset when retrying
because clicking the Retry button calls retry while isLoading stays true; update
the retry flow by adding setLoadingTimedOut(false) at the start of the retry
handler (e.g., inside handleRetry) so loadingTimedOut is cleared when retry
begins, then use handleRetry (not retry) for both Retry button onClick handlers
so the overlay/skeleton is briefly shown as feedback; keep existing retry
invocation after resetting loadingTimedOut.
🧹 Nitpick comments (10)
apps/web/src/app/api/auth/device/refresh/route.ts (1)

195-196: Pre-existing: web path lacks null guard for sessionClaims.

The mobile/desktop path (lines 220-224) correctly returns a 500 if sessionClaims is null, but the web path here silently falls through with sessionClaims?.sessionId ?? '', generating an unvalidatable CSRF token. Not introduced by this PR, but since you're already in this file, it may be worth aligning the two paths.

Suggested alignment
      const sessionClaims = await sessionService.validateSession(sessionToken);
+     if (!sessionClaims) {
+       loggers.auth.error('Failed to validate newly created session during web device refresh');
+       return Response.json({ error: 'Failed to generate session.' }, { status: 500 });
+     }
-     const csrfToken = generateCSRFToken(sessionClaims?.sessionId ?? '');
+     const csrfToken = generateCSRFToken(sessionClaims.sessionId);
apps/web/src/hooks/__tests__/useTabSync.test.ts (2)

112-129: Good coverage of the desktop bootstrap restore path.

The test correctly simulates the Electron environment and verifies that router.replace is called with the persisted active tab path. Two edge cases that would strengthen confidence:

  1. Restore is one-shot: re-render or re-trigger the effect and assert mockRouterReplace was called exactly once (validates didAttemptDesktopRestore guard).
  2. No restore when pathname ≠ /dashboard: e.g., deep-link launch should skip the restore branch entirely.

These are non-blocking but would protect against regressions in the guard logic.


44-47: Minor: mockReset is redundant before clearAllMocks.

vi.clearAllMocks() on line 47 already clears call history and return values for every mock. The explicit mockRouterReplace.mockReset() on line 45 adds no extra effect here unless you specifically need to reset a custom implementation (which doesn't exist for this mock). Harmless, but removing it reduces noise.

apps/web/src/hooks/useTabSync.ts (2)

27-50: Stale state snapshot after healing can confuse future readers.

After healing on line 32, the state captured on line 27 still holds the old activeTabId. The desktop-restore block correctly re-reads via useTabsStore.getState() (line 39), but the later code at lines 63-73 still references the original stale state. It works because navigateInActiveTab internally reads the current store, but the inconsistency is subtle.

Consider re-reading state after the desktop-restore block so the rest of the function always operates on the latest snapshot:

♻️ Suggested simplification
     // Desktop bootstrap: ...
     if (isDesktop && !didAttemptDesktopRestore.current && pathname === '/dashboard' && hasTabs) {
-      const refreshedState = useTabsStore.getState();
-      const activeTab = selectActiveTab(refreshedState);
+      const activeTab = selectActiveTab(useTabsStore.getState());
       const restorePath = activeTab?.path;
 
       didAttemptDesktopRestore.current = true;
@@ ...
     }
 
     // Skip if we already synced this path
     if (lastSyncedPath.current === pathname) return;
 
+    // Re-read after potential healing / restore
+    const currentState = useTabsStore.getState();
+
     // If no tabs exist, create one from current path
-    if (state.tabs.length === 0) {
-      state.createTab({ path: pathname });
+    if (currentState.tabs.length === 0) {
+      currentState.createTab({ path: pathname });
       lastSyncedPath.current = pathname;
       return;
     }
 
     // Get active tab's current path
-    const activeTab = selectActiveTab(state);
+    const activeTab = selectActiveTab(currentState);

37-37: typeof window !== 'undefined' check is unnecessary in a "use client" component's useEffect.

useEffect only runs on the client, so window is always defined. The check is harmless but adds dead code to every effect invocation.

apps/web/public/sw.js (1)

69-78: Bare fetch(request) for RSC requests has no offline/error fallback.

The RSC header bypass is correct — these responses are header-dependent and must never be served from cache. However, if the network fetch fails (offline, timeout, etc.), event.respondWith(fetch(request)) will reject with no fallback, surfacing as an opaque network error to the page.

Consider wrapping in a minimal catch so the browser gets a well-formed error response instead of a rejected promise:

💡 Suggested: graceful fallback for RSC fetch failures
   if (request.headers.get('rsc') ||
       request.headers.get('next-router-prefetch') ||
       request.headers.get('next-router-state-tree') ||
       request.headers.get('next-url')) {
-    event.respondWith(fetch(request));
+    event.respondWith(
+      fetch(request).catch(() =>
+        new Response('', { status: 503, statusText: 'Service Unavailable' })
+      )
+    );
     return;
   }
apps/desktop/src/main/index.ts (1)

194-194: backgroundThrottling: false applies for the window's entire lifetime, not just startup.

This prevents Chromium from throttling timers/rAF when the window is hidden, which fixes the startup race. However, it also means the app will never be throttled when minimized or hidden to tray (Line 253–256), potentially increasing CPU and battery usage in the background.

If startup is the only concern, consider re-enabling throttling after the first render completes (e.g., via mainWindow.webContents.setBackgroundThrottling(true) inside ready-to-show or after a renderer IPC signal). This would give you the best of both worlds.

💡 Example: re-enable throttling after startup
   mainWindow.once('ready-to-show', () => {
     mainWindow?.show();
+    // Re-enable background throttling now that the initial render is complete
+    mainWindow?.webContents.setBackgroundThrottling(true);
   });
apps/web/src/components/layout/middle-content/CenterPanel.tsx (1)

71-77: Consider using shadcn/ui Button instead of raw <button> elements.

The coding guidelines specify using shadcn/ui components for UI. These inline-styled buttons could be replaced with the Button component for consistency with the rest of the app (variants, focus rings, accessibility attributes, etc.).

Example for the error-state button
+import { Button } from '@/components/ui/button';
 ...
-        <button
-          onClick={retry}
-          className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
-        >
-          <RefreshCw className="h-4 w-4" />
-          Try again
-        </button>
+        <Button onClick={retry} size="sm">
+          <RefreshCw className="h-4 w-4" />
+          Try again
+        </Button>

As per coding guidelines: "Use Tailwind CSS with shadcn/ui components for all UI styling and components."

Also applies to: 90-96

apps/web/src/hooks/usePageTree.ts (1)

159-164: retry duplicates invalidateTree minus the editing guard — intentional?

retry and invalidateTree (lines 113-126) share the same cache.delete(swrKey); mutate() core, but invalidateTree adds an isAnyEditing() guard. Since retry is user-initiated (click), bypassing the guard makes sense. A small comment clarifying the distinction (or extracting the shared logic) would help future readers, but not blocking.

apps/web/src/hooks/__tests__/usePageTree.test.ts (1)

52-64: Missing isValidating in SWR mock and no tests for the new retry function.

The SWR mock doesn't return isValidating, so it'll be undefined in all tests. While no current test accesses it, this leaves the two new public APIs (retry and isValidating) — which CenterPanel directly relies on for error/loading UX — without any test coverage.

Consider adding:

  1. isValidating: false to the mock return (line 56-ish).
  2. A describe('retry') block verifying it calls cache.delete + mutate (similar to the invalidateTree tests).

Comment thread apps/web/src/components/layout/middle-content/CenterPanel.tsx Outdated
claude and others added 2 commits February 6, 2026 20:27
closeAllTabs() preserves pinned tabs by design, but logout must clear
all session-specific state. A surviving pinned tab pointing at a
previous user's drive would be auto-restored by useTabSync on the
next desktop startup, navigating to an inaccessible route.

Use setState directly to clear tabs and activeTabId, which the persist
middleware writes through to localStorage.

https://claude.ai/code/session_011dazNWCw89QmzeT8QGejAB
- CenterPanel: add handleRetry wrapper to reset loadingTimedOut on retry,
  use shadcn Button component instead of raw button elements
- SW: add 503 fallback for RSC fetch failures (offline/timeout resilience)
- Electron: re-enable backgroundThrottling after ready-to-show event
- useTabSync: remove stale state snapshot, use fresh getState() after
  healing/restore, remove unnecessary typeof window check, clean up
  redundant mockReset in test, add edge case tests for desktop restore
- usePageTree: add isValidating to SWR mock, add retry() test coverage
  with editing guard bypass verification, add clarifying comment for
  retry vs invalidateTree distinction
- Device refresh route: add null guard for sessionClaims on web path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Addressing CodeRabbit Review Feedback

All feedback addressed in commit 0327600d. Here's the summary:

Actionable Issue (Fixed)

  • CenterPanel loadingTimedOut reset: Added handleRetry wrapper that calls setLoadingTimedOut(false) before retry(). Both retry buttons now use handleRetry.

Nitpicks Addressed

  1. Device refresh route null guard (route.ts:193-196): Added explicit null check for sessionClaims on the web path with a 500 error response, aligning it with the mobile/desktop path.

  2. useTabSync test edge cases (useTabSync.test.ts): Added two new tests — "restore is one-shot" (re-render doesn't trigger second restore) and "no restore when pathname ≠ /dashboard" (deep-link launch skips restore).

  3. Redundant mockReset (useTabSync.test.ts:45): Removed — vi.clearAllMocks() already handles this.

  4. Stale state snapshot in useTabSync (useTabSync.ts): Now re-reads useTabsStore.getState() after healing/restore block (line 55), so the rest of the effect always operates on the latest snapshot.

  5. Unnecessary typeof window check (useTabSync.ts:37): Removed — useEffect only runs on client so window is always defined. Simplified to !!window.electron?.isDesktop.

  6. RSC fetch fallback in SW (sw.js:76-79): Already added .catch(() => new Response('', { status: 503, statusText: 'Service Unavailable' })) for offline/timeout resilience.

  7. backgroundThrottling re-enable (desktop/index.ts:243-247): Already re-enabled via setBackgroundThrottling(true) inside ready-to-show callback, so the window is only unthrottled during startup.

  8. shadcn Button (CenterPanel.tsx): Replaced both raw <button> elements with <Button> component for consistency.

  9. retry vs invalidateTree distinction (usePageTree.ts:159-160): Added clarifying comment explaining retry bypasses the editing guard because the user explicitly chose to retry.

  10. usePageTree test coverage (usePageTree.test.ts): Added isValidating: false to mock and two retry() tests — one verifying it bypasses the editing guard, another verifying no-op behavior without driveId.

Out of Scope (Acknowledged)

  • email in localStorage (useAuthStore.ts:559-566): Pre-existing, not introduced by this PR. Noted for future GDPR review.

All 506 tests pass locally (30 test files). CI checks now running on the latest push.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/web/public/sw.js`:
- Line 84: Update the misleading comment above the cache-first route to reflect
the actual behavior of isStaticAsset and staticExtensions: remove the mention of
generic ".js/.css" since staticExtensions no longer includes them, and clarify
that the cache-first path covers fonts, images and content-hashed assets (or
only /_next/ hashed JS/CSS if that's the intended scope); reference the
isStaticAsset function and the staticExtensions constant and ensure the comment
aligns with the network-first branch handled later (the network-first branch
around the other handler).
🧹 Nitpick comments (3)
apps/web/public/sw.js (1)

1-6: Consider updating the file-level docstring to reflect the new bypass rules.

The header still says "cache-first for static assets" which is only part of the picture now. With the /_next/ bypass and RSC header bypass, the caching strategy has become more nuanced. A brief mention of these bypasses would help future readers.

apps/web/src/hooks/usePageTree.ts (1)

49-64: Use return await inside try/finally to keep timeout and error handling effective.

Without await, the finally block (and clearTimeout) runs as soon as response.json() is called, not when the body has been fully read. This means:

  1. The abort-timeout no longer guards body reading — if the body stream stalls, the AbortController has already been defused.
  2. A JSON-parse rejection will bypass the catch block, so it can never be mapped to the "Page tree request timed out" message (or any other custom handling you add later).

return await ensures the promise settles before catch/finally execute.

Suggested fix
-    return response.json();
+    return await response.json();
apps/web/src/hooks/useTabSync.ts (1)

54-55: Re-reading state after healing is the right call, but the state / currentState duality adds cognitive overhead.

The first read (state, line 27) is used for healing and guard checks; the second (currentState, line 55) is used for the actual tab operations. This is functionally correct, but having two differently-named snapshots of the same store in scope invites accidental use of the stale one.

A small simplification: you could re-assign the same binding after healing, or extract the heal-and-get into a helper, so there's only one state variable in scope past the healing block.

♻️ Optional: collapse to a single binding
-    const state = useTabsStore.getState();
-    const hasTabs = state.tabs.length > 0;
-
-    // Heal invalid state: tabs exist but activeTabId is missing/stale.
-    if (hasTabs && !selectActiveTab(state)) {
-      state.setActiveTab(state.tabs[0].id);
-    }
+    let state = useTabsStore.getState();
+    const hasTabs = state.tabs.length > 0;
+
+    // Heal invalid state: tabs exist but activeTabId is missing/stale.
+    if (hasTabs && !selectActiveTab(state)) {
+      state.setActiveTab(state.tabs[0].id);
+      state = useTabsStore.getState(); // refresh after heal
+    }

Then replace currentState on lines 55–74 with the same state variable, and drop the separate re-read on line 55.

Comment thread apps/web/public/sw.js Outdated
2witstudios and others added 2 commits February 6, 2026 14:44
The staticExtensions array only covers fonts and images, not JS/CSS.
Updated the comment to match reality per CodeRabbit review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…state during retries

P1: After desktop OAuth exchange, the Set-Cookie from the exchange
endpoint only reached the Node.js main process fetch, not the
BrowserWindow cookie jar. This meant the Next.js middleware (which
checks for a session cookie on page routes) would redirect /dashboard
to /auth/signin after OAuth exchange. Fix: explicitly set the session
cookie in session.defaultSession.cookies before navigating.

P2: When the initial tree fetch fails and SWR auto-retries, isLoading
is false (error exists) but tree is still empty. Without the new guard,
the component falls through to "Page not found" during retry windows
instead of showing a loading skeleton. Fix: show skeleton when
isValidating && tree.length === 0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Additional Fixes (P1 + P2)

After deeper investigation of the desktop auth flow and loading states:

[P1] Desktop OAuth exchange cookie not reaching BrowserWindow (commit e575f36f)

Root cause: handleAuthExchange in Electron main process uses Node.js fetch to call /api/auth/desktop/exchange. The Set-Cookie response header is consumed by the Node.js runtime but never propagated to the BrowserWindow's cookie jar. When mainWindow.loadURL('/dashboard?auth=success') fires, the Next.js middleware finds no session cookie and redirects to /auth/signin.

Fix: After saving tokens to keychain, explicitly set the session cookie via session.defaultSession.cookies.set() before navigating (apps/desktop/src/main/index.ts lines 1009-1022). Cookie config mirrors the server: name session, httpOnly, secure in production, sameSite strict, 7-day expiry.

[P2] "Page not found" shown during SWR retry windows (commit e575f36f)

Root cause: When the initial tree fetch fails and SWR auto-retries, isLoading is false (because error exists), isValidating is true, and tree is empty. The error UI is correctly skipped (gated on !isValidating), but the component falls through to findNodeAndParent([], pageId) → null → "Page not found".

Fix: Added if (isValidating && tree.length === 0) return <Skeleton /> guard in CenterPanel (apps/web/src/components/layout/middle-content/CenterPanel.tsx lines 85-87) to show loading skeleton during retry windows.

Cookie lifetime mismatch (investigated, not changed)

Desktop session tokens are 90 days in the DB but the cookie max-age is 7 days. This is self-correcting: when the cookie expires, the auth hook triggers refreshAuthSession from the renderer, which calls /api/auth/device/refresh and gets a fresh cookie. Brief flash of signin page possible but no data loss. Left as-is since it auto-recovers.

2witstudios and others added 2 commits February 6, 2026 14:56
Gate the isValidating retry skeleton on !isLoading so the initial
load still reaches the isLoading branch with its 12s timeout and
retry button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…blic in middleware

Device refresh, mobile login, and desktop exchange endpoints authenticate
via body tokens (device token, email/password, exchange code), not session
cookies. Without this change, once the 7-day session cookie expires while
the 90-day desktop session is still valid, middleware blocks the device
refresh call itself — preventing cookie recovery and forcing unnecessary
re-authentication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Fix: Cookie-expiry deadlock in middleware (commit 53e18439)

Bug: Desktop sessions last 90 days in the DB, but the session cookie has a 7-day max-age. After 7 days, the cookie expires and middleware blocks all requests — including /api/auth/device/refresh itself. Since the refresh call doesn't carry an Authorization header (it authenticates via device token in the body), it gets blocked by middleware's cookie check, creating a deadlock: can't refresh the cookie because you need a cookie to reach the refresh endpoint.

Fix: Added /api/auth/device/, /api/auth/mobile/, and /api/auth/desktop/ to the middleware public routes list (middleware.ts:78-80). These endpoints authenticate via body tokens (device token, email/password, exchange code), not session cookies, so they're safe to bypass the cookie presence check. This allows the device refresh to succeed even after cookie expiry, restoring the cookie for subsequent page loads.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@apps/desktop/src/main/index.ts`:
- Around line 1018-1029: The cookie `secure` flag logic is inconsistent with
getAppUrl() — change the secure calculation used in the
session.defaultSession.cookies.set call so it treats both 'localhost' and
'127.0.0.1' as non-secure origins (same behavior as getAppUrl()); locate the
code around appUrl = new URL(getAppUrl()) and update the secure assignment
(currently `secure: !appUrl.origin.includes('localhost')`) to also check for
'127.0.0.1' before setting secure to true, ensuring cookies for local HTTP dev
origins are not marked secure.

In `@apps/web/src/components/layout/middle-content/CenterPanel.tsx`:
- Around line 46-54: The loading-timeout effect doesn't restart when a retry
occurs but isLoading stays true; add a retry counter state (e.g.,
loadingRetryCount) that handleRetry increments when invoking retry(), and
include that counter in the useEffect dependency list alongside isLoading so the
timer created in the effect (which uses setLoadingTimedOut and
LOADING_TIMEOUT_MS) is reset on each retry; update handleRetry to reset
loadingTimedOut and increment loadingRetryCount before calling retry().
🧹 Nitpick comments (1)
apps/web/public/sw.js (1)

96-97: Catch-all now network-first — correct direction, but the offline fallback returns JSON for non-API consumers.

Switching from cache-first to network-first for the catch-all is the right default to avoid stale content. However, networkFirstWithCache (line 116) returns a application/json error body when offline and nothing is cached. This branch also serves the catch-all, so a non-API consumer (e.g., a fetch for a .json manifest or other non-HTML resource) would receive a JSON error with { "error": "offline" }.

In practice the impact is low — this path is only reached when (a) offline, (b) nothing cached, and (c) the request doesn't match any earlier branch — but the comment on line 115 ("Return a JSON error for API requests when offline") is now slightly misleading since this function also serves the catch-all.

Consider either:

  • Adding a small note to the comment, or
  • Creating a thin wrapper / separate fallback for the catch-all that returns a plain 503 instead of JSON.

Not blocking; the current behavior is functional.

Comment thread apps/desktop/src/main/index.ts
Comment thread apps/web/src/components/layout/middle-content/CenterPanel.tsx Outdated
2witstudios and others added 2 commits February 6, 2026 15:16
- Desktop exchange cookie: check for both localhost and 127.0.0.1
  when setting secure flag, matching getAppUrl() behavior.
- CenterPanel: add timerKey state that increments on retry, used as
  an effect dependency to force the 12s timeout timer to restart
  even when isLoading stays true throughout the retry cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When desktop started with no persisted tabs, didAttemptDesktopRestore
was never set because the hasTabs guard skipped the entire block.
Later navigation back to /dashboard was misclassified as bootstrap,
bouncing the user to the previous page. Now marks the restore attempt
on the first hydrated pass regardless of tab state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@2witstudios
2witstudios merged commit e32a640 into master Feb 6, 2026
10 checks passed
@2witstudios
2witstudios deleted the claude/fix-desktop-loading-state-YmI79 branch February 6, 2026 23:39
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.

2 participants