fix(chat-input): stop template dialogs from sending and fix attachment races

- Template dialogs no longer submit the outer chat form
- Sending is blocked while files or URLs are still extracting
- File and URL extraction no longer drop or resurrect entries
- IME composition Enter no longer sends
- Tool call cards show the error text; keyboard handling on cards fixed
- Template import available when empty, edit dialog resets, saved templates refresh
- Only png/jpeg/gif/webp images accepted, SVG sent as text; PDF objects released
- parse-url request sends the access code
This commit is contained in:
dayuan.jiang
2026-10-03 17:45:41 +09:00
parent 5c7613ea09
commit 87edf2e19d
13 changed files with 360 additions and 156 deletions
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest"
import { isTextFile } from "@/lib/pdf-utils"
describe("isTextFile", () => {
it("treats SVG files as text so their markup is sent to the model", () => {
const svg = new File(["<svg/>"], "diagram.svg", {
type: "image/svg+xml",
})
expect(isTextFile(svg)).toBe(true)
})
it("does not treat raster images as text", () => {
const png = new File(["x"], "photo.png", { type: "image/png" })
expect(isTextFile(png)).toBe(false)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { STORAGE_KEYS } from "@/lib/storage"
import { extractUrlContent } from "@/lib/url-utils"
describe("extractUrlContent", () => {
afterEach(() => {
vi.unstubAllGlobals()
localStorage.clear()
})
it("sends the saved access code with the request", async () => {
localStorage.setItem(STORAGE_KEYS.accessCode, "secret")
const body = { title: "T", content: "body", charCount: 4 }
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify(body)))
vi.stubGlobal("fetch", fetchMock)
const data = await extractUrlContent("https://example.com")
expect(data.content).toBe("body")
const headers = fetchMock.mock.calls[0][1].headers
expect(headers["x-access-code"]).toBe("secret")
})
})
+102
View File
@@ -0,0 +1,102 @@
import { act, renderHook } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { extractPdfText, extractTextFileContent } from "@/lib/pdf-utils"
import { useFileProcessor } from "@/lib/use-file-processor"
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
vi.mock("@/lib/pdf-utils", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/pdf-utils")>()),
extractPdfText: vi.fn(),
extractTextFileContent: vi.fn(),
}))
// A promise we can resolve from the test, to control extraction timing
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((r) => {
resolve = r
})
return { promise, resolve }
}
const pdfFile = () =>
new File(["%PDF"], "slow.pdf", { type: "application/pdf" })
const textFile = () => new File(["notes"], "notes.txt", { type: "text/plain" })
describe("useFileProcessor", () => {
beforeEach(() => {
vi.mocked(extractPdfText).mockReset()
vi.mocked(extractTextFileContent).mockReset()
})
it("marks queued files as extracting before the first one finishes", async () => {
const pdf = deferred<string>()
vi.mocked(extractPdfText).mockReturnValue(pdf.promise)
vi.mocked(extractTextFileContent).mockResolvedValue("notes")
const a = pdfFile()
const b = textFile()
const { result } = renderHook(() => useFileProcessor())
let done!: Promise<void>
act(() => {
done = result.current.handleFileChange([a, b])
})
expect(result.current.pdfData.get(a)?.isExtracting).toBe(true)
expect(result.current.pdfData.get(b)?.isExtracting).toBe(true)
await act(async () => {
pdf.resolve("pdf text")
await done
})
expect(result.current.pdfData.get(a)?.text).toBe("pdf text")
expect(result.current.pdfData.get(b)?.text).toBe("notes")
})
it("keeps text of a file added while an earlier file is extracting", async () => {
const pdf = deferred<string>()
vi.mocked(extractPdfText).mockReturnValue(pdf.promise)
vi.mocked(extractTextFileContent).mockResolvedValue("notes")
const a = pdfFile()
const b = textFile()
const { result } = renderHook(() => useFileProcessor())
let first!: Promise<void>
act(() => {
first = result.current.handleFileChange([a])
})
await act(async () => {
await result.current.handleFileChange([a, b])
})
expect(result.current.pdfData.get(b)?.text).toBe("notes")
await act(async () => {
pdf.resolve("pdf text")
await first
})
expect(result.current.pdfData.get(a)?.text).toBe("pdf text")
expect(result.current.pdfData.get(b)?.text).toBe("notes")
})
it("does not bring back a file removed while extracting", async () => {
const pdf = deferred<string>()
vi.mocked(extractPdfText).mockReturnValue(pdf.promise)
const a = pdfFile()
const { result } = renderHook(() => useFileProcessor())
let first!: Promise<void>
act(() => {
first = result.current.handleFileChange([a])
})
await act(async () => {
await result.current.handleFileChange([])
})
await act(async () => {
pdf.resolve("pdf text")
await first
})
expect(result.current.pdfData.has(a)).toBe(false)
expect(result.current.files).toEqual([])
})
})