Skip to content
Merged
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
111 changes: 111 additions & 0 deletions apps/desktop/docs/OPTIMISTIC_ELECTRIC_UPDATES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Optimistic Electric Updates

Desktop uses TanStack DB collections backed by Electric shapes for task and workspace data. The default write model is **optimistic online**, not offline-first.

## Decision

Use TanStack DB collection mutations for routine server-backed writes that already have stable local identity:

1. The UI calls `collection.insert`, `collection.update`, or `collection.delete`.
2. TanStack DB applies optimistic state immediately.
3. The collection handler persists through our API.
4. The API returns the PostgreSQL `txid` from the same database transaction as the write.
5. Electric streams that transaction back to the client.
6. TanStack DB drops the optimistic overlay or rolls it back if persistence fails.

This matches the documented TanStack DB mutation lifecycle and Electric collection txid strategy:

- TanStack DB mutations: https://tanstack.com/db/latest/docs/guides/mutations
- Electric collection txid matching: https://tanstack.com/db/latest/docs/collections/electric-collection

## Scope

Optimistic online is the right default for task edits, status changes, assignment changes, priority changes, title/description edits, and soft deletes. These are simple, single-record writes where immediate feedback matters and rollback is acceptable if the server rejects the write.

New task creation can remain server-confirmed while it relies on server-generated slugs, default status seeding, and navigation to the canonical record. Move creation to optimistic insert only after the client can provide all stable identity and routing fields up front.

Do not treat this as offline-first. If the API call cannot run, the transaction should fail, TanStack DB should roll back the optimistic state, and the UI should show a failure toast. We are not adding a durable outbox, replay queue, conflict resolver, or persisted collection state in this pass.

## Desktop Collection Matrix

Desktop currently has three write categories.

### Server-backed Electric writes

These collections have Electric mutation handlers in `CollectionsProvider/collections.ts`:

| Collection | Handlers | Current write surface | Behavior |
| --- | --- | --- | --- |
| `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. |
| `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. |

### Read-only Electric collections

These are Electric-backed in the renderer but have no collection mutation handlers and no direct renderer `collection.insert/update/delete` calls:

