chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user