fix(app): stop retaining failed sends in history and oversized toast text (#49717)

This commit is contained in:
Luke Parker
2026-09-19 12:00:55 +10:00
committed by GitHub
parent 1c90e8f01c
commit dd71cdbd84
9 changed files with 90 additions and 9 deletions
@@ -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 = [
{
@@ -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 &&
@@ -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))
},
}
}
+1
View File
@@ -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" }),
+8 -7
View File
@@ -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 () => {
+3
View File
@@ -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(
@@ -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")
})
})
@@ -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<T extends { description?: string }>(options: T): T {
if (!options.description || options.description.length <= toastDescriptionLimit) return options
return { ...options, description: `${options.description.slice(0, toastDescriptionLimit)}…` }
}
+2 -1
View File
@@ -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<string, ActiveToast>()
const activeToastById = new Map<number, ActiveToast>()
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,