Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 0 additions & 1 deletion redisinsight/ui/src/constants/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ enum BrowserStorageItem {
whatsNewLastVersionSeen = 'whatsNewLastVersionSeen',
valueDecoderRules = 'valueDecoderRules_',
agentMemoryPanelSizes = 'agentMemoryPanelSizes',
agentMemoryLtmPanelSizes = 'agentMemoryLtmPanelSizes',
}

export default BrowserStorageItem
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ButtonHTMLAttributes,
HTMLAttributes,
ImgHTMLAttributes,
InputHTMLAttributes,
} from 'react'
import styled from 'styled-components'

Expand Down Expand Up @@ -31,8 +32,6 @@ const lightAccents = {
roleAccentBg: 'rgba(127, 219, 254, 0.18)',
chipTopicBg: 'rgba(199, 150, 228, 0.18)',
chipTopicText: '#6a3aa0',
chipEntityBg: '#e8ebec',
chipEntityText: '#163341',
paneHeaderBorder: '#e8ebec',
typeMessageBg: 'rgba(45, 71, 84, 0.1)',
typeMessageText: '#2d4754',
Expand All @@ -46,8 +45,6 @@ const darkAccents: typeof lightAccents = {
roleAccentBg: 'rgba(127, 219, 254, 0.18)',
chipTopicBg: 'rgba(199, 150, 228, 0.22)',
chipTopicText: '#d4b3f0',
chipEntityBg: 'var(--ami-bgTertiary)',
chipEntityText: 'var(--ami-text)',
paneHeaderBorder: 'var(--ami-bgTertiary)',
typeMessageBg: 'rgba(245, 247, 250, 0.08)',
typeMessageText: 'var(--ami-textSecondary)',
Expand Down Expand Up @@ -406,28 +403,20 @@ export const ActiveFilters = styled(Row)`
`

export const FilterPill = styled.span<
HTMLAttributes<HTMLSpanElement> & { $kind?: 'topic' | 'entity' }
HTMLAttributes<HTMLSpanElement> & { $kind?: 'topic' }
>`
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 4px 2px 7px;
background: ${({ $kind }) =>
$kind === 'topic'
? palette.chipTopicBg
: $kind === 'entity'
? palette.chipEntityBg
: palette.bgTertiary};
$kind === 'topic' ? palette.chipTopicBg : palette.bgTertiary};
border: 1px solid ${palette.separator};
border-radius: 12px;
font-family: ${fontMono};
font-size: ${({ theme }) => theme.core.font.fontSize.s12};
color: ${({ $kind }) =>
$kind === 'topic'
? palette.chipTopicText
: $kind === 'entity'
? palette.chipEntityText
: palette.text};
$kind === 'topic' ? palette.chipTopicText : palette.text};

em {
color: ${palette.textMuted};
Expand Down Expand Up @@ -515,7 +504,7 @@ export const ErrorText = styled.p<HTMLAttributes<HTMLParagraphElement>>`
export const Card = styled.article<HTMLAttributes<HTMLElement>>`
background: ${palette.bgPrimary};
border: 1px solid ${palette.border};
border-radius: ${({ theme }) => theme.components.card.borderRadius};
border-radius: 0;
padding: 10px 11px;
flex-shrink: 0;
`
Expand Down Expand Up @@ -667,6 +656,32 @@ export const TypeBadge = styled.span<
}}
`

export const ThresholdControl = styled.div`
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
Comment on lines +659 to +663

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Replace the flex div with a layout component

The new threshold control implements a flex container with styled.div and hardcodes its alignment and gap, bypassing the shared layout system. Use Row or FlexGroup and pass align and gap at 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 👍 / 👎.


label {
font-size: ${({ theme }) => theme.core.font.fontSize.s12};
color: ${palette.textSecondary};
white-space: nowrap;
}
`

export const ThresholdInput = styled.input<
InputHTMLAttributes<HTMLInputElement>
>`
width: 64px;
font-family: ${fontMono};
font-size: ${({ theme }) => theme.core.font.fontSize.s12};
padding: 2px 6px;
border: 1px solid ${palette.border};
border-radius: 4px;
background: ${palette.bgSecondary};
color: ${palette.text};
`

export const CardFooter = styled.footer<HTMLAttributes<HTMLElement>>`
display: flex;
align-items: center;
Expand Down Expand Up @@ -736,7 +751,7 @@ export const ChipRowChips = styled(Row)`
`

