From 40512011b6f05da1c45df3122b7a8222e2e0e2ba Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 7 Oct 2026 19:22:43 +0530 Subject: [PATCH] fix(desktop): setup cards stop redrawing in a loop useSetupRows built a new row array on every render for the app-owned lists (accent, layout, theme). That recreated the card's stage callback, whose effect wrote $setupChooseStages, which re-rendered the card. The rows are memoized on their inputs now, and stageSetupChoose skips the atom write when a patch changes nothing. --- .../assistant-ui/clarify/setup-rows.ts | 16 +++++++++++----- apps/desktop/src/store/clarify.test.ts | 15 +++++++++++++++ apps/desktop/src/store/clarify.ts | 14 +++++++++++++- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/clarify/setup-rows.ts b/apps/desktop/src/components/assistant-ui/clarify/setup-rows.ts index 5d9792e3ae..f651c0eba6 100644 --- a/apps/desktop/src/components/assistant-ui/clarify/setup-rows.ts +++ b/apps/desktop/src/components/assistant-ui/clarify/setup-rows.ts @@ -1,4 +1,5 @@ import type { ConnectorRow, SetupChooseKind, SetupChooseOption } from '@hermes/shared' +import { useMemo } from 'react' import { MODE_OPTIONS } from '@/app/settings/constants' import { $chatLayoutPicked, assembleChatOnboarding, snapshotChatLayout } from '@/components/onboarding-chat/assembly' @@ -132,15 +133,20 @@ export function useSetupRows(setup: null | SetupChooseSpec, storedId: null | str const connectors = useConnectorRows(setup?.options === null && setup.kind === 'connectors' ? storedId : null) const plugins = useOnboardingPluginList(setup?.options === null && setup.kind === 'plugins' ? storedId : null) - if (!setup) { - return [] - } + // Same inputs must return the same array: the pending card's stage callback and store write key off it. + return useMemo(() => { + if (!setup) { + return NO_ROWS + } - const rows = setup.options ?? APP_ROWS[setup.kind]({ connectors, dark: renderedMode === 'dark', plugins, t }) + const rows = setup.options ?? APP_ROWS[setup.kind]({ connectors, dark: renderedMode === 'dark', plugins, t }) - return setup.kind === 'theme' && rows ? rows.filter(row => THEME_TILES.includes(row.id)) : rows + return setup.kind === 'theme' && rows ? rows.filter(row => THEME_TILES.includes(row.id)) : rows + }, [connectors, plugins, renderedMode, setup, t]) } +const NO_ROWS: SetupRow[] = [] + export function useSetupLabel( kind: SetupChooseKind, options: null | SetupRow[], diff --git a/apps/desktop/src/store/clarify.test.ts b/apps/desktop/src/store/clarify.test.ts index 70e4a55dca..1b259d8917 100644 --- a/apps/desktop/src/store/clarify.test.ts +++ b/apps/desktop/src/store/clarify.test.ts @@ -181,6 +181,21 @@ describe('answerSetupCard', () => { expect(hasClarifyRequest('session-a')).toBe(false) expect(look).toBe('#0000ff') }) + + it('does not notify subscribers when a staged patch changes nothing', () => { + const preview = () => undefined + const listener = vi.fn() + + stageSetupChoose('req-a', { labels: ACCENTS, preview }) + const before = $setupChooseStages.get() + const unlisten = $setupChooseStages.listen(listener) + + stageSetupChoose('req-a', { labels: ACCENTS, preview }) + unlisten() + + expect($setupChooseStages.get()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) }) describe('normalizeChoices', () => { diff --git a/apps/desktop/src/store/clarify.ts b/apps/desktop/src/store/clarify.ts index fc6e9e7a84..621bcdbbce 100644 --- a/apps/desktop/src/store/clarify.ts +++ b/apps/desktop/src/store/clarify.ts @@ -283,7 +283,19 @@ export const setupChooseStage = (requestId: string): SetupChooseStage => $setupChooseStages.get()[requestId] ?? EMPTY_SETUP_STAGE export function stageSetupChoose(requestId: string, patch: Partial): void { - $setupChooseStages.set({ ...$setupChooseStages.get(), [requestId]: { ...setupChooseStage(requestId), ...patch } }) + const current = setupChooseStage(requestId) + + // A no-op patch keeps the atom reference so subscribers do not rerender. + const unchanged = Object.entries(patch).every(([key, value]) => + // SAFETY: the entries of a Partial carry only SetupChooseStage keys. + Object.is(current[key as keyof SetupChooseStage], value) + ) + + if (unchanged && requestId in $setupChooseStages.get()) { + return + } + + $setupChooseStages.set({ ...$setupChooseStages.get(), [requestId]: { ...current, ...patch } }) } export function commitSetupChoose(requestId: string): void {