Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions apps/desktop/docs/OPTIMISTIC_ELECTRIC_UPDATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ These collections have Electric mutation handlers in `CollectionsProvider/collec
| --- | --- | --- | --- |
| `tasks` | insert, update, delete | `useOptimisticCollectionActions().tasks` for update/delete; create dialog still uses `task.createFromUi` directly | Optimistic for task edits/deletes; collection handlers return `{ txid }`. |
| `v2Projects` | update | `useOptimisticCollectionActions().v2Projects` for rename/repository updates | Optimistic for project row edits; create/delete remain API-confirmed. |
| `v2Workspaces` | update | `useOptimisticCollectionActions().v2Workspaces` for rename-style updates | Optimistic for workspace row edits; create/delete remain host-service sagas. |
| `v2Workspaces` | insert, update | `useOptimisticCollectionActions().v2Workspaces` for rename-style updates; `useWorkspaceCreates().submit` for optimistic insert | Optimistic for workspace creation and row edits; delete remains host-service saga. The `onInsert` handler calls host-service `workspaces.create`, returns `{ txid }`, and rolls back to the canonical ID when the server assigns a different one. |
| `chatSessions` | delete | `useOptimisticCollectionActions().chatSessions` for chat session deletion | Optimistic delete; create remains server-confirmed because the chat runtime coordinates session creation. |
| `agentCommands` | update | `useCommandWatcher` | Background optimistic update; caller awaits `tx.isPersisted.promise` and retries on failure. |

Expand All @@ -65,12 +65,11 @@ These are Electric-backed in the renderer but have no collection mutation handle
- `automations`
- `automationRuns`

Workspace create/delete flows do not use `collections.v2Workspaces.insert/delete`. They go through host-service or tRPC APIs and then Electric streams the confirmed row back:
Workspace create now uses `collections.v2Workspaces.insert` with an `onInsert` handler that calls host-service `workspaces.create`. The renderer does an optimistic insert, the handler persists through host-service, returns `{ txid }`, and the caller awaits `waitForSyncedWorkspaceRow` before navigating. If the server assigns a different canonical ID, the handler awaits the txid then throws a rollback sentinel so TanStack DB replaces the optimistic row with the canonical one.

- workspace create/checkout/adopt writes a local `pendingWorkspaces` row, then the pending page calls host-service
- workspace delete calls host-service `workspaceCleanup.destroy`; the sidebar hides the row through `DeletingWorkspacesProvider` while the saga runs
Workspace delete still calls host-service `workspaceCleanup.destroy`; the sidebar hides the row through `DeletingWorkspacesProvider` while the saga runs.

Workspace rename does use `collections.v2Workspaces.update` via `useOptimisticCollectionActions().v2Workspaces`, backed by `v2Workspace.update` returning `{ txid }` from the same Postgres transaction.
Workspace rename uses `collections.v2Workspaces.update` via `useOptimisticCollectionActions().v2Workspaces`, backed by `v2Workspace.update` returning `{ txid }` from the same Postgres transaction.

### LocalStorage collections

Expand All @@ -80,7 +79,7 @@ These are client-local TanStack DB collections. They are synchronous local persi
- `v2WorkspaceLocalState` — sidebar placement, pane layout, viewed files, changes tab
- `v2SidebarSections` — user-created sidebar sections and ordering
- `v2TerminalPresets` — local terminal presets
- `pendingWorkspaces` — durable local bus for workspace creation progress and launch handoff
- `pendingWorkspaces` — legacy; previously used for workspace creation progress (now handled by `v2Workspaces` optimistic insert)
- `v2UserPreferences` — local v2 preferences such as link behavior and delete-branch default

LocalStorage mutations can still throw for schema/storage errors, but they do not have remote persistence confirmation or Electric rollback semantics.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "react";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
import type { CommandContext } from "./types";

Expand All @@ -32,6 +33,7 @@ export function CommandContextProvider({ children }: { children: ReactNode }) {
hostServiceStatus,
machineId,
} = useLocalHostService();
const { isDeleting } = useDeletingWorkspaces();

const navigateTo = useCallback(
(path: string) => {
Expand All @@ -54,10 +56,16 @@ export function CommandContextProvider({ children }: { children: ReactNode }) {
projectId: workspaces.projectId,
type: workspaces.type,
hostId: workspaces.hostId,
isSynced: workspaces.$synced,
})),
[collections, v2WorkspaceId],
);
const v2Workspace = v2WorkspaceId ? (v2WorkspaceRows[0] ?? null) : null;
const v2Workspace =
v2WorkspaceId &&
v2WorkspaceRows[0]?.isSynced === true &&
!isDeleting(v2WorkspaceId)
? v2WorkspaceRows[0]
: null;
const projectId = v2Workspace?.projectId ?? null;

const { data: preferredAppRows = [] } = useLiveQuery(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface DestroyWorkspaceSuccess {
worktreeRemoved: boolean;
branchDeleted: boolean;
cloudDeleted: boolean;
cloudDeleteTxid: number | null;
warnings: string[];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import {
} from "renderer/hooks/host-service/useDestroyWorkspace";
import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences/useV2UserPreferences";
import { useNavigateAwayFromWorkspace } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { ELECTRIC_WRITE_SYNC_TIMEOUT_MS } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections";
import { waitForWorkspaceDeleted } from "renderer/routes/_authenticated/providers/CollectionsProvider/workspaceSyncWaits";
import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider";

interface UseDestroyDialogStateOptions {
Expand All @@ -34,6 +37,7 @@ export function useDestroyDialogState({
onDeleted,
}: UseDestroyDialogStateOptions) {
const { destroy, inspect, hostTarget } = useDestroyWorkspace(workspaceId);
const collections = useCollections();
const { markDeleting, clearDeleting } = useDeletingWorkspaces();
const { navigateAwayFromWorkspace } = useNavigateAwayFromWorkspace();

Expand Down Expand Up @@ -109,6 +113,7 @@ export function useDestroyDialogState({
async (force: boolean) => {
if (inFlight.current) return;
inFlight.current = true;
let keepDeleting = false;

setError(null);
onOpenChange(false);
Expand Down Expand Up @@ -136,6 +141,26 @@ export function useDestroyDialogState({
throw firstErr;
}
}
try {
if (typeof result.cloudDeleteTxid === "number") {
await collections.v2Workspaces.utils.awaitTxId(
result.cloudDeleteTxid,
ELECTRIC_WRITE_SYNC_TIMEOUT_MS,
);
}
await waitForWorkspaceDeleted(collections.v2Workspaces, workspaceId);
} catch (syncErr) {
keepDeleting = true;
onDeleted?.();
console.warn("[workspace-delete] delete synced slowly", {
workspaceId,
err: syncErr,
});
toast.warning(
`Deleted ${workspaceName}, but sync is taking longer than expected.`,
);
return;
}
for (const warning of result.warnings) toast.warning(warning);
onDeleted?.();
} catch (err) {
Expand All @@ -151,7 +176,9 @@ export function useDestroyDialogState({
);
}
} finally {
clearDeleting(workspaceId);
if (!keepDeleting) {
clearDeleting(workspaceId);
}
inFlight.current = false;
}
},
Expand All @@ -165,6 +192,7 @@ export function useDestroyDialogState({
markDeleting,
clearDeleting,
navigateAwayFromWorkspace,
collections,
],
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useRelayUrl } from "renderer/hooks/useRelayUrl";
import { getHostServiceWsToken } from "renderer/lib/host-service-auth";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
import {
applyPortEventsToHostPortsResult,
Expand Down Expand Up @@ -36,6 +37,7 @@ export function useDashboardSidebarPortsData(): {
const collections = useCollections();
const queryClient = useQueryClient();
const { activeHostUrl, machineId } = useLocalHostService();
const { isDeleting } = useDeletingWorkspaces();
const relayUrl = useRelayUrl();

const { data: hosts = [] } = useLiveQuery(
Expand All @@ -56,20 +58,29 @@ export function useDashboardSidebarPortsData(): {
id: workspaces.id,
name: workspaces.name,
hostId: workspaces.hostId,
isSynced: workspaces.$synced,
})),
[collections],
);

const queryableWorkspaces = useMemo(
() =>
workspaces.filter(
(workspace) => workspace.isSynced === true && !isDeleting(workspace.id),
),
[workspaces, isDeleting],
);

const hostsToQuery = useMemo(
() =>
deriveHostPortQueryTargets({
activeHostUrl,
hosts,
machineId,
relayUrl,
workspaces,
workspaces: queryableWorkspaces,
}),
[activeHostUrl, hosts, machineId, relayUrl, workspaces],
[activeHostUrl, hosts, machineId, relayUrl, queryableWorkspaces],
);

const queries = useQueries({
Expand Down Expand Up @@ -154,9 +165,9 @@ export function useDashboardSidebarPortsData(): {
() =>
groupDashboardSidebarPorts({
hostPortResults: queries.map((query) => query.data),
workspaces,
workspaces: queryableWorkspaces,
}),
[queries, workspaces],
[queries, queryableWorkspaces],
);

const totalPortCount = workspacePortGroups.reduce(
Expand Down
Loading