feat(webui): support image uploads in composer and message bubbles
This commit is contained in:
@@ -53,6 +53,7 @@ vi.mock("@/lib/nanobot-client", () => {
|
||||
defaultChatId: string | null = null;
|
||||
connect = connectSpy;
|
||||
onStatus = () => () => {};
|
||||
onError = () => () => {};
|
||||
onChat = () => () => {};
|
||||
sendMessage = vi.fn();
|
||||
newChat = vi.fn();
|
||||
|
||||
@@ -20,7 +20,7 @@ class FakeSocket {
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onclose: ((ev?: { code?: number }) => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
@@ -36,6 +36,13 @@ class FakeSocket {
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
/** Simulate a server-initiated drop with a specific wire-level close code
|
||||
* (e.g. ``1009`` for Message Too Big). */
|
||||
fakeCloseWithCode(code: number) {
|
||||
this.readyState = FakeSocket.CLOSED;
|
||||
this.onclose?.({ code });
|
||||
}
|
||||
|
||||
fakeOpen() {
|
||||
this.readyState = FakeSocket.OPEN;
|
||||
this.onopen?.();
|
||||
@@ -172,6 +179,95 @@ describe("NanobotClient", () => {
|
||||
expect(seen.at(-1)).toBe("closed");
|
||||
});
|
||||
|
||||
it("passes media through into the message envelope", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-x", "look", [
|
||||
{ data_url: "data:image/png;base64,AAAA", name: "shot.png" },
|
||||
]);
|
||||
const lastFrame = JSON.parse(lastSocket().sent.at(-1) as string);
|
||||
expect(lastFrame).toEqual({
|
||||
type: "message",
|
||||
chat_id: "chat-x",
|
||||
content: "look",
|
||||
media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("omits media from the envelope when no images are attached", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-x", "hello");
|
||||
const lastFrame = JSON.parse(lastSocket().sent.at(-1) as string);
|
||||
expect(lastFrame).not.toHaveProperty("media");
|
||||
expect(lastFrame).toEqual({
|
||||
type: "message",
|
||||
chat_id: "chat-x",
|
||||
content: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a message_too_big error when the socket closes with code 1009", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string }> = [];
|
||||
client.onError((e) => errors.push(e));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
// Server rejected an outbound frame as too large.
|
||||
lastSocket().fakeCloseWithCode(1009);
|
||||
expect(errors).toEqual([{ kind: "message_too_big" }]);
|
||||
});
|
||||
|
||||
it("isolates throwing error handlers so reconnect bookkeeping still runs", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: true,
|
||||
maxBackoffMs: 5,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
// First handler explodes; subsequent reconnect state must be untouched.
|
||||
client.onError(() => {
|
||||
throw new Error("subscriber blew up");
|
||||
});
|
||||
const seenStatuses: string[] = [];
|
||||
client.onStatus((s) => seenStatuses.push(s));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeCloseWithCode(1009);
|
||||
// Despite the throwing handler, the client must still schedule a reconnect.
|
||||
expect(seenStatuses).toContain("reconnecting");
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(FakeSocket.instances.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("does not emit a stream error on a vanilla socket close", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string }> = [];
|
||||
client.onError((e) => errors.push(e));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().close();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("surfaces 'reconnecting' only on an unexpected drop", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import type { EncodeResponse } from "@/lib/imageEncode";
|
||||
|
||||
const encodeImage = vi.fn<[File], Promise<EncodeResponse>>();
|
||||
|
||||
vi.mock("@/lib/imageEncode", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/imageEncode")>();
|
||||
return {
|
||||
...actual,
|
||||
encodeImage: (file: File) => encodeImage(file),
|
||||
};
|
||||
});
|
||||
|
||||
function pngFile(name = "a.png", size = 10) {
|
||||
return new File([new Uint8Array(size)], name, { type: "image/png" });
|
||||
}
|
||||
|
||||
function resolveReady(file: File): EncodeResponse {
|
||||
return {
|
||||
id: "stub",
|
||||
ok: true,
|
||||
dataUrl: `data:image/png;base64,${btoa(file.name)}`,
|
||||
mimeType: "image/png",
|
||||
bytes: file.size,
|
||||
normalized: false,
|
||||
} as EncodeResponse;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
encodeImage.mockReset();
|
||||
let id = 0;
|
||||
// Tests never read the preview URL contents so a stable blob: stub is fine.
|
||||
if (!(globalThis.URL as unknown as { createObjectURL?: unknown }).createObjectURL) {
|
||||
(globalThis.URL as unknown as { createObjectURL: (b: Blob) => string }).createObjectURL =
|
||||
() => `blob:mock/${++id}`;
|
||||
}
|
||||
if (!(globalThis.URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL) {
|
||||
(globalThis.URL as unknown as { revokeObjectURL: (u: string) => void }).revokeObjectURL =
|
||||
() => {};
|
||||
}
|
||||
});
|
||||
|
||||
describe("ThreadComposer — image attachments", () => {
|
||||
it("attaches a picked image and includes its data url on send", async () => {
|
||||
const file = pngFile("a.png");
|
||||
encodeImage.mockResolvedValueOnce(resolveReady(file));
|
||||
const onSend = vi.fn();
|
||||
|
||||
render(<ThreadComposer onSend={onSend} />);
|
||||
|
||||
const input = screen
|
||||
.getByLabelText(/message input/i)
|
||||
.closest("form")!
|
||||
.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("composer-chip")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const textarea = screen.getByLabelText(/message input/i);
|
||||
fireEvent.change(textarea, { target: { value: "hi" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
const [content, images] = onSend.mock.calls[0];
|
||||
expect(content).toBe("hi");
|
||||
expect(images).toHaveLength(1);
|
||||
expect(images[0].media.data_url).toContain("data:image/png;base64,");
|
||||
expect(images[0].media.name).toBe("a.png");
|
||||
});
|
||||
|
||||
it("blocks send while an image is still encoding", async () => {
|
||||
const file = pngFile("slow.png");
|
||||
let resolveEncode: (r: EncodeResponse) => void = () => {};
|
||||
encodeImage.mockReturnValueOnce(
|
||||
new Promise((r) => {
|
||||
resolveEncode = r;
|
||||
}),
|
||||
);
|
||||
const onSend = vi.fn();
|
||||
|
||||
render(<ThreadComposer onSend={onSend} />);
|
||||
|
||||
const fileInput = screen
|
||||
.getByLabelText(/message input/i)
|
||||
.closest("form")!
|
||||
.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
const textarea = screen.getByLabelText(/message input/i);
|
||||
fireEvent.change(textarea, { target: { value: "hello" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveEncode(resolveReady(file));
|
||||
await Promise.resolve();
|
||||
});
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects a non-image paste silently without adding a chip", async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<ThreadComposer onSend={onSend} />);
|
||||
const textarea = screen.getByLabelText(/message input/i);
|
||||
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
files: [],
|
||||
items: [
|
||||
{
|
||||
kind: "string",
|
||||
type: "text/plain",
|
||||
getAsFile: () => null,
|
||||
},
|
||||
],
|
||||
types: ["text/plain"],
|
||||
getData: () => "some pasted text",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("composer-chip")).toBeNull();
|
||||
expect(encodeImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces an inline error when encoding fails", async () => {
|
||||
const file = pngFile("bad.png");
|
||||
encodeImage.mockResolvedValueOnce({
|
||||
id: "stub",
|
||||
ok: false,
|
||||
reason: "decode_failed",
|
||||
} as EncodeResponse);
|
||||
const onSend = vi.fn();
|
||||
|
||||
render(<ThreadComposer onSend={onSend} />);
|
||||
const fileInput = screen
|
||||
.getByLabelText(/message input/i)
|
||||
.closest("form")!
|
||||
.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const chip = screen.getByTestId("composer-chip");
|
||||
expect(chip.textContent ?? "").toMatch(/decode|image/i);
|
||||
});
|
||||
|
||||
const textarea = screen.getByLabelText(/message input/i);
|
||||
fireEvent.change(textarea, { target: { value: "hi" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,21 @@ import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
|
||||
function makeClient() {
|
||||
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
||||
return {
|
||||
status: "open" as const,
|
||||
defaultChatId: null as string | null,
|
||||
onStatus: () => () => {},
|
||||
onChat: () => () => {},
|
||||
onError: (handler: (err: { kind: string }) => void) => {
|
||||
errorHandlers.add(handler);
|
||||
return () => {
|
||||
errorHandlers.delete(handler);
|
||||
};
|
||||
},
|
||||
_emitError(err: { kind: string }) {
|
||||
for (const h of errorHandlers) h(err);
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
@@ -88,6 +98,7 @@ describe("ThreadShell", () => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"persist me across tabs",
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
|
||||
@@ -151,6 +162,7 @@ describe("ThreadShell", () => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
"chat-a",
|
||||
"delete me cleanly",
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
expect(screen.getByText("delete me cleanly")).toBeInTheDocument();
|
||||
@@ -241,6 +253,84 @@ describe("ThreadShell", () => {
|
||||
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a dismissible banner when the stream reports message_too_big", async () => {
|
||||
const client = makeClient();
|
||||
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
||||
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={onNewChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
// No banner yet: only appears once the client emits a matching error.
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
client._emitError({ kind: "message_too_big" });
|
||||
});
|
||||
|
||||
const banner = await screen.findByRole("alert");
|
||||
expect(banner).toHaveTextContent("Message too large");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the stream error banner when the user switches to another chat", async () => {
|
||||
const client = makeClient();
|
||||
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
||||
|
||||
const { rerender } = render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={onNewChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
client._emitError({ kind: "message_too_big" });
|
||||
});
|
||||
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||
|
||||
// Switch to a different chat. The banner was about the *previous* send
|
||||
// in chat-a; it must not leak into chat-b's view.
|
||||
await act(async () => {
|
||||
rerender(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-b")}
|
||||
title="Chat chat-b"
|
||||
onToggleSidebar={() => {}}
|
||||
onGoHome={() => {}}
|
||||
onNewChat={onNewChat}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the previous thread immediately while the next session loads", async () => {
|
||||
const client = makeClient();
|
||||
const onNewChat = vi.fn().mockResolvedValue("chat-b");
|
||||
|
||||
@@ -13,6 +13,7 @@ function fakeClient() {
|
||||
status: "open" as const,
|
||||
defaultChatId: null as string | null,
|
||||
onStatus: () => () => {},
|
||||
onError: () => () => {},
|
||||
onChat(chatId: string, h: (ev: InboundEvent) => void) {
|
||||
let set = handlers.get(chatId);
|
||||
if (!set) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useSessions } from "@/hooks/useSessions";
|
||||
import { useSessionHistory, useSessions } from "@/hooks/useSessions";
|
||||
import * as api from "@/lib/api";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
|
||||
@@ -21,6 +21,7 @@ function fakeClient() {
|
||||
status: "open" as const,
|
||||
defaultChatId: null as string | null,
|
||||
onStatus: () => () => {},
|
||||
onError: () => () => {},
|
||||
onChat: () => () => {},
|
||||
sendMessage: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
@@ -86,6 +87,55 @@ describe("useSessions", () => {
|
||||
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
|
||||
});
|
||||
|
||||
it("hydrates media_urls from historical user turns into UIMessage.images", async () => {
|
||||
// Round-trip check for the signed-media replay: the backend emits
|
||||
// ``media_urls`` on a historical user row and the hook must surface them
|
||||
// as ``images`` so the bubble can render the preview. Assistant turns
|
||||
// carry no media_urls and should not sprout an ``images`` field.
|
||||
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
|
||||
key: "websocket:chat-media",
|
||||
created_at: "2026-04-20T10:00:00Z",
|
||||
updated_at: "2026-04-20T10:05:00Z",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "what's this?",
|
||||
timestamp: "2026-04-20T10:00:00Z",
|
||||
media_urls: [
|
||||
{ url: "/api/media/sig-1/payload-1", name: "snap.png" },
|
||||
{ url: "/api/media/sig-2/payload-2", name: "diag.jpg" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "it's a cat",
|
||||
timestamp: "2026-04-20T10:00:01Z",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "follow-up without images",
|
||||
timestamp: "2026-04-20T10:01:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:chat-media"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
const [first, second, third] = result.current.messages;
|
||||
expect(first.role).toBe("user");
|
||||
expect(first.images).toEqual([
|
||||
{ url: "/api/media/sig-1/payload-1", name: "snap.png" },
|
||||
{ url: "/api/media/sig-2/payload-2", name: "diag.jpg" },
|
||||
]);
|
||||
expect(second.role).toBe("assistant");
|
||||
expect(second.images).toBeUndefined();
|
||||
expect(third.role).toBe("user");
|
||||
expect(third.images).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the session in the list when delete fails", async () => {
|
||||
vi.mocked(api.listSessions).mockResolvedValue([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user