Skip to content

Commit 2bb5684

Browse files
committed
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.
1 parent 16c3c9b commit 2bb5684

25 files changed

Lines changed: 2655 additions & 152 deletions

File tree

apps/webapp/app/components/BlankStatePanels.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
ArrowsRightLeftIcon,
23
BeakerIcon,
34
BellAlertIcon,
45
BookOpenIcon,
@@ -189,6 +190,28 @@ export function BatchesNone() {
189190
);
190191
}
191192

193+
export function SessionsNone() {
194+
return (
195+
<InfoPanel
196+
title="Sessions"
197+
icon={ArrowsRightLeftIcon}
198+
iconClassName="text-teal-500"
199+
panelClassName="max-w-full"
200+
accessory={
201+
<LinkButton to={docsPath("/ai-chat/overview")} variant="docs/small" LeadingIcon={BookOpenIcon}>
202+
Sessions docs
203+
</LinkButton>
204+
}
205+
>
206+
<Paragraph spacing variant="small">
207+
You have no sessions in this environment. Sessions are durable, typed, bidirectional I/O
208+
primitives that outlive a single run — used by <InlineCode>chat.agent</InlineCode> and any
209+
long-running task that needs streaming input and output.
210+
</Paragraph>
211+
</InfoPanel>
212+
);
213+
}
214+
192215
export function TestHasNoTasks() {
193216
const organization = useOrganization();
194217
const project = useProject();

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
AdjustmentsHorizontalIcon,
33
ArrowPathRoundedSquareIcon,
44
ArrowRightOnRectangleIcon,
5+
ArrowsRightLeftIcon,
56
ArrowTopRightOnSquareIcon,
67
BeakerIcon,
78
BellAlertIcon,
@@ -91,6 +92,7 @@ import {
9192
v3QueuesPath,
9293
v3RunsPath,
9394
v3SchedulesPath,
95+
v3SessionsPath,
9496
v3TestPath,
9597
v3UsagePath,
9698
v3WaitpointTokensPath,
@@ -478,6 +480,15 @@ export function SideMenu({
478480
to={v3AgentsPath(organization, project, environment)}
479481
isCollapsed={isCollapsed}
480482
/>
483+
<SideMenuItem
484+
name="Sessions"
485+
icon={ArrowsRightLeftIcon}
486+
activeIconColor="text-teal-500"
487+
inactiveIconColor="text-teal-500"
488+
to={v3SessionsPath(organization, project, environment)}
489+
data-action="sessions"
490+
isCollapsed={isCollapsed}
491+
/>
481492
<SideMenuItem
482493
name="Playground"
483494
icon={BeakerIcon}

apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives
2626

2727
export function AgentMessageView({ messages }: { messages: UIMessage[] }) {
2828
return (
29-
<div className="mx-auto flex max-w-[800px] flex-col gap-2">
29+
<div className="mx-auto flex w-full min-w-0 max-w-[800px] flex-col gap-2">
3030
{messages.map((msg) => (
3131
<MessageBubble key={msg.id} message={msg} />
3232
))}
@@ -55,9 +55,9 @@ export const MessageBubble = memo(function MessageBubble({
5555
.join("") ?? "";
5656

5757
return (
58-
<div className="flex justify-end">
58+
<div className="flex min-w-0 justify-end">
5959
<div className="max-w-[80%] rounded-lg bg-indigo-600 px-4 py-2.5 text-sm text-white">
60-
<div className="whitespace-pre-wrap">{text}</div>
60+
<div className="whitespace-pre-wrap [overflow-wrap:anywhere]">{text}</div>
6161
</div>
6262
</div>
6363
);

apps/webapp/app/components/runs/v3/agent/AgentView.tsx

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,6 @@ export type AgentViewAuth = {
2929
initialMessages: UIMessage[];
3030
};
3131

32-
type AgentViewRun = {
33-
friendlyId: string;
34-
taskIdentifier: string;
35-
};
36-
3732
/**
3833
* Max state-update interval while assistant chunks are streaming. Matches
3934
* the `experimental_throttle: 100` we previously passed to `useChat`.
@@ -51,9 +46,9 @@ const STATE_FLUSH_THROTTLE_MS = 100;
5146
const INITIAL_PAYLOAD_TIMESTAMP = 0;
5247

5348
/**
54-
* Renders an agent run's chat conversation as it unfolds.
49+
* Renders a Session's chat conversation as it unfolds.
5550
*
56-
* Subscribes to both channels of the run's backing {@link Session}:
51+
* Subscribes to both channels of the {@link Session}:
5752
* - **`.out`** delivers assistant `UIMessageChunk`s (text deltas, tool
5853
* calls, reasoning, etc.) produced by the agent's
5954
* `chatStream.writer(...)` calls — objects, already parsed by the S2
@@ -74,19 +69,12 @@ const INITIAL_PAYLOAD_TIMESTAMP = 0;
7469
* Intended to be mounted inside a scrollable container — the component
7570
* does not own its own scrollbar.
7671
*/
77-
export function AgentView({
78-
run,
79-
agentView,
80-
}: {
81-
run: AgentViewRun;
82-
agentView: AgentViewAuth;
83-
}) {
72+
export function AgentView({ agentView }: { agentView: AgentViewAuth }) {
8473
const organization = useOrganization();
8574
const project = useProject();
8675
const environment = useEnvironment();
8776

88-
const messages = useAgentRunMessages({
89-
runFriendlyId: run.friendlyId,
77+
const messages = useAgentSessionMessages({
9078
sessionId: agentView.sessionId,
9179
apiOrigin: agentView.apiOrigin,
9280
orgSlug: organization.slug,
@@ -120,8 +108,8 @@ export function AgentView({
120108
}
121109

122110
// ---------------------------------------------------------------------------
123-
// useAgentRunMessages — reads both realtime streams for a run and maintains
124-
// a chronologically ordered, merged message list.
111+
// useAgentSessionMessages — reads both realtime streams for a session and
112+
// maintains a chronologically ordered, merged message list.
125113
// ---------------------------------------------------------------------------
126114

127115
/**
@@ -222,16 +210,14 @@ function createOrchestrationState(): MessageOrchestrationState {
222210
};
223211
}
224212

225-
function useAgentRunMessages({
226-
runFriendlyId,
213+
function useAgentSessionMessages({
227214
sessionId,
228215
apiOrigin,
229216
orgSlug,
230217
projectSlug,
231218
envSlug,
232219
initialMessages,
233220
}: {
234-
runFriendlyId: string;
235221
sessionId: string;
236222
apiOrigin: string;
237223
orgSlug: string;
@@ -287,9 +273,14 @@ function useAgentRunMessages({
287273
const abort = new AbortController();
288274

289275
const encodedSession = encodeURIComponent(sessionId);
276+
// Always use the page's own origin to avoid CORS preflight failures
277+
// when the configured `apiOrigin` (e.g. `localhost`) differs from the
278+
// origin the dashboard was loaded from (e.g. `127.0.0.1`). The dashboard
279+
// resource route is same-origin by construction.
280+
const origin = typeof window !== "undefined" ? window.location.origin : apiOrigin;
290281
const sessionBase =
291-
`${apiOrigin}/resources/orgs/${orgSlug}/projects/${projectSlug}/env/${envSlug}` +
292-
`/runs/${runFriendlyId}/realtime/v1/sessions/${encodedSession}`;
282+
`${origin}/resources/orgs/${orgSlug}/projects/${projectSlug}/env/${envSlug}` +
283+
`/sessions/${encodedSession}/realtime/v1`;
293284

294285
const outputUrl = `${sessionBase}/out`;
295286
const inputUrl = `${sessionBase}/in`;
@@ -463,7 +454,7 @@ function useAgentRunMessages({
463454
pendingTimerRef.current = null;
464455
}
465456
};
466-
}, [runFriendlyId, sessionId, apiOrigin, orgSlug, projectSlug, envSlug]);
457+
}, [sessionId, apiOrigin, orgSlug, projectSlug, envSlug]);
467458

468459
return useMemo(() => {
469460
const timestamps = timestampsRef.current;

apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ export function AssistantResponse({
211211
/>
212212
{mode === "rendered" ? (
213213
<ChatBubble>
214-
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
214+
<div className="streamdown-container min-w-0 font-sans text-sm font-normal text-text-dimmed [overflow-wrap:anywhere]">
215215
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
216216
<StreamdownRenderer>{text}</StreamdownRenderer>
217217
</Suspense>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { XCircleIcon } from "@heroicons/react/24/solid";
2+
import { DialogClose } from "@radix-ui/react-dialog";
3+
import { Form, useNavigation } from "@remix-run/react";
4+
import { Button } from "~/components/primitives/Buttons";
5+
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
6+
import { FormButtons } from "~/components/primitives/FormButtons";
7+
import { Input } from "~/components/primitives/Input";
8+
import { Label } from "~/components/primitives/Label";
9+
import { Paragraph } from "~/components/primitives/Paragraph";
10+
import { SpinnerWhite } from "~/components/primitives/Spinner";
11+
12+
type CloseSessionDialogProps = {
13+
sessionParam: string;
14+
environmentId: string;
15+
redirectPath: string;
16+
};
17+
18+
export function CloseSessionDialog({
19+
sessionParam,
20+
environmentId,
21+
redirectPath,
22+
}: CloseSessionDialogProps) {
23+
const navigation = useNavigation();
24+
25+
const formAction = `/resources/sessions/${encodeURIComponent(sessionParam)}/close`;
26+
const isLoading = navigation.formAction === formAction;
27+
28+
return (
29+
<DialogContent key="close-session">
30+
<DialogHeader>Close this session?</DialogHeader>
31+
<div className="flex flex-col gap-3 pt-3">
32+
<Paragraph>
33+
Closing a session is permanent. The session will no longer accept new input or trigger
34+
new runs. Any in-flight run continues until it finishes on its own.
35+
</Paragraph>
36+
<Form action={formAction} method="post" className="flex flex-col gap-3">
37+
<input type="hidden" name="redirectUrl" value={redirectPath} />
38+
<input type="hidden" name="environmentId" value={environmentId} />
39+
<div className="flex flex-col gap-1">
40+
<Label htmlFor="close-session-reason">Reason (optional)</Label>
41+
<Input
42+
id="close-session-reason"
43+
name="reason"
44+
placeholder="e.g. user signed out, ticket resolved"
45+
variant="medium"
46+
spellCheck={false}
47+
autoFocus
48+
/>
49+
</div>
50+
<FormButtons
51+
confirmButton={
52+
<Button
53+
type="submit"
54+
variant="danger/medium"
55+
LeadingIcon={isLoading ? SpinnerWhite : XCircleIcon}
56+
disabled={isLoading}
57+
shortcut={{ modifiers: ["mod"], key: "enter" }}
58+
>
59+
{isLoading ? "Closing..." : "Close session"}
60+
</Button>
61+
}
62+
cancelButton={
63+
<DialogClose asChild>
64+
<Button variant={"tertiary/medium"}>Cancel</Button>
65+
</DialogClose>
66+
}
67+
/>
68+
</Form>
69+
</div>
70+
</DialogContent>
71+
);
72+
}

0 commit comments

Comments
 (0)