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/<id>.
This commit is contained in:
+18
-4
@@ -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
|
||||
|
||||
+5
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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/<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).
|
||||
* the first message), persist the lifted pre-chat settings, stash the
|
||||
* opening text beside the navigation, and return the push target —
|
||||
* /chat/<id>. 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
|
||||
|
||||
@@ -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/<id>?prompt=<text>`.
|
||||
* 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.<id>`.
|
||||
* The push target stays `/chat/<id>`. 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));
|
||||
}
|
||||
@@ -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<void> {
|
||||
if (!this.readyRef) return;
|
||||
async send(input: ChatInput): Promise<boolean> {
|
||||
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> {
|
||||
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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<string, string>();
|
||||
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" });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user