From 472844e6914742dcaa2ed5601f337efba56560b9 Mon Sep 17 00:00:00 2001 From: ferponse Date: Mon, 26 Oct 2026 00:45:16 +0100 Subject: [PATCH] fix: keep the opening chat text out of the URL A long first message in ?prompt= exceeds Node's header limit. The chat GET returns 431 and Electron commits an empty window. Stash the text in sessionStorage and open /chat/. --- components/chat.tsx | 22 ++++++++++++++--- components/home.tsx | 6 ++++- components/new-session.tsx | 16 +++++++----- components/opening-prompt.ts | 41 +++++++++++++++++++++++++++++++ components/session-core.ts | 19 +++++++++----- components/session.ts | 2 +- tests/render-new-session.test.tsx | 38 +++++++++++++++++++++++++--- 7 files changed, 123 insertions(+), 21 deletions(-) create mode 100644 components/opening-prompt.ts diff --git a/components/chat.tsx b/components/chat.tsx index 4986f3f..a42f023 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -26,6 +26,7 @@ import { Composer } from "@/components/composer"; import { useEnterSubmits } from "@/components/enter-submits"; import { blurAfterPointerClick } from "@/components/focus-hygiene"; import { dockStateKey } from "@/components/dock-state-key"; +import { clearOpeningPrompt, readOpeningPrompt } from "@/components/opening-prompt"; import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers"; import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation"; import { useFieldCycle } from "@/components/field-cycle"; @@ -248,13 +249,25 @@ export function Chat({ // New chat with a topic: send the first message exactly once // (initialPromptOwed — the pure gate, pinned in the test suite). + // The topic arrives as the server's ?prompt= (old links) or from the + // sessionStorage stash the composer wrote before navigating here. + // The stash stays until send resolves, so a reload before the POST + // lands can retry. sentInitial blocks a second send on this mount. const sentInitial = useRef(false); useEffect(() => { - if (!sentInitial.current && initialPromptOwed(initialMessages, initialPrompt)) { + const prompt = initialPrompt || readOpeningPrompt(chatId); + if (!sentInitial.current && prompt && initialPromptOwed(initialMessages, prompt)) { sentInitial.current = true; - void send({ type: "text", text: initialPrompt! }); + void send({ type: "text", text: prompt }).then( + (landed) => { + if (landed) clearOpeningPrompt(chatId); + }, + () => { + /* leave the stash: the next mount retries */ + }, + ); } - }, [initialPrompt, initialMessages, send]); + }, [chatId, initialPrompt, initialMessages, send]); // A turn running server-side, attached to no live view of ours (navigate // away mid-turn, or a wake turn discovered mid-session): the reattach @@ -1752,7 +1765,8 @@ export function Chat({ * 2026-10-02 abort chain proper (question before turn). */ export type EscAbortTarget = "mermaid" | "plan" | "setup" | "question" | "problem" | "turn" | null; /** - * Does the ?prompt topic still owe its first send? The gate is "no real + * Does the opening topic still owe its first send? The topic is the + * server's ?prompt= or the sessionStorage stash. The gate is "no real * conversation yet" (no user/assistant message) — NOT emptiness: a * session started with map picks carries a graph-context MARKER as * message[0], and that scaffolding must never swallow the first send diff --git a/components/home.tsx b/components/home.tsx index 4b16d62..bc3a025 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -12,6 +12,7 @@ import { DictationBar, joinDictation, useDictation } from "@/components/dictatio import { useFieldCycle } from "@/components/field-cycle"; import { SidebarToggle } from "@/components/sidebar"; import { StatsBar } from "@/components/stats-bar"; +import { stashOpeningPrompt } from "@/components/opening-prompt"; import type { TurnUsage } from "@/src/core"; /** @@ -150,7 +151,10 @@ export function Home({ // No settings PUT on create (per-session models, owner // 2026-09-28): the SERVER stamps the newborn from the strong // slot — the same default this surface's model control edits. - router.push(`/chat/${id}?prompt=${encodeURIComponent(t)}`); + // The opening text stays out of the URL (opening-prompt.ts): + // a long paste as ?prompt= is a 431 and a blank window. + stashOpeningPrompt(id, t); + router.push(`/chat/${id}`); } catch { setBusy(false); } diff --git a/components/new-session.tsx b/components/new-session.tsx index c2a0930..2d3cbbd 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -12,6 +12,7 @@ import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup- import { StatsBar } from "@/components/stats-bar"; import { ChainTableOverlay } from "@/components/chain-table.tsx"; import { ComposerActions, NodePickerControl } from "@/components/composer-actions"; +import { stashOpeningPrompt } from "@/components/opening-prompt"; import type { ChatSettings, GraphSnapshot } from "@/src/core"; /** The draft's pick set (owner round 10): the SAME shape the home pick @@ -34,11 +35,13 @@ 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). + * the first message), persist the lifted pre-chat settings, stash the + * opening text beside the navigation, and return the push target — + * /chat/. The chat page reads the stash and delivers it as turn 1. + * The text never rides the URL: a long paste exceeds Node's header limit + * and the window commits an empty document. Injected fetch 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, @@ -64,7 +67,8 @@ export async function startDraftSession( const { id } = (await res.json()) as { id: string }; // No settings PUT on create (per-session models, owner 2026-09-28): // the SERVER stamps the newborn from the strong slot. - return `/chat/${id}?prompt=${encodeURIComponent(t)}`; + stashOpeningPrompt(id, t); + return `/chat/${id}`; } /** The pure shell (no router) — the render-test seam: the draft view's diff --git a/components/opening-prompt.ts b/components/opening-prompt.ts new file mode 100644 index 0000000..fca3c66 --- /dev/null +++ b/components/opening-prompt.ts @@ -0,0 +1,41 @@ +/** + * The opening message rides beside the navigation, never in the URL. + * + * Home and the new-session draft used to push `/chat/?prompt=`. + * Node's default max HTTP header size is 16KB. A long paste (a syllabus) + * makes that GET return 431, and Electron commits an empty document: the + * window stays the native background and looks frozen. The chat record + * already exists, with an empty transcript, because POST /api/chats + * succeeded first. + * + * sessionStorage holds the trimmed text under `lh.openingPrompt.`. + * The push target stays `/chat/`. The chat page still honors a + * `?prompt=` search param for an old link; when that param is absent it + * reads this stash and sends it once. The stash survives until that send + * resolves, so a reload before the POST lands can retry. Absent storage + * (node tests, a thrown access) is a no-op — the short path still returns. + */ + +const keyFor = (id: string) => `lh.openingPrompt.${id}`; + +function store(): Storage | null { + try { + if (typeof sessionStorage === "undefined" || sessionStorage == null) return null; + return sessionStorage; + } catch { + return null; + } +} + +export function stashOpeningPrompt(id: string, text: string): void { + store()?.setItem(keyFor(id), text); +} + +export function readOpeningPrompt(id: string): string | undefined { + const value = store()?.getItem(keyFor(id)); + return value && value.length > 0 ? value : undefined; +} + +export function clearOpeningPrompt(id: string): void { + store()?.removeItem(keyFor(id)); +} diff --git a/components/session-core.ts b/components/session-core.ts index 657d0da..08ea64a 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -562,8 +562,8 @@ export class SessionModel { * POST + consume. Ready-gated: one turn per client at a time (the * server's 409 + identity retry covers the wake-race window). */ - async send(input: ChatInput): Promise { - if (!this.readyRef) return; + async send(input: ChatInput): Promise { + if (!this.readyRef) return false; this.consumeAbort?.abort(); const controller = new AbortController(); this.consumeAbort = controller; @@ -590,6 +590,7 @@ export class SessionModel { if (optimistic.length > 0) this.mutate(() => { this.tail = [...this.tail, ...optimistic]; }); + let accepted = false; try { // SUBMITS ARE NOT ABORTABLE BY VIEW TEARDOWN (owner, 2026-10-10 — // the dead-session-start postmortem): the POST must NOT ride @@ -607,7 +608,7 @@ export class SessionModel { if (res.status === 409) { this.setError("A turn is already running — try again in a moment."); this.setStatus("error"); - return; + return false; } if (!res.ok) { // Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body @@ -615,6 +616,7 @@ export class SessionModel { // page's refusal flow JSON-parses the envelope out of it. throw new Error((await res.text()) || `The send failed (${res.status}).`); } + accepted = true; // CONNECTION DIET B (owner, 2026-10-10 — parallel-session freeze // postmortem): the POST acknowledges and closes; there is no stream // on this response to consume. Attach through the SAME reattach wire @@ -626,12 +628,17 @@ export class SessionModel { // A stop() or a newer send/consume won while the ack was in // flight: the turn is live server-side either way — do not attach // a stream from a view that is gone or superseded. - if (this.consumeAbort !== controller) return; + if (this.consumeAbort !== controller) return true; await this.resumeStream(true); + return true; } catch (err) { - if ((err as Error).name === "AbortError") return; // stop() or a newer consume won + if ((err as Error).name === "AbortError") return accepted; // stop() or a newer consume won this.setError((err as Error).message); this.setStatus("error"); + // true only once the POST ack landed. A throw before that (the + // fetch, or !res.ok) leaves the message unsent — the caller keeps + // its opening-text stash and a reload can retry. + return accepted; } } @@ -681,7 +688,7 @@ export class SessionModel { * result (fork 1), riding as its input event. */ submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData): Promise { void tool; - return this.send({ type: "answer", toolCallId, answer: output }); + return this.send({ type: "answer", toolCallId, answer: output }).then(() => undefined); } /** The dismissal path (owner design 2026-10-03, "A2"): the skipped diff --git a/components/session.ts b/components/session.ts index e0652cf..22b21af 100644 --- a/components/session.ts +++ b/components/session.ts @@ -100,7 +100,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U [model], ); - const send = useCallback((input: ChatInput) => void model.send(input), [model]); + const send = useCallback((input: ChatInput) => model.send(input), [model]); const steer = useCallback((text: string) => void model.steer(text), [model]); const submitAnswer = useCallback( (tool: string, toolCallId: string, output: QuestionAnswerData) => diff --git a/tests/render-new-session.test.tsx b/tests/render-new-session.test.tsx index db834bc..e62a550 100644 --- a/tests/render-new-session.test.tsx +++ b/tests/render-new-session.test.tsx @@ -5,6 +5,7 @@ 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 { clearOpeningPrompt, readOpeningPrompt } from "../components/opening-prompt.ts"; /** * The sessionless draft view (/chat/new, owner 2026-09-13): "+ New session" @@ -25,10 +26,10 @@ function fakeFetch(id: string) { } describe("startDraftSession — the create-on-send contract", () => { - it("creates the session titled with the first message, returns the prompt redirect", async () => { + it("creates the session titled with the first message and returns a short path", async () => { const { f, calls } = fakeFetch("c_new"); const target = await startDraftSession("teach me orbital mechanics", f); - assert.equal(target, "/chat/c_new?prompt=teach%20me%20orbital%20mechanics"); + assert.equal(target, "/chat/c_new"); assert.equal(calls.length, 1); assert.equal(calls[0]!.url, "/api/chats"); assert.equal(calls[0]!.method, "POST"); @@ -41,6 +42,37 @@ describe("startDraftSession — the create-on-send contract", () => { assert.equal(calls.length, 1, "a single create call — the birth stamp is server-side"); }); + it("a long opening text stays out of the URL and is stashed for the chat page", async () => { + const mem = new Map(); + const fake = { + getItem: (k: string) => mem.get(k) ?? null, + setItem: (k: string, v: string) => { + mem.set(k, v); + }, + removeItem: (k: string) => { + mem.delete(k); + }, + }; + const holder = globalThis as { sessionStorage?: Storage }; + const prev = holder.sessionStorage; + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: fake }); + try { + const { f } = fakeFetch("c_long"); + const text = `${"B2 First grammar item. ".repeat(1500)}end`; + const target = await startDraftSession(text, f); + assert.equal(target, "/chat/c_long"); + assert.equal(target!.includes("?"), false); + const stashed = readOpeningPrompt("c_long"); + assert.equal(stashed?.length, text.length); + assert.equal(stashed, text); + clearOpeningPrompt("c_long"); + assert.equal(readOpeningPrompt("c_long"), undefined); + } finally { + if (prev === undefined) delete holder.sessionStorage; + else Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: prev }); + } + }); + it("empty or whitespace text never creates anything", async () => { const { f, calls } = fakeFetch("never"); assert.equal(await startDraftSession("", f), null); @@ -188,7 +220,7 @@ describe("the practice draft (?mode=practice, 2026-10-04)", () => { return new Response(JSON.stringify({ id: "c9" }), { status: 201 }); }) as unknown as typeof fetch; const target = await startDraftSession("the parity chain", f, "practice"); - assert.equal(target, "/chat/c9?prompt=the%20parity%20chain"); + assert.equal(target, "/chat/c9"); assert.deepEqual(body, { title: "the parity chain", kind: "practice" }); });