Keep composer drafts across reloads and preview attached images

The composer's unsent text and uploaded files are kept in localStorage
per bot and workspace until sent. Picked images show a small preview in
place of their icon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 14:18:13 +01:00
co-authored by Claude Opus 5.5
parent e7a7f56ec7
commit 6c1399f88f
8 changed files with 158 additions and 9 deletions
+5
View File
@@ -6,6 +6,11 @@ export const AppData = {
* Shared with twui's `ColorSchemeSelector`, so it must stay `theme`. * Shared with twui's `ColorSchemeSelector`, so it must stay `theme`.
*/ */
ThemeStorageKey: "theme", ThemeStorageKey: "theme",
/**
* Prefix of the composer's unsent drafts in `localStorage`, one per
* bot and workspace.
*/
ComposerDraftStorageKey: `${SiteData["SiteSlug"]}_composer_draft`,
ServerHealthMessage: "Server Running Well", ServerHealthMessage: "Server Running Well",
AuthKeyCookieName: `${SiteData["SiteSlug"]}_auth_key`, AuthKeyCookieName: `${SiteData["SiteSlug"]}_auth_key`,
AuthCSRFCookieName: `${SiteData["SiteSlug"]}_auth_csrf`, AuthCSRFCookieName: `${SiteData["SiteSlug"]}_auth_csrf`,
@@ -1,7 +1,8 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import type { BotMessageAttachment } from "@/src/types"; import type { BotMessageAttachment } from "@/src/types";
import uploadBotAttachment from "../(functions)/upload-bot-attachment"; import uploadBotAttachment from "../(functions)/upload-bot-attachment";
import composerDraftKey from "../(utils)/composer-draft-key";
export type PendingAttachment = { export type PendingAttachment = {
key: string; key: string;
@@ -9,14 +10,59 @@ export type PendingAttachment = {
status: "uploading" | "ready" | "failed"; status: "uploading" | "ready" | "failed";
attachment?: BotMessageAttachment; attachment?: BotMessageAttachment;
error?: string; error?: string;
/**
* A local object URL of a picked image, shown while it uploads and
* after, so its preview never waits for the server.
*/
preview_url?: string;
};
type Params = {
bot_id?: number | "" | null;
workspace_id?: number | null;
}; };
/** /**
* The files picked for the next message: each uploads as soon as it's * The files picked for the next message: each uploads as soon as it's
* added, so sending only waits for ones still going up. * added, so sending only waits for ones still going up. Uploaded ones are
* kept in `localStorage` per bot and workspace until sent, like the text
* (`useComposerDraft`), and come back after a reload.
*/ */
export default function useComposerAttachments() { export default function useComposerAttachments({ bot_id, workspace_id }: Params) {
const storage_key = composerDraftKey({ bot_id, workspace_id, part: "files" });
const [pending, setPending] = useState<PendingAttachment[]>([]); const [pending, setPending] = useState<PendingAttachment[]>([]);
const [loaded_key, setLoadedKey] = useState<string>();
useEffect(() => {
let saved: BotMessageAttachment[] = [];
try {
saved = JSON.parse(localStorage.getItem(storage_key) || "[]");
} catch {}
setPending(
(Array.isArray(saved) ? saved : []).map((attachment) => ({
key: `saved-${attachment.id}`,
name: attachment.name,
status: "ready" as const,
attachment,
})),
);
setLoadedKey(storage_key);
}, [storage_key]);
useEffect(() => {
if (loaded_key != storage_key) return;
const ready = pending.flatMap((item) =>
item.status == "ready" && item.attachment ? [item.attachment] : [],
);
try {
if (ready.length) localStorage.setItem(storage_key, JSON.stringify(ready));
else localStorage.removeItem(storage_key);
} catch {}
}, [pending, loaded_key]);
const update = ({ key, ...fields }: Partial<PendingAttachment> & { key: string }) => const update = ({ key, ...fields }: Partial<PendingAttachment> & { key: string }) =>
setPending((prev) => prev.map((item) => (item.key == key ? { ...item, ...fields } : item))); setPending((prev) => prev.map((item) => (item.key == key ? { ...item, ...fields } : item)));
@@ -31,6 +77,9 @@ export default function useComposerAttachments() {
key: `${Date.now()}-${Math.random().toString(36).slice(2)}`, key: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: file.name || "file", name: file.name || "file",
status: "uploading" as const, status: "uploading" as const,
preview_url: file.type.startsWith("image/")
? URL.createObjectURL(file)
: undefined,
}, },
})); }));
@@ -47,14 +96,22 @@ export default function useComposerAttachments() {
} }
}; };
const remove = ({ key }: { key: string }) => const revoke = ({ items }: { items: PendingAttachment[] }) =>
items.forEach((item) => item.preview_url && URL.revokeObjectURL(item.preview_url));
const remove = ({ key }: { key: string }) => {
revoke({ items: pending.filter((item) => item.key == key) });
setPending((prev) => prev.filter((item) => item.key != key)); setPending((prev) => prev.filter((item) => item.key != key));
};
return { return {
pending, pending,
add, add,
remove, remove,
clear: () => setPending([]), clear: () => {
revoke({ items: pending });
setPending([]);
},
uploading: pending.some((item) => item.status == "uploading"), uploading: pending.some((item) => item.status == "uploading"),
media_ids: pending.flatMap((item) => media_ids: pending.flatMap((item) =>
item.status == "ready" && item.attachment ? [item.attachment.id] : [], item.status == "ready" && item.attachment ? [item.attachment.id] : [],
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import composerDraftKey from "../(utils)/composer-draft-key";
type Params = {
bot_id?: number | "" | null;
workspace_id?: number | null;
};
/**
* The composer's text, kept in `localStorage` per bot and workspace until
* it's sent, so a reload or leaving the conversation doesn't lose it.
* Read after mount, since the page renders on the server first.
*/
export default function useComposerDraft({ bot_id, workspace_id }: Params) {
const key = composerDraftKey({ bot_id, workspace_id, part: "text" });
const [draft, setDraft] = useState({ key, text: "" });
useEffect(() => {
let text = "";
try {
text = localStorage.getItem(key) || "";
} catch {}
setDraft({ key, text });
}, [key]);
const setText = (text: string) => {
setDraft({ key, text });
try {
if (text) localStorage.setItem(key, text);
else localStorage.removeItem(key);
} catch {}
};
return {
text: draft.key == key ? draft.text : "",
setText,
};
}
@@ -5,6 +5,7 @@ import Span from "@moduletrace/twui/layout/Span";
import { X } from "lucide-react"; import { X } from "lucide-react";
import type { PendingAttachment } from "../(hooks)/use-composer-attachments"; import type { PendingAttachment } from "../(hooks)/use-composer-attachments";
import AttachmentIcon from "./attachment-icon"; import AttachmentIcon from "./attachment-icon";
import ComposerAttachmentPreview from "./composer-attachment-preview";
type Props = Omit<PendingAttachment, "key"> & { type Props = Omit<PendingAttachment, "key"> & {
onRemove: () => void; onRemove: () => void;
@@ -12,15 +13,23 @@ type Props = Omit<PendingAttachment, "key"> & {
/** /**
* A file waiting to go with the next message: uploading, ready, or failed * A file waiting to go with the next message: uploading, ready, or failed
* (with why, on hover). Removable until sent. * (with why, on hover). Removable until sent. An image shows a
* small preview in place of its icon.
*/ */
export default function ComposerAttachmentChip({ export default function ComposerAttachmentChip({
name, name,
status, status,
attachment, attachment,
error, error,
preview_url,
onRemove, onRemove,
}: Props) { }: Props) {
const image_src =
preview_url ||
(attachment?.media_type == "image"
? attachment.thumbnail_url || attachment.url
: undefined);
return ( return (
<Row <Row
title={status == "failed" ? `${name}: ${error}` : name} title={status == "failed" ? `${name}: ${error}` : name}
@@ -28,6 +37,8 @@ export default function ComposerAttachmentChip({
> >
{status == "uploading" ? ( {status == "uploading" ? (
<Loading size="smaller" /> <Loading size="smaller" />
) : image_src ? (
<ComposerAttachmentPreview src={image_src} />
) : ( ) : (
<AttachmentIcon media_type={attachment?.media_type} /> <AttachmentIcon media_type={attachment?.media_type} />
)} )}
@@ -0,0 +1,18 @@
import Img from "@moduletrace/twui/layout/Img";
type Props = {
src: string;
};
/**
* A picked image's preview, the size of the icon it stands in for.
*/
export default function ComposerAttachmentPreview({ src }: Props) {
return (
<Img
src={src}
alt=""
className="size-7 shrink-0 rounded-sm object-cover"
/>
);
}
@@ -22,6 +22,7 @@ export default function ComposerAttachments({ pending, onRemove }: Props) {
status={item.status} status={item.status}
attachment={item.attachment} attachment={item.attachment}
error={item.error} error={item.error}
preview_url={item.preview_url}
onRemove={() => onRemove({ key: item.key })} onRemove={() => onRemove({ key: item.key })}
/> />
))} ))}
@@ -8,6 +8,7 @@ import type {
} from "@/src/types"; } from "@/src/types";
import sendBotMessage from "../(functions)/send-bot-message"; import sendBotMessage from "../(functions)/send-bot-message";
import useComposerAttachments from "../(hooks)/use-composer-attachments"; import useComposerAttachments from "../(hooks)/use-composer-attachments";
import useComposerDraft from "../(hooks)/use-composer-draft";
import ComposerAttachButton from "./composer-attach-button"; import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments"; import ComposerAttachments from "./composer-attachments";
import ComposerSendButton from "./composer-send-button"; import ComposerSendButton from "./composer-send-button";
@@ -24,13 +25,14 @@ type Props = {
/** /**
* Enter sends, Shift+Enter adds a line. Grows to five lines, then scrolls. * Enter sends, Shift+Enter adds a line. Grows to five lines, then scrolls.
* Its top rule lines up with the rail's user-row rule; picked files sit * Its top rule lines up with the rail's user-row rule; picked files sit
* just above it. Files can also be pasted or dropped in. * just above it. Files can also be pasted or dropped in. Unsent text is
* kept per conversation across reloads, with its uploaded files.
*/ */
export default function Composer({ bot, workspace_id, onSent }: Props) { export default function Composer({ bot, workspace_id, onSent }: Props) {
const [text, setText] = useState(""); const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const files = useComposerAttachments(); const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
const can_send = const can_send =
Boolean(text.trim() || files.media_ids.length) && Boolean(text.trim() || files.media_ids.length) &&
@@ -0,0 +1,14 @@
import { AppData } from "@/src/data/app-data";
type Params = {
bot_id?: number | "" | null;
workspace_id?: number | null;
part: "text" | "files";
};
/**
* The `localStorage` key of one part of a conversation's unsent draft.
*/
export default function composerDraftKey({ bot_id, workspace_id, part }: Params) {
return `${AppData.ComposerDraftStorageKey}:${bot_id || 0}:${workspace_id || 0}:${part}`;
}