- `organizations`
- `taskStatuses`
- `projects`
- `v2Hosts`
- `v2Clients`
- `v2UsersHosts`
- `workspaces`
- `members`
- `users`
- `invitations`
- `integrationConnections`
- `subscriptions`
- `apiKeys`
- `sessionHosts`
- `githubRepositories`
- `githubPullRequests`
- `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/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 rename does use `collections.v2Workspaces.update` via `useOptimisticCollectionActions().v2Workspaces`, backed by `v2Workspace.update` returning `{ txid }` from the same Postgres transaction.

### LocalStorage collections

These are client-local TanStack DB collections. They are synchronous local persistence, not Electric/Postgres optimistic writes:

- `v2SidebarProjects` — sidebar project order/collapse/default app
- `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
- `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.

## Implementation Contract

Collection handlers must return `{ txid }` for server-backed Electric writes. The txid must come from `pg_current_xact_id()` inside the same transaction that performs the mutation. A txid captured before or after the write can leave `tx.isPersisted.promise` waiting for a transaction that Electric will never stream.

Feature code should not scatter direct server-backed collection mutations. Use `useOptimisticCollectionActions` and the relevant grouped action surface, such as `.tasks`, `.v2Workspaces`, `.v2Projects`, or `.chatSessions`, so every call site gets the same behavior:

- apply the optimistic collection mutation immediately
- attach a rejection handler to `tx.isPersisted.promise`
- show a user-visible error when persistence fails
- let TanStack DB own rollback

Use `{ optimistic: false }` only for exceptional flows where the UI must wait for server confirmation before revealing the result, such as a workflow that depends on a server-generated identifier or a multi-step server-side effect.

## Offline-First Boundary

Offline-first needs more than optimistic state. It needs durable local persistence, queued transactions, replay ordering, idempotency, and conflict handling. If we decide to support offline task writes later, design it as a separate feature with:

- a durable transaction queue
- client-generated stable IDs for created records
- idempotent API mutations
- explicit conflict policy per write type
- UI for pending and failed replays

Until then, Electric is our read/sync confirmation path and the API remains the write authority.
93 changes: 79 additions & 14 deletions apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const STORAGE_KEY_PREFIX = "terminal-buffer:";
const DIMS_KEY_PREFIX = "terminal-dims:";
const DEFAULT_COLS = 120;
const DEFAULT_ROWS = 32;
const RESIZE_DEBOUNCE_MS = 75;

// xterm's _keyDown calls stopPropagation after processing, so any chord we
// want the host (react-hotkeys-hook, Electron menu accelerators) or the shell
Expand Down Expand Up @@ -85,6 +86,7 @@ export interface TerminalRuntime {
wrapper: HTMLDivElement;
container: HTMLDivElement | null;
resizeObserver: ResizeObserver | null;
_disposeResizeObserver: (() => void) | null;
lastCols: number;
lastRows: number;
_disposeAddons: (() => void) | null;
Expand Down Expand Up @@ -211,11 +213,70 @@ function getParkingContainer(): HTMLDivElement {
return el;
}

function measureAndResize(runtime: TerminalRuntime) {
if (!hostIsVisible(runtime.container)) return;
function measureAndResize(runtime: TerminalRuntime): boolean {
if (!hostIsVisible(runtime.container)) return false;
const { terminal } = runtime;
const buffer = terminal.buffer.active;
const wasPinnedToBottom = buffer.viewportY >= buffer.baseY;
const savedViewportY = buffer.viewportY;
const prevCols = terminal.cols;
const prevRows = terminal.rows;

runtime.fitAddon.fit();
runtime.lastCols = runtime.terminal.cols;
runtime.lastRows = runtime.terminal.rows;
runtime.lastCols = terminal.cols;
runtime.lastRows = terminal.rows;

if (wasPinnedToBottom) {
terminal.scrollToBottom();
} else {
const targetY = Math.min(savedViewportY, terminal.buffer.active.baseY);
if (terminal.buffer.active.viewportY !== targetY) {
terminal.scrollToLine(targetY);
}
}

terminal.refresh(0, Math.max(0, terminal.rows - 1));

return terminal.cols !== prevCols || terminal.rows !== prevRows;
}

function createResizeScheduler(
runtime: TerminalRuntime,
onResize?: () => void,
): {
observe: ResizeObserverCallback;
dispose: () => void;
} {
let timeoutId: ReturnType<typeof setTimeout> | null = null;

const dispose = () => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
};

const run = () => {
timeoutId = null;
const changed = measureAndResize(runtime);
if (changed) onResize?.();
};

const observe: ResizeObserverCallback = (entries) => {
if (
entries.some(
(entry) =>
entry.contentRect.width <= 0 || entry.contentRect.height <= 0,
)
) {
dispose();
return;
}
dispose();
timeoutId = setTimeout(run, RESIZE_DEBOUNCE_MS);
};

return { observe, dispose };
}

export function createRuntime(
Expand Down Expand Up @@ -259,6 +320,7 @@ export function createRuntime(
wrapper,
container: null,
resizeObserver: null,
_disposeResizeObserver: null,
lastCols: cols,
lastRows: rows,
_disposeAddons: addonsResult.dispose,
Expand Down Expand Up @@ -287,9 +349,10 @@ export function attachToContainer(
containerWidth: container.clientWidth,
containerHeight: container.clientHeight,
});
measureAndResize(runtime);
if (measureAndResize(runtime)) onResize?.();

// Renderer may have skipped frames while the wrapper was detached.
// (refresh is now handled inside measureAndResize)
terminalRendererDebug.info(
"runtime-refresh",
{
Expand All @@ -301,15 +364,15 @@ export function attachToContainer(
fingerprint: ["terminal.renderer", "runtime-refresh"],
},
);
runtime.terminal.refresh(0, runtime.terminal.rows - 1);

runtime._disposeResizeObserver?.();
runtime._disposeResizeObserver = null;
runtime.resizeObserver?.disconnect();
const observer = new ResizeObserver(() => {
measureAndResize(runtime);
onResize?.();
});
const scheduler = createResizeScheduler(runtime, onResize);
const observer = new ResizeObserver(scheduler.observe);
observer.observe(container);
runtime.resizeObserver = observer;
runtime._disposeResizeObserver = scheduler.dispose;

runtime.terminal.focus();
}
Expand All @@ -329,6 +392,8 @@ export function detachFromContainer(runtime: TerminalRuntime) {
fingerprint: ["terminal.renderer", "runtime-detach-from-container"],
},
);
runtime._disposeResizeObserver?.();
runtime._disposeResizeObserver = null;
runtime.resizeObserver?.disconnect();
runtime.resizeObserver = null;
// Park instead of .remove() so xterm survives the React unmount —
Expand All @@ -341,7 +406,7 @@ export function updateRuntimeAppearance(
runtime: TerminalRuntime,
appearance: TerminalAppearance,
) {
const { terminal, fitAddon } = runtime;
const { terminal } = runtime;
terminal.options.theme = appearance.theme;

const fontChanged =
Expand All @@ -352,9 +417,7 @@ export function updateRuntimeAppearance(
terminal.options.fontFamily = appearance.fontFamily;
terminal.options.fontSize = appearance.fontSize;
if (hostIsVisible(runtime.container)) {
fitAddon.fit();
runtime.lastCols = terminal.cols;
runtime.lastRows = terminal.rows;
measureAndResize(runtime);
}
}
}
Expand All @@ -370,6 +433,8 @@ export function disposeRuntime(
}
runtime._disposeAddons?.();
runtime._disposeAddons = null;
runtime._disposeResizeObserver?.();
runtime._disposeResizeObserver = null;
runtime.resizeObserver?.disconnect();
runtime.resizeObserver = null;
runtime.wrapper.remove();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { alert } from "@superset/ui/atoms/Alert";
import { toast } from "@superset/ui/sonner";
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { apiTrpcClient } from "renderer/lib/api-trpc-client";
import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState";
import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions";
import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal";
import type { DashboardSidebarProject } from "../../../../types";

Expand All @@ -16,6 +16,7 @@ export function useDashboardSidebarProjectSectionActions({
}: UseDashboardSidebarProjectSectionActionsOptions) {
const openModal = useOpenNewWorkspaceModal();
const navigate = useNavigate();
const { v2Projects: projectActions } = useOptimisticCollectionActions();
const {
createSection,
deleteSection,
Expand All @@ -37,20 +38,11 @@ export function useDashboardSidebarProjectSectionActions({
setRenameValue(project.name);
};

const submitRename = async () => {
const submitRename = () => {
setIsRenaming(false);
const trimmed = renameValue.trim();
if (!trimmed || trimmed === project.name) return;
try {
await apiTrpcClient.v2Project.update.mutate({
id: project.id,
name: trimmed,
});
} catch (error) {
toast.error(
`Failed to rename: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
projectActions.renameProject(project.id, trimmed);
};

