From 9d2c78b4544e647fc0285b4c55c57c05d3e7d89b Mon Sep 17 00:00:00 2001
From: Naveen Saharan
Date: Tue, 7 Jul 2026 16:09:28 +0530
Subject: [PATCH 01/10] feat: connect memory/rule deletion to backend API and
align user_id
- Add deleteMemory, deleteAllMemories, deleteRule, deleteAllRules API functions
- MemoryList.tsx and RulesEditor.tsx now call backend API on delete
- Use JWT sub claim for user identification instead of preferred_username
Signed-off-by: Naveen Saharan
---
src/frontend/components/layout/AppLayout.tsx | 2 +-
.../components/settings/MemoryList.tsx | 5 +-
.../components/settings/RulesEditor.tsx | 5 +-
src/frontend/pages/ChatPage.tsx | 2 +-
src/frontend/services/agent-rest.ts | 60 +++++++++++++++++++
5 files changed, 68 insertions(+), 6 deletions(-)
diff --git a/src/frontend/components/layout/AppLayout.tsx b/src/frontend/components/layout/AppLayout.tsx
index a722121..91ee6ce 100644
--- a/src/frontend/components/layout/AppLayout.tsx
+++ b/src/frontend/components/layout/AppLayout.tsx
@@ -116,7 +116,7 @@ export function AppLayout({ children }: AppLayoutProps) {
async function loadUserHistory() {
try {
dispatch(setLoadingThreads(true));
- const history = await getAllThreadsByUserId(window.USER_DATA.preferred_username);
+ const history = await getAllThreadsByUserId(window.USER_DATA.sub || window.USER_DATA.preferred_username);
const backendIds = new Set(history.map((t) => t.id));
const local = chatsRef.current;
diff --git a/src/frontend/components/settings/MemoryList.tsx b/src/frontend/components/settings/MemoryList.tsx
index a08fabd..f339b00 100644
--- a/src/frontend/components/settings/MemoryList.tsx
+++ b/src/frontend/components/settings/MemoryList.tsx
@@ -3,6 +3,7 @@ import { Button } from '@patternfly/react-core';
import { Plus, Trash2, Brain, AlertCircle } from 'lucide-react';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
import { addMemory, removeMemory, clearMemories, selectMemories } from '../../redux/slices/personalization';
+import { deleteMemory, deleteAllMemories } from '../../services/agent-rest';
export function MemoryList() {
const dispatch = useAppDispatch();
@@ -75,7 +76,7 @@ export function MemoryList() {
{mem.content}
diff --git a/src/frontend/components/settings/RulesEditor.tsx b/src/frontend/components/settings/RulesEditor.tsx
index dc7c030..ca8bafc 100644
--- a/src/frontend/components/settings/RulesEditor.tsx
+++ b/src/frontend/components/settings/RulesEditor.tsx
@@ -9,6 +9,7 @@ import {
clearRules,
selectRules,
} from '../../redux/slices/personalization';
+import { deleteRule, deleteAllRules } from '../../services/agent-rest';
export function RulesEditor() {
const dispatch = useAppDispatch();
@@ -93,7 +94,7 @@ export function RulesEditor() {
{rule.content}
diff --git a/src/frontend/pages/ChatPage.tsx b/src/frontend/pages/ChatPage.tsx
index c98b8e1..f645bb8 100644
--- a/src/frontend/pages/ChatPage.tsx
+++ b/src/frontend/pages/ChatPage.tsx
@@ -85,7 +85,7 @@ export function ChatPage({ threadId }: { threadId: string }) {
const feedbackUserId = useMemo(() => {
if (typeof window === 'undefined') return 'anonymous';
- const u = window.USER_DATA?.preferred_username;
+ const u = window.USER_DATA?.sub || window.USER_DATA?.preferred_username;
return typeof u === 'string' && u.length > 0 ? u : 'anonymous';
}, []);
diff --git a/src/frontend/services/agent-rest.ts b/src/frontend/services/agent-rest.ts
index 54a1b0d..49b34d6 100644
--- a/src/frontend/services/agent-rest.ts
+++ b/src/frontend/services/agent-rest.ts
@@ -161,6 +161,66 @@ export async function deleteThread(threadId: string): Promise {
}
}
+/**
+ * Delete a single memory from the backend.
+ */
+export async function deleteMemory(memoryId: string): Promise {
+ try {
+ const resp = await authenticatedFetch(buildAgentApiUrl(`/memories/${memoryId}`), {
+ method: 'DELETE',
+ headers: getAuthHeaders(),
+ });
+ return resp.ok || resp.status === 404;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Delete all memories for the authenticated user.
+ */
+export async function deleteAllMemories(): Promise {
+ try {
+ const resp = await authenticatedFetch(buildAgentApiUrl('/memories'), {
+ method: 'DELETE',
+ headers: getAuthHeaders(),
+ });
+ return resp.ok;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Delete a single rule from the backend.
+ */
+export async function deleteRule(ruleId: string): Promise {
+ try {
+ const resp = await authenticatedFetch(buildAgentApiUrl(`/rules/${ruleId}`), {
+ method: 'DELETE',
+ headers: getAuthHeaders(),
+ });
+ return resp.ok || resp.status === 404;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Delete all rules for the authenticated user.
+ */
+export async function deleteAllRules(): Promise {
+ try {
+ const resp = await authenticatedFetch(buildAgentApiUrl('/rules'), {
+ method: 'DELETE',
+ headers: getAuthHeaders(),
+ });
+ return resp.ok;
+ } catch {
+ return false;
+ }
+}
+
/**
* Fetch full state for a single thread (lazy, on-demand).
* Called only when a user navigates into a specific chat.
From 0cc486481884314316a7ad41d4db85f6544e0118 Mon Sep 17 00:00:00 2001
From: Naveen Saharan
Date: Tue, 7 Jul 2026 18:17:14 +0530
Subject: [PATCH 02/10] fix: remove Content-Type header from DELETE requests to
prevent proxy rejection
Signed-off-by: Naveen Saharan
---
src/frontend/services/agent-rest.ts | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/src/frontend/services/agent-rest.ts b/src/frontend/services/agent-rest.ts
index 49b34d6..7453f74 100644
--- a/src/frontend/services/agent-rest.ts
+++ b/src/frontend/services/agent-rest.ts
@@ -97,10 +97,11 @@ function combineToolCallandResult(messages: Message[]) {
return newMessages;
}
-function getAuthHeaders(): Record {
- const headers: Record = {
- 'Content-Type': 'application/json',
- };
+function getAuthHeaders(includeContentType = true): Record {
+ const headers: Record = {};
+ if (includeContentType) {
+ headers['Content-Type'] = 'application/json';
+ }
if (window.USER_DATA?.accessToken) {
headers['X-Token'] = window.USER_DATA.accessToken;
}
@@ -153,7 +154,7 @@ export async function deleteThread(threadId: string): Promise {
try {
const resp = await authenticatedFetch(deleteUrl, {
method: 'DELETE',
- headers: getAuthHeaders(),
+ headers: getAuthHeaders(false),
});
return resp.ok || resp.status === 404;
} catch {
@@ -168,7 +169,7 @@ export async function deleteMemory(memoryId: string): Promise {
try {
const resp = await authenticatedFetch(buildAgentApiUrl(`/memories/${memoryId}`), {
method: 'DELETE',
- headers: getAuthHeaders(),
+ headers: getAuthHeaders(false),
});
return resp.ok || resp.status === 404;
} catch {
@@ -183,7 +184,7 @@ export async function deleteAllMemories(): Promise {
try {
const resp = await authenticatedFetch(buildAgentApiUrl('/memories'), {
method: 'DELETE',
- headers: getAuthHeaders(),
+ headers: getAuthHeaders(false),
});
return resp.ok;
} catch {
@@ -198,7 +199,7 @@ export async function deleteRule(ruleId: string): Promise {
try {
const resp = await authenticatedFetch(buildAgentApiUrl(`/rules/${ruleId}`), {
method: 'DELETE',
- headers: getAuthHeaders(),
+ headers: getAuthHeaders(false),
});
return resp.ok || resp.status === 404;
} catch {
@@ -213,7 +214,7 @@ export async function deleteAllRules(): Promise {
try {
const resp = await authenticatedFetch(buildAgentApiUrl('/rules'), {
method: 'DELETE',
- headers: getAuthHeaders(),
+ headers: getAuthHeaders(false),
});
return resp.ok;
} catch {
From 4b7a04d4aaaed52e387f26a02535c084f3bc67a6 Mon Sep 17 00:00:00 2001
From: Naveen Saharan
Date: Wed, 8 Jul 2026 12:23:41 +0530
Subject: [PATCH 03/10] feat: /memory command + fix stream cancel on navigation
- /memory command: typing "/memory " in chat saves to backend DB
- createMemory() API function added to agent-rest.ts
- MemoryList.tsx: add-memory now syncs to backend API
- Fix: cancel active stream when navigating between chats
- Fix: reload thread state from backend when returning to a chat
that only has human messages (stream was cancelled mid-flight)
Signed-off-by: Naveen Saharan
---
.../components/settings/MemoryList.tsx | 3 +-
src/frontend/hooks/useStreamingAPI.ts | 30 +++++++++++++++++++
src/frontend/pages/ChatPage.tsx | 5 +++-
src/frontend/services/agent-rest.ts | 16 ++++++++++
4 files changed, 52 insertions(+), 2 deletions(-)
diff --git a/src/frontend/components/settings/MemoryList.tsx b/src/frontend/components/settings/MemoryList.tsx
index f339b00..3632271 100644
--- a/src/frontend/components/settings/MemoryList.tsx
+++ b/src/frontend/components/settings/MemoryList.tsx
@@ -3,7 +3,7 @@ import { Button } from '@patternfly/react-core';
import { Plus, Trash2, Brain, AlertCircle } from 'lucide-react';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
import { addMemory, removeMemory, clearMemories, selectMemories } from '../../redux/slices/personalization';
-import { deleteMemory, deleteAllMemories } from '../../services/agent-rest';
+import { createMemory, deleteMemory, deleteAllMemories } from '../../services/agent-rest';
export function MemoryList() {
const dispatch = useAppDispatch();
@@ -14,6 +14,7 @@ export function MemoryList() {
const text = draft.trim();
if (!text) return;
dispatch(addMemory(text));
+ createMemory(text).catch(() => {});
setDraft('');
};
diff --git a/src/frontend/hooks/useStreamingAPI.ts b/src/frontend/hooks/useStreamingAPI.ts
index 1f0651c..a328410 100644
--- a/src/frontend/hooks/useStreamingAPI.ts
+++ b/src/frontend/hooks/useStreamingAPI.ts
@@ -250,6 +250,16 @@ export function useStreamingAPI(threadId: string) {
};
}, []);
+ useEffect(() => {
+ const manager = managerRef.current;
+ return () => {
+ const st = manager?.getStatus();
+ if (st === 'connecting' || st === 'streaming') {
+ manager?.cancel();
+ }
+ };
+ }, [threadId]);
+
useEffect(() => {
if (!threadId) return;
chatStorage.saveChatByThreadId(threadId, messages);
@@ -296,6 +306,26 @@ export function useStreamingAPI(threadId: string) {
const messageText = serializeLastMessage(clones);
if (messageText === '') return;
+ if (messageText.startsWith('/memory ')) {
+ const memoryContent = messageText.slice('/memory '.length).trim();
+ if (memoryContent) {
+ const { createMemory } = await import('@/services/agent-rest');
+ const saved = await createMemory(memoryContent);
+ const confirmMsg: Message = {
+ id: `msg-${Date.now()}`,
+ type: 'ai' as const,
+ content: saved
+ ? `Memory saved: "${memoryContent}"`
+ : `Failed to save memory. Please try again.`,
+ };
+ const updated = [...clones, confirmMsg];
+ dispatch(updateChat({ id: threadId, updates: { messages: updated } }));
+ setMessages(updated.map((m) => JSON.parse(JSON.stringify(m))));
+ chatStorage.saveChatByThreadId(threadId, updated);
+ }
+ return;
+ }
+
const token = typeof window.USER_DATA.accessToken === 'string' ? window.USER_DATA.accessToken : undefined;
const userId =
typeof window.USER_DATA.preferred_username === 'string'
diff --git a/src/frontend/pages/ChatPage.tsx b/src/frontend/pages/ChatPage.tsx
index f645bb8..a2ed44b 100644
--- a/src/frontend/pages/ChatPage.tsx
+++ b/src/frontend/pages/ChatPage.tsx
@@ -90,12 +90,15 @@ export function ChatPage({ threadId }: { threadId: string }) {
}, []);
useEffect(() => {
- if (!chatId || hasMessages || hydrating) return;
+ if (!chatId || hydrating) return;
const locState = location.state as Record | null;
if (locState?.initialPrompt != null) return;
if (isClientCreatedChat(chatId)) return;
+ const hasOnlyHumanMessages = hasMessages && currentChat?.messages.every(m => m.type === 'human');
+ if (hasMessages && !hasOnlyHumanMessages) return;
+
let cancelled = false;
setHydrating(true);
diff --git a/src/frontend/services/agent-rest.ts b/src/frontend/services/agent-rest.ts
index 7453f74..1db6684 100644
--- a/src/frontend/services/agent-rest.ts
+++ b/src/frontend/services/agent-rest.ts
@@ -162,6 +162,22 @@ export async function deleteThread(threadId: string): Promise {
}
}
+/**
+ * Create a persistent memory on the backend for the authenticated user.
+ */
+export async function createMemory(content: string): Promise {
+ try {
+ const resp = await authenticatedFetch(buildAgentApiUrl('/memories'), {
+ method: 'POST',
+ headers: getAuthHeaders(),
+ body: JSON.stringify({ content }),
+ });
+ return resp.ok;
+ } catch {
+ return false;
+ }
+}
+
/**
* Delete a single memory from the backend.
*/
From bb47129060b27e064760e355c0dac5418a0debf2 Mon Sep 17 00:00:00 2001
From: Naveen Saharan
Date: Wed, 8 Jul 2026 12:32:32 +0530
Subject: [PATCH 04/10] fix: load backend memories into Settings page on mount
Signed-off-by: Naveen Saharan
---
src/frontend/components/settings/MemoryList.tsx | 16 ++++++++++++++--
src/frontend/services/agent-rest.ts | 16 ++++++++++++++++
2 files changed, 30 insertions(+), 2 deletions(-)
diff --git a/src/frontend/components/settings/MemoryList.tsx b/src/frontend/components/settings/MemoryList.tsx
index 3632271..9f787bc 100644
--- a/src/frontend/components/settings/MemoryList.tsx
+++ b/src/frontend/components/settings/MemoryList.tsx
@@ -1,15 +1,27 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { Button } from '@patternfly/react-core';
import { Plus, Trash2, Brain, AlertCircle } from 'lucide-react';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
import { addMemory, removeMemory, clearMemories, selectMemories } from '../../redux/slices/personalization';
-import { createMemory, deleteMemory, deleteAllMemories } from '../../services/agent-rest';
+import { createMemory, deleteMemory, deleteAllMemories, listMemories } from '../../services/agent-rest';
export function MemoryList() {
const dispatch = useAppDispatch();
const memories = useAppSelector(selectMemories);
const [draft, setDraft] = useState('');
+ useEffect(() => {
+ listMemories().then((backendMems) => {
+ const localIds = new Set(memories.map((m) => m.content));
+ for (const bm of backendMems) {
+ if (!localIds.has(bm.content)) {
+ dispatch(addMemory(bm.content));
+ }
+ }
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
const handleAdd = () => {
const text = draft.trim();
if (!text) return;
diff --git a/src/frontend/services/agent-rest.ts b/src/frontend/services/agent-rest.ts
index 1db6684..c4d9959 100644
--- a/src/frontend/services/agent-rest.ts
+++ b/src/frontend/services/agent-rest.ts
@@ -162,6 +162,22 @@ export async function deleteThread(threadId: string): Promise {
}
}
+/**
+ * List all memories from the backend for the authenticated user.
+ */
+export async function listMemories(): Promise> {
+ try {
+ const resp = await authenticatedFetch(buildAgentApiUrl('/memories'), {
+ headers: getAuthHeaders(),
+ });
+ if (!resp.ok) return [];
+ const data = await resp.json();
+ return data.memories || [];
+ } catch {
+ return [];
+ }
+}
+
/**
* Create a persistent memory on the backend for the authenticated user.
*/
From acf3b027f5e6565bed951f48bed01fc9388d3412 Mon Sep 17 00:00:00 2001
From: Naveen Saharan
Date: Wed, 8 Jul 2026 12:35:27 +0530
Subject: [PATCH 05/10] fix: prevent duplicate memories when syncing backend to
localStorage
Signed-off-by: Naveen Saharan
---
src/frontend/components/settings/MemoryList.tsx | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/src/frontend/components/settings/MemoryList.tsx b/src/frontend/components/settings/MemoryList.tsx
index 9f787bc..345f04e 100644
--- a/src/frontend/components/settings/MemoryList.tsx
+++ b/src/frontend/components/settings/MemoryList.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { Button } from '@patternfly/react-core';
import { Plus, Trash2, Brain, AlertCircle } from 'lucide-react';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
@@ -9,12 +9,18 @@ export function MemoryList() {
const dispatch = useAppDispatch();
const memories = useAppSelector(selectMemories);
const [draft, setDraft] = useState('');
+ const syncedRef = useRef(false);
useEffect(() => {
+ if (syncedRef.current) return;
+ syncedRef.current = true;
listMemories().then((backendMems) => {
- const localIds = new Set(memories.map((m) => m.content));
+ const existing = new Set(
+ document.querySelectorAll('[data-memory-content]')
+ );
+ const localContents = new Set(memories.map((m) => m.content));
for (const bm of backendMems) {
- if (!localIds.has(bm.content)) {
+ if (!localContents.has(bm.content)) {
dispatch(addMemory(bm.content));
}
}
@@ -25,7 +31,10 @@ export function MemoryList() {
const handleAdd = () => {
const text = draft.trim();
if (!text) return;
- dispatch(addMemory(text));
+ const alreadyExists = memories.some((m) => m.content === text);
+ if (!alreadyExists) {
+ dispatch(addMemory(text));
+ }
createMemory(text).catch(() => {});
setDraft('');
};
From 9df525a0d04213df3d9633fc5a6a86623c2e3f47 Mon Sep 17 00:00:00 2001
From: Naveen Saharan
Date: Wed, 8 Jul 2026 12:41:14 +0530
Subject: [PATCH 06/10] fix: cancel backend stream and send beacon when
navigating between chats
Signed-off-by: Naveen Saharan
---
src/frontend/hooks/useStreamingAPI.ts | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/src/frontend/hooks/useStreamingAPI.ts b/src/frontend/hooks/useStreamingAPI.ts
index a328410..3f9dda3 100644
--- a/src/frontend/hooks/useStreamingAPI.ts
+++ b/src/frontend/hooks/useStreamingAPI.ts
@@ -252,10 +252,20 @@ export function useStreamingAPI(threadId: string) {
useEffect(() => {
const manager = managerRef.current;
+ const currentThreadId = threadIdRef.current;
return () => {
const st = manager?.getStatus();
if (st === 'connecting' || st === 'streaming') {
manager?.cancel();
+ const apiUrl = typeof window.APP_DATA?.apiUrl === 'string' ? window.APP_DATA.apiUrl : '';
+ const cancelUrl = apiUrl ? `${apiUrl}/v1/stream/cancel` : buildAgentApiUrl('/v1/stream/cancel');
+ if (typeof navigator.sendBeacon === 'function') {
+ const payload = JSON.stringify({
+ thread_id: currentThreadId,
+ event: 'client_stream_cancel',
+ });
+ navigator.sendBeacon(cancelUrl, new Blob([payload], { type: 'application/json' }));
+ }
}
};
}, [threadId]);
From a0b2eaa68eec33e37fb9480e6b8483d5800288dd Mon Sep 17 00:00:00 2001
From: Naveen Saharan
Date: Wed, 8 Jul 2026 16:37:30 +0530
Subject: [PATCH 07/10] fix: replace localStorage memories with backend data on
mount to prevent duplicates
Signed-off-by: Naveen Saharan
---
src/frontend/components/settings/MemoryList.tsx | 11 ++++-------
1 file changed, 4 insertions(+), 7 deletions(-)
diff --git a/src/frontend/components/settings/MemoryList.tsx b/src/frontend/components/settings/MemoryList.tsx
index 345f04e..dd8a67a 100644
--- a/src/frontend/components/settings/MemoryList.tsx
+++ b/src/frontend/components/settings/MemoryList.tsx
@@ -14,15 +14,12 @@ export function MemoryList() {
useEffect(() => {
if (syncedRef.current) return;
syncedRef.current = true;
+
listMemories().then((backendMems) => {
- const existing = new Set(
- document.querySelectorAll('[data-memory-content]')
- );
- const localContents = new Set(memories.map((m) => m.content));
+ if (backendMems.length === 0) return;
+ dispatch(clearMemories());
for (const bm of backendMems) {
- if (!localContents.has(bm.content)) {
- dispatch(addMemory(bm.content));
- }
+ dispatch(addMemory(bm.content));
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
From 7c6780bff121ac8760b37562d1908d84a14747ad Mon Sep 17 00:00:00 2001
From: Naveen Saharan
Date: Wed, 8 Jul 2026 17:48:15 +0530
Subject: [PATCH 08/10] fix: handle empty body DELETE requests in BFF proxy and
fix memory dedup
Signed-off-by: Naveen Saharan
---
src/server/router/proxy.router.ts | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/server/router/proxy.router.ts b/src/server/router/proxy.router.ts
index 34bf205..068dcff 100644
--- a/src/server/router/proxy.router.ts
+++ b/src/server/router/proxy.router.ts
@@ -318,6 +318,14 @@ function buildForwardedQueryString(query: Record): string {
async function proxyRoutes(fastify: FastifyInstance) {
await fastify.register(authCheckPlugin);
+ fastify.addContentTypeParser('application/json', { parseAs: 'string', bodyLimit: 1048576 }, (req, body, done) => {
+ if (!body || (typeof body === 'string' && body.trim() === '')) {
+ done(null, undefined);
+ } else {
+ try { done(null, JSON.parse(body as string)); } catch (err) { done(err as Error, undefined); }
+ }
+ });
+
/**
* Streaming endpoint — translates between the UI's simple
* {message, thread_id, user_id} payload and Aegra's LangGraph
@@ -691,9 +699,11 @@ async function proxyRoutes(fastify: FastifyInstance) {
}
const headers: Record = {
- 'Content-Type': 'application/json',
'X-Trace-ID': traceId,
};
+ if (request.method !== 'DELETE' && request.method !== 'GET') {
+ headers['Content-Type'] = 'application/json';
+ }
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
From af04af6d670b266beb154a8e9c589d5250b95088 Mon Sep 17 00:00:00 2001
From: Naveen Saharan
Date: Wed, 8 Jul 2026 18:23:26 +0530
Subject: [PATCH 09/10] fix: use preferred_username for thread search since
metadata stores it, not sub
Signed-off-by: Naveen Saharan
---
src/frontend/components/layout/AppLayout.tsx | 2 +-
src/frontend/pages/ChatPage.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/frontend/components/layout/AppLayout.tsx b/src/frontend/components/layout/AppLayout.tsx
index 91ee6ce..3c96368 100644
--- a/src/frontend/components/layout/AppLayout.tsx
+++ b/src/frontend/components/layout/AppLayout.tsx
@@ -116,7 +116,7 @@ export function AppLayout({ children }: AppLayoutProps) {
async function loadUserHistory() {
try {
dispatch(setLoadingThreads(true));
- const history = await getAllThreadsByUserId(window.USER_DATA.sub || window.USER_DATA.preferred_username);
+ const history = await getAllThreadsByUserId(window.USER_DATA.preferred_username || window.USER_DATA.sub);
const backendIds = new Set(history.map((t) => t.id));
const local = chatsRef.current;
diff --git a/src/frontend/pages/ChatPage.tsx b/src/frontend/pages/ChatPage.tsx
index a2ed44b..c7d01ae 100644
--- a/src/frontend/pages/ChatPage.tsx
+++ b/src/frontend/pages/ChatPage.tsx
@@ -85,7 +85,7 @@ export function ChatPage({ threadId }: { threadId: string }) {
const feedbackUserId = useMemo(() => {
if (typeof window === 'undefined') return 'anonymous';
- const u = window.USER_DATA?.sub || window.USER_DATA?.preferred_username;
+ const u = window.USER_DATA?.preferred_username || window.USER_DATA?.sub;
return typeof u === 'string' && u.length > 0 ? u : 'anonymous';
}, []);
From 15c2419af828e747fed1bc773eea3915966fdcc8 Mon Sep 17 00:00:00 2001
From: Naveen Saharan
Date: Thu, 30 Jul 2026 03:51:02 +0530
Subject: [PATCH 10/10] feat: user isolation UI hardening and backend sync
- Rules: load from backend on mount (listRules), save on add (createRule)
- Memories: preserve backend UUIDs with setMemories action
- localStorage: scope keys per user (dataverse-ai-chats:{sub})
- Personalization: start empty, load from backend (single source of truth)
- Feedback: use authenticatedFetch with USER_DATA.sub as userId
- chatStorage: scope CHATS_STORAGE_KEY per user
- auth-check: only set dummy user when session.user is unset
- test-login: dev-only endpoint for multi-user browser testing
Signed-off-by: Naveen Saharan
---
src/frontend/components/layout/AppLayout.tsx | 2 +-
.../components/settings/MemoryList.tsx | 7 ++--
.../components/settings/RulesEditor.tsx | 21 ++++++++++--
src/frontend/pages/ChatPage.tsx | 2 +-
src/frontend/redux/slices/personalization.ts | 30 ++++++++++++-----
src/frontend/services/agent-rest.ts | 32 +++++++++++++++++++
src/frontend/services/chatStorage.ts | 8 +++--
src/frontend/services/feedback-api.ts | 21 ++++++------
src/server/plugins/auth-check.plugin.ts | 2 +-
src/server/server.ts | 25 +++++++++++++++
10 files changed, 121 insertions(+), 29 deletions(-)
diff --git a/src/frontend/components/layout/AppLayout.tsx b/src/frontend/components/layout/AppLayout.tsx
index 3c96368..a722121 100644
--- a/src/frontend/components/layout/AppLayout.tsx
+++ b/src/frontend/components/layout/AppLayout.tsx
@@ -116,7 +116,7 @@ export function AppLayout({ children }: AppLayoutProps) {
async function loadUserHistory() {
try {
dispatch(setLoadingThreads(true));
- const history = await getAllThreadsByUserId(window.USER_DATA.preferred_username || window.USER_DATA.sub);
+ const history = await getAllThreadsByUserId(window.USER_DATA.preferred_username);
const backendIds = new Set(history.map((t) => t.id));
const local = chatsRef.current;
diff --git a/src/frontend/components/settings/MemoryList.tsx b/src/frontend/components/settings/MemoryList.tsx
index dd8a67a..88a6c06 100644
--- a/src/frontend/components/settings/MemoryList.tsx
+++ b/src/frontend/components/settings/MemoryList.tsx
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { Button } from '@patternfly/react-core';
import { Plus, Trash2, Brain, AlertCircle } from 'lucide-react';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
-import { addMemory, removeMemory, clearMemories, selectMemories } from '../../redux/slices/personalization';
+import { addMemory, setMemories, removeMemory, clearMemories, selectMemories } from '../../redux/slices/personalization';
import { createMemory, deleteMemory, deleteAllMemories, listMemories } from '../../services/agent-rest';
export function MemoryList() {
@@ -17,10 +17,7 @@ export function MemoryList() {
listMemories().then((backendMems) => {
if (backendMems.length === 0) return;
- dispatch(clearMemories());
- for (const bm of backendMems) {
- dispatch(addMemory(bm.content));
- }
+ dispatch(setMemories(backendMems));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
diff --git a/src/frontend/components/settings/RulesEditor.tsx b/src/frontend/components/settings/RulesEditor.tsx
index ca8bafc..01cb362 100644
--- a/src/frontend/components/settings/RulesEditor.tsx
+++ b/src/frontend/components/settings/RulesEditor.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { Button, Switch } from '@patternfly/react-core';
import { Plus, Trash2, ScrollText, AlertCircle } from 'lucide-react';
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
@@ -8,18 +8,35 @@ import {
toggleRule,
clearRules,
selectRules,
+ setRules,
} from '../../redux/slices/personalization';
-import { deleteRule, deleteAllRules } from '../../services/agent-rest';
+import { createRule, deleteRule, deleteAllRules, listRules } from '../../services/agent-rest';
export function RulesEditor() {
const dispatch = useAppDispatch();
const rules = useAppSelector(selectRules);
const [draft, setDraft] = useState('');
+ const loaded = useRef(false);
+
+ useEffect(() => {
+ if (loaded.current) return;
+ loaded.current = true;
+ listRules().then((backendRules) => {
+ if (backendRules.length === 0) return;
+ const merged = backendRules.map((br) => ({
+ id: br.id,
+ content: br.content,
+ isActive: br.is_active,
+ }));
+ dispatch(setRules(merged));
+ });
+ }, [dispatch]);
const handleAdd = () => {
const text = draft.trim();
if (!text) return;
dispatch(addRule(text));
+ createRule(text).catch(() => {});
setDraft('');
};
diff --git a/src/frontend/pages/ChatPage.tsx b/src/frontend/pages/ChatPage.tsx
index c7d01ae..daabd91 100644
--- a/src/frontend/pages/ChatPage.tsx
+++ b/src/frontend/pages/ChatPage.tsx
@@ -85,7 +85,7 @@ export function ChatPage({ threadId }: { threadId: string }) {
const feedbackUserId = useMemo(() => {
if (typeof window === 'undefined') return 'anonymous';
- const u = window.USER_DATA?.preferred_username || window.USER_DATA?.sub;
+ const u = window.USER_DATA?.preferred_username;
return typeof u === 'string' && u.length > 0 ? u : 'anonymous';
}, []);
diff --git a/src/frontend/redux/slices/personalization.ts b/src/frontend/redux/slices/personalization.ts
index b66f232..d4e6234 100644
--- a/src/frontend/redux/slices/personalization.ts
+++ b/src/frontend/redux/slices/personalization.ts
@@ -19,21 +19,18 @@ interface PersonalizationState {
rules: RuleItem[];
}
-const STORAGE_KEY = 'template-ui-personalization';
+function storageKey(): string {
+ const userId = globalThis.window?.USER_DATA?.sub || globalThis.window?.USER_DATA?.preferred_username || '';
+ return userId ? `template-ui-personalization:${userId}` : 'template-ui-personalization';
+}
function loadState(): PersonalizationState {
- try {
- const stored = localStorage.getItem(STORAGE_KEY);
- if (stored) return JSON.parse(stored);
- } catch {
- /* ignore */
- }
return { memories: [], rules: [] };
}
function persist(state: PersonalizationState) {
try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+ localStorage.setItem(storageKey(), JSON.stringify(state));
} catch {
/* ignore */
}
@@ -55,6 +52,14 @@ const personalizationSlice = createSlice({
state.memories = state.memories.filter((m) => m.id !== action.payload);
persist(state);
},
+ setMemories(state, action: PayloadAction>) {
+ state.memories = action.payload.map((m) => ({
+ id: m.id,
+ content: m.content,
+ createdAt: new Date().toISOString(),
+ }));
+ persist(state);
+ },
clearMemories(state) {
state.memories = [];
persist(state);
@@ -87,6 +92,13 @@ const personalizationSlice = createSlice({
state.rules = [];
persist(state);
},
+ setRules(state, action: PayloadAction>) {
+ state.rules = action.payload.map((r) => ({
+ ...r,
+ createdAt: new Date().toISOString(),
+ }));
+ persist(state);
+ },
resetPersonalization(state) {
state.memories = [];
state.rules = [];
@@ -97,6 +109,7 @@ const personalizationSlice = createSlice({
export const {
addMemory,
+ setMemories,
removeMemory,
clearMemories,
addRule,
@@ -104,6 +117,7 @@ export const {
toggleRule,
removeRule,
clearRules,
+ setRules,
resetPersonalization,
} = personalizationSlice.actions;
diff --git a/src/frontend/services/agent-rest.ts b/src/frontend/services/agent-rest.ts
index c4d9959..d7c8428 100644
--- a/src/frontend/services/agent-rest.ts
+++ b/src/frontend/services/agent-rest.ts
@@ -224,6 +224,38 @@ export async function deleteAllMemories(): Promise {
}
}
+/**
+ * List all rules from the backend for the authenticated user.
+ */
+export async function listRules(): Promise> {
+ try {
+ const resp = await authenticatedFetch(buildAgentApiUrl('/rules'), {
+ headers: getAuthHeaders(),
+ });
+ if (!resp.ok) return [];
+ const data = await resp.json();
+ return data.rules || [];
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Create a rule on the backend for the authenticated user.
+ */
+export async function createRule(content: string): Promise {
+ try {
+ const resp = await authenticatedFetch(buildAgentApiUrl('/rules'), {
+ method: 'POST',
+ headers: getAuthHeaders(),
+ body: JSON.stringify({ content, is_active: true }),
+ });
+ return resp.ok;
+ } catch {
+ return false;
+ }
+}
+
/**
* Delete a single rule from the backend.
*/
diff --git a/src/frontend/services/chatStorage.ts b/src/frontend/services/chatStorage.ts
index e4dab57..d83823f 100644
--- a/src/frontend/services/chatStorage.ts
+++ b/src/frontend/services/chatStorage.ts
@@ -1,8 +1,12 @@
import { ChatItem } from '../types/chat';
class ChatStorageService {
- private readonly CHATS_STORAGE_KEY = 'dataverse-ai-chats';
- private readonly MAX_CHATS = 50; // Limit to prevent localStorage bloat
+ private readonly MAX_CHATS = 50;
+
+ private get CHATS_STORAGE_KEY(): string {
+ const userId = globalThis.window?.USER_DATA?.sub || globalThis.window?.USER_DATA?.preferred_username || '';
+ return userId ? `dataverse-ai-chats:${userId}` : 'dataverse-ai-chats';
+ }
/**
* Save chats to localStorage with error handling and size limits
diff --git a/src/frontend/services/feedback-api.ts b/src/frontend/services/feedback-api.ts
index f3e49bc..c8b8dc8 100644
--- a/src/frontend/services/feedback-api.ts
+++ b/src/frontend/services/feedback-api.ts
@@ -1,3 +1,4 @@
+import { authenticatedFetch } from './authenticated-fetch';
import { buildAgentApiUrl } from '../lib/app-paths';
export interface FeedbackPayload {
@@ -10,11 +11,15 @@ export interface FeedbackPayload {
userId?: string;
}
+function getCurrentUserId(): string {
+ return window.USER_DATA?.sub || window.USER_DATA?.preferred_username || 'anonymous';
+}
+
export async function submitFeedback(payload: FeedbackPayload): Promise {
- const response = await fetch(buildAgentApiUrl('/feedback'), {
+ const userId = payload.userId || getCurrentUserId();
+ const response = await authenticatedFetch(buildAgentApiUrl('/feedback'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
body: JSON.stringify({
trace_id: payload.traceId,
name: payload.name,
@@ -22,7 +27,7 @@ export async function submitFeedback(payload: FeedbackPayload): Promise {
kwargs: payload.comment ? { comment: payload.comment } : {},
thread_id: payload.threadId,
message_id: payload.messageId,
- user_id: payload.userId || 'anonymous',
+ user_id: userId,
}),
});
if (!response.ok) {
@@ -32,13 +37,11 @@ export async function submitFeedback(payload: FeedbackPayload): Promise {
export async function getThreadFeedback(
threadId: string,
- userId: string = 'anonymous',
+ userId?: string,
): Promise> {
- const response = await fetch(
- `${buildAgentApiUrl(`/feedback/${encodeURIComponent(threadId)}`)}?user_id=${encodeURIComponent(userId)}`,
- {
- credentials: 'include',
- },
+ const effectiveUserId = userId || getCurrentUserId();
+ const response = await authenticatedFetch(
+ `${buildAgentApiUrl(`/feedback/${encodeURIComponent(threadId)}`)}?user_id=${encodeURIComponent(effectiveUserId)}`,
);
if (!response.ok) return {};
const data = (await response.json()) as { feedback?: Array<{ message_id: string; feedback: 'up' | 'down' }> };
diff --git a/src/server/plugins/auth-check.plugin.ts b/src/server/plugins/auth-check.plugin.ts
index 4527cb4..89f29c5 100644
--- a/src/server/plugins/auth-check.plugin.ts
+++ b/src/server/plugins/auth-check.plugin.ts
@@ -72,7 +72,7 @@ function authCheck(
preferred_username: gwEmail.split("@")[0],
sub: gwSub || gwEmail,
};
- } else {
+ } else if (!request.session.user) {
const dummyUser = {
accessToken: "access-token",
expiresAt: "2026-10-29T23:20:00.417Z",
diff --git a/src/server/server.ts b/src/server/server.ts
index 18b4b23..7a128ad 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -146,6 +146,31 @@ export async function setupServer(): Promise {
await fastify.register(logoutPlugin);
+ if (process.env.ENVIRONMENT === "development" && process.env.TEST_JWT_DIR) {
+ const fs = await import("fs");
+ fastify.get("/test-login/:username", async (request, reply) => {
+ const { username } = request.params as { username: string };
+ const tokenFile = `${process.env.TEST_JWT_DIR}/${username}_token.txt`;
+ if (!fs.existsSync(tokenFile)) {
+ return reply.status(404).send({ error: `No token for '${username}'. Generate with jwt_provider.` });
+ }
+ const accessToken = fs.readFileSync(tokenFile, "utf-8").trim();
+ const session = (request as any).session;
+ session.user = {
+ sub: username,
+ preferred_username: username,
+ name: username.charAt(0).toUpperCase() + username.slice(1),
+ displayName: username.charAt(0).toUpperCase() + username.slice(1),
+ email: `${username}@test.local`,
+ };
+ session.token = {
+ access_token: accessToken,
+ expires_at: new Date(Date.now() + 3600_000).toISOString(),
+ };
+ return reply.redirect("/");
+ });
+ }
+
await fastify.register(apiRoutes, { prefix: "/api" });
await fastify.register(proxyRoutes, { prefix: "/api" });