P1 exit: chat UI, sessions column, agent create — 11 E2E journeys green

- Stream layer: incremental SSE parser tested against bytes captured from
  the REAL gateway (chunking-independent, UTF-8-safe, heartbeat-tolerant);
  Zod gateway event schemas; pure transcript reducer (optimistic send,
  delta streaming, step traces, resume dedupe, error states)
- Same-origin /api proxy route: httpOnly cookie -> bearer, unbuffered SSE
  passthrough; NuqsAdapter in root layout
- Chat workspace: route /claws/{id}/chat/{key} (RSC history + settings),
  WelcomeState with suggested prompts, MessageList (right user bubbles,
  left agent messages, blink caret), collapsible StepTrace, Composer
  (Enter sends, Shift+Enter newline)
- SessionsColumn in 208px SlidePanel (?sessions=1): search, relative
  times, active coral border, new session; /claws/{id} resumes latest or
  opens fresh; minimal create-claw form; rail + button and roster links
- P1 exit E2E: scripted reply streams, tool step trace survives reload,
  separate transcripts across sessions with column switching, create claw
  and chat immediately

83 Rust + 56 frontend unit/component tests + 11 Playwright journeys.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-09 23:29:27 -05:00
co-authored by Claude Fable 5
parent 32008c9ef0
commit 9f9f507c15
29 changed files with 1774 additions and 4 deletions
@@ -0,0 +1,64 @@
import { z } from "zod";
import { ChatWorkspace } from "@/components/chat/ChatWorkspace";
import { apiFetch } from "@/lib/api/http";
import { AgentSchema } from "@/lib/api/schemas";
import {
fetchHistory,
fetchSessions,
type HistoryMessage,
} from "@/lib/api/sessions";
import { decodeSessionKeyParam, formatSessionKey } from "@/lib/url/session-key";
import type { UiMessage } from "@/lib/gateway/transcript";
const SettingsSchema = z.object({ agent: AgentSchema }).passthrough();
function toUiMessage(entry: HistoryMessage): UiMessage | null {
if (entry.role === "system") {
return null;
}
return {
id: entry.id,
role: entry.role,
text: entry.content.text,
steps: entry.steps.map((step) => ({
seq: step.seq,
tool: step.tool_name ?? step.kind,
input: step.input,
output: step.output,
status: step.status,
})),
status: "complete",
};
}
export default async function ChatPage({
params,
}: {
params: Promise<{ clawId: string; sessionKey: string }>;
}) {
const { clawId, sessionKey: rawKey } = await params;
const key = formatSessionKey(decodeSessionKeyParam(rawKey));
const [settings, history, sessions] = await Promise.all([
apiFetch(
SettingsSchema,
`/api/claws/settings/full?clawId=${encodeURIComponent(clawId)}`,
),
fetchHistory(key),
fetchSessions(clawId),
]);
const initialMessages = history
.map(toUiMessage)
.filter((m): m is UiMessage => m !== null);
return (
<ChatWorkspace
agent={settings.agent}
sessionKey={key}
initialMessages={initialMessages}
sessions={sessions}
/>
);
}
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { createSession, fetchSessions } from "@/lib/api/sessions";
import { encodeSessionKeyParam } from "@/lib/url/session-key";
// Clicking a claw in the rail lands here: resume the most recent session,
// or open a fresh one (sessions are resumable, §6).
export default async function ClawHome({
params,
}: {
params: Promise<{ clawId: string }>;
}) {
const { clawId } = await params;
const sessions = await fetchSessions(clawId);
const target = sessions[0] ?? (await createSession(clawId));
redirect(
`/claws/${clawId}/chat/${encodeSessionKeyParam(target.sessionKey)}`,
);
}
@@ -0,0 +1,17 @@
import { CreateClawForm } from "@/components/wizard/CreateClawForm";
export default function NewClawPage() {
return (
<section className="flex min-h-dvh flex-col items-center justify-center gap-6 px-4 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]">
<div className="text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Create your Claw
</h1>
<p className="pt-1 text-sm text-muted-foreground">
Claws help get your work done.
</p>
</div>
<CreateClawForm />
</section>
);
}
+54
View File
@@ -0,0 +1,54 @@
// Same-origin /api/* proxy (spec §13): browser requests carry the httpOnly
// session cookie; this handler swaps it for the bearer header and streams
// the backend response through unbuffered (the gateway SSE depends on it).
import { NextResponse, type NextRequest } from "next/server";
import { apiOrigin, TOKEN_COOKIE } from "@/lib/api/http";
async function proxy(
request: NextRequest,
context: { params: Promise<{ path: string[] }> },
) {
const token = request.cookies.get(TOKEN_COOKIE)?.value;
if (!token) {
return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
}
const { path } = await context.params;
const url = new URL(`/api/${path.join("/")}`, apiOrigin());
url.search = request.nextUrl.search;
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
};
const contentType = request.headers.get("content-type");
if (contentType) {
headers["Content-Type"] = contentType;
}
const upstream = await fetch(url, {
method: request.method,
headers,
body:
request.method === "GET" || request.method === "HEAD"
? undefined
: await request.arrayBuffer(),
});
// Pass the body stream through untouched; SSE frames must not buffer.
return new Response(upstream.body, {
status: upstream.status,
headers: {
"Content-Type": upstream.headers.get("content-type") ?? "application/json",
"Cache-Control": "no-store",
},
});
}
export {
proxy as GET,
proxy as POST,
proxy as PATCH,
proxy as PUT,
proxy as DELETE,
};
+4 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import "./globals.css"; import "./globals.css";
// Vendored variable fonts (src/fonts) — zero external requests, so the same // Vendored variable fonts (src/fonts) — zero external requests, so the same
@@ -26,7 +27,9 @@ export default function RootLayout({
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="en" className={`${geist.variable} ${geistMono.variable} h-full`}> <html lang="en" className={`${geist.variable} ${geistMono.variable} h-full`}>
<body className="min-h-full antialiased">{children}</body> <body className="min-h-full antialiased">
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html> </html>
); );
} }
@@ -0,0 +1,46 @@
"use client";
import { useQueryStates } from "nuqs";
import type { Agent } from "@/lib/api/schemas";
import { Avatar } from "@/components/ui/Avatar";
import { panelParsers } from "@/lib/url/panel-params";
/** Chat header (§5b): identity left, sessions/new-session controls right. */
export function ChatHeader({
agent,
onNewSession,
}: {
agent: Agent;
onNewSession: () => void;
}) {
const [{ sessions }, setParams] = useQueryStates(panelParsers, {
shallow: true,
});
return (
<header className="flex h-14 items-center gap-3 border-b border-border px-4">
<Avatar name={agent.name} accent={agent.accent} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{agent.name}</p>
<p className="truncate text-xxs text-muted-foreground">
{agent.job_title}
</p>
</div>
<button
type="button"
aria-pressed={sessions}
onClick={() => setParams({ sessions: !sessions })}
className="rounded-(--radius-button) border border-border px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
>
Sessions
</button>
<button
type="button"
onClick={onNewSession}
className="rounded-(--radius-button) border border-border px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
>
New
</button>
</header>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useQueryStates } from "nuqs";
import type { Agent } from "@/lib/api/schemas";
import type { Session } from "@/lib/api/sessions";
import { useChat } from "@/lib/gateway/use-chat";
import type { UiMessage } from "@/lib/gateway/transcript";
import { encodeSessionKeyParam } from "@/lib/url/session-key";
import { panelParsers } from "@/lib/url/panel-params";
import { SlidePanel } from "@/components/ui/SlidePanel";
import { SessionsColumn } from "@/components/sessions/SessionsColumn";
import { ChatHeader } from "./ChatHeader";
import { Composer } from "./Composer";
import { MessageList } from "./MessageList";
import { WelcomeState } from "./WelcomeState";
interface ChatWorkspaceProps {
agent: Agent;
sessionKey: string;
initialMessages: UiMessage[];
sessions: Session[];
}
/** The chat column orchestrator (§5/§6). Search-param state is client-only
* and shallow; navigating sessions is a real path navigation. */
export function ChatWorkspace({
agent,
sessionKey,
initialMessages,
sessions,
}: ChatWorkspaceProps) {
const router = useRouter();
const { state, send } = useChat(agent.id, sessionKey, initialMessages);
const [draft, setDraft] = useState("");
const [{ sessions: showSessions }] = useQueryStates(panelParsers, {
shallow: true,
});
function openSession(session: Session) {
router.push(
`/claws/${agent.id}/chat/${encodeSessionKeyParam(session.sessionKey)}?sessions=1`,
);
}
async function newSession() {
const res = await fetch("/api/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clawId: agent.id, title: "" }),
});
if (res.ok) {
const session = (await res.json()) as Session;
router.push(
`/claws/${agent.id}/chat/${encodeSessionKeyParam(session.sessionKey)}`,
);
}
}
return (
<div className="flex h-dvh">
<SlidePanel open={showSessions} width={208} label="Sessions">
<SessionsColumn
sessions={sessions}
activeSessionKey={sessionKey}
onOpen={openSession}
onNewSession={newSession}
/>
</SlidePanel>
<section className="flex min-w-0 flex-1 flex-col">
<ChatHeader agent={agent} onNewSession={newSession} />
{state.messages.length === 0 ? (
<WelcomeState agent={agent} onPick={setDraft} />
) : (
<MessageList messages={state.messages} agent={agent} />
)}
<Composer
agentName={agent.name}
disabled={state.streaming}
onSend={send}
draft={draft}
onDraftChange={setDraft}
/>
</section>
</div>
);
}
@@ -0,0 +1,47 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Composer } from "./Composer";
describe("Composer", () => {
it("sends trimmed text on Enter and clears", async () => {
const user = userEvent.setup();
const onSend = vi.fn();
render(<Composer agentName="Scout" disabled={false} onSend={onSend} />);
const box = screen.getByLabelText("Message Scout");
await user.type(box, " hello there {Enter}");
expect(onSend).toHaveBeenCalledWith("hello there");
expect(box).toHaveValue("");
});
it("Shift+Enter inserts a newline instead of sending", async () => {
const user = userEvent.setup();
const onSend = vi.fn();
render(<Composer agentName="Scout" disabled={false} onSend={onSend} />);
const box = screen.getByLabelText("Message Scout");
await user.type(box, "line one{Shift>}{Enter}{/Shift}line two");
expect(onSend).not.toHaveBeenCalled();
expect(box).toHaveValue("line one\nline two");
});
it("does not send empty input", async () => {
const user = userEvent.setup();
const onSend = vi.fn();
render(<Composer agentName="Scout" disabled={false} onSend={onSend} />);
await user.type(screen.getByLabelText("Message Scout"), " {Enter}");
expect(onSend).not.toHaveBeenCalled();
});
it("blocks sending while streaming", async () => {
const user = userEvent.setup();
const onSend = vi.fn();
render(<Composer agentName="Scout" disabled onSend={onSend} />);
const box = screen.getByLabelText("Message Scout");
await user.type(box, "hi{Enter}");
expect(onSend).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
});
});
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useState, type FormEvent, type KeyboardEvent } from "react";
interface ComposerProps {
agentName: string;
disabled: boolean;
onSend: (text: string) => void;
/** Prefill from a suggested-prompt chip; cleared after send. */
draft?: string;
onDraftChange?: (value: string) => void;
}
/** The chat composer (§5): Enter sends, Shift+Enter inserts a newline. */
export function Composer({
agentName,
disabled,
onSend,
draft,
onDraftChange,
}: ComposerProps) {
const [inner, setInner] = useState("");
const value = draft ?? inner;
const setValue = onDraftChange ?? setInner;
function submit(event?: FormEvent) {
event?.preventDefault();
const text = value.trim();
if (text === "" || disabled) {
return;
}
setValue("");
onSend(text);
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
}
return (
<form
onSubmit={submit}
className="flex items-end gap-2 border-t border-border bg-background px-4 py-3"
>
<textarea
aria-label={`Message ${agentName}`}
placeholder={`Message ${agentName}...`}
value={value}
rows={1}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
className="max-h-40 min-h-10 flex-1 resize-y rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
/>
<button
type="submit"
disabled={disabled || value.trim() === ""}
className="rounded-(--radius-button) bg-accent px-4 py-2 text-sm font-medium text-background shadow-(--shadow-button) hover:bg-coral-light disabled:opacity-40"
>
Send
</button>
</form>
);
}
@@ -0,0 +1,79 @@
"use client";
import { useEffect, useRef } from "react";
import type { UiMessage } from "@/lib/gateway/transcript";
import type { Agent } from "@/lib/api/schemas";
import { Avatar } from "@/components/ui/Avatar";
import { StepTrace } from "./steps/StepTrace";
function UserBubble({ message }: { message: UiMessage }) {
return (
<div className="flex justify-end">
<p
className={`max-w-[70%] whitespace-pre-wrap rounded-(--radius) bg-surface-warm-muted px-3 py-2 text-sm shadow-(--shadow-bubble) ${
message.status === "sending" ? "opacity-60" : ""
}`}
>
{message.text}
</p>
</div>
);
}
function AgentMessage({ message, agent }: { message: UiMessage; agent: Agent }) {
return (
<div className="flex gap-2">
<Avatar name={agent.name} accent={agent.accent} size="sm" />
<div className="min-w-0 flex-1">
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{message.text}
{message.status === "streaming" && (
<span
aria-hidden
className="ml-0.5 inline-block h-4 w-0.5 translate-y-0.5 bg-accent motion-safe:animate-[caret-blink_1s_steps(1)_infinite]"
/>
)}
</p>
{message.status === "error" && (
<p role="alert" className="pt-1 text-xs text-accent">
Something went wrong with this reply.
</p>
)}
<StepTrace steps={message.steps} />
</div>
</div>
);
}
/** The transcript (§5b): user bubbles right, agent messages left. */
export function MessageList({
messages,
agent,
}: {
messages: UiMessage[];
agent: Agent;
}) {
const bottomRef = useRef<HTMLDivElement>(null);
const tailText = messages.at(-1)?.text;
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" });
}, [messages.length, tailText]);
return (
<div
aria-live="polite"
className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-6"
>
{messages.map((message) =>
message.role === "user" ? (
<UserBubble key={message.id} message={message} />
) : (
<AgentMessage key={message.id} message={message} agent={agent} />
),
)}
<div ref={bottomRef} />
</div>
);
}
@@ -0,0 +1,42 @@
import type { Agent } from "@/lib/api/schemas";
import { Avatar } from "@/components/ui/Avatar";
const SUGGESTED_PROMPTS = [
"Create a daily briefing",
"Write a report",
"Make a presentation",
"What can you do?",
];
/** Empty-session welcome (§5a). */
export function WelcomeState({
agent,
onPick,
}: {
agent: Agent;
onPick: (prompt: string) => void;
}) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]">
<Avatar name={agent.name} accent={agent.accent} size="lg" />
<h2 className="text-center text-xl font-semibold tracking-tight">
Hi, I&apos;m {agent.name}. What can I help with?
</h2>
<p className="text-sm text-muted-foreground">
{agent.job_title} · Shared with your team
</p>
<div className="flex max-w-md flex-wrap justify-center gap-2 pt-2">
{SUGGESTED_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onPick(prompt)}
className="rounded-(--radius-button) border border-border bg-subtle px-3 py-1.5 text-xs text-muted-foreground hover:border-accent hover:text-foreground"
>
{prompt}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import type { UiStep } from "@/lib/gateway/transcript";
import { StepTrace } from "./StepTrace";
const steps: UiStep[] = [
{ seq: 1, tool: "clock.now", input: {}, output: { now: "12:00" }, status: "ok" },
{ seq: 2, tool: "files.read", input: {}, output: null, status: "running" },
];
describe("StepTrace", () => {
it("renders nothing for zero steps", () => {
const { container } = render(<StepTrace steps={[]} />);
expect(container).toBeEmptyDOMElement();
});
it("collapses by default showing the step count", () => {
render(<StepTrace steps={steps} />);
const toggle = screen.getByRole("button", { name: /2 steps/ });
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText(/clock\.now/)).not.toBeInTheDocument();
});
it("expands to show tool rows and collapses again", async () => {
const user = userEvent.setup();
render(<StepTrace steps={steps} />);
const toggle = screen.getByRole("button", { name: /2 steps/ });
await user.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText(/clock\.now/)).toBeInTheDocument();
expect(screen.getByText(/12:00/)).toBeInTheDocument();
await user.click(toggle);
expect(screen.queryByText(/clock\.now/)).not.toBeInTheDocument();
});
it("uses singular wording for one step", () => {
render(<StepTrace steps={[steps[0]]} />);
expect(screen.getByRole("button", { name: /1 step$/ })).toBeInTheDocument();
});
});
@@ -0,0 +1,56 @@
"use client";
import { useId, useState } from "react";
import type { UiStep } from "@/lib/gateway/transcript";
/** The collapsible "N steps" tool/reasoning trace on agent messages (§5b). */
export function StepTrace({ steps }: { steps: UiStep[] }) {
const [expanded, setExpanded] = useState(false);
const regionId = useId();
if (steps.length === 0) {
return null;
}
return (
<div className="pt-1">
<button
type="button"
aria-expanded={expanded}
aria-controls={regionId}
onClick={() => setExpanded((v) => !v)}
className="rounded-(--radius) px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-surface-warm hover:text-foreground"
>
{expanded ? "▾" : "▸"} {steps.length}{" "}
{steps.length === 1 ? "step" : "steps"}
</button>
{expanded && (
<ul id={regionId} className="flex flex-col gap-1 pl-3 pt-1">
{steps.map((step) => (
<li
key={step.seq}
className="rounded-(--radius) border border-border bg-subtle px-2 py-1.5 font-mono text-xs motion-safe:animate-[toolSlideIn_var(--duration-fast)_var(--ease-app)]"
>
<span
className={
step.status === "error"
? "text-accent"
: step.status === "running"
? "text-muted-foreground motion-safe:animate-[pulse_1.5s_ease-in-out_infinite]"
: "text-foreground"
}
>
{step.status === "ok" ? "✓" : step.status === "error" ? "✗" : "…"}{" "}
{step.tool}
</span>
{step.output != null && (
<span className="block truncate pt-0.5 text-muted-foreground">
{JSON.stringify(step.output)}
</span>
)}
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import type { Session } from "@/lib/api/sessions";
import { relativeTime } from "@/lib/format/relative-time";
interface SessionsColumnProps {
sessions: Session[];
activeSessionKey: string;
onOpen: (session: Session) => void;
onNewSession: () => void;
}
/** Session list content (§6); rendered inside a 208px SlidePanel. */
export function SessionsColumn({
sessions,
activeSessionKey,
onOpen,
onNewSession,
}: SessionsColumnProps) {
const [query, setQuery] = useState("");
const visible = sessions.filter((s) =>
(s.title || "Untitled").toLowerCase().includes(query.toLowerCase()),
);
return (
<div className="flex h-full flex-col border-r border-border bg-subtle">
<div className="p-2">
<input
type="search"
aria-label="Search sessions"
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-(--radius) border border-input bg-background px-2 py-1.5 text-xs outline-none focus:border-accent"
/>
</div>
<ul aria-label="Sessions" className="flex-1 overflow-y-auto px-2">
{visible.map((session) => {
const active = session.sessionKey === activeSessionKey;
return (
<li key={session.id}>
<button
type="button"
aria-current={active ? "true" : undefined}
onClick={() => onOpen(session)}
className={`w-full rounded-(--radius) border-l-2 px-2 py-2 text-left hover:bg-surface-warm ${
active ? "border-accent bg-surface-warm" : "border-transparent"
}`}
>
<span className="block truncate text-xs text-foreground">
{session.title || "Untitled"}
</span>
<span className="block text-xxs text-muted-foreground">
{relativeTime(session.last_active_at)}
</span>
</button>
</li>
);
})}
</ul>
<div className="p-2">
<button
type="button"
onClick={onNewSession}
className="w-full rounded-(--radius-button) border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-accent hover:text-foreground"
>
+ New session
</button>
</div>
</div>
);
}
@@ -1,10 +1,16 @@
import Link from "next/link";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
/** One agent row in the left rail (§4): avatar, name, online dot. */ /** One agent row in the left rail (§4): avatar, name, online dot. Links to
* the claw's chat (latest session resumes, or a fresh one opens). */
export function AgentRosterItem({ agent }: { agent: Agent }) { export function AgentRosterItem({ agent }: { agent: Agent }) {
return ( return (
<div className="flex items-center gap-2 rounded-(--radius) px-2 py-1.5 text-sm text-foreground hover:bg-surface-warm"> <Link
href={`/claws/${agent.id}`}
className="flex items-center gap-2 rounded-(--radius) px-2 py-1.5 text-sm text-foreground hover:bg-surface-warm"
>
<Avatar name={agent.name} accent={agent.accent} size="sm" /> <Avatar name={agent.name} accent={agent.accent} size="sm" />
<span className="truncate">{agent.name}</span> <span className="truncate">{agent.name}</span>
{agent.status === "online" && ( {agent.status === "online" && (
@@ -14,6 +20,6 @@ export function AgentRosterItem({ agent }: { agent: Agent }) {
className="ml-auto size-1.5 shrink-0 rounded-full bg-green-500" className="ml-auto size-1.5 shrink-0 rounded-full bg-green-500"
/> />
)} )}
</div> </Link>
); );
} }
@@ -1,3 +1,5 @@
import Link from "next/link";
import type { Agent, User } from "@/lib/api/schemas"; import type { Agent, User } from "@/lib/api/schemas";
import { AgentRosterItem } from "./AgentRosterItem"; import { AgentRosterItem } from "./AgentRosterItem";
import { ShellNav } from "./ShellNav"; import { ShellNav } from "./ShellNav";
@@ -28,6 +30,18 @@ export function LeftRail({ user, roster }: LeftRailProps) {
))} ))}
</ul> </ul>
)} )}
<Link
href="/claws/new"
className="mt-1 flex items-center gap-2 rounded-(--radius) px-2 py-1.5 text-sm text-muted-foreground hover:bg-surface-warm hover:text-foreground"
>
<span
aria-hidden
className="inline-flex size-6 items-center justify-center rounded-full border border-dashed border-border"
>
+
</span>
New claw
</Link>
</div> </div>
<div className="flex flex-col gap-3 pt-3"> <div className="flex flex-col gap-3 pt-3">
<ShellNav /> <ShellNav />
@@ -0,0 +1,104 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
const ACCENTS = ["#f96565", "#65a8f9", "#65f9a8", "#f9d965", "#c465f9"];
/** Minimal claw creation (full §9 wizard with provisioning lands in P5).
* Completing this form creates a LIVE agent. */
export function CreateClawForm() {
const router = useRouter();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const [accent, setAccent] = useState(ACCENTS[0]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setFailed(false);
const data = new FormData(event.currentTarget);
const res = await fetch("/api/claws", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: data.get("name"),
job_title: data.get("job_title"),
system_prompt: data.get("system_prompt") ?? "",
accent,
}),
});
if (res.ok) {
const claw = (await res.json()) as { id: string };
router.push(`/claws/${claw.id}`);
router.refresh();
return;
}
setPending(false);
setFailed(true);
}
return (
<form onSubmit={handleSubmit} className="flex w-full max-w-md flex-col gap-4">
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Name *
<input
name="name"
required
placeholder="Scout"
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Job title *
<input
name="job_title"
required
placeholder="Research Analyst"
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Job description
<textarea
name="system_prompt"
rows={4}
placeholder="Describe how this claw should think and work..."
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
/>
<span className="text-xxs">Becomes part of its system prompt.</span>
</label>
<div role="radiogroup" aria-label="Accent color" className="flex gap-2">
{ACCENTS.map((color) => (
<button
key={color}
type="button"
role="radio"
aria-checked={accent === color}
aria-label={`Accent ${color}`}
onClick={() => setAccent(color)}
style={{ backgroundColor: color }}
className={`size-6 rounded-full ${
accent === color ? "ring-2 ring-foreground ring-offset-2 ring-offset-background" : ""
}`}
/>
))}
</div>
{failed && (
<p role="alert" className="text-xs text-accent">
Could not create the claw. Check the fields and try again.
</p>
)}
<button
type="submit"
disabled={pending}
className="rounded-(--radius-button) bg-accent px-4 py-2 text-sm font-medium text-background shadow-(--shadow-cta) hover:bg-coral-light disabled:opacity-50"
>
{pending ? "Creating…" : "Create claw"}
</button>
<p className="text-xxs text-muted-foreground">
Creating a claw brings it online immediately.
</p>
</form>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { z } from "zod";
import { apiFetch } from "./http";
export const SessionSchema = z.object({
id: z.string().uuid(),
agent_id: z.string().uuid(),
workspace_id: z.string().uuid(),
title: z.string(),
shard: z.number(),
created_at: z.string(),
last_active_at: z.string(),
sessionKey: z.string(),
});
export type Session = z.infer<typeof SessionSchema>;
export const StepSchema = z.object({
id: z.string().uuid(),
message_id: z.string().uuid(),
seq: z.number(),
kind: z.string(),
tool_name: z.string().nullable(),
input: z.unknown(),
output: z.unknown(),
taint: z.array(z.string()),
status: z.enum(["running", "ok", "error"]),
});
export const HistoryMessageSchema = z.object({
id: z.string().uuid(),
session_id: z.string().uuid(),
seq: z.number(),
role: z.enum(["user", "agent", "system"]),
content: z.object({ text: z.string() }).passthrough(),
created_at: z.string(),
steps: z.array(StepSchema),
});
export type HistoryMessage = z.infer<typeof HistoryMessageSchema>;
export function fetchSessions(clawId: string): Promise<Session[]> {
return apiFetch(
z.array(SessionSchema),
`/api/sessions?clawId=${encodeURIComponent(clawId)}`,
);
}
export function createSession(clawId: string, title = ""): Promise<Session> {
return apiFetch(SessionSchema, "/api/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clawId, title }),
});
}
export function fetchHistory(sessionKey: string): Promise<HistoryMessage[]> {
return apiFetch(
z.array(HistoryMessageSchema),
`/api/sessions/history?sessionKey=${encodeURIComponent(sessionKey)}&tools=true`,
);
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { relativeTime } from "./relative-time";
const now = new Date("2026-06-09T12:00:00Z");
describe("relativeTime", () => {
it.each([
["2026-06-09T11:59:40Z", "just now"],
["2026-06-09T11:55:00Z", "5m ago"],
["2026-06-09T08:00:00Z", "4h ago"],
["2026-06-08T11:00:00Z", "1d ago"],
["2026-06-01T12:00:00Z", "8d ago"],
])("%s -> %s", (iso, expected) => {
expect(relativeTime(iso, now)).toBe(expected);
});
it("clamps future timestamps to just now", () => {
expect(relativeTime("2026-06-09T12:05:00Z", now)).toBe("just now");
});
});
+18
View File
@@ -0,0 +1,18 @@
/** Compact relative timestamps for session rows (§6): "4h ago". */
export function relativeTime(iso: string, now: Date = new Date()): string {
const then = new Date(iso).getTime();
const seconds = Math.max(0, Math.floor((now.getTime() - then) / 1000));
if (seconds < 60) {
return "just now";
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
+34
View File
@@ -0,0 +1,34 @@
// Gateway event vocabulary — mirrors tc-runtime's RunEventBody (the Rust
// side is the source of truth; the fixtures keep both honest).
import { z } from "zod";
export const GatewayEventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("run_started"), run_id: z.string() }),
z.object({ type: z.literal("text_delta"), delta: z.string() }),
z.object({
type: z.literal("step_started"),
step_seq: z.number(),
tool: z.string(),
input: z.unknown(),
}),
z.object({
type: z.literal("step_finished"),
step_seq: z.number(),
status: z.enum(["ok", "error"]),
output: z.unknown(),
}),
z.object({ type: z.literal("run_completed"), message_id: z.string() }),
z.object({ type: z.literal("error"), message: z.string() }),
]);
export type GatewayEvent = z.infer<typeof GatewayEventSchema>;
export interface GatewayEnvelope {
seq: number;
event: GatewayEvent;
}
/** Parses one SSE frame's data into a typed envelope. */
export function parseEnvelope(id: string, data: string): GatewayEnvelope {
return { seq: Number(id), event: GatewayEventSchema.parse(JSON.parse(data)) };
}
@@ -0,0 +1,88 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createSseParser, type SseEvent } from "./stream-parser";
const textFixture = readFileSync(
join(__dirname, "../../../tests/fixtures/gateway-text.sse"),
);
const toolFixture = readFileSync(
join(__dirname, "../../../tests/fixtures/gateway-tool.sse"),
);
/** Feeds a buffer through the parser in slices of the given size. */
function parseInChunks(buffer: Buffer, chunkSize: number): SseEvent[] {
const parser = createSseParser();
const events: SseEvent[] = [];
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
const chunk = new Uint8Array(buffer.subarray(offset, offset + chunkSize));
events.push(...parser.push(chunk));
}
events.push(...parser.flush());
return events;
}
describe("createSseParser on real gateway bytes", () => {
it("parses the text-run fixture into the full event sequence", () => {
const events = parseInChunks(textFixture, textFixture.length);
expect(events[0].event).toBe("run_started");
expect(events.at(-1)!.event).toBe("run_completed");
const text = events
.filter((e) => e.event === "text_delta")
.map((e) => (JSON.parse(e.data) as { delta: string }).delta)
.join("");
expect(text).toBe("I received: hello fixture");
// Ids are present and monotonic.
const ids = events.map((e) => Number(e.id));
expect(ids).toEqual([...ids].sort((a, b) => a - b));
});
it("parses the tool-run fixture including step events", () => {
const events = parseInChunks(toolFixture, toolFixture.length);
const names = events.map((e) => e.event);
expect(names).toContain("step_started");
expect(names).toContain("step_finished");
const finished = events.find((e) => e.event === "step_finished")!;
expect(JSON.parse(finished.data).status).toBe("ok");
});
it.each([1, 2, 3, 7, 16])(
"is byte-chunking independent (chunk size %i)",
(size) => {
const whole = parseInChunks(textFixture, textFixture.length);
const chunked = parseInChunks(textFixture, size);
expect(chunked).toEqual(whole);
},
);
it("ignores keep-alive comment lines", () => {
const withHeartbeat = Buffer.concat([
Buffer.from(": hb\n\n"),
textFixture,
Buffer.from("\n: hb\n\n"),
]);
const events = parseInChunks(withHeartbeat, 5);
expect(events).toEqual(parseInChunks(textFixture, textFixture.length));
});
it("handles CRLF line endings", () => {
const crlf = Buffer.from(
"id: 1\r\nevent: text_delta\r\ndata: {\"delta\":\"x\"}\r\n\r\n",
);
const events = parseInChunks(crlf, crlf.length);
expect(events).toEqual([
{ id: "1", event: "text_delta", data: '{"delta":"x"}' },
]);
});
it("does not split multi-byte UTF-8 characters across chunks", () => {
const emoji = Buffer.from(
'id: 1\nevent: text_delta\ndata: {"delta":"héllo 🦀"}\n\n',
);
for (const size of [1, 2, 3]) {
const events = parseInChunks(emoji, size);
expect(JSON.parse(events[0].data).delta).toBe("héllo 🦀");
}
});
});
+110
View File
@@ -0,0 +1,110 @@
// Incremental SSE frame parser for the gateway stream. Pure and
// chunking-independent: bytes may arrive split anywhere (including inside
// multi-byte UTF-8 sequences); events come out identical.
export interface SseEvent {
id: string;
event: string;
data: string;
}
export interface SseParser {
/** Feed raw bytes; returns any events completed by this chunk. */
push(chunk: Uint8Array): SseEvent[];
/** Signal end of stream; returns a final event if one was unterminated. */
flush(): SseEvent[];
}
export function createSseParser(): SseParser {
// stream:true buffers incomplete UTF-8 sequences between calls.
const decoder = new TextDecoder("utf-8");
let buffer = "";
let id = "";
let event = "";
let dataLines: string[] = [];
function takeEvent(): SseEvent | null {
if (dataLines.length === 0) {
// Comment-only or empty frame (keep-alives): reset and skip.
id = "";
event = "";
return null;
}
const complete: SseEvent = {
id,
event: event || "message",
data: dataLines.join("\n"),
};
id = "";
event = "";
dataLines = [];
return complete;
}
function consumeLine(line: string): SseEvent | null {
if (line === "") {
return takeEvent();
}
if (line.startsWith(":")) {
return null; // comment / heartbeat
}
const colon = line.indexOf(":");
const field = colon === -1 ? line : line.slice(0, colon);
let value = colon === -1 ? "" : line.slice(colon + 1);
if (value.startsWith(" ")) {
value = value.slice(1);
}
switch (field) {
case "id":
id = value;
break;
case "event":
event = value;
break;
case "data":
dataLines.push(value);
break;
default:
break; // unknown fields are ignored per the SSE spec
}
return null;
}
function drain(): SseEvent[] {
const events: SseEvent[] = [];
let newline = buffer.indexOf("\n");
while (newline !== -1) {
let line = buffer.slice(0, newline);
if (line.endsWith("\r")) {
line = line.slice(0, -1);
}
buffer = buffer.slice(newline + 1);
const completed = consumeLine(line);
if (completed) {
events.push(completed);
}
newline = buffer.indexOf("\n");
}
return events;
}
return {
push(chunk: Uint8Array): SseEvent[] {
buffer += decoder.decode(chunk, { stream: true });
return drain();
},
flush(): SseEvent[] {
buffer += decoder.decode();
const events = drain();
if (buffer !== "") {
consumeLine(buffer);
buffer = "";
}
const last = takeEvent();
if (last) {
events.push(last);
}
return events;
},
};
}
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import type { GatewayEnvelope } from "./events";
import {
initialTranscript,
transcriptReducer,
type TranscriptState,
} from "./transcript";
function apply(
state: TranscriptState,
...envelopes: GatewayEnvelope[]
): TranscriptState {
return envelopes.reduce(
(acc, envelope) => transcriptReducer(acc, { kind: "gateway", envelope }),
state,
);
}
const history = [
{
id: "m1",
role: "user" as const,
text: "earlier question",
steps: [],
status: "complete" as const,
},
{
id: "m2",
role: "agent" as const,
text: "earlier answer",
steps: [],
status: "complete" as const,
},
];
describe("transcriptReducer", () => {
it("loads history", () => {
const state = transcriptReducer(initialTranscript(), {
kind: "history",
messages: history,
});
expect(state.messages).toHaveLength(2);
expect(state.streaming).toBe(false);
});
it("send appends an optimistic user message and a pending reply", () => {
let state = transcriptReducer(initialTranscript(), {
kind: "history",
messages: history,
});
state = transcriptReducer(state, { kind: "send", text: "new question" });
expect(state.messages).toHaveLength(4);
expect(state.messages[2]).toMatchObject({
role: "user",
text: "new question",
status: "sending",
});
expect(state.messages[3]).toMatchObject({ role: "agent", status: "streaming" });
expect(state.streaming).toBe(true);
});
it("streams text deltas into the pending reply and completes", () => {
let state = transcriptReducer(initialTranscript(), {
kind: "send",
text: "hi",
});
state = apply(
state,
{ seq: 1, event: { type: "run_started", run_id: "r1" } },
{ seq: 2, event: { type: "text_delta", delta: "Hel" } },
{ seq: 3, event: { type: "text_delta", delta: "lo" } },
{ seq: 4, event: { type: "run_completed", message_id: "m9" } },
);
const reply = state.messages.at(-1)!;
expect(reply.text).toBe("Hello");
expect(reply.status).toBe("complete");
expect(reply.id).toBe("m9");
// The optimistic user message is confirmed once the run starts.
expect(state.messages.at(-2)!.status).toBe("complete");
expect(state.streaming).toBe(false);
});
it("collects step events into the reply trace", () => {
let state = transcriptReducer(initialTranscript(), {
kind: "send",
text: "time?",
});
state = apply(
state,
{ seq: 1, event: { type: "run_started", run_id: "r1" } },
{
seq: 2,
event: { type: "step_started", step_seq: 1, tool: "clock.now", input: {} },
},
{
seq: 3,
event: {
type: "step_finished",
step_seq: 1,
status: "ok",
output: { now: "12:00" },
},
},
{ seq: 4, event: { type: "run_completed", message_id: "m9" } },
);
const reply = state.messages.at(-1)!;
expect(reply.steps).toHaveLength(1);
expect(reply.steps[0]).toMatchObject({
seq: 1,
tool: "clock.now",
status: "ok",
});
});
it("duplicate sequence numbers are ignored (resume overlap)", () => {
let state = transcriptReducer(initialTranscript(), {
kind: "send",
text: "hi",
});
const delta: GatewayEnvelope = {
seq: 2,
event: { type: "text_delta", delta: "x" },
};
state = apply(
state,
{ seq: 1, event: { type: "run_started", run_id: "r1" } },
delta,
delta,
);
expect(state.messages.at(-1)!.text).toBe("x");
});
it("stream errors mark the reply failed", () => {
let state = transcriptReducer(initialTranscript(), {
kind: "send",
text: "hi",
});
state = apply(state, {
seq: 1,
event: { type: "error", message: "llm unreachable" },
});
expect(state.messages.at(-1)!.status).toBe("error");
expect(state.streaming).toBe(false);
});
});
+154
View File
@@ -0,0 +1,154 @@
// Pure transcript state machine: history + optimistic sends + streamed
// gateway events. Owning this as a reducer keeps streaming logic fully
// unit-testable; durable truth remains /api/sessions/history.
import type { GatewayEnvelope } from "./events";
export interface UiStep {
seq: number;
tool: string;
input: unknown;
output: unknown;
status: "running" | "ok" | "error";
}
export interface UiMessage {
id: string;
role: "user" | "agent";
text: string;
steps: UiStep[];
status: "sending" | "streaming" | "complete" | "error";
}
export interface TranscriptState {
messages: UiMessage[];
streaming: boolean;
/** Highest gateway seq applied — dedupes resume overlap. */
lastSeq: number;
}
export type TranscriptAction =
| { kind: "history"; messages: UiMessage[] }
| { kind: "send"; text: string }
| { kind: "gateway"; envelope: GatewayEnvelope }
| { kind: "transport_error"; message: string };
export function initialTranscript(): TranscriptState {
return { messages: [], streaming: false, lastSeq: 0 };
}
let optimisticCounter = 0;
function updateReply(
state: TranscriptState,
update: (reply: UiMessage) => UiMessage,
): TranscriptState {
const index = state.messages.findLastIndex((m) => m.role === "agent");
if (index === -1) {
return state;
}
const messages = [...state.messages];
messages[index] = update(messages[index]);
return { ...state, messages };
}
function failPending(state: TranscriptState): TranscriptState {
return {
...updateReply(state, (reply) =>
reply.status === "complete" ? reply : { ...reply, status: "error" },
),
streaming: false,
};
}
export function transcriptReducer(
state: TranscriptState,
action: TranscriptAction,
): TranscriptState {
switch (action.kind) {
case "history":
return { messages: action.messages, streaming: false, lastSeq: 0 };
case "send": {
optimisticCounter += 1;
const user: UiMessage = {
id: `optimistic-user-${optimisticCounter}`,
role: "user",
text: action.text,
steps: [],
status: "sending",
};
const reply: UiMessage = {
id: `optimistic-reply-${optimisticCounter}`,
role: "agent",
text: "",
steps: [],
status: "streaming",
};
return {
messages: [...state.messages, user, reply],
streaming: true,
lastSeq: 0,
};
}
case "transport_error":
return failPending(state);
case "gateway": {
const { seq, event } = action.envelope;
if (seq <= state.lastSeq) {
return state; // resume overlap
}
const next = { ...state, lastSeq: seq };
switch (event.type) {
case "run_started": {
// Confirm the optimistic user message: the run is persisted.
const messages = next.messages.map((m) =>
m.status === "sending" ? { ...m, status: "complete" as const } : m,
);
return { ...next, messages };
}
case "text_delta":
return updateReply(next, (reply) => ({
...reply,
text: reply.text + event.delta,
}));
case "step_started":
return updateReply(next, (reply) => ({
...reply,
steps: [
...reply.steps,
{
seq: event.step_seq,
tool: event.tool,
input: event.input,
output: null,
status: "running",
},
],
}));
case "step_finished":
return updateReply(next, (reply) => ({
...reply,
steps: reply.steps.map((step) =>
step.seq === event.step_seq
? { ...step, status: event.status, output: event.output }
: step,
),
}));
case "run_completed":
return {
...updateReply(next, (reply) => ({
...reply,
id: event.message_id,
status: "complete",
})),
streaming: false,
};
case "error":
return failPending(next);
}
}
}
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
// Client chat engine: optimistic sends through the same-origin gateway
// proxy, incremental SSE parsing, reducer-owned streaming state.
import { useCallback, useReducer, useRef } from "react";
import { parseEnvelope } from "./events";
import { createSseParser } from "./stream-parser";
import {
initialTranscript,
transcriptReducer,
type TranscriptState,
type UiMessage,
} from "./transcript";
export interface ChatHandle {
state: TranscriptState;
send: (text: string) => Promise<void>;
}
export function useChat(
clawId: string,
sessionKey: string,
initialMessages: UiMessage[],
): ChatHandle {
const [state, dispatch] = useReducer(
transcriptReducer,
undefined,
(): TranscriptState => ({
...initialTranscript(),
messages: initialMessages,
}),
);
const abortRef = useRef<AbortController | null>(null);
const send = useCallback(
async (text: string) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
dispatch({ kind: "send", text });
try {
const response = await fetch(
`/api/gateway?clawId=${encodeURIComponent(clawId)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionKey, message: text }),
signal: controller.signal,
},
);
if (!response.ok || !response.body) {
dispatch({
kind: "transport_error",
message: `gateway returned ${response.status}`,
});
return;
}
const parser = createSseParser();
const reader = response.body.getReader();
for (;;) {
const { done, value } = await reader.read();
const frames = done ? parser.flush() : parser.push(value);
for (const frame of frames) {
dispatch({
kind: "gateway",
envelope: parseEnvelope(frame.id, frame.data),
});
}
if (done) {
break;
}
}
} catch (error) {
if (!controller.signal.aborted) {
dispatch({ kind: "transport_error", message: String(error) });
}
}
},
[clawId, sessionKey],
);
return { state, send };
}
+133
View File
@@ -0,0 +1,133 @@
import { expect, test, type Page } from "@playwright/test";
// P1 exit criterion (spec §17): a multi-session conversation with
// replayable step traces, end-to-end against the real backend running the
// scripted provider (deploy/e2e/scenarios.toml).
const OWNER_EMAIL = "[email protected]";
const OWNER_PASSWORD = "e2e-password";
async function signIn(page: Page) {
await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "TeamClaw" })).toBeVisible();
}
async function openScout(page: Page) {
await page.getByRole("link", { name: /Scout/ }).click();
await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
}
async function sendMessage(page: Page, text: string) {
const box = page.getByLabel("Message Scout");
await box.fill(text);
await box.press("Enter");
}
test("welcome state appears for a fresh session", async ({ page }) => {
await signIn(page);
await openScout(page);
await page.getByRole("button", { name: "New" }).click();
await expect(
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Create a daily briefing" }),
).toBeVisible();
});
test("a message streams back a scripted reply", async ({ page }) => {
await signIn(page);
await openScout(page);
await sendMessage(page, "hello [[scenario:hello]]");
await expect(page.getByText("hello [[scenario:hello]]")).toBeVisible();
await expect(
page.getByText("Hello! I'm Scout, your research analyst."),
).toBeVisible();
});
test("tool runs render a step trace that survives reload", async ({ page }) => {
await signIn(page);
await openScout(page);
await page.getByRole("button", { name: "New" }).click();
await expect(
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
).toBeVisible();
await sendMessage(page, "what time is it? [[scenario:tool-time]]");
await expect(page.getByText(/I checked the current time/)).toBeVisible();
const trace = page.getByRole("button", { name: /1 step/ });
await expect(trace).toBeVisible();
await trace.click();
await expect(page.getByText(/clock\.now/)).toBeVisible();
// Reload: history replays the identical transcript and trace (§13).
await page.reload();
await expect(page.getByText(/I checked the current time/)).toBeVisible();
const replayedTrace = page.getByRole("button", { name: /1 step/ });
await replayedTrace.click();
await expect(page.getByText(/clock\.now/)).toBeVisible();
});
test("multiple sessions hold separate transcripts", async ({ page }) => {
await signIn(page);
await openScout(page);
// First session.
await page.getByRole("button", { name: "New" }).click();
await expect(
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
).toBeVisible();
await sendMessage(page, "first session message");
await expect(page.getByText("I received: first session message")).toBeVisible();
// Second session.
await page.getByRole("button", { name: "New" }).click();
await expect(
page.getByRole("heading", { name: /Hi, I'm Scout/ }),
).toBeVisible();
await sendMessage(page, "second session message");
await expect(
page.getByText("I received: second session message"),
).toBeVisible();
await expect(page.getByText("first session message")).not.toBeVisible();
// Switch back via the sessions column (?sessions=1, §6). Rows sort most
// recently active first: [current, first-session, original]; the first
// non-active row is the session we just left.
await page.getByRole("button", { name: "Sessions" }).click();
const column = page.getByRole("complementary", { name: "Sessions" });
await expect(column.getByRole("listitem").first()).toBeVisible();
await column
.locator("button:not([aria-current])")
.filter({ hasText: "Untitled" })
.first()
.click();
await expect(page.getByText("I received: first session message")).toBeVisible();
});
test("creating a claw from the rail goes straight to its chat", async ({
page,
}) => {
await signIn(page);
await page.getByRole("link", { name: "New claw" }).click();
await page.getByLabel("Name *").fill("Drafter");
await page.getByLabel("Job title *").fill("Writer");
await page
.getByLabel(/Job description/)
.fill("You draft crisp documents.");
await page.getByRole("button", { name: "Create claw" }).click();
await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
await expect(
page.getByRole("heading", { name: /Hi, I'm Drafter/ }),
).toBeVisible();
const box = page.getByLabel("Message Drafter");
await box.fill("ping");
await box.press("Enter");
await expect(page.getByText("I received: ping")).toBeVisible();
});
+24
View File
@@ -0,0 +1,24 @@
id: 1
event: run_started
data: {"run_id":"019eafbf-36e7-7e52-b99f-d9aca31ca99a","type":"run_started"}
id: 2
event: text_delta
data: {"delta":"I","type":"text_delta"}
id: 3
event: text_delta
data: {"delta":" received:","type":"text_delta"}
id: 4
event: text_delta
data: {"delta":" hello","type":"text_delta"}
id: 5
event: text_delta
data: {"delta":" fixture","type":"text_delta"}
id: 6
event: run_completed
data: {"message_id":"019eafbf-36ed-7462-8d3a-5493f5cfb1cb","type":"run_completed"}
+72
View File
@@ -0,0 +1,72 @@
id: 1
event: run_started
data: {"run_id":"019eafbf-3702-7010-aee0-ea30279449ad","type":"run_started"}
id: 2
event: text_delta
data: {"delta":"Let","type":"text_delta"}
id: 3
event: text_delta
data: {"delta":" me","type":"text_delta"}
id: 4
event: text_delta
data: {"delta":" check","type":"text_delta"}
id: 5
event: text_delta
data: {"delta":" the","type":"text_delta"}
id: 6
event: text_delta
data: {"delta":" clock.","type":"text_delta"}
id: 7
event: step_started
data: {"input":{},"step_seq":1,"tool":"clock.now","type":"step_started"}
id: 8
event: step_finished
data: {"output":{"now":"2026-06-10T04:16:44.81021Z"},"status":"ok","step_seq":1,"type":"step_finished"}
id: 9
event: text_delta
data: {"delta":"Done","type":"text_delta"}
id: 10
event: text_delta
data: {"delta":" —","type":"text_delta"}
id: 11
event: text_delta
data: {"delta":" I","type":"text_delta"}
id: 12
event: text_delta
data: {"delta":" checked","type":"text_delta"}
id: 13
event: text_delta
data: {"delta":" the","type":"text_delta"}
id: 14
event: text_delta
data: {"delta":" current","type":"text_delta"}
id: 15
event: text_delta
data: {"delta":" time","type":"text_delta"}
id: 16
event: text_delta
data: {"delta":" for","type":"text_delta"}
id: 17
event: text_delta
data: {"delta":" you.","type":"text_delta"}
id: 18
event: run_completed
data: {"message_id":"019eafbf-3704-7c71-ad26-bbed5f071da5","type":"run_completed"}