diff --git a/apps/desktop/src/store/composer-suggestions.test.ts b/apps/desktop/src/store/composer-suggestions.test.ts index 2dc3d6337b..e76c56a2f4 100644 --- a/apps/desktop/src/store/composer-suggestions.test.ts +++ b/apps/desktop/src/store/composer-suggestions.test.ts @@ -80,4 +80,47 @@ describe('composer suggestion bus', () => { offerSuggestions('s6', 'test', []) }) + + it('replaces an offer whose rendered copy changed under the same key', () => { + offerSuggestions('s7', 'test', [{ ...suggestion('linear'), tip: 'because you mentioned “linear”' }]) + offerSuggestions('s7', 'test', [{ ...suggestion('linear'), tip: 'because you pasted linear.app' }]) + + // Same key, new trigger — the strip must paint the new reason, not the + // first one it ever saw. + expect(($composerSuggestionsBySession.get().s7 ?? []).map(s => s.tip)).toEqual(['because you pasted linear.app']) + + offerSuggestions('s7', 'test', []) + }) + + it('re-offering the same key swaps in the fresh invoke closure', async () => { + const calls: string[] = [] + + const offer = (tag: string) => + offerSuggestions('s8', 'test', [ + { ...suggestion('linear'), invoke: async () => void calls.push(tag), label: `Add linear ${tag}` } + ]) + + offer('first') + offer('second') + + await ($composerSuggestionsBySession.get().s8 ?? [])[0]!.invoke({ cancelled: () => false, sessionId: 's8' }) + + // A pinned first object means the pill runs work built for a draft the + // user has since changed. + expect(calls).toEqual(['second']) + + offerSuggestions('s8', 'test', []) + }) + + it('keeps the array reference when nothing the pill paints changed', () => { + offerSuggestions('s9', 'test', [suggestion('linear')]) + + const first = $composerSuggestionsBySession.get().s9 + + offerSuggestions('s9', 'test', [suggestion('linear')]) + + expect($composerSuggestionsBySession.get().s9).toBe(first) + + offerSuggestions('s9', 'test', []) + }) }) diff --git a/apps/desktop/src/store/composer-suggestions.ts b/apps/desktop/src/store/composer-suggestions.ts index d0724ddd88..a241c3395a 100644 --- a/apps/desktop/src/store/composer-suggestions.ts +++ b/apps/desktop/src/store/composer-suggestions.ts @@ -67,8 +67,31 @@ export const $composerSuggestionsBySession = atom sessionId ?? '' +// Everything the pill actually paints. Compared field-by-field rather than by +// key alone: a provider rebuilds its suggestion objects on every sample, so +// key equality is true constantly, and treating that as "no change" pins the +// FIRST object forever — the strip then paints a stale tip and, worse, calls a +// stale `invoke` closure. Comparing the rendered copy keeps the cheap bail-out +// for the common case (same draft, same match) while letting a genuinely +// changed offer through. +const RENDERED: readonly (keyof ComposerSuggestion)[] = [ + 'brand', + 'doneLabel', + 'doneTip', + 'icon', + 'label', + 'tip', + 'workingLabel', + 'workingTip' +] + const sameSuggestions = (a: readonly ComposerSuggestion[], b: readonly ComposerSuggestion[]) => - a.length === b.length && a.every((x, i) => suggestionKey(x) === suggestionKey(b[i]!)) + a.length === b.length && + a.every((x, i) => { + const y = b[i]! + + return suggestionKey(x) === suggestionKey(y) && RENDERED.every(field => x[field] === y[field]) + }) function write(sessionId: string | null | undefined, suggestions: ComposerSuggestion[]): void { const key = keyFor(sessionId)