diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx index 260bfe17e..11e550b4b 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx @@ -10,6 +10,11 @@ import { LinearAgentSkillSetupPrompt, _linearAgentSkillSetupPromptInternalsForTests } from './LinearAgentSkillSetupPrompt' +import { + dismissLinearAgentSkillSetupReminderToast, + resetLinearAgentSkillSetupReminderToastForRuntime +} from './linear-agent-skill-setup-reminder-toast' +import { getExistingLinearAgentSkillSetupReminderState } from './linear-agent-skill-setup-reminders' const HOST_DISMISS_STORAGE_KEY = 'orca.linearTicketsSkill.setupDismissed.host' @@ -149,6 +154,11 @@ type ReminderToastAction = { onClick?: () => void } +type ReminderToastCallbacks = { + onAutoClose?: () => void + onDismiss?: () => void +} + describe('LinearAgentSkillSetupPrompt reminder toast', () => { beforeEach(() => { mocks.skillState.installed = false @@ -289,6 +299,38 @@ describe('LinearAgentSkillSetupPrompt reminder toast', () => { ) }) + it.each(['onAutoClose', 'onDismiss'] as const)( + 'clears active reminder state after %s', + async (callbackName) => { + await snoozeInitialModal({ linked: true, remote: false, surface: 'modal' }) + await renderPrompt({ linked: true, remote: false, surface: 'modal' }) + + const callbacks = vi.mocked(toast.warning).mock.calls.at(-1)?.[1] as + | ReminderToastCallbacks + | undefined + callbacks?.[callbackName]?.() + + expect( + getExistingLinearAgentSkillSetupReminderState(HOST_DISMISS_STORAGE_KEY)?.activeToastId + ).toBeUndefined() + } + ) + + it('does not recreate missing reminder state during toast cleanup', () => { + dismissLinearAgentSkillSetupReminderToast(HOST_DISMISS_STORAGE_KEY) + + expect(getExistingLinearAgentSkillSetupReminderState(HOST_DISMISS_STORAGE_KEY)).toBeUndefined() + expect(toast.dismiss).toHaveBeenCalledWith( + 'linear-agent-skill-setup-orca.linearTicketsSkill.setupDismissed.host' + ) + }) + + it('does not create missing reminder state when resetting a runtime', () => { + resetLinearAgentSkillSetupReminderToastForRuntime(HOST_DISMISS_STORAGE_KEY) + + expect(getExistingLinearAgentSkillSetupReminderState(HOST_DISMISS_STORAGE_KEY)).toBeUndefined() + }) + it('dismisses an active reminder toast on permanent dismissal', async () => { await snoozeInitialModal({ linked: true, remote: false, surface: 'modal' }) await renderPrompt({ linked: true, remote: false, surface: 'modal' }) @@ -305,6 +347,8 @@ describe('LinearAgentSkillSetupPrompt reminder toast', () => { }) expect(window.localStorage.getItem(HOST_DISMISS_STORAGE_KEY)).toBe('1') - expect(toast.dismiss).not.toHaveBeenCalled() + expect(toast.dismiss).toHaveBeenCalledWith( + 'linear-agent-skill-setup-orca.linearTicketsSkill.setupDismissed.host' + ) }) }) diff --git a/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminder-toast.ts b/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminder-toast.ts index a2b858560..801857cd8 100644 --- a/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminder-toast.ts +++ b/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminder-toast.ts @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { LINEAR_AGENT_SKILL_SETUP_TOAST_LIMIT, createLinearAgentSkillSetupActivationId, + getExistingLinearAgentSkillSetupReminderState, getLinearAgentSkillSetupReminderState, resetLinearAgentSkillSetupReminderState } from './linear-agent-skill-setup-reminders' @@ -27,21 +28,29 @@ export function snoozeLinearAgentSkillSetupReminderToast(localDismissStorageKey: } export function dismissLinearAgentSkillSetupReminderToast(localDismissStorageKey: string): void { - const state = getLinearAgentSkillSetupReminderState(localDismissStorageKey) - if (state.activeToastId !== undefined) { - toast.dismiss(state.activeToastId) + const state = getExistingLinearAgentSkillSetupReminderState(localDismissStorageKey) + // Why: the state may already be evicted, but the deterministic id still lets + // cleanup dismiss a visible toast without recreating a cache entry. + toast.dismiss(getLinearAgentSkillSetupReminderToastId(localDismissStorageKey)) + if (state) { state.activeToastId = undefined } } +function getLinearAgentSkillSetupReminderToastId(localDismissStorageKey: string): string { + return `linear-agent-skill-setup-${localDismissStorageKey}` +} + export function resetLinearAgentSkillSetupReminderToastForRuntime( localDismissStorageKey: string ): void { - const state = getLinearAgentSkillSetupReminderState(localDismissStorageKey) - state.modalShown = false - state.snoozed = false - state.toastCount = 0 - state.lastToastActivationId = undefined + const state = getExistingLinearAgentSkillSetupReminderState(localDismissStorageKey) + if (state) { + state.modalShown = false + state.snoozed = false + state.toastCount = 0 + state.lastToastActivationId = undefined + } dismissLinearAgentSkillSetupReminderToast(localDismissStorageKey) } @@ -89,15 +98,24 @@ export function useLinearAgentSkillSetupReminderToast({ } state.toastCount += 1 state.lastToastActivationId = activationId - const toastId = `linear-agent-skill-setup-${localDismissStorageKey}` + const toastId = getLinearAgentSkillSetupReminderToastId(localDismissStorageKey) + const clearActiveToast = (): void => { + const currentState = getExistingLinearAgentSkillSetupReminderState(localDismissStorageKey) + if (currentState?.activeToastId === toastId) { + currentState.activeToastId = undefined + } + } const openSetupFromToast = (): void => { toast.dismiss(toastId) - state.activeToastId = undefined + clearActiveToast() openSetupDialog() } - state.activeToastId = toast.warning(toastTitle, { + state.activeToastId = toastId + toast.warning(toastTitle, { id: toastId, description: toastDescription, + onDismiss: clearActiveToast, + onAutoClose: clearActiveToast, action: { label: translate('auto.components.sidebar.LinearAgentSkillSetupPrompt.setup', 'Set up'), onClick: openSetupFromToast @@ -120,9 +138,14 @@ export function useLinearAgentSkillSetupReminderToast({ }, [localDismissStorageKey, missingSetup]) useEffect( - () => () => { - dismissLinearAgentSkillSetupReminderToast(localDismissStorageKey) + () => { + if (surface !== 'modal') { + return + } + return () => { + dismissLinearAgentSkillSetupReminderToast(localDismissStorageKey) + } }, - [localDismissStorageKey] + [localDismissStorageKey, surface] ) } diff --git a/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts b/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts new file mode 100644 index 000000000..6fce02713 --- /dev/null +++ b/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS, + createLinearAgentSkillSetupActivationId, + getExistingLinearAgentSkillSetupReminderState, + getLinearAgentSkillSetupReminderState, + getLinearAgentSkillSetupReminderStateCountForTests, + hasLinearAgentSkillSetupReminderStateForTests, + resetLinearAgentSkillSetupReminderState +} from './linear-agent-skill-setup-reminders' + +afterEach(() => { + resetLinearAgentSkillSetupReminderState() +}) + +describe('linear agent skill setup reminders', () => { + it('bounds runtime reminder state through prolonged key churn', () => { + const churnedRuntimeCount = MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS * 4 + for (let i = 0; i < churnedRuntimeCount; i += 1) { + getLinearAgentSkillSetupReminderState(`runtime-${i}`) + } + + expect(getLinearAgentSkillSetupReminderStateCountForTests()).toBe( + MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS + ) + expect(hasLinearAgentSkillSetupReminderStateForTests('runtime-0')).toBe(false) + expect(hasLinearAgentSkillSetupReminderStateForTests(`runtime-${churnedRuntimeCount - 1}`)).toBe( + true + ) + }) + + it('retains recently reused keys while trimming', () => { + getLinearAgentSkillSetupReminderState('keep').modalShown = true + for (let i = 0; i < MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS - 1; i += 1) { + getLinearAgentSkillSetupReminderState(`runtime-${i}`).toastCount = 1 + } + + expect(getLinearAgentSkillSetupReminderState('keep').modalShown).toBe(true) + + getLinearAgentSkillSetupReminderState('runtime-new') + + expect(getLinearAgentSkillSetupReminderStateCountForTests()).toBe( + MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS + ) + expect(hasLinearAgentSkillSetupReminderStateForTests('runtime-0')).toBe(false) + expect(getLinearAgentSkillSetupReminderState('keep').modalShown).toBe(true) + }) + + it('keeps active toast state ahead of inactive stale entries when trimming', () => { + getLinearAgentSkillSetupReminderState('toast-active').activeToastId = 'toast-id' + for (let i = 0; i < MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS; i += 1) { + getLinearAgentSkillSetupReminderState(`runtime-${i}`) + } + + expect(getLinearAgentSkillSetupReminderStateCountForTests()).toBe( + MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS + ) + expect(hasLinearAgentSkillSetupReminderStateForTests('toast-active')).toBe(true) + expect(hasLinearAgentSkillSetupReminderStateForTests('runtime-0')).toBe(false) + }) + + it('retains a new key when every existing entry has an active toast', () => { + for (let i = 0; i < MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS; i += 1) { + getLinearAgentSkillSetupReminderState(`runtime-${i}`).activeToastId = `toast-${i}` + } + + const newState = getLinearAgentSkillSetupReminderState('runtime-new') + + expect(getLinearAgentSkillSetupReminderStateCountForTests()).toBe( + MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS + ) + expect(hasLinearAgentSkillSetupReminderStateForTests('runtime-0')).toBe(false) + expect(getExistingLinearAgentSkillSetupReminderState('runtime-new')).toBe(newState) + }) + + it('does not create reminder state when peeking at a missing key', () => { + expect(getExistingLinearAgentSkillSetupReminderState('missing')).toBeUndefined() + expect(getLinearAgentSkillSetupReminderStateCountForTests()).toBe(0) + }) + + it('resets activation ids with reminder state for tests', () => { + expect(createLinearAgentSkillSetupActivationId()).toBe('linear-agent-skill-setup-0') + expect(createLinearAgentSkillSetupActivationId()).toBe('linear-agent-skill-setup-1') + + resetLinearAgentSkillSetupReminderState() + + expect(createLinearAgentSkillSetupActivationId()).toBe('linear-agent-skill-setup-0') + }) +}) diff --git a/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.ts b/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.ts index f9bc8c0c2..4b60ae410 100644 --- a/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.ts +++ b/src/renderer/src/components/sidebar/linear-agent-skill-setup-reminders.ts @@ -1,16 +1,35 @@ export const LINEAR_AGENT_SKILL_SETUP_TOAST_LIMIT = 3 +export const MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS = 256 type LinearAgentSkillSetupReminderState = { modalShown: boolean toastCount: number snoozed: boolean lastToastActivationId?: string - activeToastId?: string | number + activeToastId?: string } const reminderStateByRuntimeKey = new Map() let nextActivationId = 0 +function evictLinearAgentSkillSetupReminderStateIfAtCapacity(): void { + if (reminderStateByRuntimeKey.size < MAX_LINEAR_AGENT_SKILL_SETUP_REMINDER_RUNTIME_KEYS) { + return + } + let evictionKey = reminderStateByRuntimeKey.keys().next().value + // Why: visible toasts retain their reminder state when an inactive entry can + // be evicted instead; deterministic toast ids still make fallback cleanup safe. + for (const [runtimeKey, state] of reminderStateByRuntimeKey) { + if (state.activeToastId === undefined) { + evictionKey = runtimeKey + break + } + } + if (evictionKey !== undefined) { + reminderStateByRuntimeKey.delete(evictionKey) + } +} + export function createLinearAgentSkillSetupActivationId(): string { const activationId = `linear-agent-skill-setup-${nextActivationId}` nextActivationId += 1 @@ -22,6 +41,8 @@ export function getLinearAgentSkillSetupReminderState( ): LinearAgentSkillSetupReminderState { const existing = reminderStateByRuntimeKey.get(localDismissStorageKey) if (existing) { + reminderStateByRuntimeKey.delete(localDismissStorageKey) + reminderStateByRuntimeKey.set(localDismissStorageKey, existing) return existing } const nextState: LinearAgentSkillSetupReminderState = { @@ -29,11 +50,30 @@ export function getLinearAgentSkillSetupReminderState( toastCount: 0, snoozed: false } + // Why: runtime dismiss keys can churn as local/remote targets change; keep + // recent reminder UX state without retaining stale runtime keys forever. + evictLinearAgentSkillSetupReminderStateIfAtCapacity() reminderStateByRuntimeKey.set(localDismissStorageKey, nextState) return nextState } +export function getExistingLinearAgentSkillSetupReminderState( + localDismissStorageKey: string +): LinearAgentSkillSetupReminderState | undefined { + return reminderStateByRuntimeKey.get(localDismissStorageKey) +} + export function resetLinearAgentSkillSetupReminderState(): void { reminderStateByRuntimeKey.clear() nextActivationId = 0 } + +export function getLinearAgentSkillSetupReminderStateCountForTests(): number { + return reminderStateByRuntimeKey.size +} + +export function hasLinearAgentSkillSetupReminderStateForTests( + localDismissStorageKey: string +): boolean { + return reminderStateByRuntimeKey.has(localDismissStorageKey) +}