diff --git a/packages/app/src/composer/history/entry.test.ts b/packages/app/src/composer/history/entry.test.ts index 32ae31172a0..5b747cc7ce8 100644 --- a/packages/app/src/composer/history/entry.test.ts +++ b/packages/app/src/composer/history/entry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Prompt } from "@/composer/state" -import { prependHistoryEntry, type PromptHistoryComment } from "./entry" +import { prependHistoryEntry, removeHistoryEntry, type PromptHistoryComment } from "./entry" import { Schema } from "effect" import { PromptHistoryState } from "../schema" import { Persistence } from "@/runtime/persistence/schema" @@ -36,6 +36,24 @@ describe("Composer history", () => { expect(dedupedComments).toBe(commentsOnly) }) + test("removeHistoryEntry drops the entry recorded for a prompt and leaves others alone", () => { + const image: Prompt = [ + { type: "text", content: "look", start: 0, end: 4 }, + { type: "image", id: "img", filename: "big.png", mime: "image/png", blob: { id: "hash", url: "" } }, + ] + const entries = prependHistoryEntry(prependHistoryEntry([], text("earlier")), image, [comment("c1")]) + expect(entries).toHaveLength(2) + + const untouched = removeHistoryEntry(entries, text("never sent")) + expect(untouched).toBe(entries) + + const withoutComments = removeHistoryEntry(entries, image) + expect(withoutComments).toBe(entries) + + const removed = removeHistoryEntry(entries, image, [comment("c1")]) + expect(removed).toEqual(prependHistoryEntry([], text("earlier"))) + }) + test("insertion isolates canonical entries from source mutations", () => { const prompt: Prompt = [ { diff --git a/packages/app/src/composer/history/entry.ts b/packages/app/src/composer/history/entry.ts index 1233cf2a278..0c76c01d29f 100644 --- a/packages/app/src/composer/history/entry.ts +++ b/packages/app/src/composer/history/entry.ts @@ -48,6 +48,18 @@ export function prependHistoryEntry( return [entry, ...entries].slice(0, max) } +// A send that failed puts its prompt back in the composer, so the entry recorded for it would +// only duplicate the draft and keep its attachments referenced for as long as history holds it. +export function removeHistoryEntry( + entries: PromptHistoryStoredEntry[], + prompt: Prompt, + comments: PromptHistoryComment[] = [], +) { + const entry = { prompt, comments } satisfies PromptHistoryEntry + const next = entries.filter((item) => !isPromptEqual(item, entry)) + return next.length === entries.length ? entries : next +} + function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) { return ( commentA.path === commentB.path && diff --git a/packages/app/src/composer/history/store.ts b/packages/app/src/composer/history/store.ts index f29ab3de3ea..d72314b0b30 100644 --- a/packages/app/src/composer/history/store.ts +++ b/packages/app/src/composer/history/store.ts @@ -4,6 +4,7 @@ import { Persist, persisted } from "@/runtime/persistence/storage" import { clonePromptHistoryComments, prependHistoryEntry, + removeHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry, } from "./entry" @@ -13,6 +14,7 @@ import { PromptHistoryState } from "../schema" export type ComposerHistoryStore = { entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void + remove: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void } type PromptHistoryState = typeof PromptHistoryState.Type @@ -32,6 +34,13 @@ function createComposerHistoryStore( if (next === current.entries) return setCurrent("entries", next) }, + remove(prompt, mode, comments) { + const current = mode === "shell" ? shell : normal + const setCurrent = mode === "shell" ? setShell : setNormal + const next = removeHistoryEntry(current.entries, prompt, comments) + if (next === current.entries) return + setCurrent("entries", next) + }, } } @@ -56,5 +65,12 @@ export function createComposerHistory() { const metadata = clonePromptHistoryComments(comments) void ready.then(() => history.add(saved, mode, metadata)) }, + remove(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) { + const ready = mode === "shell" ? shellInit : normalInit + if (!(ready instanceof Promise)) return history.remove(prompt, mode, comments) + const saved = clonePrompt(prompt) + const metadata = clonePromptHistoryComments(comments) + void ready.then(() => history.remove(saved, mode, metadata)) + }, } } diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index 108a7d6443b..c785e95da7c 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -264,6 +264,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? editor: () => editor, queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })), addToHistory: (value, mode) => controller.addHistory(value, mode), + removeFromHistory: (value, mode, comments) => history.remove(value, mode, mode === "shell" ? [] : comments), resetHistory: () => controller.resetHistory(), setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }), closePopover: () => controller.dispatch({ type: "popover.close" }), diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index d100291cd1f..9db0102c368 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -53,6 +53,7 @@ function submitInput( notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} }, mode: "normal" | "shell" = "normal", commands: () => readonly { name: string }[] | undefined = () => [], + history: string[] = [], ) { return createComposerSubmit({ adapter, @@ -60,7 +61,9 @@ function submitInput( commands, editor: () => undefined, queueScroll() {}, - addToHistory() {}, + addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`), + removeFromHistory: (prompt) => + history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`), resetHistory() {}, setMode() {}, closePopover() {}, @@ -533,12 +536,8 @@ describe("Composer submission", () => { missingSelection() {}, failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()), } - const submission = submitInput( - adapter, - notify, - "normal", - () => [], - ) + const history: string[] = [] + const submission = submitInput(adapter, notify, "normal", () => [], history) await submission.submit(new Event("submit")) await first.promise @@ -549,6 +548,8 @@ describe("Composer submission", () => { expect(new Set(attempts).size).toBe(1) expect(statuses).toEqual(["running", "idle", "running", "idle"]) expect(state.current()).toMatchObject([{ type: "text", content: text }]) + // The restored prompt is the draft again, so history does not also keep it (and its attachments). + expect(history).toEqual([`add:${text}`, `remove:${text}`, `add:${text}`, `remove:${text}`]) }) test("forwards structured mentions to custom commands", async () => { diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 31a368f886a..762fc07978a 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -32,6 +32,7 @@ type ComposerSubmitInput = { editor: () => HTMLDivElement | undefined queueScroll: () => void addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void + removeFromHistory: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void resetHistory: () => void setMode: (mode: "normal" | "shell") => void closePopover: () => void @@ -250,6 +251,8 @@ function restoreSubmission( ) { const restored = submission.restore() if (!restored) return false + // The prompt is back in the composer; its history entry would only keep attachments referenced. + input.removeFromHistory(value.prompt, value.mode, comments) restored.target.set(restored.prompt, promptLength(restored.prompt)) restored.target.mode.set(value.mode) restored.target.context.replaceComments( diff --git a/packages/ui/src/feedback/toast/description.test.ts b/packages/ui/src/feedback/toast/description.test.ts new file mode 100644 index 00000000000..ca1c5ce2fb0 --- /dev/null +++ b/packages/ui/src/feedback/toast/description.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { boundToastDescription, toastDescriptionLimit } from "./description" + +describe("toast description bound", () => { + test("keeps ordinary descriptions and the options object", () => { + const options = { title: "Failed", description: "Attachment exceeds the limit" } + expect(boundToastDescription(options)).toBe(options) + expect(boundToastDescription({ description: undefined })).toEqual({ description: undefined }) + }) + + test("cuts a description that embeds a whole data URL", () => { + const description = `Attachment exceeds the limit: data:text/plain;base64,${"Q".repeat(50 * 1024 * 1024)}` + const bounded = boundToastDescription({ title: "Failed to send prompt", description }) + expect(bounded.description!.length).toBe(toastDescriptionLimit + 1) + expect(bounded.description!.endsWith("…")).toBe(true) + expect(bounded.description!.startsWith("Attachment exceeds the limit: data:text/plain;base64,")).toBe(true) + expect(bounded.title).toBe("Failed to send prompt") + }) +}) + diff --git a/packages/ui/src/feedback/toast/description.ts b/packages/ui/src/feedback/toast/description.ts new file mode 100644 index 00000000000..eaaba104a73 --- /dev/null +++ b/packages/ui/src/feedback/toast/description.ts @@ -0,0 +1,9 @@ +// A toast shows a few lines; the rest of a long description would only be retained (in the dedupe +// key, the active-toast registry and the toaster's store) for as long as the toast lives. Server +// errors have carried a whole data URL here. +export const toastDescriptionLimit = 2000 + +export function boundToastDescription(options: T): T { + if (!options.description || options.description.length <= toastDescriptionLimit) return options + return { ...options, description: `${options.description.slice(0, toastDescriptionLimit)}…` } +} diff --git a/packages/ui/src/feedback/toast/toast.tsx b/packages/ui/src/feedback/toast/toast.tsx index 554f03b7728..cab8b7a707b 100644 --- a/packages/ui/src/feedback/toast/toast.tsx +++ b/packages/ui/src/feedback/toast/toast.tsx @@ -1,4 +1,5 @@ import { Toaster, toast, type ToasterProps } from "solid-sonner" +import { boundToastDescription } from "./description" import { isRTL } from "@kobalte/core/i18n" import type { ComponentProps, JSX } from "solid-js" import { createContext, onCleanup, onMount, splitProps, useContext } from "solid-js" @@ -205,7 +206,7 @@ const activeToastByKey = new Map() const activeToastById = new Map() export function showToast(options: ToastOptions | string) { - const opts: ToastOptions = typeof options === "string" ? { description: options } : options + const opts = boundToastDescription(typeof options === "string" ? { description: options } : options) const key = JSON.stringify({ title: opts.title, description: opts.description,