diff --git a/static/app/utils/useDeferredSessionStorage.tsx b/static/app/utils/useDeferredSessionStorage.tsx new file mode 100644 index 00000000000000..8300170789c66a --- /dev/null +++ b/static/app/utils/useDeferredSessionStorage.tsx @@ -0,0 +1,49 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; + +import { + readStorageValue, + writeStorageValue, + removeStorageValue, +} from 'sentry/utils/useSessionStorage'; + +/** + * Persists a value to sessionStorage under `key`. Unlike `useSessionStorage`, writes are deferred — + * storage is only updated on: + * - key change (flush old key, load new key) + * - unmount (flush current key) + * - explicit `reset()` (drop the persisted value and reset state to initialValue) + * + * When `key` is null the storage persistence is disabled - the value lives in React state only. + */ +export function useDeferredSessionStorage(key: string | null, initialValue: T) { + const [value, setValue] = useState(() => readStorageValue(key, initialValue)); + + const keyRef = useRef(key); + const valueRef = useRef(value); + valueRef.current = value; + + // Flush to storage and update value on key change + useEffect(() => { + if (keyRef.current !== key) { + writeStorageValue(keyRef.current, valueRef.current); + setValue(readStorageValue(key, initialValue)); + keyRef.current = key; + } + }, [key, initialValue]); + + // Flush to storage on unmount + useEffect(() => { + return () => { + writeStorageValue(keyRef.current, valueRef.current); + }; + }, []); + + const reset = useCallback(() => { + setValue(() => { + removeStorageValue(keyRef.current); + return initialValue; + }); + }, [initialValue]); + + return {value, setValue, reset}; +} diff --git a/static/app/utils/useSessionStorage.tsx b/static/app/utils/useSessionStorage.tsx index 2aadde457c5dfc..2065b9537e684c 100644 --- a/static/app/utils/useSessionStorage.tsx +++ b/static/app/utils/useSessionStorage.tsx @@ -4,7 +4,11 @@ import {sessionStorageWrapper} from 'sentry/utils/sessionStorage'; const isBrowser = typeof window !== 'undefined'; -export function readStorageValue(key: string, initialValue: unknown) { +export function readStorageValue(key: string | null, initialValue: T): T { + if (key === null) { + return initialValue; + } + const value = sessionStorageWrapper.getItem(key); // We check for 'undefined' because the value may have @@ -23,8 +27,33 @@ export function readStorageValue(key: string, initialValue: unknown) { } } +export function writeStorageValue(key: string | null, value: unknown): void { + if (key === null) { + return; + } + try { + sessionStorageWrapper.setItem(key, JSON.stringify(value)); + } catch { + // Best effort and just update the in-memory value. + } +} + +export function removeStorageValue(key: string | null): void { + if (key === null) { + return; + } + try { + sessionStorageWrapper.removeItem(key); + } catch { + // Best effort + } +} + +/** + * Hook for managing a react state backed by sessionStorage. When `key` is null the storage persistence is disabled. + */ export function useSessionStorage( - key: string, + key: string | null, initialValue: T ): [T, (value: SetStateAction) => void, () => void] { const [state, setState] = useState(() => readStorageValue(key, initialValue)); @@ -45,12 +74,7 @@ export function useSessionStorage( ? (valueOrUpdater as (prev: T) => T)(prev) : valueOrUpdater; - try { - sessionStorageWrapper.setItem(key, JSON.stringify(next)); - } catch { - // Best effort and just update the in-memory value. - } - + writeStorageValue(key, next); return next; }); }, @@ -59,7 +83,7 @@ export function useSessionStorage( const removeItem = useCallback(() => { setState(() => { - sessionStorageWrapper.removeItem(key); + removeStorageValue(key); return initialValue; }); }, [key, initialValue]); diff --git a/static/app/views/seerExplorer/components/drawer/explorerDrawerContent.spec.tsx b/static/app/views/seerExplorer/components/drawer/explorerDrawerContent.spec.tsx index 0fa356bad0725c..2874a985505292 100644 --- a/static/app/views/seerExplorer/components/drawer/explorerDrawerContent.spec.tsx +++ b/static/app/views/seerExplorer/components/drawer/explorerDrawerContent.spec.tsx @@ -5,6 +5,7 @@ import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrar import {ConfigStore} from 'sentry/stores/configStore'; import {ExplorerDrawerContent} from 'sentry/views/seerExplorer/components/drawer/explorerDrawerContent'; +import {INPUT_STORAGE_KEY_PREFIX} from 'sentry/views/seerExplorer/components/drawer/explorerDrawerContent'; import * as useSeerExplorerModule from 'sentry/views/seerExplorer/hooks/useSeerExplorer'; import {SeerExplorerSessionsProvider} from 'sentry/views/seerExplorer/seerExplorerSessionContext'; import type {SeerExplorerResponse} from 'sentry/views/seerExplorer/types'; @@ -472,6 +473,127 @@ describe('ExplorerDrawerContent', () => { }); }); + describe('Input Persistence', () => { + it('restores the persisted draft when the drawer remounts', async () => { + jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({ + ...defaultHookReturn, + runId: 7, + }); + + const {unmount} = render( + + + , + {organization} + ); + + await userEvent.type( + await screen.findByTestId('seer-explorer-input'), + 'draft message' + ); + unmount(); + + render( + + + , + {organization} + ); + + expect(await screen.findByTestId('seer-explorer-input')).toHaveValue( + 'draft message' + ); + }); + + it('persists the draft per runId across run switches', async () => { + const useSeerExplorerSpy = jest.spyOn(useSeerExplorerModule, 'useSeerExplorer'); + useSeerExplorerSpy.mockReturnValue({...defaultHookReturn, runId: 1}); + + const {rerender} = render( + + + , + {organization} + ); + + await userEvent.type( + await screen.findByTestId('seer-explorer-input'), + 'draft for run 1' + ); + + useSeerExplorerSpy.mockReturnValue({...defaultHookReturn, runId: 2}); + rerender( + + + + ); + + await waitFor(() => + expect(screen.getByTestId('seer-explorer-input')).toHaveValue('') + ); + expect( + JSON.parse(sessionStorage.getItem(`${INPUT_STORAGE_KEY_PREFIX}:1`) ?? '') + ).toBe('draft for run 1'); + + useSeerExplorerSpy.mockReturnValue({...defaultHookReturn, runId: 1}); + rerender( + + + + ); + + await waitFor(() => + expect(screen.getByTestId('seer-explorer-input')).toHaveValue('draft for run 1') + ); + }); + + it('never writes to sessionStorage when runId is null (no session)', async () => { + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); + + const {unmount} = render( + + + , + {organization} + ); + + await userEvent.type( + await screen.findByTestId('seer-explorer-input'), + 'unsaved draft' + ); + unmount(); + + const draftWrites = setItemSpy.mock.calls.filter(([k]) => + String(k).startsWith(`${INPUT_STORAGE_KEY_PREFIX}:`) + ); + expect(draftWrites).toHaveLength(0); + }); + + it('clears the persisted draft when a message is sent', async () => { + const sendMessage = jest.fn(); + jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({ + ...defaultHookReturn, + sendMessage, + runId: 42, + }); + + render( + + + , + {organization} + ); + + const textarea = await screen.findByTestId('seer-explorer-input'); + await userEvent.type(textarea, 'hello'); + await userEvent.keyboard('{Enter}'); + + expect(sendMessage).toHaveBeenCalledWith('hello', 0); + expect(textarea).toHaveValue(''); + expect(sessionStorage.getItem(`${INPUT_STORAGE_KEY_PREFIX}:42`)).toBeNull(); + }); + }); + describe('Read-only State', () => { it('disables input when session owner differs from current user', async () => { ConfigStore.set('user', UserFixture({id: '1'})); diff --git a/static/app/views/seerExplorer/components/drawer/explorerDrawerContent.tsx b/static/app/views/seerExplorer/components/drawer/explorerDrawerContent.tsx index 42e372ef018422..e17b1ccf69691d 100644 --- a/static/app/views/seerExplorer/components/drawer/explorerDrawerContent.tsx +++ b/static/app/views/seerExplorer/components/drawer/explorerDrawerContent.tsx @@ -7,6 +7,7 @@ import {Stack} from '@sentry/scraps/layout'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {SEER_AGENTS_PROJECT_ID} from 'sentry/constants'; import {trackAnalytics} from 'sentry/utils/analytics'; +import {useDeferredSessionStorage} from 'sentry/utils/useDeferredSessionStorage'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; @@ -31,6 +32,8 @@ import { useSeerExplorerDeepLink, } from 'sentry/views/seerExplorer/utils'; +export const INPUT_STORAGE_KEY_PREFIX = 'seer-explorer-draft'; + export function ExplorerDrawerContent({ getPageReferrer, initialQuery, @@ -43,7 +46,6 @@ export function ExplorerDrawerContent({ const user = useUser(); const {closeDrawer} = useDrawer(); - const [inputValue, setInputValue] = useState(''); const [showThinking, setShowThinking] = useState(false); const textareaRef = useRef(null); @@ -65,8 +67,8 @@ export function ExplorerDrawerContent({ errorStatusCode, isTimedOut, sendMessage, - startNewSession: startNewSessionBase, - switchToRun: switchToRunBase, + startNewSession, + switchToRun, respondToUserInput, createPR, interruptRun, @@ -76,10 +78,16 @@ export function ExplorerDrawerContent({ setOverrideCodeModeEnable, } = useSeerExplorer(); - const clearInput = () => setInputValue(''); - const startNewSession = () => startNewSessionBase({onSuccess: clearInput}); - const switchToRun = (newRunId: number | null) => - switchToRunBase(newRunId, {onSuccess: clearInput}); + // Persist the input draft per-run so drawer closes / run switches + // don't lose the user's in-progress text. + const { + value: inputValue, + setValue: setInputValue, + reset: clearInput, + } = useDeferredSessionStorage( + runId === null ? null : `${INPUT_STORAGE_KEY_PREFIX}:${runId}`, + '' + ); const readOnly = sessionData?.owner_user_id !== undefined && @@ -260,9 +268,9 @@ export function ExplorerDrawerContent({ return; } sendMessage(inputValue.trim(), blocks.length); - setInputValue(''); + clearInput(); userScrolledUpRef.current = false; - }, [canSendMessage, inputValue, sendMessage, blocks.length]); + }, [canSendMessage, inputValue, sendMessage, blocks.length, clearInput]); const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -452,7 +460,7 @@ export function ExplorerDrawerContent({ canSendMessage={canSendMessage} interruptState={interruptState} isTimedOut={isTimedOut} - onClear={() => setInputValue('')} + onClear={clearInput} onCreatePR={createPR} onInputChange={handleInputChange} onInputClick={handleInputClick}