Skip to content

feat: optimistic workspace creation + Electric write-sync correctness#4707

Merged
saddlepaddle merged 7 commits into
mainfrom
quixotic-patient
May 19, 2026
Merged

feat: optimistic workspace creation + Electric write-sync correctness#4707
saddlepaddle merged 7 commits into
mainfrom
quixotic-patient

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented May 19, 2026

Summary

Reworks workspace creation onto TanStack DB optimistic inserts with Electric txid-confirmed sync, and fixes the write-path correctness gaps underneath it. Four parts:

  1. Electric write-path correctnessgetCurrentTxid() xid cast, electric-proxy protocol-param constant, and no-op mutations no longer fabricate txids.
  2. Workspace create-flow rewrite — optimistic v2Workspaces insert + a synchronous fire-and-forget submit(); the in-flight Zustand sidecar (workspace-creates store + Manager) is gone, replaced by a minimal failedWorkspaceCreates collection that only holds failures.
  3. Shape-stream onError — Electric streams now retry (and refresh the JWT on 401) instead of silently dying on the first non-retryable error.
  4. Electric-confirmed deletion — the delete dialog waits for the row to actually leave the local collection before clearing deleting-state.

Relationship to #4632

This is an alternative to the open Electric-team PR #4632. It keeps that PR's write-path correctness fixes and the optimistic-insert direction, but preserves the in-product retry UI#4632 removes WorkspaceCreateErrorState / WorkspaceCreatingState; here they're kept and wired to a real retry mechanism — and keeps in-flight creates visible in the sidebar as loading rows. It also adds shape-stream onError (Part 3), a read-path gap #4632 doesn't touch.


Part 1: Electric write-path correctness

Why

getCurrentTxid() returned pg_current_xact_id()::text — a 64-bit xid8. Electric receives the 32-bit xid from logical replication. The two agree on a fresh database but diverge once the xid epoch advances, after which awaitTxId can never match and every optimistic write times out. Separately, no-op writes (raced deletes, no-change updates) returned a freshly-minted txid that Electric will never emit downstream, so the renderer waited the full 30s sync timeout for nothing.

What

  • getCurrentTxid()pg_current_xact_id()::xid::text, parsed and Number.isSafeInteger-validated.
  • electric-proxy uses the official ELECTRIC_PROTOCOL_QUERY_PARAMS constant from @electric-sql/client instead of a hand-maintained PROTOCOL_PARAMS set (so new protocol params don't silently get dropped).
  • v2-workspace / v2-host / v2-project / chat routers gate getCurrentTxid() behind .returning() / affected-row checks — no-ops return no txid (or null), missing rows throw NOT_FOUND.
  • host-service workspaces.create surfaces the cloud txid (extractCreateTxid) so the renderer can hand it back to Electric.

Part 2: Workspace create-flow rewrite

Why

Workspace creation ran through a heavyweight renderer-memory sidecar — the workspace-creates Zustand store plus a Manager component reconciling it against Electric. It modeled in-flight rows outside TanStack DB, duplicating state the collection already tracks, and was hard to reason about.

What / How

  • submit() is now synchronous fire-and-forget — it returns { workspaceId, completed: Promise<SubmitOutcome> }. It does an optimistic collections.v2Workspaces.insert() (carrying a create-mutation metadata payload) and writes the pane layout up-front; v2Workspaces.onInsert calls host-service workspaces.create and returns the resulting txid for Electric to match.
  • TanStack DB's optimistic row already models in-flight ($synced: false) and succeeded ($synced: true). The one state it can't model is failed — the optimistic row rolls back on error — so a small localStorage-backed failedWorkspaceCreates collection holds failures, and the route's WorkspaceCreateErrorState reads it. Retry re-submit()s the stashed input; Dismiss deletes the entry.
  • v2-workspace/layout.tsx is now a reactive 4-way tree: synced row → real workspace UI; unsynced row → WorkspaceCreatingState; no row + failed entry → WorkspaceCreateErrorState; otherwise → WorkspaceNotFoundState.
  • The sidebar is de-coupled from in-flight bookkeeping: the creationStatus enum collapses to an isSynced boolean (workspaces.$synced), getCreationStatusText is deleted, and in-flight creates simply render as loading rows. <WorkspaceCreatesManager /> is removed from the dashboard layout.

Key Decisions

Decision Choice Rationale
In-flight state store TanStack DB optimistic row The collection already tracks it via $synced; a separate store is duplicate state.
Failure state Minimal failedWorkspaceCreates localStorage collection The only state the optimistic row can't represent (it rolls back). Powers the existing retry UI.
Canonical-id mismatch Optimistic row self-drops (txid null) Avoids a bespoke rollback sentinel — earlier WorkspaceCreateRollbackError approach was dropped as over-complex.
submit() shape Synchronous, returns { workspaceId, completed } Callers can navigate optimistically immediately and still await the outcome if they want.

New Files

  • workspace-creates/writeWorkspacePaneLayout.ts — pane-layout write, extracted so submit() can run it up-front.
  • CollectionsProvider/workspaceSyncWaits.tswaitForWorkspaceDeleted() (used by Part 4).

Removed Files

  • workspace-creates/store.ts, workspace-creates/Manager.tsx — the Zustand sidecar.
  • DashboardSidebarWorkspaceItem/utils/getCreationStatusText.ts (+ utils/index.ts).

Part 3: Shape-stream onError

Why

Electric ShapeStreams had no onError handler. A single non-retryable error (notably a 401 once the JWT expires) permanently kills the stream — after which every awaitTxId on that collection times out. This is the most likely root cause of the txid-timeout reports against #4632.

What

  • Added a shared handleElectricSyncError (ElectricSyncErrorHandler) wired to all 24 collections' shapeOptions.onError.
  • On a 401 it refreshes the JWT (authClient.token() + setJwt) so the stream can resume authenticated; otherwise it logs. It always returns {} so the stream retries rather than dying.

Part 4: Electric-confirmed deletion

Why

The destroy dialog cleared its deleting-state as soon as the host-service teardown returned, before Electric had synced the row's removal — leaving a brief window where a deleted workspace could flash back into the sidebar.

What

  • useDestroyDialogState.run() now awaits waitForWorkspaceDeleted(collections.v2Workspaces, workspaceId) after the destroy saga — it resolves when the row leaves the local collection and rejects after the 30s sync timeout.
  • On a slow sync it sets keepDeleting and surfaces a warning toast instead of clearing state, so the row stays visually deleting rather than reappearing.

Manual QA Checklist

Automated checks only were run (see Testing). The desktop app cannot be exercised from this environment — the items below are unverified and should be checked before merge.

Workspace creation

  • Creating a workspace navigates optimistically and the sidebar shows a loading row
  • On sync the loading row becomes a normal workspace row
  • A failed create surfaces WorkspaceCreateErrorState; Retry re-runs the create; Dismiss removes it
  • Multiple queued creates from the task list each resolve independently
  • In-flight create survives an app restart sensibly (re-syncs or shows error)

Workspace deletion

  • Deleting a workspace removes it from the sidebar and does not flash back
  • A slow delete-sync shows the "taking longer than expected" warning toast
  • Dirty-worktree force-retry path still works

Electric sync

  • Collections keep syncing after the JWT would have expired (401 → silent token refresh)
  • No-op updates/deletes don't hang on the 30s txid timeout

Testing

  • bun run typecheck@superset/desktop, trpc, host-service, db, electric-proxy
  • bun run lint — 0 issues ✓
  • bun testv2-project (36), task (8), host-service (645), desktop CollectionsProvider (15) ✓
  • Manual / packaged-build E2E not run — requires a running desktop app, unavailable in this environment.

Design Decisions

  • Optimistic row over a sidecar store — see Part 2. The collection is the source of truth; the only thing it can't express (a rolled-back failure) gets the minimal sidecar.
  • onError returns {} to retry rather than rethrowing — a dead stream is strictly worse than a retrying one; matches the Electric client's intended use of onError.
  • Delete waits on row-absence, not awaitTxId — observing the row leave the collection avoids retyping all 23 collection slots into ElectricCollectionUtils just to call awaitTxId on the delete path.

Known Limitations

  • No parser is configured for timestamptz columns — they sync as ISO strings, not Dates. Deferred.
  • $synced / isDeleting guards are intentionally not added to ancillary surfaces (command palette, resource monitor, ports list, /v2-workspaces). The workspace-layout guard covers the real hazard; showing in-flight rows in those lists is consistent with the sidebar.

Risks / Rollback

  • Risk: Broad blast radius — tRPC router changes touch multiple desktop call-sites; the create flow and sidebar are rewritten. Type-checked and lint-clean, but no manual E2E.
  • Rollback: Revert the branch. No schema or data migrations; nothing to recover. electric-proxy and the routers are forward-compatible with the old renderer (extra/absent txids are tolerated), so a renderer-only revert is also safe.

Notes

  • The branch carries two pre-existing WIP commits — squash-merge recommended so the history reads from this description.

Open in Stage

Summary by cubic

Moves workspace creation to optimistic TanStack DB inserts with Electric txid-confirmed sync, removes the in-flight store, and fixes Electric write-sync correctness and stream resilience. Unsynced workspaces are now non-actionable until they sync, and deletions wait for Electric to confirm before clearing UI state.

  • New Features

    • Optimistic v2Workspaces insert with a synchronous submit() that returns { workspaceId, completed }; pane layout is written up-front.
    • Replaces the in-flight Zustand sidecar with a minimal failedWorkspaceCreates local collection; retry re-submits, dismiss clears.
    • UI uses isSynced instead of creationStatus; sidebar renders loading rows for unsynced workspaces; delete waits for the row to leave the local collection and warns on slow sync.
    • Electric shape streams add onError that retries and refreshes the JWT on 401 across all collections.
  • Bug Fixes

    • Unsynced rows are non-actionable: host URL resolution is gated on $synced, and sidebar jump shortcuts ignore unsynced or deleting workspaces.
    • Create retry now redirects to the canonical workspace id if it differs from the optimistic id.
    • Electric txid correctness: pg_current_xact_id()::xid::text with integer validation; no-op mutations return no txid; routers gate txid and throw NOT_FOUND on missing rows.
    • TRPC mutations wrap writes in transactions and return txids (projects, hosts, workspaces, chat) so UI awaitTxId can match correctly.
    • electric-proxy now uses ELECTRIC_PROTOCOL_QUERY_PARAMS from @electric-sql/client.

Written for commit 3d00569. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • New Features

    • Persist failed workspace creations for reliable retry.
    • Deletions wait for cloud sync confirmation before finalizing.
  • Improvements

    • Clearer "synced" vs "syncing" status in sidebar icons and labels.
    • Simplified pending/creation UI and tooltips.
    • Keyboard navigation and hotkeys skip unsynced or deleting workspaces.
    • Optimistic workspace navigation reconciles to the canonical workspace when operations complete.

Review Change Stack

Electric collections had no shapeOptions.onError, so a non-retryable
error (expired-JWT 401, 409 shape-expired, 4xx) permanently stopped the
stream — that collection silently stopped syncing until app restart.

Add a shared handleElectricSyncError wired to every collection: returns
{} to retry (ShapeStream backoff paces it), and refreshes the JWT on a
401 so the retry uses a fresh token.
The delete dialog now awaits waitForWorkspaceDeleted — the workspace
row leaving the local Electric collection — before clearing the
deleting state. If sync is slow it keeps the row hidden and warns,
rather than re-enabling stale actions on a half-deleted workspace.
@capy-ai
Copy link
Copy Markdown

capy-ai Bot commented May 19, 2026

Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 19, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ca1c423f-ba90-4ee0-8710-cf8578dcf38f

📥 Commits

Reviewing files that changed from the base of the PR and between f669763 and 3d00569.

📒 Files selected for processing (2)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx
  • packages/trpc/src/router/v2-project/v2-project.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx
  • packages/trpc/src/router/v2-project/v2-project.test.ts

📝 Walkthrough

Walkthrough

This PR replaces the renderer-side in-flight workspace-creation Zustand store with Electric-backed persistence: workspaces now track sync status via an isSynced boolean field queried from the database, failed creations persist in the failedWorkspaceCreates collection, and the useWorkspaceCreates hook's API simplifies from store-based entries/retry/dismiss to a promise-based submission pattern returning SubmitHandle with optimistic workspace ID and deferred completion outcome.

Changes

Workspace creation flow migration

Layer / File(s) Summary
Sync state type and failed-workspace schema
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts, apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts
DashboardSidebarWorkspace now includes isSynced: boolean; new failedWorkspaceCreateSchema and FailedWorkspaceCreateRow type persist failed create records with id, hostId, input, error, and failedAt.
Dashboard sidebar data queries with synced state
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/*
useDashboardSidebarData removes useWorkspaceCreatesStore import and in-flight row injection logic; DB queries now select isSynced from workspaces.$synced; visible workspace list combines only main workspaces + synced sidebar workspaces (no cloud-row fallback).
Sidebar UI components using isSynced
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/*, apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/*, apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceTitle/*
Sidebar icon, button, and expanded row components destructure and use isSynced instead of creationStatus; pending state derived from !isSynced; exclamation-icon "failed" branch removed; V2WorkspaceTitle drops in-flight store fallback and extracts name/branch directly from synced workspace query.
Workspace creation API rewrite
apps/desktop/src/renderer/stores/workspace-creates/useWorkspaceCreates.ts, apps/desktop/src/renderer/stores/workspace-creates/writeWorkspacePaneLayout.ts, apps/desktop/src/renderer/stores/workspace-creates/index.ts
useWorkspaceCreates() now returns { submit } only; submit returns SubmitHandle with optimistic workspaceId and completed: Promise<SubmitOutcome>; removes in-flight store dispatch, uses collections.failedWorkspaceCreates for error persistence, performs optimistic v2Workspaces.insert with metadata, and manages pane layout via new writeWorkspacePaneLayout helper.
Failed workspace collection and error state
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts, apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx
Introduces failedWorkspaceCreates as indexed local collection; WorkspaceCreateErrorState now accepts entry: FailedWorkspaceCreateRow, derives name/branch from entry.input, and uses useWorkspaceCreates().submit() + collection removal for retry/dismiss.
Create submission callsites using SubmitHandle pattern
apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/*, apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/*, apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx
All workspace-creation submission sites destructure { workspaceId, completed } from submit() result, navigate optimistically to /v2-workspace/$workspaceId, then attach completed.then(outcome => ...) handlers for conditional re-navigation when canonical workspace ID differs; removed prior promise chaining and some toast error reporting.
v2-workspace layout with isSynced and failed DB entries
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx
Layout now queries failedWorkspaceCreates for the current workspace ID; uses isSynced to gate sidebar ensure effect and distinguish error (failed entry), creating (exists but not synced), and found (synced) states; defers host-status initialization until sync.
Workspace deletion synchronization
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/*, apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/workspaceSyncWaits.ts
useDestroyDialogState integrates deletion-sync waiting via new waitForWorkspaceDeleted helper; on destroy success, waits for workspace to vanish from collections.v2Workspaces; if sync wait fails, preserves deleting state, calls onDeleted, logs/shows a toast warning, and returns early.
Electric collections error handling and txid extraction
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
Adds handleElectricSyncError that refreshes JWTs on 401 and logs errors; introduces ELECTRIC_WRITE_SYNC_TIMEOUT_MS and electricTxidMatch helper; wires onError handlers to many collections; adds v2Workspaces onInsert hook that calls host-service workspaces.create, stores result in metadata, and returns Electric txid match.
Server mutations with transaction ID responses
packages/trpc/src/router/v2-workspace/v2-workspace.ts, packages/trpc/src/router/v2-project/v2-project.ts, packages/trpc/src/router/v2-host/v2-host.ts, packages/trpc/src/router/chat/chat.ts, packages/db/src/utils/sql.ts, packages/host-service/src/trpc/router/workspaces/workspaces.ts
Workspace, project, host, and chat mutations now perform operations inside dbWs.transaction, capture txid via getCurrentTxid(tx), and include txid in response payloads; code now returns txid: null when idempotent/reused paths occur or throws NOT_FOUND when updates miss.
Electric proxy dependency and query params
apps/electric-proxy/package.json, apps/electric-proxy/src/electric.ts
Adds @electric-sql/client v1.5.15 dependency; updates PROTOCOL_PARAMS to import from upstream ELECTRIC_PROTOCOL_QUERY_PARAMS instead of hardcoding.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🐰 I hopped through code and stitched the thread,
Optimistic IDs to txids softly led,
Failed creates stored so they do not hide,
isSynced now tells when the UI's bona fide,
A carrot-toast cheer for syncs ahead 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main changes: optimistic workspace creation and Electric write-sync correctness fixes. It directly reflects the primary objectives of the PR.
Description check ✅ Passed The description is comprehensive and well-structured with all major sections completed: Summary explaining the four-part approach, detailed rationale for each part, relationship to prior work, manual QA checklist, testing summary, design decisions, known limitations, and rollback notes. Exceeds template requirements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 quixotic-patient

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 19, 2026

🚀 Preview Deployment

🔗 Preview Links

Service Status Link
Neon Database (Neon) View Branch
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

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 19, 2026

Greptile Summary

This PR rewrites workspace creation onto TanStack DB optimistic inserts, fixes Electric write-path correctness (xid8→xid cast, no-op mutation txid suppression, protocol-param constant), adds onError retry handling to all 24 Electric shape-streams, and makes deletion wait for Electric confirmation before clearing visual state.

  • Electric correctness (Part 1): getCurrentTxid now casts xid8 to 32-bit xid, electricTxidMatch returns undefined for no-op mutations so they don't block on a phantom txid, and the electric-proxy drops the hand-rolled PROTOCOL_PARAMS set in favour of the official ELECTRIC_PROTOCOL_QUERY_PARAMS constant.
  • Workspace create rewrite (Part 2): The Zustand workspace-creates store and Manager.tsx are deleted; submit() is now synchronous and fire-and-forget, inserting an optimistic row via TanStack DB with WorkspaceCreateMutationMetadata carrying the host-service call; failures land in a new localStorage-backed failedWorkspaceCreates collection; the layout renders a clean 4-way state tree (synced / unsynced / failed / not-found).
  • Shape-stream onError (Part 3): A shared handleElectricSyncError handler is wired to every collection; 401s trigger a JWT refresh via authClient.token() + setJwt; all errors return {} to keep streams alive.
  • Electric-confirmed deletion (Part 4): useDestroyDialogState.run() awaits waitForWorkspaceDeleted after the destroy saga; a 30 s timeout surfaces a warning toast but leaves keepDeleting = true, meaning clearDeleting is never called for that session.

Confidence Score: 3/5

The broad rewrite is type-checked and lint-clean, but has not had manual E2E validation and contains a stuck-deleting-state defect in the timeout path.

The workspace deletion flow has a real behavioral gap: when waitForWorkspaceDeleted times out, clearDeleting is never called and the workspace remains permanently locked in the deleting state for that session with no in-app recovery path. The rest of the changes are well-reasoned and mechanically correct, but the blast radius is large (38 files, rewritten create flow, 24 collections touched) and no manual E2E has been run against the packaged build.

useDestroyDialogState.ts (keepDeleting never clears), host-service workspaces.ts (unsafe CloudWorkspace cast for txid), v2-project.ts (undefined-spread dead-code path)

Important Files Changed

Filename Overview
packages/db/src/utils/sql.ts Fixes getCurrentTxid to cast xid8 to xid (32-bit) before parsing, and validates with Number.isSafeInteger; corrects the root cause of Electric txid-timeout divergence
apps/electric-proxy/src/electric.ts Replaces hand-maintained PROTOCOL_PARAMS set with ELECTRIC_PROTOCOL_QUERY_PARAMS from @electric-sql/client; clean improvement with no functional risk
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts Adds onInsert for v2Workspaces (calls host-service create), handleElectricSyncError wired to all 24 collections, failedWorkspaceCreates local-storage collection, and electricTxidMatch guard for no-op mutations; broad surface area but mechanically sound
apps/desktop/src/renderer/stores/workspace-creates/useWorkspaceCreates.ts Rewrites submit() as synchronous fire-and-forget returning { workspaceId, completed }; inserts an optimistic row and stores failures in failedWorkspaceCreates; replaces heavyweight Zustand sidecar cleanly
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/useDestroyDialogState.ts Adds waitForWorkspaceDeleted after destroy to confirm Electric sync; clearDeleting is permanently skipped when the 30s timeout fires, leaving the workspace locked in deleting state for the session lifetime
packages/trpc/src/router/v2-workspace/v2-workspace.ts Wraps all mutating endpoints in transactions, adds .returning() guards to skip getCurrentTxid on no-ops, and surfaces txid on create/update/delete responses; logic is correct, posthog skipped for already-gone deletes as expected
packages/host-service/src/trpc/router/workspaces/workspaces.ts Adds extractCreateTxid to surface the cloud txid for Electric matching, but uses an unsafe cast because CloudWorkspace lacks txid in its TypeScript type despite the router now returning it
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/workspaceSyncWaits.ts New waitForWorkspaceDeleted utility; subscription + synchronous final check with settled guard correctly handles races; timeout and cleanup logic look solid
packages/trpc/src/router/v2-project/v2-project.ts Wraps project create in a transaction to capture txid; the !inserted dead-code path spreads undefined producing a typeless return value without explicit error
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx Simplified to a clean 4-way tree: synced to real UI, unsynced to creating, failed entry to error state, missing to not found; ensureWorkspaceInSidebar now guarded on isSynced to avoid acting on optimistic rows

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["submit(args)"] --> B["Clear failedWorkspaceCreates entry"]
    B --> C["v2Workspaces.insert(optimisticRow, metadata)"]
    C --> D["writeWorkspacePaneLayout no launches"]
    D --> E["Return workspaceId and completed Promise"]
    E --> F["User navigates to /v2-workspace/:id"]
    C --> G["onInsert fires async"]
    G --> H["host-service workspaces.create"]
    H -->|success| I["metadata.result = result, return electricTxidMatch"]
    H -->|failure| J["throw - TanStack DB rolls back optimistic row"]
    I --> K["Electric stream delivers row, isPersisted resolves"]
    K --> L["writeWorkspacePaneLayout with terminals and agents"]
    L --> M["completed ok: true"]
    J --> N["completed .catch fires"]
    N --> O["recordFailure - failedWorkspaceCreates.insert"]
    O --> P["Layout: WorkspaceCreateErrorState"]
    F --> Q{workspace state}
    Q -->|synced true| R["Real workspace UI"]
    Q -->|synced false| S["WorkspaceCreatingState"]
    Q -->|no row with failed entry| P
    Q -->|no row no failure| T["WorkspaceNotFoundState"]
Loading
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/useDestroyDialogState.ts:143-176
**Deleting state permanently leaked on slow-sync timeout**

When `waitForWorkspaceDeleted` times out, `keepDeleting` is set to `true`, `onDeleted()` navigates the user away, and the function returns early. The `finally` block then skips `clearDeleting(workspaceId)`. For the session lifetime, `DeletingWorkspacesProvider` considers this workspace as still-deleting.

In the happy path (Electric eventually delivers the row removal) this is harmless. But if the row persists — e.g. Electric is down and the delete is not durably confirmed — the workspace reappears in the sidebar permanently locked in the deleting state, with no in-app way to recover short of restarting. The user also can't retry the delete because `isDeleting` guards the destroy action.

### Issue 2 of 3
packages/host-service/src/trpc/router/workspaces/workspaces.ts:117-122
**Unsafe cast bypasses TypeScript type-checking for `txid`**

`(row as { txid?: unknown })` is needed only because `CloudWorkspace` — inferred from the tRPC output type of `v2WorkspaceRouter.create` — does not include `txid` in its TypeScript type, even though the field is present at runtime. This means the compiler will not catch future type mismatches between the router's return shape and this access site. Since `v2-workspace.ts` was updated to return `{ ...result.workspace, txid: result.txid }`, the inferred type should include `txid: number | null`; if it doesn't, the tRPC type for the `create` endpoint may need regenerating or the `CloudWorkspace` alias should be tightened to use the actual router output type directly.

### Issue 3 of 3
packages/trpc/src/router/v2-project/v2-project.ts:220-315
**`{ ...undefined, txid }` returns an incomplete object when `inserted` is falsy**

When `!inserted`, `result.project` is `undefined`, and the outer `return { ...project, txid }` spreads `undefined` — producing `{ txid: null }` without any project fields. TypeScript allows this because the declared type of `project` is `typeof v2Projects.$inferSelect | undefined` and spreading `undefined` is a no-op. Any caller that destructures project properties from the return value would silently receive `undefined`. In practice `INSERT ... RETURNING` always either returns the row or throws, so the branch is dead code, but making the dead-code path explicit (e.g. `throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" })`) would prevent this from becoming a silent data hole if the assumption ever breaks.

Reviews (1): Last reviewed commit: "feat(desktop): confirm workspace deletio..." | Re-trigger Greptile

Comment on lines 143 to 176
@@ -151,7 +169,9 @@ export function useDestroyDialogState({
);
}
} finally {
clearDeleting(workspaceId);
if (!keepDeleting) {
clearDeleting(workspaceId);
}
inFlight.current = false;
}
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.

P1 Deleting state permanently leaked on slow-sync timeout

When waitForWorkspaceDeleted times out, keepDeleting is set to true, onDeleted() navigates the user away, and the function returns early. The finally block then skips clearDeleting(workspaceId). For the session lifetime, DeletingWorkspacesProvider considers this workspace as still-deleting.

In the happy path (Electric eventually delivers the row removal) this is harmless. But if the row persists — e.g. Electric is down and the delete is not durably confirmed — the workspace reappears in the sidebar permanently locked in the deleting state, with no in-app way to recover short of restarting. The user also can't retry the delete because isDeleting guards the destroy action.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/useDestroyDialogState.ts
Line: 143-176

Comment:
**Deleting state permanently leaked on slow-sync timeout**

When `waitForWorkspaceDeleted` times out, `keepDeleting` is set to `true`, `onDeleted()` navigates the user away, and the function returns early. The `finally` block then skips `clearDeleting(workspaceId)`. For the session lifetime, `DeletingWorkspacesProvider` considers this workspace as still-deleting.

In the happy path (Electric eventually delivers the row removal) this is harmless. But if the row persists — e.g. Electric is down and the delete is not durably confirmed — the workspace reappears in the sidebar permanently locked in the deleting state, with no in-app way to recover short of restarting. The user also can't retry the delete because `isDeleting` guards the destroy action.

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

Comment on lines 117 to +122
>
>;

function extractCreateTxid(row: CloudWorkspace): number | null {
const txid = (row as { txid?: unknown }).txid;
return typeof txid === "number" ? txid : null;
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.

P2 Unsafe cast bypasses TypeScript type-checking for txid

(row as { txid?: unknown }) is needed only because CloudWorkspace — inferred from the tRPC output type of v2WorkspaceRouter.create — does not include txid in its TypeScript type, even though the field is present at runtime. This means the compiler will not catch future type mismatches between the router's return shape and this access site. Since v2-workspace.ts was updated to return { ...result.workspace, txid: result.txid }, the inferred type should include txid: number | null; if it doesn't, the tRPC type for the create endpoint may need regenerating or the CloudWorkspace alias should be tightened to use the actual router output type directly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/trpc/router/workspaces/workspaces.ts
Line: 117-122

Comment:
**Unsafe cast bypasses TypeScript type-checking for `txid`**

`(row as { txid?: unknown })` is needed only because `CloudWorkspace` — inferred from the tRPC output type of `v2WorkspaceRouter.create` — does not include `txid` in its TypeScript type, even though the field is present at runtime. This means the compiler will not catch future type mismatches between the router's return shape and this access site. Since `v2-workspace.ts` was updated to return `{ ...result.workspace, txid: result.txid }`, the inferred type should include `txid: number | null`; if it doesn't, the tRPC type for the `create` endpoint may need regenerating or the `CloudWorkspace` alias should be tightened to use the actual router output type directly.

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

Comment on lines 220 to +315
@@ -300,7 +312,7 @@ export const v2ProjectRouter = {
})();
}

return project;
return { ...project, txid };
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.

P2 { ...undefined, txid } returns an incomplete object when inserted is falsy

When !inserted, result.project is undefined, and the outer return { ...project, txid } spreads undefined — producing { txid: null } without any project fields. TypeScript allows this because the declared type of project is typeof v2Projects.$inferSelect | undefined and spreading undefined is a no-op. Any caller that destructures project properties from the return value would silently receive undefined. In practice INSERT ... RETURNING always either returns the row or throws, so the branch is dead code, but making the dead-code path explicit (e.g. throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" })) would prevent this from becoming a silent data hole if the assumption ever breaks.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/trpc/src/router/v2-project/v2-project.ts
Line: 220-315

Comment:
**`{ ...undefined, txid }` returns an incomplete object when `inserted` is falsy**

When `!inserted`, `result.project` is `undefined`, and the outer `return { ...project, txid }` spreads `undefined` — producing `{ txid: null }` without any project fields. TypeScript allows this because the declared type of `project` is `typeof v2Projects.$inferSelect | undefined` and spreading `undefined` is a no-op. Any caller that destructures project properties from the return value would silently receive `undefined`. In practice `INSERT ... RETURNING` always either returns the row or throws, so the branch is dead code, but making the dead-code path explicit (e.g. `throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" })`) would prevent this from becoming a silent data hole if the assumption ever breaks.

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

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.

Actionable comments posted: 2

Caution

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

⚠️ Outside diff range comments (1)
packages/trpc/src/router/v2-workspace/v2-workspace.ts (1)

576-579: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize idempotent delete responses to always include txid.

Both early alreadyGone branches omit txid, while other delete branches return it. Keep the response shape stable by returning txid: null there too.

Suggested patch
-			if (!workspace) {
-				// Already gone in the cloud; idempotent success.
-				return { success: true, alreadyGone: true as const };
-			}
+			if (!workspace) {
+				// Already gone in the cloud; idempotent success.
+				return { success: true, alreadyGone: true as const, txid: null };
+			}
...
-			if (!workspace) {
-				return { success: true, alreadyGone: true as const };
-			}
+			if (!workspace) {
+				return { success: true, alreadyGone: true as const, txid: null };
+			}

Also applies to: 635-637

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/trpc/src/router/v2-workspace/v2-workspace.ts` around lines 576 -
579, Early idempotent delete branches that return { success: true, alreadyGone:
true as const } omit txid, causing inconsistent response shape; update those
early return sites (the checks on the workspace variable inside the delete
handler in v2-workspace.ts) to return { success: true, alreadyGone: true as
const, txid: null } so they match the other delete branches that include txid;
apply the same change to both occurrences around the existing workspace checks
(the two alreadyGone return points referenced in the review).
🧹 Nitpick comments (2)
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts (1)

242-256: 💤 Low value

Consider preventing retry on persistent auth failure.

When JWT refresh fails (line 249-251), the function still returns {} which signals Electric to retry the stream. If the auth issue persists, this could cause continuous retry attempts with an invalid token.

Consider returning undefined or a signal to stop retrying when refresh definitively fails, or implementing exponential backoff/max retry limits at a higher level.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts`
around lines 242 - 256, The handler handleElectricSyncError currently returns {}
after a failed JWT refresh which signals Electric to retry; change it so that
when authClient.token() fails (inside the catch in handleElectricSyncError) you
return undefined (or another stop-retry sentinel) instead of {} to prevent
immediate retries with an invalid token; keep the existing setJwt call when
refresh succeeds, and only return {} for non-auth errors as before, ensuring
handleElectricSyncError short-circuits retry on definitive auth refresh failure.
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/workspaceSyncWaits.ts (1)

14-38: ⚡ Quick win

Consider defensive refactoring for subscription cleanup timing.

The code at lines 35-38 relies on explicit callback guarding via the settled flag. While this works correctly, the pattern of accessing subscription in callbacks defined before assignment could be hardened. Adding a nullable subscription wrapper and shared cleanup function (as shown in the diff) eliminates theoretical callback-timing brittleness and makes cleanup logic DRY.

Proposed defensive refactor
 export function waitForWorkspaceDeleted(
 	collection: AppCollections["v2Workspaces"],
 	workspaceId: string,
 ): Promise<void> {
 	if (!collection.get(workspaceId)) {
 		return Promise.resolve();
 	}

 	return new Promise((resolve, reject) => {
 		let settled = false;
+		let subscription: { unsubscribe: () => void } | null = null;
+		const cleanup = () => {
+			clearTimeout(timeoutId);
+			subscription?.unsubscribe();
+		};
+
 		const timeoutId = setTimeout(() => {
 			if (settled) return;
 			settled = true;
-			subscription.unsubscribe();
+			cleanup();
 			reject(
 				new Error(
 					`Workspace ${workspaceId} deletion did not sync to the local collection`,
 				),
 			);
 		}, ELECTRIC_WRITE_SYNC_TIMEOUT_MS);

 		const finish = () => {
 			if (settled || collection.get(workspaceId)) return;
 			settled = true;
-			clearTimeout(timeoutId);
-			subscription.unsubscribe();
+			cleanup();
 			resolve();
 		};

-		const subscription = collection.subscribeChanges(finish, {
-			includeInitialState: false,
-		});
-		finish();
+		try {
+			subscription = collection.subscribeChanges(finish, {
+				includeInitialState: false,
+			});
+			finish();
+		} catch (err) {
+			if (settled) return;
+			settled = true;
+			cleanup();
+			reject(err instanceof Error ? err : new Error(String(err)));
+		}
 	});
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/workspaceSyncWaits.ts`
around lines 14 - 38, The current promise uses a `settled` flag and callbacks
(`finish` and the timeout) that reference `subscription` before it's assigned,
which is brittle; refactor by introducing a nullable `let activeSubscription:
Unsubscribe | null = null` (or similar) and a single shared `cleanup` function
that checks `settled`, clears the timeout, unsubscribes `activeSubscription` if
non-null, and resolves/rejects as needed; assign `activeSubscription =
collection.subscribeChanges(finish, { includeInitialState: false })` and have
both the timeout handler and `finish` call the shared `cleanup` to remove
duplication and eliminate timing race conditions (reference symbols:
`subscription`, `finish`, `settled`, `collection.subscribeChanges`,
`ELECTRIC_WRITE_SYNC_TIMEOUT_MS`, `workspaceId`).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx`:
- Around line 22-24: The retry handler currently calls submit(...) but never
removes the failed entry from failedWorkspaceCreates, leaving orphaned state and
stale UI; update handleRetry to first remove the current entry from
failedWorkspaceCreates (use the same identifier used in storage, e.g., entry.id
or entry.hostId via the existing removal helper or a new
removeFailedWorkspaceCreate function), then call submit({ hostId: entry.hostId,
snapshot: entry.input }) and proceed with the existing navigation flow so the UI
no longer shows the failed entry after a successful retry.

In
`@apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx`:
- Line 238: The toast.error call currently passes a plain string
(`toast.error(\`Failed to import ${wt.branch}: ${outcome.error}\`)`) which is
not selectable due to renderer global styles; change it to pass a React element
with selectable classes so users can copy the error text. Replace the string
argument to toast.error with a JSX element (e.g., a <span> or <div>) that
contains the same interpolated text and has className="select-text cursor-text",
keeping the same variables (wt.branch and outcome.error) and preserving existing
behavior of the toast invocation.

---

Outside diff comments:
In `@packages/trpc/src/router/v2-workspace/v2-workspace.ts`:
- Around line 576-579: Early idempotent delete branches that return { success:
true, alreadyGone: true as const } omit txid, causing inconsistent response
shape; update those early return sites (the checks on the workspace variable
inside the delete handler in v2-workspace.ts) to return { success: true,
alreadyGone: true as const, txid: null } so they match the other delete branches
that include txid; apply the same change to both occurrences around the existing
workspace checks (the two alreadyGone return points referenced in the review).

---

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts`:
- Around line 242-256: The handler handleElectricSyncError currently returns {}
after a failed JWT refresh which signals Electric to retry; change it so that
when authClient.token() fails (inside the catch in handleElectricSyncError) you
return undefined (or another stop-retry sentinel) instead of {} to prevent
immediate retries with an invalid token; keep the existing setJwt call when
refresh succeeds, and only return {} for non-auth errors as before, ensuring
handleElectricSyncError short-circuits retry on definitive auth refresh failure.

In
`@apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/workspaceSyncWaits.ts`:
- Around line 14-38: The current promise uses a `settled` flag and callbacks
(`finish` and the timeout) that reference `subscription` before it's assigned,
which is brittle; refactor by introducing a nullable `let activeSubscription:
Unsubscribe | null = null` (or similar) and a single shared `cleanup` function
that checks `settled`, clears the timeout, unsubscribes `activeSubscription` if
non-null, and resolves/rejects as needed; assign `activeSubscription =
collection.subscribeChanges(finish, { includeInitialState: false })` and have
both the timeout handler and `finish` call the shared `cleanup` to remove
duplication and eliminate timing race conditions (reference symbols:
`subscription`, `finish`, `settled`, `collection.subscribeChanges`,
`ELECTRIC_WRITE_SYNC_TIMEOUT_MS`, `workspaceId`).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 68f3c76f-8a74-48a8-a401-0565ba71b463

📥 Commits

Reviewing files that changed from the base of the PR and between d81dec1 and b50b4f3.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (37)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/useDestroyDialogState.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarCollapsedWorkspaceButton/DashboardSidebarCollapsedWorkspaceButton.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getCreationStatusText.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceTitle/V2WorkspaceTitle.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspaceV2/OpenInWorkspaceV2.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopoverV2/RunInWorkspacePopoverV2.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunIssuesInWorkspacePopover/RunIssuesInWorkspacePopover.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx
  • apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useBranchPickerController/useBranchPickerController.ts
  • apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts
  • apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts
  • apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts
  • apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/workspaceSyncWaits.ts
  • apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx
  • apps/desktop/src/renderer/stores/workspace-creates/Manager.tsx
  • apps/desktop/src/renderer/stores/workspace-creates/index.ts
  • apps/desktop/src/renderer/stores/workspace-creates/store.ts
  • apps/desktop/src/renderer/stores/workspace-creates/useWorkspaceCreates.ts
  • apps/desktop/src/renderer/stores/workspace-creates/writeWorkspacePaneLayout.ts
  • apps/electric-proxy/package.json
  • apps/electric-proxy/src/electric.ts
  • packages/db/src/utils/sql.ts
  • packages/host-service/src/trpc/router/workspaces/workspaces.ts
  • packages/trpc/src/router/chat/chat.ts
  • packages/trpc/src/router/v2-host/v2-host.ts
  • packages/trpc/src/router/v2-project/v2-project.test.ts
  • packages/trpc/src/router/v2-project/v2-project.ts
  • packages/trpc/src/router/v2-workspace/v2-workspace.ts
💤 Files with no reviewable changes (5)
  • apps/desktop/src/renderer/stores/workspace-creates/store.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getCreationStatusText.ts
  • apps/desktop/src/renderer/stores/workspace-creates/Manager.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx

Comment on lines +22 to +24
const handleRetry = () => {
submit({ hostId: entry.hostId, snapshot: entry.input });
};
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Retry does not remove the failed entry from failedWorkspaceCreates.

When the user clicks "Try again", handleRetry calls submit() but doesn't remove the current entry from failedWorkspaceCreates. This means:

  1. If retry succeeds, the failed entry remains in storage (orphaned data)
  2. The user stays on the error page viewing stale state

Consider removing the failed entry before submitting the retry and navigating to the new workspace.

🐛 Proposed fix
 const handleRetry = () => {
+    collections.failedWorkspaceCreates.delete(entry.id);
-    submit({ hostId: entry.hostId, snapshot: entry.input });
+    const { workspaceId } = submit({ hostId: entry.hostId, snapshot: entry.input });
+    void navigate({ to: "/v2-workspace/$workspaceId", params: { workspaceId } });
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceCreateErrorState/WorkspaceCreateErrorState.tsx`
around lines 22 - 24, The retry handler currently calls submit(...) but never
removes the failed entry from failedWorkspaceCreates, leaving orphaned state and
stale UI; update handleRetry to first remove the current entry from
failedWorkspaceCreates (use the same identifier used in storage, e.g., entry.id
or entry.hostId via the existing removal helper or a new
removeFailedWorkspaceCreate function), then call submit({ hostId: entry.hostId,
snapshot: entry.input }) and proceed with the existing navigation flow so the UI
no longer shows the failed entry after a successful retry.

ensureWorkspaceInSidebar(outcome.workspaceId, v2ProjectId);
} else {
toast.error(`Failed to import ${wt.branch}: ${result.error}`);
toast.error(`Failed to import ${wt.branch}: ${outcome.error}`);
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cat -n apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx | sed -n '230,250p'

Repository: superset-sh/superset

Length of output: 844


🏁 Script executed:

rg "toast\." apps/desktop/src/renderer --type tsx --type ts -A 2 -B 2 | head -100

Repository: superset-sh/superset

Length of output: 92


🏁 Script executed:

rg "toast\.error" apps/desktop/src/renderer -A 1 -B 1 | head -80

Repository: superset-sh/superset

Length of output: 8355


🏁 Script executed:

grep -n "toast" apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx | head -5

Repository: superset-sh/superset

Length of output: 322


🏁 Script executed:

cat -n apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx | sed -n '208,220p'

Repository: superset-sh/superset

Length of output: 500


🏁 Script executed:

rg "toast\.(error|success|warning|info)\(" apps/desktop/src/renderer --multiline -A 3 | rg "<" | head -20

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

rg "from.*sonner" apps/desktop/src/renderer

Repository: superset-sh/superset

Length of output: 23263


🏁 Script executed:

find . -path "*/node_modules" -prune -o -name "sonner*" -type f -print 2>/dev/null | head -20

Repository: superset-sh/superset

Length of output: 107


🏁 Script executed:

cat packages/ui/src/components/ui/sonner.tsx

Repository: superset-sh/superset

Length of output: 1252


🏁 Script executed:

rg "user-select.*none" apps/desktop/src/renderer --type css --type tsx --type ts -i

Repository: superset-sh/superset

Length of output: 92


🏁 Script executed:

rg "user-select.*none" apps/desktop/src/renderer -i

Repository: superset-sh/superset

Length of output: 817


🏁 Script executed:

cat apps/desktop/src/renderer/globals.css | grep -A 5 -B 5 "user-select"

Repository: superset-sh/superset

Length of output: 334


🏁 Script executed:

rg "select-text cursor-text" apps/desktop/src/renderer

Repository: superset-sh/superset

Length of output: 8098


Make the new toast error message selectable.

Line 238 adds user-facing error text without explicit selectable-text classes. Please render it with select-text cursor-text so users can copy details.

Suggested change
-						toast.error(`Failed to import ${wt.branch}: ${outcome.error}`);
+						toast.error(
+							<span className="select-text cursor-text">
+								{`Failed to import ${wt.branch}: ${outcome.error}`}
+							</span>,
+						);

As per coding guidelines, apps/desktop/**/*.{tsx,jsx}: Error text must be selectable by users with explicit select-text cursor-text classes; renderer sets user-select: none on body.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
toast.error(`Failed to import ${wt.branch}: ${outcome.error}`);
toast.error(
<span className="select-text cursor-text">
{`Failed to import ${wt.branch}: ${outcome.error}`}
</span>,
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx`
at line 238, The toast.error call currently passes a plain string
(`toast.error(\`Failed to import ${wt.branch}: ${outcome.error}\`)`) which is
not selectable due to renderer global styles; change it to pass a React element
with selectable classes so users can copy the error text. Replace the string
argument to toast.error with a JSX element (e.g., a <span> or <div>) that
contains the same interpolated text and has className="select-text cursor-text",
keeping the same variables (wt.branch and outcome.error) and preserving existing
behavior of the toast invocation.

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.

3 issues found across 38 files

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/stores/workspace-creates/writeWorkspacePaneLayout.ts">

<violation number="1" location="apps/desktop/src/renderer/stores/workspace-creates/writeWorkspacePaneLayout.ts:59">
P2: Ensure the sidebar project record exists before inserting workspace local state; otherwise newly created workspaces can be filtered out from sidebar rendering.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

.filter((item) => item.projectId === projectId)
.map((item) => ({ tabOrder: item.tabOrder })),
];
collections.v2WorkspaceLocalState.insert({
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.

P2: Ensure the sidebar project record exists before inserting workspace local state; otherwise newly created workspaces can be filtered out from sidebar rendering.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/stores/workspace-creates/writeWorkspacePaneLayout.ts, line 59:

<comment>Ensure the sidebar project record exists before inserting workspace local state; otherwise newly created workspaces can be filtered out from sidebar rendering.</comment>

<file context>
@@ -0,0 +1,74 @@
+			.filter((item) => item.projectId === projectId)
+			.map((item) => ({ tabOrder: item.tabOrder })),
+	];
+	collections.v2WorkspaceLocalState.insert({
+		workspaceId: workspace.id,
+		createdAt: new Date(),
</file context>

Comment thread packages/trpc/src/router/v2-project/v2-project.test.ts
useWorkspaceHostTarget resolved a host URL for any row present in the
v2Workspaces collection, including optimistic create rows. That made
useDiffStats fire git.getStatus against a host that has not registered
the workspace yet, returning NOT_FOUND on every create. Gate host
resolution on $synced so host-backed hooks stay disabled until sync.

Also exclude in-flight deletes from sidebar jump shortcuts: a deleting
row stays $synced until Electric removes it, so hotkeys could navigate
into a workspace whose host record was already destroyed.
When a create retry resolves to a different (canonical) workspace id,
the optimistic row for the original id rolls back and the route is left
on a workspace that no longer exists — WorkspaceNotFoundState. Await the
submit outcome and navigate to the canonical id when it differs.
v2Project.create inserts via tx.insert() inside a transaction, so
expect(dbInsert).not.toHaveBeenCalled() was vacuously true. Assert on
txInsert so the "rejected before insert" tests actually verify it.
@saddlepaddle saddlepaddle merged commit 0a40cf2 into main May 19, 2026
16 checks passed
saddlepaddle added a commit that referenced this pull request May 20, 2026
saddlepaddle added a commit that referenced this pull request May 20, 2026
sazabi Bot pushed a commit that referenced this pull request May 20, 2026
…#4707)

* WIP

* WIP

* fix(desktop): retry Electric shape streams on sync errors

Electric collections had no shapeOptions.onError, so a non-retryable
error (expired-JWT 401, 409 shape-expired, 4xx) permanently stopped the
stream — that collection silently stopped syncing until app restart.

Add a shared handleElectricSyncError wired to every collection: returns
{} to retry (ShapeStream backoff paces it), and refreshes the JWT on a
401 so the retry uses a fresh token.

* feat(desktop): confirm workspace deletion synced before clearing state

The delete dialog now awaits waitForWorkspaceDeleted — the workspace
row leaving the local Electric collection — before clearing the
deleting state. If sync is slow it keeps the row hidden and warns,
rather than re-enabling stale actions on a half-deleted workspace.

* fix(desktop): treat unsynced workspaces as non-actionable

useWorkspaceHostTarget resolved a host URL for any row present in the
v2Workspaces collection, including optimistic create rows. That made
useDiffStats fire git.getStatus against a host that has not registered
the workspace yet, returning NOT_FOUND on every create. Gate host
resolution on $synced so host-backed hooks stay disabled until sync.

Also exclude in-flight deletes from sidebar jump shortcuts: a deleting
row stays $synced until Electric removes it, so hotkeys could navigate
into a workspace whose host record was already destroyed.

* fix(desktop): redirect to canonical workspace after create retry

When a create retry resolves to a different (canonical) workspace id,
the optimistic row for the original id rolls back and the route is left
on a workspace that no longer exists — WorkspaceNotFoundState. Await the
submit outcome and navigate to the canonical id when it differs.

* test(trpc): assert tx.insert in v2-project create reject tests

v2Project.create inserts via tx.insert() inside a transaction, so
expect(dbInsert).not.toHaveBeenCalled() was vacuously true. Assert on
txInsert so the "rejected before insert" tests actually verify it.
sazabi Bot pushed a commit that referenced this pull request May 20, 2026
sazabi Bot pushed a commit that referenced this pull request May 20, 2026
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