export const Chip = styled.button<
ButtonHTMLAttributes<HTMLButtonElement> & { $kind: 'topic' | 'entity' }
ButtonHTMLAttributes<HTMLButtonElement> & { $kind: 'topic' }
>`
display: inline-flex;
align-items: center;
Expand All @@ -752,10 +767,8 @@ export const Chip = styled.button<
transition:
border-color 120ms ease,
transform 80ms ease;
background: ${({ $kind }) =>
$kind === 'topic' ? palette.chipTopicBg : palette.chipEntityBg};
color: ${({ $kind }) =>
$kind === 'topic' ? palette.chipTopicText : palette.chipEntityText};
background: ${palette.chipTopicBg};
color: ${palette.chipTopicText};

&:hover {
border-color: currentColor;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
} from 'uiSrc/slices/agentMemory/endpoints'
import {
agentMemoryFiltersSelector,
agentMemoryLongTermSelector,
discoverFiltersAction,
fetchLongTermMemoryAction,
fetchOverviewLongTermMemoryAction,
fetchWorkingMemoryAction,
resetWorkspace,
Expand All @@ -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]

Expand All @@ -50,6 +57,11 @@
label: 'Overview',
content: null,
},
{
value: AgentMemoryWorkspaceTab.LongTermMemory,
label: 'Long-term memory',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Translate the new explorer copy

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 useTranslation, as required by the repository's i18n guidance.

AGENTS.md reference: AGENTS.md:L127-L127

Useful? React with 👍 / 👎.

content: null,
},
]

const getStoredPanelSizes = (key: BrowserStorageItem): number[] => {
Expand All @@ -64,6 +76,7 @@
const { endpointId, tab } = useParams<{ endpointId: string; tab?: string }>()
const connectedEndpoint = useAppSelector(connectedAgentMemoryEndpointSelector)
const filters = useAppSelector(agentMemoryFiltersSelector)
const longTermMemory = useAppSelector(agentMemoryLongTermSelector)

Check warning on line 79 in redisinsight/ui/src/pages/agent-memory/workspace/AgentMemoryWorkspacePage.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

const isConnected = connectedEndpoint.id === endpointId

Expand All @@ -87,6 +100,7 @@
history.push(Pages.agentMemoryWorkspace(endpointId, nextTab))
}
const isOverviewTab = activeTab === AgentMemoryWorkspaceTab.Overview
const isLtmTab = activeTab === AgentMemoryWorkspaceTab.LongTermMemory

Check warning on line 103 in redisinsight/ui/src/pages/agent-memory/workspace/AgentMemoryWorkspacePage.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

// Normalize bare/unknown tab segments to the canonical overview URL.
useEffect(() => {
Expand Down Expand Up @@ -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))

Check warning on line 166 in redisinsight/ui/src/pages/agent-memory/workspace/AgentMemoryWorkspacePage.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
Comment on lines +165 to +166

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Fetch explorer data during initial tab load

When the workspace is opened or reloaded directly on the Long-term memory URL, didMountRef makes this effect skip its first run, while bootstrap() only calls refreshAll(), which fetches long-term memory with scopeToSession=true. The explorer therefore initially shows records from the auto-selected Overview session rather than the explorer result set, and remains incomplete until the user manually refreshes or changes a filter; make the bootstrap request depend on the active tab or allow the initial explorer fetch.

Useful? React with 👍 / 👎.

}

Check warning on line 167 in redisinsight/ui/src/pages/agent-memory/workspace/AgentMemoryWorkspacePage.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}, [activeTab])

Check warning on line 168 in redisinsight/ui/src/pages/agent-memory/workspace/AgentMemoryWorkspacePage.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

// Session pick scopes both Overview panes; user/namespace changes are
// Session pick scopes both Overview panes; owner changes are
Comment thread
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(() => {
Expand All @@ -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

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 182 in redisinsight/ui/src/pages/agent-memory/workspace/AgentMemoryWorkspacePage.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
dispatch(fetchLongTermMemoryAction(endpointId))

Check warning on line 183 in redisinsight/ui/src/pages/agent-memory/workspace/AgentMemoryWorkspacePage.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
Comment on lines +182 to +183

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Gate explorer filter refetches to the explorer tab

On a normal initial Overview load, discoverFiltersAction eventually dispatches loadSessionsSuccess, which replaces longTermMemory.sessionIds with a new array; because didMountRef is already true and this effect is not gated by isLtmTab, it starts an unscoped explorer request after the bootstrap/session-scoped requests. The monotonic long-term request sequence makes that request authoritative, so Overview can display records from every owner/session instead of its selected scope.

Useful? React with 👍 / 👎.

}, [
longTermMemory.similarityThreshold,
longTermMemory.topics,
longTermMemory.sessionIds,
longTermMemory.memoryTypes,
longTermMemory.userIds,
longTermMemory.namespaces,
])

Check warning on line 191 in redisinsight/ui/src/pages/agent-memory/workspace/AgentMemoryWorkspacePage.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

// 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)

Check warning on line 198 in redisinsight/ui/src/pages/agent-memory/workspace/AgentMemoryWorkspacePage.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
useDebouncedEffect(
() => {
if (!searchDidMountRef.current) {
searchDidMountRef.current = true
return
Comment on lines +201 to +203

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not discard the first debounced search

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 👍 / 👎.

}

Check warning on line 204 in redisinsight/ui/src/pages/agent-memory/workspace/AgentMemoryWorkspacePage.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
if (!isConnected) return
dispatch(fetchLongTermMemoryAction(endpointId))
Comment on lines +205 to +206

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cancel explorer searches when leaving the tab

When a user changes the search and switches to Overview before the 300 ms debounce expires, this callback remains scheduled because activeTab is neither a dependency nor a guard. It starts an unscoped explorer request after the tab-switch effect starts the session-scoped Overview request; the thunk's monotonic request sequence makes this later request authoritative, so the Overview pane is populated with explorer results. Cancel or suppress the callback whenever the Long-term memory tab is no longer active.

Useful? React with 👍 / 👎.

},
SEARCH_DEBOUNCE_MS,
[longTermMemory.search],
)

Check warning on line 210 in redisinsight/ui/src/pages/agent-memory/workspace/AgentMemoryWorkspacePage.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
Comment thread
cursor[bot] marked this conversation as resolved.

// Keep last so the change-effects above skip their initial-mount run.
useEffect(() => {
didMountRef.current = true
Expand Down Expand Up @@ -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} />
)}
Expand Down
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'
Loading
Loading