diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 5610f006..04d18fa5 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -407,6 +407,7 @@ async function handlePOST(req: Request) { const memoryWrappedModel = maybeWithSupermemory(tracedModel, { userId: userId ?? "", + workspaceId: body.workspaceId, threadId, memoryEnabled, }); diff --git a/src/components/chat/ChatProvider.tsx b/src/components/chat/ChatProvider.tsx index 63f8c85e..b53c826f 100644 --- a/src/components/chat/ChatProvider.tsx +++ b/src/components/chat/ChatProvider.tsx @@ -2,24 +2,23 @@ import { Chat, useChat } from "@ai-sdk/react"; import { useQueryClient } from "@tanstack/react-query"; -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; - -import { useChatRuntimesContext } from "@/components/chat/ChatRuntimes"; -import { chatQueryKeys } from "@/lib/chat/queries"; +import { createContext, useCallback, useContext, useEffect, useEffectEvent, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; +import { toast } from "sonner"; +import { useShallow } from "zustand/react/shallow"; + +import { useWorkspaceContext } from "@/contexts/WorkspaceContext"; +import { useViewingItemIds } from "@/hooks/ui/use-viewing-item-ids"; +import { useWorkspaceContextProvider } from "@/hooks/ai/use-workspace-context-provider"; +import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { chatDebug, summarizeRoster } from "@/lib/chat/debug"; +import { chatQueryKeys, fetchThreadMessages, type ThreadListItem } from "@/lib/chat/queries"; +import { createChatTransport } from "@/lib/chat/transport"; import type { ChatMessage } from "@/lib/chat/types"; -import { - selectCurrentThreadId, - useWorkspaceStore, -} from "@/lib/stores/workspace-store"; +import { hasMeaningfulContent } from "@/lib/chat/types"; +import { type ThreadStatus } from "@/lib/chat/thread-runtime-state"; +import { useUIStore } from "@/lib/stores/ui-store"; +import { selectCurrentThreadId, useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { formatSelectedCardsMetadata } from "@/lib/utils/format-workspace-context"; interface ChatContextValue { threadId: string; @@ -32,27 +31,159 @@ interface ChatContextValue { regenerate: ReturnType>["regenerate"]; stop: ReturnType["stop"]; clearError: ReturnType["clearError"]; - /** True until the runtime for the active thread is ready (hydrated). */ isHistoryLoading: boolean; selectThread: (threadId: string) => void; - /** Reset the active chat (new thread id, empty messages). */ startNewThread: () => void; } +interface ChatManagerContextValue { + workspaceId: string; + aliveThreadIds: readonly string[]; + threadOrderVersion: number; + getRuntime: (threadId: string) => Chat | undefined; + disposeThread: (threadId: string) => void; + getThreadStatusSnapshot: (threadId: string | undefined) => ThreadStatus; + getThreadLastStartedAt: (threadId: string | undefined) => number; +} + const ChatContext = createContext(null); +const ChatManagerContext = createContext(null); + +function getStatusSubscription(chat: Chat) { + const register = ( + chat as Chat & { + "~registerStatusCallback"?: (onChange: () => void) => () => void; + } + )["~registerStatusCallback"]; + + if (typeof register !== "function") { + if (process.env.NODE_ENV !== "production") { + console.warn( + "[ChatProvider] Chat['~registerStatusCallback'] is unavailable. Per-thread status subscriptions are disabled.", + ); + } + return null; + } + + return register.bind(chat); +} + +function describeChatError(error: Error): { + title: string; + description: string; +} { + const errorMessage = error.message?.toLowerCase() || ""; + const responseBody = ( + error as unknown as { responseBody?: string } + ).responseBody?.toLowerCase() || ""; + const errorData = ( + error as unknown as { data?: { error?: { message?: string } } } + ).data?.error?.message?.toLowerCase() || ""; + const combined = `${errorMessage} ${responseBody} ${errorData}`; + + if ( + combined.includes("timeout") || + combined.includes("504") || + combined.includes("gateway") + ) { + return { + title: "Request timed out", + description: "The AI is taking too long to respond. Please try again.", + }; + } + if ( + combined.includes("network") || + combined.includes("fetch") || + combined.includes("failed to fetch") + ) { + return { + title: "Connection error", + description: "Unable to reach the server. Please check your connection.", + }; + } + if (combined.includes("500") || combined.includes("internal server")) { + return { + title: "Server error", + description: "Something went wrong on our end. Please try again.", + }; + } + if (combined.includes("429") || combined.includes("rate limit")) { + return { + title: "Rate limited", + description: "Too many requests. Please wait a moment and try again.", + }; + } + if (combined.includes("401") || combined.includes("unauthorized")) { + return { + title: "Authentication error", + description: "Your session may have expired. Please refresh the page.", + }; + } + if ( + combined.includes("api key not valid") || + combined.includes("api_key_invalid") || + combined.includes("api key not defined") || + combined.includes("api key is not set") || + (combined.includes("api key") && + (combined.includes("not valid") || combined.includes("invalid"))) + ) { + return { + title: "AI backend configuration error", + description: + "Please check the AI provider or gateway environment variables configured for your self-hosted deployment.", + }; + } + return { + title: "Something went wrong", + description: + error.message || "An unexpected error occurred. Please try again.", + }; +} export function useChatContext(): ChatContextValue { const ctx = useContext(ChatContext); - if (!ctx) + if (!ctx) { throw new Error("useChatContext must be used inside "); + } return ctx; } -/** Same hook, but does not throw when used outside. Use sparingly. */ export function useChatContextSafe(): ChatContextValue | null { return useContext(ChatContext); } +export function useChatThreadManager(): ChatManagerContextValue { + const ctx = useContext(ChatManagerContext); + if (!ctx) { + throw new Error( + "useChatThreadManager must be used inside ", + ); + } + return ctx; +} + +export function useThreadStatus(threadId: string | undefined): ThreadStatus { + const { aliveThreadIds, getRuntime } = useChatThreadManager(); + const chat = + threadId && aliveThreadIds.includes(threadId) + ? getRuntime(threadId) + : undefined; + + return useSyncExternalStore( + useCallback( + (onChange) => { + if (!chat) return () => {}; + const register = getStatusSubscription(chat); + if (!register) return () => {}; + return register(onChange); + }, + [chat], + ), + () => (chat ? (chat.status as ThreadStatus) : "idle"), + () => "idle", + ); +} + interface ChatProviderProps { workspaceId: string; children: ReactNode; @@ -64,76 +195,315 @@ function generateThreadId(): string { } return `thread-${Math.random().toString(36).slice(2)}-${Date.now()}`; } -/** - * Owns the *visible* threadId selection for a workspace and binds the - * current Chat runtime to the rest of the chat surface. - * - * Concurrent generation across threads is achieved by storing one `Chat` - * instance per threadId in ``'s in-memory registry. - * Switching threads simply rebinds via `useChat({ chat })`; previous - * runtimes keep streaming in the background. - */ + export function ChatProvider({ workspaceId, children }: ChatProviderProps) { const queryClient = useQueryClient(); + const persistedThreadId = useWorkspaceStore( selectCurrentThreadId(workspaceId), ); const setCurrentThreadId = useWorkspaceStore( (state) => state.setCurrentThreadId, ); - const { ensureRuntime, getRuntime, aliveThreadIds } = - useChatRuntimesContext(); + + const selectedModelId = useUIStore((state) => state.selectedModelId); + const memoryEnabled = useUIStore((state) => state.memoryEnabled); + const activeFolderId = useUIStore((state) => state.activeFolderId); + const selectedCardIdsSet = useUIStore((state) => state.selectedCardIds); + const activePdfPageByItemId = useUIStore( + useShallow((state) => state.activePdfPageByItemId), + ); + const { state: workspaceState } = useWorkspaceState(workspaceId); + const viewingItemIds = useViewingItemIds(); + const { currentWorkspace } = useWorkspaceContext(); + const systemPrompt = useWorkspaceContextProvider( + workspaceId, + workspaceState, + currentWorkspace?.name, + ); + + const contextCardIds = useMemo(() => { + const ids = new Set(selectedCardIdsSet); + viewingItemIds.forEach((id) => ids.add(id)); + return ids; + }, [selectedCardIdsSet, viewingItemIds]); + + const selectedCardsContext = useMemo(() => { + if (contextCardIds.size === 0) return ""; + const contextItems = workspaceState.filter((item) => + contextCardIds.has(item.id), + ); + if (contextItems.length === 0) return ""; + return formatSelectedCardsMetadata( + contextItems, + workspaceState, + activePdfPageByItemId, + viewingItemIds, + ); + }, [workspaceState, contextCardIds, activePdfPageByItemId, viewingItemIds]); + + const transportContext = useMemo( + () => ({ + workspaceId, + modelId: selectedModelId, + memoryEnabled, + activeFolderId, + selectedCardsContext, + system: systemPrompt, + }), + [ + activeFolderId, + memoryEnabled, + selectedCardsContext, + selectedModelId, + systemPrompt, + workspaceId, + ], + ); + + const getTransportContext = useEffectEvent(() => transportContext); + + const [transport] = useState(() => + createChatTransport(getTransportContext), + ); + + const runtimesRef = useRef>>(new Map()); + const pendingRef = useRef>>>(new Map()); + const threadLastStartedAtRef = useRef>(new Map()); + const runtimeStatusDisposersRef = useRef void>>(new Map()); + const generationRef = useRef(0); + const [aliveThreadIds, setAliveThreadIds] = useState([]); + const [threadOrderVersion, setThreadOrderVersion] = useState(0); + const [activeRuntime, setActiveRuntime] = useState | null>( + null, + ); + + const bumpThreadOrderVersion = useCallback(() => { + setThreadOrderVersion((value) => value + 1); + }, []); + + const handleError = useCallback((error: Error) => { + console.error("[Chat Error]", error); + const { title, description } = describeChatError(error); + toast.error(title, { description }); + }, []); + + const buildRuntime = useCallback( + (threadId: string, initialMessages: ChatMessage[]): Chat => { + const cleaned = initialMessages.filter(hasMeaningfulContent); + chatDebug("runtime: create", { + threadId, + droppedEmpty: initialMessages.length - cleaned.length, + ...summarizeRoster(cleaned as unknown[]), + }); + + const chat = new Chat({ + id: threadId, + transport, + messages: cleaned, + onError: handleError, + onData: (dataPart) => { + if (dataPart.type !== "data-chat-title") return; + const title = + typeof dataPart.data === "string" ? dataPart.data : undefined; + if (!title) return; + queryClient.setQueryData( + chatQueryKeys.threads(workspaceId), + (prev) => + prev?.map((thread) => + thread.id === threadId ? { ...thread, title } : thread, + ), + ); + }, + onFinish: () => { + void queryClient.invalidateQueries({ + queryKey: chatQueryKeys.threads(workspaceId), + }); + }, + }); + + const register = getStatusSubscription(chat); + if (register) { + const dispose = register(() => { + bumpThreadOrderVersion(); + }); + runtimeStatusDisposersRef.current.set(threadId, dispose); + } + + return chat; + }, + [bumpThreadOrderVersion, handleError, queryClient, transport, workspaceId], + ); + + const ensureRuntime = useCallback( + async (threadId: string, opts: { hydrate: boolean }) => { + const existing = runtimesRef.current.get(threadId); + if (existing) return existing; + + const inFlight = pendingRef.current.get(threadId); + if (inFlight) return inFlight; + + const generation = generationRef.current; + let promise!: Promise>; + promise = (async () => { + let initial: ChatMessage[] = []; + + if (opts.hydrate) { + try { + initial = await fetchThreadMessages(threadId); + } catch (error) { + console.warn("[ChatProvider] failed to hydrate thread", { + threadId, + error, + }); + initial = []; + } + } + + if (generationRef.current !== generation) { + throw new Error("runtime build superseded"); + } + if (pendingRef.current.get(threadId) !== promise) { + throw new Error("runtime build superseded"); + } + + pendingRef.current.delete(threadId); + + const chat = buildRuntime(threadId, initial); + runtimesRef.current.set(threadId, chat); + setAliveThreadIds((prev) => + prev.includes(threadId) ? prev : [...prev, threadId], + ); + bumpThreadOrderVersion(); + return chat; + })(); + + pendingRef.current.set(threadId, promise); + return promise; + }, + [buildRuntime, bumpThreadOrderVersion], + ); + + const getRuntime = useCallback( + (threadId: string) => runtimesRef.current.get(threadId), + [], + ); + + const disposeThread = useCallback( + (threadId: string) => { + pendingRef.current.delete(threadId); + + const statusDispose = runtimeStatusDisposersRef.current.get(threadId); + if (statusDispose) { + statusDispose(); + runtimeStatusDisposersRef.current.delete(threadId); + } + + threadLastStartedAtRef.current.delete(threadId); + + const chat = runtimesRef.current.get(threadId); + if (chat) { + try { + chat.stop(); + } catch { + // noop + } + runtimesRef.current.delete(threadId); + } + + setActiveRuntime((current) => + current?.id === threadId ? null : current, + ); + setAliveThreadIds((prev) => prev.filter((id) => id !== threadId)); + bumpThreadOrderVersion(); + }, + [bumpThreadOrderVersion], + ); + + useEffect(() => { + const runtimeStatusDisposers = runtimeStatusDisposersRef.current; + const runtimes = runtimesRef.current; + const pending = pendingRef.current; + const threadLastStartedAt = threadLastStartedAtRef.current; + + return () => { + generationRef.current += 1; + + runtimeStatusDisposers.forEach((dispose) => dispose()); + runtimeStatusDisposers.clear(); + + runtimes.forEach((chat) => { + try { + chat.stop(); + } catch { + // noop + } + }); + + runtimes.clear(); + pending.clear(); + threadLastStartedAt.clear(); + setAliveThreadIds([]); + bumpThreadOrderVersion(); + }; + }, [workspaceId, bumpThreadOrderVersion]); const [threadId, setThreadId] = useState( () => persistedThreadId ?? generateThreadId(), ); - const [shouldHydrateOnFirstLoad, setShouldHydrateOnFirstLoad] = useState( + const [shouldHydrateOnOpen, setShouldHydrateOnOpen] = useState( () => !!persistedThreadId, ); - const threadIdRef = useRef(threadId); const previousWorkspaceIdRef = useRef(workspaceId); - useEffect(() => { - threadIdRef.current = threadId; - }, [threadId]); - useEffect(() => { const workspaceChanged = previousWorkspaceIdRef.current !== workspaceId; previousWorkspaceIdRef.current = workspaceId; - if (!persistedThreadId) { - setShouldHydrateOnFirstLoad(false); - if (workspaceChanged) { - setThreadId(generateThreadId()); - } + if (persistedThreadId) { + setThreadId((current) => + workspaceChanged || current !== persistedThreadId + ? persistedThreadId + : current, + ); + setActiveRuntime(getRuntime(persistedThreadId) ?? null); + setShouldHydrateOnOpen(true); return; } - if (workspaceChanged || persistedThreadId !== threadIdRef.current) { - setThreadId(persistedThreadId); - setShouldHydrateOnFirstLoad(true); + setShouldHydrateOnOpen(false); + setActiveRuntime(null); + if (workspaceChanged) { + setThreadId(generateThreadId()); } - }, [persistedThreadId, workspaceId]); + }, [getRuntime, persistedThreadId, workspaceId]); - // Kick off (or short-circuit on) the runtime for the active threadId. The - // resolved Chat is stored in the workspace-level registry; we read it back - // synchronously below so a thread switch never displays a stale runtime. useEffect(() => { + let cancelled = false; + void ensureRuntime(threadId, { - hydrate: shouldHydrateOnFirstLoad, - }).catch((err) => { - const isSuperseded = - err instanceof Error && /superseded/i.test(err.message); - if (!isSuperseded) { - console.error("[ChatProvider] failed to ensure runtime", err); - } - }); - }, [threadId, shouldHydrateOnFirstLoad, ensureRuntime]); + hydrate: shouldHydrateOnOpen, + }) + .then((runtime) => { + if (!cancelled) { + setActiveRuntime(runtime); + } + }) + .catch((error) => { + const isSuperseded = + error instanceof Error && /superseded/i.test(error.message); + if (!isSuperseded) { + console.error("[ChatProvider] failed to ensure runtime", error); + } + }); + + return () => { + cancelled = true; + }; + }, [ensureRuntime, shouldHydrateOnOpen, threadId]); + + const chat = activeRuntime; - const chat = useMemo | null>(() => { - if (!aliveThreadIds.includes(threadId)) return null; - return getRuntime(threadId) ?? null; - }, [aliveThreadIds, getRuntime, threadId]); const pendingTransport = useMemo( () => ({ sendMessages: async () => { @@ -143,6 +513,7 @@ export function ChatProvider({ workspaceId, children }: ChatProviderProps) { }), [], ); + const pendingChat = useMemo( () => new Chat({ @@ -152,57 +523,105 @@ export function ChatProvider({ workspaceId, children }: ChatProviderProps) { }), [pendingTransport], ); + const activeChat = chat ?? pendingChat; + const { + messages, + status, + error, + sendMessage, + regenerate, + stop, + clearError, + setMessages, + } = useChat({ chat: activeChat }); + + const markThreadStarted = useCallback( + (targetThreadId: string) => { + threadLastStartedAtRef.current.set(targetThreadId, Date.now()); + bumpThreadOrderVersion(); + queryClient.setQueryData( + chatQueryKeys.threads(workspaceId), + (previous) => { + if (!previous) { + return [ + { + id: targetThreadId, + status: "regular", + }, + ]; + } + + if (previous.some((thread) => thread.id === targetThreadId)) { + return previous; + } + + return [ + { + id: targetThreadId, + status: "regular", + }, + ...previous, + ]; + }, + ); + void queryClient.invalidateQueries({ + queryKey: chatQueryKeys.threads(workspaceId), + }); + }, + [bumpThreadOrderVersion, queryClient, workspaceId], + ); + + const persistActiveThreadSelection = useCallback(() => { + if (persistedThreadId !== threadId) { + setCurrentThreadId(workspaceId, threadId); + } + }, [persistedThreadId, setCurrentThreadId, threadId, workspaceId]); + const selectThread = useCallback( (nextThreadId: string) => { + setActiveRuntime(getRuntime(nextThreadId) ?? null); setThreadId(nextThreadId); - setShouldHydrateOnFirstLoad(true); + setShouldHydrateOnOpen(true); setCurrentThreadId(workspaceId, nextThreadId); }, - [setCurrentThreadId, workspaceId], + [getRuntime, setCurrentThreadId, workspaceId], ); const startNewThread = useCallback(() => { - const next = generateThreadId(); - setThreadId(next); - setShouldHydrateOnFirstLoad(false); + const nextThreadId = generateThreadId(); + setActiveRuntime(null); + setThreadId(nextThreadId); + setShouldHydrateOnOpen(false); void queryClient.invalidateQueries({ queryKey: chatQueryKeys.threads(workspaceId), }); }, [queryClient, workspaceId]); - const { - messages, - status, - error, - sendMessage, - regenerate, - stop, - clearError, - setMessages, - } = useChat({ chat: activeChat }); const sendMessageWithPersistence = useCallback< ChatContextValue["sendMessage"] >( (...args) => { if (!chat) return Promise.resolve(undefined); - if (persistedThreadId !== threadId) { - setCurrentThreadId(workspaceId, threadId); - } + markThreadStarted(threadId); + persistActiveThreadSelection(); return sendMessage(...args); }, - [ - chat, - persistedThreadId, - sendMessage, - setCurrentThreadId, - threadId, - workspaceId, - ], + [chat, markThreadStarted, persistActiveThreadSelection, sendMessage, threadId], + ); + + const regenerateWithTracking = useCallback( + (...args) => { + if (!chat) return Promise.resolve(undefined); + markThreadStarted(threadId); + persistActiveThreadSelection(); + return regenerate(...args); + }, + [chat, markThreadStarted, persistActiveThreadSelection, regenerate, threadId], ); - const value = useMemo( + const chatValue = useMemo( () => ({ threadId, workspaceId, @@ -211,7 +630,7 @@ export function ChatProvider({ workspaceId, children }: ChatProviderProps) { messages: chat ? (messages as ChatMessage[]) : [], setMessages: chat ? setMessages : () => {}, sendMessage: sendMessageWithPersistence, - regenerate: chat ? regenerate : async () => {}, + regenerate: regenerateWithTracking, stop: chat ? stop : async () => {}, clearError: chat ? clearError : () => {}, isHistoryLoading: !chat, @@ -220,20 +639,50 @@ export function ChatProvider({ workspaceId, children }: ChatProviderProps) { }), [ chat, - threadId, - workspaceId, - status, + clearError, error, messages, - setMessages, - sendMessageWithPersistence, - regenerate, - stop, - clearError, + regenerateWithTracking, selectThread, + sendMessageWithPersistence, + setMessages, startNewThread, + status, + stop, + threadId, + workspaceId, ], ); - return {children}; + const managerValue = useMemo( + () => ({ + workspaceId, + aliveThreadIds, + threadOrderVersion, + getRuntime, + disposeThread, + getThreadStatusSnapshot: (targetThreadId) => { + if (!targetThreadId) return "idle"; + const runtime = getRuntime(targetThreadId); + return runtime ? (runtime.status as ThreadStatus) : "idle"; + }, + getThreadLastStartedAt: (targetThreadId) => + targetThreadId + ? (threadLastStartedAtRef.current.get(targetThreadId) ?? 0) + : 0, + }), + [ + aliveThreadIds, + disposeThread, + getRuntime, + threadOrderVersion, + workspaceId, + ], + ); + + return ( + + {children} + + ); } diff --git a/src/components/chat/ChatRuntimes.tsx b/src/components/chat/ChatRuntimes.tsx deleted file mode 100644 index 533bdce1..00000000 --- a/src/components/chat/ChatRuntimes.tsx +++ /dev/null @@ -1,435 +0,0 @@ -"use client"; - -import { Chat } from "@ai-sdk/react"; -import { useQueryClient } from "@tanstack/react-query"; -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, - type ReactNode, -} from "react"; -import { toast } from "sonner"; -import { useShallow } from "zustand/react/shallow"; - -import { useWorkspaceContext } from "@/contexts/WorkspaceContext"; -import { useViewingItemIds } from "@/hooks/ui/use-viewing-item-ids"; -import { useWorkspaceContextProvider } from "@/hooks/ai/use-workspace-context-provider"; -import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import { chatDebug, chatWarn, summarizeRoster } from "@/lib/chat/debug"; -import { - chatQueryKeys, - fetchThreadMessages, - type ThreadListItem, -} from "@/lib/chat/queries"; -import { createChatTransport } from "@/lib/chat/transport"; -import type { ChatMessage } from "@/lib/chat/types"; -import { hasMeaningfulContent } from "@/lib/chat/types"; -import { useUIStore } from "@/lib/stores/ui-store"; -import { formatSelectedCardsMetadata } from "@/lib/utils/format-workspace-context"; - -/** - * Owns one `Chat` instance per visited threadId for the active workspace. - * - * Visible components bind via `useChat({ chat })` (in `ChatProvider`); the - * dropdown subscribes to per-thread status with `useThreadStatus`. Switching - * threads simply rebinds — the previous Chat keeps its in-flight stream - * because it is no longer keyed by `threadId` at the hook level. - */ - -type ChatStatus = "submitted" | "streaming" | "ready" | "error"; -type ThreadStatus = ChatStatus | "idle"; - -interface ChatRuntimesContextValue { - workspaceId: string; - /** Get-or-create a runtime. Resolves once history is hydrated for existing threads. */ - ensureRuntime: ( - threadId: string, - opts: { hydrate: boolean }, - ) => Promise>; - /** Get an already-created runtime synchronously (or undefined). */ - getRuntime: (threadId: string) => Chat | undefined; - /** Dispose a runtime (calls chat.stop() and removes from registry). */ - disposeRuntime: (threadId: string) => void; - /** Reactive list of threadIds currently in the registry — drives consumer re-renders. */ - aliveThreadIds: readonly string[]; -} - -const ChatRuntimesContext = createContext( - null, -); - -function describeChatError(error: Error): { - title: string; - description: string; -} { - const errorMessage = error.message?.toLowerCase() || ""; - const responseBody = - ( - error as unknown as { responseBody?: string } - ).responseBody?.toLowerCase() || ""; - const errorData = - ( - error as unknown as { data?: { error?: { message?: string } } } - ).data?.error?.message?.toLowerCase() || ""; - const combined = `${errorMessage} ${responseBody} ${errorData}`; - - if ( - combined.includes("timeout") || - combined.includes("504") || - combined.includes("gateway") - ) { - return { - title: "Request timed out", - description: "The AI is taking too long to respond. Please try again.", - }; - } - if ( - combined.includes("network") || - combined.includes("fetch") || - combined.includes("failed to fetch") - ) { - return { - title: "Connection error", - description: "Unable to reach the server. Please check your connection.", - }; - } - if (combined.includes("500") || combined.includes("internal server")) { - return { - title: "Server error", - description: "Something went wrong on our end. Please try again.", - }; - } - if (combined.includes("429") || combined.includes("rate limit")) { - return { - title: "Rate limited", - description: "Too many requests. Please wait a moment and try again.", - }; - } - if (combined.includes("401") || combined.includes("unauthorized")) { - return { - title: "Authentication error", - description: "Your session may have expired. Please refresh the page.", - }; - } - if ( - combined.includes("api key not valid") || - combined.includes("api_key_invalid") || - combined.includes("api key not defined") || - combined.includes("api key is not set") || - (combined.includes("api key") && - (combined.includes("not valid") || combined.includes("invalid"))) - ) { - return { - title: "AI backend configuration error", - description: - "Please check the AI provider or gateway environment variables configured for your self-hosted deployment.", - }; - } - return { - title: "Something went wrong", - description: - error.message || "An unexpected error occurred. Please try again.", - }; -} - -interface ChatRuntimesProviderProps { - workspaceId: string; - children: ReactNode; -} - -export function ChatRuntimesProvider({ - workspaceId, - children, -}: ChatRuntimesProviderProps) { - const queryClient = useQueryClient(); - - // === Workspace-level request payload (model, memory, selections, system) === - const selectedModelId = useUIStore((state) => state.selectedModelId); - const memoryEnabled = useUIStore((state) => state.memoryEnabled); - const activeFolderId = useUIStore((state) => state.activeFolderId); - const selectedCardIdsSet = useUIStore((state) => state.selectedCardIds); - const activePdfPageByItemId = useUIStore( - useShallow((state) => state.activePdfPageByItemId), - ); - const { state: workspaceState } = useWorkspaceState(workspaceId); - const viewingItemIds = useViewingItemIds(); - const { currentWorkspace } = useWorkspaceContext(); - const systemPrompt = useWorkspaceContextProvider( - workspaceId, - workspaceState, - currentWorkspace?.name, - ); - - const contextCardIds = useMemo(() => { - const ids = new Set(selectedCardIdsSet); - viewingItemIds.forEach((id) => ids.add(id)); - return ids; - }, [selectedCardIdsSet, viewingItemIds]); - - const selectedCardsContext = useMemo(() => { - if (contextCardIds.size === 0) return ""; - const contextItems = workspaceState.filter((item) => - contextCardIds.has(item.id), - ); - if (contextItems.length === 0) return ""; - return formatSelectedCardsMetadata( - contextItems, - workspaceState, - activePdfPageByItemId, - viewingItemIds, - ); - }, [workspaceState, contextCardIds, activePdfPageByItemId, viewingItemIds]); - - // Live ref so the shared transport always reads fresh values without - // recreating it for every keystroke. The Chat's own `id` is threaded into - // the body via `prepareSendMessagesRequest`'s `id` parameter, so the - // workspace transport is shared across all runtimes. - const ctxRef = useRef({ - workspaceId, - modelId: selectedModelId, - memoryEnabled, - activeFolderId, - selectedCardsContext, - system: systemPrompt, - }); - ctxRef.current.workspaceId = workspaceId; - ctxRef.current.modelId = selectedModelId; - ctxRef.current.memoryEnabled = memoryEnabled; - ctxRef.current.activeFolderId = activeFolderId; - ctxRef.current.selectedCardsContext = selectedCardsContext; - ctxRef.current.system = systemPrompt; - - const transport = useMemo( - () => createChatTransport(() => ctxRef.current), - // Stable transport for the lifetime of the provider; payload is pulled - // through the ref on every send. - [], - ); - - // === Registry === - const runtimesRef = useRef>>(new Map()); - const pendingRef = useRef>>>(new Map()); - const generationRef = useRef(0); - const [aliveThreadIds, setAliveThreadIds] = useState([]); - - const handleError = useCallback((error: Error) => { - console.error("[Chat Error]", error); - const { title, description } = describeChatError(error); - toast.error(title, { description }); - }, []); - - const buildRuntime = useCallback( - (threadId: string, initialMessages: ChatMessage[]): Chat => { - const cleaned = initialMessages.filter(hasMeaningfulContent); - chatDebug("runtime: create", { - threadId, - droppedEmpty: initialMessages.length - cleaned.length, - ...summarizeRoster(cleaned as unknown[]), - }); - const chat = new Chat({ - id: threadId, - transport, - messages: cleaned, - onError: handleError, - // Live title updates: the chat route writes a `data-chat-title` - // part to the SSE stream once `generateThreadTitle` resolves. We - // patch the threads list cache so ChatHeader / ThreadListDropdown - // show the real title without a refetch — even if the user is - // currently viewing a different thread. - onData: (dataPart) => { - if (dataPart.type !== "data-chat-title") return; - const title = - typeof dataPart.data === "string" ? dataPart.data : undefined; - if (!title) return; - queryClient.setQueryData( - chatQueryKeys.threads(workspaceId), - (prev) => - prev?.map((t) => (t.id === threadId ? { ...t, title } : t)), - ); - }, - onFinish: () => { - // Refresh threads list once a turn finishes so newly-created - // threads appear and lastMessageAt sort order updates — even when - // the user has navigated away from this thread. - void queryClient.invalidateQueries({ - queryKey: chatQueryKeys.threads(workspaceId), - }); - }, - }); - return chat; - }, - [transport, handleError, queryClient, workspaceId], - ); - - const ensureRuntime = useCallback( - async (threadId, opts) => { - const existing = runtimesRef.current.get(threadId); - if (existing) return existing; - const inFlight = pendingRef.current.get(threadId); - if (inFlight) return inFlight; - - const generation = generationRef.current; - let promise!: Promise>; - promise = (async () => { - let initial: ChatMessage[] = []; - if (opts.hydrate) { - try { - initial = await fetchThreadMessages(threadId); - } catch (err) { - chatWarn("runtime: history fetch failed", { threadId, err }); - initial = []; - } - } - // Workspace-level teardown invalidates everything. - if (generationRef.current !== generation) { - chatDebug("runtime: stale build discarded (workspace teardown)", { - threadId, - }); - throw new Error("runtime build superseded"); - } - // Per-thread disposal during hydration: the pending entry was either - // removed by disposeRuntime or replaced by a newer ensureRuntime call. - // Either way, abandon this build so we don't resurrect a disposed - // thread or stomp a fresher Promise. - if (pendingRef.current.get(threadId) !== promise) { - chatDebug("runtime: stale build discarded (per-thread)", { threadId }); - throw new Error("runtime build superseded"); - } - pendingRef.current.delete(threadId); - const chat = buildRuntime(threadId, initial); - runtimesRef.current.set(threadId, chat); - setAliveThreadIds((prev) => - prev.includes(threadId) ? prev : [...prev, threadId], - ); - return chat; - })(); - pendingRef.current.set(threadId, promise); - return promise; - }, - [buildRuntime], - ); - - const getRuntime = useCallback( - (threadId) => runtimesRef.current.get(threadId), - [], - ); - - const disposeRuntime = useCallback< - ChatRuntimesContextValue["disposeRuntime"] - >((threadId) => { - const chat = runtimesRef.current.get(threadId); - pendingRef.current.delete(threadId); - if (!chat) return; - chatDebug("runtime: dispose", { threadId }); - try { - chat.stop(); - } catch { - /* noop */ - } - runtimesRef.current.delete(threadId); - setAliveThreadIds((prev) => prev.filter((id) => id !== threadId)); - }, []); - - // Dispose all runtimes on unmount AND when the workspace changes in place. - // ChatRuntimesProvider isn't keyed by workspaceId in DashboardLayout, so the - // workspaceId prop can flip without a remount; the effect's cleanup catches - // both cases. The generation bump invalidates any in-flight hydrate promises. - useEffect(() => { - const runtimes = runtimesRef.current; - const pending = pendingRef.current; - const generation = generationRef; - return () => { - generation.current++; - runtimes.forEach((chat, threadId) => { - chatDebug("runtime: dispose (workspace teardown)", { threadId }); - try { - chat.stop(); - } catch { - /* noop */ - } - }); - runtimes.clear(); - pending.clear(); - setAliveThreadIds([]); - }; - }, [workspaceId]); - - const value = useMemo( - () => ({ - workspaceId, - ensureRuntime, - getRuntime, - disposeRuntime, - aliveThreadIds, - }), - [workspaceId, ensureRuntime, getRuntime, disposeRuntime, aliveThreadIds], - ); - - return ( - - {children} - - ); -} - -export function useChatRuntimesContext(): ChatRuntimesContextValue { - const ctx = useContext(ChatRuntimesContext); - if (!ctx) { - throw new Error( - "useChatRuntimesContext must be used inside ", - ); - } - return ctx; -} - -/** - * Returns the runtime for `threadId` if it exists in the registry. Re-renders - * when the registry adds/removes runtimes so callers pick up new instances - * without an extra subscription layer. - */ -export function useThreadRuntime( - threadId: string | undefined, -): Chat | undefined { - const { getRuntime, aliveThreadIds } = useChatRuntimesContext(); - if (!threadId || !aliveThreadIds.includes(threadId)) return undefined; - return getRuntime(threadId); -} - -/** - * Subscribes to a thread's status reactively. Returns "idle" when no runtime - * exists yet (brand-new thread that hasn't been created or visited). - */ -export function useThreadStatus( - threadId: string | undefined, -): ThreadStatus { - const chat = useThreadRuntime(threadId); - return useSyncExternalStore( - useCallback( - (onChange) => { - if (!chat) return () => {}; - const register = chat["~registerStatusCallback"]; - if (typeof register !== "function") { - // SDK API drift: if the internal subscription method is renamed or - // removed, fall back to a no-op subscribe so status indicators - // silently degrade to "always idle" rather than throwing a - // TypeError for every dropdown row. - if (process.env.NODE_ENV !== "production") { - console.warn( - "[ChatRuntimes] Chat['~registerStatusCallback'] is unavailable — status indicators disabled.", - ); - } - return () => {}; - } - return register.call(chat, onChange); - }, - [chat], - ), - () => (chat ? chat.status : "idle"), - () => "idle", - ); -} diff --git a/src/components/chat/Messages.tsx b/src/components/chat/Messages.tsx index 863c0d03..a8c797eb 100644 --- a/src/components/chat/Messages.tsx +++ b/src/components/chat/Messages.tsx @@ -8,22 +8,19 @@ import { useLayoutEffect, useMemo, useRef, - useState, type ReactNode, } from "react"; -import { VList, type VListHandle } from "virtua"; +import { VList } from "virtua"; import { PendingAssistantLoader } from "@/components/chat/AssistantLoader"; import { AssistantMessage } from "@/components/chat/AssistantMessage"; import { useChatContext } from "@/components/chat/ChatProvider"; +import { useThreadViewportController } from "@/components/chat/use-thread-viewport-controller"; import { Button } from "@/components/ui/button"; import { UserMessage } from "@/components/chat/UserMessage"; import { chatDebug, chatWarn, summarizeRoster } from "@/lib/chat/debug"; import type { ChatMessage } from "@/lib/chat/types"; -import { - THREAD_SCROLL_PIN_OFFSET, - THREAD_TOP_INSET, -} from "@/components/chat/thread-layout"; +import { THREAD_TOP_INSET } from "@/components/chat/thread-layout"; /** * Virtualized message list with pin-to-top autoscroll. Assumes the thread @@ -50,24 +47,13 @@ import { * 5. After the turn ends, the spacer persists on the (now) last assistant * row. The next turn repeats the cycle. * - * Scroll positioning is governed by a single `useLayoutEffect` with three - * mutually exclusive branches: - * - * A. Snap-to-bottom on thread mount/switch when the user did NOT just send - * here. Covers opening an existing thread and switching between threads - * (including switching into a mid-assistant-stream). - * B. Pin user-tail to top on thread mount/switch when the user DID just - * send here. Covers welcome → first send and the rare case of mounting - * into a thread whose tail is a freshly-sent user message. - * C. Pin user-tail to top on same-thread `ready → submitted/streaming` - * transitions — i.e. every subsequent turn within a thread. - * - * `isFirstSeeing` is checked first, so A and B win unambiguously over C - * across thread boundaries. + * Thread-open and same-thread send scroll behavior lives in + * `useThreadViewportController()`. `ThreadBody` keys this component by + * `threadId`, so "open/switch thread" becomes a real mount instead of + * sharing one long-lived effect across multiple thread lifecycles. */ const MessagesImpl = () => { - const { messages, status, error, regenerate, clearError, threadId } = - useChatContext(); + const { messages, status, error, regenerate, clearError } = useChatContext(); const isStreaming = status === "streaming" || status === "submitted"; // [chat-debug] Log a roster snapshot whenever the message list changes @@ -100,6 +86,18 @@ const MessagesImpl = () => { }, [messages]); const lastUserMessageId = lastUserIndex >= 0 ? (messages[lastUserIndex]?.id ?? null) : null; + + const { + viewportRef, + listRef, + reservedTail, + measurePinnedUser, + } = useThreadViewportController({ + messages, + status, + lastUserMessageId, + }); + // Between `sendMessage` and the first streamed chunk, useChat has not yet // materialized an assistant message. Render a dedicated pending row instead // of inventing a fake assistant message identity that later has to be @@ -109,124 +107,6 @@ const MessagesImpl = () => { messages.length > 0 && messages[messages.length - 1].role === "user"; - // Measure the scroll viewport so we know how tall the spacer should be. - // Kept in sync via a ResizeObserver so the reservation adapts when the - // panel is maximized/minimized. - const containerRef = useRef(null); - const [reservedMinHeight, setReservedMinHeight] = useState(0); - useLayoutEffect(() => { - const el = containerRef.current; - if (!el) return; - setReservedMinHeight(el.clientHeight); - const ro = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - setReservedMinHeight(entry.contentRect.height); - }); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - // Track the height of the most-recent user message so we can size the - // trailing spacer correctly. The UserRow calls `onMeasure` when it's the - // pin target (i.e. the last user message in the list). - const [lastMeasuredUser, setLastMeasuredUser] = useState<{ - messageId: string | null; - height: number; - }>({ - messageId: null, - height: 0, - }); - const lastUserSize = - lastMeasuredUser.messageId === lastUserMessageId - ? lastMeasuredUser.height - : 0; - - const reservedTail = Math.max(0, reservedMinHeight - lastUserSize); - - const vlistRef = useRef(null); - - // Three exclusive branches govern the scroll position: - // - // A. Thread mount/switch + the user did NOT just send here → snap to the - // bottom of the scroll container instantly. Covers "open an existing - // thread", "switch between threads", and "switch into a thread that's - // mid-assistant-stream". - // - // B. Thread mount/switch + the user DID just send here (status is - // submitted/streaming with a user-tail) → pin the user message to the - // viewport top. Covers welcome → first send, and the rare case of - // switching into a thread whose tail is a freshly-sent user message. - // - // C. Same thread, status transitioned ready → submitted/streaming → pin - // the newly-sent user message to the viewport top. This is the every - // subsequent turn case within a thread. - // - // Branches are mutually exclusive: A and B are gated on `isFirstSeeing`, - // C only fires when we've already seen this thread. That mutual exclusion - // is what fixes the cross-thread status race — e.g. switching from a - // ready thread into a streaming thread used to fire BOTH pin-to-top (via - // C) AND snap-to-bottom (via A); now A wins because `isFirstSeeing` is - // checked first. - const prevStatusRef = useRef(status); - const prevSeenThreadIdRef = useRef(null); - useLayoutEffect(() => { - const prevStatus = prevStatusRef.current; - const prevSeen = prevSeenThreadIdRef.current; - prevStatusRef.current = status; - - const lastMessageIndex = messages.length - 1; - if (lastMessageIndex < 0) return; - const handle = vlistRef.current; - if (!handle) return; - - const tail = messages[lastMessageIndex]; - const userJustSent = - (status === "submitted" || status === "streaming") && - tail?.role === "user"; - const isFirstSeeing = prevSeen !== threadId; - - let rafId: number | null = null; - - if (isFirstSeeing && !userJustSent) { - // Branch A — snap to bottom (instant). - prevSeenThreadIdRef.current = threadId; - rafId = requestAnimationFrame(() => { - handle.scrollToIndex(lastMessageIndex, { align: "end" }); - }); - } else if (isFirstSeeing && userJustSent) { - // Branch B — pin user-tail to top on first turn out of welcome / when - // mounting into an existing user-tail thread. - prevSeenThreadIdRef.current = threadId; - rafId = requestAnimationFrame(() => { - handle.scrollToIndex(lastMessageIndex, { - smooth: true, - align: "start", - offset: -THREAD_SCROLL_PIN_OFFSET, - }); - }); - } else { - // Branch C — same thread, watch for a fresh turn start. - const turnStarted = - prevStatus === "ready" && - (status === "submitted" || status === "streaming"); - if (turnStarted) { - rafId = requestAnimationFrame(() => { - handle.scrollToIndex(lastMessageIndex, { - smooth: true, - align: "start", - offset: -THREAD_SCROLL_PIN_OFFSET, - }); - }); - } - } - - if (rafId !== null) { - const id = rafId; - return () => cancelAnimationFrame(id); - } - }, [threadId, status, messages]); - // Whichever row ends up at the tail gets the spacer. When the pending // loader is present it's the tail; otherwise the last real message is. const lastMessageIndex = messages.length - 1; @@ -253,18 +133,7 @@ const MessagesImpl = () => { onMeasure={ idx === lastUserIndex ? (height) => { - setLastMeasuredUser((prev) => { - if ( - prev.messageId === message.id && - prev.height === height - ) { - return prev; - } - return { - messageId: message.id, - height, - }; - }); + measurePinnedUser(message.id, height); } : undefined } @@ -311,9 +180,9 @@ const MessagesImpl = () => { // `document.querySelector` — it uses this node for range-bounds // containment checks and tooltip positioning. Don't rename without // updating both selectors. -
+
diff --git a/src/components/chat/ThreadBody.tsx b/src/components/chat/ThreadBody.tsx index a81e4b4e..6b90d853 100644 --- a/src/components/chat/ThreadBody.tsx +++ b/src/components/chat/ThreadBody.tsx @@ -19,7 +19,7 @@ interface ThreadBodyProps { * `` can rely on their DOM being present on first mount. */ export function ThreadBody({ empty }: ThreadBodyProps) { - const { messages, isHistoryLoading } = useChatContext(); + const { messages, isHistoryLoading, threadId } = useChatContext(); if (messages.length === 0 && isHistoryLoading) { return ( @@ -45,5 +45,5 @@ export function ThreadBody({ empty }: ThreadBodyProps) { ); } - return ; + return ; } diff --git a/src/components/chat/ThreadListDropdown.tsx b/src/components/chat/ThreadListDropdown.tsx index d968ed5b..e3043594 100644 --- a/src/components/chat/ThreadListDropdown.tsx +++ b/src/components/chat/ThreadListDropdown.tsx @@ -11,11 +11,11 @@ import { } from "react"; import { toast } from "sonner"; -import { useChatContext } from "@/components/chat/ChatProvider"; import { - useChatRuntimesContext, + useChatContext, + useChatThreadManager, useThreadStatus, -} from "@/components/chat/ChatRuntimes"; +} from "@/components/chat/ChatProvider"; import { TooltipIconButton } from "@/components/chat/tooltip-icon-button"; import { AlertDialog, @@ -46,6 +46,7 @@ import { useRenameThread, useThreadsQuery, } from "@/lib/chat/queries"; +import { orderThreadsByRuntimeActivity } from "@/lib/chat/thread-order"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; interface ThreadListDropdownProps { @@ -57,11 +58,19 @@ export const ThreadListDropdown: FC = ({ }) => { const [open, setOpen] = useState(false); const { workspaceId, threadId, selectThread, startNewThread } = useChatContext(); + const { + getThreadLastStartedAt, + getThreadStatusSnapshot, + } = useChatThreadManager(); const { data: threads, isLoading } = useThreadsQuery(workspaceId); - const visibleThreads = (threads ?? []).filter( - (t) => t.status !== "archived", + const visibleThreads = orderThreadsByRuntimeActivity( + (threads ?? []).filter((thread) => thread.status !== "archived"), + { + getThreadStatus: getThreadStatusSnapshot, + getThreadLastStartedAt, + }, ); const handleSelect = useCallback( @@ -159,14 +168,10 @@ const ThreadListItemRow: FC = ({ const clearCurrentThreadId = useWorkspaceStore( (s) => s.clearCurrentThreadId, ); - const { disposeRuntime } = useChatRuntimesContext(); + const { disposeThread } = useChatThreadManager(); const status = useThreadStatus(thread.id); const isGenerating = status === "submitted" || status === "streaming"; - useEffect(() => { - if (!isEditing) setEditValue(thread.title ?? "New Chat"); - }, [thread.title, isEditing]); - useEffect(() => { if (isEditing && inputRef.current) { inputRef.current.focus(); @@ -179,6 +184,7 @@ const ThreadListItemRow: FC = ({ const handleStartEdit = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); + setEditValue(title); setIsEditing(true); }; @@ -196,9 +202,8 @@ const ThreadListItemRow: FC = ({ console.error("Failed to rename thread:", err); toast.error("Failed to update title"); setEditValue(title); - } finally { - setIsEditing(false); } + setIsEditing(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -222,7 +227,7 @@ const ThreadListItemRow: FC = ({ setShowDeleteDialog(false); try { await deleteMutation.mutateAsync(thread.id); - disposeRuntime(thread.id); + disposeThread(thread.id); clearCurrentThreadId(workspaceId, thread.id); if (isActive) { onDeletedActiveThread(); diff --git a/src/components/chat/use-thread-viewport-controller.ts b/src/components/chat/use-thread-viewport-controller.ts new file mode 100644 index 00000000..c2f2706d --- /dev/null +++ b/src/components/chat/use-thread-viewport-controller.ts @@ -0,0 +1,167 @@ +"use client"; + +import { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, + type RefObject, +} from "react"; +import type { VListHandle } from "virtua"; + +import { THREAD_SCROLL_PIN_OFFSET } from "@/components/chat/thread-layout"; +import { + isRunningThreadStatus, + type ActiveChatStatus, +} from "@/lib/chat/thread-runtime-state"; +import type { ChatMessage } from "@/lib/chat/types"; + +type ThreadViewportAction = "snap-to-bottom" | "pin-last-user"; + +interface ResolveThreadViewportActionOptions { + phase: "thread-open" | "status-change"; + previousStatus: ActiveChatStatus | null; + status: ActiveChatStatus; + tailRole: ChatMessage["role"] | null; +} + +export function resolveThreadViewportAction({ + phase, + previousStatus, + status, + tailRole, +}: ResolveThreadViewportActionOptions): ThreadViewportAction | null { + const userTailWhileRunning = + tailRole === "user" && isRunningThreadStatus(status); + + if (phase === "thread-open") { + return userTailWhileRunning ? "pin-last-user" : "snap-to-bottom"; + } + + const turnStarted = + previousStatus === "ready" && isRunningThreadStatus(status); + + return turnStarted ? "pin-last-user" : null; +} + +interface UseThreadViewportControllerOptions { + messages: ChatMessage[]; + status: ActiveChatStatus; + lastUserMessageId: string | null; +} + +interface UseThreadViewportControllerResult { + viewportRef: RefObject; + listRef: RefObject; + reservedTail: number; + measurePinnedUser: (messageId: string, height: number) => void; +} + +export function useThreadViewportController({ + messages, + status, + lastUserMessageId, +}: UseThreadViewportControllerOptions): UseThreadViewportControllerResult { + const viewportRef = useRef(null); + const listRef = useRef(null); + const previousStatusRef = useRef(null); + + const [viewportHeight, setViewportHeight] = useState(0); + useLayoutEffect(() => { + const element = viewportRef.current; + if (!element) return; + + setViewportHeight(element.clientHeight); + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + setViewportHeight(entry.contentRect.height); + }); + + observer.observe(element); + return () => observer.disconnect(); + }, []); + + const [lastMeasuredUser, setLastMeasuredUser] = useState<{ + messageId: string | null; + height: number; + }>({ + messageId: null, + height: 0, + }); + + const measurePinnedUser = useCallback((messageId: string, height: number) => { + setLastMeasuredUser((previous) => { + if (previous.messageId === messageId && previous.height === height) { + return previous; + } + return { messageId, height }; + }); + }, []); + + const lastUserHeight = + lastMeasuredUser.messageId === lastUserMessageId + ? lastMeasuredUser.height + : 0; + + const reservedTail = useMemo( + () => Math.max(0, viewportHeight - lastUserHeight), + [lastUserHeight, viewportHeight], + ); + + useLayoutEffect(() => { + const previousStatus = previousStatusRef.current; + const lastMessageIndex = messages.length - 1; + const list = listRef.current; + if (lastMessageIndex < 0 || !list) return; + if (previousStatus == null && viewportHeight === 0) return; + + const action = resolveThreadViewportAction({ + phase: previousStatus == null ? "thread-open" : "status-change", + previousStatus, + status, + tailRole: messages[lastMessageIndex]?.role ?? null, + }); + + previousStatusRef.current = status; + + if (!action) return; + + const runScroll = () => { + if (action === "pin-last-user") { + list.scrollToIndex(lastMessageIndex, { + smooth: true, + align: "start", + offset: -THREAD_SCROLL_PIN_OFFSET, + }); + return; + } + + list.scrollToIndex(lastMessageIndex, { align: "end" }); + }; + + let innerFrameId: number | null = null; + const outerFrameId = requestAnimationFrame(() => { + if (previousStatus == null) { + innerFrameId = requestAnimationFrame(runScroll); + return; + } + runScroll(); + }); + + return () => { + cancelAnimationFrame(outerFrameId); + if (innerFrameId !== null) { + cancelAnimationFrame(innerFrameId); + } + }; + }, [messages, status, viewportHeight]); + + return { + viewportRef, + listRef, + reservedTail, + measurePinnedUser, + }; +} diff --git a/src/components/layout/DashboardLayout.tsx b/src/components/layout/DashboardLayout.tsx index cefc475c..f8de769e 100644 --- a/src/components/layout/DashboardLayout.tsx +++ b/src/components/layout/DashboardLayout.tsx @@ -3,7 +3,6 @@ import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/componen import WorkspaceSidebar from "@/components/workspace-canvas/WorkspaceSidebar"; import { ChatPanel } from "@/components/chat/ChatPanel"; import { ChatProvider } from "@/components/chat/ChatProvider"; -import { ChatRuntimesProvider } from "@/components/chat/ChatRuntimes"; import { ComposerProvider } from "@/components/chat/composer-context"; import { WorkspaceCanvasDropzone } from "@/components/workspace-canvas/WorkspaceCanvasDropzone"; import { PANEL_DEFAULTS } from "@/lib/layout-constants"; @@ -132,11 +131,9 @@ export function DashboardLayout({ if (currentWorkspaceId) { return ( - - - {content} - - + + {content} + ); } diff --git a/src/lib/ai/supermemory.ts b/src/lib/ai/supermemory.ts index 2304d62c..c5aa6da8 100644 --- a/src/lib/ai/supermemory.ts +++ b/src/lib/ai/supermemory.ts @@ -4,8 +4,12 @@ import { withSupermemory } from "@supermemory/tools/ai-sdk"; import { logger } from "@/lib/utils/logger"; +/** Supermemory ingest constraint on `containerTag` (see SDK `DocumentAddParams`). */ +const MAX_CONTAINER_TAG_LENGTH = 100; + type SupermemoryWrapOptions = { userId: string; + workspaceId: string; threadId?: string | null; memoryEnabled: boolean; }; @@ -14,7 +18,7 @@ export function maybeWithSupermemory( model: T, options: SupermemoryWrapOptions, ): T { - const { userId, threadId, memoryEnabled } = options; + const { userId, workspaceId, threadId, memoryEnabled } = options; // Server-side kill switch. Neutralizes the wrapper without touching UI, // state, or transport plumbing — set the env to `false` to @@ -28,6 +32,12 @@ export function maybeWithSupermemory( if (!memoryEnabled) return model; if (!userId) return model; + if (!workspaceId?.trim()) { + logger.warn( + "🧠 [SUPERMEMORY] memoryEnabled=true but workspaceId is missing; skipping memory injection", + ); + return model; + } const apiKey = process.env.SUPERMEMORY_API_KEY; if (!apiKey) { @@ -45,8 +55,17 @@ export function maybeWithSupermemory( ? threadId : `user:${userId}`; + const containerTag = `${userId}_${workspaceId}`; + if (containerTag.length > MAX_CONTAINER_TAG_LENGTH) { + logger.warn( + "🧠 [SUPERMEMORY] workspace-scoped containerTag exceeds limit; skipping memory injection", + { length: containerTag.length }, + ); + return model; + } + return withSupermemory(model as never, { - containerTag: userId, + containerTag, customId, apiKey, mode: "full" as const, diff --git a/src/lib/chat/__tests__/thread-order.test.ts b/src/lib/chat/__tests__/thread-order.test.ts new file mode 100644 index 00000000..80fc735a --- /dev/null +++ b/src/lib/chat/__tests__/thread-order.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { orderThreadsByRuntimeActivity } from "@/lib/chat/thread-order"; +import type { ThreadStatus } from "@/lib/chat/thread-runtime-state"; + +const threads = [ + { id: "thread-a", status: "regular" as const, title: "A" }, + { id: "thread-b", status: "regular" as const, title: "B" }, + { id: "thread-c", status: "regular" as const, title: "C" }, +]; + +describe("orderThreadsByRuntimeActivity", () => { + it("preserves server ordering when nothing is running", () => { + const ordered = orderThreadsByRuntimeActivity(threads, { + getThreadStatus: () => "ready", + getThreadLastStartedAt: () => 0, + }); + + expect(ordered.map((thread) => thread.id)).toEqual([ + "thread-a", + "thread-b", + "thread-c", + ]); + }); + + it("moves running threads to the top", () => { + const statuses: Record = { + "thread-a": "ready", + "thread-b": "streaming", + "thread-c": "ready", + }; + + const ordered = orderThreadsByRuntimeActivity(threads, { + getThreadStatus: (threadId) => statuses[threadId] ?? "idle", + getThreadLastStartedAt: () => 0, + }); + + expect(ordered.map((thread) => thread.id)).toEqual([ + "thread-b", + "thread-a", + "thread-c", + ]); + }); + + it("orders running threads by most recent start time", () => { + const statuses: Record = { + "thread-a": "streaming", + "thread-b": "submitted", + "thread-c": "ready", + }; + const startedAt: Record = { + "thread-a": 10, + "thread-b": 20, + "thread-c": 0, + }; + + const ordered = orderThreadsByRuntimeActivity(threads, { + getThreadStatus: (threadId) => statuses[threadId] ?? "idle", + getThreadLastStartedAt: (threadId) => startedAt[threadId] ?? 0, + }); + + expect(ordered.map((thread) => thread.id)).toEqual([ + "thread-b", + "thread-a", + "thread-c", + ]); + }); +}); diff --git a/src/lib/chat/__tests__/thread-viewport-policy.test.ts b/src/lib/chat/__tests__/thread-viewport-policy.test.ts new file mode 100644 index 00000000..653d4a8e --- /dev/null +++ b/src/lib/chat/__tests__/thread-viewport-policy.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { resolveThreadViewportAction } from "@/components/chat/use-thread-viewport-controller"; + +describe("resolveThreadViewportAction", () => { + it("snaps to bottom when opening a thread with an assistant tail", () => { + expect( + resolveThreadViewportAction({ + phase: "thread-open", + previousStatus: null, + status: "ready", + tailRole: "assistant", + }), + ).toBe("snap-to-bottom"); + }); + + it("pins the last user when opening a running user-tail thread", () => { + expect( + resolveThreadViewportAction({ + phase: "thread-open", + previousStatus: null, + status: "submitted", + tailRole: "user", + }), + ).toBe("pin-last-user"); + }); + + it("pins the last user when a new turn starts in the current thread", () => { + expect( + resolveThreadViewportAction({ + phase: "status-change", + previousStatus: "ready", + status: "streaming", + tailRole: "user", + }), + ).toBe("pin-last-user"); + }); + + it("does nothing for steady-state streaming updates", () => { + expect( + resolveThreadViewportAction({ + phase: "status-change", + previousStatus: "streaming", + status: "streaming", + tailRole: "assistant", + }), + ).toBeNull(); + }); +}); diff --git a/src/lib/chat/thread-order.ts b/src/lib/chat/thread-order.ts new file mode 100644 index 00000000..63db77ec --- /dev/null +++ b/src/lib/chat/thread-order.ts @@ -0,0 +1,35 @@ +import type { ThreadListItem } from "@/lib/chat/queries"; +import { + isRunningThreadStatus, + type ThreadStatus, +} from "@/lib/chat/thread-runtime-state"; + +interface ThreadOrderingInput { + getThreadStatus: (threadId: string) => ThreadStatus; + getThreadLastStartedAt: (threadId: string) => number; +} + +/** + * Preserve the server's recency ordering for non-running threads, while + * bubbling currently-running threads to the top. Among running threads, the + * most recently started one wins. + */ +export function orderThreadsByRuntimeActivity( + threads: ThreadListItem[], + input: ThreadOrderingInput, +): ThreadListItem[] { + return [...threads].sort((a, b) => { + const aRunning = isRunningThreadStatus(input.getThreadStatus(a.id)); + const bRunning = isRunningThreadStatus(input.getThreadStatus(b.id)); + + if (aRunning !== bRunning) { + return aRunning ? -1 : 1; + } + + if (!aRunning && !bRunning) { + return 0; + } + + return input.getThreadLastStartedAt(b.id) - input.getThreadLastStartedAt(a.id); + }); +} diff --git a/src/lib/chat/thread-runtime-state.ts b/src/lib/chat/thread-runtime-state.ts new file mode 100644 index 00000000..2d5018e5 --- /dev/null +++ b/src/lib/chat/thread-runtime-state.ts @@ -0,0 +1,7 @@ +export type ActiveChatStatus = "submitted" | "streaming" | "ready" | "error"; + +export type ThreadStatus = ActiveChatStatus | "idle"; + +export function isRunningThreadStatus(status: ThreadStatus): boolean { + return status === "submitted" || status === "streaming"; +}