-
Notifications
You must be signed in to change notification settings - Fork 491
feat(agent-memory): add long-term memory explorer tab #6429
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,7 +15,9 @@ | |
| } from 'uiSrc/slices/agentMemory/endpoints' | ||
| import { | ||
| agentMemoryFiltersSelector, | ||
| agentMemoryLongTermSelector, | ||
| discoverFiltersAction, | ||
| fetchLongTermMemoryAction, | ||
| fetchOverviewLongTermMemoryAction, | ||
| fetchWorkingMemoryAction, | ||
| resetWorkspace, | ||
|
|
@@ -31,16 +33,21 @@ | |
| import { Text } from 'uiSrc/components/base/text' | ||
| import agentMemoryIcon from 'uiSrc/assets/img/agent-memory/agent-memory-icon.svg' | ||
| import { localStorageService } from 'uiSrc/services' | ||
| import { useDebouncedEffect } from 'uiSrc/services/hooks/hooks' | ||
|
|
||
| import Tabs from 'uiSrc/components/base/layout/tabs' | ||
| import { AgentMemoryWorkspaceTab } from 'uiSrc/slices/interfaces/agentMemory' | ||
|
|
||
| import FilterPills from './components/filter-pills/FilterPills' | ||
| import WorkingMemoryPanel from './components/working-memory-panel/WorkingMemoryPanel' | ||
| import LongTermOverviewPanel from './components/long-term-overview-panel/LongTermOverviewPanel' | ||
| import LongTermMemoryPanel from './components/long-term-memory-panel/LongTermMemoryPanel' | ||
| import LongTermMemoryToolbar from './components/long-term-memory-toolbar/LongTermMemoryToolbar' | ||
| import ConfigurationPanel from './components/configuration-panel/ConfigurationPanel' | ||
| import * as S from './AgentMemoryWorkspacePage.styles' | ||
|
|
||
| export const SEARCH_DEBOUNCE_MS = 300 | ||
|
|
||
| const PANEL_MIN_SIZE = 20 | ||
| const PANEL_DEFAULT_SIZES = [50, 50] | ||
|
|
||
|
|
@@ -50,6 +57,11 @@ | |
| label: 'Overview', | ||
| content: null, | ||
| }, | ||
| { | ||
| value: AgentMemoryWorkspaceTab.LongTermMemory, | ||
| label: 'Long-term memory', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the UI is displayed in a non-English locale, this new tab label—and the newly added toolbar, card, tooltip, empty-state, and deletion copy—bypasses i18next and remains English. Add agent-memory keys to both locale files and render this copy through AGENTS.md reference: AGENTS.md:L127-L127 Useful? React with 👍 / 👎. |
||
| content: null, | ||
| }, | ||
| ] | ||
|
|
||
| const getStoredPanelSizes = (key: BrowserStorageItem): number[] => { | ||
|
|
@@ -64,6 +76,7 @@ | |
| const { endpointId, tab } = useParams<{ endpointId: string; tab?: string }>() | ||
| const connectedEndpoint = useAppSelector(connectedAgentMemoryEndpointSelector) | ||
| const filters = useAppSelector(agentMemoryFiltersSelector) | ||
| const longTermMemory = useAppSelector(agentMemoryLongTermSelector) | ||
|
|
||
| const isConnected = connectedEndpoint.id === endpointId | ||
|
|
||
|
|
@@ -87,6 +100,7 @@ | |
| history.push(Pages.agentMemoryWorkspace(endpointId, nextTab)) | ||
| } | ||
| const isOverviewTab = activeTab === AgentMemoryWorkspaceTab.Overview | ||
| const isLtmTab = activeTab === AgentMemoryWorkspaceTab.LongTermMemory | ||
|
|
||
| // Normalize bare/unknown tab segments to the canonical overview URL. | ||
| useEffect(() => { | ||
|
|
@@ -142,15 +156,18 @@ | |
| const didMountRef = useRef(false) | ||
|
|
||
| // Refresh on tab switch so filter changes made elsewhere show at once. | ||
| useEffect(() => { | ||
| if (!didMountRef.current || !isConnected) return | ||
| if (isOverviewTab) { | ||
| dispatch(fetchWorkingMemoryAction(endpointId)) | ||
| dispatch(fetchOverviewLongTermMemoryAction(endpointId)) | ||
| } | ||
| if (isLtmTab) { | ||
| dispatch(fetchLongTermMemoryAction(endpointId)) | ||
|
Comment on lines
+165
to
+166
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the workspace is opened or reloaded directly on the Long-term memory URL, Useful? React with 👍 / 👎. |
||
| } | ||
| }, [activeTab]) | ||
|
|
||
| // Session pick scopes both Overview panes; user/namespace changes are | ||
| // Session pick scopes both Overview panes; owner changes are | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| // orchestrated by changeScopeAction (sessions must re-list before any | ||
| // refetch pairs the new scope with a stale session). | ||
| useEffect(() => { | ||
|
|
@@ -159,6 +176,39 @@ | |
| dispatch(fetchOverviewLongTermMemoryAction(endpointId)) | ||
| }, [filters.sessionId]) | ||
|
|
||
| // Records refetch when the explorer filters change. The search text is | ||
| // handled separately with a debounce. | ||
| useEffect(() => { | ||
| if (!didMountRef.current || !isConnected) return | ||
|
Check warning on line 182 in redisinsight/ui/src/pages/agent-memory/workspace/AgentMemoryWorkspacePage.tsx
|
||
| dispatch(fetchLongTermMemoryAction(endpointId)) | ||
|
Comment on lines
+182
to
+183
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On a normal initial Overview load, Useful? React with 👍 / 👎. |
||
| }, [ | ||
| longTermMemory.similarityThreshold, | ||
| longTermMemory.topics, | ||
| longTermMemory.sessionIds, | ||
| longTermMemory.memoryTypes, | ||
| longTermMemory.userIds, | ||
| longTermMemory.namespaces, | ||
| ]) | ||
|
|
||
| // Debounced search - the input dispatches per keystroke, but only the | ||
| // settled value triggers the (semantic, hence relatively expensive) | ||
| // long-term memory search request. | ||
| // didMountRef is already true when the debounced callback fires, so it | ||
| // can't skip the mount run - track that separately. | ||
| const searchDidMountRef = useRef(false) | ||
| useDebouncedEffect( | ||
| () => { | ||
| if (!searchDidMountRef.current) { | ||
| searchDidMountRef.current = true | ||
| return | ||
|
Comment on lines
+201
to
+203
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the user types or pastes within the first 300 ms after this component mounts, the dependency change cancels the initial timer, so the callback for the user's query is the first callback to run; this branch then treats it as the mount callback and returns without issuing a search. The entered query remains displayed while the results are not refreshed until the search changes again, so initialize the mount flag independently of the debounced callback. Useful? React with 👍 / 👎. |
||
| } | ||
| if (!isConnected) return | ||
| dispatch(fetchLongTermMemoryAction(endpointId)) | ||
|
Comment on lines
+205
to
+206
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a user changes the search and switches to Overview before the 300 ms debounce expires, this callback remains scheduled because Useful? React with 👍 / 👎. |
||
| }, | ||
| SEARCH_DEBOUNCE_MS, | ||
| [longTermMemory.search], | ||
| ) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| // Keep last so the change-effects above skip their initial-mount run. | ||
| useEffect(() => { | ||
| didMountRef.current = true | ||
|
|
@@ -255,6 +305,16 @@ | |
| </S.PanesContainer> | ||
| </S.PanesArea> | ||
| )} | ||
| {isLtmTab && <LongTermMemoryToolbar />} | ||
| {isLtmTab && ( | ||
| <S.PanesArea> | ||
| <S.PanesContainer direction="horizontal"> | ||
| <ResizablePanel id="agent-memory-records-panel" defaultSize={100}> | ||
| <LongTermMemoryPanel endpointId={endpointId} /> | ||
| </ResizablePanel> | ||
| </S.PanesContainer> | ||
| </S.PanesArea> | ||
| )} | ||
| {activeTab === AgentMemoryWorkspaceTab.Configuration && ( | ||
| <ConfigurationPanel endpointId={endpointId} /> | ||
| )} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import React from 'react' | ||
|
|
||
| import { cleanup, fireEvent, render, screen } from 'uiSrc/utils/test-utils' | ||
|
|
||
| import FilterDropdown, { FilterDropdownProps } from './FilterDropdown' | ||
|
|
||
| const TEST_ID = 'ltm-filter-topics' | ||
| const mockedOptions = ['alpha', 'beta', 'gamma'] | ||
|
|
||
| describe('FilterDropdown', () => { | ||
| const defaultProps: FilterDropdownProps = { | ||
| label: 'topics', | ||
| options: mockedOptions, | ||
| selected: [], | ||
| onToggle: jest.fn(), | ||
| 'data-testid': TEST_ID, | ||
| } | ||
|
|
||
| const renderComponent = (propsOverride?: Partial<FilterDropdownProps>) => { | ||
| const props = { ...defaultProps, ...propsOverride } | ||
|
|
||
| return render(<FilterDropdown {...props} />) | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| cleanup() | ||
| jest.clearAllMocks() | ||
| }) | ||
|
|
||
| it('should render', () => { | ||
| expect(renderComponent()).toBeTruthy() | ||
| }) | ||
|
|
||
| it('should render the plain label when nothing is selected', () => { | ||
| renderComponent() | ||
|
|
||
| expect(screen.getByTestId(TEST_ID)).toHaveTextContent('topics ▾') | ||
| }) | ||
|
|
||
| it('should render the selection count in the button label', () => { | ||
| renderComponent({ selected: [mockedOptions[0], mockedOptions[2]] }) | ||
|
|
||
| expect(screen.getByTestId(TEST_ID)).toHaveTextContent('topics (2) ▾') | ||
| }) | ||
|
|
||
| it('should open the popover with one checkbox per option', async () => { | ||
| renderComponent() | ||
|
|
||
| fireEvent.click(screen.getByTestId(TEST_ID)) | ||
|
|
||
| expect( | ||
| await screen.findByTestId(`${TEST_ID}-option-${mockedOptions[0]}`), | ||
| ).toBeInTheDocument() | ||
| mockedOptions.forEach((option) => { | ||
| expect( | ||
| screen.getByTestId(`${TEST_ID}-option-${option}`), | ||
| ).toBeInTheDocument() | ||
| }) | ||
| }) | ||
|
|
||
| it('should mark selected options as checked', async () => { | ||
| renderComponent({ selected: [mockedOptions[1]] }) | ||
|
|
||
| fireEvent.click(screen.getByTestId(TEST_ID)) | ||
|
|
||
| expect( | ||
| await screen.findByTestId(`${TEST_ID}-option-${mockedOptions[1]}`), | ||
| ).toBeChecked() | ||
| expect( | ||
| screen.getByTestId(`${TEST_ID}-option-${mockedOptions[0]}`), | ||
| ).not.toBeChecked() | ||
| }) | ||
|
|
||
| it('should call onToggle with the option value when it is clicked', async () => { | ||
| const onToggle = jest.fn() | ||
| renderComponent({ onToggle }) | ||
|
|
||
| fireEvent.click(screen.getByTestId(TEST_ID)) | ||
| fireEvent.click( | ||
| await screen.findByTestId(`${TEST_ID}-option-${mockedOptions[1]}`), | ||
| ) | ||
|
|
||
| expect(onToggle).toHaveBeenCalledWith(mockedOptions[1]) | ||
| }) | ||
|
|
||
| it('should render the empty text when there are no options', async () => { | ||
| renderComponent({ options: [], emptyText: 'no topics seen yet' }) | ||
|
|
||
| fireEvent.click(screen.getByTestId(TEST_ID)) | ||
|
|
||
| expect(await screen.findByText('no topics seen yet')).toBeInTheDocument() | ||
| }) | ||
|
|
||
| it('should render the default empty text when none is provided', async () => { | ||
| renderComponent({ options: [], emptyText: undefined }) | ||
|
|
||
| fireEvent.click(screen.getByTestId(TEST_ID)) | ||
|
|
||
| expect(await screen.findByText('no options')).toBeInTheDocument() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export { | ||
| FilterDropdownList, | ||
| FilterDropdownEmpty, | ||
| } from '../../AgentMemoryWorkspacePage.styles' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new threshold control implements a flex container with
styled.divand hardcodes its alignment and gap, bypassing the shared layout system. UseRoworFlexGroupand passalignandgapat the JSX call site so this control follows the repository's layout and spacing conventions.AGENTS.md reference: AGENTS.md:L154-L155
Useful? React with 👍 / 👎.