Skip to content
Merged
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
175 changes: 107 additions & 68 deletions apps/web/stores/chat.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,120 @@
import type { UIMessage } from "@ai-sdk/react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { UIMessage } from "@ai-sdk/react"
import { create } from "zustand"
import { persist } from "zustand/middleware"

/**
* Deep equality check for UIMessage arrays to prevent unnecessary state updates
*/
function areUIMessageArraysEqual(a: UIMessage[], b: UIMessage[]): boolean {
if (a === b) return true
if (a.length !== b.length) return false

for (let i = 0; i < a.length; i++) {
const msgA = a[i]
const msgB = b[i]

// Both messages should exist at this index
if (!msgA || !msgB) return false

if (msgA === msgB) continue

if (msgA.id !== msgB.id || msgA.role !== msgB.role) {
return false
}

// Compare the entire message using JSON serialization as a fallback
// This handles all properties including parts, toolInvocations, etc.
if (JSON.stringify(msgA) !== JSON.stringify(msgB)) {
return false
}
}

return true
}

export interface ConversationSummary {
id: string;
title?: string;
lastUpdated: string;
id: string
title?: string
lastUpdated: string
}

interface ConversationRecord {
messages: UIMessage[];
title?: string;
lastUpdated: string;
messages: UIMessage[]
title?: string
lastUpdated: string
}

interface ProjectConversationsState {
currentChatId: string | null;
conversations: Record<string, ConversationRecord>;
currentChatId: string | null
conversations: Record<string, ConversationRecord>
}

interface ConversationsStoreState {
byProject: Record<string, ProjectConversationsState>;
setCurrentChatId: (projectId: string, chatId: string | null) => void;
byProject: Record<string, ProjectConversationsState>
setCurrentChatId: (projectId: string, chatId: string | null) => void
setConversation: (
projectId: string,
chatId: string,
messages: UIMessage[],
) => void;
deleteConversation: (projectId: string, chatId: string) => void;
) => void
deleteConversation: (projectId: string, chatId: string) => void
setConversationTitle: (
projectId: string,
chatId: string,
title: string | undefined,
) => void;
) => void
}

export const usePersistentChatStore = create<ConversationsStoreState>()(
persist(
(set, get) => ({
(set, _get) => ({
byProject: {},

setCurrentChatId(projectId, chatId) {
set((state) => {
const project = state.byProject[projectId] ?? {
currentChatId: null,
conversations: {},
};
}
return {
byProject: {
...state.byProject,
[projectId]: { ...project, currentChatId: chatId },
},
};
});
}
})
},

setConversation(projectId, chatId, messages) {
const now = new Date().toISOString();
const now = new Date().toISOString()
set((state) => {
const project = state.byProject[projectId] ?? {
currentChatId: null,
conversations: {},
};
const existing = project.conversations[chatId];
}
const existing = project.conversations[chatId]

// Check if messages are actually different to prevent unnecessary updates
if (
existing &&
areUIMessageArraysEqual(existing.messages, messages)
) {
return state // No change needed
}

const shouldTouchLastUpdated = (() => {
if (!existing) return messages.length > 0;
const previousLength = existing.messages?.length ?? 0;
return messages.length > previousLength;
})();
if (!existing) return messages.length > 0
const previousLength = existing.messages?.length ?? 0
return messages.length > previousLength
})()

const record: ConversationRecord = {
messages,
title: existing?.title,
lastUpdated: shouldTouchLastUpdated
? now
: (existing?.lastUpdated ?? now),
};
}
return {
byProject: {
...state.byProject,
Expand All @@ -87,37 +126,37 @@ export const usePersistentChatStore = create<ConversationsStoreState>()(
},
},
},
};
});
}
})
},

