1359 lines
49 KiB
TypeScript
1359 lines
49 KiB
TypeScript
import type { UIMessage } from "@ai-sdk/react";
|
||
import { useChat } from "@ai-sdk/react";
|
||
import { BoltIcon, CheckIcon, StopIcon } from "@heroicons/react/20/solid";
|
||
import { ClipboardIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||
import { type MetaFunction } from "@remix-run/node";
|
||
import { Link, useFetcher, useNavigate, useRouteLoaderData } from "@remix-run/react";
|
||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||
import { generateJWT as internal_generateJWT, MachinePresetName } from "@trigger.dev/core/v3";
|
||
import { TriggerChatTransport } from "@trigger.dev/sdk/chat";
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
|
||
import { PlusIcon } from "~/assets/icons/PlusIcon";
|
||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||
import { JSONEditor } from "~/components/code/JSONEditor";
|
||
import { Button } from "~/components/primitives/Buttons";
|
||
import {
|
||
ClientTabs,
|
||
ClientTabsContent,
|
||
ClientTabsList,
|
||
ClientTabsTrigger,
|
||
} from "~/components/primitives/ClientTabs";
|
||
import { DateTime } from "~/components/primitives/DateTime";
|
||
import { DurationPicker } from "~/components/primitives/DurationPicker";
|
||
import { Header3 } from "~/components/primitives/Headers";
|
||
import { Hint } from "~/components/primitives/Hint";
|
||
import { Input } from "~/components/primitives/Input";
|
||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||
import { Label } from "~/components/primitives/Label";
|
||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverMenuItem,
|
||
PopoverVerticalEllipseTrigger,
|
||
} from "~/components/primitives/Popover";
|
||
import {
|
||
ResizableHandle,
|
||
ResizablePanel,
|
||
ResizablePanelGroup,
|
||
} from "~/components/primitives/Resizable";
|
||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||
import { Spinner } from "~/components/primitives/Spinner";
|
||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||
import { MessageBubble } from "~/components/runs/v3/agent/AgentMessageView";
|
||
import { RunTagInput } from "~/components/runs/v3/RunTagInput";
|
||
import { env as serverEnv } from "~/env.server";
|
||
import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
|
||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||
import { useOrganization } from "~/hooks/useOrganizations";
|
||
import { useProject } from "~/hooks/useProject";
|
||
import { findProjectBySlug } from "~/models/project.server";
|
||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||
import type { PlaygroundConversation } from "~/presenters/v3/PlaygroundPresenter.server";
|
||
import { playgroundPresenter } from "~/presenters/v3/PlaygroundPresenter.server";
|
||
import { AIPayloadTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent";
|
||
import { SchemaTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent";
|
||
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
|
||
import { requireUserId } from "~/services/session.server";
|
||
import { cn } from "~/utils/cn";
|
||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||
|
||
export const meta: MetaFunction = () => {
|
||
return [{ title: "Playground | Trigger.dev" }];
|
||
};
|
||
|
||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||
const userId = await requireUserId(request);
|
||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||
const agentSlug = params.agentParam;
|
||
|
||
if (!agentSlug) {
|
||
throw new Response(undefined, { status: 404, statusText: "Agent not specified" });
|
||
}
|
||
|
||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||
if (!project) {
|
||
throw new Response(undefined, { status: 404, statusText: "Project not found" });
|
||
}
|
||
|
||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||
if (!environment) {
|
||
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
|
||
}
|
||
|
||
const agent = await playgroundPresenter.getAgent({
|
||
environmentId: environment.id,
|
||
environmentType: environment.type,
|
||
agentSlug,
|
||
});
|
||
|
||
if (!agent) {
|
||
throw new Response(undefined, { status: 404, statusText: "Agent not found" });
|
||
}
|
||
|
||
const agentConfig = agent.config as { type?: string } | null;
|
||
const apiOrigin = serverEnv.API_ORIGIN || serverEnv.LOGIN_ORIGIN || "http://localhost:3030";
|
||
|
||
const recentConversations = await playgroundPresenter.getRecentConversations({
|
||
environmentId: environment.id,
|
||
agentSlug,
|
||
userId,
|
||
});
|
||
|
||
// Check for ?conversation= param to resume an existing conversation
|
||
const url = new URL(request.url);
|
||
const conversationId = url.searchParams.get("conversation");
|
||
|
||
let activeConversation: {
|
||
chatId: string;
|
||
runFriendlyId: string | null;
|
||
publicAccessToken: string | null;
|
||
clientData: unknown;
|
||
messages: unknown;
|
||
lastEventId: string | null;
|
||
} | null = null;
|
||
|
||
if (conversationId) {
|
||
const conv = recentConversations.find((c) => c.id === conversationId);
|
||
if (conv) {
|
||
let jwt: string | null = null;
|
||
if (conv.isActive && conv.runFriendlyId) {
|
||
jwt = await internal_generateJWT({
|
||
secretKey: extractJwtSigningSecretKey(environment),
|
||
payload: {
|
||
sub: environment.id,
|
||
pub: true,
|
||
scopes: [`read:runs:${conv.runFriendlyId}`, `write:inputStreams:${conv.runFriendlyId}`],
|
||
},
|
||
expirationTime: "1h",
|
||
});
|
||
}
|
||
|
||
activeConversation = {
|
||
chatId: conv.chatId,
|
||
runFriendlyId: conv.runFriendlyId,
|
||
publicAccessToken: jwt,
|
||
clientData: conv.clientData,
|
||
messages: conv.messages,
|
||
lastEventId: conv.lastEventId,
|
||
};
|
||
}
|
||
}
|
||
|
||
return typedjson({
|
||
agent: {
|
||
slug: agent.slug,
|
||
filePath: agent.filePath,
|
||
type: agentConfig?.type ?? "unknown",
|
||
clientDataSchema: agent.payloadSchema ?? null,
|
||
},
|
||
apiOrigin,
|
||
recentConversations,
|
||
activeConversation,
|
||
});
|
||
};
|
||
|
||
export default function PlaygroundAgentPage() {
|
||
const { agent, activeConversation } = useTypedLoaderData<typeof loader>();
|
||
// Key on agent slug + conversation chatId so React remounts all stateful
|
||
// children when switching agents or navigating between conversations.
|
||
// Without the agent slug, switching agents keeps key="new" and React
|
||
// reuses the component — useState initializers don't re-run.
|
||
const conversationKey = `${agent.slug}:${activeConversation?.chatId ?? "new"}`;
|
||
return <PlaygroundChat key={conversationKey} />;
|
||
}
|
||
|
||
const PARENT_ROUTE_ID =
|
||
"routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground";
|
||
|
||
function PlaygroundChat() {
|
||
const { agent, apiOrigin, recentConversations, activeConversation } =
|
||
useTypedLoaderData<typeof loader>();
|
||
const parentData = useRouteLoaderData(PARENT_ROUTE_ID) as
|
||
| {
|
||
agents: Array<{ slug: string }>;
|
||
versions: string[];
|
||
regions: Array<{
|
||
id: string;
|
||
name: string;
|
||
description?: string;
|
||
isDefault: boolean;
|
||
}>;
|
||
isDev: boolean;
|
||
}
|
||
| undefined;
|
||
const versions = parentData?.versions ?? [];
|
||
const regions = parentData?.regions ?? [];
|
||
const isDev = parentData?.isDev ?? false;
|
||
const defaultRegion = regions.find((r) => r.isDefault);
|
||
const navigate = useNavigate();
|
||
const organization = useOrganization();
|
||
const project = useProject();
|
||
const environment = useEnvironment();
|
||
|
||
const [conversationId, setConversationId] = useState<string | null>(() =>
|
||
activeConversation
|
||
? (recentConversations.find((c) => c.chatId === activeConversation.chatId)?.id ?? null)
|
||
: null
|
||
);
|
||
const [chatId, _setChatId] = useState(() => activeConversation?.chatId ?? crypto.randomUUID());
|
||
const [clientDataJson, setClientDataJson] = useState(() =>
|
||
activeConversation?.clientData ? JSON.stringify(activeConversation.clientData, null, 2) : "{}"
|
||
);
|
||
const clientDataJsonRef = useRef(clientDataJson);
|
||
clientDataJsonRef.current = clientDataJson;
|
||
const [machine, setMachine] = useState<string | undefined>(undefined);
|
||
const [tags, setTags] = useState<string[]>([]);
|
||
const [maxAttempts, setMaxAttempts] = useState<number | undefined>(undefined);
|
||
const [maxDuration, setMaxDuration] = useState<number | undefined>(undefined);
|
||
const [version, setVersion] = useState<string | undefined>(undefined);
|
||
const [region, setRegion] = useState<string | undefined>(() =>
|
||
isDev ? undefined : defaultRegion?.name
|
||
);
|
||
|
||
const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/playground/action`;
|
||
|
||
// Server-side `start` via Remix action — atomically creates the
|
||
// backing Session for `chatId` and triggers the first run, returns
|
||
// the session-scoped PAT. Idempotent: called on initial use AND on
|
||
// 401, so the same code path serves both first-run and PAT renewal.
|
||
const startSession = useCallback(async (): Promise<string> => {
|
||
const formData = new FormData();
|
||
formData.set("intent", "start");
|
||
formData.set("agentSlug", agent.slug);
|
||
formData.set("chatId", chatId);
|
||
formData.set("clientData", clientDataJsonRef.current);
|
||
if (tags.length > 0) formData.set("tags", tags.join(","));
|
||
if (machine) formData.set("machine", machine);
|
||
if (maxAttempts) formData.set("maxAttempts", String(maxAttempts));
|
||
if (maxDuration) formData.set("maxDuration", String(maxDuration));
|
||
if (version) formData.set("version", version);
|
||
if (region) formData.set("region", region);
|
||
|
||
const response = await fetch(actionPath, { method: "POST", body: formData });
|
||
const data = (await response.json()) as {
|
||
runId?: string;
|
||
publicAccessToken?: string;
|
||
conversationId?: string;
|
||
error?: string;
|
||
};
|
||
|
||
if (!response.ok || !data.publicAccessToken) {
|
||
throw new Error(data.error ?? "Failed to start chat session");
|
||
}
|
||
|
||
if (data.conversationId) {
|
||
setConversationId(data.conversationId);
|
||
}
|
||
|
||
return data.publicAccessToken;
|
||
}, [actionPath, agent.slug, chatId, tags, machine, maxAttempts, maxDuration, version, region]);
|
||
|
||
// Same-origin resource routes: use the page origin, not apiOrigin, so in/append
|
||
// doesn't go cross-origin (CORS preflight) when API_ORIGIN != APP_ORIGIN.
|
||
const origin = typeof window !== "undefined" ? window.location.origin : apiOrigin;
|
||
const playgroundBaseURL = `${origin}/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/playground`;
|
||
|
||
// The transport is constructed once (guarded ref below); reading
|
||
// `startSession` directly there would freeze its closure to the
|
||
// first render's sidebar values, so subsequent edits to tags /
|
||
// machine / maxAttempts / maxDuration / version / region would be
|
||
// silently ignored on the first send. Mirror the `clientDataJsonRef`
|
||
// pattern so the transport always calls the latest `startSession`.
|
||
const startSessionRef = useRef(startSession);
|
||
startSessionRef.current = startSession;
|
||
|
||
// Create TriggerChatTransport directly (not via useTriggerChatTransport hook
|
||
// to avoid React version mismatch between SDK and webapp)
|
||
const transportRef = useRef<TriggerChatTransport | null>(null);
|
||
if (transportRef.current === null) {
|
||
transportRef.current = new TriggerChatTransport({
|
||
task: agent.slug,
|
||
// The Remix action is idempotent on `(env, externalId)` and
|
||
// returns a fresh session PAT every time, so it serves both
|
||
// first-run create and PAT renewal. `startSession` runs on
|
||
// `transport.preload(chatId)` and lazily on the first
|
||
// `sendMessage`; `accessToken` runs on a 401/403 from any
|
||
// session-PAT-authed request. Wiring the same call to both
|
||
// keeps the Preload button working without a separate refresh
|
||
// route.
|
||
startSession: async () => ({ publicAccessToken: await startSessionRef.current() }),
|
||
accessToken: () => startSessionRef.current(),
|
||
baseURL: playgroundBaseURL,
|
||
// Use safeParseJson so a mid-edit invalid JSON state in the editor
|
||
// doesn't throw and crash the component during transport construction.
|
||
clientData: safeParseJson(clientDataJson),
|
||
...(activeConversation?.publicAccessToken
|
||
? {
|
||
sessions: {
|
||
[activeConversation.chatId]: {
|
||
publicAccessToken: activeConversation.publicAccessToken,
|
||
lastEventId: activeConversation.lastEventId ?? undefined,
|
||
},
|
||
},
|
||
}
|
||
: {}),
|
||
});
|
||
}
|
||
const transport = transportRef.current;
|
||
|
||
// Keep the transport's `defaultMetadata` in sync with the JSON editor.
|
||
// Without this the transport uses the value captured at construction for
|
||
// every per-turn metadata merge, even after the user edits the JSON.
|
||
// `startSession` reads from `clientDataJsonRef.current` directly so session
|
||
// creation is unaffected — this only fixes the per-turn metadata path.
|
||
useEffect(() => {
|
||
// JSONEditor fires onChange on every keystroke — intermediate values
|
||
// like `{"key":` are syntactically invalid. `safeParseJson` returns
|
||
// `{}` on parse failure so the next valid keystroke lands the update
|
||
// without crashing the component mid-edit.
|
||
transport.setClientData(safeParseJson(clientDataJson));
|
||
}, [clientDataJson, transport]);
|
||
|
||
// Initial messages from persisted conversation (for resume)
|
||
const initialMessages = activeConversation?.messages
|
||
? (activeConversation.messages as UIMessage[])
|
||
: [];
|
||
|
||
// Track the initial message count so we only save after genuinely new turns
|
||
// (not during resume replay which re-fires onFinish for replayed turns)
|
||
const initialMessageCountRef = useRef(initialMessages?.length ?? 0);
|
||
|
||
// Save messages after each turn completes
|
||
const saveMessages = useCallback(
|
||
(allMessages: UIMessage[]) => {
|
||
// Skip saves during resume replay — only save when we have more messages than we started with
|
||
if (allMessages.length <= initialMessageCountRef.current) return;
|
||
|
||
const currentSession = transport.getSession(chatId);
|
||
const lastEventId = currentSession?.lastEventId;
|
||
|
||
const formData = new FormData();
|
||
formData.set("intent", "save");
|
||
formData.set("agentSlug", agent.slug);
|
||
formData.set("chatId", chatId);
|
||
formData.set("messages", JSON.stringify(allMessages));
|
||
if (lastEventId) formData.set("lastEventId", lastEventId);
|
||
|
||
// Fire and forget
|
||
fetch(actionPath, { method: "POST", body: formData }).catch(() => {});
|
||
|
||
// Update the baseline so subsequent saves work correctly
|
||
initialMessageCountRef.current = allMessages.length;
|
||
},
|
||
[chatId, agent.slug, actionPath, transport]
|
||
);
|
||
|
||
// useChat from AI SDK — handles message accumulation, streaming, stop
|
||
const { messages, sendMessage, stop, status, error } = useChat({
|
||
id: chatId,
|
||
messages: initialMessages,
|
||
transport,
|
||
onFinish: ({ messages: allMessages }) => {
|
||
saveMessages(allMessages);
|
||
},
|
||
});
|
||
|
||
const isStreaming = status === "streaming";
|
||
const isSubmitted = status === "submitted";
|
||
|
||
// Sticky-bottom auto-scroll for the messages list. The hook walks up to
|
||
// the surrounding `overflow-y-auto` panel and follows the conversation
|
||
// as new chunks stream in — pauses if you scroll up to read history,
|
||
// resumes when you scroll back into the bottom band. Same behavior as
|
||
// the run-inspector Agent tab.
|
||
const messagesRootRef = useAutoScrollToBottom([messages, isSubmitted]);
|
||
|
||
// Pending messages — steering during streaming
|
||
const pending = usePlaygroundPendingMessages({
|
||
transport,
|
||
chatId,
|
||
status,
|
||
messages,
|
||
sendMessage,
|
||
metadata: safeParseJson(clientDataJson),
|
||
});
|
||
|
||
const [input, setInput] = useState("");
|
||
const [preloading, setPreloading] = useState(false);
|
||
const [preloaded, setPreloaded] = useState(false);
|
||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
// Focus on mount; refocus when the state-1 ↔ state-2 transition remounts the textarea.
|
||
const isEmpty = messages.length === 0;
|
||
useEffect(() => {
|
||
inputRef.current?.focus();
|
||
}, [isEmpty]);
|
||
|
||
const session = transport.getSession(chatId);
|
||
|
||
const handlePreload = useCallback(async () => {
|
||
setPreloading(true);
|
||
try {
|
||
await transport.preload(chatId);
|
||
setPreloaded(true);
|
||
inputRef.current?.focus();
|
||
} finally {
|
||
setPreloading(false);
|
||
}
|
||
}, [transport, chatId]);
|
||
|
||
const handleNewConversation = useCallback(() => {
|
||
// Navigate without ?conversation= so the loader returns activeConversation=null
|
||
// and the key changes to "new", causing a full remount with fresh state.
|
||
navigate(window.location.pathname);
|
||
}, [navigate]);
|
||
|
||
const handleDeleteConversation = useCallback(async () => {
|
||
if (!conversationId) return;
|
||
|
||
const formData = new FormData();
|
||
formData.set("intent", "delete");
|
||
formData.set("agentSlug", agent.slug);
|
||
formData.set("deleteConversationId", conversationId);
|
||
|
||
await fetch(actionPath, { method: "POST", body: formData });
|
||
handleNewConversation();
|
||
}, [conversationId, agent.slug, actionPath, handleNewConversation]);
|
||
|
||
const handleSend = useCallback(() => {
|
||
const trimmed = input.trim();
|
||
if (!trimmed) return;
|
||
|
||
setInput("");
|
||
// steer() handles both cases: sends via input stream during streaming,
|
||
// or sends as a normal message when ready
|
||
pending.steer(trimmed);
|
||
// Keep focus after the state-1 → state-2 remount completes.
|
||
requestAnimationFrame(() => inputRef.current?.focus());
|
||
}, [input, pending]);
|
||
|
||
const handleKeyDown = useCallback(
|
||
(e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
},
|
||
[handleSend]
|
||
);
|
||
|
||
return (
|
||
<ResizablePanelGroup orientation="horizontal" className="h-full">
|
||
<ResizablePanel id="playground-chat" min="300px">
|
||
<div className="relative flex h-full flex-col">
|
||
{/* Header — floats over the scroll area so messages can scroll behind it */}
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-end px-2 py-2">
|
||
<div className="pointer-events-auto">
|
||
<ConversationMenu
|
||
runFriendlyId={activeConversation?.runFriendlyId ?? null}
|
||
hasMessages={messages.length > 0}
|
||
hasConversation={conversationId !== null}
|
||
messages={messages}
|
||
onDeleteConversation={handleDeleteConversation}
|
||
onNewConversation={handleNewConversation}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Scroll container is always mounted — useAutoScrollToBottom caches
|
||
its container on mount and won't refind it across remounts. */}
|
||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||
<div
|
||
ref={messagesRootRef}
|
||
className={cn(
|
||
"min-h-full",
|
||
messages.length === 0
|
||
? "flex items-center justify-center px-4 pb-[20vh] pt-8"
|
||
: "mx-auto w-full max-w-3xl space-y-4 px-4 pb-4 pt-12"
|
||
)}
|
||
>
|
||
{messages.length === 0 ? (
|
||
<div className="w-full max-w-2xl">
|
||
<h2 className="mb-6 text-center font-sans text-xl font-semibold leading-7 tracking-tight text-text-dimmed">
|
||
Type a message to start testing{" "}
|
||
<span className="inline-flex items-center gap-1.5 align-middle">
|
||
<CubeSparkleIcon className="size-6 text-agents" />
|
||
<code className="text-text-bright">{agent.slug}</code>
|
||
</span>
|
||
</h2>
|
||
<ChatComposer
|
||
inputRef={inputRef}
|
||
value={input}
|
||
onChange={setInput}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="Type a message…"
|
||
minHeightClassName="min-h-[80px]"
|
||
maxHeightClassName="max-h-[60vh]"
|
||
footer={
|
||
<>
|
||
<span className="text-[11px] text-text-dimmed">
|
||
Press Enter to send, Shift+Enter for new line
|
||
</span>
|
||
<PreloadButton
|
||
preloading={preloading}
|
||
preloaded={preloaded}
|
||
onPreload={handlePreload}
|
||
/>
|
||
</>
|
||
}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{messages.map((msg) => (
|
||
<MessageBubble key={msg.id} message={msg} />
|
||
))}
|
||
{isSubmitted && (
|
||
<div className="flex justify-start">
|
||
<div className="flex items-center gap-2 rounded-lg bg-background-hover px-4 py-2.5">
|
||
<Spinner className="size-3" />
|
||
<span className="text-sm text-text-dimmed">Thinking…</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error */}
|
||
{error && messages.length > 0 && (
|
||
<div className="mx-4 mb-2 flex items-start gap-2 rounded border border-error/30 bg-error/10 px-3 py-2">
|
||
<span className="flex-1 text-xs text-error">{error.message}</span>
|
||
</div>
|
||
)}
|
||
|
||
{messages.length > 0 && (
|
||
<div className="px-4 pb-8">
|
||
<div className="mx-auto max-w-3xl">
|
||
<ChatComposer
|
||
inputRef={inputRef}
|
||
value={input}
|
||
onChange={setInput}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder={isStreaming ? "Send a steering message…" : "Type a message…"}
|
||
minHeightClassName="min-h-[44px]"
|
||
maxHeightClassName="max-h-[40vh]"
|
||
trailing={
|
||
isStreaming ? (
|
||
<Button variant="danger/small" LeadingIcon={StopIcon} onClick={stop}>
|
||
Stop
|
||
</Button>
|
||
) : undefined
|
||
}
|
||
footer={
|
||
<span className="text-[11px] text-text-dimmed">
|
||
Press Enter to send, Shift+Enter for new line
|
||
</span>
|
||
}
|
||
/>
|
||
{pending.pending.length > 0 && (
|
||
<div className="mt-1.5 space-y-1">
|
||
{pending.pending.map((msg) => (
|
||
<div key={msg.id} className="flex items-center gap-2 text-[10px]">
|
||
<span
|
||
className={cn(
|
||
"rounded px-1.5 py-0.5",
|
||
msg.mode === "steering"
|
||
? "bg-amber-500/10 text-amber-400"
|
||
: "bg-background-raised text-text-dimmed"
|
||
)}
|
||
>
|
||
{msg.mode === "steering" ? "Steering" : "Queued"}
|
||
</span>
|
||
<span className="truncate text-text-dimmed">{msg.text}</span>
|
||
{msg.injected && <span className="text-success">Injected</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{isStreaming && (
|
||
<div className="mt-1.5 text-[10px] text-text-dimmed">
|
||
Send a steering message to guide the agent between tool calls
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ResizablePanel>
|
||
<ResizableHandle id="playground-sidebar-handle" />
|
||
<ResizablePanel id="playground-sidebar" default="420px" min="360px" max="720px">
|
||
<PlaygroundSidebar
|
||
clientDataJson={clientDataJson}
|
||
onClientDataChange={setClientDataJson}
|
||
getCurrentClientData={() => clientDataJsonRef.current}
|
||
clientDataSchema={agent.clientDataSchema}
|
||
agentSlug={agent.slug}
|
||
machine={machine}
|
||
onMachineChange={setMachine}
|
||
tags={tags}
|
||
onTagsChange={setTags}
|
||
maxAttempts={maxAttempts}
|
||
onMaxAttemptsChange={setMaxAttempts}
|
||
maxDuration={maxDuration}
|
||
onMaxDurationChange={setMaxDuration}
|
||
version={version}
|
||
onVersionChange={setVersion}
|
||
versions={versions}
|
||
region={region}
|
||
onRegionChange={setRegion}
|
||
regions={regions}
|
||
isDev={isDev}
|
||
session={session}
|
||
runFriendlyId={activeConversation?.runFriendlyId ?? undefined}
|
||
messageCount={messages.length}
|
||
isStreaming={isStreaming}
|
||
status={status}
|
||
recentConversations={recentConversations}
|
||
currentConversationId={conversationId}
|
||
actionPath={actionPath}
|
||
/>
|
||
</ResizablePanel>
|
||
</ResizablePanelGroup>
|
||
);
|
||
}
|
||
|
||
// Message rendering — `MessageBubble` is imported from
|
||
// `~/components/runs/v3/agent/AgentMessageView`. The same module is used by
|
||
// the run details Agent view so both surfaces stay in sync.
|
||
|
||
function ChatComposer({
|
||
inputRef,
|
||
value,
|
||
onChange,
|
||
onKeyDown,
|
||
placeholder,
|
||
minHeightClassName,
|
||
maxHeightClassName,
|
||
trailing,
|
||
footer,
|
||
}: {
|
||
inputRef: React.RefObject<HTMLTextAreaElement>;
|
||
value: string;
|
||
onChange: (val: string) => void;
|
||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||
placeholder: string;
|
||
minHeightClassName: string;
|
||
maxHeightClassName: string;
|
||
trailing?: React.ReactNode;
|
||
footer?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="rounded-2xl border border-border-bright bg-background-bright p-2 transition focus-within:border-border-brighter">
|
||
<div className="flex items-end gap-2">
|
||
<textarea
|
||
ref={inputRef}
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
onKeyDown={onKeyDown}
|
||
placeholder={placeholder}
|
||
className={cn(
|
||
"scrollbar-gutter-stable flex-1 resize-none border-0 bg-transparent px-2 py-1.5 text-sm text-text-bright placeholder-text-dimmed outline-hidden ring-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control field-sizing-content focus:border-0 focus:outline-hidden focus:ring-0",
|
||
minHeightClassName,
|
||
maxHeightClassName
|
||
)}
|
||
/>
|
||
{trailing}
|
||
</div>
|
||
{footer && (
|
||
<div className="mt-1 flex items-center justify-between gap-2 px-2 pb-1">{footer}</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PreloadButton({
|
||
preloading,
|
||
preloaded,
|
||
onPreload,
|
||
}: {
|
||
preloading: boolean;
|
||
preloaded: boolean;
|
||
onPreload: () => void;
|
||
}) {
|
||
const tooltip = preloaded
|
||
? "The agent is warmed up and waiting."
|
||
: "Pre-starts the agent to eliminate the cold-start delay on your first message.";
|
||
|
||
// Wrap in a span so hover fires even when the inner button is disabled —
|
||
// disabled buttons don't receive pointer events on their own.
|
||
return (
|
||
<SimpleTooltip
|
||
asChild
|
||
side="top"
|
||
className="max-w-[240px]"
|
||
content={tooltip}
|
||
button={
|
||
<span className="inline-flex">
|
||
<Button
|
||
variant="tertiary/small"
|
||
LeadingIcon={preloading ? Spinner : preloaded ? CheckIcon : BoltIcon}
|
||
leadingIconClassName={preloaded ? "text-success" : undefined}
|
||
onClick={onPreload}
|
||
disabled={preloading || preloaded}
|
||
>
|
||
{preloaded ? "Preloaded" : preloading ? "Preloading…" : "Preload"}
|
||
</Button>
|
||
</span>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sidebar
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const machinePresets = Object.values(MachinePresetName.enum);
|
||
|
||
function PlaygroundSidebar({
|
||
clientDataJson,
|
||
onClientDataChange,
|
||
getCurrentClientData,
|
||
clientDataSchema,
|
||
agentSlug,
|
||
machine,
|
||
onMachineChange,
|
||
tags,
|
||
onTagsChange,
|
||
maxAttempts,
|
||
onMaxAttemptsChange,
|
||
maxDuration,
|
||
onMaxDurationChange,
|
||
version,
|
||
onVersionChange,
|
||
versions,
|
||
region,
|
||
onRegionChange,
|
||
regions,
|
||
isDev,
|
||
session,
|
||
runFriendlyId,
|
||
messageCount,
|
||
isStreaming,
|
||
status,
|
||
recentConversations,
|
||
currentConversationId,
|
||
actionPath,
|
||
}: {
|
||
clientDataJson: string;
|
||
onClientDataChange: (val: string) => void;
|
||
getCurrentClientData: () => string;
|
||
clientDataSchema: unknown;
|
||
agentSlug: string;
|
||
machine: string | undefined;
|
||
onMachineChange: (val: string | undefined) => void;
|
||
tags: string[];
|
||
onTagsChange: (val: string[]) => void;
|
||
maxAttempts: number | undefined;
|
||
onMaxAttemptsChange: (val: number | undefined) => void;
|
||
maxDuration: number | undefined;
|
||
onMaxDurationChange: (val: number | undefined) => void;
|
||
version: string | undefined;
|
||
onVersionChange: (val: string | undefined) => void;
|
||
versions: string[];
|
||
region: string | undefined;
|
||
onRegionChange: (val: string | undefined) => void;
|
||
regions: Array<{ id: string; name: string; description?: string; isDefault: boolean }>;
|
||
isDev: boolean;
|
||
session:
|
||
| {
|
||
publicAccessToken: string;
|
||
lastEventId?: string;
|
||
isStreaming?: boolean;
|
||
}
|
||
| undefined;
|
||
/**
|
||
* Friendly id of the latest run for this conversation (drawn from the
|
||
* playground's own `playgroundConversation` table, which mirrors the
|
||
* Session's `currentRunId`). Optional because a conversation may
|
||
* exist briefly before the first run lands.
|
||
*/
|
||
runFriendlyId: string | undefined;
|
||
messageCount: number;
|
||
isStreaming: boolean;
|
||
status: string;
|
||
recentConversations: PlaygroundConversation[];
|
||
currentConversationId: string | null;
|
||
actionPath: string;
|
||
}) {
|
||
const regionItems = regions.map((r) => ({
|
||
value: r.name,
|
||
label: r.description ? `${r.name} — ${r.description}` : r.name,
|
||
}));
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
<ClientTabs
|
||
defaultValue="clientData"
|
||
className="flex h-full min-h-0 flex-col overflow-hidden pt-1"
|
||
>
|
||
<div className="h-fit overflow-x-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||
<ClientTabsList variant="underline" className="mx-3 shrink-0">
|
||
<ClientTabsTrigger
|
||
value="clientData"
|
||
variant="underline"
|
||
layoutId="playground-sidebar-tabs"
|
||
className="shrink-0"
|
||
>
|
||
Client Data
|
||
</ClientTabsTrigger>
|
||
<ClientTabsTrigger
|
||
value="options"
|
||
variant="underline"
|
||
layoutId="playground-sidebar-tabs"
|
||
className="shrink-0"
|
||
>
|
||
Options
|
||
</ClientTabsTrigger>
|
||
<ClientTabsTrigger
|
||
value="session"
|
||
variant="underline"
|
||
layoutId="playground-sidebar-tabs"
|
||
className="shrink-0"
|
||
>
|
||
Session
|
||
</ClientTabsTrigger>
|
||
<ClientTabsTrigger
|
||
value="history"
|
||
variant="underline"
|
||
layoutId="playground-sidebar-tabs"
|
||
className="shrink-0"
|
||
>
|
||
History
|
||
</ClientTabsTrigger>
|
||
</ClientTabsList>
|
||
</div>
|
||
|
||
{/* Client Data tab */}
|
||
<ClientTabsContent
|
||
value="clientData"
|
||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||
>
|
||
<div className="min-w-64 space-y-4 p-3">
|
||
<div className="space-y-2">
|
||
<div className="space-y-0.5">
|
||
<Header3 className="text-text-bright">Custom metadata</Header3>
|
||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||
Sent with each conversation turn.
|
||
</Paragraph>
|
||
</div>
|
||
<div className="overflow-hidden rounded border border-border-bright">
|
||
<JSONEditor
|
||
defaultValue={clientDataJson}
|
||
readOnly={false}
|
||
onChange={onClientDataChange}
|
||
minHeight="120px"
|
||
maxHeight="300px"
|
||
showClearButton={false}
|
||
showCopyButton={false}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<AIPayloadTabContent
|
||
onPayloadGenerated={onClientDataChange}
|
||
payloadSchema={clientDataSchema ?? undefined}
|
||
taskIdentifier={agentSlug}
|
||
getCurrentPayload={getCurrentClientData}
|
||
generateButtonLabel="Generate client data"
|
||
placeholder="Prompt to generate client data using AI…"
|
||
isAgent={true}
|
||
showExamplePromptsHeader={false}
|
||
examplePromptsOverride={[
|
||
"Generate valid client data",
|
||
"Generate client data with all fields",
|
||
"Generate client data with edge cases",
|
||
]}
|
||
/>
|
||
|
||
{clientDataSchema != null && (
|
||
<SchemaTabContent
|
||
schema={clientDataSchema}
|
||
title="Schema"
|
||
description="JSON Schema for the agent's client data."
|
||
showDocsLink={false}
|
||
/>
|
||
)}
|
||
</div>
|
||
</ClientTabsContent>
|
||
|
||
{/* Options tab */}
|
||
<ClientTabsContent
|
||
value="options"
|
||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||
>
|
||
<div className="space-y-4 p-3">
|
||
<InputGroup fullWidth>
|
||
<Label variant="small" required={false}>
|
||
Machine
|
||
</Label>
|
||
<Select
|
||
value={machine ?? ""}
|
||
setValue={(val) =>
|
||
onMachineChange(val && typeof val === "string" ? val : undefined)
|
||
}
|
||
placeholder="Default"
|
||
variant="tertiary/small"
|
||
items={machinePresets}
|
||
filter={(item, search) => item.toLowerCase().includes(search.toLowerCase())}
|
||
>
|
||
{(matches) =>
|
||
matches.map((preset) => (
|
||
<SelectItem key={preset} value={preset}>
|
||
{preset}
|
||
</SelectItem>
|
||
))
|
||
}
|
||
</Select>
|
||
<Hint>Overrides the machine preset.</Hint>
|
||
</InputGroup>
|
||
|
||
<InputGroup fullWidth>
|
||
<Label variant="small" required={false}>
|
||
Tags
|
||
</Label>
|
||
<RunTagInput
|
||
tags={tags}
|
||
onTagsChange={onTagsChange}
|
||
variant="small"
|
||
maxTags={3}
|
||
placeholder="Add tag…"
|
||
/>
|
||
<Hint>Add tags to easily filter runs. 3 max (2 added automatically).</Hint>
|
||
</InputGroup>
|
||
|
||
<InputGroup fullWidth>
|
||
<Label variant="small" required={false}>
|
||
Max attempts
|
||
</Label>
|
||
<Input
|
||
type="number"
|
||
variant="small"
|
||
min={1}
|
||
placeholder="Default"
|
||
value={maxAttempts ?? ""}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
onMaxAttemptsChange(val ? parseInt(val, 10) : undefined);
|
||
}}
|
||
/>
|
||
<Hint>Retries failed runs up to the specified number of attempts.</Hint>
|
||
</InputGroup>
|
||
|
||
<InputGroup fullWidth>
|
||
<Label variant="small" required={false}>
|
||
Max duration
|
||
</Label>
|
||
<DurationPicker value={maxDuration} onChange={onMaxDurationChange} variant="small" />
|
||
<Hint>Overrides the maximum compute time limit for the run.</Hint>
|
||
</InputGroup>
|
||
|
||
{versions.length > 0 && (
|
||
<InputGroup fullWidth>
|
||
<Label variant="small" required={false}>
|
||
Version
|
||
</Label>
|
||
<Select
|
||
value={version ?? ""}
|
||
setValue={(val) =>
|
||
onVersionChange(val && typeof val === "string" ? val : undefined)
|
||
}
|
||
placeholder="Latest"
|
||
variant="tertiary/small"
|
||
disabled={isDev}
|
||
items={versions}
|
||
filter={(item, search) => item.toLowerCase().includes(search.toLowerCase())}
|
||
>
|
||
{(matches) =>
|
||
matches.map((v, i) => (
|
||
<SelectItem key={v} value={v}>
|
||
{i === 0 ? `${v} (latest)` : v}
|
||
</SelectItem>
|
||
))
|
||
}
|
||
</Select>
|
||
<Hint>
|
||
{isDev
|
||
? "Version is determined by the running dev server."
|
||
: "Lock the run to a specific deployed version."}
|
||
</Hint>
|
||
</InputGroup>
|
||
)}
|
||
|
||
{regionItems.length > 1 && (
|
||
<InputGroup fullWidth>
|
||
<Label variant="small" required={false}>
|
||
Region
|
||
</Label>
|
||
<Select
|
||
value={region ?? ""}
|
||
setValue={(val) =>
|
||
onRegionChange(val && typeof val === "string" ? val : undefined)
|
||
}
|
||
text={(val) => val || undefined}
|
||
placeholder={isDev ? "–" : "Default"}
|
||
variant="tertiary/small"
|
||
disabled={isDev}
|
||
items={regionItems}
|
||
filter={(item, search) => item.label.toLowerCase().includes(search.toLowerCase())}
|
||
>
|
||
{(matches) =>
|
||
matches.map((r) => (
|
||
<SelectItem key={r.value} value={r.value}>
|
||
{r.label}
|
||
</SelectItem>
|
||
))
|
||
}
|
||
</Select>
|
||
<Hint>
|
||
{isDev
|
||
? "Region is not applicable in development."
|
||
: "Run the agent in a specific region."}
|
||
</Hint>
|
||
</InputGroup>
|
||
)}
|
||
</div>
|
||
</ClientTabsContent>
|
||
|
||
{/* Session tab */}
|
||
<ClientTabsContent
|
||
value="session"
|
||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||
>
|
||
<div className="min-w-64 space-y-3 p-3">
|
||
{session ? (
|
||
<>
|
||
{runFriendlyId && <SessionField label="Run ID" value={runFriendlyId} />}
|
||
<SessionField label="Messages" value={String(messageCount)} />
|
||
<div>
|
||
<label className="mb-0.5 block text-[10px] font-medium uppercase tracking-wider text-text-dimmed">
|
||
Status
|
||
</label>
|
||
<span className="flex items-center gap-1.5 text-xs">
|
||
<span
|
||
className={cn(
|
||
"size-1.5 rounded-full",
|
||
isStreaming ? "animate-pulse bg-success" : "bg-blue-500"
|
||
)}
|
||
/>
|
||
<span className="capitalize text-text-bright">{status}</span>
|
||
</span>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="text-xs text-text-dimmed">
|
||
No active session. Send a message to start a conversation.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</ClientTabsContent>
|
||
|
||
{/* History tab */}
|
||
<ClientTabsContent
|
||
value="history"
|
||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||
>
|
||
<HistoryTabContent
|
||
conversations={recentConversations}
|
||
currentConversationId={currentConversationId}
|
||
actionPath={actionPath}
|
||
/>
|
||
</ClientTabsContent>
|
||
</ClientTabs>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pending messages hook (reimplemented to avoid React version mismatch)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const PENDING_MESSAGE_INJECTED_TYPE = "data-pending-message-injected";
|
||
|
||
type PendingMessageEntry = {
|
||
id: string;
|
||
text: string;
|
||
mode: "steering" | "queued";
|
||
injected: boolean;
|
||
};
|
||
|
||
function usePlaygroundPendingMessages({
|
||
transport,
|
||
chatId,
|
||
status,
|
||
messages,
|
||
sendMessage,
|
||
metadata,
|
||
}: {
|
||
transport: TriggerChatTransport;
|
||
chatId: string;
|
||
status: string;
|
||
messages: UIMessage[];
|
||
sendMessage: (msg: { text: string }, opts?: { metadata?: Record<string, unknown> }) => void;
|
||
metadata?: Record<string, unknown>;
|
||
}) {
|
||
type InternalMsg = {
|
||
id: string;
|
||
role: "user";
|
||
parts: { type: "text"; text: string }[];
|
||
_mode: "steering" | "queued";
|
||
};
|
||
const [pendingMsgs, setPendingMsgs] = useState<InternalMsg[]>([]);
|
||
const injectedIdsRef = useRef<Set<string>>(new Set());
|
||
const prevStatusRef = useRef(status);
|
||
|
||
// Watch for injection confirmation chunks
|
||
useEffect(() => {
|
||
if (status !== "streaming") return;
|
||
let newlyInjected = false;
|
||
for (const msg of messages) {
|
||
if (msg.role !== "assistant") continue;
|
||
for (const part of msg.parts ?? []) {
|
||
if ((part as any).type === PENDING_MESSAGE_INJECTED_TYPE) {
|
||
const messageIds = (part as any).data?.messageIds as string[] | undefined;
|
||
if (Array.isArray(messageIds)) {
|
||
for (const id of messageIds) {
|
||
if (!injectedIdsRef.current.has(id)) {
|
||
injectedIdsRef.current.add(id);
|
||
newlyInjected = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (newlyInjected) {
|
||
setPendingMsgs((prev) => prev.filter((m) => !injectedIdsRef.current.has(m.id)));
|
||
}
|
||
}, [status, messages]);
|
||
|
||
// Handle turn completion — auto-send non-injected messages as next turn
|
||
useEffect(() => {
|
||
const turnCompleted = prevStatusRef.current === "streaming" && status === "ready";
|
||
prevStatusRef.current = status;
|
||
if (!turnCompleted) return;
|
||
|
||
const toSend = pendingMsgs.filter((m) => !injectedIdsRef.current.has(m.id));
|
||
setPendingMsgs([]);
|
||
injectedIdsRef.current.clear();
|
||
|
||
if (toSend.length > 0) {
|
||
const text = toSend.map((m) => m.parts[0]?.text ?? "").join("\n");
|
||
sendMessage({ text }, metadata ? { metadata } : undefined);
|
||
}
|
||
}, [status, pendingMsgs, sendMessage, metadata, messages]);
|
||
|
||
const steer = useCallback(
|
||
(text: string) => {
|
||
if (status === "streaming") {
|
||
const msg: InternalMsg = {
|
||
id: crypto.randomUUID(),
|
||
role: "user",
|
||
parts: [{ type: "text", text }],
|
||
_mode: "steering",
|
||
};
|
||
transport.sendPendingMessage(chatId, msg as any, metadata);
|
||
setPendingMsgs((prev) => [...prev, msg]);
|
||
} else {
|
||
sendMessage({ text }, metadata ? { metadata } : undefined);
|
||
}
|
||
},
|
||
[status, transport, chatId, sendMessage, metadata]
|
||
);
|
||
|
||
const pending: PendingMessageEntry[] = pendingMsgs.map((m) => ({
|
||
id: m.id,
|
||
text: m.parts[0]?.text ?? "",
|
||
mode: m._mode,
|
||
injected: injectedIdsRef.current.has(m.id),
|
||
}));
|
||
|
||
return { pending, steer };
|
||
}
|
||
|
||
function ConversationMenu({
|
||
runFriendlyId,
|
||
hasMessages,
|
||
hasConversation,
|
||
messages,
|
||
onDeleteConversation,
|
||
onNewConversation,
|
||
}: {
|
||
runFriendlyId: string | null;
|
||
hasMessages: boolean;
|
||
hasConversation: boolean;
|
||
messages: UIMessage[];
|
||
onDeleteConversation: () => void;
|
||
onNewConversation: () => void;
|
||
}) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
|
||
const handleCopyRaw = useCallback(() => {
|
||
void navigator.clipboard.writeText(JSON.stringify(messages, null, 2));
|
||
setIsOpen(false);
|
||
}, [messages]);
|
||
|
||
const wrap = useCallback(
|
||
(fn: () => void) => () => {
|
||
setIsOpen(false);
|
||
fn();
|
||
},
|
||
[]
|
||
);
|
||
|
||
return (
|
||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||
<PopoverVerticalEllipseTrigger variant="minimal" />
|
||
<PopoverContent className="w-fit min-w-48 p-1" align="end">
|
||
<div className="flex flex-col gap-1">
|
||
<PopoverMenuItem
|
||
icon={PlusIcon}
|
||
leadingIconClassName="text-green-500 -mx-0.5"
|
||
title="New conversation"
|
||
onClick={wrap(onNewConversation)}
|
||
/>
|
||
{runFriendlyId && (
|
||
<PopoverMenuItem
|
||
to={`/runs/${runFriendlyId}`}
|
||
icon={RunsIcon}
|
||
leadingIconClassName="text-runs -mx-0.5"
|
||
title="View runs"
|
||
onClick={() => setIsOpen(false)}
|
||
/>
|
||
)}
|
||
{hasMessages && (
|
||
<PopoverMenuItem
|
||
icon={ClipboardIcon}
|
||
leadingIconClassName="text-yellow-500"
|
||
title="Copy raw"
|
||
onClick={handleCopyRaw}
|
||
/>
|
||
)}
|
||
{hasConversation && (
|
||
<PopoverMenuItem
|
||
icon={TrashIcon}
|
||
leadingIconClassName="text-error"
|
||
title="Delete conversation"
|
||
danger
|
||
onClick={wrap(onDeleteConversation)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
function HistoryTabContent({
|
||
conversations,
|
||
currentConversationId,
|
||
actionPath,
|
||
}: {
|
||
conversations: PlaygroundConversation[];
|
||
currentConversationId: string | null;
|
||
actionPath: string;
|
||
}) {
|
||
const fetcher = useFetcher();
|
||
const navigate = useNavigate();
|
||
|
||
const deletingId =
|
||
fetcher.state !== "idle" ? (fetcher.formData?.get("deleteConversationId") as string) : null;
|
||
|
||
const handleDelete = useCallback(
|
||
(e: React.MouseEvent, conv: PlaygroundConversation) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
fetcher.submit(
|
||
{
|
||
intent: "delete",
|
||
agentSlug: conv.agentSlug,
|
||
deleteConversationId: conv.id,
|
||
},
|
||
{ method: "POST", action: actionPath }
|
||
);
|
||
|
||
// Deleted the active conv → clear ?conversation= so chat resets.
|
||
if (conv.id === currentConversationId) {
|
||
navigate(window.location.pathname);
|
||
}
|
||
},
|
||
[actionPath, currentConversationId, fetcher, navigate]
|
||
);
|
||
|
||
if (conversations.length === 0) {
|
||
return (
|
||
<div className="p-3">
|
||
<p className="text-xs text-text-dimmed">
|
||
No previous conversations yet. Send a message to start one.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<ol className="space-y-0.5 p-2">
|
||
{conversations.map((conv, index) => {
|
||
const isActive = conv.id === currentConversationId;
|
||
return (
|
||
<li key={conv.id}>
|
||
<div
|
||
className={cn(
|
||
"group flex items-start gap-2 rounded-sm px-2 py-1.5 transition-colors hover:bg-background-bright",
|
||
isActive && "bg-background-hover hover:bg-background-hover",
|
||
deletingId === conv.id && "pointer-events-none opacity-50"
|
||
)}
|
||
>
|
||
<span className="w-5 shrink-0 text-right text-sm font-medium tabular-nums text-text-dimmed">
|
||
{index + 1}
|
||
</span>
|
||
<Link
|
||
to={`?conversation=${conv.id}`}
|
||
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 outline-hidden focus-custom"
|
||
>
|
||
<Paragraph variant="small/bright" className="line-clamp-1 text-left">
|
||
{conv.title}
|
||
</Paragraph>
|
||
<div className="text-xs text-text-dimmed">
|
||
<DateTime date={conv.updatedAt} showTooltip={false} />
|
||
</div>
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => handleDelete(e, conv)}
|
||
aria-label="Delete conversation"
|
||
className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity group-hover:opacity-100 hover:text-error"
|
||
>
|
||
<TrashIcon className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
);
|
||
}
|
||
|
||
function safeParseJson(json: string): Record<string, unknown> {
|
||
try {
|
||
return JSON.parse(json || "{}") as Record<string, unknown>;
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function SessionField({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div>
|
||
<label className="mb-0.5 block text-[10px] font-medium uppercase tracking-wider text-text-dimmed">
|
||
{label}
|
||
</label>
|
||
<code className="block truncate text-xs text-text-bright">{value}</code>
|
||
</div>
|
||
);
|
||
}
|