diff --git a/client/src/components/SidePanel/Agents/Version/VersionItem.tsx b/client/src/components/SidePanel/Agents/Version/VersionItem.tsx index 1e36ef706a..407860caa8 100644 --- a/client/src/components/SidePanel/Agents/Version/VersionItem.tsx +++ b/client/src/components/SidePanel/Agents/Version/VersionItem.tsx @@ -53,11 +53,12 @@ export default function VersionItem({ const date = getTimestampDate(version); const hasUpdatedAt = version.updatedAt != null; const hasCreatedAt = version.createdAt != null; - const relativeLabel = date - ? formatDistanceToNow(date, { addSuffix: true }) - : hasUpdatedAt || hasCreatedAt - ? localize('com_ui_agent_version_unknown_date') - : localize('com_ui_agent_version_no_date'); + const fallbackDateLabel = localize( + hasUpdatedAt || hasCreatedAt + ? 'com_ui_agent_version_unknown_date' + : 'com_ui_agent_version_no_date', + ); + const relativeLabel = date ? formatDistanceToNow(date, { addSuffix: true }) : fallbackDateLabel; const absoluteLabel = date ? date.toLocaleString() : relativeLabel; const toolsCount = countItems(version.tools); diff --git a/client/src/components/SidePanel/Builder/ActionsPanel.tsx b/client/src/components/SidePanel/Builder/ActionsPanel.tsx index 9cf39a2a62..edb6837ca8 100644 --- a/client/src/components/SidePanel/Builder/ActionsPanel.tsx +++ b/client/src/components/SidePanel/Builder/ActionsPanel.tsx @@ -71,7 +71,7 @@ export default function ActionsPanel({ }, }); - const { reset, watch } = methods; + const { reset } = methods; useEffect(() => { if (action?.metadata?.auth) { diff --git a/client/src/data-provider/Favorites.ts b/client/src/data-provider/Favorites.ts index 4bac514ab5..17b53513b5 100644 --- a/client/src/data-provider/Favorites.ts +++ b/client/src/data-provider/Favorites.ts @@ -62,13 +62,20 @@ export const useGetToolFavoritesQuery = ( export const useAddToolFavoriteMutation = () => { const queryClient = useQueryClient(); return useMutation((favorite: TToolFavorite) => dataService.addToolFavorite(favorite), { + /** Optimistic writes only apply over known server data. Before the list + * query has populated, seeding the cache from `[]` would make the toggled + * item look like the user's only favorite (and `cancelQueries` kills the + * initial fetch that would correct it) — so skip the write and let + * `onSettled` refetch the authoritative list instead. */ onMutate: async (favorite) => { await queryClient.cancelQueries([QueryKeys.toolFavorites]); const previous = queryClient.getQueryData([QueryKeys.toolFavorites]); - queryClient.setQueryData([QueryKeys.toolFavorites], (current) => { - const list = current ?? []; - return list.some((f) => sameFavorite(f, favorite)) ? list : [...list, favorite]; - }); + if (previous !== undefined) { + queryClient.setQueryData( + [QueryKeys.toolFavorites], + previous.some((f) => sameFavorite(f, favorite)) ? previous : [...previous, favorite], + ); + } return { previous }; }, onError: (_err, _favorite, context) => { @@ -76,6 +83,11 @@ export const useAddToolFavoriteMutation = () => { queryClient.setQueryData([QueryKeys.toolFavorites], context.previous); } }, + onSettled: (_data, _err, _favorite, context) => { + if (context?.previous === undefined) { + queryClient.invalidateQueries([QueryKeys.toolFavorites]); + } + }, }); }; @@ -85,9 +97,12 @@ export const useRemoveToolFavoriteMutation = () => { onMutate: async (favorite) => { await queryClient.cancelQueries([QueryKeys.toolFavorites]); const previous = queryClient.getQueryData([QueryKeys.toolFavorites]); - queryClient.setQueryData([QueryKeys.toolFavorites], (current) => - (current ?? []).filter((f) => !sameFavorite(f, favorite)), - ); + if (previous !== undefined) { + queryClient.setQueryData( + [QueryKeys.toolFavorites], + previous.filter((f) => !sameFavorite(f, favorite)), + ); + } return { previous }; }, onError: (_err, _favorite, context) => { @@ -95,5 +110,10 @@ export const useRemoveToolFavoriteMutation = () => { queryClient.setQueryData([QueryKeys.toolFavorites], context.previous); } }, + onSettled: (_data, _err, _favorite, context) => { + if (context?.previous === undefined) { + queryClient.invalidateQueries([QueryKeys.toolFavorites]); + } + }, }); }; diff --git a/client/src/hooks/__tests__/useToolFavorites.spec.tsx b/client/src/hooks/__tests__/useToolFavorites.spec.tsx index d2672bf2df..eb6ddf15ff 100644 --- a/client/src/hooks/__tests__/useToolFavorites.spec.tsx +++ b/client/src/hooks/__tests__/useToolFavorites.spec.tsx @@ -98,6 +98,25 @@ describe('useToolFavorites', () => { expect(result.current.isFavorite({ kind: 'action', id: 'a1' })).toBe(false); }); + test('starring before the list loads refetches instead of hiding existing favorites', async () => { + mockGetToolFavorites + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockResolvedValueOnce([...seeded, { itemType: 'skill', itemId: 's1' }]); + + const { result } = renderHook(() => useToolFavorites(), { wrapper: createWrapper() }); + + await act(async () => { + await result.current.toggle({ kind: 'skill', id: 's1' }); + }); + + await waitFor(() => + expect(result.current.favoriteKeys).toEqual( + new Set(['tool:dalle', 'mcp:everything', 'skill:s1']), + ), + ); + expect(mockGetToolFavorites).toHaveBeenCalledTimes(2); + }); + test('shows the item-worded cap toast and rolls back on rejection', async () => { mockAddToolFavorite.mockRejectedValue({ response: { data: { code: 'MAX_FAVORITES_EXCEEDED', limit: 100 } },