fix: finish new-session opening-message handoff

This commit is contained in:
amosblomqvist
2026-10-27 18:19:38 +02:00
parent 8ca291526c
commit f24fda926a
11 changed files with 689 additions and 107 deletions
+67 -24
View File
@@ -26,7 +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 { clearOpeningPrompt, readOpeningPromptEntry, stashOpeningPrompt, OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
import { forwardDockWheel, useDockHeight } from "@/components/dock-height";
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation";
@@ -238,7 +238,7 @@ export function Chat({
// (core/view.ts projectView); sends are ONE explicit input event each
// ({chatId, input} — the stash indirection died with the SDK), and the
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
const { messages, nativeMessages, contextTokens, send, continueIfOwed, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, questionDraft, saveQuestionDraft, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
const { messages, nativeMessages, hasConfirmedConversation, contextTokens, send, continueIfOwed, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, questionDraft, saveQuestionDraft, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
useChatSession({ chatId, initialMessages, initialError, initiallyInFlight });
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
@@ -248,28 +248,6 @@ export function Chat({
// The reattach settled terminal → the sync-and-continue seam (below).
setReattachSettled(() => reattachSettledRef.current());
// 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(() => {
const prompt = initialPrompt || readOpeningPrompt(chatId);
if (!sentInitial.current && prompt && initialPromptOwed(initialMessages, prompt)) {
sentInitial.current = true;
void send({ type: "text", text: prompt }).then(
(landed) => {
if (landed) clearOpeningPrompt(chatId);
},
() => {
/* leave the stash: the next mount retries */
},
);
}
}, [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
// stream is the primary channel (resumeStream — the same rendering path
@@ -579,6 +557,66 @@ export function Chat({
* Auto-continuations (probe answers) never set it — their refusal
* opens recovery with no replay. */
const draftRef = useRef<string | null>(null);
const openingDraftRef = useRef<string | null>(null);
const [openingError, setOpeningError] = useState(false);
const openingMountedRef = useRef(false);
useEffect(() => {
openingMountedRef.current = true;
return () => { openingMountedRef.current = false; };
}, []);
const deliverOpeningPrompt = useCallback((prompt: string) => {
openingDraftRef.current = prompt;
// Persist uncertainty BEFORE the POST: another mount must restore,
// not race the still-arriving request. No storage means no auto-send.
if (!stashOpeningPrompt(chatId, prompt, true)) {
applyText(prompt);
setOpeningError(true);
return;
}
setOpeningError(false);
draftRef.current = prompt; // The existing 503 flow must restore it too.
applyText("");
void send({ type: "text", text: prompt }, () => {
clearOpeningPrompt(chatId);
openingDraftRef.current = null;
if (draftRef.current === prompt) draftRef.current = null;
}).then(accepted => {
if (!accepted && openingMountedRef.current && openingDraftRef.current === prompt && !textRef.current) {
applyText(prompt);
}
});
}, [chatId, send, applyText]);
const sentInitial = useRef(false);
useEffect(() => {
if (sentInitial.current) return;
sentInitial.current = true;
const entry = readOpeningPromptEntry(chatId);
const prompt = initialPrompt || entry?.text; // Legacy links still work.
if (!initialPromptOwed(initialMessages, prompt)) {
if (initialMessages.some(m => m.role === "user" || m.role === "assistant")) clearOpeningPrompt(chatId);
return;
}
openingDraftRef.current = prompt!;
if (entry?.attempted || initiallyInFlight) {
applyText(prompt!); // Interrupted start: the learner explicitly resends.
return;
}
deliverOpeningPrompt(prompt!);
}, [chatId, initialPrompt, initialMessages, initiallyInFlight, deliverOpeningPrompt, applyText]);
useEffect(() => {
if (!hasConfirmedConversation) return;
clearOpeningPrompt(chatId);
// A later canonical read may confirm a lost acknowledgment. Clear
// only the unchanged recovered draft, never the learner's newer work.
const opening = openingDraftRef.current;
if (opening !== null && textRef.current === opening) applyText("");
if (draftRef.current === opening) draftRef.current = null;
openingDraftRef.current = null;
setOpeningError(false);
}, [chatId, hasConfirmedConversation, applyText]);
// THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's
// 503 models_not_configured envelope renders as the SETUP FLOW, never
@@ -1155,6 +1193,10 @@ export function Chat({
openModelPicker();
return; // No optimistic send and no change to the learner's draft.
}
if (openingDraftRef.current !== null) {
deliverOpeningPrompt(t);
return;
}
draftRef.current = t; // the refusal flow's preserve-and-replay seam
applyText("");
void send({ type: "text", text: t });
@@ -1586,6 +1628,7 @@ export function Chat({
</Shimmer>
</span>
)}
{openingError && <p role="alert" className="text-sm text-destructive">{OPENING_PROMPT_FAILURE}</p>}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
Something went wrong: {error.message}
+17 -19
View File
@@ -12,7 +12,8 @@ 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 { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
import { SessionStarter } from "@/components/start-session";
import type { TurnUsage } from "@/src/core";
/**
@@ -57,6 +58,9 @@ export function Home({
},
});
const [busy, setBusy] = useState(false);
const startingRef = useRef(false);
const starterRef = useRef(new SessionStarter());
const [startError, setStartError] = useState(false);
/* Run end focuses the topic input (owner, 2026-10-01): the hands are on
* the keyboard from the stop. Falling edge only; this surface's only
* dictation target IS this textarea, so no case-split is needed. */
@@ -106,7 +110,7 @@ export function Home({
};
const begin = async () => {
const t = topicRef.current.trim();
if (!t || busy) return;
if (!t || startingRef.current) return;
// Unconfigured: the send INTERCEPTS into the setup flow (the draft
// stays in the textarea; the learner sends it after Save).
if (!configured) {
@@ -129,32 +133,25 @@ export function Home({
openModelPicker();
return; // Preserve the draft and never create an orphan session.
}
startingRef.current = true;
setBusy(true);
setStartError(false);
try {
// The map context (owner, 2026-10-04): picks in EITHER mode ride
// the CREATION as graphContext — the server writes a
// graph-context marker as message[0] (a separate custom message
// before the learner's first user message, propositions + the
// chains among them); the prompt stays the learner's own words.
const res = await fetch("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: t,
...(mode === "practice" ? { kind: "practice" } : {}),
...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}),
}),
});
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 — the same default this surface's model control edits.
// 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}`);
const target = await starterRef.current.start({
title: t,
...(mode === "practice" ? { kind: "practice" } : {}),
...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}),
}, fetch, JSON.stringify(catalog?.slots.strong));
router.push(target);
} catch {
startingRef.current = false;
setBusy(false);
setStartError(true);
}
};
@@ -243,6 +240,7 @@ export function Home({
)
)}
/>
{startError && <p role="alert" className="mb-2 text-sm text-destructive">{OPENING_PROMPT_FAILURE}</p>}
<form
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
onSubmit={(e) => {
+34 -28
View File
@@ -12,7 +12,8 @@ import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill
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 { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
import { SessionStarter } from "@/components/start-session";
import type { ChatSettings, GraphSnapshot } from "@/src/core";
/** The draft's pick set (owner round 10): the SAME shape the home pick
@@ -34,20 +35,18 @@ export type DraftPick = { id: string; name: string; statement: string };
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, 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).
* Create, safely hand over the full opening text, then return a short
* /chat/<id> target. Storage/creation failures leave the caller's draft
* intact. A per-composer starter reuses an already-created session on an
* unchanged retry. The server stamps settings at birth; no settings PUT.
*/
export async function startDraftSession(
text: string,
fetchLike: typeof fetch,
mode?: "practice",
picks?: readonly DraftPick[],
starter = new SessionStarter(),
settingsIdentity?: string,
): Promise<string | null> {
const t = text.trim();
if (!t) return null;
@@ -55,20 +54,13 @@ export async function startDraftSession(
// from the pick bar creates its session WITH the graph context — the
// same contract as the home screen's practice send (the marker with
// [id] name: statement lines, target validation's truth set).
const res = await fetchLike("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
mode === "practice"
? { title: t, kind: "practice", ...(picks && picks.length > 0 ? { graphContext: { propositions: picks } } : {}) }
: { title: t },
),
});
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.
stashOpeningPrompt(id, t);
return `/chat/${id}`;
return starter.start(
mode === "practice"
? { title: t, kind: "practice", ...(picks && picks.length > 0 ? { graphContext: { propositions: picks } } : {}) }
: { title: t },
fetchLike,
settingsIdentity,
);
}
/** The pure shell (no router) — the render-test seam: the draft view's
@@ -81,6 +73,7 @@ export function DraftShell({
text,
onChange,
onSend,
startError = false,
setupCta,
mode = "teach",
picks = [],
@@ -92,6 +85,7 @@ export function DraftShell({
text: string;
onChange: (v: string) => void;
onSend: () => void;
startError?: boolean;
/** The practice draft (owner, 2026-10-04): ?mode=practice — the
* created session carries kind:"practice" and the copy changes
* (the practice agent opens; no probing). */
@@ -194,6 +188,7 @@ export function DraftShell({
)}
setup={setupCta}
/>
{startError && <p role="alert" className="pointer-events-auto mb-2 text-sm text-destructive">{OPENING_PROMPT_FAILURE}</p>}
<div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<Composer
busy={busy}
@@ -247,6 +242,9 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
setText(v);
};
const [busy, setBusy] = useState(false);
const startingRef = useRef(false);
const starterRef = useRef(new SessionStarter());
const [startError, setStartError] = useState(false);
// THE PICK BAR (owner round 10): the draft's lifted pick set + the
// snapshot the standalone chain-table picker reads. Lazy — the graph
// is fetched on FIRST open only, then reused.
@@ -284,7 +282,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
const send = useCallback(() => {
const draft = textRef.current.trim();
if (!draft || busy) return;
if (!draft || startingRef.current) return;
if (!configured) {
setSetupOpen(true);
return;
@@ -304,19 +302,27 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
openModelPicker();
return; // The learner replaces it explicitly, then sends again.
}
startingRef.current = true;
setBusy(true);
void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined)
setStartError(false);
void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined,
starterRef.current, JSON.stringify(catalog?.slots.strong))
.then((target) => {
if (target) router.push(target);
else setBusy(false);
else { startingRef.current = false; setBusy(false); }
})
.catch(() => setBusy(false));
}, [busy, router, configured, zeroKeys, catalog]);
.catch(() => {
startingRef.current = false;
setBusy(false);
setStartError(true);
});
}, [router, configured, zeroKeys, catalog, mode]);
return (
<>
<DraftShell
busy={busy}
startError={startError}
text={text}
onChange={applyText}
onSend={send}
+43 -25
View File
@@ -1,41 +1,59 @@
/**
* 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.
* Same-tab handover of a new session's full opening text. Never put the
* message in the navigation URL: long query payloads can be rejected before
* the chat page loads. Storage is required for automatic delivery, not an
* optional best-effort side effect of navigating away from the source draft.
*/
export const OPENING_PROMPT_FAILURE = "Couldn’t open the session. Your message is still here. Try sending again.";
export interface OpeningPrompt {
text: string;
/** Written BEFORE sending. A later mount restores a draft, never reposts. */
attempted: boolean;
}
const keyFor = (id: string) => `lh.openingPrompt.${id}`;
function store(): Storage | null {
export function stashOpeningPrompt(id: string, text: string, attempted = false): boolean {
try {
if (typeof sessionStorage === "undefined" || sessionStorage == null) return null;
return sessionStorage;
const value = JSON.stringify({ text, attempted } satisfies OpeningPrompt);
// Catch both the storage getter and its methods (quota/security errors).
const storage = globalThis.sessionStorage;
storage.setItem(keyFor(id), value);
return storage.getItem(keyFor(id)) === value;
} catch {
return null;
return false;
}
}
export function stashOpeningPrompt(id: string, text: string): void {
store()?.setItem(keyFor(id), text);
export function readOpeningPromptEntry(id: string): OpeningPrompt | undefined {
try {
const value = globalThis.sessionStorage.getItem(keyFor(id));
if (!value) return undefined;
try {
const entry: unknown = JSON.parse(value);
if (entry && typeof entry === "object" && "text" in entry && typeof entry.text === "string" &&
entry.text.length > 0 && "attempted" in entry && typeof entry.attempted === "boolean") {
return { text: entry.text, attempted: entry.attempted };
}
} catch { /* Old raw-text stashes have no proof that sending never began. */ }
return { text: value, attempted: true };
} catch {
return undefined;
}
}
export function readOpeningPrompt(id: string): string | undefined {
const value = store()?.getItem(keyFor(id));
return value && value.length > 0 ? value : undefined;
return readOpeningPromptEntry(id)?.text;
}
export function clearOpeningPrompt(id: string): void {
store()?.removeItem(keyFor(id));
export function clearOpeningPrompt(id: string): boolean {
try {
globalThis.sessionStorage.removeItem(keyFor(id));
return true;
} catch {
// Canonical conversation/acknowledgment remains the truth. A later
// mount with real conversation can try cleanup again; never crash it.
return false;
}
}
+13 -8
View File
@@ -179,6 +179,8 @@ function sameDetails(a: unknown, b: unknown): boolean {
export interface SessionState {
messages: LhViewMessage[];
nativeMessages: LhMessage[];
/** Server-read history only; optimistic bubbles are not delivery proof. */
hasConfirmedConversation: boolean;
/** Completed conversation usage; null after compaction or before first usage. */
contextTokens: number | null;
status: SessionStatus;
@@ -298,6 +300,7 @@ export class SessionModel {
return {
messages: projectView(messages, live),
nativeMessages: messages,
hasConfirmedConversation: this.base.some(m => m.role === "user" || m.role === "assistant"),
contextTokens: measuredContextTokens(messages, this.incompleteUsageIds),
status: this.statusValue,
error: this.errorValue ?? providerError,
@@ -636,7 +639,7 @@ 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<boolean> {
async send(input: ChatInput, onAccepted?: () => void): Promise<boolean> {
const recovering = this.statusValue === "error";
if (!this.readyRef && !recovering) return false;
this.consumeAbort?.abort();
@@ -721,6 +724,8 @@ export class SessionModel {
throw new Error((await res.text()) || `The send failed (${res.status}).`);
}
accepted = true;
// Opening-message handover ends at acceptance, not stream completion.
onAccepted?.();
// 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
@@ -737,10 +742,10 @@ export class SessionModel {
return true;
} catch (err) {
if (this.consumeAbort !== controller || controller.signal.aborted) return accepted;
// Restore the question when acceptance failed or is unknown. Remove
// only THIS optimistic answer, never saved copies/newer live content.
// Other input kinds retain their existing optimistic presentation.
if (input.type === "answer") {
// Restore the question, or the new-session opening draft, when
// acceptance failed/is unknown. Other text sends keep their existing
// optimistic presentation; remove only THIS attempt's local entries.
if (input.type === "answer" || (input.type === "text" && onAccepted && !accepted)) {
const ids = new Set(optimistic.map(m => m.id));
this.mutate(() => {
this.tail = this.tail.filter(m => !ids.has(m.id));
@@ -749,9 +754,9 @@ export class SessionModel {
}
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.
// true means we observed this POST's acknowledgment. false does
// NOT prove non-delivery: a network failure may hide acceptance.
// Opening-message callers restore a draft, never automatically retry.
return accepted;
}
}
+2 -1
View File
@@ -102,7 +102,7 @@ export function useChatSession({ chatId, initialMessages, initialError, initiall
[model],
);
const send = useCallback((input: ChatInput) => model.send(input), [model]);
const send = useCallback((input: ChatInput, onAccepted?: () => void) => model.send(input, onAccepted), [model]);
const continueIfOwed = useCallback((record: readonly LhMessage[]) => void model.continueIfOwed(record), [model]);
const steer = useCallback((text: string) => void model.steer(text), [model]);
const submitAnswer = useCallback(
@@ -129,6 +129,7 @@ export function useChatSession({ chatId, initialMessages, initialError, initiall
return {
messages: state.messages,
nativeMessages: state.nativeMessages,
hasConfirmedConversation: state.hasConfirmedConversation,
contextTokens: state.contextTokens,
status: state.status,
error: state.error,
+34
View File
@@ -0,0 +1,34 @@
import { clearOpeningPrompt, OPENING_PROMPT_FAILURE, stashOpeningPrompt } from "./opening-prompt";
interface SessionCreation {
title: string;
kind?: "practice";
graphContext?: { propositions: readonly { id: string; name: string; statement: string }[] };
}
/** Per source-composer instance. Retain a successful creation if storage
* fails, so an unchanged retry does not create another empty session.
* Changed text/context/settings is a different start, not a stale title.
*/
export class SessionStarter {
private created?: { identity: string; id: string };
async start(body: SessionCreation, fetchLike: typeof fetch, settingsIdentity?: string): Promise<string> {
const identity = JSON.stringify([body, settingsIdentity]);
if (this.created?.identity !== identity) {
if (this.created) clearOpeningPrompt(this.created.id);
this.created = undefined;
const res = await fetchLike("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(OPENING_PROMPT_FAILURE);
const result = await res.json() as { id?: unknown };
if (typeof result.id !== "string" || !/^[A-Za-z0-9_-]+$/.test(result.id)) throw new Error(OPENING_PROMPT_FAILURE);
this.created = { identity, id: result.id };
}
if (!stashOpeningPrompt(this.created.id, body.title)) throw new Error(OPENING_PROMPT_FAILURE);
return `/chat/${this.created.id}`;
}
}