deleteConversation(projectId, chatId) {
set((state) => {
const project = state.byProject[projectId] ?? {
currentChatId: null,
conversations: {},
};
const { [chatId]: _, ...rest } = project.conversations;
}
const { [chatId]: _, ...rest } = project.conversations
const nextCurrent =
project.currentChatId === chatId ? null : project.currentChatId;
project.currentChatId === chatId ? null : project.currentChatId
return {
byProject: {
...state.byProject,
[projectId]: { currentChatId: nextCurrent, conversations: rest },
},
};
});
}
})
},

setConversationTitle(projectId, chatId, title) {
const now = new Date().toISOString();
const now = new Date().toISOString()
set((state) => {
const project = state.byProject[projectId] ?? {
currentChatId: null,
conversations: {},
};
const existing = project.conversations[chatId];
if (!existing) return { byProject: state.byProject };
}
const existing = project.conversations[chatId]
if (!existing) return { byProject: state.byProject }
return {
byProject: {
...state.byProject,
Expand All @@ -129,76 +168,76 @@ export const usePersistentChatStore = create<ConversationsStoreState>()(
},
},
},
};
});
}
})
},
}),
{
name: "supermemory-chats",
},
),
);
)

// Always scoped to the current project via useProject
import { useProject } from ".";
import { useProject } from "."

export function usePersistentChat() {
const { selectedProject } = useProject();
const projectId = selectedProject;
const { selectedProject } = useProject()
const projectId = selectedProject

const projectState = usePersistentChatStore((s) => s.byProject[projectId]);
const setCurrentChatIdRaw = usePersistentChatStore((s) => s.setCurrentChatId);
const setConversationRaw = usePersistentChatStore((s) => s.setConversation);
const projectState = usePersistentChatStore((s) => s.byProject[projectId])
const setCurrentChatIdRaw = usePersistentChatStore((s) => s.setCurrentChatId)
const setConversationRaw = usePersistentChatStore((s) => s.setConversation)
const deleteConversationRaw = usePersistentChatStore(
(s) => s.deleteConversation,
);
)
const setConversationTitleRaw = usePersistentChatStore(
(s) => s.setConversationTitle,
);
)

const conversations: ConversationSummary[] = (() => {
const convs = projectState?.conversations ?? {};
const convs = projectState?.conversations ?? {}
return Object.entries(convs).map(([id, rec]) => ({
id,
title: rec.title,
lastUpdated: rec.lastUpdated,
}));
})();
}))
})()

const currentChatId = projectState?.currentChatId ?? null;
const currentChatId = projectState?.currentChatId ?? null

function setCurrentChatId(chatId: string | null): void {
setCurrentChatIdRaw(projectId, chatId);
setCurrentChatIdRaw(projectId, chatId)
}

function setConversation(chatId: string, messages: UIMessage[]): void {
setConversationRaw(projectId, chatId, messages);
setConversationRaw(projectId, chatId, messages)
}

function deleteConversation(chatId: string): void {
deleteConversationRaw(projectId, chatId);
deleteConversationRaw(projectId, chatId)
}

function setConversationTitle(
chatId: string,
title: string | undefined,
): void {
setConversationTitleRaw(projectId, chatId, title);
setConversationTitleRaw(projectId, chatId, title)
}

function getCurrentConversation(): UIMessage[] | undefined {
const convs = projectState?.conversations ?? {};
const id = currentChatId;
if (!id) return undefined;
return convs[id]?.messages;
const convs = projectState?.conversations ?? {}
const id = currentChatId
if (!id) return undefined
return convs[id]?.messages
}

function getCurrentChat(): ConversationSummary | undefined {
const id = currentChatId;
if (!id) return undefined;
const rec = projectState?.conversations?.[id];
if (!rec) return undefined;
return { id, title: rec.title, lastUpdated: rec.lastUpdated };
const id = currentChatId
if (!id) return undefined
const rec = projectState?.conversations?.[id]
if (!rec) return undefined
return { id, title: rec.title, lastUpdated: rec.lastUpdated }
}

return {
Expand All @@ -210,5 +249,5 @@ export function usePersistentChat() {
setConversationTitle,
getCurrentConversation,
getCurrentChat,
};
}
}