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) =>
|
||||
|
||||
Reference in New Issue
Block a user