stop swallowing dashboard persist failures

The optimistic cache made a refused write invisible: the UI stayed correct until the next reload, at
which point the change was simply gone. That is tolerable for a pane size and not for a chat panel's
agent name, which is the address a peer agent is delivered to.

Roll back only if the cache still holds exactly what we wrote — writes to one key overlap freely (a
window resize fires one per group) and rolling back over a later successful write would turn one
failure into two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 08:48:44 +00:00
co-authored by Claude Opus 5
parent 85452d1afa
commit 70c2f0811d
+42 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import type { UserState } from './useSettings'; import type { UserState } from './useSettings';
@@ -29,7 +30,9 @@ export function useDashboardState<T>(key: string, defaultValue: T) {
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {}; const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
if (!(key in currentState)) { if (!(key in currentState)) {
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: defaultValue }); queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: defaultValue });
clientRef.current.patch('/dashboards', { [key]: defaultValue }).catch(() => { }); clientRef.current
.patch('/dashboards', { [key]: defaultValue })
.catch((err: unknown) => revert(queryClient, key, defaultValue, undefined, err));
} }
}, [isSuccess, key, defaultValue, queryClient]); }, [isSuccess, key, defaultValue, queryClient]);
@@ -43,10 +46,47 @@ export function useDashboardState<T>(key: string, defaultValue: T) {
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue }); queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
clientRef.current.patch('/dashboards', { [key]: newValue }).catch(() => { }); clientRef.current
.patch('/dashboards', { [key]: newValue })
.catch((err: unknown) =>
revert(queryClient, key, newValue, key in currentState ? currentValue : undefined, err),
);
}, },
[key, defaultValue, queryClient], [key, defaultValue, queryClient],
); );
return { key, value, setValue, isLoaded: isSuccess }; return { key, value, setValue, isLoaded: isSuccess };
} }
/**
* Undo an optimistic write the server refused, and say so.
*
* The optimistic cache is what made these failures invisible: the UI stayed correct until the next
* reload, at which point the change was simply gone. That is worst for the things this store holds that
* are not merely cosmetic — a chat panel's `config.agentName` is the address a peer agent is delivered
* to, so a silently-dropped write leaves a panel that answers to a name it will not have tomorrow.
*
* Reverts only if the cache still holds exactly what we wrote. Writes to one key overlap freely (a
* window resize fires one per group), and rolling back over a later successful write would turn one
* failure into two.
*/
function revert(
queryClient: ReturnType<typeof useQueryClient>,
key: string,
attempted: unknown,
previous: unknown,
err: unknown,
): void {
const state = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
if (state[key] === attempted) {
const next = { ...state };
if (previous === undefined) delete next[key];
else next[key] = previous;
queryClient.setQueryData(QUERY_KEY, next);
}
console.error(`[dashboard-state] failed to persist "${key}"`, err);
toast.error('Could not save your layout', {
description: `"${key}" was not written and has been rolled back. ${err instanceof Error ? err.message : String(err)}`,
});
}