Fix Linear setup reminder state retention (#7690)
This commit is contained in:
parent
acb35ee649
commit
adcc0e9884
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue