Drafts survive reload

This commit is contained in:
Ian Gulliver
2026-10-16 08:42:48 -07:00
parent 14cf1f582b
commit cac24e534f
15 changed files with 556 additions and 92 deletions
+49 -18
View File
@@ -3,6 +3,7 @@ import { ago, copyable, errorText, h, replace, showAlert, showConfirm } from "./
import { markdown } from "./markdown.js";
import { previewURL } from "./preview.js";
import { findingCount, reviewCounts } from "./reviews.js";
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, showReview, state, type Topic } from "./state.js";
import { taskStyle } from "./taskcolor.js";
import { fillTodoLinks } from "./todolink.js";
@@ -284,6 +285,10 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
let focusStart = false;
const renderComposer = () => {
const key = src.key();
const old = composer.querySelector("textarea");
if (old) {
releaseInput(old);
}
if (!key) {
replace(composer);
return;
@@ -292,7 +297,6 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
const live = sess && sess.state !== "completed" ? sess : undefined;
composerModel = live ? live.model || context.get(live.id)?.model || "" : "";
const form = composeForm(src, key, live?.id, live?.state ?? "idle", composerModel, live?.vendor, showPending);
const old = composer.querySelector("textarea");
const caret = old !== null && document.activeElement === old ? [old.selectionStart, old.selectionEnd] : undefined;
replace(composer, form);
const text = form.querySelector("textarea")!;
@@ -557,21 +561,43 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
resizes.disconnect();
mutations.disconnect();
dropStream();
const input = composer.querySelector("textarea");
if (input) {
releaseInput(input);
}
flushDrafts();
};
}
const composerDrafts = new Map<string, string>();
const composerInputs = new Map<string, HTMLTextAreaElement>();
function keepDraft(input: HTMLTextAreaElement, key: string): () => void {
input.value = composerDrafts.get(key) ?? "";
input.addEventListener("input", () => {
if (input.value) {
composerDrafts.set(key, input.value);
} else {
composerDrafts.delete(key);
function releaseInput(input: HTMLTextAreaElement): void {
for (const [key, el] of composerInputs) {
if (el === input) {
composerInputs.delete(key);
}
});
return () => composerDrafts.delete(key);
}
}
function keepDraft(input: HTMLTextAreaElement, key: string): void {
composerInputs.set(key, input);
input.value = composerText(key);
input.addEventListener("input", () => setComposerText(key, input.value));
input.addEventListener("blur", flushDrafts);
}
function lostImagesNote(key: string): HTMLElement | null {
const n = lostImageCount(key);
if (n === 0) {
return null;
}
const dismiss = h("button", { type: "button", class: "remove", title: "Dismiss" }, "×");
const note = h("div", { class: "compose-note" }, `${n === 1 ? "1 image" : `${n} images`} from before the reload need re-attaching.`, dismiss);
dismiss.onclick = () => {
forgetLostImages(key);
note.remove();
};
return note;
}
type PendingImage = { file: Blob; url: string };
@@ -585,6 +611,7 @@ function imageTray(form: HTMLFormElement, input: HTMLTextAreaElement, key: strin
const el = h("div", { class: "compose-images" });
const render = () => {
const imgs = imageDrafts.get(key) ?? [];
setComposerImages(key, imgs.length);
el.hidden = imgs.length === 0;
replace(
el,
@@ -715,7 +742,8 @@ function composeForm(
showPending: (text: string, imgs: PendingImage[]) => () => void,
): HTMLElement {
const text = h("textarea", { placeholder: src.placeholder, rows: 3 });
const clearDraft = keepDraft(text, `message:${key}`);
const draftKey = `message:${key}`;
keepDraft(text, draftKey);
const send = h("button", { type: "submit", disabled: !src.canSend(sessionState) }, "Send");
const interrupt = h("button", { type: "button", class: "danger", disabled: sessionState !== "running" }, "Interrupt");
interrupt.onclick = async () => {
@@ -732,26 +760,29 @@ function composeForm(
const modelKey = `message:${sessionId ?? key}`;
const model = modelSelect(modelKey, currentModel, vendor);
const form = h("form", { class: "compose" });
const tray = imageTray(form, text, `message:${key}`);
form.append(tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send));
const tray = imageTray(form, text, draftKey);
form.append(lostImagesNote(draftKey) ?? "", tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send));
form.onsubmit = async (e) => {
e.preventDefault();
const value = text.value;
if (!value.trim() && !imageDrafts.has(`message:${key}`)) {
if (!value.trim() && !imageDrafts.has(draftKey)) {
return;
}
const imgs = tray.take();
text.value = "";
clearDraft();
const sent = startSend(draftKey, value, imgs.length);
const cancel = showPending(value, imgs);
try {
const images = await uploadImages(imgs);
await src.send({ key, sessionId, text: value, images, model: model.value !== currentModel ? model.value : undefined, firstModel: model.value });
modelChoices.delete(modelKey);
endSend(sent, true);
form.querySelector(".compose-note")?.remove();
} catch (err) {
cancel();
composerDrafts.set(`message:${key}`, value);
text.value = value;
endSend(sent, false);
const live = composerInputs.get(draftKey) ?? text;
live.value = composerText(draftKey);
tray.restore(imgs);
void showAlert(errorText(err));
}