Fix Linear setup reminder state retention (#7690)

This commit is contained in:
Neil 2026-07-11 01:30:44 -07:00 committed by GitHub
parent acb35ee649
commit adcc0e9884
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 212 additions and 16 deletions

View File

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

View File

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

View File

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

View File

@ -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<string, LinearAgentSkillSetupReminderState>()
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)
}