feat: the sessionless draft view — "+ New session" creates nothing

The sidebar button no longer POSTs a record (no more "Untitled session"
litter): it opens /chat/new, a sessionless draft view visually identical
to a fresh chat (same header + probe chip, the same ConversationEmptyState,
the same composer blob + stats dock). The session comes to exist only
when the learner sends the first message — and that message becomes its
title (startDraftSession: POST /api/chats {title: msg} → PUT the lifted
pre-chat settings → redirect /chat/<id>?prompt=<msg>, whose initialPrompt
path delivers the message as turn 1 — the home composer's proven contract).

- app/chat/new/page.tsx: the static draft route (static beats [id]).
- components/new-session.tsx: DraftShell (pure render seam) + NewSession
  (router wiring); the model picker's empty-chatId branch defers its save
  and lifts the selection, so pre-chat model/thinking rides the creation.
- components/sidebar.tsx: newSession is a plain router.push("/chat/new").
- tests/render-new-session.test.tsx: 6 pins — the create-on-send contract
  (title = message, settings PUT, no-op on empty text) + the shell render.

Live-verified end to end: button → draft (0 creations), pre-chat Qwen
pick, send → exactly one session titled with the message, settings
persisted, redirect + turn 1 running. Gates 431/431.
This commit is contained in:
amosblomqvist
2026-09-14 09:06:48 +03:00
parent 7f001cdb46
commit 0f6e36f85f
4 changed files with 271 additions and 7 deletions
+14
View File
@@ -0,0 +1,14 @@
import { NewSession } from "@/components/new-session";
export const dynamic = "force-dynamic";
/**
* The draft view behind "+ New session" (owner, 2026-09-13): NO session is
* created until the first message is sent — the view is a sessionless empty
* chat; on send it creates the chat (title = that message) and redirects to
* the real page, whose initialPrompt path delivers the message as turn 1.
* Static segment beats the dynamic [id] route — "new" is never a chat id.
*/
export default function NewSessionPage() {
return <NewSession />;
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Composer } from "@/components/composer";
import { PhaseChip } from "@/components/phase-divider";
import { SidebarToggle } from "@/components/sidebar";
import { StatsBar } from "@/components/stats-bar";
import type { ChatSettings } from "@/src/core";
/**
* The sessionless draft view at /chat/new (owner, 2026-09-13): "+ New
* session" opens THIS, never a created record — a session comes to exist
* only when the learner sends the first message, and that message becomes
* the session's title (the exact create-then-redirect contract the home
* composer established). Visually identical to a fresh chat: same header
* (title + probe chip), the same empty state, the same composer blob +
* stats dock. The model picker's empty-chatId branch defers its save and
* lifts the selection here, so the pre-chat model/thinking choice rides
* the creation.
*/
const DOCK = "mx-auto w-full max-w-[46rem] px-4";
/**
* The draft's whole behavior as one pure seam: create the session (title =
* the first message), persist the lifted pre-chat settings, return the push
* target — /chat/<id>?prompt=<msg> — whose initialPrompt path delivers the
* message as turn 1. Injected fetch/router so tests pin the contract.
* Returns null on empty text; rethrows on transport failure (the caller's
* busy flag resets).
*/
export async function startDraftSession(
text: string,
settings: ChatSettings | undefined,
fetchLike: typeof fetch,
): Promise<string | null> {
const t = text.trim();
if (!t) return null;
const res = await fetchLike("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: t }),
});
const { id } = (await res.json()) as { id: string };
if (settings) {
// Best-effort persist — the selection was made pre-chat.
await fetchLike(`/api/chats/${id}/settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
}).catch(() => {});
}
return `/chat/${id}?prompt=${encodeURIComponent(t)}`;
}
/** The pure shell (no router) — the render-test seam: the draft view's
* visible surface, assembled from the same pieces a fresh chat shows. */
export function DraftShell({
busy,
text,
onChange,
onSend,
settings,
onSettingsSaved,
}: {
busy: boolean;
text: string;
onChange: (v: string) => void;
onSend: () => void;
settings: ChatSettings | undefined;
onSettingsSaved: (s: ChatSettings) => void;
}) {
const canSend = text.trim().length > 0;
return (
<div className="flex h-dvh">
<div className="flex min-w-0 flex-1 flex-col">
<header className="relative flex h-10 items-center gap-3 px-3">
<SidebarToggle />
<div className="min-w-0 flex-1 truncate font-title text-2xl tracking-tight">New session</div>
<PhaseChip phase="probe" />
</header>
<div className="relative flex min-h-0 flex-1">
{/* The pointer-events pattern (953b22b): the sticky wrapper is
pointer-events-none so nothing here blocks the empty-state
area; the blob and the stats row re-enable it themselves. */}
<div className="pointer-events-none sticky bottom-0 mt-auto w-full">
<Conversation className="h-full min-w-0 flex-1" initial="instant">
<ConversationContent className="min-h-full gap-0 p-0">
<ConversationEmptyState
title="New session"
description="The teacher will open by probing what you already know."
/>
</ConversationContent>
</Conversation>
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
<div className="bg-background pb-4">
<div className={DOCK}>
<div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<Composer
busy={busy}
value={text}
onChange={onChange}
onSend={onSend}
onStop={() => {}}
canSend={canSend}
autoFocus
/>
</div>
<StatsBar turns={[]} chatId="" settings={settings} onSettingsSaved={onSettingsSaved} />
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export function NewSession() {
const router = useRouter();
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
// The pre-chat model+thinking selection, lifted from the StatsBar's
// ModelControl (empty chatId → it never PUTs; it hands the choice up).
const [settings, setSettings] = useState<ChatSettings | undefined>(undefined);
const send = useCallback(() => {
if (!text.trim() || busy) return;
setBusy(true);
void startDraftSession(text, settings, fetch)
.then((target) => {
if (target) router.push(target);
else setBusy(false);
})
.catch(() => setBusy(false));
}, [text, busy, settings, router]);
return (
<DraftShell
busy={busy}
text={text}
onChange={setText}
onSend={send}
settings={settings}
onSettingsSaved={setSettings}
/>
);
}
+6 -7
View File
@@ -186,13 +186,12 @@ function SessionsSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
/* New session: one POST, then hello. Search: a client-side filter over /* New session (owner, 2026-09-13): NO POST — the button opens the
the poll snapshot — no server round trip. Both were visual-only in sessionless draft view; the session is created only when the first
the wave-5 mock; wiring each is a single tiny handler. */ message is sent (and that message becomes its title). Search: a
const newSession = useCallback(async () => { client-side filter over the poll snapshot — no server round trip. */
const res = await fetch("/api/chats", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }); const newSession = useCallback(() => {
const d = (await res.json().catch(() => null)) as { id?: string } | null; router.push("/chat/new");
if (d?.id) router.push(`/chat/${d.id}`);
}, [router]); }, [router]);
const shown = useMemo(() => { const shown = useMemo(() => {
+100
View File
@@ -0,0 +1,100 @@
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import { renderToStaticMarkup } from "react-dom/server";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { DraftShell, startDraftSession } from "../components/new-session.tsx";
import type { ChatSettings } from "../src/core/index.ts";
/**
* The sessionless draft view (/chat/new, owner 2026-09-13): "+ New session"
* opens the draft — NOTHING is created until the first message is sent, and
* that message becomes the session's title. These pins hold the two seams:
* startDraftSession (the create-on-send contract) and DraftShell (the
* visible surface: composer blob + stats bar + the fresh-chat empty state).
*/
/** A fetch double: records calls, answers /api/chats with a canned id. */
function fakeFetch(id: string) {
const calls: { url: string; method: string; body: unknown }[] = [];
const f = (async (url: string, init?: RequestInit) => {
calls.push({ url: String(url), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : null });
return new Response(JSON.stringify({ id }), { status: 200 });
}) as typeof fetch;
return { f, calls };
}
describe("startDraftSession — the create-on-send contract", () => {
it("creates the session titled with the first message, returns the prompt redirect", async () => {
const { f, calls } = fakeFetch("c_new");
const target = await startDraftSession("teach me orbital mechanics", undefined, f);
assert.equal(target, "/chat/c_new?prompt=teach%20me%20orbital%20mechanics");
assert.equal(calls.length, 1);
assert.equal(calls[0]!.url, "/api/chats");
assert.equal(calls[0]!.method, "POST");
assert.deepEqual(calls[0]!.body, { title: "teach me orbital mechanics" });
});
it("persists the lifted pre-chat settings after creation", async () => {
const { f, calls } = fakeFetch("c_2");
const settings: ChatSettings = { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "medium" };
await startDraftSession("hello", settings, f);
assert.equal(calls.length, 2);
assert.equal(calls[1]!.url, "/api/chats/c_2/settings");
assert.equal(calls[1]!.method, "PUT");
assert.deepEqual(calls[1]!.body, settings);
});
it("no settings lift → no settings write (a single create call)", async () => {
const { f, calls } = fakeFetch("c_3");
await startDraftSession("hello", undefined, f);
assert.equal(calls.length, 1);
});
it("empty or whitespace text never creates anything", async () => {
const { f, calls } = fakeFetch("never");
assert.equal(await startDraftSession("", undefined, f), null);
assert.equal(await startDraftSession(" ", undefined, f), null);
assert.equal(calls.length, 0);
});
});
describe("DraftShell — the sessionless surface", () => {
it("renders the composer blob, the stats bar, and the fresh-chat empty state", () => {
const html = renderToStaticMarkup(
<DraftShell
busy={false}
text=""
onChange={() => {}}
onSend={() => {}}
settings={undefined}
onSettingsSaved={() => {}}
/>,
);
// The chat composer, ready for the first message.
assert.ok(html.includes('data-testid="composer"'));
// The pre-chat model control (its empty-chatId defer branch lifts here).
assert.ok(html.includes('data-testid="stats-model"'));
// The same empty state a fresh chat shows.
assert.ok(html.includes("The teacher will open by probing what you already know."));
// The pointer-events pattern (953b22b): the wrapper stays inert…
assert.ok(html.includes("pointer-events-none"));
// …and the blob re-enables it.
assert.ok(/rounded-2xl[^>]*pointer-events-auto|pointer-events-auto[^>]*rounded-2xl/.test(html));
});
it("no chat id anywhere — nothing in the markup references a session", () => {
const html = renderToStaticMarkup(
<DraftShell
busy={false}
text=""
onChange={() => {}}
onSend={() => {}}
settings={undefined}
onSettingsSaved={() => {}}
/>,
);
assert.ok(!/\/chat\/c[a-z0-9_]+/.test(html));
});
});