Files
learnharness/tests/render-new-session.test.tsx
T
amosblomqvist 0f6e36f85f 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.
2026-09-14 09:06:48 +03:00

100 lines
4.0 KiB
TypeScript

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));
});
});