Merge PR #154 short opening URLs and recoverable prompt handoff
This commit is contained in:
+69
-12
@@ -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, 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";
|
||||
@@ -251,7 +252,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);
|
||||
@@ -261,16 +262,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).
|
||||
const sentInitial = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!sentInitial.current && initialPromptOwed(initialMessages, initialPrompt)) {
|
||||
sentInitial.current = true;
|
||||
void send({ type: "text", text: initialPrompt! });
|
||||
}
|
||||
}, [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 +570,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
|
||||
@@ -1161,6 +1212,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 });
|
||||
@@ -1592,6 +1647,7 @@ export function Chat({
|
||||
</Shimmer>
|
||||
</span>
|
||||
)}
|
||||
{openingError && <p role="alert" className="text-sm text-destructive">{OPENING_PROMPT_FAILURE}</p>}
|
||||
{error && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
||||
<span>Something went wrong: {error.message}</span>
|
||||
@@ -1779,7 +1835,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
|
||||
|
||||
+17
-15
@@ -12,6 +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 { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
|
||||
import { SessionStarter } from "@/components/start-session";
|
||||
import type { TurnUsage } from "@/src/core";
|
||||
|
||||
/**
|
||||
@@ -56,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. */
|
||||
@@ -105,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) {
|
||||
@@ -128,29 +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.
|
||||
router.push(`/chat/${id}?prompt=${encodeURIComponent(t)}`);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,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
-24
@@ -12,6 +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 { 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
|
||||
@@ -33,18 +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, 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).
|
||||
* 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;
|
||||
@@ -52,19 +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.
|
||||
return `/chat/${id}?prompt=${encodeURIComponent(t)}`;
|
||||
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
|
||||
@@ -77,6 +73,7 @@ export function DraftShell({
|
||||
text,
|
||||
onChange,
|
||||
onSend,
|
||||
startError = false,
|
||||
setupCta,
|
||||
mode = "teach",
|
||||
picks = [],
|
||||
@@ -88,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). */
|
||||
@@ -190,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}
|
||||
@@ -243,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.
|
||||
@@ -280,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;
|
||||
@@ -300,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}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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}`;
|
||||
|
||||
export function stashOpeningPrompt(id: string, text: string, attempted = false): boolean {
|
||||
try {
|
||||
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 false;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return readOpeningPromptEntry(id)?.text;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+41
-14
@@ -180,6 +180,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;
|
||||
@@ -257,6 +259,9 @@ export class SessionModel {
|
||||
// The single live consume (POST response or reattach stream): a newer
|
||||
// consume cancels the older — one reader at a time.
|
||||
private consumeAbort: AbortController | null = null;
|
||||
/** Retain the latest request across stop(): an opening POST still owns
|
||||
* its refusal after view detachment, but never after a newer request. */
|
||||
private latestRequest: AbortController | null = null;
|
||||
/** Synchronous ready check for the gate (send/read mount logic). */
|
||||
readyRef = true;
|
||||
|
||||
@@ -299,6 +304,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,
|
||||
@@ -553,6 +559,7 @@ export class SessionModel {
|
||||
this.consumeAbort?.abort();
|
||||
const controller = new AbortController();
|
||||
this.consumeAbort = controller;
|
||||
this.latestRequest = controller;
|
||||
// REPLAY ONTO A CLEAN SLATE (owner, optimistic round 3, 2026-10-02):
|
||||
// the reattach stream replays the turn from its FIRST event — an
|
||||
// aborted earlier attempt (React StrictMode's simulated remount)
|
||||
@@ -637,40 +644,41 @@ 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> {
|
||||
async send(input: ChatInput, onAccepted?: () => void): Promise<boolean> {
|
||||
const recovering = this.statusValue === "error";
|
||||
if (!this.readyRef && !recovering) return;
|
||||
if (!this.readyRef && !recovering) return false;
|
||||
this.consumeAbort?.abort();
|
||||
const controller = new AbortController();
|
||||
this.consumeAbort = controller;
|
||||
this.latestRequest = controller;
|
||||
this.setError(null);
|
||||
this.setStatus("submitted");
|
||||
if (recovering && input.type === "answer") {
|
||||
try {
|
||||
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, { signal: controller.signal });
|
||||
if (this.consumeAbort !== controller) return;
|
||||
if (this.consumeAbort !== controller) return false;
|
||||
if (!res.ok) throw new Error((await res.text()) || "An error occurred.");
|
||||
const record = await res.json() as { messages?: LhMessage[]; turnInFlight?: boolean; turnError?: string };
|
||||
if (this.consumeAbort !== controller) return;
|
||||
if (this.consumeAbort !== controller) return false;
|
||||
// No verified record/liveness: retain the draft and never guess
|
||||
// whether a lost acknowledgement meant the input was accepted.
|
||||
if (!Array.isArray(record.messages) || typeof record.turnInFlight !== "boolean") throw new Error("An error occurred.");
|
||||
this.setMessages(record.messages, record.turnError);
|
||||
if (record.turnInFlight) {
|
||||
await this.resumeStream(false);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (input.type === "answer" && (answeredCallIds(record.messages).has(input.toolCallId) || danglingQuestionOf(record.messages)?.toolCallId !== input.toolCallId)) {
|
||||
// The call already resolved, or no longer owns the dock. Adopt
|
||||
// truth, never POST that answer a second time.
|
||||
this.setStatus("ready");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.consumeAbort !== controller || controller.signal.aborted) return;
|
||||
if (this.consumeAbort !== controller || controller.signal.aborted) return false;
|
||||
this.setError((err as Error).message);
|
||||
this.setStatus("error");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (input.type !== "continue") this.automaticContinuationBlocked = false;
|
||||
@@ -697,6 +705,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
|
||||
@@ -727,6 +736,9 @@ export class SessionModel {
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
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
|
||||
@@ -738,13 +750,24 @@ 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 (this.consumeAbort !== controller || controller.signal.aborted) return;
|
||||
// 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.
|
||||
const failedOpening = input.type === "text" && onAccepted !== undefined && !accepted;
|
||||
if (failedOpening) {
|
||||
// The POST outlives stop(), including StrictMode's cleanup. Remove
|
||||
// only its own failed optimistic bubble even if a newer request
|
||||
// won; never touch canonical messages or another attempt's tail.
|
||||
const ids = new Set(optimistic.map(m => m.id));
|
||||
this.mutate(() => { this.tail = this.tail.filter(m => !ids.has(m.id)); });
|
||||
}
|
||||
const detachedOpening = failedOpening && this.consumeAbort === null && this.latestRequest === controller;
|
||||
if ((this.consumeAbort !== controller || controller.signal.aborted) && !detachedOpening) return accepted;
|
||||
// A detached opening refusal must still reach the replayed mount's
|
||||
// existing setup recovery. A newer send/reattach ALWAYS owns errors
|
||||
// and status instead, even if that request has since been stopped.
|
||||
// Ordinary text and answer teardown behavior stays unchanged.
|
||||
if (input.type === "answer") {
|
||||
const ids = new Set(optimistic.map(m => m.id));
|
||||
this.mutate(() => {
|
||||
@@ -754,6 +777,10 @@ export class SessionModel {
|
||||
}
|
||||
this.setError((err as Error).message);
|
||||
this.setStatus("error");
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -803,7 +830,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
|
||||
|
||||
@@ -102,7 +102,7 @@ export function useChatSession({ chatId, initialMessages, initialError, initiall
|
||||
[model],
|
||||
);
|
||||
|
||||
const send = useCallback((input: ChatInput) => void 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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user