import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; import type { CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types"; vi.mock("@/lib/imageEncode", () => ({ encodeImage: vi.fn(async (file: File) => ({ ok: true, dataUrl: `data:${file.type || "image/png"};base64,aW1hZ2U=`, bytes: Math.max(1, file.size), normalized: false, })), })); const COMMANDS: SlashCommand[] = [ { command: "/stop", title: "Stop current task", description: "Cancel the active agent turn.", icon: "square", lifecycle: "stop_active_turn", acceptsArgs: false, }, { command: "/history", title: "Show conversation history", description: "Print the last N persisted messages.", icon: "history", argHint: "[n]", lifecycle: "side_channel", acceptsArgs: true, }, ]; const CLI_APPS: CliAppInfo[] = [ { name: "gimp", display_name: "GIMP", category: "image", description: "Image editing", requires: "", source: "harness", entry_point: "cli-anything-gimp", install_supported: true, installed: true, available: true, status: "installed", logo_url: "https://example.invalid/gimp.svg", brand_color: "#5C5543", skill_installed: true, }, { name: "blender", display_name: "Blender", category: "3d", description: "3D creation", requires: "", source: "harness", entry_point: "cli-anything-blender", install_supported: true, installed: true, available: true, status: "installed", logo_url: null, brand_color: "#E87D0D", skill_installed: true, }, { name: "krita", display_name: "Krita", category: "image", description: "Painting", requires: "", source: "harness", entry_point: "cli-anything-krita", install_supported: true, installed: false, available: false, status: "not_installed", logo_url: null, brand_color: "#3BABFF", skill_installed: false, }, ]; const MCP_PRESETS: McpPresetInfo[] = [ { name: "browserbase", display_name: "Browserbase", category: "browser", description: "Cloud browser automation", docs_url: "https://docs.browserbase.com", transport: "streamableHttp", requires: "Browserbase API key", note: "", install_supported: true, installed: true, configured: true, available: true, status: "configured", logo_url: "https://example.invalid/browserbase.svg", brand_color: "#111827", required_fields: [], connection_summary: "https://mcp.browserbase.com/mcp", }, { name: "figma", display_name: "Figma", category: "design", description: "Design context", docs_url: "https://figma.com", transport: "streamableHttp", requires: "Figma local app", note: "", install_supported: true, installed: true, configured: false, available: false, status: "missing_credentials", logo_url: null, brand_color: "#F24E1E", required_fields: [], connection_summary: "", }, ]; const ORIGINAL_INNER_HEIGHT = window.innerHeight; const ORIGINAL_MEDIA_DEVICES = navigator.mediaDevices; function mockBlobUrls() { Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:composer-test"), }); Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: vi.fn(), }); } function stubVisualViewport({ height, offsetTop = 0, }: { height: number; offsetTop?: number; }) { const target = new EventTarget(); vi.stubGlobal("visualViewport", { width: 390, height, offsetTop, offsetLeft: 0, pageTop: offsetTop, pageLeft: 0, scale: 1, addEventListener: target.addEventListener.bind(target), removeEventListener: target.removeEventListener.bind(target), dispatchEvent: target.dispatchEvent.bind(target), } as unknown as VisualViewport); } afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); Reflect.deleteProperty(window, "nanobotHost"); if (ORIGINAL_MEDIA_DEVICES) { Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: ORIGINAL_MEDIA_DEVICES, }); } else { Reflect.deleteProperty(navigator, "mediaDevices"); } window.localStorage.clear(); Object.defineProperty(window, "innerHeight", { value: ORIGINAL_INNER_HEIGHT, configurable: true, }); }); function rect(init: Partial): DOMRect { const top = init.top ?? 0; const left = init.left ?? 0; const width = init.width ?? 0; const height = init.height ?? 0; return { x: init.x ?? left, y: init.y ?? top, top, left, width, height, right: init.right ?? left + width, bottom: init.bottom ?? top + height, toJSON: () => ({}), }; } function mockVoiceRecorder(blob = new Blob(["voice"], { type: "audio/webm" })) { const stopTrack = vi.fn(); const getUserMedia = vi.fn(async () => ({ getTracks: () => [{ stop: stopTrack }], })); Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia }, }); class FakeMediaRecorder { static isTypeSupported = vi.fn((type: string) => type === "audio/webm"); state: RecordingState = "inactive"; mimeType = blob.type; ondataavailable: ((event: BlobEvent) => void) | null = null; onstop: (() => void) | null = null; start() { this.state = "recording"; } stop() { this.state = "inactive"; this.ondataavailable?.({ data: blob } as BlobEvent); this.onstop?.(); } } vi.stubGlobal("MediaRecorder", FakeMediaRecorder); return { getUserMedia, stopTrack }; } function mockVoiceAudioInput( sample = 128, state: AudioContextState = "running", decodedChannels?: Float32Array[], ) { const decodeAudioDataMock = vi.fn(async () => { if (!decodedChannels) throw new Error("decodeAudioData not mocked"); return { numberOfChannels: decodedChannels.length, sampleRate: 16_000, getChannelData: (channel: number) => decodedChannels[channel], } as AudioBuffer; }); class FakeAudioContext { state = state; createMediaStreamSource() { return { connect: vi.fn(), disconnect: vi.fn() }; } createAnalyser() { return { fftSize: 256, smoothingTimeConstant: 0, disconnect: vi.fn(), getByteTimeDomainData: (data: Uint8Array) => data.fill(sample), }; } close = vi.fn(async () => undefined); decodeAudioData = decodeAudioDataMock; resume = vi.fn(async () => undefined); } vi.stubGlobal("AudioContext", FakeAudioContext); vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => window.setTimeout(() => callback(performance.now()), 16) as unknown as number ); vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => window.clearTimeout(id as unknown as number) ); return { decodeAudioData: decodeAudioDataMock }; } async function waitForVoiceCapture(): Promise { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 700)); }); } function bytesFromDataUrl(dataUrl: string): Uint8Array { const [, base64 = ""] = dataUrl.split(","); return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); } function ascii(bytes: Uint8Array, offset: number, length: number): string { return String.fromCharCode(...bytes.slice(offset, offset + length)); } const MODEL_PRESETS = [ { name: "kimi", label: "Kimi", provider: "moonshot" }, { name: "dflash", label: "DFlash", provider: "deepseek" }, { name: "dspro", label: "DS Pro", provider: "deepseek" }, ]; function renderPresetComposer(variant: "thread" | "hero" = "thread") { const onPresetChange = vi.fn(); render( , ); return { badge: screen.getByRole("spinbutton", { name: "Kimi" }), onPresetChange, }; } function pointerDown(badge: HTMLElement, pointerId = 7, clientY = 100, button = 0) { fireEvent.pointerDown(badge, { button, clientY, isPrimary: true, pointerId, pointerType: "mouse", }); } function longPress(badge: HTMLElement, pointerId = 7) { pointerDown(badge, pointerId); act(() => { vi.advanceTimersByTime(400); }); } describe("ThreadComposer", () => { it("focuses and sends a removable quoted answer excerpt", async () => { const onSend = vi.fn(); const onQuotedContextChange = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); await waitFor(() => expect(input).toHaveFocus()); expect(screen.getByLabelText("Quoted context")).toHaveTextContent("selected answer excerpt"); fireEvent.change(input, { target: { value: "What does this mean?" } }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); expect(onSend).toHaveBeenCalledWith("What does this mean?", undefined, { quotedContext: "selected answer excerpt", }); expect(onQuotedContextChange).toHaveBeenCalledWith(null); }); it("removes quoted context without clearing the draft", () => { const onQuotedContextChange = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "keep this draft" } }); fireEvent.click(screen.getByRole("button", { name: "Remove quoted context" })); expect(onQuotedContextChange).toHaveBeenCalledWith(null); expect(input).toHaveValue("keep this draft"); }); it("renders a readonly hero model composer when provided", () => { render( , ); expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Search" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Reason" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Deep research" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Voice input" })).not.toBeInTheDocument(); const input = screen.getByPlaceholderText("Ask anything..."); expect(input).toBeInTheDocument(); expect(input.className).toContain("min-h-[78px]"); expect(input.className).toContain("text-[16px]"); expect(input.className).toContain("pt-[27px]"); fireEvent.change(input, { target: { value: "1" } }); expect(input.className).toContain("pt-[27px]"); expect(input.parentElement?.parentElement?.className).toContain("max-w-[58rem]"); }); it("keeps the thread composer compact while matching the hero style", () => { render( , ); expect(screen.getByText("gpt-4o")).toBeInTheDocument(); expect(screen.getByTestId("composer-model-logo-openai")).toBeInTheDocument(); const input = screen.getByPlaceholderText("Type your message..."); expect(input.className).toContain("min-h-[50px]"); expect(input.className).toContain("text-[16px]"); expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]"); expect(input.parentElement?.parentElement?.className).toContain("rounded-[22px]"); expect(input.parentElement?.parentElement?.className).not.toContain("shadow-"); expect(screen.getByRole("button", { name: "Attach files" }).className).toContain("bg-card"); expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground"); expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument(); }); it("scrolls complete preset pills after a left-button long press and wraps", () => { vi.useFakeTimers(); const { badge, onPresetChange } = renderPresetComposer(); expect(badge).toHaveClass("h-9"); expect(badge).toHaveStyle({ touchAction: "manipulation" }); const idleTouchMove = new Event("touchmove", { bubbles: true, cancelable: true, }); badge.dispatchEvent(idleTouchMove); expect(idleTouchMove.defaultPrevented).toBe(false); fireEvent.click(badge); pointerDown(badge); fireEvent.pointerMove(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" }); act(() => vi.advanceTimersByTime(500)); fireEvent.pointerUp(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" }); expect(onPresetChange).not.toHaveBeenCalled(); longPress(badge); expect(badge).toHaveAttribute("data-switching", "true"); const viewport = screen.getByTestId("composer-model-pill-viewport"); expect(viewport).toHaveClass("overflow-hidden", "-left-2", "-top-3", "-bottom-3"); const track = screen.getByTestId("composer-model-pill-track"); expect(track).toHaveClass("items-end", "gap-1"); const activeTouchMove = new Event("touchmove", { bubbles: true, cancelable: true, }); badge.dispatchEvent(activeTouchMove); expect(activeTouchMove.defaultPrevented).toBe(true); const pills = track.querySelectorAll(".composer-model-pill"); expect(pills).toHaveLength(5); expect(Array.from(pills).every((pill) => pill.classList.contains("w-fit"))).toBe(true); expect(Array.from(pills).every((pill) => pill.querySelector("img"))).toBe(true); expect(Array.from(badge.querySelectorAll("img")).every((image) => !image.draggable)).toBe(true); const centeredPill = track.querySelector("[data-preset-offset='0']"); expect(centeredPill).toHaveTextContent("Kimi"); expect(centeredPill).toHaveStyle({ transform: "scale(1.0800)" }); expect( track.querySelector("[data-preset-offset='1']"), ).toHaveStyle({ transform: "scale(1.0200)" }); fireEvent.pointerMove(badge, { clientY: 122, pointerId: 7, pointerType: "mouse", }); expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("Kimi"); fireEvent.pointerMove(badge, { clientY: 123, pointerId: 7, pointerType: "mouse", }); expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("DS Pro"); fireEvent.pointerUp(badge, { clientY: 123, pointerId: 7, pointerType: "mouse", }); expect(onPresetChange).toHaveBeenCalledWith("dspro"); expect(badge).toHaveAttribute("data-settling", "true"); expect(track).toHaveAttribute("data-settling", "true"); act(() => { vi.advanceTimersByTime(260); }); expect(badge).not.toHaveAttribute("data-switching"); expect(badge).not.toHaveAttribute("data-settling"); }); it("supports the same long-press switcher in hero mode and cancels pointercancel", () => { vi.useFakeTimers(); const { badge, onPresetChange } = renderPresetComposer("hero"); expect(badge).toHaveClass("h-8"); longPress(badge, 9); expect(badge).toHaveAttribute("data-switching", "true"); fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" }); fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" }); expect(badge).not.toHaveAttribute("data-switching"); expect(onPresetChange).not.toHaveBeenCalled(); }); it("transcribes voice input into the composer without sending", async () => { mockVoiceRecorder(); const onSend = vi.fn(); const onTranscribeAudio = vi.fn(async () => "hello voice"); render( , ); fireEvent.click(screen.getByRole("button", { name: "Voice input" })); expect(await screen.findByLabelText("Recording 0:00")).toBeInTheDocument(); await waitForVoiceCapture(); fireEvent.click(await screen.findByRole("button", { name: "Stop recording" })); await waitFor(() => expect(onTranscribeAudio).toHaveBeenCalledWith( expect.stringMatching(/^data:audio\/webm;base64,/), expect.objectContaining({ durationMs: expect.any(Number) }), )); await waitFor(() => expect(screen.getByLabelText("Message input")).toHaveValue("hello voice")); expect(onSend).not.toHaveBeenCalled(); }); it("converts voice recordings to wav for Xiaomi MiMo transcription", async () => { mockVoiceRecorder(new Blob([new Uint8Array([1, 2, 3, 4])], { type: "audio/webm" })); const { decodeAudioData } = mockVoiceAudioInput( 180, "running", [new Float32Array([0, 0.5, -0.5])], ); const onTranscribeAudio = vi.fn(async () => "mimo voice"); render( , ); fireEvent.click(screen.getByRole("button", { name: "Voice input" })); expect(await screen.findByLabelText("Recording 0:00")).toBeInTheDocument(); await waitForVoiceCapture(); fireEvent.click(await screen.findByRole("button", { name: "Stop recording" })); await waitFor(() => expect(onTranscribeAudio).toHaveBeenCalledTimes(1)); const [dataUrl, options] = onTranscribeAudio.mock.calls[0]; expect(dataUrl).toMatch(/^data:audio\/wav;base64,/); expect(options).toEqual(expect.objectContaining({ durationMs: expect.any(Number) })); expect(decodeAudioData).toHaveBeenCalledTimes(1); const bytes = bytesFromDataUrl(dataUrl); const view = new DataView(bytes.buffer); expect(ascii(bytes, 0, 4)).toBe("RIFF"); expect(ascii(bytes, 8, 4)).toBe("WAVE"); expect(ascii(bytes, 12, 4)).toBe("fmt "); expect(view.getUint16(20, true)).toBe(1); expect(view.getUint16(22, true)).toBe(1); expect(view.getUint32(24, true)).toBe(16_000); expect(view.getUint16(34, true)).toBe(16); expect(ascii(bytes, 36, 4)).toBe("data"); await waitFor(() => expect(screen.getByLabelText("Message input")).toHaveValue("mimo voice")); }); it("does not start duplicate voice recordings while microphone access is pending", async () => { const { getUserMedia, stopTrack } = mockVoiceRecorder(); let resolveStream: ((stream: MediaStream) => void) | undefined; getUserMedia.mockImplementation(() => new Promise((resolve) => { resolveStream = resolve as (stream: MediaStream) => void; })); const onTranscribeAudio = vi.fn(async () => "one recording"); render( , ); const voiceButton = screen.getByRole("button", { name: "Voice input" }); fireEvent.click(voiceButton); fireEvent.click(voiceButton); expect(getUserMedia).toHaveBeenCalledTimes(1); await act(async () => { resolveStream?.({ getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream); }); expect(await screen.findByLabelText("Recording 0:00")).toBeInTheDocument(); await waitForVoiceCapture(); fireEvent.click(await screen.findByRole("button", { name: "Stop recording" })); await waitFor(() => expect(onTranscribeAudio).toHaveBeenCalledTimes(1)); await waitFor(() => expect(screen.getByLabelText("Message input")).toHaveValue("one recording")); }); it("distinguishes a missing microphone from a blocked permission", async () => { const { getUserMedia } = mockVoiceRecorder(); getUserMedia.mockRejectedValue(Object.assign(new Error("no microphone"), { name: "NotFoundError", })); render( "unused")} placeholder="Type your message..." />, ); fireEvent.click(screen.getByRole("button", { name: "Voice input" })); await waitFor(() => { expect(screen.getByText("No microphone was found. Connect a microphone and try again.")).toBeInTheDocument(); }); }); it("clears a previous voice error when retrying microphone access", async () => { const { getUserMedia } = mockVoiceRecorder(); getUserMedia.mockRejectedValueOnce(new Error("permission denied")); const onTranscribeAudio = vi.fn(async () => "voice retry"); render( , ); const voiceButton = screen.getByRole("button", { name: "Voice input" }); fireEvent.click(voiceButton); await waitFor(() => expect(screen.getByText("Allow microphone access in the address bar, then retry.")).toBeInTheDocument()); fireEvent.click(voiceButton); await waitFor(() => expect(screen.queryByText("Allow microphone access in the address bar, then retry.")).not.toBeInTheDocument()); expect(await screen.findByLabelText("Recording 0:00")).toBeInTheDocument(); fireEvent.click(await screen.findByRole("button", { name: "Stop recording" })); }); it("supports press-and-hold voice recording", async () => { mockVoiceRecorder(); const onSend = vi.fn(); const onTranscribeAudio = vi.fn(async () => "held voice"); render( , ); const voiceButton = screen.getByRole("button", { name: "Voice input" }); fireEvent.pointerDown(voiceButton, { button: 0, pointerId: 1, pointerType: "touch" }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 180)); }); expect(await screen.findByLabelText("Recording 0:00")).toBeInTheDocument(); await waitForVoiceCapture(); fireEvent.pointerUp(screen.getByRole("button", { name: "Stop recording" }), { pointerId: 1, pointerType: "touch", }); await waitFor(() => expect(onTranscribeAudio).toHaveBeenCalled()); await waitFor(() => expect(screen.getByLabelText("Message input")).toHaveValue("held voice")); expect(onSend).not.toHaveBeenCalled(); }); it("supports keyboard hold voice recording", async () => { mockVoiceRecorder(); const onSend = vi.fn(); const onTranscribeAudio = vi.fn(async () => "shortcut voice"); render( , ); const voiceButton = screen.getByRole("button", { name: "Voice input" }); expect(voiceButton).toHaveAttribute("title", "Click to dictate or hold"); expect(voiceButton).toHaveAttribute("aria-keyshortcuts", "Control+Shift+D"); fireEvent.keyDown(window, { code: "KeyD", ctrlKey: true, key: "D", shiftKey: true }); expect(await screen.findByLabelText("Recording 0:00")).toBeInTheDocument(); await waitForVoiceCapture(); fireEvent.keyUp(window, { code: "KeyD", ctrlKey: true, key: "D", shiftKey: true }); await waitFor(() => expect(onTranscribeAudio).toHaveBeenCalled()); await waitFor(() => expect(screen.getByLabelText("Message input")).toHaveValue("shortcut voice")); expect(onSend).not.toHaveBeenCalled(); }); it("ignores the delayed click emitted after a long-press voice recording", async () => { const { getUserMedia } = mockVoiceRecorder(); const onTranscribeAudio = vi.fn(async () => "held once"); render( , ); const voiceButton = screen.getByRole("button", { name: "Voice input" }); fireEvent.pointerDown(voiceButton, { button: 0, pointerId: 1, pointerType: "touch" }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 180)); }); expect(await screen.findByLabelText("Recording 0:00")).toBeInTheDocument(); await waitForVoiceCapture(); fireEvent.pointerUp(screen.getByRole("button", { name: "Stop recording" }), { pointerId: 1, pointerType: "touch", }); await waitFor(() => expect(screen.getByLabelText("Message input")).toHaveValue("held once")); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); fireEvent.click(screen.getByRole("button", { name: "Voice input" })); expect(getUserMedia).toHaveBeenCalledTimes(1); expect(onTranscribeAudio).toHaveBeenCalledTimes(1); }); it("keeps existing text when voice transcription fails", async () => { mockVoiceRecorder(); const onSend = vi.fn(); const onTranscribeAudio = vi.fn(async () => { throw new Error("not_configured"); }); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "draft" } }); fireEvent.click(screen.getByRole("button", { name: "Voice input" })); await waitForVoiceCapture(); fireEvent.click(await screen.findByRole("button", { name: "Stop recording" })); await waitFor(() => { expect(screen.getByText("Configure a transcription provider first.")).toBeInTheDocument(); }); expect(input).toHaveValue("draft"); expect(onSend).not.toHaveBeenCalled(); }); it("does not transcribe recordings that are too short", async () => { mockVoiceRecorder(); const onTranscribeAudio = vi.fn(async () => "should not appear"); render( , ); fireEvent.click(screen.getByRole("button", { name: "Voice input" })); fireEvent.click(await screen.findByRole("button", { name: "Stop recording" })); await waitFor(() => { expect(screen.getByText("Hold a little longer to record voice.")).toBeInTheDocument(); }); expect(onTranscribeAudio).not.toHaveBeenCalled(); }); it("warns during recording when microphone input is silent", async () => { mockVoiceRecorder(); mockVoiceAudioInput(); const onTranscribeAudio = vi.fn(async () => "should not appear"); render( , ); fireEvent.click(screen.getByRole("button", { name: "Voice input" })); expect(await screen.findByLabelText("Recording 0:00")).toBeInTheDocument(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 1_150)); }); expect(screen.getByText("No microphone input detected.")).toBeInTheDocument(); fireEvent.click(await screen.findByRole("button", { name: "Stop recording" })); expect(onTranscribeAudio).not.toHaveBeenCalled(); }); it("does not treat unavailable microphone levels as silence", async () => { mockVoiceRecorder(); mockVoiceAudioInput(128, "suspended"); const onTranscribeAudio = vi.fn(async () => "voice text"); render( , ); fireEvent.click(screen.getByRole("button", { name: "Voice input" })); expect(await screen.findByLabelText("Recording 0:00")).toBeInTheDocument(); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 1_150)); }); expect(screen.queryByText("No microphone input detected.")).not.toBeInTheDocument(); fireEvent.click(await screen.findByRole("button", { name: "Stop recording" })); await waitFor(() => expect(onTranscribeAudio).toHaveBeenCalledTimes(1)); expect(screen.getByDisplayValue("voice text")).toBeInTheDocument(); }); it("renders and changes workspace access mode", async () => { const onWorkspaceScopeChange = vi.fn(); render( , ); fireEvent.pointerDown(screen.getByRole("button", { name: /Workspace access mode/ })); fireEvent.click(await screen.findByRole("menuitem", { name: /Full Access/ })); expect(onWorkspaceScopeChange).toHaveBeenCalledWith( expect.objectContaining({ project_path: "/tmp/project", access_mode: "full", restrict_to_workspace: false, }), ); }); it("exposes full and compact workspace labels for container-driven compression", () => { render( , ); const accessButton = screen.getByRole("button", { name: "Workspace access mode: Full Access", }); const fullLabel = within(accessButton).getByText("Full Access"); const shortLabel = within(accessButton).getByText("Full"); expect(accessButton).toHaveAttribute("title", "Full Access"); expect(fullLabel).toHaveClass("thread-composer-access-label-full"); expect(shortLabel).toHaveClass("thread-composer-access-label-short"); expect(shortLabel).toHaveClass("hidden"); }); it("keeps project selection as a compact composer dropdown", async () => { const onWorkspaceScopeChange = vi.fn(); const defaultScope = { project_path: "/Users/test/.nanobot/workspace", project_name: "workspace", access_mode: "restricted" as const, restrict_to_workspace: true, }; render( , ); fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" })); expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); const input = screen.getByLabelText("Paste path"); fireEvent.change(input, { target: { value: "relative/project" } }); fireEvent.click(screen.getByRole("button", { name: "Use Path" })); expect(screen.getByRole("alert")).toHaveTextContent( "Enter an absolute folder path on this machine.", ); expect(onWorkspaceScopeChange).not.toHaveBeenCalled(); fireEvent.change(input, { target: { value: "/Users/test/project-alpha" } }); fireEvent.click(screen.getByRole("button", { name: "Use Path" })); expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({ project_path: "/Users/test/project-alpha", project_name: "project-alpha", access_mode: "full", restrict_to_workspace: false, })); fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" })); const reopenedInput = await screen.findByLabelText("Paste path"); fireEvent.change(reopenedInput, { target: { value: "~/Pictures/Photos" } }); fireEvent.click(screen.getByRole("button", { name: "Use Path" })); expect(onWorkspaceScopeChange).toHaveBeenLastCalledWith(expect.objectContaining({ project_path: "~/Pictures/Photos", project_name: "Photos", access_mode: "full", restrict_to_workspace: false, })); }); it("uses the native folder picker for project selection on native host", async () => { const onWorkspaceScopeChange = vi.fn(); const pickFolder = vi.fn().mockResolvedValue("/Users/test/native-project"); const defaultScope = { project_path: "/Users/test/.nanobot/workspace", project_name: "workspace", access_mode: "full" as const, restrict_to_workspace: false, }; Object.defineProperty(window, "nanobotHost", { configurable: true, value: { getRuntimeInfo: vi.fn(), restartEngine: vi.fn(), pickFolder, openLogs: vi.fn(), exportDiagnostics: vi.fn(), }, }); render( , ); fireEvent.click(screen.getByRole("button", { name: "Choose project" })); await waitFor(() => expect(pickFolder).toHaveBeenCalled()); expect(screen.queryByRole("menuitem", { name: /Default workspace/ })).not.toBeInTheDocument(); expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({ project_path: "/Users/test/native-project", project_name: "native-project", access_mode: "full", restrict_to_workspace: false, })); }); it("uses the web path menu when no native host picker is available", async () => { const defaultScope = { project_path: "/Users/test/.nanobot/workspace", project_name: "workspace", access_mode: "full" as const, restrict_to_workspace: false, }; render( , ); fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" })); expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument(); expect(screen.getByLabelText("Paste path")).toBeInTheDocument(); }); it("shows turn run timer when runStartedAt is set", () => { vi.useFakeTimers(); vi.setSystemTime(new Date((1_000 + 125) * 1000)); render( , ); const status = screen.getByRole("status"); expect(status).toHaveTextContent(/Running/); expect(status).toHaveTextContent(/2:05/); expect(status.parentElement).toHaveClass("composer-status-strip"); expect(status.parentElement).toHaveAttribute("data-state", "enter"); expect(status.querySelector(".run-pulse-icon")).not.toBeNull(); vi.useRealTimers(); }); it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => { const longObjective = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789GoalTail"; render( , ); fireEvent.click(screen.getByRole("button", { name: "Show full goal" })); const dialog = await screen.findByRole("dialog", { name: "Goal" }); expect(dialog).toBeInTheDocument(); expect(dialog).toHaveTextContent("Short summary for strip"); expect(dialog).toHaveTextContent(longObjective); }); it("opens a slash command palette and inserts the selected command", () => { const onSend = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/" } }); const palette = screen.getByRole("listbox", { name: "Slash commands" }); expect(palette).toBeInTheDocument(); expect(palette).toHaveStyle({ maxHeight: "288px" }); expect(screen.queryByRole("option", { name: /\/stop/i })).not.toBeInTheDocument(); expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute( "aria-selected", "true", ); fireEvent.keyDown(input, { key: "Enter" }); expect(input).toHaveValue("/history "); expect(onSend).not.toHaveBeenCalled(); expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument(); }); it("offers stop autocomplete once the user starts typing it", () => { const onSend = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/sto" } }); expect(screen.getByRole("option", { name: /\/stop/i })).toHaveAttribute( "aria-selected", "true", ); fireEvent.keyDown(input, { key: "Enter" }); expect(input).toHaveValue("/stop"); expect(onSend).not.toHaveBeenCalled(); }); it("renders slash commands as direct actions with current status", () => { render( , ); fireEvent.change(screen.getByLabelText("Message input"), { target: { value: "/" }, }); expect(screen.getByRole("option", { name: /Model deepseek-v4-pro/i })).toBeInTheDocument(); expect(screen.getByText("Current")).toBeInTheDocument(); expect(screen.getByText("/model [preset]")).toBeInTheDocument(); }); it("prioritizes stop as an immediate slash action while streaming", () => { const onStop = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/" } }); expect(screen.getByRole("option", { name: /Stop current task/i })).toHaveAttribute( "aria-selected", "true", ); fireEvent.keyDown(input, { key: "Enter" }); expect(onStop).toHaveBeenCalledTimes(1); expect(input).toHaveValue(""); expect(window.localStorage.getItem("nanobot.webui.slashCommandRecents")).toBeNull(); }); it("orders recent slash commands first for the blank slash menu", () => { window.localStorage.setItem("nanobot.webui.slashCommandRecents", JSON.stringify(["/history"])); render( , ); fireEvent.change(screen.getByLabelText("Message input"), { target: { value: "/" }, }); expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute( "aria-selected", "true", ); expect(screen.getByText("Recent")).toBeInTheDocument(); }); it("keeps keyboard-selected slash options visible while navigating", () => { const scrollIntoView = vi.fn(); const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; HTMLElement.prototype.scrollIntoView = scrollIntoView; try { render( ({ command: `/cmd-${index}`, title: `Command ${index}`, description: `Description ${index}`, icon: "activity", lifecycle: "side_channel", acceptsArgs: false, }))} />, ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/" } }); scrollIntoView.mockClear(); fireEvent.keyDown(input, { key: "ArrowDown" }); fireEvent.keyDown(input, { key: "ArrowDown" }); expect(screen.getByRole("option", { name: /\/cmd-2/i })).toHaveAttribute( "aria-selected", "true", ); expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "nearest" }); } finally { HTMLElement.prototype.scrollIntoView = originalScrollIntoView; } }); it("opens the CLI app mention palette and inserts the selected app", () => { const onSend = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "@", selectionStart: 1 } }); const palette = screen.getByRole("listbox", { name: "Apps" }); expect(palette).toBeInTheDocument(); expect(screen.getByRole("option", { name: /@gimp/i })).toHaveAttribute( "aria-selected", "true", ); expect(screen.queryByRole("option", { name: /@krita/i })).not.toBeInTheDocument(); fireEvent.keyDown(input, { key: "ArrowDown" }); expect(screen.getByRole("option", { name: /@blender/i })).toHaveAttribute( "aria-selected", "true", ); fireEvent.keyDown(input, { key: "Enter" }); expect(input).toHaveValue("@blender "); expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender"); expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument(); expect(onSend).not.toHaveBeenCalled(); expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Send message" })); expect(onSend).toHaveBeenCalledWith("@blender", undefined, { cliApps: [{ name: "blender", display_name: "Blender", category: "3d", entry_point: "cli-anything-blender", logo_url: null, brand_color: "#E87D0D", }], }); }); it("keeps keyboard-selected mention options visible while navigating", () => { const scrollIntoView = vi.fn(); const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; HTMLElement.prototype.scrollIntoView = scrollIntoView; try { render( ({ name: `app-${index}`, display_name: `App ${index}`, category: "test", description: "Test app", requires: "", source: "harness", entry_point: `app-${index}`, install_supported: true, installed: true, available: true, status: "installed", logo_url: null, brand_color: "#111827", skill_installed: true, }))} />, ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "@", selectionStart: 1 } }); scrollIntoView.mockClear(); fireEvent.keyDown(input, { key: "ArrowDown" }); fireEvent.keyDown(input, { key: "ArrowDown" }); expect(screen.getByRole("option", { name: /@app-2/i })).toHaveAttribute( "aria-selected", "true", ); expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "nearest" }); } finally { HTMLElement.prototype.scrollIntoView = originalScrollIntoView; } }); it("completes a CLI app mention with Tab and adds exactly one trailing space", () => { render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "use @ble", selectionStart: 8 }, }); fireEvent.keyDown(input, { key: "Tab" }); expect(input).toHaveValue("use @blender "); expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender"); }); it("shows configured MCP presets in the mention palette and submits metadata", () => { const onSend = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "use @bro", selectionStart: 8 }, }); expect(screen.getByRole("option", { name: /@browserbase/i })).toBeInTheDocument(); expect(screen.queryByRole("option", { name: /@figma/i })).not.toBeInTheDocument(); fireEvent.keyDown(input, { key: "Tab" }); expect(input).toHaveValue("use @browserbase "); expect(screen.getByTestId("composer-mcp-mention-browserbase")).toHaveTextContent("@browserbase"); fireEvent.click(screen.getByRole("button", { name: "Send message" })); expect(onSend).toHaveBeenCalledWith("use @browserbase", undefined, { mcpPresets: [{ name: "browserbase", display_name: "Browserbase", category: "browser", transport: "streamableHttp", status: "configured", configured: true, logo_url: "https://example.invalid/browserbase.svg", brand_color: "#111827", }], }); }); it("opens skills only from a $ reference and prioritizes the skill name", () => { const skillName = "arxiv-intelligence-filter"; render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/git", selectionStart: 4 } }); expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument(); fireEvent.change(input, { target: { value: "please use $arxiv", selectionStart: 17 } }); const palette = screen.getByRole("listbox", { name: "Slash commands" }); const option = within(palette).getByRole("option", { name: new RegExp(skillName, "i") }); const name = within(option).getByText(skillName); expect(name).not.toHaveClass("truncate"); expect(within(option).queryByText(`$${skillName}`)).not.toBeInTheDocument(); expect(within(palette).queryByText("/model")).not.toBeInTheDocument(); fireEvent.keyDown(input, { key: "Tab" }); expect(input).toHaveValue(`please use $${skillName} `); }); it("shows right-side source badges so users can distinguish CLI apps from MCP servers", () => { render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "@", selectionStart: 1 } }); expect(screen.queryByText("CLI Apps")).not.toBeInTheDocument(); expect(screen.queryByText("MCP servers")).not.toBeInTheDocument(); const gimp = screen.getByRole("option", { name: /GIMP @gimp .* CLI/i }); const browserbase = screen.getByRole("option", { name: /Browserbase @browserbase .* MCP/i }); expect(within(gimp).getByText("CLI")).toBeInTheDocument(); expect(within(browserbase).getByText("MCP")).toBeInTheDocument(); expect(within(gimp).getByText("@gimp")).toBeInTheDocument(); expect(within(browserbase).getByText("@browserbase")).toBeInTheDocument(); }); it("does not duplicate the next word separator when completing a CLI app mention", () => { render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "use @ble tonight", selectionStart: 8 }, }); fireEvent.keyDown(input, { key: "Tab" }); expect(input).toHaveValue("use @blender tonight"); }); it("renders a CLI app mention logo inline without moving the text cursor slot", () => { render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "meeting in @gimp", selectionStart: 16 }, }); expect(input).toHaveValue("meeting in @gimp"); const token = screen.getByTestId("composer-cli-mention-gimp"); expect(token).toHaveTextContent("@gimp"); expect(token.className).not.toContain("font-semibold"); expect(token.className).not.toContain("zoom-in"); expect(token.className).not.toContain("px-"); expect(token.className).not.toContain("mx-"); expect(token.getAttribute("style")).toContain("color: #5C5543"); expect(token.getAttribute("style")).toContain("text-shadow"); expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument(); const logo = screen.getByTestId("composer-cli-mention-logo-gimp"); expect(logo.className).toContain("top-1/2"); expect(logo.className).toContain("left-1/2"); expect(logo.className).not.toContain("-top-"); }); it("uses the shared accent when an installed CLI app has no brand metadata", () => { const mention = "@obsidian-agent-cli"; const app: CliAppInfo = { name: "obsidian-agent-cli", display_name: "Obsidian CLI", category: "productivity", description: "Obsidian automation", requires: "", source: "local", entry_point: "obsidian-agent", install_supported: true, installed: true, available: true, status: "installed", logo_url: null, brand_color: null, skill_installed: true, }; render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: mention, selectionStart: mention.length }, }); const token = screen.getByTestId("composer-cli-mention-obsidian-agent-cli"); expect(token.getAttribute("style")).toContain("var(--inline-token-highlight)"); expect(token.getAttribute("style")).not.toContain("var(--primary)"); }); it("opens the slash command palette downward when there is more room below", async () => { vi.spyOn(HTMLFormElement.prototype, "getBoundingClientRect").mockReturnValue( rect({ top: 40, bottom: 160, width: 800, height: 120 }), ); Object.defineProperty(window, "innerHeight", { value: 330, configurable: true, }); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/" } }); await waitFor(() => { const palette = screen.getByRole("listbox", { name: "Slash commands" }); expect(palette.className).toContain("top-full"); expect(palette).toHaveStyle({ maxHeight: "162px" }); }); }); it("keeps the slash command palette above a keyboard-constrained visual viewport", async () => { vi.spyOn(HTMLFormElement.prototype, "getBoundingClientRect").mockReturnValue( rect({ top: 120, bottom: 220, width: 390, height: 100 }), ); Object.defineProperty(window, "innerHeight", { value: 800, configurable: true, }); stubVisualViewport({ height: 300 }); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/" } }); await waitFor(() => { const palette = screen.getByRole("listbox", { name: "Slash commands" }); expect(palette.className).toContain("bottom-full"); expect(palette).toHaveStyle({ maxHeight: "112px" }); }); }); it("dismisses the slash command palette on outside click", () => { render(
, ); fireEvent.change(screen.getByLabelText("Message input"), { target: { value: "/" }, }); expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument(); fireEvent.pointerDown(screen.getByRole("button", { name: "outside" })); expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument(); }); it("keeps image generation mode out of the composer chrome", () => { const onSend = vi.fn(); render( , ); expect(screen.queryByRole("button", { name: "Toggle image generation mode" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Image aspect ratio" })).not.toBeInTheDocument(); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "Draw a friendly robot" } }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); expect(onSend).toHaveBeenCalledWith("Draw a friendly robot", undefined, undefined); }); it("marks known slash commands as side-channel sends", () => { const onSend = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/history" } }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); expect(onSend).toHaveBeenCalledWith("/history", undefined, { sideChannel: true }); }); it("does not infer side-channel behavior before command metadata loads", () => { const onSend = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/status" } }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); expect(onSend).toHaveBeenCalledWith("/status", undefined, undefined); }); it("marks new chat commands as side-channel sends that finalize the active turn", () => { const onSend = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/new" } }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); expect(onSend).toHaveBeenCalledWith( "/new", undefined, { sideChannel: true, finalizeActiveTurn: true }, ); }); it("does not classify exact-only slash commands with arguments", () => { const onSend = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/new with a title" } }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); expect(onSend).toHaveBeenCalledWith("/new with a title", undefined, undefined); }); it("routes a manually submitted stop command through the stop handler", () => { const onSend = vi.fn(); const onStop = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/stop" } }); fireEvent.keyDown(input, { key: "Escape" }); fireEvent.keyDown(input, { key: "Enter" }); expect(onStop).toHaveBeenCalledTimes(1); expect(onSend).not.toHaveBeenCalled(); }); it("keeps goal task commands on the normal agent turn path", () => { const onSend = vi.fn(); render( ", lifecycle: "agent_turn_with_args", acceptsArgs: true, }, ]} />, ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/goal fix the release blocker" } }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); expect(onSend).toHaveBeenCalledWith( "/goal fix the release blocker", undefined, undefined, ); }); it("keeps goal usage commands on the side-channel path", () => { const onSend = vi.fn(); render( ", lifecycle: "agent_turn_with_args", acceptsArgs: true, }, ]} />, ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "/goal" } }); fireEvent.click(screen.getByRole("button", { name: "Send message" })); expect(onSend).toHaveBeenCalledWith("/goal", undefined, { sideChannel: true }); }); it("shows a stop button while streaming", () => { const onStop = vi.fn(); render( , ); fireEvent.click(screen.getByRole("button", { name: "Stop response" })); expect(onStop).toHaveBeenCalledTimes(1); expect(screen.queryByRole("button", { name: "Send message" })).not.toBeInTheDocument(); }); it("queues plain guidance while a task is running", () => { const onSend = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "keep the UI minimal" } }); fireEvent.keyDown(input, { key: "Enter" }); expect(onSend).not.toHaveBeenCalled(); expect(input).toHaveValue(""); expect(screen.getByText("keep the UI minimal")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Guide" })); expect(onSend).toHaveBeenCalledWith( "keep the UI minimal", undefined, { continueActiveTurn: true }, ); expect(screen.queryByText("keep the UI minimal")).not.toBeInTheDocument(); }); it("guides queued guidance when Enter is pressed again", () => { const onSend = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "send this guidance now" } }); fireEvent.keyDown(input, { key: "Enter" }); expect(onSend).not.toHaveBeenCalled(); expect(input).toHaveValue(""); expect(screen.getByText("send this guidance now")).toBeInTheDocument(); fireEvent.keyDown(input, { key: "Enter", repeat: true }); expect(onSend).not.toHaveBeenCalled(); expect(screen.getByText("send this guidance now")).toBeInTheDocument(); fireEvent.keyDown(input, { key: "Enter" }); expect(onSend).toHaveBeenCalledWith( "send this guidance now", undefined, { continueActiveTurn: true }, ); expect(onSend).toHaveBeenCalledTimes(1); expect(screen.queryByText("send this guidance now")).not.toBeInTheDocument(); }); it("disarms the second Enter shortcut when keyboard voice recording starts", async () => { mockVoiceRecorder(); const onSend = vi.fn(); const onTranscribeAudio = vi.fn(async () => "voice guidance"); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "keep this queued" } }); fireEvent.keyDown(input, { key: "Enter" }); fireEvent.keyDown(window, { code: "KeyD", ctrlKey: true, key: "D", shiftKey: true }); expect(await screen.findByLabelText("Recording 0:00")).toBeInTheDocument(); expect(input).toHaveFocus(); fireEvent.keyDown(input, { key: "Enter" }); expect(onSend).not.toHaveBeenCalled(); expect(screen.getByText("keep this queued")).toBeInTheDocument(); await waitForVoiceCapture(); fireEvent.keyUp(window, { code: "KeyD", ctrlKey: true, key: "D", shiftKey: true }); await waitFor(() => expect(onTranscribeAudio).toHaveBeenCalled()); }); it("disarms the second Enter shortcut after stopping the active response", () => { const onSend = vi.fn(); const onStop = vi.fn(); const { rerender } = render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "keep this queued" } }); fireEvent.keyDown(input, { key: "Enter" }); fireEvent.click(screen.getByRole("button", { name: "Stop response" })); fireEvent.keyDown(input, { key: "Enter" }); expect(onStop).toHaveBeenCalledTimes(1); expect(onSend).not.toHaveBeenCalled(); expect(screen.getByText("keep this queued")).toBeInTheDocument(); rerender( , ); rerender( , ); fireEvent.keyDown(screen.getByLabelText("Message input"), { key: "Enter" }); expect(onSend).not.toHaveBeenCalled(); expect(screen.getByText("keep this queued")).toBeInTheDocument(); }); it("disarms the second Enter shortcut when the composer loses focus", () => { const onSend = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "leave this queued" } }); fireEvent.keyDown(input, { key: "Enter" }); fireEvent.blur(input); fireEvent.focus(input); fireEvent.keyDown(input, { key: "Enter" }); expect(onSend).not.toHaveBeenCalled(); expect(screen.getByText("leave this queued")).toBeInTheDocument(); }); it("guides the newly queued prompt when older guidance is still waiting", () => { const onSend = vi.fn(); render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "older guidance" } }); fireEvent.keyDown(input, { key: "Enter" }); fireEvent.change(input, { target: { value: "guide this one now" } }); fireEvent.keyDown(input, { key: "Enter" }); fireEvent.keyDown(input, { key: "Enter" }); expect(onSend).toHaveBeenCalledWith( "guide this one now", undefined, { continueActiveTurn: true }, ); expect(onSend).toHaveBeenCalledTimes(1); expect(screen.getByText("older guidance")).toBeInTheDocument(); expect(screen.queryByText("guide this one now")).not.toBeInTheDocument(); }); it("keeps queued guidance attached to the composer and sends it one item at a time", async () => { const onSend = vi.fn(); const { rerender } = render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "first follow-up" } }); fireEvent.keyDown(input, { key: "Enter" }); fireEvent.change(input, { target: { value: "second follow-up" } }); fireEvent.keyDown(input, { key: "Enter" }); const queue = screen.getByRole("group", { name: "Queued guidance" }); expect(queue).toHaveClass("composer-status-strip"); expect(queue).toHaveClass("mx-3"); expect(queue.parentElement?.className).toContain("group/composer"); expect(within(queue).getByText("first follow-up")).toBeInTheDocument(); expect(within(queue).getByText("second follow-up")).toBeInTheDocument(); expect(within(queue).getAllByRole("button", { name: "Edit guidance" })).toHaveLength(2); expect(within(queue).getAllByRole("button", { name: "Guide" })).toHaveLength(2); rerender( , ); await waitFor(() => { expect(onSend).toHaveBeenCalledWith("first follow-up"); }); expect(onSend).toHaveBeenCalledTimes(1); expect(screen.queryByText("first follow-up")).not.toBeInTheDocument(); expect(screen.getByText("second follow-up")).toBeInTheDocument(); rerender( , ); rerender( , ); await waitFor(() => { expect(onSend).toHaveBeenLastCalledWith("second follow-up"); }); expect(onSend).toHaveBeenCalledTimes(2); expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument(); }); it("lets users edit queued guidance before it is sent", async () => { const onSend = vi.fn(); const { rerender } = render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "rough follow-up" } }); fireEvent.keyDown(input, { key: "Enter" }); const editButton = screen.getByRole("button", { name: "Edit guidance" }); fireEvent.click(editButton); await waitFor(() => { expect(input).toHaveFocus(); }); expect(input).toHaveValue("rough follow-up"); expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument(); fireEvent.change(input, { target: { value: "polished follow-up" } }); fireEvent.keyDown(input, { key: "Enter" }); rerender( , ); await waitFor(() => { expect(onSend).toHaveBeenCalledWith("polished follow-up"); }); }); it("requeues edited guidance at the end of the pending list", async () => { const onSend = vi.fn(); const { rerender } = render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "first follow-up" } }); fireEvent.keyDown(input, { key: "Enter" }); fireEvent.change(input, { target: { value: "second follow-up" } }); fireEvent.keyDown(input, { key: "Enter" }); fireEvent.click(screen.getAllByRole("button", { name: "Edit guidance" })[0]); await waitFor(() => { expect(input).toHaveValue("first follow-up"); }); fireEvent.change(input, { target: { value: "first follow-up edited" } }); fireEvent.keyDown(input, { key: "Enter" }); rerender( , ); await waitFor(() => { expect(onSend).toHaveBeenCalledWith("second follow-up"); }); expect(onSend).toHaveBeenCalledTimes(1); rerender( , ); rerender( , ); await waitFor(() => { expect(onSend).toHaveBeenLastCalledWith("first follow-up edited"); }); expect(onSend).toHaveBeenCalledTimes(2); }); it("queues image guidance while running and restores it for editing", async () => { mockBlobUrls(); const onSend = vi.fn(); const { container, rerender } = render( , ); const input = screen.getByLabelText("Message input"); const fileInput = container.querySelector('input[type="file"]'); expect(fileInput).toBeTruthy(); const file = new File(["image"], "draft.png", { type: "image/png" }); fireEvent.change(fileInput!, { target: { files: [file] } }); await screen.findByText("draft.png"); fireEvent.change(input, { target: { value: "look at this" } }); fireEvent.keyDown(input, { key: "Enter" }); expect(onSend).not.toHaveBeenCalled(); expect(screen.getByRole("group", { name: "Queued guidance" })).toBeInTheDocument(); expect(screen.getByText("look at this")).toBeInTheDocument(); expect(screen.queryByTestId("composer-chip")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Edit guidance" })); expect(input).toHaveValue("look at this"); expect(screen.getByTestId("composer-chip")).toHaveTextContent("draft.png"); expect(screen.queryByRole("group", { name: "Queued guidance" })).not.toBeInTheDocument(); fireEvent.keyDown(input, { key: "Enter" }); rerender( , ); await waitFor(() => { expect(onSend).toHaveBeenCalledWith( "look at this", [expect.objectContaining({ media: expect.objectContaining({ data_url: "data:image/png;base64,aW1hZ2U=", name: "draft.png", }), })], ); }); }); it("reorders queued guidance while dragging over another row", async () => { const onSend = vi.fn(); const { rerender } = render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "first follow-up" } }); fireEvent.keyDown(input, { key: "Enter" }); fireEvent.change(input, { target: { value: "second follow-up" } }); fireEvent.keyDown(input, { key: "Enter" }); const handles = screen.getAllByLabelText("Drag to reorder"); const secondRow = screen .getByText("second follow-up") .closest("[data-queued-prompt-row='true']"); expect(secondRow).toBeTruthy(); const dataTransfer = { effectAllowed: "", dropEffect: "", setData: vi.fn(), getData: vi.fn(), }; fireEvent.dragStart(handles[0], { dataTransfer }); fireEvent.dragEnter(secondRow!, { dataTransfer }); fireEvent.dragEnd(handles[0], { dataTransfer }); rerender( , ); await waitFor(() => { expect(onSend).toHaveBeenCalledWith("second follow-up"); }); }); it("moves later queued guidance before an earlier item while dragging", async () => { const onSend = vi.fn(); const { rerender } = render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "first follow-up" } }); fireEvent.keyDown(input, { key: "Enter" }); fireEvent.change(input, { target: { value: "second follow-up" } }); fireEvent.keyDown(input, { key: "Enter" }); const handles = screen.getAllByLabelText("Drag to reorder"); const firstRow = screen .getByText("first follow-up") .closest("[data-queued-prompt-row='true']"); expect(firstRow).toBeTruthy(); const dataTransfer = { effectAllowed: "", dropEffect: "", setData: vi.fn(), getData: vi.fn(), }; fireEvent.dragStart(handles[1], { dataTransfer }); fireEvent.dragEnter(firstRow!, { dataTransfer }); fireEvent.dragEnd(handles[1], { dataTransfer }); rerender( , ); await waitFor(() => { expect(onSend).toHaveBeenCalledWith("second follow-up"); }); }); it("persists queued guidance per chat across remounts", async () => { const onSend = vi.fn(); const { rerender, unmount } = render( , ); const input = screen.getByLabelText("Message input"); fireEvent.change(input, { target: { value: "remember this follow-up" } }); fireEvent.keyDown(input, { key: "Enter" }); fireEvent.click(screen.getByRole("button", { name: "Edit guidance" })); fireEvent.change(input, { target: { value: "remember this edited follow-up" } }); fireEvent.keyDown(input, { key: "Enter" }); expect(screen.getByText("remember this edited follow-up")).toBeInTheDocument(); rerender( , ); await waitFor(() => { expect(screen.queryByText("remember this edited follow-up")).not.toBeInTheDocument(); }); rerender( , ); expect(await screen.findByText("remember this edited follow-up")).toBeInTheDocument(); fireEvent.keyDown(screen.getByLabelText("Message input"), { key: "Enter" }); expect(onSend).not.toHaveBeenCalled(); unmount(); const remount = render( , ); expect(await screen.findByText("remember this edited follow-up")).toBeInTheDocument(); fireEvent.keyDown(screen.getByLabelText("Message input"), { key: "Enter" }); expect(onSend).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Guide" })); expect(onSend).toHaveBeenCalledWith( "remember this edited follow-up", undefined, { continueActiveTurn: true }, ); remount.unmount(); render( , ); await waitFor(() => { expect(screen.queryByText("remember this edited follow-up")).not.toBeInTheDocument(); }); }); });