feat(desktop): quiet suggestions the user has repeatedly ignored

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.
This commit is contained in:
Brooklyn Nicholson 2026-08-13 01:19:07 -05:00 committed by brooklyn!
parent fe5e7799f2
commit a3da6d8071
3 changed files with 133 additions and 2 deletions

View File

@ -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 })

View File

@ -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', [])
})
})

View File

@ -148,6 +148,50 @@ export function offerSuggestions(
// Last draft-provider results per session, merged with event offerings.
const draftOfferings = new Map<string, ComposerSuggestion[]>()
// ---------------------------------------------------------------------------
// 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<string, Map<string, number>>()
const shown = new Map<string, Set<string>>()
/** 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<string, number>()
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)
}