Skip to content

fix(desktop): prevent "workspace not found" flash after v2 workspace create#3494

Merged
Kitenite merged 5 commits into
mainfrom
round-list
Apr 15, 2026
Merged

fix(desktop): prevent "workspace not found" flash after v2 workspace create#3494
Kitenite merged 5 commits into
mainfrom
round-list

Conversation

@Kitenite
Copy link
Copy Markdown
Collaborator

@Kitenite Kitenite commented Apr 15, 2026

Summary

  • Fixes a race in v2 workspace creation: the pending page navigated to /v2-workspace/$id after 3s even when the workspace row hadn't synced into the local Electric collection, causing WorkspaceNotFoundState to flash on the destination route.
  • Hard-gates navigation on the row appearing in collections.v2Workspaces (removes the silent || syncTimedOut escape that was jumping into a broken page).
  • Bumps the sync stall timeout from 3s → 10s so Electric has realistic headroom in healthy conditions.
  • On stall, surfaces a recoverable amber error with Keep waiting / Open anyway / Dismiss instead of silently navigating, so degraded sync becomes visible and user-controllable.

Test plan

  • Create a workspace via fork intent — verify it navigates cleanly with no NotFound flash.
  • Create via adopt intent (fast path) — verify navigation waits for sync rather than beating it.
  • Simulate slow sync (throttle network or block Electric) — verify after 10s the amber stall UI appears with the three actions.
  • Click Keep waiting — verify the timeout resets and navigation proceeds once sync lands.
  • Click Open anyway — verify it force-navigates (user-acknowledged escape hatch).
  • Click Dismiss — verify pending row is deleted and returns home.

Summary by cubic

Prevents the "Workspace not found" flash after creating a v2 workspace by waiting for local sync before navigating. Unifies the not‑found UI and adds a recoverable stall state with a 10s timeout.

  • Bug Fixes
    • Hard-gate navigation until the workspace row appears in collections.v2Workspaces (no more silent timeout escape).
    • Show an amber stall message after 10s with actions: Keep waiting (re-arms the timer), Open anyway, Dismiss; reset when the pending ID changes.
    • Promote WorkspaceNotFoundState to shared v2-workspace/components and use it in both the layout and page to avoid the unstyled flash.

Written for commit 3278690. Summary will update on new commits.

Summary by CodeRabbit

  • Improvements

    • Navigation now waits up to 10s for a workspace to sync locally; navigation is guarded to avoid duplicates and pending entries are cleaned up after opening.
    • Updated success flow to show a recoverable amber warning when sync stalls, offering “Keep waiting”, “Open anyway”, or “Dismiss”.
  • Bug Fixes

    • Replaced inline "workspace not found" text with a dedicated not-found UI for clearer feedback.

…create

The pending page previously navigated to /v2-workspace/$id after 3s even if
the workspace row hadn't synced into the local Electric collection. The
destination route's live query resolved empty and flashed
WorkspaceNotFoundState.

Hard-gate navigation on sync completion, bump the stall timeout to 10s, and
on stall show a recoverable error with Keep waiting / Open anyway / Dismiss
instead of silently navigating into a broken page.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 15, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Navigation from the pending workspace page is gated by a 10s SYNC_TIMEOUT_MS; navigation now uses a guarded doNavigate that requires a local workspace row and prevents duplicate navigation. On timeout, the UI shows a recoverable stalled warning with actions: keep waiting, open anyway, or dismiss.

Changes

