import type { UIMessage } from "@ai-sdk/react"; import { useLocation } from "@remix-run/react"; import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Spinner } from "~/components/primitives/Spinner"; import { useApiOrigin } from "~/hooks/useApiOrigin"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useUser } from "~/hooks/useUser"; import { DashboardAgentChat, type DashboardAgentClientData, type DashboardAgentSession, } from "./DashboardAgentChat"; import { DashboardAgentDraft } from "./DashboardAgentDraft"; import { DashboardAgentHeader } from "./DashboardAgentHeader"; import { DashboardAgentHistory, type DashboardAgentChat as DashboardAgentChatListItem, } from "./DashboardAgentHistory"; // Restore the last open chat across panel re-opens and page reloads. Scoped by // org because chats are org-scoped. localStorage (not a cookie) since the panel // only mounts client-side — the server never needs this. const lastChatStorageKey = (organizationId: string) => `tdev:dashboard-agent:last-chat:${organizationId}`; type ActiveChat = { chatId: string; messages: UIMessage[]; session: DashboardAgentSession | null; // Cold start only: the agent run has no warm step-1, so the mounted chat sends // this first message through the transport to trigger the turn. Undefined for // head-started and resumed chats — their stream is resumed, not re-sent. pendingFirstMessage?: string; // True for a head-started chat: the turn is already in flight server-side, so // the transport must hydrate the session as streaming to resume `session.out`. streaming?: boolean; }; /** * The dashboard agent side panel. Owns history, the active chat, and last-chat * persistence. New chats start in a draft state with no id; the server * generates the chat id on the first send (`create`) and owns the chat record, * so the client never invents an id. Existing chats resolve their stored * transcript + session before mounting `DashboardAgentChat` (keyed by chatId). */ export function DashboardAgentPanel({ onClose }: { onClose: () => void }) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const user = useUser(); const apiOrigin = useApiOrigin(); const location = useLocation(); const [view, setView] = useState<"chat" | "history">("chat"); const [chats, setChats] = useState([]); const [active, setActive] = useState(null); const [loading, setLoading] = useState(false); const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`; const storageKey = lastChatStorageKey(organization.id); const currentPage = location.pathname.split("/").filter(Boolean).pop() ?? "overview"; const clientData = useMemo( () => ({ userId: user.id, organizationId: organization.id, projectId: project.id, environmentId: environment.id, currentPage: location.pathname, }), [user.id, organization.id, project.id, environment.id, location.pathname] ); const loadHistory = useCallback(async () => { const res = await fetch(actionPath); if (res.ok) { const data = (await res.json()) as { chats?: DashboardAgentChatListItem[] }; setChats(data.chats ?? []); } }, [actionPath]); // Bumped on each open so a slower earlier open can't overwrite a newer one // when chats are switched rapidly. const openChatRequestSeq = useRef(0); // Open an existing chat: fetch its stored transcript + session so resume flows // in through the transport at mount. A stored id that's gone (deleted / never // sent) drops back to the draft state. const openChat = useCallback( async (id: string) => { setView("chat"); const seq = ++openChatRequestSeq.current; setLoading(true); try { const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(id)}`); const data = res.ok ? ((await res.json()) as { messages?: UIMessage[]; session?: { publicAccessToken: string; lastEventId: string | null } | null; }) : { messages: [], session: null }; if (seq !== openChatRequestSeq.current) return; if (data.messages && data.messages.length > 0) { setActive({ chatId: id, messages: data.messages, session: data.session?.publicAccessToken ? { publicAccessToken: data.session.publicAccessToken, lastEventId: data.session.lastEventId ?? undefined, } : null, }); } else { // Nothing stored under this id — drop to a fresh draft. setActive(null); } } finally { if (seq === openChatRequestSeq.current) setLoading(false); } }, [actionPath] ); // Start a new chat by sending its first message. The server generates the id, // creates the chat record, and kicks off the first turn (head start when // configured, else a cold session). We then mount the real chat on the server // id and either resume its stream (head start) or send the message through // the transport (cold start). const createChat = useCallback( async (text: string) => { setView("chat"); const seq = ++openChatRequestSeq.current; setLoading(true); try { const userMessage: UIMessage = { id: generateFriendlyId("msg"), role: "user", parts: [{ type: "text", text }], }; const body = new FormData(); body.set("intent", "create"); body.set("message", JSON.stringify(userMessage)); body.set("clientData", JSON.stringify(clientData)); const res = await fetch(actionPath, { method: "POST", body }); const data = (await res.json()) as { chatId?: string; publicAccessToken?: string; headStarted?: boolean; error?: string; }; // A newer open/create (or New chat) superseded this one — drop the result. if (seq !== openChatRequestSeq.current) return; if (!res.ok || !data.chatId || !data.publicAccessToken) { setActive(null); return; } setActive({ chatId: data.chatId, messages: data.headStarted ? [userMessage] : [], session: { publicAccessToken: data.publicAccessToken }, pendingFirstMessage: data.headStarted ? undefined : text, streaming: data.headStarted, }); } finally { if (seq === openChatRequestSeq.current) setLoading(false); } }, [actionPath, clientData] ); // On open, restore the last chat if there is one; otherwise stay in the draft // state (active = null). Runs once per mount. const restored = useRef(false); useEffect(() => { if (restored.current) return; restored.current = true; let stored: string | null = null; try { stored = window.localStorage.getItem(storageKey); } catch { /* localStorage unavailable — start fresh */ } if (stored) void openChat(stored); }, [openChat, storageKey]); // Persist the active chat as the one to restore next time. useEffect(() => { if (!active?.chatId) return; try { window.localStorage.setItem(storageKey, active.chatId); } catch { /* ignore */ } }, [active?.chatId, storageKey]); const newChat = useCallback(() => { // Invalidate any in-flight open/create so its result can't replace the draft. openChatRequestSeq.current += 1; setLoading(false); setView("chat"); setActive(null); }, []); const switchChat = useCallback( (id: string) => { void openChat(id); }, [openChat] ); const deleteChat = useCallback( async (id: string) => { const body = new FormData(); body.set("intent", "delete"); body.set("chatId", id); await fetch(actionPath, { method: "POST", body }); if (id === active?.chatId) newChat(); void loadHistory(); }, [actionPath, active?.chatId, newChat, loadHistory] ); const toggleHistory = useCallback(() => { setView((v) => { if (v === "chat") void loadHistory(); return v === "chat" ? "history" : "chat"; }); }, [loadHistory]); return (
{view === "history" ? ( ) : loading ? (
) : active ? ( ) : ( )}
); }