From a3da6d8071d4467d730a8c7f5183aa9e43019626 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 13 Aug 2026 01:19:07 -0500 Subject: [PATCH] feat(desktop): quiet suggestions the user has repeatedly ignored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bus now keeps a session-scoped declined ledger: a pill the user watched appear and let die three times stops re-offering for the rest of the session. Acting on a pill clears its count, so a suggestion that was taken can come back for the next trigger. In-memory on purpose — a fresh session is a fresh chance. --- .../app/chat/composer/suggestion-pills.tsx | 5 +- .../src/store/composer-suggestions.test.ts | 83 +++++++++++++++++++ .../desktop/src/store/composer-suggestions.ts | 47 ++++++++++- 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/store/composer-suggestions.test.ts diff --git a/apps/desktop/src/app/chat/composer/suggestion-pills.tsx b/apps/desktop/src/app/chat/composer/suggestion-pills.tsx index 00f9c0c21a345..6165c08d0db80 100644 --- a/apps/desktop/src/app/chat/composer/suggestion-pills.tsx +++ b/apps/desktop/src/app/chat/composer/suggestion-pills.tsx @@ -7,7 +7,7 @@ import { triggerHaptic } from '@/lib/haptics' import { brandFor, brandGlyphStyle } from '@/lib/mcp-brands' import { useSessionSlice } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' -import { $composerSuggestionsBySession, suggestionKey } from '@/store/composer-suggestions' +import { $composerSuggestionsBySession, markSuggestionInvoked, suggestionKey } from '@/store/composer-suggestions' /** * The composer suggestion strip — generic pills fed by the suggestion bus @@ -53,6 +53,9 @@ export function SuggestionPills({ sessionId }: { sessionId: null | string }) { cancels.set(key, false) setPhase(key, 'working') triggerHaptic('selection') + // Acting on a pill clears its ignored-count in the bus's declined + // ledger — its later withdrawal is success, not a strike. + markSuggestionInvoked(sessionId, key) try { await suggestion.invoke({ cancelled: () => cancels.get(key) === true, sessionId }) diff --git a/apps/desktop/src/store/composer-suggestions.test.ts b/apps/desktop/src/store/composer-suggestions.test.ts new file mode 100644 index 0000000000000..2dc3d6337ba3b --- /dev/null +++ b/apps/desktop/src/store/composer-suggestions.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' + +import { + $composerSuggestionsBySession, + type ComposerSuggestion, + markSuggestionInvoked, + offerSuggestions, + suggestionKey +} from './composer-suggestions' + +const suggestion = (id: string, provider = 'test'): ComposerSuggestion => ({ + doneLabel: 'done', + doneTip: 'done', + id, + invoke: async () => {}, + label: id, + provider, + tip: 'because', + workingLabel: 'working', + workingTip: 'working' +}) + +const pillsFor = (sessionId: string) => ($composerSuggestionsBySession.get()[sessionId] ?? []).map(s => s.id) + +describe('composer suggestion bus', () => { + it('publishes event offerings per session and withdraws on empty offer', () => { + offerSuggestions('s1', 'test', [suggestion('a')]) + + expect(pillsFor('s1')).toEqual(['a']) + expect(pillsFor('s2')).toEqual([]) + + offerSuggestions('s1', 'test', []) + + expect(pillsFor('s1')).toEqual([]) + }) + + it('caps merged suggestions at two', () => { + offerSuggestions('s3', 'test', [suggestion('a'), suggestion('b'), suggestion('c')]) + + expect(pillsFor('s3')).toHaveLength(2) + + offerSuggestions('s3', 'test', []) + }) + + it('dedupes by provider-namespaced key across providers', () => { + offerSuggestions('s4', 'p1', [suggestion('same', 'p1')]) + offerSuggestions('s4', 'p2', [suggestion('same', 'p2')]) + + // Different providers, same id — distinct keys, both allowed. + expect(pillsFor('s4')).toEqual(['same', 'same']) + + offerSuggestions('s4', 'p1', []) + offerSuggestions('s4', 'p2', []) + }) + + it('quiets a suggestion after it is repeatedly withdrawn uninvoked', () => { + // Three offer/withdraw cycles = three strikes. + for (let i = 0; i < 3; i += 1) { + offerSuggestions('s5', 'test', [suggestion('naggy')]) + offerSuggestions('s5', 'test', []) + } + + offerSuggestions('s5', 'test', [suggestion('naggy')]) + + expect(pillsFor('s5')).toEqual([]) + + offerSuggestions('s5', 'test', []) + }) + + it('an invoked suggestion never accrues strikes', () => { + for (let i = 0; i < 3; i += 1) { + offerSuggestions('s6', 'test', [suggestion('used')]) + markSuggestionInvoked('s6', suggestionKey(suggestion('used'))) + offerSuggestions('s6', 'test', []) + } + + offerSuggestions('s6', 'test', [suggestion('used')]) + + expect(pillsFor('s6')).toEqual(['used']) + + offerSuggestions('s6', 'test', []) + }) +}) diff --git a/apps/desktop/src/store/composer-suggestions.ts b/apps/desktop/src/store/composer-suggestions.ts index d4247604f4d05..d0724ddd88748 100644 --- a/apps/desktop/src/store/composer-suggestions.ts +++ b/apps/desktop/src/store/composer-suggestions.ts @@ -148,6 +148,50 @@ export function offerSuggestions( // Last draft-provider results per session, merged with event offerings. const draftOfferings = new Map() +// --------------------------------------------------------------------------- +// Declined ledger (VS Code's hardest-won recommendation lesson: never keep +// re-firing at someone who has seen the offer and not taken it) +// --------------------------------------------------------------------------- + +// Times a suggestion key was WITHDRAWN without ever being invoked, per +// session. A pill the user watched appear and let die N times is a declined +// offer — stop making it for the rest of the session. Session-scoped and +// in-memory on purpose: a fresh session is a fresh chance, and acting on a +// pill clears its count. +const IGNORED_LIMIT = 3 +const ignoredCounts = new Map>() +const shown = new Map>() + +/** The pill strip marks a suggestion invoked so its withdrawal isn't + * miscounted as the user ignoring it. */ +export function markSuggestionInvoked(sessionId: string | null | undefined, key: string): void { + ignoredCounts.get(keyFor(sessionId))?.delete(key) + shown.get(keyFor(sessionId))?.delete(key) +} + +const quieted = (sessionKey: string, key: string): boolean => + (ignoredCounts.get(sessionKey)?.get(key) ?? 0) >= IGNORED_LIMIT + +// Suggestions visible in the last publish that vanish uninvoked get a strike. +function recordWithdrawals(sessionKey: string, next: readonly ComposerSuggestion[]): void { + const previous = shown.get(sessionKey) + + if (previous) { + const surviving = new Set(next.map(suggestionKey)) + + for (const key of previous) { + if (!surviving.has(key)) { + const counts = ignoredCounts.get(sessionKey) ?? new Map() + + counts.set(key, (counts.get(key) ?? 0) + 1) + ignoredCounts.set(sessionKey, counts) + } + } + } + + shown.set(sessionKey, new Set(next.map(suggestionKey))) +} + /** Event offerings first (they carry session/tool state, stronger signal * than draft keywords), then draft matches, capped. */ function publish(sessionId: string | null): void { @@ -160,7 +204,7 @@ function publish(sessionId: string | null): void { for (const suggestion of [...event, ...draft]) { const k = suggestionKey(suggestion) - if (!seen.has(k)) { + if (!seen.has(k) && !quieted(key, k)) { seen.add(k) merged.push(suggestion) } @@ -170,6 +214,7 @@ function publish(sessionId: string | null): void { } } + recordWithdrawals(key, merged) write(sessionId, merged) }