Merge PR #154 short opening URLs and recoverable prompt handoff

This commit is contained in:
amosblomqvist
2026-10-27 19:13:19 +02:00
11 changed files with 889 additions and 70 deletions
+41 -14
View File
@@ -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