diff --git a/app/chat/new/page.tsx b/app/chat/new/page.tsx new file mode 100644 index 0000000..cd0f0b4 --- /dev/null +++ b/app/chat/new/page.tsx @@ -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 ; +} \ No newline at end of file diff --git a/components/new-session.tsx b/components/new-session.tsx new file mode 100644 index 0000000..f5faf00 --- /dev/null +++ b/components/new-session.tsx @@ -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/?prompt= — 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 { + 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 ( +
+
+
+ +
New session
+ +
+ +
+ {/* 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. */} +
+ + + + + +
+
+
+
+ {}} + canSend={canSend} + autoFocus + /> +
+ +
+
+
+
+
+
+ ); +} + +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(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 ( + + ); +} \ No newline at end of file diff --git a/components/sidebar.tsx b/components/sidebar.tsx index d2913d5..3f7f033 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -186,13 +186,12 @@ function SessionsSidebar() { const pathname = usePathname(); const router = useRouter(); - /* New session: one POST, then hello. Search: a client-side filter over - the poll snapshot — no server round trip. Both were visual-only in - the wave-5 mock; wiring each is a single tiny handler. */ - const newSession = useCallback(async () => { - const res = await fetch("/api/chats", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }); - const d = (await res.json().catch(() => null)) as { id?: string } | null; - if (d?.id) router.push(`/chat/${d.id}`); + /* New session (owner, 2026-09-13): NO POST — the button opens the + sessionless draft view; the session is created only when the first + message is sent (and that message becomes its title). Search: a + client-side filter over the poll snapshot — no server round trip. */ + const newSession = useCallback(() => { + router.push("/chat/new"); }, [router]); const shown = useMemo(() => { diff --git a/tests/render-new-session.test.tsx b/tests/render-new-session.test.tsx new file mode 100644 index 0000000..a8675cf --- /dev/null +++ b/tests/render-new-session.test.tsx @@ -0,0 +1,100 @@ +import React from "react"; +(globalThis as Record).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( + {}} + 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( + {}} + onSend={() => {}} + settings={undefined} + onSettingsSaved={() => {}} + />, + ); + assert.ok(!/\/chat\/c[a-z0-9_]+/.test(html)); + }); +}); \ No newline at end of file