283 lines
9.9 KiB
TypeScript
283 lines
9.9 KiB
TypeScript
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<DashboardAgentChatListItem[]>([]);
|
|
const [active, setActive] = useState<ActiveChat | null>(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<DashboardAgentClientData>(
|
|
() => ({
|
|
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 (
|
|
<div className="flex h-full flex-col bg-background-bright animate-in slide-in-from-right-2 duration-150">
|
|
<DashboardAgentHeader
|
|
view={view}
|
|
onNewChat={newChat}
|
|
onToggleHistory={toggleHistory}
|
|
onClose={onClose}
|
|
/>
|
|
|
|
{view === "history" ? (
|
|
<DashboardAgentHistory
|
|
chats={chats}
|
|
currentChatId={active?.chatId ?? ""}
|
|
onSelect={switchChat}
|
|
onNewChat={newChat}
|
|
onDelete={deleteChat}
|
|
/>
|
|
) : loading ? (
|
|
<div className="flex flex-1 items-center justify-center">
|
|
<Spinner className="size-5" />
|
|
</div>
|
|
) : active ? (
|
|
<DashboardAgentChat
|
|
key={active.chatId}
|
|
chatId={active.chatId}
|
|
initialMessages={active.messages}
|
|
session={active.session}
|
|
pendingFirstMessage={active.pendingFirstMessage}
|
|
streaming={active.streaming}
|
|
clientData={clientData}
|
|
apiOrigin={apiOrigin}
|
|
actionPath={actionPath}
|
|
projectSlug={project.slug}
|
|
environmentSlug={environment.slug}
|
|
currentPage={currentPage}
|
|
onTurnSettled={loadHistory}
|
|
/>
|
|
) : (
|
|
<DashboardAgentDraft
|
|
onSubmit={createChat}
|
|
projectSlug={project.slug}
|
|
environmentSlug={environment.slug}
|
|
currentPage={currentPage}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|