Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,126 @@ 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(
<SeerExplorerSessionsProvider>
<ExplorerDrawerContent getPageReferrer={mockGetPageReferrer} />
</SeerExplorerSessionsProvider>,
{organization}
);

await userEvent.type(
await screen.findByTestId('seer-explorer-input'),
'draft message'
);
unmount();

render(
<SeerExplorerSessionsProvider>
<ExplorerDrawerContent getPageReferrer={mockGetPageReferrer} />
</SeerExplorerSessionsProvider>,
{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(
<SeerExplorerSessionsProvider>
<ExplorerDrawerContent getPageReferrer={mockGetPageReferrer} />
</SeerExplorerSessionsProvider>,
{organization}
);

await userEvent.type(
await screen.findByTestId('seer-explorer-input'),
'draft for run 1'
);

useSeerExplorerSpy.mockReturnValue({...defaultHookReturn, runId: 2});
rerender(
<SeerExplorerSessionsProvider>
<ExplorerDrawerContent getPageReferrer={mockGetPageReferrer} />
</SeerExplorerSessionsProvider>
);

await waitFor(() =>
expect(screen.getByTestId('seer-explorer-input')).toHaveValue('')
);
expect(sessionStorage.getItem('seer-explorer-draft:1')).toBe('draft for run 1');

useSeerExplorerSpy.mockReturnValue({...defaultHookReturn, runId: 1});
rerender(
<SeerExplorerSessionsProvider>
<ExplorerDrawerContent getPageReferrer={mockGetPageReferrer} />
</SeerExplorerSessionsProvider>
);

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(
<SeerExplorerSessionsProvider>
<ExplorerDrawerContent getPageReferrer={mockGetPageReferrer} />
</SeerExplorerSessionsProvider>,
{organization}
);

await userEvent.type(
await screen.findByTestId('seer-explorer-input'),
'unsaved draft'
);
unmount();

const draftWrites = setItemSpy.mock.calls.filter(([k]) =>
String(k).startsWith('seer-explorer-draft:')
);
expect(draftWrites).toHaveLength(0);
expect(sessionStorage.getItem('seer-explorer-draft:null')).toBeNull();
});

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(
<SeerExplorerSessionsProvider>
<ExplorerDrawerContent getPageReferrer={mockGetPageReferrer} />
</SeerExplorerSessionsProvider>,
{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('seer-explorer-draft:42')).toBeNull();
});
});

