Files
triggerdotdev--trigger.dev/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx
T
2026-07-13 13:32:57 +08:00

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>
);
}