Centralize chat session state sync with single subscription via atom

This commit is contained in:
CREDO23 2026-01-22 19:04:23 +02:00
parent 4cb835f19f
commit 39d434b00c
4 changed files with 48 additions and 18 deletions

View file

@ -50,7 +50,8 @@ import {
type MessageRecord, type MessageRecord,
type ThreadRecord, type ThreadRecord,
} from "@/lib/chat/thread-persistence"; } from "@/lib/chat/thread-persistence";
import { useChatSessionState } from "@/hooks/use-chat-session-state"; import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesElectric } from "@/hooks/use-messages-electric"; import { useMessagesElectric } from "@/hooks/use-messages-electric";
import { import {
trackChatCreated, trackChatCreated,
@ -260,8 +261,11 @@ export default function NewChatPage() {
// Get current user for author info in shared chats // Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom); const { data: currentUser } = useAtomValue(currentUserAtom);
// Live collaboration: sync messages from other users via Electric SQL // Live collaboration: sync session state and messages via Electric SQL
const { isAiResponding, respondingToUserId } = useChatSessionState(threadId); useChatSessionStateSync(threadId);
const sessionState = useAtomValue(chatSessionStateAtom);
const isAiResponding = sessionState?.isAiResponding ?? false;
const respondingToUserId = sessionState?.respondingToUserId ?? null;
const { data: membersData } = useAtomValue(membersAtom); const { data: membersData } = useAtomValue(membersAtom);
const handleElectricMessagesUpdate = useCallback( const handleElectricMessagesUpdate = useCallback(

View file

@ -0,0 +1,15 @@
"use client";
import { atom } from "jotai";
export interface ChatSessionStateData {
threadId: number;
isAiResponding: boolean;
respondingToUserId: string | null;
}
/**
* Global chat session state atom.
* Updated by useChatSessionStateSync hook, read anywhere.
*/
export const chatSessionStateAtom = atom<ChatSessionStateData | null>(null);

View file

@ -61,7 +61,7 @@ import {
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Document } from "@/contracts/types/document.types"; import type { Document } from "@/contracts/types/document.types";
import { useChatSessionState } from "@/hooks/use-chat-session-state"; import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { useCommentsElectric } from "@/hooks/use-comments-electric"; import { useCommentsElectric } from "@/hooks/use-comments-electric";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -236,7 +236,9 @@ const Composer: FC = () => {
} }
return typeof chat_id === "string" ? Number.parseInt(chat_id, 10) || null : null; return typeof chat_id === "string" ? Number.parseInt(chat_id, 10) || null : null;
}, [chat_id]); }, [chat_id]);
const { isAiResponding, respondingToUserId } = useChatSessionState(threadId); const sessionState = useAtomValue(chatSessionStateAtom);
const isAiResponding = sessionState?.isAiResponding ?? false;
const respondingToUserId = sessionState?.respondingToUserId ?? null;
const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id; const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id;
// Sync comments for the entire thread via Electric SQL (one subscription per thread) // Sync comments for the entire thread via Electric SQL (one subscription per thread)

View file

@ -1,30 +1,39 @@
"use client"; "use client";
import { useShape } from "@electric-sql/react"; import { useShape } from "@electric-sql/react";
import { useSetAtom } from "jotai";
import { useEffect } from "react";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import type { ChatSessionState } from "@/contracts/types/chat-session-state.types"; import type { ChatSessionState } from "@/contracts/types/chat-session-state.types";
const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133"; const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133";
/** /**
* Hook to get live chat session state for collaboration. * Syncs chat session state for a thread via Electric SQL.
* Tracks which user the AI is currently responding to. * Call once per thread (in page.tsx). Updates global atom.
*/ */
export function useChatSessionState(threadId: number | null) { export function useChatSessionStateSync(threadId: number | null) {
const { data, isLoading, isError, error } = useShape<ChatSessionState>({ const setSessionState = useSetAtom(chatSessionStateAtom);
const { data } = useShape<ChatSessionState>({
url: `${ELECTRIC_URL}/v1/shape`, url: `${ELECTRIC_URL}/v1/shape`,
params: { params: {
table: "chat_session_state", table: "chat_session_state",
where: `thread_id = ${threadId}`, where: `thread_id = ${threadId ?? -1}`,
}, },
}); });
const sessionState = data?.[0] ?? null; useEffect(() => {
if (!threadId) {
setSessionState(null);
return;
}
return { const row = data?.[0];
sessionState, setSessionState({
isAiResponding: !!sessionState?.ai_responding_to_user_id, threadId,
respondingToUserId: sessionState?.ai_responding_to_user_id ?? null, isAiResponding: !!row?.ai_responding_to_user_id,
loading: isLoading, respondingToUserId: row?.ai_responding_to_user_id ?? null,
error: isError ? error : null, });
}; }, [threadId, data, setSessionState]);
} }