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:
co-authored by
Claude Fable 5
parent
32008c9ef0
commit
9f9f507c15
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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 🦀");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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"}
|
||||||
|
|
||||||
Reference in New Issue
Block a user