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:
Eric Allam
2026-05-07 09:46:49 +01:00
parent 16c3c9bc1a
commit 2bb56849fd
25 changed files with 2655 additions and 152 deletions
@@ -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,
@@ -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>
);
}
@@ -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>
</>
);
}
@@ -0,0 +1,10 @@
import { Outlet } from "@remix-run/react";
import { PageContainer } from "~/components/layout/AppLayout";
export default function Page() {
return (
<PageContainer>
<Outlet />
</PageContainer>
);
}
@@ -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">
@@ -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">
@@ -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;
};
}>;
+17
View File
@@ -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,
});
},
});