diff --git a/server/composer-attachments.test.ts b/server/composer-attachments.test.ts new file mode 100644 index 000000000..08d381ccd --- /dev/null +++ b/server/composer-attachments.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { + PASTE_CHARS, + PASTE_LINES, + byteLength, + composeMessage, + isAttachment, + isLongPaste, + pasteAttachment, + pasteSummary, +} from "../src/lib/composer-attachments.ts"; + +describe("composer paste attachments", () => { + it("classifies long character and line pastes without changing short text", () => { + expect(isLongPaste("x".repeat(PASTE_CHARS - 1))).toBe(false); + expect(isLongPaste("x".repeat(PASTE_CHARS))).toBe(true); + expect(isLongPaste(Array.from({ length: PASTE_LINES }, () => "x").join("\n"))).toBe(true); + }); + + it("measures UTF-8 once and reports a useful summary", () => { + const attachment = pasteAttachment("héllo\n世界"); + expect(attachment.size).toBe(byteLength(attachment.text)); + expect(attachment.size).toBeGreaterThan(attachment.text.length); + expect(attachment.lines).toBe(2); + expect(pasteSummary(attachment)).toMatch(/^2 lines, /); + }); + + it("composes attachment-only and mixed messages in a stable order", () => { + const first = pasteAttachment("first"); + const second = pasteAttachment("second"); + expect(composeMessage("", [first])).toBe( + '\nfirst\n', + ); + expect(composeMessage(" intro ", [first, second])).toBe( + 'intro\n\n\nfirst\n\n\n' + + '\nsecond\n', + ); + }); + + it("rejects malformed persisted attachments", () => { + expect(isAttachment({ kind: "paste", id: "a", text: "ok", size: 2, lines: 1 })).toBe(true); + expect(isAttachment({ kind: "paste", id: "a", text: "missing size" })).toBe(false); + expect(isAttachment({ kind: "file", id: "a", text: "wrong kind", size: 2 })).toBe(false); + }); +}); diff --git a/server/drafts.test.ts b/server/drafts.test.ts new file mode 100644 index 000000000..08be22363 --- /dev/null +++ b/server/drafts.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { + getDraft, + getDraftAttachments, + setDraft, + setDraftAttachments, +} from "../src/lib/drafts.ts"; +import { pasteAttachment } from "../src/lib/composer-attachments.ts"; + +function memoryStore() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + }; +} + +describe("composer drafts", () => { + it("keeps text and attachments isolated per bot or room", () => { + const store = memoryStore(); + const paste = pasteAttachment("bot paste"); + setDraft(store, "bot:one", "hello"); + setDraftAttachments(store, "bot:one", [paste]); + setDraft(store, "group:two", "room text"); + + expect(getDraft(store, "bot:one")).toBe("hello"); + expect(getDraftAttachments(store, "bot:one")).toEqual([paste]); + expect(getDraft(store, "group:two")).toBe("room text"); + expect(getDraftAttachments(store, "group:two")).toEqual([]); + }); + + it("clears empty entries and ignores malformed stored attachments", () => { + const store = memoryStore(); + setDraft(store, "bot:one", "hello"); + setDraft(store, "bot:one", ""); + store.setItem( + "omb-draft-attachments", + JSON.stringify({ "bot:one": [{ kind: "paste", id: "broken" }] }), + ); + + expect(getDraft(store, "bot:one")).toBe(""); + expect(getDraftAttachments(store, "bot:one")).toEqual([]); + }); +}); diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 203c1ab0c..4537357db 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -1,10 +1,16 @@ import { track } from "@/lib/analytics"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArrowUp, Clock, Mic, Square, X } from "lucide-react"; import { useStore, visibleMessages, type Bot, type Group } from "@/state/store"; import { cn } from "@/lib/cn"; -import { useDraft } from "@/lib/drafts"; +import { useComposerDraft } from "@/lib/drafts"; import { MausAvatar } from "./Avatar"; +import { ComposerAttachments } from "./ComposerAttachments"; +import { + composeMessage, + isLongPaste, + pasteAttachment, +} from "@/lib/composer-attachments"; import { normalizeState } from "@/lib/mascot"; import { PendingApprovalActions, PendingApprovalPanel, pendingApprovals } from "./PendingApproval"; import { useDesktopCapabilities } from "./DesktopCapabilities"; @@ -50,9 +56,15 @@ export function Composer({ const busyName = group ? (members?.find((b) => b.id === group.busyBotId)?.name ?? "A bot") : (bot?.name ?? "The bot"); - // per-thread draft: switching bots unmounts this component, so the text - // has to outlive it (see lib/drafts) - const [text, setText] = useDraft(group ? `group:${group.id}` : `bot:${bot?.id ?? ""}`); + // Per-thread draft: switching bots unmounts this component, so both the + // text and its attachment chips have to outlive it (see lib/drafts). + const [text, setText, attachments, setAttachments] = useComposerDraft( + group ? `group:${group.id}` : `bot:${bot?.id ?? ""}`, + ); + const removeAttachment = useCallback( + (id: string) => setAttachments((prev) => prev.filter((a) => a.id !== id)), + [setAttachments], + ); const [recording, setRecording] = useState(false); const [speechError, setSpeechError] = useState(null); const [caret, setCaret] = useState(0); @@ -103,12 +115,15 @@ export function Composer({ // One message may be queued while the bot works; it auto-sends the moment // the turn settles. Enter during a turn queues instead of silently dying. const [queued, setQueued] = useState(null); + // a chip on its own is a message: the send control has to appear for it + const hasContent = Boolean(text.trim()) || attachments.length > 0; const send = () => { - const t = text.trim(); + const t = composeMessage(text, attachments); if (!t) return; if (busy) { setQueued(t); setText(""); + setAttachments([]); return; } if (group) { @@ -119,6 +134,7 @@ export function Composer({ track("message_sent", { driver: bot.modelSelection?.instanceId }); } setText(""); + setAttachments([]); }; useEffect(() => { if (!busy && queued) { @@ -236,6 +252,7 @@ export function Composer({ /> )} +