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,6 +5,7 @@ import Span from "@moduletrace/twui/layout/Span";
import { X } from "lucide-react";
import type { PendingAttachment } from "../(hooks)/use-composer-attachments";
import AttachmentIcon from "./attachment-icon";
import ComposerAttachmentPreview from "./composer-attachment-preview";
type Props = Omit<PendingAttachment, "key"> & {
onRemove: () => void;
@@ -12,15 +13,23 @@ type Props = Omit<PendingAttachment, "key"> & {
/**
* 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({
name,
status,
attachment,
error,
preview_url,
onRemove,
}: Props) {
const image_src =
preview_url ||
(attachment?.media_type == "image"
? attachment.thumbnail_url || attachment.url
: undefined);
return (
<Row
title={status == "failed" ? `${name}: ${error}` : name}
@@ -28,6 +37,8 @@ export default function ComposerAttachmentChip({
>
{status == "uploading" ? (
<Loading size="smaller" />
) : image_src ? (
<ComposerAttachmentPreview src={image_src} />
) : (
<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}
attachment={item.attachment}
error={item.error}
preview_url={item.preview_url}
onRemove={() => onRemove({ key: item.key })}
/>
))}
@@ -8,6 +8,7 @@ import type {
} from "@/src/types";
import sendBotMessage from "../(functions)/send-bot-message";
import useComposerAttachments from "../(hooks)/use-composer-attachments";
import useComposerDraft from "../(hooks)/use-composer-draft";
import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments";
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.
* 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) {
const [text, setText] = useState("");
const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>();
const files = useComposerAttachments();
const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
const can_send =
Boolean(text.trim() || files.media_ids.length) &&