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:
ferponse
2026-10-26 00:45:16 +01:00
parent 0795ef197c
commit 472844e691
7 changed files with 123 additions and 21 deletions
+18 -4
View File
@@ -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
View File
@@ -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);
}
+10 -6
View File
@@ -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
+41
View File
@@ -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));
}
+13 -6
View File
@@ -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
+1 -1
View File
@@ -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) =>