const handleOpenInFinder = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ export const DashboardSidebarCollapsedWorkspaceButton = forwardRef<
ref={ref}
className={cn(
"relative flex items-center justify-center size-8 rounded-md",
"hover:bg-muted/50 transition-colors cursor-pointer",
isActive && "bg-muted",
"transition-colors cursor-pointer",
isActive ? "bg-muted hover:bg-muted" : "hover:bg-muted/50",
className,
)}
{...props}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef<
onDoubleClick={onDoubleClick}
className={cn(
"relative flex w-full items-center pl-3 pr-2 text-left text-sm",
onClick && "cursor-pointer hover:bg-muted/50",
onClick &&
(isActive
? "cursor-pointer hover:bg-muted"
: "cursor-pointer hover:bg-muted/50"),
"group",
"py-1.5",
isActive && "bg-muted",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { toast } from "@superset/ui/sonner";
import { useMatchRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard";
import { apiTrpcClient } from "renderer/lib/api-trpc-client";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
import { electronTrpcClient } from "renderer/lib/trpc-client";
import { getDeleteFocusTargetWorkspaceId } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getDeleteFocusTargetWorkspaceId";
import { getFlattenedV2WorkspaceIds } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds";
import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation";
import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState";
import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";

Expand All @@ -30,6 +30,7 @@ export function useDashboardSidebarWorkspaceItemActions({
const collections = useCollections();
const { activeHostUrl } = useLocalHostService();
const { copyToClipboard } = useCopyToClipboard();
const { v2Workspaces: workspaceActions } = useOptimisticCollectionActions();
const { createSection, moveWorkspaceToSection, removeWorkspaceFromSidebar } =
useDashboardSidebarState();

Expand Down Expand Up @@ -61,20 +62,11 @@ export function useDashboardSidebarWorkspaceItemActions({
setRenameValue(workspaceName);
};

const submitRename = async () => {
const submitRename = () => {
setIsRenaming(false);
const trimmed = renameValue.trim();
if (!trimmed || trimmed === workspaceName) return;
try {
await apiTrpcClient.v2Workspace.update.mutate({
id: workspaceId,
name: trimmed,
});
} catch (error) {
toast.error(
`Failed to rename: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
workspaceActions.renameWorkspace(workspaceId, trimmed);
};

/**
Expand Down
Loading
Loading