feat(webapp): Sessions list + detail views and run inspector linkage
- Sessions list mirrors the Runs list (ClickHouse-backed, filterable, cursor-paginated, derived ACTIVE/CLOSED/EXPIRED status). - Session detail page: split-pane Conversation + Inspector with Overview/Runs/Metadata tabs, breadcrumb status combo, Close session action via a Remix resource route, dashboard-cookie-authed SSE for input/output streams. - AgentView decoupled from a specific run — now subscribes via session-scoped SSE, so the same component renders on both run and session pages with identical streaming behavior. - Run inspector adds a Session row (gated on AGENT-tagged runs) linking back to the owning session, mirroring the existing Batch row pattern. - stress-emit chat.agent task added to the ai-chat reference for stress-testing the conversation UI.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ArrowsRightLeftIcon,
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
BookOpenIcon,
|
||||
@@ -189,6 +190,28 @@ export function BatchesNone() {
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionsNone() {
|
||||
return (
|
||||
<InfoPanel
|
||||
title="Sessions"
|
||||
icon={ArrowsRightLeftIcon}
|
||||
iconClassName="text-teal-500"
|
||||
panelClassName="max-w-full"
|
||||
accessory={
|
||||
<LinkButton to={docsPath("/ai-chat/overview")} variant="docs/small" LeadingIcon={BookOpenIcon}>
|
||||
Sessions docs
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
You have no sessions in this environment. Sessions are durable, typed, bidirectional I/O
|
||||
primitives that outlive a single run — used by <InlineCode>chat.agent</InlineCode> and any
|
||||
long-running task that needs streaming input and output.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function TestHasNoTasks() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AdjustmentsHorizontalIcon,
|
||||
ArrowPathRoundedSquareIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
@@ -91,6 +92,7 @@ import {
|
||||
v3QueuesPath,
|
||||
v3RunsPath,
|
||||
v3SchedulesPath,
|
||||
v3SessionsPath,
|
||||
v3TestPath,
|
||||
v3UsagePath,
|
||||
v3WaitpointTokensPath,
|
||||
@@ -478,6 +480,15 @@ export function SideMenu({
|
||||
to={v3AgentsPath(organization, project, environment)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Sessions"
|
||||
icon={ArrowsRightLeftIcon}
|
||||
activeIconColor="text-teal-500"
|
||||
inactiveIconColor="text-teal-500"
|
||||
to={v3SessionsPath(organization, project, environment)}
|
||||
data-action="sessions"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Playground"
|
||||
icon={BeakerIcon}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives
|
||||
|
||||
export function AgentMessageView({ messages }: { messages: UIMessage[] }) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[800px] flex-col gap-2">
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[800px] flex-col gap-2">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
@@ -55,9 +55,9 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
.join("") ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="flex min-w-0 justify-end">
|
||||
<div className="max-w-[80%] rounded-lg bg-indigo-600 px-4 py-2.5 text-sm text-white">
|
||||
<div className="whitespace-pre-wrap">{text}</div>
|
||||
<div className="whitespace-pre-wrap [overflow-wrap:anywhere]">{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,11 +29,6 @@ export type AgentViewAuth = {
|
||||
initialMessages: UIMessage[];
|
||||
};
|
||||
|
||||
type AgentViewRun = {
|
||||
friendlyId: string;
|
||||
taskIdentifier: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Max state-update interval while assistant chunks are streaming. Matches
|
||||
* the `experimental_throttle: 100` we previously passed to `useChat`.
|
||||
@@ -51,9 +46,9 @@ const STATE_FLUSH_THROTTLE_MS = 100;
|
||||
const INITIAL_PAYLOAD_TIMESTAMP = 0;
|
||||
|
||||
/**
|
||||
* Renders an agent run's chat conversation as it unfolds.
|
||||
* Renders a Session's chat conversation as it unfolds.
|
||||
*
|
||||
* Subscribes to both channels of the run's backing {@link Session}:
|
||||
* Subscribes to both channels of the {@link Session}:
|
||||
* - **`.out`** delivers assistant `UIMessageChunk`s (text deltas, tool
|
||||
* calls, reasoning, etc.) produced by the agent's
|
||||
* `chatStream.writer(...)` calls — objects, already parsed by the S2
|
||||
@@ -74,19 +69,12 @@ const INITIAL_PAYLOAD_TIMESTAMP = 0;
|
||||
* Intended to be mounted inside a scrollable container — the component
|
||||
* does not own its own scrollbar.
|
||||
*/
|
||||
export function AgentView({
|
||||
run,
|
||||
agentView,
|
||||
}: {
|
||||
run: AgentViewRun;
|
||||
agentView: AgentViewAuth;
|
||||
}) {
|
||||
export function AgentView({ agentView }: { agentView: AgentViewAuth }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const messages = useAgentRunMessages({
|
||||
runFriendlyId: run.friendlyId,
|
||||
const messages = useAgentSessionMessages({
|
||||
sessionId: agentView.sessionId,
|
||||
apiOrigin: agentView.apiOrigin,
|
||||
orgSlug: organization.slug,
|
||||
@@ -120,8 +108,8 @@ export function AgentView({
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useAgentRunMessages — reads both realtime streams for a run and maintains
|
||||
// a chronologically ordered, merged message list.
|
||||
// useAgentSessionMessages — reads both realtime streams for a session and
|
||||
// maintains a chronologically ordered, merged message list.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -222,8 +210,7 @@ function createOrchestrationState(): MessageOrchestrationState {
|
||||
};
|
||||
}
|
||||
|
||||
function useAgentRunMessages({
|
||||
runFriendlyId,
|
||||
function useAgentSessionMessages({
|
||||
sessionId,
|
||||
apiOrigin,
|
||||
orgSlug,
|
||||
@@ -231,7 +218,6 @@ function useAgentRunMessages({
|
||||
envSlug,
|
||||
initialMessages,
|
||||
}: {
|
||||
runFriendlyId: string;
|
||||
sessionId: string;
|
||||
apiOrigin: string;
|
||||
orgSlug: string;
|
||||
@@ -287,9 +273,14 @@ function useAgentRunMessages({
|
||||
const abort = new AbortController();
|
||||
|
||||
const encodedSession = encodeURIComponent(sessionId);
|
||||
// Always use the page's own origin to avoid CORS preflight failures
|
||||
// when the configured `apiOrigin` (e.g. `localhost`) differs from the
|
||||
// origin the dashboard was loaded from (e.g. `127.0.0.1`). The dashboard
|
||||
// resource route is same-origin by construction.
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : apiOrigin;
|
||||
const sessionBase =
|
||||
`${apiOrigin}/resources/orgs/${orgSlug}/projects/${projectSlug}/env/${envSlug}` +
|
||||
`/runs/${runFriendlyId}/realtime/v1/sessions/${encodedSession}`;
|
||||
`${origin}/resources/orgs/${orgSlug}/projects/${projectSlug}/env/${envSlug}` +
|
||||
`/sessions/${encodedSession}/realtime/v1`;
|
||||
|
||||
const outputUrl = `${sessionBase}/out`;
|
||||
const inputUrl = `${sessionBase}/in`;
|
||||
@@ -463,7 +454,7 @@ function useAgentRunMessages({
|
||||
pendingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [runFriendlyId, sessionId, apiOrigin, orgSlug, projectSlug, envSlug]);
|
||||
}, [sessionId, apiOrigin, orgSlug, projectSlug, envSlug]);
|
||||
|
||||
return useMemo(() => {
|
||||
const timestamps = timestampsRef.current;
|
||||
|
||||
@@ -211,7 +211,7 @@ export function AssistantResponse({
|
||||
/>
|
||||
{mode === "rendered" ? (
|
||||
<ChatBubble>
|
||||
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
|
||||
<div className="streamdown-container min-w-0 font-sans text-sm font-normal text-text-dimmed [overflow-wrap:anywhere]">
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
|
||||
<StreamdownRenderer>{text}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
|
||||
type CloseSessionDialogProps = {
|
||||
sessionParam: string;
|
||||
environmentId: string;
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
export function CloseSessionDialog({
|
||||
sessionParam,
|
||||
environmentId,
|
||||
redirectPath,
|
||||
}: CloseSessionDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/sessions/${encodeURIComponent(sessionParam)}/close`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="close-session">
|
||||
<DialogHeader>Close this session?</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph>
|
||||
Closing a session is permanent. The session will no longer accept new input or trigger
|
||||
new runs. Any in-flight run continues until it finishes on its own.
|
||||
</Paragraph>
|
||||
<Form action={formAction} method="post" className="flex flex-col gap-3">
|
||||
<input type="hidden" name="redirectUrl" value={redirectPath} />
|
||||
<input type="hidden" name="environmentId" value={environmentId} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="close-session-reason">Reason (optional)</Label>
|
||||
<Input
|
||||
id="close-session-reason"
|
||||
name="reason"
|
||||
placeholder="e.g. user signed out, ticket resolved"
|
||||
variant="medium"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : XCircleIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Closing..." : "Close session"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant={"tertiary/medium"}>Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import {
|
||||
CpuChipIcon,
|
||||
FingerPrintIcon,
|
||||
TagIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { ListFilterIcon } from "lucide-react";
|
||||
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { StatusIcon } from "~/assets/icons/StatusIcon";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectButtonItem,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
import {
|
||||
appliedSummary,
|
||||
FilterMenuProvider,
|
||||
TimeFilter,
|
||||
} from "../../runs/v3/SharedFilters";
|
||||
import {
|
||||
allSessionStatuses,
|
||||
descriptionForSessionStatus,
|
||||
SessionStatusCombo,
|
||||
sessionStatusTitle,
|
||||
} from "./SessionStatus";
|
||||
|
||||
const StringOrStringArray = z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
z.array(z.string()).optional()
|
||||
);
|
||||
|
||||
export const SessionStatus = z.enum(allSessionStatuses);
|
||||
|
||||
export const SessionListSearchFilters = z.object({
|
||||
cursor: z.string().optional(),
|
||||
direction: z.enum(["forward", "backward"]).optional(),
|
||||
statuses: z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
SessionStatus.array().optional()
|
||||
),
|
||||
types: StringOrStringArray,
|
||||
taskIdentifiers: StringOrStringArray,
|
||||
externalId: z.string().optional(),
|
||||
tags: StringOrStringArray,
|
||||
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
|
||||
from: z.coerce.number().optional(),
|
||||
to: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export type SessionListSearchFilters = z.infer<typeof SessionListSearchFilters>;
|
||||
export type SessionListSearchFilterKey = keyof SessionListSearchFilters;
|
||||
|
||||
export function getSessionFiltersFromSearchParams(
|
||||
searchParams: URLSearchParams
|
||||
): SessionListSearchFilters {
|
||||
function listOrUndefined(key: string) {
|
||||
const values = searchParams.getAll(key).filter((v) => v.length > 0);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
const params = {
|
||||
cursor: searchParams.get("cursor") ?? undefined,
|
||||
direction: searchParams.get("direction") ?? undefined,
|
||||
statuses: listOrUndefined("statuses"),
|
||||
types: listOrUndefined("types"),
|
||||
taskIdentifiers: listOrUndefined("taskIdentifiers"),
|
||||
externalId: searchParams.get("externalId") ?? undefined,
|
||||
tags: listOrUndefined("tags"),
|
||||
period: searchParams.get("period") ?? undefined,
|
||||
from: searchParams.get("from") ?? undefined,
|
||||
to: searchParams.get("to") ?? undefined,
|
||||
};
|
||||
|
||||
const parsed = SessionListSearchFilters.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return {};
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
type SessionFiltersProps = {
|
||||
hasFilters: boolean;
|
||||
possibleTypes?: string[];
|
||||
};
|
||||
|
||||
export function SessionFilters(props: SessionFiltersProps) {
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const hasFilters =
|
||||
searchParams.has("statuses") ||
|
||||
searchParams.has("types") ||
|
||||
searchParams.has("taskIdentifiers") ||
|
||||
searchParams.has("externalId") ||
|
||||
searchParams.has("tags");
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
<FilterMenu {...props} />
|
||||
<TimeFilter />
|
||||
<AppliedFilters />
|
||||
{hasFilters && (
|
||||
<Form className="h-6">
|
||||
<Button variant="secondary/small" LeadingIcon={XMarkIcon} tooltip="Clear all filters" />
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filterTypes = [
|
||||
{
|
||||
name: "statuses",
|
||||
title: "Status",
|
||||
icon: <StatusIcon className="size-4 border-text-bright" />,
|
||||
},
|
||||
{ name: "types", title: "Type", icon: <CpuChipIcon className="size-4" /> },
|
||||
{
|
||||
name: "taskIdentifiers",
|
||||
title: "Task",
|
||||
icon: <TaskIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
name: "externalId",
|
||||
title: "External ID",
|
||||
icon: <FingerPrintIcon className="size-4" />,
|
||||
},
|
||||
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
|
||||
] as const;
|
||||
|
||||
type FilterType = (typeof filterTypes)[number]["name"];
|
||||
|
||||
const shortcut = { key: "f" };
|
||||
|
||||
function FilterMenu(props: SessionFiltersProps) {
|
||||
const [filterType, setFilterType] = useState<FilterType | undefined>();
|
||||
|
||||
const filterTrigger = (
|
||||
<SelectTrigger
|
||||
icon={
|
||||
<div className="flex size-4 items-center justify-center">
|
||||
<ListFilterIcon className="size-3.5" />
|
||||
</div>
|
||||
}
|
||||
variant={"secondary/small"}
|
||||
shortcut={shortcut}
|
||||
tooltipTitle={"Filter sessions"}
|
||||
>
|
||||
Filter
|
||||
</SelectTrigger>
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterMenuProvider onClose={() => setFilterType(undefined)}>
|
||||
{(search, setSearch) => (
|
||||
<Menu
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
trigger={filterTrigger}
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedFilters() {
|
||||
return (
|
||||
<>
|
||||
<AppliedStatusFilter />
|
||||
<AppliedTypeFilter />
|
||||
<AppliedTaskIdentifierFilter />
|
||||
<AppliedExternalIdFilter />
|
||||
<AppliedTagsFilter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MenuProps = {
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
trigger: React.ReactNode;
|
||||
filterType: FilterType | undefined;
|
||||
setFilterType: (filterType: FilterType | undefined) => void;
|
||||
} & SessionFiltersProps;
|
||||
|
||||
function Menu(props: MenuProps) {
|
||||
switch (props.filterType) {
|
||||
case undefined:
|
||||
return <MainMenu {...props} />;
|
||||
case "statuses":
|
||||
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "types":
|
||||
return <TypeDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "taskIdentifiers":
|
||||
return (
|
||||
<TaskIdentifierDropdown onClose={() => props.setFilterType(undefined)} {...props} />
|
||||
);
|
||||
case "externalId":
|
||||
return <ExternalIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "tags":
|
||||
return <TagsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
|
||||
const filtered = useMemo(() => {
|
||||
return filterTypes.filter((item) =>
|
||||
item.title.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover>
|
||||
<ComboBox placeholder={"Filter by..."} shortcut={shortcut} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((type, index) => (
|
||||
<SelectButtonItem
|
||||
key={type.name}
|
||||
onClick={() => {
|
||||
clearSearchValue();
|
||||
setFilterType(type.name);
|
||||
}}
|
||||
icon={type.icon}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
{type.title}
|
||||
</SelectButtonItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const statusItems = allSessionStatuses.map((status) => ({
|
||||
title: sessionStatusTitle(status),
|
||||
value: status,
|
||||
}));
|
||||
|
||||
function StatusDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (next: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ statuses: next, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return statusItems.filter((item) =>
|
||||
item.title.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("statuses")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by status..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="group flex w-full flex-col py-0">
|
||||
<SessionStatusCombo status={item.value} iconClassName="animate-none" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={9}>
|
||||
<Paragraph variant="extra-small">
|
||||
{descriptionForSessionStatus(item.value)}
|
||||
</Paragraph>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedStatusFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const statuses = values("statuses");
|
||||
|
||||
if (statuses.length === 0) return null;
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<StatusDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Status"
|
||||
icon={<StatusIcon className="size-3.5" />}
|
||||
value={appliedSummary(
|
||||
statuses.map((v) => sessionStatusTitle(v as (typeof allSessionStatuses)[number]))
|
||||
)}
|
||||
onRemove={() => del(["statuses", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeDropdown({
|
||||
trigger,
|
||||
searchValue,
|
||||
clearSearchValue,
|
||||
possibleTypes,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
possibleTypes?: string[];
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (next: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ types: next, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const items = useMemo(() => {
|
||||
const all = possibleTypes && possibleTypes.length > 0 ? possibleTypes : ["chat"];
|
||||
const seen = new Set(all);
|
||||
for (const v of values("types")) {
|
||||
if (!seen.has(v)) {
|
||||
all.push(v);
|
||||
seen.add(v);
|
||||
}
|
||||
}
|
||||
return all.filter((t) => t.toLowerCase().includes(searchValue.toLowerCase()));
|
||||
}, [possibleTypes, searchValue, values]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("types")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by type..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{items.map((value, index) => (
|
||||
<SelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<span className="font-mono text-xs">{value}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedTypeFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const types = values("types");
|
||||
if (types.length === 0) return null;
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TypeDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Type"
|
||||
icon={<CpuChipIcon className="size-3.5" />}
|
||||
value={appliedSummary(types)}
|
||||
onRemove={() => del(["types", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskIdentifierDropdown({
|
||||
trigger,
|
||||
searchValue,
|
||||
clearSearchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const current = value("taskIdentifiers");
|
||||
const [draft, setDraft] = useState(current ?? "");
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
taskIdentifiers: draft.trim() === "" ? undefined : [draft.trim()],
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
});
|
||||
setOpen(false);
|
||||
}, [clearSearchValue, draft, replace]);
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(32ch,var(--popover-available-width))]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Task identifier</Label>
|
||||
<Input
|
||||
placeholder="my-task"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[29ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["mod"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={apply}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedTaskIdentifierFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const taskIdentifiers = values("taskIdentifiers");
|
||||
if (taskIdentifiers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TaskIdentifierDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Task"
|
||||
icon={<TaskIcon className="size-3.5" />}
|
||||
value={appliedSummary(taskIdentifiers)}
|
||||
onRemove={() => del(["taskIdentifiers", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalIdDropdown({
|
||||
trigger,
|
||||
searchValue,
|
||||
clearSearchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const current = value("externalId");
|
||||
const [draft, setDraft] = useState(current ?? "");
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
externalId: draft.trim() === "" ? undefined : draft.trim(),
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
});
|
||||
setOpen(false);
|
||||
}, [clearSearchValue, draft, replace]);
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(36ch,var(--popover-available-width))]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>External ID</Label>
|
||||
<Input
|
||||
placeholder="user-supplied id"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[33ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["mod"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={apply}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedExternalIdFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
const externalId = value("externalId");
|
||||
if (!externalId) return null;
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<ExternalIdDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="External ID"
|
||||
icon={<FingerPrintIcon className="size-3.5" />}
|
||||
value={externalId}
|
||||
onRemove={() => del(["externalId", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TagsDropdown({
|
||||
trigger,
|
||||
searchValue,
|
||||
clearSearchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { values, replace } = useSearchParams();
|
||||
const current = values("tags");
|
||||
const [draft, setDraft] = useState(current.join(", "));
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
const next = draft
|
||||
.split(/[,\n]/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
replace({
|
||||
tags: next.length === 0 ? undefined : next,
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
});
|
||||
setOpen(false);
|
||||
}, [clearSearchValue, draft, replace]);
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(40ch,var(--popover-available-width))]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Tags</Label>
|
||||
<Input
|
||||
placeholder="tag1, tag2"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[37ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Paragraph variant="extra-small/dimmed">
|
||||
Comma-separated. Matches sessions with any of these tags.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["mod"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={apply}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedTagsFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const tags = values("tags");
|
||||
if (tags.length === 0) return null;
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TagsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Tags"
|
||||
icon={<TagIcon className="size-3.5" />}
|
||||
value={appliedSummary(tags)}
|
||||
onRemove={() => del(["tags", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { CheckCircleIcon, ClockIcon } from "@heroicons/react/20/solid";
|
||||
import assertNever from "assert-never";
|
||||
import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const allSessionStatuses = ["ACTIVE", "CLOSED", "EXPIRED"] as const satisfies Readonly<
|
||||
Array<SessionStatus>
|
||||
>;
|
||||
|
||||
const descriptions: Record<SessionStatus, string> = {
|
||||
ACTIVE: "The session is open and can receive input or schedule new runs.",
|
||||
CLOSED: "The session was closed; no further input or runs can be triggered against it.",
|
||||
EXPIRED: "The session passed its expiry time without being closed explicitly.",
|
||||
};
|
||||
|
||||
export function descriptionForSessionStatus(status: SessionStatus): string {
|
||||
return descriptions[status];
|
||||
}
|
||||
|
||||
export function sessionStatusTitle(status: SessionStatus): string {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "Active";
|
||||
case "CLOSED":
|
||||
return "Closed";
|
||||
case "EXPIRED":
|
||||
return "Expired";
|
||||
default:
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionStatusColor(status: SessionStatus): string {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "text-pending";
|
||||
case "CLOSED":
|
||||
return "text-success";
|
||||
case "EXPIRED":
|
||||
return "text-text-dimmed";
|
||||
default:
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionStatusIcon({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status: SessionStatus;
|
||||
className: string;
|
||||
}) {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return (
|
||||
<span className={cn("inline-flex items-center justify-center", className)}>
|
||||
<span className="size-2 rounded-full bg-pending" />
|
||||
</span>
|
||||
);
|
||||
case "CLOSED":
|
||||
return <CheckCircleIcon className={cn(sessionStatusColor(status), className)} />;
|
||||
case "EXPIRED":
|
||||
return <ClockIcon className={cn(sessionStatusColor(status), className)} />;
|
||||
default:
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionStatusLabel({ status }: { status: SessionStatus }) {
|
||||
return <span className={sessionStatusColor(status)}>{sessionStatusTitle(status)}</span>;
|
||||
}
|
||||
|
||||
export function SessionStatusCombo({
|
||||
status,
|
||||
className,
|
||||
iconClassName,
|
||||
}: {
|
||||
status: SessionStatus;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1", className)}>
|
||||
<SessionStatusIcon status={status} className={cn("h-4 w-4", iconClassName)} />
|
||||
<SessionStatusLabel status={status} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/20/solid";
|
||||
import { useLocation, useNavigation } from "@remix-run/react";
|
||||
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { ListBulletIcon } from "~/assets/icons/ListBulletIcon";
|
||||
import { MiddleTruncate } from "~/components/primitives/MiddleTruncate";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { RunTag } from "~/components/runs/v3/RunTag";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import {
|
||||
type SessionListItem,
|
||||
type SessionList,
|
||||
} from "~/presenters/v3/SessionListPresenter.server";
|
||||
import { v3RunPath, v3RunsPath, v3SessionPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
descriptionForSessionStatus,
|
||||
SessionStatusCombo,
|
||||
allSessionStatuses,
|
||||
} from "./SessionStatus";
|
||||
|
||||
type SessionsTableProps = Pick<SessionList, "sessions" | "filters" | "hasFilters">;
|
||||
|
||||
export function SessionsTable({ sessions, hasFilters }: SessionsTableProps) {
|
||||
const navigation = useNavigation();
|
||||
const location = useLocation();
|
||||
const isLoading =
|
||||
navigation.state !== "idle" && navigation.location?.pathname === location.pathname;
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<Table className="max-h-full overflow-y-auto">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||||
{allSessionStatuses.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className="grid grid-cols-[6rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||||
<SessionStatusCombo status={status} iconClassName="animate-none" />
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="!text-wrap text-text-dimmed">
|
||||
{descriptionForSessionStatus(status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Status
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Type</TableHeaderCell>
|
||||
<TableHeaderCell>Task</TableHeaderCell>
|
||||
<TableHeaderCell>Tags</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Duration</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.length === 0 ? (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">
|
||||
{hasFilters
|
||||
? "No sessions match these filters"
|
||||
: "No sessions in this environment yet"}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const runPath = session.currentRunFriendlyId
|
||||
? v3RunPath(organization, project, environment, {
|
||||
friendlyId: session.currentRunFriendlyId,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const displayId = session.externalId ?? session.friendlyId;
|
||||
const sessionPath = v3SessionPath(organization, project, environment, {
|
||||
friendlyId: session.friendlyId,
|
||||
});
|
||||
const allRunsPath = v3RunsPath(organization, project, environment, {
|
||||
tags: [`chat:${displayId}`],
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRow key={session.id}>
|
||||
<TableCell to={sessionPath} isTabbableCell>
|
||||
<div className="w-[28ch]">
|
||||
<MiddleTruncate text={displayId} className="font-mono text-xs" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath}>
|
||||
<SimpleTooltip
|
||||
content={descriptionForSessionStatus(session.status)}
|
||||
disableHoverableContent
|
||||
button={<SessionStatusCombo status={session.status} />}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath}>
|
||||
<span className="font-mono text-xs">{session.type}</span>
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath}>
|
||||
<div className="w-[24ch]">
|
||||
<MiddleTruncate
|
||||
text={session.taskIdentifier}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath}>
|
||||
{session.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{session.tags.map((tag) => (
|
||||
<RunTag key={tag} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={sessionPath}>
|
||||
<DateTime date={session.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
to={sessionPath}
|
||||
className="w-[1%]"
|
||||
actionClassName="pr-0 tabular-nums"
|
||||
>
|
||||
<SessionDuration session={session} />
|
||||
</TableCell>
|
||||
<SessionActionsCell runPath={runPath} allRunsPath={allRunsPath} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{isLoading && (
|
||||
<TableBlankRow
|
||||
colSpan={8}
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
|
||||
>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading…</span>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionDuration({ session }: { session: SessionListItem }) {
|
||||
// Active sessions tick live; closed/expired sessions freeze at the
|
||||
// moment they ended (closedAt for explicit closes, expiresAt when the
|
||||
// TTL ran out without a close call).
|
||||
const endedAt =
|
||||
session.status === "CLOSED"
|
||||
? session.closedAt
|
||||
: session.status === "EXPIRED"
|
||||
? session.expiresAt
|
||||
: undefined;
|
||||
|
||||
if (endedAt) {
|
||||
return <>{formatDuration(new Date(session.createdAt), new Date(endedAt), { style: "short" })}</>;
|
||||
}
|
||||
|
||||
return <LiveTimer startTime={new Date(session.createdAt)} />;
|
||||
}
|
||||
|
||||
function SessionActionsCell({
|
||||
runPath,
|
||||
allRunsPath,
|
||||
}: {
|
||||
runPath?: string;
|
||||
allRunsPath: string;
|
||||
}) {
|
||||
return (
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
{runPath && (
|
||||
<PopoverMenuItem
|
||||
to={runPath}
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-runs"
|
||||
title="View current run"
|
||||
/>
|
||||
)}
|
||||
<PopoverMenuItem
|
||||
to={allRunsPath}
|
||||
icon={ListBulletIcon}
|
||||
leadingIconClassName="text-runs"
|
||||
title="View all runs"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
getSessionFiltersFromSearchParams,
|
||||
SessionListSearchFilters,
|
||||
} from "~/components/sessions/v1/SessionFilters";
|
||||
import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
|
||||
|
||||
export type SessionFiltersFromRequest = SessionListSearchFilters & {
|
||||
statuses?: SessionStatus[];
|
||||
};
|
||||
|
||||
export function getSessionFiltersFromRequest(request: Request): SessionFiltersFromRequest {
|
||||
const url = new URL(request.url);
|
||||
const s = getSessionFiltersFromSearchParams(url.searchParams);
|
||||
return {
|
||||
...s,
|
||||
statuses: s.statuses as SessionStatus[] | undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { type Span } from "@opentelemetry/api";
|
||||
import { type ClickHouse } from "@internal/clickhouse";
|
||||
import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
import { type Direction } from "~/components/ListPagination";
|
||||
import { timeFilters } from "~/components/runs/v3/SharedFilters";
|
||||
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
type SessionStatus,
|
||||
SessionsRepository,
|
||||
} from "~/services/sessionsRepository/sessionsRepository.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
|
||||
export type SessionListOptions = {
|
||||
userId?: string;
|
||||
projectId: string;
|
||||
// filters
|
||||
types?: string[];
|
||||
taskIdentifiers?: string[];
|
||||
externalId?: string;
|
||||
tags?: string[];
|
||||
statuses?: SessionStatus[];
|
||||
period?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
// pagination
|
||||
direction?: Direction;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
export type SessionList = Awaited<ReturnType<SessionListPresenter["call"]>>;
|
||||
export type SessionListItem = SessionList["sessions"][0];
|
||||
export type SessionListAppliedFilters = SessionList["filters"];
|
||||
|
||||
export class SessionListPresenter {
|
||||
constructor(
|
||||
private readonly replica: PrismaClientOrTransaction,
|
||||
private readonly clickhouse: ClickHouse
|
||||
) {}
|
||||
|
||||
public async call(
|
||||
organizationId: string,
|
||||
environmentId: string,
|
||||
options: SessionListOptions
|
||||
) {
|
||||
return startActiveSpan(
|
||||
"SessionListPresenter.call",
|
||||
(span) => this.#call(organizationId, environmentId, options, span),
|
||||
{
|
||||
attributes: {
|
||||
organizationId,
|
||||
environmentId,
|
||||
projectId: options.projectId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async #call(
|
||||
organizationId: string,
|
||||
environmentId: string,
|
||||
{
|
||||
userId,
|
||||
projectId,
|
||||
types,
|
||||
taskIdentifiers,
|
||||
externalId,
|
||||
tags,
|
||||
statuses,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
direction = "forward",
|
||||
cursor,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: SessionListOptions,
|
||||
rootSpan: Span
|
||||
) {
|
||||
const time = timeFilters({ period, from, to });
|
||||
|
||||
const hasFilters =
|
||||
(types !== undefined && types.length > 0) ||
|
||||
(taskIdentifiers !== undefined && taskIdentifiers.length > 0) ||
|
||||
(externalId !== undefined && externalId !== "") ||
|
||||
(tags !== undefined && tags.length > 0) ||
|
||||
(statuses !== undefined && statuses.length > 0) ||
|
||||
!time.isDefault;
|
||||
|
||||
rootSpan.setAttribute("filters.hasFilters", hasFilters);
|
||||
rootSpan.setAttribute("page.size", pageSize);
|
||||
if (cursor) rootSpan.setAttribute("page.cursor", cursor);
|
||||
|
||||
const displayableEnvironment = await startActiveSpan(
|
||||
"SessionListPresenter.findDisplayableEnvironment",
|
||||
() => findDisplayableEnvironment(environmentId, userId)
|
||||
);
|
||||
if (!displayableEnvironment) {
|
||||
throw new ServiceValidationError("No environment found");
|
||||
}
|
||||
|
||||
const sessionsRepository = new SessionsRepository({
|
||||
clickhouse: this.clickhouse,
|
||||
prisma: this.replica as PrismaClient,
|
||||
});
|
||||
|
||||
function clampToNow(date: Date): Date {
|
||||
const now = new Date();
|
||||
return date > now ? now : date;
|
||||
}
|
||||
|
||||
const { sessions, pagination } = await sessionsRepository.listSessions({
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
types,
|
||||
taskIdentifiers,
|
||||
externalId,
|
||||
tags,
|
||||
statuses,
|
||||
period,
|
||||
from: time.from ? time.from.getTime() : undefined,
|
||||
to: time.to ? clampToNow(time.to).getTime() : undefined,
|
||||
page: {
|
||||
size: pageSize,
|
||||
cursor,
|
||||
direction,
|
||||
},
|
||||
});
|
||||
|
||||
rootSpan.setAttribute("page.count", sessions.length);
|
||||
|
||||
let hasAnySessions = sessions.length > 0;
|
||||
if (!hasAnySessions) {
|
||||
const firstSession = await startActiveSpan(
|
||||
"SessionListPresenter.hasAnySessions",
|
||||
() =>
|
||||
this.replica.session.findFirst({
|
||||
where: { runtimeEnvironmentId: environmentId },
|
||||
select: { id: true },
|
||||
})
|
||||
);
|
||||
if (firstSession) {
|
||||
hasAnySessions = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve current-run friendlyIds in one query so each row can link to
|
||||
// its live run. Status is intentionally not joined yet — that lives in
|
||||
// ClickHouse and would mean a second query per page; the link itself
|
||||
// is the value most viewers want first.
|
||||
const currentRunIds = sessions
|
||||
.map((s) => s.currentRunId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
|
||||
const currentRuns = await startActiveSpan(
|
||||
"SessionListPresenter.findCurrentRuns",
|
||||
async (span) => {
|
||||
span.setAttribute("currentRunIds.count", currentRunIds.length);
|
||||
return currentRunIds.length > 0
|
||||
? this.replica.taskRun.findMany({
|
||||
where: { id: { in: currentRunIds } },
|
||||
select: { id: true, friendlyId: true },
|
||||
})
|
||||
: [];
|
||||
}
|
||||
);
|
||||
const runById = new Map(currentRuns.map((r) => [r.id, r] as const));
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
sessions: sessions.map((session) => {
|
||||
const status: SessionStatus =
|
||||
session.closedAt != null
|
||||
? "CLOSED"
|
||||
: session.expiresAt != null && session.expiresAt.getTime() < now
|
||||
? "EXPIRED"
|
||||
: "ACTIVE";
|
||||
|
||||
const currentRun = session.currentRunId ? runById.get(session.currentRunId) : undefined;
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
friendlyId: session.friendlyId,
|
||||
externalId: session.externalId,
|
||||
type: session.type,
|
||||
taskIdentifier: session.taskIdentifier,
|
||||
tags: session.tags ? [...session.tags].sort((a, b) => a.localeCompare(b)) : [],
|
||||
status,
|
||||
closedAt: session.closedAt ? session.closedAt.toISOString() : undefined,
|
||||
closedReason: session.closedReason ?? undefined,
|
||||
expiresAt: session.expiresAt ? session.expiresAt.toISOString() : undefined,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
updatedAt: session.updatedAt.toISOString(),
|
||||
environment: displayableEnvironment,
|
||||
currentRunFriendlyId: currentRun?.friendlyId,
|
||||
};
|
||||
}),
|
||||
pagination: {
|
||||
next: pagination.nextCursor ?? undefined,
|
||||
previous: pagination.previousCursor ?? undefined,
|
||||
},
|
||||
filters: {
|
||||
types: types ?? [],
|
||||
taskIdentifiers: taskIdentifiers ?? [],
|
||||
externalId,
|
||||
tags: tags ?? [],
|
||||
statuses: statuses ?? [],
|
||||
from: time.from,
|
||||
to: time.to,
|
||||
},
|
||||
hasFilters,
|
||||
hasAnySessions,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { type Span } from "@opentelemetry/api";
|
||||
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
import { env } from "~/env.server";
|
||||
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
|
||||
export type SessionDetail = NonNullable<Awaited<ReturnType<SessionPresenter["call"]>>>;
|
||||
|
||||
export class SessionPresenter {
|
||||
constructor(private readonly replica: PrismaClientOrTransaction) {}
|
||||
|
||||
public async call(args: {
|
||||
userId: string;
|
||||
environmentId: string;
|
||||
sessionParam: string;
|
||||
}) {
|
||||
return startActiveSpan(
|
||||
"SessionPresenter.call",
|
||||
(span) => this.#call(args, span),
|
||||
{
|
||||
attributes: {
|
||||
environmentId: args.environmentId,
|
||||
sessionParam: args.sessionParam,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async #call(
|
||||
{
|
||||
userId,
|
||||
environmentId,
|
||||
sessionParam,
|
||||
}: {
|
||||
userId: string;
|
||||
environmentId: string;
|
||||
sessionParam: string;
|
||||
},
|
||||
rootSpan: Span
|
||||
) {
|
||||
const session = await startActiveSpan(
|
||||
"SessionPresenter.resolveSession",
|
||||
() => resolveSessionByIdOrExternalId(this.replica, environmentId, sessionParam)
|
||||
);
|
||||
if (!session) {
|
||||
rootSpan.setAttribute("session.found", false);
|
||||
return null;
|
||||
}
|
||||
rootSpan.setAttribute("session.found", true);
|
||||
rootSpan.setAttribute("session.id", session.id);
|
||||
|
||||
const displayableEnvironment = await startActiveSpan(
|
||||
"SessionPresenter.findDisplayableEnvironment",
|
||||
() => findDisplayableEnvironment(environmentId, userId)
|
||||
);
|
||||
if (!displayableEnvironment) {
|
||||
throw new ServiceValidationError("No environment found");
|
||||
}
|
||||
|
||||
// Run history is append-only; latest first matches the runs list.
|
||||
// 50 covers the vast majority of sessions; longer histories link out
|
||||
// to the runs page via tag filter.
|
||||
const sessionRuns = await startActiveSpan(
|
||||
"SessionPresenter.findSessionRuns",
|
||||
async (span) => {
|
||||
const rows = await this.replica.sessionRun.findMany({
|
||||
where: { sessionId: session.id },
|
||||
orderBy: { triggeredAt: "desc" },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true,
|
||||
runId: true,
|
||||
reason: true,
|
||||
triggeredAt: true,
|
||||
},
|
||||
});
|
||||
span.setAttribute("sessionRuns.count", rows.length);
|
||||
return rows;
|
||||
}
|
||||
);
|
||||
|
||||
const runIds = sessionRuns.map((r) => r.runId);
|
||||
const runs = await startActiveSpan(
|
||||
"SessionPresenter.findRuns",
|
||||
async (span) => {
|
||||
span.setAttribute("runIds.count", runIds.length);
|
||||
return runIds.length > 0
|
||||
? this.replica.taskRun.findMany({
|
||||
where: { id: { in: runIds } },
|
||||
select: { id: true, friendlyId: true, status: true },
|
||||
})
|
||||
: [];
|
||||
}
|
||||
);
|
||||
const runsById = new Map(runs.map((r) => [r.id, r] as const));
|
||||
|
||||
const currentRun = session.currentRunId
|
||||
? runsById.get(session.currentRunId) ??
|
||||
(await startActiveSpan(
|
||||
"SessionPresenter.findCurrentRunFallback",
|
||||
() =>
|
||||
this.replica.taskRun.findFirst({
|
||||
where: { id: session.currentRunId! },
|
||||
select: { id: true, friendlyId: true, status: true },
|
||||
})
|
||||
))
|
||||
: null;
|
||||
|
||||
// The dashboard SSE route is cookie-authed, so `publicAccessToken` is
|
||||
// unused — kept here to match the existing `AgentViewAuth` shape.
|
||||
const addressingKey = session.externalId ?? session.friendlyId;
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
friendlyId: session.friendlyId,
|
||||
externalId: session.externalId,
|
||||
type: session.type,
|
||||
taskIdentifier: session.taskIdentifier,
|
||||
tags: session.tags ? [...session.tags].sort((a, b) => a.localeCompare(b)) : [],
|
||||
metadata: session.metadata,
|
||||
triggerConfig: session.triggerConfig,
|
||||
streamBasinName: session.streamBasinName,
|
||||
closedAt: session.closedAt ? session.closedAt.toISOString() : undefined,
|
||||
closedReason: session.closedReason ?? undefined,
|
||||
expiresAt: session.expiresAt ? session.expiresAt.toISOString() : undefined,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
updatedAt: session.updatedAt.toISOString(),
|
||||
environment: displayableEnvironment,
|
||||
currentRun: currentRun
|
||||
? { friendlyId: currentRun.friendlyId, status: currentRun.status }
|
||||
: null,
|
||||
runs: sessionRuns.map((r) => {
|
||||
const run = runsById.get(r.runId);
|
||||
return {
|
||||
id: r.id,
|
||||
reason: r.reason,
|
||||
triggeredAt: r.triggeredAt.toISOString(),
|
||||
run: run
|
||||
? { friendlyId: run.friendlyId, status: run.status }
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
agentView: {
|
||||
publicAccessToken: "",
|
||||
apiOrigin: env.API_ORIGIN || env.LOGIN_ORIGIN,
|
||||
sessionId: addressingKey,
|
||||
initialMessages: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
type MachinePreset,
|
||||
parsePacket,
|
||||
prettyPrintPacket,
|
||||
RunAnnotations,
|
||||
SemanticInternalAttributes,
|
||||
@@ -10,20 +9,6 @@ import {
|
||||
type V3TaskRunContext,
|
||||
} from "@trigger.dev/core/v3";
|
||||
|
||||
/**
|
||||
* Minimal structural type for the user messages we extract from an agent
|
||||
* run's task payload. We deliberately avoid importing AI SDK's `UIMessage`
|
||||
* here because the webapp's pinned `ai@4` declares a wider role union
|
||||
* (`'data' | ...`) than `@ai-sdk/react@3`'s `UIMessage` accepts. The data
|
||||
* crosses a JSON boundary anyway (typedjson) — keeping this loose lets the
|
||||
* client-side type be the source of truth.
|
||||
*/
|
||||
type AgentInitialMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
parts?: unknown[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic";
|
||||
import {
|
||||
extractIdempotencyKeyScope,
|
||||
@@ -260,46 +245,6 @@ export class SpanPresenter extends BasePresenter {
|
||||
const taskKind = RunAnnotations.safeParse(run.annotations).data?.taskKind;
|
||||
const isAgentRun = taskKind === "AGENT";
|
||||
|
||||
// For agent runs, extract the initial user messages + the backing
|
||||
// Session handle from the task payload (from the original
|
||||
// `triggerTask({ payload: { messages, sessionId, chatId, ... } })`
|
||||
// call). When the run was started with `trigger: "preload"`,
|
||||
// `messages` is empty — the first user message arrives later over
|
||||
// the session `.in` channel and is merged in by the AgentView.
|
||||
//
|
||||
// `agentSession` is the identifier the dashboard uses to address the
|
||||
// backing Session when subscribing to `.out` / `.in`. Prefer the
|
||||
// explicit `sessionId` threaded by `TriggerChatTransport` /
|
||||
// `chat.createTriggerAction`; fall back to `chatId` for pre-migration
|
||||
// agent runs (the session resource route accepts either, matching
|
||||
// `resolveSessionByIdOrExternalId`).
|
||||
let agentInitialMessages: AgentInitialMessage[] = [];
|
||||
let agentSession: string | null = null;
|
||||
if (isAgentRun && run.payload && run.payloadType !== "application/store") {
|
||||
try {
|
||||
const parsed = await parsePacket({
|
||||
data: typeof run.payload === "string" ? run.payload : JSON.stringify(run.payload),
|
||||
dataType: run.payloadType ?? "application/json",
|
||||
});
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (Array.isArray((parsed as any).messages)) {
|
||||
agentInitialMessages = (parsed as any).messages as AgentInitialMessage[];
|
||||
}
|
||||
const sessionId = (parsed as any).sessionId;
|
||||
const chatId = (parsed as any).chatId;
|
||||
if (typeof sessionId === "string" && sessionId.length > 0) {
|
||||
agentSession = sessionId;
|
||||
} else if (typeof chatId === "string" && chatId.length > 0) {
|
||||
agentSession = chatId;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to empty initial messages + null session — the
|
||||
// AgentView will show a loading spinner and surface any stream
|
||||
// subscription errors to the console.
|
||||
}
|
||||
}
|
||||
|
||||
let region: { name: string; location: string | null } | null = null;
|
||||
|
||||
if (run.runtimeEnvironment.type !== "DEVELOPMENT" && run.engine !== "V1") {
|
||||
@@ -316,6 +261,48 @@ export class SpanPresenter extends BasePresenter {
|
||||
region = workerGroup ?? null;
|
||||
}
|
||||
|
||||
// Only AGENT-tagged runs (chat.agent and friends) can be session-bound,
|
||||
// so skip the SessionRun lookup for the much larger set of standard runs.
|
||||
// Lookup is by the unique `runId` index, but the cheapest query is the
|
||||
// one we don't run.
|
||||
const sessionRun = isAgentRun
|
||||
? await this._replica.sessionRun.findFirst({
|
||||
where: { runId: run.id },
|
||||
select: {
|
||||
reason: true,
|
||||
triggeredAt: true,
|
||||
session: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
externalId: true,
|
||||
type: true,
|
||||
taskIdentifier: true,
|
||||
closedAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const session = sessionRun
|
||||
? {
|
||||
friendlyId: sessionRun.session.friendlyId,
|
||||
externalId: sessionRun.session.externalId,
|
||||
type: sessionRun.session.type,
|
||||
taskIdentifier: sessionRun.session.taskIdentifier,
|
||||
status:
|
||||
sessionRun.session.closedAt != null
|
||||
? ("CLOSED" as const)
|
||||
: sessionRun.session.expiresAt != null &&
|
||||
sessionRun.session.expiresAt.getTime() < Date.now()
|
||||
? ("EXPIRED" as const)
|
||||
: ("ACTIVE" as const),
|
||||
reason: sessionRun.reason,
|
||||
triggeredAt: sessionRun.triggeredAt,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: run.id,
|
||||
friendlyId: run.friendlyId,
|
||||
@@ -358,8 +345,6 @@ export class SpanPresenter extends BasePresenter {
|
||||
isRunning: RUNNING_STATUSES.includes(run.status),
|
||||
isError: isFailedRunStatus(run.status),
|
||||
isAgentRun,
|
||||
agentInitialMessages,
|
||||
agentSession,
|
||||
payload,
|
||||
payloadType: run.payloadType,
|
||||
output,
|
||||
@@ -378,6 +363,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
metadata,
|
||||
maxDurationInSeconds: getMaxDuration(run.maxDurationInSeconds),
|
||||
batch: run.batch ? { friendlyId: run.batch.friendlyId } : undefined,
|
||||
session,
|
||||
engine: run.engine,
|
||||
region,
|
||||
workerQueue: run.workerQueue,
|
||||
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
import { ArrowsRightLeftIcon, BookOpenIcon, XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { PageBody } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { AgentView } from "~/components/runs/v3/agent/AgentView";
|
||||
import { RealtimeStreamViewer } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
|
||||
import { RunTag } from "~/components/runs/v3/RunTag";
|
||||
import {
|
||||
descriptionForTaskRunStatus,
|
||||
TaskRunStatusCombo,
|
||||
} from "~/components/runs/v3/TaskRunStatus";
|
||||
import { CloseSessionDialog } from "~/components/sessions/v1/CloseSessionDialog";
|
||||
import { SessionStatusCombo } from "~/components/sessions/v1/SessionStatus";
|
||||
import { $replica } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { SessionPresenter } from "~/presenters/v3/SessionPresenter.server";
|
||||
import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3RunPath,
|
||||
v3RunsPath,
|
||||
v3SessionsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = EnvironmentParamSchema.extend({
|
||||
sessionParam: z.string(),
|
||||
});
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: `Session | Trigger.dev` }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam, sessionParam } = ParamsSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage("/", request, "Project not found");
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
const presenter = new SessionPresenter($replica);
|
||||
const session = await presenter.call({
|
||||
userId,
|
||||
environmentId: environment.id,
|
||||
sessionParam,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new Response("Session not found", { status: 404 });
|
||||
}
|
||||
|
||||
return typedjson({ session });
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { session } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const status: SessionStatus =
|
||||
session.closedAt != null
|
||||
? "CLOSED"
|
||||
: session.expiresAt != null && new Date(session.expiresAt).getTime() < Date.now()
|
||||
? "EXPIRED"
|
||||
: "ACTIVE";
|
||||
|
||||
const displayId = session.externalId ?? session.friendlyId;
|
||||
const sessionsPath = v3SessionsPath(organization, project, environment);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle
|
||||
backButton={{ to: sessionsPath, text: "Sessions" }}
|
||||
title={
|
||||
<CopyableText
|
||||
value={displayId}
|
||||
variant="text-below"
|
||||
className="-ml-[0.4375rem] h-6 px-1.5 font-mono text-xs hover:text-text-bright"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/ai-chat/overview")}
|
||||
>
|
||||
Sessions docs
|
||||
</LinkButton>
|
||||
{status === "ACTIVE" && (
|
||||
<Dialog key={`close-${session.friendlyId}`}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="danger/small" LeadingIcon={XCircleIcon}>
|
||||
Close session…
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CloseSessionDialog
|
||||
sessionParam={session.friendlyId}
|
||||
environmentId={environment.id}
|
||||
redirectPath={`${sessionsPath}/${session.friendlyId}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="session-conversation" min={"300px"}>
|
||||
<ConversationPane session={session} />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="session-handle" />
|
||||
<ResizablePanel
|
||||
id="session-inspector"
|
||||
min="380px"
|
||||
default="420px"
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<InspectorPane session={session} status={status} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type LoadedSession = ReturnType<typeof useTypedLoaderData<typeof loader>>["session"];
|
||||
|
||||
function ConversationPane({ session }: { session: LoadedSession }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { value, replace } = useSearchParams();
|
||||
const isRaw = value("raw") === "1";
|
||||
const stream: "out" | "in" = value("stream") === "in" ? "in" : "out";
|
||||
|
||||
const sessionId = session.agentView.sessionId;
|
||||
const encodedSession = encodeURIComponent(sessionId);
|
||||
const sessionResourceBase = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/sessions/${encodedSession}/realtime/v1`;
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-bright px-3">
|
||||
<div className="flex items-center gap-2 overflow-x-hidden">
|
||||
<ArrowsRightLeftIcon className="size-4 text-teal-500" />
|
||||
<Header2 className={cn("overflow-x-hidden text-text-bright")}>
|
||||
<span className="truncate">Conversation</span>
|
||||
</Header2>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
name="conversation-view"
|
||||
value={isRaw ? "raw" : "rendered"}
|
||||
variant="secondary/small"
|
||||
options={[
|
||||
{ label: "Rendered", value: "rendered" },
|
||||
{ label: "Raw", value: "raw" },
|
||||
]}
|
||||
onChange={(v) => replace({ raw: v === "raw" ? "1" : undefined })}
|
||||
/>
|
||||
</div>
|
||||
{isRaw ? (
|
||||
<div className="overflow-hidden">
|
||||
<RealtimeStreamViewer
|
||||
key={stream}
|
||||
resourcePath={`${sessionResourceBase}/${stream}`}
|
||||
displayName={`.${stream}`}
|
||||
headerLeft={
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={stream === "out"}
|
||||
layoutId="conversation-stream"
|
||||
onClick={() => replace({ stream: undefined })}
|
||||
>
|
||||
Output
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={stream === "in"}
|
||||
layoutId="conversation-stream"
|
||||
onClick={() => replace({ stream: "in" })}
|
||||
>
|
||||
Input
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0 overflow-x-hidden overflow-y-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<AgentView agentView={session.agentView} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InspectorPane({
|
||||
session,
|
||||
status,
|
||||
}: {
|
||||
session: LoadedSession;
|
||||
status: SessionStatus;
|
||||
}) {
|
||||
const { value, replace } = useSearchParams();
|
||||
const tab = value("tab") ?? "overview";
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const displayId = session.externalId ?? session.friendlyId;
|
||||
const allRunsPath = v3RunsPath(organization, project, environment, {
|
||||
tags: [`chat:${displayId}`],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
|
||||
<div className="flex items-center gap-2 overflow-x-hidden">
|
||||
<SessionStatusCombo status={status} />
|
||||
<span className="truncate font-mono text-xs text-text-dimmed">
|
||||
{session.friendlyId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-fit overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={tab === "overview"}
|
||||
layoutId="session-inspector"
|
||||
onClick={() => replace({ tab: "overview" })}
|
||||
shortcut={{ key: "o" }}
|
||||
>
|
||||
Overview
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={tab === "runs"}
|
||||
layoutId="session-inspector"
|
||||
onClick={() => replace({ tab: "runs" })}
|
||||
shortcut={{ key: "r" }}
|
||||
>
|
||||
Runs
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={tab === "metadata"}
|
||||
layoutId="session-inspector"
|
||||
onClick={() => replace({ tab: "metadata" })}
|
||||
shortcut={{ key: "m" }}
|
||||
>
|
||||
Metadata
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
{tab === "overview" ? (
|
||||
<OverviewTab session={session} status={status} />
|
||||
) : tab === "runs" ? (
|
||||
<RunsTab session={session} allRunsPath={allRunsPath} />
|
||||
) : (
|
||||
<MetadataTab session={session} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
session,
|
||||
status,
|
||||
}: {
|
||||
session: LoadedSession;
|
||||
status: SessionStatus;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const isAdmin = useHasAdminAccess();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<SessionStatusCombo status={status} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Friendly ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={session.friendlyId} className="font-mono text-xs" />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{session.externalId ? (
|
||||
<Property.Item>
|
||||
<Property.Label>External ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={session.externalId} className="font-mono text-xs" />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
<Property.Item>
|
||||
<Property.Label>Type</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="font-mono text-xs">{session.type}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Task</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="font-mono text-xs">{session.taskIdentifier}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{session.currentRun ? (
|
||||
<Property.Item>
|
||||
<Property.Label>Current run</Property.Label>
|
||||
<Property.Value>
|
||||
<TextLink
|
||||
to={v3RunPath(organization, project, environment, {
|
||||
friendlyId: session.currentRun.friendlyId,
|
||||
})}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs">{session.currentRun.friendlyId}</span>
|
||||
<SimpleTooltip
|
||||
button={<TaskRunStatusCombo status={session.currentRun.status} />}
|
||||
content={descriptionForTaskRunStatus(session.currentRun.status)}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</span>
|
||||
</TextLink>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
<Property.Item>
|
||||
<Property.Label>Tags</Property.Label>
|
||||
<Property.Value>
|
||||
{session.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{session.tags.map((tag) => (
|
||||
<RunTag key={tag} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Created</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={session.createdAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Updated</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={session.updatedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{session.expiresAt ? (
|
||||
<Property.Item>
|
||||
<Property.Label>
|
||||
{new Date(session.expiresAt).getTime() < Date.now() ? "Expired" : "Expires"}
|
||||
</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={session.expiresAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
{session.closedAt ? (
|
||||
<Property.Item>
|
||||
<Property.Label>Closed</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={session.closedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
{session.closedReason ? (
|
||||
<Property.Item>
|
||||
<Property.Label>Close reason</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="text-xs">{session.closedReason}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
</Property.Table>
|
||||
<CodeBlock
|
||||
code={JSON.stringify(session.triggerConfig, null, 2)}
|
||||
language="json"
|
||||
rowTitle="Trigger config"
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showTextWrapping
|
||||
/>
|
||||
{isAdmin && (
|
||||
<div className="border-t border-yellow-500/50 pt-2">
|
||||
<Paragraph spacing variant="small" className="text-yellow-500">
|
||||
Admin only
|
||||
</Paragraph>
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Session ID</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="font-mono text-xs">{session.id}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Stream basin</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="font-mono text-xs">
|
||||
{session.streamBasinName ?? "(global)"}
|
||||
</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataTab({ session }: { session: LoadedSession }) {
|
||||
if (session.metadata == null) {
|
||||
return (
|
||||
<Paragraph variant="small/dimmed">No metadata.</Paragraph>
|
||||
);
|
||||
}
|
||||
const json = JSON.stringify(session.metadata, null, 2);
|
||||
return (
|
||||
<CodeBlock code={json} language="json" showLineNumbers={false} showTextWrapping />
|
||||
);
|
||||
}
|
||||
|
||||
function RunsTab({
|
||||
session,
|
||||
allRunsPath,
|
||||
}: {
|
||||
session: LoadedSession;
|
||||
allRunsPath: string;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
if (session.runs.length === 0) {
|
||||
return <Paragraph variant="small/dimmed">No runs yet.</Paragraph>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Property.Table>
|
||||
{session.runs.map((entry) => {
|
||||
const runPath = entry.run
|
||||
? v3RunPath(organization, project, environment, {
|
||||
friendlyId: entry.run.friendlyId,
|
||||
})
|
||||
: undefined;
|
||||
return (
|
||||
<Property.Item key={entry.id}>
|
||||
<Property.Label>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="capitalize">{entry.reason}</span>
|
||||
<span className="text-xs text-text-dimmed">
|
||||
<DateTime date={entry.triggeredAt} />
|
||||
</span>
|
||||
</div>
|
||||
</Property.Label>
|
||||
<Property.Value>
|
||||
{entry.run && runPath ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<TextLink
|
||||
to={runPath}
|
||||
className="group flex flex-wrap items-center gap-x-2 gap-y-0"
|
||||
>
|
||||
<CopyableText
|
||||
value={entry.run.friendlyId}
|
||||
copyValue={entry.run.friendlyId}
|
||||
asChild
|
||||
/>
|
||||
<TaskRunStatusCombo status={entry.run.status} />
|
||||
</TextLink>
|
||||
}
|
||||
content={`Jump to run`}
|
||||
disableHoverableContent
|
||||
/>
|
||||
) : (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
);
|
||||
})}
|
||||
</Property.Table>
|
||||
<div className="flex justify-end">
|
||||
<LinkButton variant="tertiary/small" to={allRunsPath}>
|
||||
View all runs
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ListPagination } from "~/components/ListPagination";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { SessionFilters } from "~/components/sessions/v1/SessionFilters";
|
||||
import { SessionsTable } from "~/components/sessions/v1/SessionsTable";
|
||||
import { SessionsNone } from "~/components/BlankStatePanels";
|
||||
import { $replica } from "~/db.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getSessionFiltersFromRequest } from "~/presenters/SessionFilters.server";
|
||||
import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Sessions | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage("/", request, "Project not found");
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
const filters = getSessionFiltersFromRequest(request);
|
||||
|
||||
const presenter = new SessionListPresenter($replica, clickhouseClient);
|
||||
const list = await presenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
statuses: filters.statuses,
|
||||
types: filters.types,
|
||||
taskIdentifiers: filters.taskIdentifiers,
|
||||
externalId: filters.externalId,
|
||||
tags: filters.tags,
|
||||
period: filters.period,
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
cursor: filters.cursor,
|
||||
direction: filters.direction,
|
||||
});
|
||||
|
||||
return typedjson(list);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const list = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle title="Sessions" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip />
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/ai-chat/overview")}
|
||||
>
|
||||
Sessions docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
{!list.hasAnySessions ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<SessionsNone />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<SessionFilters hasFilters={list.hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination: list.pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
<SessionsTable
|
||||
sessions={list.sessions}
|
||||
filters={list.filters}
|
||||
hasFilters={list.hasFilters}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { PageContainer } from "~/components/layout/AppLayout";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Outlet />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+32
-55
@@ -53,6 +53,7 @@ import {
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { SessionStatusCombo } from "~/components/sessions/v1/SessionStatus";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { RunTimeline, RunTimelineEvent, SpanTimeline } from "~/components/run/RunTimeline";
|
||||
@@ -79,20 +80,16 @@ import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { useCanViewLogsPage } from "~/hooks/useCanViewLogsPage";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { type Span, SpanPresenter, type SpanRun } from "~/presenters/v3/SpanPresenter.server";
|
||||
import { AgentView, type AgentViewAuth } from "~/components/runs/v3/agent/AgentView";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { mintRunToken } from "~/services/realtime/mintRunToken.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
import {
|
||||
docsPath,
|
||||
v3BatchPath,
|
||||
v3SessionPath,
|
||||
v3DeploymentVersionPath,
|
||||
v3LogsPath,
|
||||
v3RunDownloadLogsPath,
|
||||
@@ -142,39 +139,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
);
|
||||
}
|
||||
|
||||
// For agent runs, mint a read-only run-scoped token so the Agent tab
|
||||
// can subscribe to the run's backing Session from the browser. We
|
||||
// also forward the initial user messages + the session identifier
|
||||
// extracted from the task payload — the AgentView uses the session
|
||||
// to subscribe to `.in` / `.out` (replaces the old run-scoped
|
||||
// chat-messages + chat streams). Runs without an identifiable
|
||||
// session (misformed payload, legacy pre-chat-agent runs) get
|
||||
// `agentSession: null`; the AgentView renders a loading spinner
|
||||
// without subscribing.
|
||||
let agentView: AgentViewAuth | null = null;
|
||||
if (result.type === "run" && result.run.isAgentRun && result.run.agentSession) {
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
const environment = project
|
||||
? await findEnvironmentBySlug(project.id, envParam, userId)
|
||||
: null;
|
||||
if (environment) {
|
||||
const publicAccessToken = await mintRunToken(environment, result.run.friendlyId);
|
||||
agentView = {
|
||||
publicAccessToken,
|
||||
apiOrigin: env.API_ORIGIN || env.LOGIN_ORIGIN,
|
||||
sessionId: result.run.agentSession,
|
||||
initialMessages: (result.run.agentInitialMessages ?? []) as AgentViewAuth["initialMessages"],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct the discriminated union explicitly. Spreading
|
||||
// `{ ...result, agentView }` collapses the union and loses the
|
||||
// `{ ...result }` collapses the union and loses the
|
||||
// `type === "run" | "span"` discriminant downstream in `SpanView`.
|
||||
if (result.type === "run") {
|
||||
return typedjson({ type: "run" as const, run: result.run, agentView });
|
||||
return typedjson({ type: "run" as const, run: result.run });
|
||||
}
|
||||
return typedjson({ type: "span" as const, span: result.span, agentView });
|
||||
return typedjson({ type: "span" as const, span: result.span });
|
||||
} catch (error) {
|
||||
logger.error("Error loading span", {
|
||||
projectParam,
|
||||
@@ -264,7 +235,6 @@ export function SpanView({
|
||||
return (
|
||||
<RunBody
|
||||
run={fetcher.data.run}
|
||||
agentView={fetcher.data.agentView}
|
||||
runParam={runParam}
|
||||
spanId={spanId}
|
||||
closePanel={closePanel}
|
||||
@@ -399,13 +369,11 @@ function applySpanOverrides(span: Span, spanOverrides?: SpanOverride): Span {
|
||||
|
||||
function RunBody({
|
||||
run,
|
||||
agentView,
|
||||
runParam,
|
||||
spanId,
|
||||
closePanel,
|
||||
}: {
|
||||
run: SpanRun;
|
||||
agentView: AgentViewAuth | null;
|
||||
runParam: string;
|
||||
spanId: string;
|
||||
closePanel?: () => void;
|
||||
@@ -458,18 +426,6 @@ function RunBody({
|
||||
>
|
||||
Overview
|
||||
</TabButton>
|
||||
{run.isAgentRun && (
|
||||
<TabButton
|
||||
isActive={tab === "agent"}
|
||||
layoutId="span-run"
|
||||
onClick={() => {
|
||||
replace({ tab: "agent" });
|
||||
}}
|
||||
shortcut={{ key: "a" }}
|
||||
>
|
||||
Agent
|
||||
</TabButton>
|
||||
)}
|
||||
<TabButton
|
||||
isActive={tab === "detail"}
|
||||
layoutId="span-run"
|
||||
@@ -505,12 +461,7 @@ function RunBody({
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div>
|
||||
{tab === "agent" && run.isAgentRun && agentView ? (
|
||||
<AgentView
|
||||
run={{ friendlyId: run.friendlyId, taskIdentifier: run.taskIdentifier }}
|
||||
agentView={agentView}
|
||||
/>
|
||||
) : tab === "detail" ? (
|
||||
{tab === "detail" ? (
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
@@ -688,6 +639,32 @@ function RunBody({
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{run.session && (
|
||||
<Property.Item>
|
||||
<Property.Label>Session</Property.Label>
|
||||
<Property.Value>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<TextLink
|
||||
to={v3SessionPath(organization, project, environment, {
|
||||
friendlyId: run.session.friendlyId,
|
||||
})}
|
||||
className="group flex flex-wrap items-center gap-x-2 gap-y-0"
|
||||
>
|
||||
<CopyableText
|
||||
value={run.session.externalId ?? run.session.friendlyId}
|
||||
copyValue={run.session.externalId ?? run.session.friendlyId}
|
||||
asChild
|
||||
/>
|
||||
<SessionStatusCombo status={run.session.status} />
|
||||
</TextLink>
|
||||
}
|
||||
content={`Jump to session (${run.session.reason})`}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
+31
-12
@@ -101,17 +101,32 @@ export function RealtimeStreamViewer({
|
||||
streamKey,
|
||||
metadata,
|
||||
displayName,
|
||||
resourcePath: resourcePathOverride,
|
||||
headerLabel,
|
||||
headerLeft,
|
||||
}: {
|
||||
runId: string;
|
||||
streamKey: string;
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
runId?: string;
|
||||
streamKey?: string;
|
||||
metadata?: Record<string, unknown> | undefined;
|
||||
displayName?: string;
|
||||
/** Pre-built resource path. When provided, `runId`/`streamKey` are unused. */
|
||||
resourcePath?: string;
|
||||
/** Override the "Stream:" / "Input stream:" prefix in the header. */
|
||||
headerLabel?: string;
|
||||
/**
|
||||
* Replaces the default "Stream: <name>" content next to the connection
|
||||
* icon. Use to inline tabs or other navigation in place of a static
|
||||
* label.
|
||||
*/
|
||||
headerLeft?: React.ReactNode;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runId}/streams/${streamKey}`;
|
||||
const resourcePath =
|
||||
resourcePathOverride ??
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runId}/streams/${streamKey}`;
|
||||
|
||||
const startIndex = typeof metadata?.startIndex === "number" ? metadata.startIndex : undefined;
|
||||
const { chunks, error, isConnected } = useRealtimeStream(resourcePath, startIndex);
|
||||
@@ -229,7 +244,7 @@ export function RealtimeStreamViewer({
|
||||
{/* Header */}
|
||||
<div className="border-b border-grid-bright bg-background-bright @container">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5 @[300px]:flex-nowrap">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
@@ -244,13 +259,17 @@ export function RealtimeStreamViewer({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Paragraph
|
||||
variant="small/bright"
|
||||
className="mb-0 flex min-w-0 items-center gap-1 truncate whitespace-nowrap"
|
||||
>
|
||||
<span>{displayName ? "Input stream:" : "Stream:"}</span>
|
||||
<span className="truncate font-mono text-text-dimmed">{displayName ?? streamKey}</span>
|
||||
</Paragraph>
|
||||
{headerLeft ?? (
|
||||
<Paragraph
|
||||
variant="small/bright"
|
||||
className="mb-0 flex min-w-0 items-center gap-1 truncate whitespace-nowrap"
|
||||
>
|
||||
<span>{headerLabel ?? (displayName ? "Input stream:" : "Stream:")}</span>
|
||||
<span className="truncate font-mono text-text-dimmed">
|
||||
{displayName ?? streamKey ?? ""}
|
||||
</span>
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3 @[300px]:w-auto @[300px]:flex-nowrap">
|
||||
<Paragraph variant="small" className="mb-0 whitespace-nowrap">
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
||||
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
|
||||
import {
|
||||
canonicalSessionAddressingKey,
|
||||
resolveSessionByIdOrExternalId,
|
||||
} from "~/services/realtime/sessions.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
sessionParam: z.string(),
|
||||
io: z.enum(["out", "in"]),
|
||||
});
|
||||
|
||||
// GET: SSE stream subscription for a Session's `.out` / `.in` channel.
|
||||
// Dashboard-auth counterpart to the public API's
|
||||
// `/realtime/v1/sessions/:sessionId/:io`. Used by the Sessions detail
|
||||
// view (and the run page's Agent tab) to observe assistant chunks
|
||||
// (`.out`) and user-side ChatInputChunk payloads (`.in`).
|
||||
//
|
||||
// The `:sessionParam` segment accepts either the `session_*` friendlyId
|
||||
// or the externalId the transport registered for the chat (typically the
|
||||
// browser's `chatId`).
|
||||
//
|
||||
// Authenticated by the dashboard session — the user must have access to
|
||||
// the project and environment. The session must live in that environment.
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
const { sessionParam, io } = ParamsSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const session = await resolveSessionByIdOrExternalId($replica, environment.id, sessionParam);
|
||||
if (!session) {
|
||||
return new Response("Session not found", { status: 404 });
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session });
|
||||
|
||||
if (!(realtimeStream instanceof S2RealtimeStreams)) {
|
||||
return new Response("Session channels require the S2 realtime backend", {
|
||||
status: 501,
|
||||
});
|
||||
}
|
||||
|
||||
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
|
||||
const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds") ?? undefined;
|
||||
const timeoutInSeconds = timeoutInSecondsRaw ? parseInt(timeoutInSecondsRaw) : undefined;
|
||||
|
||||
if (
|
||||
timeoutInSeconds &&
|
||||
(isNaN(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600)
|
||||
) {
|
||||
return new Response("Invalid timeout", { status: 400 });
|
||||
}
|
||||
|
||||
// The agent writes via the canonical addressing key (externalId if
|
||||
// set, else friendlyId). Subscribe with the same key so the read
|
||||
// hits the same S2 stream the agent is writing into.
|
||||
const addressingKey = canonicalSessionAddressingKey(session, sessionParam);
|
||||
|
||||
return realtimeStream.streamResponseFromSessionStream(
|
||||
request,
|
||||
addressingKey,
|
||||
io,
|
||||
getRequestAbortSignal(),
|
||||
{ lastEventId, timeoutInSeconds }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { type ActionFunction, json } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
export const closeSessionSchema = z.object({
|
||||
redirectUrl: z.string(),
|
||||
environmentId: z.string(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
const ParamSchema = z.object({
|
||||
sessionParam: z.string(),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { sessionParam } = ParamSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: closeSessionSchema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const { redirectUrl, environmentId, reason } = submission.value;
|
||||
const trimmedReason = reason?.trim();
|
||||
const closedReason =
|
||||
trimmedReason && trimmedReason.length > 0 ? trimmedReason : "closed-from-dashboard";
|
||||
|
||||
try {
|
||||
// Confirm the user belongs to the org that owns this environment, then
|
||||
// resolve the session by friendlyId or externalId scoped to that env.
|
||||
const environment = await $replica.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
id: environmentId,
|
||||
organization: { members: { some: { userId } } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
submission.error = { environmentId: ["Environment not found"] };
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const session = await resolveSessionByIdOrExternalId(
|
||||
$replica,
|
||||
environment.id,
|
||||
sessionParam
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
submission.error = { sessionParam: ["Session not found"] };
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (session.closedAt) {
|
||||
// Already closed — no-op, but redirect with a friendly message so the
|
||||
// UI doesn't look like it did nothing.
|
||||
return redirectWithSuccessMessage(redirectUrl, request, `Session already closed`);
|
||||
}
|
||||
|
||||
// Conditional update mirrors the public API: two concurrent closes race
|
||||
// through the read but only one wins this update.
|
||||
await prisma.session.updateMany({
|
||||
where: { id: session.id, closedAt: null },
|
||||
data: {
|
||||
closedAt: new Date(),
|
||||
closedReason,
|
||||
},
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(redirectUrl, request, `Closed session`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Failed to close session", {
|
||||
error: { name: error.name, message: error.message, stack: error.stack },
|
||||
});
|
||||
return redirectWithErrorMessage(
|
||||
redirectUrl,
|
||||
request,
|
||||
`Failed to close session, ${error.message}`
|
||||
);
|
||||
}
|
||||
logger.error("Failed to close session", { error });
|
||||
return redirectWithErrorMessage(
|
||||
redirectUrl,
|
||||
request,
|
||||
`Failed to close session, ${JSON.stringify(error)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -101,6 +101,7 @@ export class ClickHouseSessionsRepository implements ISessionsRepository {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
runtimeEnvironmentId: true,
|
||||
currentRunId: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ export type ListedSession = Prisma.SessionGetPayload<{
|
||||
createdAt: true;
|
||||
updatedAt: true;
|
||||
runtimeEnvironmentId: true;
|
||||
currentRunId: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
|
||||
@@ -507,6 +507,23 @@ export function v3BatchesPath(
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/batches`;
|
||||
}
|
||||
|
||||
export function v3SessionsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/sessions`;
|
||||
}
|
||||
|
||||
export function v3SessionPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
session: { friendlyId: string }
|
||||
) {
|
||||
return `${v3SessionsPath(organization, project, environment)}/${session.friendlyId}`;
|
||||
}
|
||||
|
||||
export function v3BatchPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
|
||||
@@ -117,6 +117,7 @@ export function ChatSidebar({
|
||||
<option value="ai-chat-raw">ai-chat-raw (raw task)</option>
|
||||
<option value="ai-chat-session">ai-chat-session (session)</option>
|
||||
<option value="upgrade-test">upgrade-test (requestUpgrade after 3 turns)</option>
|
||||
<option value="stress-emit">stress-emit (UI stress test)</option>
|
||||
</select>
|
||||
</div>
|
||||
<label
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Stress-test chat.agent. Emits a configurable number of `text-delta`
|
||||
// chunks of a configurable size — no LLM call, no tokens spent. Lets us
|
||||
// stress the dashboard's session detail view (rendered conversation +
|
||||
// raw stream tabs) with deterministic load.
|
||||
//
|
||||
// Config is parsed from the last user message's text. Two formats:
|
||||
// "1000 10" → chunkCount=1000, chunkSize=10
|
||||
// "1000 10 messages" → chunkCount messages of one delta each
|
||||
//
|
||||
// Defaults: 1000 chunks × 10 chars, single message.
|
||||
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { type UIMessage, simulateReadableStream, streamText } from "ai";
|
||||
import { MockLanguageModelV3 } from "ai/test";
|
||||
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
|
||||
|
||||
type StressConfig = {
|
||||
chunkCount: number;
|
||||
chunkSize: number;
|
||||
manyMessages: boolean;
|
||||
};
|
||||
|
||||
function parseConfig(messages: UIMessage[]): StressConfig {
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
||||
const text =
|
||||
lastUser?.parts?.[0]?.type === "text" ? lastUser.parts[0].text.trim() : "";
|
||||
const parts = text.split(/\s+/);
|
||||
const chunkCount = Number(parts[0]);
|
||||
const chunkSize = Number(parts[1]);
|
||||
const manyMessages = parts[2] === "messages";
|
||||
return {
|
||||
chunkCount: Number.isFinite(chunkCount) && chunkCount > 0 ? chunkCount : 1000,
|
||||
chunkSize: Number.isFinite(chunkSize) && chunkSize > 0 ? chunkSize : 10,
|
||||
manyMessages,
|
||||
};
|
||||
}
|
||||
|
||||
function buildModelStream(config: StressConfig): LanguageModelV3StreamPart[] {
|
||||
const delta = "x".repeat(config.chunkSize);
|
||||
// Each `text-start`/`text-end` pair maps to a separate assistant message
|
||||
// in the AI SDK pipeline when `manyMessages` is set; without it, all
|
||||
// deltas accumulate into a single message.
|
||||
if (config.manyMessages) {
|
||||
const stream: LanguageModelV3StreamPart[] = [];
|
||||
for (let i = 0; i < config.chunkCount; i++) {
|
||||
const id = `t${i}`;
|
||||
stream.push({ type: "text-start", id });
|
||||
stream.push({ type: "text-delta", id, delta });
|
||||
stream.push({ type: "text-end", id });
|
||||
}
|
||||
stream.push({
|
||||
type: "finish",
|
||||
finishReason: { unified: "stop", raw: "stop" },
|
||||
usage: {
|
||||
inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: {
|
||||
total: config.chunkCount,
|
||||
text: config.chunkCount,
|
||||
reasoning: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
const stream: LanguageModelV3StreamPart[] = [{ type: "text-start", id: "t1" }];
|
||||
for (let i = 0; i < config.chunkCount; i++) {
|
||||
stream.push({ type: "text-delta", id: "t1", delta });
|
||||
}
|
||||
stream.push({ type: "text-end", id: "t1" });
|
||||
stream.push({
|
||||
type: "finish",
|
||||
finishReason: { unified: "stop", raw: "stop" },
|
||||
usage: {
|
||||
inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined },
|
||||
outputTokens: {
|
||||
total: config.chunkCount,
|
||||
text: config.chunkCount,
|
||||
reasoning: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
export const stressEmit = chat.agent({
|
||||
id: "stress-emit",
|
||||
run: async ({ messages, signal }) => {
|
||||
const config = parseConfig(messages);
|
||||
const chunks = buildModelStream(config);
|
||||
return streamText({
|
||||
model: new MockLanguageModelV3({
|
||||
doStream: async () => ({ stream: simulateReadableStream({ chunks }) }),
|
||||
}),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user