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:
parent
fe5e7799f2
commit
a3da6d8071
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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', [])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue