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
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/changes/branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const createBranchesRouter = () => {
defaultBranch: string;
checkedOutBranches: Record<string, string>;
worktreeBaseBranch: string | null;
currentBranch: string | null;
}> => {
assertRegisteredWorktree(input.worktreePath);

Expand Down Expand Up @@ -83,6 +84,7 @@ export const createBranchesRouter = () => {
defaultBranch,
checkedOutBranches,
worktreeBaseBranch: configuredBaseBranch ?? persistedBaseBranch,
currentBranch,
};
},
),
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/renderer/hooks/useDebouncedValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useEffect, useState } from "react";

export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
const timeoutId = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);

return () => {
clearTimeout(timeoutId);
};
}, [value, delayMs]);

return debouncedValue;
}
202 changes: 188 additions & 14 deletions apps/desktop/src/renderer/lib/trpc-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,37 +20,210 @@ export function setSkipNextHotkeysPersist(skip: boolean): void {
interface TrpcStorageConfig {
get: () => Promise<unknown>;
set: (input: unknown) => Promise<unknown>;
writeDebounceMs?: number;
}

const PENDING_SNAPSHOT_TTL_MS = 5 * 60 * 1000;
const LOCAL_SNAPSHOT_WRITE_DEBOUNCE_MS = 250;

function createTrpcStorageAdapter(config: TrpcStorageConfig): StateStorage {
const debounceMs = config.writeDebounceMs ?? 0;
let pendingValue: string | null = null;
let lastFlushedValue: string | null = null;
let flushTimer: ReturnType<typeof setTimeout> | null = null;
let isFlushing = false;
let pendingSnapshotValue: string | null = null;
let pendingSnapshotTimer: ReturnType<typeof setTimeout> | null = null;

const getPendingSnapshotKey = (name: string) => `${name}:pending`;
const getPendingSnapshotUpdatedAtKey = (name: string) =>
`${name}:pending:updatedAt`;
const pendingSnapshotDebounceMs =
debounceMs > 0
? Math.min(debounceMs, LOCAL_SNAPSHOT_WRITE_DEBOUNCE_MS)
: LOCAL_SNAPSHOT_WRITE_DEBOUNCE_MS;

const clearPendingSnapshot = (name: string, expectedValue?: string): void => {
try {
const pendingKey = getPendingSnapshotKey(name);
if (
expectedValue !== undefined &&
localStorage.getItem(pendingKey) !== expectedValue
) {
return;
}
localStorage.removeItem(pendingKey);
localStorage.removeItem(getPendingSnapshotUpdatedAtKey(name));
} catch (error) {
console.error("[trpc-storage] Failed to clear pending snapshot:", error);
}
};

const schedulePendingSnapshotPersist = (
name: string,
snapshot: string,
): void => {
pendingSnapshotValue = snapshot;

if (pendingSnapshotTimer) {
clearTimeout(pendingSnapshotTimer);
pendingSnapshotTimer = null;
}

pendingSnapshotTimer = setTimeout(() => {
pendingSnapshotTimer = null;
const valueToPersist = pendingSnapshotValue;
pendingSnapshotValue = null;
if (!valueToPersist) return;

try {
localStorage.setItem(getPendingSnapshotKey(name), valueToPersist);
localStorage.setItem(
getPendingSnapshotUpdatedAtKey(name),
String(Date.now()),
);
} catch (error) {
console.error(
"[trpc-storage] Failed to cache pending snapshot in localStorage:",
error,
);
}
}, pendingSnapshotDebounceMs);
};

const scheduleImmediateFlush = (name: string, snapshot: string): void => {
// Ensure pending snapshot eventually syncs to appState.
if (pendingValue === null) {
pendingValue = snapshot;
}
if (!isFlushing && flushTimer === null) {
flushTimer = setTimeout(() => {
flushTimer = null;
void flushPendingWrite(name);
}, 0);
}
};

const flushPendingWrite = async (name: string): Promise<void> => {
if (isFlushing || pendingValue === null) return;
const valueToFlush = pendingValue;
pendingValue = null;

if (valueToFlush === lastFlushedValue) {
return;
}

isFlushing = true;
try {
const parsed = JSON.parse(valueToFlush) as {
state: unknown;
version: number;
};
// Persist version in localStorage, bare state via tRPC.
localStorage.setItem(`${name}:version`, String(parsed.version));
await config.set(parsed.state);
lastFlushedValue = valueToFlush;
Comment on lines +122 to +125
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

Persist the sidecar version only after the tRPC write succeeds.

Line 49 updates ${name}:version before config.set(parsed.state) resolves. If the tRPC write fails, hydrate can read old state with a newer version and skip required migrations.

🔧 Suggested fix
-			// Persist version in localStorage, bare state via tRPC.
-			localStorage.setItem(`${name}:version`, String(parsed.version));
-			await config.set(parsed.state);
+			// Persist bare state via tRPC first, then advance sidecar version.
+			await config.set(parsed.state);
+			localStorage.setItem(`${name}:version`, String(parsed.version));
📝 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
// Persist version in localStorage, bare state via tRPC.
localStorage.setItem(`${name}:version`, String(parsed.version));
await config.set(parsed.state);
lastFlushedValue = valueToFlush;
// Persist bare state via tRPC first, then advance sidecar version.
await config.set(parsed.state);
localStorage.setItem(`${name}:version`, String(parsed.version));
lastFlushedValue = valueToFlush;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/renderer/lib/trpc-storage.ts` around lines 48 - 51, The
current sequence writes the sidecar version to localStorage before awaiting the
tRPC write, which can cause hydrate to skip migrations if
config.set(parsed.state) fails; move the localStorage.setItem(`${name}:version`,
String(parsed.version)) call to after the await config.set(parsed.state)
resolves (i.e., only after config.set succeeds), keeping assignment to
lastFlushedValue unchanged and ensuring any thrown errors prevent the version
bump; update the code around the config.set(parsed.state), localStorage.setItem,
and lastFlushedValue references accordingly.


// Cancel delayed snapshot write if this exact snapshot was already flushed.
if (pendingSnapshotValue === valueToFlush && pendingSnapshotTimer) {
clearTimeout(pendingSnapshotTimer);
pendingSnapshotTimer = null;
pendingSnapshotValue = null;
}
clearPendingSnapshot(name, valueToFlush);
} catch (error) {
console.error("[trpc-storage] Failed to set state:", error);
} finally {
isFlushing = false;
if (pendingValue !== null) {
if (debounceMs > 0) {
flushTimer = setTimeout(() => {
flushTimer = null;
void flushPendingWrite(name);
}, debounceMs);
} else {
void flushPendingWrite(name);
}
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
};

return {
getItem: async (name: string): Promise<string | null> => {
try {
const state = await config.get();
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 5, 2026

Choose a reason for hiding this comment

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

P1: Backend failure now prevents pending snapshot recovery. Moving config.get() before the localStorage read means any tRPC/IPC error causes the catch to return null, silently skipping crash-recovery data that exists in localStorage. The old code checked localStorage first, so a backend failure didn't block recovery. Consider isolating the config.get() in its own try-catch so pending snapshot reads still proceed when the backend is unavailable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/lib/trpc-storage.ts, line 154:

<comment>Backend failure now prevents pending snapshot recovery. Moving `config.get()` before the localStorage read means any tRPC/IPC error causes the catch to return `null`, silently skipping crash-recovery data that exists in localStorage. The old code checked localStorage first, so a backend failure didn't block recovery. Consider isolating the `config.get()` in its own try-catch so pending snapshot reads still proceed when the backend is unavailable.</comment>

<file context>
@@ -77,33 +151,55 @@ function createTrpcStorageAdapter(config: TrpcStorageConfig): StateStorage {
 		getItem: async (name: string): Promise<string | null> => {
 			try {
-				// Prefer the latest pending snapshot to avoid dropping state on fast exit.
+				const state = await config.get();
+				const version = Number.parseInt(
+					localStorage.getItem(`${name}:version`) ?? "0",
</file context>
Suggested change
const state = await config.get();
let state: unknown;
try {
state = await config.get();
} catch {
state = null;
}
Fix with Cubic

if (!state) return null;
// Version is stored in localStorage as a sidecar since the
// tRPC backend validates bare state and rejects envelopes.
const version = Number.parseInt(
localStorage.getItem(`${name}:version`) ?? "0",
10,
);
return JSON.stringify({ state, version });
const canonicalSnapshot = state
? JSON.stringify({ state, version })
: null;

const pendingSnapshot = localStorage.getItem(
getPendingSnapshotKey(name),
);
const pendingUpdatedAt = Number.parseInt(
localStorage.getItem(getPendingSnapshotUpdatedAtKey(name)) ?? "0",
10,
);
const pendingAgeMs =
Number.isFinite(pendingUpdatedAt) && pendingUpdatedAt > 0
? Date.now() - pendingUpdatedAt
: Number.POSITIVE_INFINITY;
const isPendingFresh = pendingAgeMs <= PENDING_SNAPSHOT_TTL_MS;

if (pendingSnapshot) {
if (!canonicalSnapshot) {
if (isPendingFresh) {
scheduleImmediateFlush(name, pendingSnapshot);
return pendingSnapshot;
}
clearPendingSnapshot(name);
return null;
}

if (pendingSnapshot === canonicalSnapshot) {
clearPendingSnapshot(name);
return canonicalSnapshot;
}

// Only trust pending snapshots that are very recent; otherwise
// canonical appState remains the source of truth.
if (isPendingFresh) {
scheduleImmediateFlush(name, pendingSnapshot);
return pendingSnapshot;
}

clearPendingSnapshot(name);
return canonicalSnapshot;
}

return canonicalSnapshot;
} catch (error) {
console.error("[trpc-storage] Failed to get state:", error);
return null;
Comment on lines 153 to 205
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

getItem should still return canonical state when localStorage access fails.

A localStorage exception inside this block currently drops to return null, even if config.get() already succeeded. That breaks hydration fallback and can surface as data loss under storage errors.

Suggested fix
 		getItem: async (name: string): Promise<string | null> => {
 			try {
 				const state = await config.get();
-				const version = Number.parseInt(
-					localStorage.getItem(`${name}:version`) ?? "0",
-					10,
-				);
+				let version = 0;
+				try {
+					version = Number.parseInt(
+						localStorage.getItem(`${name}:version`) ?? "0",
+						10,
+					);
+				} catch (error) {
+					console.error("[trpc-storage] Failed to read version sidecar:", error);
+				}
 				const canonicalSnapshot = state
 					? JSON.stringify({ state, version })
 					: null;
 
-				const pendingSnapshot = localStorage.getItem(
-					getPendingSnapshotKey(name),
-				);
-				const pendingUpdatedAt = Number.parseInt(
-					localStorage.getItem(getPendingSnapshotUpdatedAtKey(name)) ?? "0",
-					10,
-				);
+				let pendingSnapshot: string | null = null;
+				let pendingUpdatedAt = 0;
+				try {
+					pendingSnapshot = localStorage.getItem(getPendingSnapshotKey(name));
+					pendingUpdatedAt = Number.parseInt(
+						localStorage.getItem(getPendingSnapshotUpdatedAtKey(name)) ?? "0",
+						10,
+					);
+				} catch (error) {
+					console.error("[trpc-storage] Failed to read pending snapshot:", error);
+				}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/renderer/lib/trpc-storage.ts` around lines 153 - 205, The
catch currently returns null even when config.get() succeeded and
canonicalSnapshot exists; hoist or predeclare canonicalSnapshot (e.g., let
canonicalSnapshot: string | null = null) and assign it from JSON.stringify({
state, version }) after awaiting config.get(), then in the catch block return
canonicalSnapshot if it's non-null (and still log the error) instead of
unconditionally returning null so canonical state is preserved on localStorage
errors; reference config.get(), canonicalSnapshot, and the catch that currently
console.error(...); return null.

}
},
setItem: async (name: string, value: string): Promise<void> => {
try {
const parsed = JSON.parse(value) as {
state: unknown;
version: number;
};
// Persist version in localStorage, bare state via tRPC.
localStorage.setItem(`${name}:version`, String(parsed.version));
await config.set(parsed.state);
} catch (error) {
console.error("[trpc-storage] Failed to set state:", error);
if (value === pendingValue || value === lastFlushedValue) {
return;
}

pendingValue = value;
schedulePendingSnapshotPersist(name, value);
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}

if (debounceMs > 0) {
flushTimer = setTimeout(() => {
flushTimer = null;
void flushPendingWrite(name);
}, debounceMs);
} else {
void flushPendingWrite(name);
}
},
removeItem: async (_name: string): Promise<void> => {
Expand All @@ -68,6 +241,7 @@ export const trpcTabsStorage = createJSONStorage(() =>
get: () => electronTrpcClient.uiState.tabs.get.query(),
// biome-ignore lint/suspicious/noExplicitAny: Zustand persist passes unknown, tRPC expects typed input
set: (input) => electronTrpcClient.uiState.tabs.set.mutate(input as any),
writeDebounceMs: 300,
}),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import {
useIsWorkspaceInitializing,
} from "renderer/stores/workspace-init";

const EMPTY_HISTORY_STACK: string[] = [];

export const Route = createFileRoute(
"/_authenticated/_dashboard/workspace/$workspaceId/",
)({
Expand Down Expand Up @@ -117,9 +119,12 @@ function WorkspacePage() {
const showInitView = isInitializing || hasFailed || hasIncompleteInit;

const allTabs = useTabsStore((s) => s.tabs);
const activeTabIds = useTabsStore((s) => s.activeTabIds);
const tabHistoryStacks = useTabsStore((s) => s.tabHistoryStacks);
const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds);
const activeTabIdForWorkspace = useTabsStore(
(s) => s.activeTabIds[workspaceId] ?? null,
);
const tabHistoryStack = useTabsStore(
(s) => s.tabHistoryStacks[workspaceId] ?? EMPTY_HISTORY_STACK,
);
const {
addTab,
splitPaneAuto,
Expand Down Expand Up @@ -149,17 +154,19 @@ function WorkspacePage() {
return resolveActiveTabIdForWorkspace({
workspaceId,
tabs,
activeTabIds,
tabHistoryStacks,
activeTabIds: { [workspaceId]: activeTabIdForWorkspace },
tabHistoryStacks: { [workspaceId]: tabHistoryStack },
});
}, [workspaceId, tabs, activeTabIds, tabHistoryStacks]);
}, [workspaceId, tabs, activeTabIdForWorkspace, tabHistoryStack]);

const activeTab = useMemo(
() => (activeTabId ? tabs.find((t) => t.id === activeTabId) : null),
[activeTabId, tabs],
);

const focusedPaneId = activeTabId ? focusedPaneIds[activeTabId] : null;
const focusedPaneId = useTabsStore((s) =>
activeTabId ? (s.focusedPaneIds[activeTabId] ?? null) : null,
);

const { presets } = usePresets();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useState } from "react";
import { useDebouncedValue } from "renderer/hooks/useDebouncedValue";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { useSearchDialogStore } from "renderer/stores/search-dialog-state";
import { useTabsStore } from "renderer/stores/tabs/store";
Expand Down Expand Up @@ -45,19 +46,22 @@ export function useKeywordSearch({
(state) => state.setFiltersOpen,
);
const trimmedQuery = query.trim();
const debouncedQuery = useDebouncedValue(trimmedQuery, 150);
const isDebouncing =
trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery;

const { data: searchResults, isFetching } =
electronTrpc.filesystem.searchKeyword.useQuery(
{
rootPath: worktreePath ?? "",
query: trimmedQuery,
query: debouncedQuery,
includePattern,
excludePattern,
includeHidden: false,
limit: SEARCH_LIMIT,
},
{
enabled: open && Boolean(worktreePath) && trimmedQuery.length > 0,
enabled: open && Boolean(worktreePath) && debouncedQuery.length > 0,
staleTime: 1000,
placeholderData: (previous) => previous ?? [],
},
Expand Down Expand Up @@ -126,6 +130,6 @@ export function useKeywordSearch({
toggle,
selectMatch,
searchResults: searchResults ?? [],
isFetching,
isFetching: isFetching || isDebouncing,
};
}
Loading
Loading