describe('Read-only State', () => {
it('disables input when session owner differs from current user', async () => {
ConfigStore.set('user', UserFixture({id: '1'}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {FileChangeApprovalBlock} from 'sentry/views/seerExplorer/components/file
import {InputSection} from 'sentry/views/seerExplorer/components/inputSection';
import {usePRWidgetData} from 'sentry/views/seerExplorer/components/prWidget';
import {usePendingUserInput} from 'sentry/views/seerExplorer/hooks/usePendingUserInput';
import {usePersistedValue} from 'sentry/views/seerExplorer/hooks/usePersistedValue';
import {useSeerExplorer} from 'sentry/views/seerExplorer/hooks/useSeerExplorer';
import type {Block} from 'sentry/views/seerExplorer/types';
import {
Expand All @@ -43,7 +44,6 @@ export function ExplorerDrawerContent({
const user = useUser();
const {closeDrawer} = useDrawer();

const [inputValue, setInputValue] = useState('');
const [showThinking, setShowThinking] = useState(false);

const textareaRef = useRef<HTMLTextAreaElement>(null);
Expand All @@ -65,8 +65,8 @@ export function ExplorerDrawerContent({
errorStatusCode,
isTimedOut,
sendMessage,
startNewSession: startNewSessionBase,
switchToRun: switchToRunBase,
startNewSession,
switchToRun,
respondToUserInput,
createPR,
interruptRun,
Expand All @@ -76,10 +76,13 @@ 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,
clear: clearInput,
} = usePersistedValue('seer-explorer-draft', runId);

const readOnly =
sessionData?.owner_user_id !== undefined &&
Expand Down Expand Up @@ -260,9 +263,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<HTMLTextAreaElement>) => {
Expand Down Expand Up @@ -452,7 +455,7 @@ export function ExplorerDrawerContent({
canSendMessage={canSendMessage}
interruptState={interruptState}
isTimedOut={isTimedOut}
onClear={() => setInputValue('')}
onClear={clearInput}
onCreatePR={createPR}
onInputChange={handleInputChange}
onInputClick={handleInputClick}
Expand Down
69 changes: 69 additions & 0 deletions static/app/views/seerExplorer/hooks/usePersistedValue.tsx
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks similar to useLocalStorageState. you could use that and pass in the same kind of key

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of our existing use*Storage hooks support deferred writes like this hook, which avoids hammering storage on every keystroke in a textarea.

There's certainly an argument to be made that we should add an option for one of those hooks to support it, though.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'd aim to layer a throttle or something, instead of baking things in

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ryan953 do you mean to throttle the number of updates to local/session storage?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have strong enough opinions to block this PR. But I'll think it all through (rant) since we're here...

I was sorta alerted because i know the llms love to make new files instead of finding existing ones, and i think that kind of duplication was already a problem and it'll get much worse over time.

So we've got the base useLocalStorageState, which is the place that does serialization as well as the setState call (2 jobs). I'd argue that those two jobs could be split up a little better; the benefit could be that we don't need to have readValue() and writeValue() re-implemented.
I also found NoteInputWithStorage which is another implementation of the persist-unsaved-content pattern. It's got debounce() to wrap the save call, evidence that its a great call to have here too 👍 . It would be ideal if that component were composable, so we could just use it directly. Instead its coupled to NoteInput.

This kind of circles back to the idea that there are multiple similar implementations all over and they're hard to find. So if we keep this file i'd move it into something like static/app/utils/storage. The NoteInput isn't obvious to find, and who wants to share by importing from components/activity/note/

So yeah, not trying to block this. brain dump for y'all.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, thanks for the thoughts! I can move to app/utils so its easier to find, and see if we can reuse read/write

Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {useCallback, useEffect, useRef, useState} from 'react';

import {sessionStorageWrapper} from 'sentry/utils/sessionStorage';

function readValue(key: string | null): string {
if (key === null) {
return '';
}
return sessionStorageWrapper.getItem(key) ?? '';
}

function writeValue(key: string | null, value: string): void {
if (key === null) {
return;
}
try {
sessionStorageWrapper.setItem(key, value);
} catch {
// best effort
}
}

/**
* Persists a string value (e.g. a textarea draft) to sessionStorage, scoped by
* `scopeId`. Unlike `useSessionStorage`, writes are deferred — storage is only
* touched on:
* - scope change (flush old key, load new key)
* - unmount (flush current key)
* - explicit `clear()` (drop the persisted value and reset state to '')
*
* When `scopeId` is null the value lives in React state only — no
* sessionStorage reads or writes happen for that scope.
*/
export function usePersistedValue(
scope: string,
scopeId: string | number | null | undefined
) {
const key = scopeId === null ? null : `${scope}:${scopeId}`;
const [value, setValue] = useState(() => readValue(key));

const valueRef = useRef(value);
valueRef.current = value;

// Flush to storage and update value on key change
const keyRef = useRef(key);
useEffect(() => {
if (keyRef.current !== key) {
writeValue(keyRef.current, valueRef.current);
setValue(readValue(key));
keyRef.current = key;
}
}, [key]);

// Flush to storage on unmount
useEffect(() => {
return () => {
writeValue(keyRef.current, valueRef.current);
};
}, []);

const clear = useCallback(() => {
setValue('');
if (keyRef.current !== null) {
sessionStorageWrapper.removeItem(keyRef.current);
}
}, []);

return {value, setValue, clear};
}
Loading