diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index fe39f6785..9a4884e09 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -1,4 +1,5 @@ import { + ArrowsRightLeftIcon, BeakerIcon, BellAlertIcon, BookOpenIcon, @@ -189,6 +190,28 @@ export function BatchesNone() { ); } +export function SessionsNone() { + return ( + + Sessions docs + + } + > + + You have no sessions in this environment. Sessions are durable, typed, bidirectional I/O + primitives that outlive a single run — used by chat.agent and any + long-running task that needs streaming input and output. + + + ); +} + export function TestHasNoTasks() { const organization = useOrganization(); const project = useProject(); diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 175bc1527..b8cf5c8e7 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -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} /> + +
{messages.map((msg) => ( ))} @@ -55,9 +55,9 @@ export const MessageBubble = memo(function MessageBubble({ .join("") ?? ""; return ( -
+
-
{text}
+
{text}
); diff --git a/apps/webapp/app/components/runs/v3/agent/AgentView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentView.tsx index c54904d5b..eee7646d0 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentView.tsx @@ -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; diff --git a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx index 3cfbf7521..72539cd79 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx @@ -211,7 +211,7 @@ export function AssistantResponse({ /> {mode === "rendered" ? ( -
+
{text}}> {text} diff --git a/apps/webapp/app/components/sessions/v1/CloseSessionDialog.tsx b/apps/webapp/app/components/sessions/v1/CloseSessionDialog.tsx new file mode 100644 index 000000000..7feba8e6d --- /dev/null +++ b/apps/webapp/app/components/sessions/v1/CloseSessionDialog.tsx @@ -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 ( + + Close this session? +
+ + 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. + +
+ + +
+ + +
+ + {isLoading ? "Closing..." : "Close session"} + + } + cancelButton={ + + + + } + /> + +
+
+ ); +} diff --git a/apps/webapp/app/components/sessions/v1/SessionFilters.tsx b/apps/webapp/app/components/sessions/v1/SessionFilters.tsx new file mode 100644 index 000000000..9c13b7b4b --- /dev/null +++ b/apps/webapp/app/components/sessions/v1/SessionFilters.tsx @@ -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; +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 ( +
+ + + + {hasFilters && ( +
+
+ ); +} + +const filterTypes = [ + { + name: "statuses", + title: "Status", + icon: , + }, + { name: "types", title: "Type", icon: }, + { + name: "taskIdentifiers", + title: "Task", + icon: , + }, + { + name: "externalId", + title: "External ID", + icon: , + }, + { name: "tags", title: "Tags", icon: }, +] as const; + +type FilterType = (typeof filterTypes)[number]["name"]; + +const shortcut = { key: "f" }; + +function FilterMenu(props: SessionFiltersProps) { + const [filterType, setFilterType] = useState(); + + const filterTrigger = ( + + +
+ } + variant={"secondary/small"} + shortcut={shortcut} + tooltipTitle={"Filter sessions"} + > + Filter + + ); + + return ( + setFilterType(undefined)}> + {(search, setSearch) => ( + setSearch("")} + trigger={filterTrigger} + filterType={filterType} + setFilterType={setFilterType} + {...props} + /> + )} + + ); +} + +function AppliedFilters() { + return ( + <> + + + + + + + ); +} + +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 ; + case "statuses": + return props.setFilterType(undefined)} {...props} />; + case "types": + return props.setFilterType(undefined)} {...props} />; + case "taskIdentifiers": + return ( + props.setFilterType(undefined)} {...props} /> + ); + case "externalId": + return props.setFilterType(undefined)} {...props} />; + case "tags": + return 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 ( + + {trigger} + + + + {filtered.map((type, index) => ( + { + clearSearchValue(); + setFilterType(type.name); + }} + icon={type.icon} + shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })} + > + {type.title} + + ))} + + + + ); +} + +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 ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + > + + + {filtered.map((item, index) => ( + + + + + + + + + {descriptionForSessionStatus(item.value)} + + + + + + ))} + + + + ); +} + +function AppliedStatusFilter() { + const { values, del } = useSearchParams(); + const statuses = values("statuses"); + + if (statuses.length === 0) return null; + + return ( + + {(search, setSearch) => ( + }> + } + value={appliedSummary( + statuses.map((v) => sessionStatusTitle(v as (typeof allSessionStatuses)[number])) + )} + onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +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 ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + > + + + {items.map((value, index) => ( + + {value} + + ))} + + + + ); +} + +function AppliedTypeFilter() { + const { values, del } = useSearchParams(); + const types = values("types"); + if (types.length === 0) return null; + + return ( + + {(search, setSearch) => ( + }> + } + value={appliedSummary(types)} + onRemove={() => del(["types", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function TaskIdentifierDropdown({ + trigger, + searchValue, + clearSearchValue, + onClose, +}: { + trigger: ReactNode; + searchValue: string; + clearSearchValue: () => void; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + 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 ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + className="max-w-[min(32ch,var(--popover-available-width))]" + > +
+
+ + setDraft(e.target.value)} + variant="small" + className="w-[29ch] font-mono" + spellCheck={false} + /> +
+
+ + +
+
+
+
+ ); +} + +function AppliedTaskIdentifierFilter() { + const { values, del } = useSearchParams(); + const taskIdentifiers = values("taskIdentifiers"); + if (taskIdentifiers.length === 0) return null; + + return ( + + {(search, setSearch) => ( + }> + } + value={appliedSummary(taskIdentifiers)} + onRemove={() => del(["taskIdentifiers", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function ExternalIdDropdown({ + trigger, + searchValue, + clearSearchValue, + onClose, +}: { + trigger: ReactNode; + searchValue: string; + clearSearchValue: () => void; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + 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 ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + className="max-w-[min(36ch,var(--popover-available-width))]" + > +
+
+ + setDraft(e.target.value)} + variant="small" + className="w-[33ch] font-mono" + spellCheck={false} + /> +
+
+ + +
+
+
+
+ ); +} + +function AppliedExternalIdFilter() { + const { value, del } = useSearchParams(); + const externalId = value("externalId"); + if (!externalId) return null; + + return ( + + {(search, setSearch) => ( + }> + } + value={externalId} + onRemove={() => del(["externalId", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function TagsDropdown({ + trigger, + searchValue, + clearSearchValue, + onClose, +}: { + trigger: ReactNode; + searchValue: string; + clearSearchValue: () => void; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + 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 ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + className="max-w-[min(40ch,var(--popover-available-width))]" + > +
+
+ + setDraft(e.target.value)} + variant="small" + className="w-[37ch] font-mono" + spellCheck={false} + /> + + Comma-separated. Matches sessions with any of these tags. + +
+
+ + +
+
+
+
+ ); +} + +function AppliedTagsFilter() { + const { values, del } = useSearchParams(); + const tags = values("tags"); + if (tags.length === 0) return null; + + return ( + + {(search, setSearch) => ( + }> + } + value={appliedSummary(tags)} + onRemove={() => del(["tags", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + diff --git a/apps/webapp/app/components/sessions/v1/SessionStatus.tsx b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx new file mode 100644 index 000000000..a4e17affd --- /dev/null +++ b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx @@ -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 +>; + +const descriptions: Record = { + 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 ( + + + + ); + case "CLOSED": + return ; + case "EXPIRED": + return ; + default: + assertNever(status); + } +} + +export function SessionStatusLabel({ status }: { status: SessionStatus }) { + return {sessionStatusTitle(status)}; +} + +export function SessionStatusCombo({ + status, + className, + iconClassName, +}: { + status: SessionStatus; + className?: string; + iconClassName?: string; +}) { + return ( + + + + + ); +} + diff --git a/apps/webapp/app/components/sessions/v1/SessionsTable.tsx b/apps/webapp/app/components/sessions/v1/SessionsTable.tsx new file mode 100644 index 000000000..fb83f2d03 --- /dev/null +++ b/apps/webapp/app/components/sessions/v1/SessionsTable.tsx @@ -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; + +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 ( + + + + ID + + {allSessionStatuses.map((status) => ( +
+
+ +
+ + {descriptionForSessionStatus(status)} + +
+ ))} + + } + > + Status +
+ Type + Task + Tags + Created + Duration + + Actions + +
+
+ + {sessions.length === 0 ? ( + +
+ + {hasFilters + ? "No sessions match these filters" + : "No sessions in this environment yet"} + +
+
+ ) : ( + 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 ( + + +
+ +
+
+ + } + /> + + + {session.type} + + +
+ +
+
+ + {session.tags.length > 0 ? ( +
+ {session.tags.map((tag) => ( + + ))} +
+ ) : ( + + )} +
+ + + + + + + +
+ ); + }) + )} + {isLoading && ( + + Loading… + + )} +
+
+ ); +} + +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 ; +} + +function SessionActionsCell({ + runPath, + allRunsPath, +}: { + runPath?: string; + allRunsPath: string; +}) { + return ( + + {runPath && ( + + )} + + + } + /> + ); +} diff --git a/apps/webapp/app/presenters/SessionFilters.server.ts b/apps/webapp/app/presenters/SessionFilters.server.ts new file mode 100644 index 000000000..81e12af67 --- /dev/null +++ b/apps/webapp/app/presenters/SessionFilters.server.ts @@ -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, + }; +} diff --git a/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts new file mode 100644 index 000000000..684d5d6da --- /dev/null +++ b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts @@ -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>; +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, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/SessionPresenter.server.ts b/apps/webapp/app/presenters/v3/SessionPresenter.server.ts new file mode 100644 index 000000000..27807971d --- /dev/null +++ b/apps/webapp/app/presenters/v3/SessionPresenter.server.ts @@ -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>>; + +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: [], + }, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index b186848f2..ffa4ea718 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -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, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx new file mode 100644 index 000000000..496a5fb62 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx @@ -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(); + 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 ( + <> + + + } + /> + + + Sessions docs + + {status === "ACTIVE" && ( + + + + + + + )} + + + + + + + + + + + + + + + ); +} + +type LoadedSession = ReturnType>["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 ( +
+
+
+ + + Conversation + +
+ replace({ raw: v === "raw" ? "1" : undefined })} + /> +
+ {isRaw ? ( +
+ + replace({ stream: undefined })} + > + Output + + replace({ stream: "in" })} + > + Input + + + } + /> +
+ ) : ( +
+ +
+ )} +
+ ); +} + +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 ( +
+
+
+ + + {session.friendlyId} + +
+
+
+ + replace({ tab: "overview" })} + shortcut={{ key: "o" }} + > + Overview + + replace({ tab: "runs" })} + shortcut={{ key: "r" }} + > + Runs + + replace({ tab: "metadata" })} + shortcut={{ key: "m" }} + > + Metadata + + +
+
+ {tab === "overview" ? ( + + ) : tab === "runs" ? ( + + ) : ( + + )} +
+
+ ); +} + +function OverviewTab({ + session, + status, +}: { + session: LoadedSession; + status: SessionStatus; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const isAdmin = useHasAdminAccess(); + + return ( +
+ + + Status + + + + + + Friendly ID + + + + + {session.externalId ? ( + + External ID + + + + + ) : null} + + Type + + {session.type} + + + + Task + + {session.taskIdentifier} + + + {session.currentRun ? ( + + Current run + + + + {session.currentRun.friendlyId} + } + content={descriptionForTaskRunStatus(session.currentRun.status)} + disableHoverableContent + /> + + + + + ) : null} + + Tags + + {session.tags.length > 0 ? ( +
+ {session.tags.map((tag) => ( + + ))} +
+ ) : ( + + )} +
+
+ + Created + + + + + + Updated + + + + + {session.expiresAt ? ( + + + {new Date(session.expiresAt).getTime() < Date.now() ? "Expired" : "Expires"} + + + + + + ) : null} + {session.closedAt ? ( + + Closed + + + + + ) : null} + {session.closedReason ? ( + + Close reason + + {session.closedReason} + + + ) : null} +
+ + {isAdmin && ( +
+ + Admin only + + + + Session ID + + {session.id} + + + + Stream basin + + + {session.streamBasinName ?? "(global)"} + + + + +
+ )} +
+ ); +} + +function MetadataTab({ session }: { session: LoadedSession }) { + if (session.metadata == null) { + return ( + No metadata. + ); + } + const json = JSON.stringify(session.metadata, null, 2); + return ( + + ); +} + +function RunsTab({ + session, + allRunsPath, +}: { + session: LoadedSession; + allRunsPath: string; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + if (session.runs.length === 0) { + return No runs yet.; + } + + return ( +
+ + {session.runs.map((entry) => { + const runPath = entry.run + ? v3RunPath(organization, project, environment, { + friendlyId: entry.run.friendlyId, + }) + : undefined; + return ( + + +
+ {entry.reason} + + + +
+
+ + {entry.run && runPath ? ( + + + + + } + content={`Jump to run`} + disableHoverableContent + /> + ) : ( + + )} + +
+ ); + })} +
+
+ + View all runs + +
+
+ ); +} + diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx new file mode 100644 index 000000000..99b0a96b5 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx @@ -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(); + + return ( + <> + + + + + + Sessions docs + + + + + {!list.hasAnySessions ? ( + + + + ) : ( +
+
+ +
+ +
+
+ +
+ )} +
+ + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions/route.tsx new file mode 100644 index 000000000..f6723ddeb --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions/route.tsx @@ -0,0 +1,10 @@ +import { Outlet } from "@remix-run/react"; +import { PageContainer } from "~/components/layout/AppLayout"; + +export default function Page() { + return ( + + + + ); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 263921733..3e4c231cc 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -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 ( void; @@ -458,18 +426,6 @@ function RunBody({ > Overview - {run.isAgentRun && ( - { - replace({ tab: "agent" }); - }} - shortcut={{ key: "a" }} - > - Agent - - )}
- {tab === "agent" && run.isAgentRun && agentView ? ( - - ) : tab === "detail" ? ( + {tab === "detail" ? (
@@ -688,6 +639,32 @@ function RunBody({ )} + {run.session && ( + + Session + + + + + + } + content={`Jump to session (${run.session.reason})`} + disableHoverableContent + /> + + + )}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx index b6a72d3aa..4a9581831 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route.tsx @@ -101,17 +101,32 @@ export function RealtimeStreamViewer({ streamKey, metadata, displayName, + resourcePath: resourcePathOverride, + headerLabel, + headerLeft, }: { - runId: string; - streamKey: string; - metadata: Record | undefined; + runId?: string; + streamKey?: string; + metadata?: Record | 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: " 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 */}
-
+
@@ -244,13 +259,17 @@ export function RealtimeStreamViewer({ - - {displayName ? "Input stream:" : "Stream:"} - {displayName ?? streamKey} - + {headerLeft ?? ( + + {headerLabel ?? (displayName ? "Input stream:" : "Stream:")} + + {displayName ?? streamKey ?? ""} + + + )}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.$io.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.$io.ts new file mode 100644 index 000000000..c8676cacb --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.$io.ts @@ -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 } + ); +} diff --git a/apps/webapp/app/routes/resources.sessions.$sessionParam.close.ts b/apps/webapp/app/routes/resources.sessions.$sessionParam.close.ts new file mode 100644 index 000000000..27ffec567 --- /dev/null +++ b/apps/webapp/app/routes/resources.sessions.$sessionParam.close.ts @@ -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)}` + ); + } +}; diff --git a/apps/webapp/app/services/sessionsRepository/clickhouseSessionsRepository.server.ts b/apps/webapp/app/services/sessionsRepository/clickhouseSessionsRepository.server.ts index c810a0dfa..aebf61628 100644 --- a/apps/webapp/app/services/sessionsRepository/clickhouseSessionsRepository.server.ts +++ b/apps/webapp/app/services/sessionsRepository/clickhouseSessionsRepository.server.ts @@ -101,6 +101,7 @@ export class ClickHouseSessionsRepository implements ISessionsRepository { createdAt: true, updatedAt: true, runtimeEnvironmentId: true, + currentRunId: true, }, }); diff --git a/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts b/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts index 15566295e..245f1df22 100644 --- a/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts +++ b/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts @@ -95,6 +95,7 @@ export type ListedSession = Prisma.SessionGetPayload<{ createdAt: true; updatedAt: true; runtimeEnvironmentId: true; + currentRunId: true; }; }>; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 135733bc4..6712ee918 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -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, diff --git a/references/ai-chat/src/components/chat-sidebar.tsx b/references/ai-chat/src/components/chat-sidebar.tsx index 5c4bd6c67..e036eebc7 100644 --- a/references/ai-chat/src/components/chat-sidebar.tsx +++ b/references/ai-chat/src/components/chat-sidebar.tsx @@ -117,6 +117,7 @@ export function ChatSidebar({ +