Cohort / File(s) Summary
Pending Workspace Page — Navigation & Sync
apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx
Introduces SYNC_TIMEOUT_MS (10s), moves syncTimedOut state earlier and resets on pendingId change, consolidates navigation side effects into doNavigate (guarded by pending.workspaceId and !navigatedRef.current), narrows succeeded-navigation effect to workspaceSynced, optionally persists paneLayout, updates sidebar, navigates to /v2-workspace/$workspaceId, and schedules pending-row deletion. Adds recoverable stalled UI with "Keep waiting", "Open anyway", and "Dismiss" actions.
Workspace Page — Import Path Fix
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx
Fixed import path for WorkspaceNotFoundState from ./components/... to ../components/... (no behavior change).
Workspace Layout — Not Found UI
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx
Replaces inline "Workspace not found" JSX with WorkspaceNotFoundState component when `!workspace

Sequence Diagram(s)

sequenceDiagram
  participant PendingPage as Pending Page
  participant Timer as Sync Timer (SYNC_TIMEOUT_MS)
  participant Collections as Local Collections
  participant Sidebar as Dashboard Sidebar
  participant Router as Router
  participant Cleanup as Pending Row Cleanup

  PendingPage->>Collections: check for workspace row
  alt workspace present
    PendingPage->>PendingPage: set workspaceSynced = true
    PendingPage->>PendingPage: call doNavigate()
  else workspace not present
    Timer->>PendingPage: SYNC_TIMEOUT_MS elapsed -> set syncTimedOut
    PendingPage-->>PendingPage: show recoverable warning (wait / open anyway / dismiss)
    alt user chooses "Open anyway"
      PendingPage->>Collections: ensure workspace row exists
      PendingPage->>Sidebar: ensure workspace in sidebar
      PendingPage->>Collections: optionally write paneLayout to v2WorkspaceLocalState
      PendingPage->>Router: navigate to /v2-workspace/$workspaceId
      PendingPage->>Cleanup: schedule delete pending row (1s)
    else user chooses "Keep waiting"
      PendingPage->>Timer: clear syncTimedOut / continue waiting
    else user chooses "Dismiss"
      PendingPage->>Cleanup: delete pending row + clear attachments
      PendingPage->>Router: navigate to /
    end
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I waited ten seconds by the gate,
a pending row and patient fate.
If sync is slow, choose what to do—
wait, open on, or bid adieu.
I hop to cheer your chosen cue.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.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
Title check ✅ Passed The title accurately summarizes the main change: preventing a workspace-not-found flash after v2 workspace creation, which directly aligns with the core bug fix in the changeset.
Description check ✅ Passed The PR description is comprehensive and well-structured, clearly explaining the problem, solution, and testing approach without requiring additional required sections.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch round-list

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.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 15, 2026

Greptile Summary

This PR fixes a race condition in v2 workspace creation where the pending page would navigate to /v2-workspace/$id before the workspace row had synced into the local Electric collection, causing a "Workspace not found" flash. The fix hard-gates navigation on workspaceSynced (a live query checking collections.v2Workspaces), removes the old silent || syncTimedOut escape hatch, bumps the stall timeout from 3 s → 10 s, and surfaces a recoverable amber error with Keep waiting / Open anyway / Dismiss when sync stalls.

Key changes:

  • workspaceSynced live query added; the auto-navigate effect and doNavigate both gate on it being true
  • New syncTimedOut state + useEffect timer: if pending.status === \"succeeded\" but workspaceSynced is still false after 10 s, the amber stall UI is shown instead of silently navigating
  • Open anyway force-navigates via the existing doNavigate callback (respects navigatedRef.current guard)
  • Dismiss in the stall UI cleans up the pending row and navigates home
  • The doNavigate callback now writes paneLayout to v2WorkspaceLocalState before navigating and schedules a 1 s deferred delete of the pending row

Confidence Score: 4/5

Safe to merge with one targeted fix: the "Keep waiting" timer restart bug should be addressed to prevent users being stranded.

The core sync-gate logic is correct and directly addresses the race condition. The three-button stall UI is a clean improvement. Two issues exist in the error-recovery path: (1) clicking "Keep waiting" clears syncTimedOut but doesn't restart the 10 s timer (missing dep in the useEffect), leaving the user on a spinner with no action buttons if sync still doesn't arrive; (2) syncTimedOut is not reset when pendingId changes, potentially showing the stall UI prematurely on a new pending page. Issue (1) is a concrete P1 bug in the error path; issue (2) is an edge-case P2. The happy path (sync arrives within 10 s) is unaffected.

apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx — sync timeout useEffect dependency array and pendingId-change guard

Important Files Changed

Filename Overview
apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx Core sync-gate fix is correct; two issues in the "Keep waiting" path: the sync timer is never restarted after the flag is cleared (leaving the user stranded with no UI actions), and syncTimedOut is not reset when pendingId changes between navigations.

Sequence Diagram

sequenceDiagram
    participant Modal
    participant PendingPage
    participant HostService
    participant Electric

    Modal->>PendingPage: navigate(/pending/$id) with intent row
    PendingPage->>HostService: fireIntent() (fork/checkout/adopt)
    HostService-->>PendingPage: result { workspace.id, terminals, warnings }
    PendingPage->>PendingPage: pending.status = "succeeded", workspaceId set

    PendingPage->>Electric: live query collections.v2Workspaces WHERE id=workspaceId
    alt Sync arrives within 10s (happy path)
        Electric-->>PendingPage: workspaceSynced = true
        PendingPage->>PendingPage: doNavigate() → /v2-workspace/$id
    else Sync stalls past 10s
        PendingPage->>PendingPage: syncTimedOut = true → show amber error UI
        alt User clicks Keep waiting
            PendingPage->>PendingPage: syncTimedOut = false (timer NOT restarted)
            Electric-->>PendingPage: workspaceSynced = true (eventually)
            PendingPage->>PendingPage: doNavigate()
        else User clicks Open anyway
            PendingPage->>PendingPage: doNavigate() (force, no sync check)
        else User clicks Dismiss
            PendingPage->>PendingPage: delete pending row → navigate(/)
        end
    end
Loading

Comments Outside Diff (1)

  1. apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx, line 136-141 (link)

    P2 syncTimedOut not reset when pendingId changes

    The guard block that resets the per-pendingId bookkeeping when the route param changes resets firedRef and navigatedRef, but it does not reset syncTimedOut.

    If a user navigates from pending page A (where sync timed out → syncTimedOut=true) to pending page B (where the workspace has already succeeded but sync hasn't arrived yet), syncTimedOut will still be true on mount. The render condition syncTimedOut && !workspaceSynced will immediately be satisfied, showing the amber stall UI on page B — before the 10 s timeout has even elapsed.

    if (prevPendingIdRef.current !== pendingId) {
        prevPendingIdRef.current = pendingId;
        firedRef.current = false;
        navigatedRef.current = false;
        setSyncTimedOut(false); // add this
    }

    Calling a state setter during render is a documented React pattern for derived-from-params state ("store information from previous renders"). React will immediately discard the current render and restart with the new value.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx
    Line: 136-141
    
    Comment:
    **`syncTimedOut` not reset when `pendingId` changes**
    
    The guard block that resets the per-pendingId bookkeeping when the route param changes resets `firedRef` and `navigatedRef`, but it does **not** reset `syncTimedOut`.
    
    If a user navigates from pending page A (where sync timed out → `syncTimedOut=true`) to pending page B (where the workspace has already succeeded but sync hasn't arrived yet), `syncTimedOut` will still be `true` on mount. The render condition `syncTimedOut && !workspaceSynced` will immediately be satisfied, showing the amber stall UI on page B — before the 10 s timeout has even elapsed.
    
    ```tsx
    if (prevPendingIdRef.current !== pendingId) {
        prevPendingIdRef.current = pendingId;
        firedRef.current = false;
        navigatedRef.current = false;
        setSyncTimedOut(false); // add this
    }
    ```
    
    Calling a state setter during render is a documented React pattern for derived-from-params state ("store information from previous renders"). React will immediately discard the current render and restart with the new value.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx
Line: 375-380

Comment:
**"Keep waiting" leaves user with no escape UI**

When the user clicks **Keep waiting**, `setSyncTimedOut(false)` is called. This dismisses the amber error and renders the `"Workspace ready — opening..."` success branch, which has **no action buttons** (no Dismiss, no Keep waiting).

The sync-timeout `useEffect` won't restart because its dependency array `[pending?.status, pending?.workspaceId, workspaceSynced]` doesn't include `syncTimedOut` — so no new 10 s timer is scheduled after the reset. If sync never arrives, the user is stranded on a spinner with no way to dismiss or escape.

Fix: add `syncTimedOut` as a guard *and* dependency so the effect rearms itself when the flag is cleared:

```tsx
useEffect(() => {
    if (
        pending?.status !== "succeeded" ||
        !pending.workspaceId ||
        workspaceSynced ||
        syncTimedOut ||          // don't double-arm while already showing the error
        navigatedRef.current
    ) {
        return;
    }
    const timer = setTimeout(() => setSyncTimedOut(true), SYNC_TIMEOUT_MS);
    return () => clearTimeout(timer);
}, [pending?.status, pending?.workspaceId, workspaceSynced, syncTimedOut]);
```

With this change, clicking **Keep waiting** (`setSyncTimedOut(false)`) re-triggers the effect, arms a fresh 10 s timer, and the amber error will reappear if sync still hasn't landed — giving the user another opportunity to act.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx
Line: 136-141

Comment:
**`syncTimedOut` not reset when `pendingId` changes**

The guard block that resets the per-pendingId bookkeeping when the route param changes resets `firedRef` and `navigatedRef`, but it does **not** reset `syncTimedOut`.

If a user navigates from pending page A (where sync timed out → `syncTimedOut=true`) to pending page B (where the workspace has already succeeded but sync hasn't arrived yet), `syncTimedOut` will still be `true` on mount. The render condition `syncTimedOut && !workspaceSynced` will immediately be satisfied, showing the amber stall UI on page B — before the 10 s timeout has even elapsed.

```tsx
if (prevPendingIdRef.current !== pendingId) {
    prevPendingIdRef.current = pendingId;
    firedRef.current = false;
    navigatedRef.current = false;
    setSyncTimedOut(false); // add this
}
```

Calling a state setter during render is a documented React pattern for derived-from-params state ("store information from previous renders"). React will immediately discard the current render and restart with the new value.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(desktop): prevent "workspace not fou..." | Re-trigger Greptile

The layout had its own bare unstyled "Workspace not found" text that fired
before the page-level component could render, so users saw a raw centered
string instead of the styled empty state. Promote WorkspaceNotFoundState to
the shared v2-workspace/components/ folder and use it in both the layout
and the page.
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.

Caution

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

⚠️ Outside diff range comments (1)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx (1)

225-237: ⚠️ Potential issue | 🟠 Major

Re-arm the sync timeout after "Keep waiting".

The timeout effect (line 225) does not include syncTimedOut in its dependency array. When "Keep waiting" (line 377) sets syncTimedOut to false, the effect does not re-run because none of its listed dependencies (pending?.status, pending?.workspaceId, workspaceSynced) have changed. This means no new timeout is started, so "Keep waiting" only hides the error UI without actually resetting the timer. Users pressing it will not get a fresh 10-second window for sync to complete.

Proposed fix
 const [syncTimedOut, setSyncTimedOut] = useState(false);
+useEffect(() => {
+	setSyncTimedOut(false);
+}, [pendingId]);

 useEffect(() => {
 	if (
 		pending?.status !== "succeeded" ||
 		!pending.workspaceId ||
 		workspaceSynced ||
-		navigatedRef.current
+		navigatedRef.current ||
+		syncTimedOut
 	) {
 		return;
 	}
 	const timer = setTimeout(() => setSyncTimedOut(true), SYNC_TIMEOUT_MS);
 	return () => clearTimeout(timer);
-}, [pending?.status, pending?.workspaceId, workspaceSynced]);
+}, [
+	pending?.status,
+	pending?.workspaceId,
+	workspaceSynced,
+	syncTimedOut,
+	pendingId,
+]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/`$pendingId/page.tsx
around lines 225 - 237, The useEffect that starts the sync timeout (uses
SYNC_TIMEOUT_MS and setSyncTimedOut) doesn't include the syncTimedOut state in
its dependency array so clicking "Keep waiting" (which sets syncTimedOut =
false) won't re-arm the timer; update the dependency array of that effect to
include syncTimedOut (alongside pending?.status, pending?.workspaceId,
workspaceSynced) so the effect re-runs when syncTimedOut is reset, keeping the
same timer setup and cleanup (clearTimeout) logic in the effect body.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/`$pendingId/page.tsx:
- Around line 225-237: The useEffect that starts the sync timeout (uses
SYNC_TIMEOUT_MS and setSyncTimedOut) doesn't include the syncTimedOut state in
its dependency array so clicking "Keep waiting" (which sets syncTimedOut =
false) won't re-arm the timer; update the dependency array of that effect to
include syncTimedOut (alongside pending?.status, pending?.workspaceId,
workspaceSynced) so the effect re-runs when syncTimedOut is reset, keeping the
same timer setup and cleanup (clearTimeout) logic in the effect body.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db62a3de-3c41-4739-b0d3-b57893e15c26

📥 Commits

Reviewing files that changed from the base of the PR and between 9fff075 and 8675b1a.

📒 Files selected for processing (1)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx:377">
P2: `Keep waiting` clears `syncTimedOut` but does not restart the sync timeout, so the stall warning can be permanently dismissed while sync is still stalled.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

…ingId change

Clicking "Keep waiting" cleared syncTimedOut but the effect's dep array
didn't include it, so no new timer was scheduled. User would land on the
"Workspace ready — opening..." branch (no buttons) with no way out if sync
still didn't arrive. Add syncTimedOut as a dep and an early-return guard so
the effect re-arms cleanly. Also reset syncTimedOut when pendingId changes
so a new pending page doesn't inherit the prior one's stall UI.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 15, 2026

🚀 Preview Deployment

🔗 Preview Links

Service Status Link
Neon Database (Neon) View Branch
Fly.io Electric (Fly.io) View App
Vercel API (Vercel) Open Preview
Vercel Web (Vercel) Open Preview
Vercel Marketing (Vercel) Open Preview
Vercel Admin (Vercel) Open Preview
Vercel Docs (Vercel) Open Preview

Preview updates automatically with new commits

@Kitenite Kitenite merged commit 1b7cb9c into main Apr 15, 2026
13 of 14 checks passed
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.

1 participant