diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts index e2a6d6e..dee0945 100644 --- a/frontend/e2e/chat.spec.ts +++ b/frontend/e2e/chat.spec.ts @@ -180,8 +180,8 @@ test("manages settings, permanent memory, and model pull progress", async ({ pag await expect(page.getByText("Settings saved.")).toBeVisible(); await page.getByRole("button", { name: "Memory", exact: true }).click(); await expect(page.getByRole("textbox", { name: "Memory 1" })).toHaveValue("Remember tea"); - page.once("dialog", (dialog) => void dialog.accept()); await page.getByRole("button", { name: "Clear all" }).click(); + await page.getByRole("alertdialog").getByRole("button", { name: "Clear all memories" }).click(); await expect(page.getByText("Permanent memories cleared.")).toBeVisible(); await page.getByLabel("New memory").fill("Remember local data"); await page.getByRole("button", { name: "Add memory" }).click(); diff --git a/frontend/src/app/App.chatDelete.test.tsx b/frontend/src/app/App.chatDelete.test.tsx new file mode 100644 index 0000000..af36d64 --- /dev/null +++ b/frontend/src/app/App.chatDelete.test.tsx @@ -0,0 +1,189 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; +import { CortexApi } from "../api/client"; +import { useChatStore } from "../stores/useChatStore"; +import { useUiStore } from "../stores/useUiStore"; +import { ToastProvider } from "./ToastProvider"; + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, +}); + +const CHATS = [ + { id: "chat-a", title: "Alpha plan", timestamp: "2026-01-02T00:00:00Z" }, + { id: "chat-b", title: "Beta notes", timestamp: "2026-01-01T00:00:00Z" }, +]; + +/** The undo window in App.tsx. */ +const UNDO_WINDOW_MS = 6000; + +describe("App chat deletion with Undo", () => { + beforeAll(async () => { + await Promise.all([ + import("../features/chat/ChatPage"), + import("../features/settings/SettingsPanel"), + ]); + }, 120_000); + + afterEach(() => { + vi.useRealTimers(); + act(() => { useUiStore.setState({ toasts: [] }); }); + window.sessionStorage.clear(); + window.history.replaceState({}, "", "/"); + }); + + function workspace(options: { deleteStatus?: number } = {}) { + const deletes: string[] = []; + const fetcher = vi.fn(async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + const chatMatch = /\/chats\/([^/?]+)$/.exec(url); + if (chatMatch && method === "DELETE") { + deletes.push(decodeURIComponent(chatMatch[1])); + return options.deleteStatus && options.deleteStatus >= 400 + ? json({ detail: "The chat could not be deleted." }, options.deleteStatus) + : new Response(null, { status: 204 }); + } + if (chatMatch && method === "GET") { + const chat = CHATS.find((candidate) => candidate.id === chatMatch[1]); + return chat ? json({ ...chat, revision: 1, messages: [] }) : json({ detail: "Not found." }, 404); + } + if (url.endsWith("/system")) return json({ status: "ok", preview: true, session_required: true, started_at: "2026-07-21T18:00:00Z" }); + if (url.endsWith("/chat-groups")) return json([]); + if (url.endsWith("/chats")) return json(CHATS); + if (url.endsWith("/settings")) return json({ settings: { models: { chat: null, title: null }, appearance: { theme: "dark" } } }); + if (url.endsWith("/models")) return json({ required_models: [], optional_models: [], installed_models: [], connection: { success: true, status: "connected", message: "Ready" } }); + return json({ detail: "Unexpected test route." }, 404); + }); + return { fetcher, deletes }; + } + + async function openWorkspace(fetcher: typeof fetch, path = "/chat/new") { + window.sessionStorage.setItem("cortex.session.token", "local-session"); + window.history.replaceState({}, "", path); + render(); + expect(await screen.findByRole("button", { name: "Delete Alpha plan" }, { timeout: 10_000 })).toBeInTheDocument(); + } + + /** Confirm the dialog, then switch to fake timers so the undo window can be stepped through. */ + async function deleteChatAndControlTime(title: string) { + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: `Delete ${title}` })); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete chat" })); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + } + + const passTime = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); }); + /** Let a request the last step started finish. Testing Library's waitFor cannot poll under fake timers. */ + const settle = () => passTime(50); + const toastRegion = () => within(document.querySelector(".toast-region") as HTMLElement); + + it("hides the chat at once, sends nothing while Undo is offered, and restores it on Undo", async () => { + const { fetcher, deletes } = workspace(); + await openWorkspace(fetcher); + + window.localStorage.setItem("cortex.composer.draft.chat-a", "unsent text, kept for Undo"); + + await deleteChatAndControlTime("Alpha plan"); + + expect(screen.queryByRole("button", { name: "Delete Alpha plan" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete Beta notes" })).toBeInTheDocument(); + expect(toastRegion().getByText("Chat deleted.")).toBeVisible(); + expect(deletes).toEqual([]); + + fireEvent.click(toastRegion().getByRole("button", { name: "Undo" })); + await passTime(0); + + expect(screen.getByRole("button", { name: "Delete Alpha plan" })).toBeInTheDocument(); + expect(toastRegion().getByText("Chat restored.")).toBeVisible(); + expect(toastRegion().queryByText("Chat deleted.")).not.toBeInTheDocument(); + + // Long after the window would have closed, nothing was ever sent, and the draft is intact. + await passTime(60_000); + await settle(); + expect(deletes).toEqual([]); + expect(window.localStorage.getItem("cortex.composer.draft.chat-a")).toBe("unsent text, kept for Undo"); + expect(useChatStore.getState().chats.map((chat) => chat.id)).toEqual(["chat-a", "chat-b"]); + }); + + it("sends exactly one delete when the Undo window closes, and the chat stays gone", async () => { + const { fetcher, deletes } = workspace(); + await openWorkspace(fetcher); + window.localStorage.setItem("cortex.composer.draft.chat-a", "unsent text for the doomed chat"); + + await deleteChatAndControlTime("Alpha plan"); + await passTime(UNDO_WINDOW_MS - 1); + expect(deletes).toEqual([]); + expect(toastRegion().getByRole("button", { name: "Undo" })).toBeInTheDocument(); + + await passTime(1); + await settle(); + expect(deletes).toEqual(["chat-a"]); + await passTime(60_000); + + expect(deletes).toEqual(["chat-a"]); + expect(window.localStorage.getItem("cortex.composer.draft.chat-a")).toBeNull(); + expect(screen.queryByRole("button", { name: "Undo" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete Alpha plan" })).not.toBeInTheDocument(); + expect(useChatStore.getState().chats.map((chat) => chat.id)).toEqual(["chat-b"]); + }); + + it("brings the chat back and says so when the delete fails at the end of the window", async () => { + const { fetcher, deletes } = workspace({ deleteStatus: 500 }); + await openWorkspace(fetcher); + + await deleteChatAndControlTime("Alpha plan"); + await passTime(UNDO_WINDOW_MS); + await settle(); + + expect(deletes).toEqual(["chat-a"]); + expect(screen.getByRole("button", { name: "Delete Alpha plan" })).toBeInTheDocument(); + expect(toastRegion().getByRole("alert")).toHaveTextContent("The chat could not be deleted."); + expect(useChatStore.getState().chats.map((chat) => chat.id)).toEqual(["chat-a", "chat-b"]); + }); + + it("keeps a chat hidden while its Undo window is open even if the list is refilled from the server", async () => { + const { fetcher, deletes } = workspace(); + await openWorkspace(fetcher); + + await deleteChatAndControlTime("Alpha plan"); + act(() => { useChatStore.getState().setChats([...CHATS]); }); + + expect(screen.queryByRole("button", { name: "Delete Alpha plan" })).not.toBeInTheDocument(); + expect(deletes).toEqual([]); + }); + + it("moves off an open chat when it is deleted and returns to it on Undo", async () => { + const { fetcher, deletes } = workspace(); + await openWorkspace(fetcher, "/chat/chat-a"); + expect(window.location.pathname).toBe("/chat/chat-a"); + + await deleteChatAndControlTime("Alpha plan"); + expect(window.location.pathname).toBe("/chat/chat-b"); + + fireEvent.click(toastRegion().getByRole("button", { name: "Undo" })); + await passTime(0); + + expect(window.location.pathname).toBe("/chat/chat-a"); + expect(deletes).toEqual([]); + }); + + it("does not pull the person back to a restored chat after they moved on", async () => { + const { fetcher } = workspace(); + await openWorkspace(fetcher, "/chat/chat-a"); + + await deleteChatAndControlTime("Alpha plan"); + expect(window.location.pathname).toBe("/chat/chat-b"); + act(() => { window.history.pushState({}, "", "/chat/new"); window.dispatchEvent(new PopStateEvent("popstate")); }); + + fireEvent.click(toastRegion().getByRole("button", { name: "Undo" })); + await passTime(0); + + expect(window.location.pathname).toBe("/chat/new"); + expect(screen.getByRole("button", { name: "Delete Alpha plan" })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/App.drafts.test.tsx b/frontend/src/app/App.drafts.test.tsx new file mode 100644 index 0000000..9a2b793 --- /dev/null +++ b/frontend/src/app/App.drafts.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; +import { CortexApi } from "../api/client"; +import { ToastProvider } from "./ToastProvider"; + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, +}); + +describe("App composer drafts", () => { + beforeAll(async () => { + await Promise.all([ + import("../features/chat/ChatPage"), + import("../features/settings/SettingsPanel"), + ]); + }, 120_000); + + afterEach(() => { + window.sessionStorage.clear(); + window.localStorage.clear(); + window.history.replaceState({}, "", "/"); + }); + + it("prunes drafts of chats that no longer exist once the workspace loads, and keeps the rest", async () => { + window.sessionStorage.setItem("cortex.session.token", "local-session"); + window.localStorage.setItem("cortex.composer.draft.chat-a", "still here"); + window.localStorage.setItem("cortex.composer.draft.deleted-chat", "belonged to a deleted chat"); + window.localStorage.setItem("cortex.composer.draft.new", "the new-chat draft"); + const fetcher = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith("/system")) return json({ status: "ok", preview: true, session_required: true, started_at: "2026-07-21T18:00:00Z" }); + if (url.endsWith("/chat-groups")) return json([]); + if (url.endsWith("/chats")) return json([{ id: "chat-a", title: "Alpha plan", timestamp: "2026-01-02T00:00:00Z" }]); + if (url.endsWith("/settings")) return json({ settings: { models: { chat: null, title: null }, appearance: { theme: "dark" } } }); + if (url.endsWith("/models")) return json({ required_models: [], optional_models: [], installed_models: [], connection: { success: true, status: "connected", message: "Ready" } }); + return json({ detail: "Unexpected test route." }, 404); + }); + + render(); + expect(await screen.findByRole("button", { name: "Delete Alpha plan" }, { timeout: 10_000 })).toBeInTheDocument(); + + expect(window.localStorage.getItem("cortex.composer.draft.deleted-chat")).toBeNull(); + expect(window.localStorage.getItem("cortex.composer.draft.chat-a")).toBe("still here"); + expect(window.localStorage.getItem("cortex.composer.draft.new")).toBe("the new-chat draft"); + }); + + it("does not prune anything when the chat list could not be loaded", async () => { + window.sessionStorage.setItem("cortex.session.token", "local-session"); + window.localStorage.setItem("cortex.composer.draft.chat-a", "must survive a failed load"); + const fetcher = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith("/chats")) return json({ detail: "The chat list is unavailable." }, 500); + if (url.endsWith("/system")) return json({ status: "ok", preview: true, session_required: true, started_at: "2026-07-21T18:00:00Z" }); + if (url.endsWith("/settings")) return json({ settings: { models: { chat: null, title: null }, appearance: { theme: "dark" } } }); + return json({ detail: "Unexpected test route." }, 404); + }); + + render(); + expect(await screen.findByRole("heading", { name: "Workspace unavailable" }, { timeout: 10_000 })).toBeVisible(); + + expect(window.localStorage.getItem("cortex.composer.draft.chat-a")).toBe("must survive a failed load"); + }); +}); diff --git a/frontend/src/app/App.inventoryLoading.test.tsx b/frontend/src/app/App.inventoryLoading.test.tsx new file mode 100644 index 0000000..e190b74 --- /dev/null +++ b/frontend/src/app/App.inventoryLoading.test.tsx @@ -0,0 +1,156 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; +import { CortexApi } from "../api/client"; +import { useModelStore } from "../stores/useModelStore"; +import { ToastProvider } from "./ToastProvider"; + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, +}); + +const UNAVAILABLE_TEXT = "The selected local model is unavailable"; + +describe("App model inventory loading", () => { + beforeAll(async () => { + await Promise.all([ + import("../features/chat/ChatPage"), + import("../features/settings/SettingsPanel"), + ]); + }, 120_000); + + afterEach(() => { + useModelStore.getState().setModels(null); + window.sessionStorage.clear(); + window.history.replaceState({}, "", "/"); + }); + + /** A workspace whose model inventory answers only when the test says so. */ + function workspaceWithSlowInventory() { + let answer: (response: Response) => void = () => undefined; + const inventory = new Promise((resolve) => { answer = resolve; }); + const fetcher = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith("/system")) return json({ status: "ok", preview: true, session_required: true, started_at: "2026-07-21T18:00:00Z" }); + if (url.endsWith("/chat-groups")) return json([]); + if (url.endsWith("/chats")) return json([]); + if (url.endsWith("/settings")) return json({ settings: { models: { chat: "qwen3:8b", title: null }, appearance: { theme: "dark" } } }); + if (url.endsWith("/memories")) return json({ memos: [] }); + if (url.endsWith("/models")) return inventory; + return json({ detail: "Unexpected test route." }, 404); + }); + return { fetcher, answer }; + } + + const openWorkspace = async (fetcher: typeof fetch) => { + window.sessionStorage.setItem("cortex.session.token", "local-session"); + window.history.replaceState({}, "", "/chat/new"); + render(); + expect(await screen.findByRole("heading", { name: "New thread" }, { timeout: 10_000 })).toBeVisible(); + }; + + const indicator = () => document.querySelector(".connection-indicator") as HTMLElement; + + it("does not report the selected model as unavailable while the inventory is loading", async () => { + const { fetcher, answer } = workspaceWithSlowInventory(); + await openWorkspace(fetcher); + + // The workspace is open and the inventory has not answered: neutral, not a fault. + expect(await screen.findByText("Checking local models…")).toBeVisible(); + expect(screen.queryByText(new RegExp(UNAVAILABLE_TEXT))).not.toBeInTheDocument(); + expect(indicator()).toHaveClass("connection-pending"); + expect(indicator()).not.toHaveClass("connection-error"); + expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(); + + await act(async () => { + answer(json({ required_models: [], optional_models: [], installed_models: ["qwen3:8b"], models: [{ name: "qwen3:8b" }], connection: { success: true, status: "connected", message: "Ready" } })); + }); + + await waitFor(() => expect(screen.queryByText("Checking local models…")).not.toBeInTheDocument()); + expect(screen.queryByText(new RegExp(UNAVAILABLE_TEXT))).not.toBeInTheDocument(); + expect(indicator()).toHaveClass("connection-connected"); + }); + + it("says the selected model is unavailable once the inventory has answered without it", async () => { + const { fetcher, answer } = workspaceWithSlowInventory(); + await openWorkspace(fetcher); + expect(await screen.findByText("Checking local models…")).toBeVisible(); + + await act(async () => { + answer(json({ required_models: [], optional_models: [], installed_models: ["other:1b"], models: [{ name: "other:1b" }], connection: { success: true, status: "connected", message: "Ready" } })); + }); + + expect(await screen.findByText(new RegExp(UNAVAILABLE_TEXT))).toBeVisible(); + expect(screen.queryByText("Checking local models…")).not.toBeInTheDocument(); + }); + + it("reports the model service as unavailable when the inventory request fails", async () => { + const { fetcher, answer } = workspaceWithSlowInventory(); + await openWorkspace(fetcher); + expect(await screen.findByText("Checking local models…")).toBeVisible(); + + await act(async () => { answer(json({ detail: "Model inventory is unavailable." }, 503)); }); + + expect(await screen.findByText(new RegExp(UNAVAILABLE_TEXT))).toBeVisible(); + expect(indicator()).toHaveClass("connection-error"); + expect(screen.queryByText("Checking local models…")).not.toBeInTheDocument(); + }); + + it("lets a finished model job's inventory stand when the first request answers after it", async () => { + // The first inventory request is still out when a rescan (a model job) + // finishes and reads a fresh inventory of its own. The job's answer is the + // newer one, so it must end the loading placeholder and must not be + // overwritten when the older request finally comes back. + let answerFirst: (response: Response) => void = () => undefined; + const firstInventory = new Promise((resolve) => { answerFirst = resolve; }); + let inventoryRequests = 0; + const inventoryOf = (name: string) => ({ + required_models: [], + optional_models: [], + installed_models: [name], + models: [{ name }], + connection: { success: true, status: "connected", message: "Ready" }, + }); + const fetcher = vi.fn(async (input, init) => { + const url = String(input); + if (url.endsWith("/system")) return json({ status: "ok", preview: true, session_required: true, started_at: "2026-07-21T18:00:00Z" }); + if (url.endsWith("/chat-groups")) return json([]); + if (url.endsWith("/chats")) return json([]); + if (url.endsWith("/settings")) return json({ settings: { models: { chat: "qwen3:8b", title: null }, appearance: { theme: "dark" } } }); + if (url.endsWith("/memories")) return json({ memos: [] }); + if (url.endsWith("/jobs/models") && init?.method === "POST") return json({ job_id: "scan-job", kind: "models", status: "queued" }, 202); + if (url.endsWith("/jobs/scan-job/events")) { + const event = { id: 1, job_id: "scan-job", kind: "completed", status: "succeeded", phase: null, data: {} }; + return new Response(`data: ${JSON.stringify(event)}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); + } + if (url.endsWith("/models")) { + inventoryRequests += 1; + return inventoryRequests === 1 ? firstInventory : json(inventoryOf("qwen3:8b")); + } + return json({ detail: "Unexpected test route." }, 404); + }); + await openWorkspace(fetcher); + expect(await screen.findByText("Checking local models…")).toBeVisible(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("link", { name: "Settings" })); + await user.click(await screen.findByRole("button", { name: "System" }, { timeout: 10_000 })); + await user.click(screen.getByRole("button", { name: /Rescan local models/ })); + await waitFor(() => expect(useModelStore.getState().models?.installed_models).toEqual(["qwen3:8b"]), { timeout: 10_000 }); + + // Back in the chat, the placeholder is gone although the first request is still out. + await user.click(screen.getByRole("button", { name: "Close settings" })); + await waitFor(() => expect(indicator()).toHaveClass("connection-connected")); + expect(screen.queryByText("Checking local models…")).not.toBeInTheDocument(); + expect(screen.queryByText(new RegExp(UNAVAILABLE_TEXT))).not.toBeInTheDocument(); + + await act(async () => { answerFirst(json(inventoryOf("stale:1b"))); }); + + expect(useModelStore.getState().models?.installed_models).toEqual(["qwen3:8b"]); + expect(indicator()).toHaveClass("connection-connected"); + expect(screen.queryByText(new RegExp(UNAVAILABLE_TEXT))).not.toBeInTheDocument(); + expect(inventoryRequests).toBe(2); + }); +}); diff --git a/frontend/src/app/App.modelDownloadFailure.test.tsx b/frontend/src/app/App.modelDownloadFailure.test.tsx new file mode 100644 index 0000000..6abf82a --- /dev/null +++ b/frontend/src/app/App.modelDownloadFailure.test.tsx @@ -0,0 +1,101 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; +import { CortexApi } from "../api/client"; +import { ToastProvider } from "./ToastProvider"; + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, +}); + +describe("App model download failures", () => { + // The two lazy routes are loaded once up front so the first test's time + // covers what it does rather than a cold module graph (see App.test.tsx). + beforeAll(async () => { + await Promise.all([ + import("../features/chat/ChatPage"), + import("../features/settings/SettingsPanel"), + ]); + }, 120_000); + + afterEach(() => { + window.sessionStorage.clear(); + window.history.replaceState({}, "", "/"); + }); + + const workspace = (onDownload: (url: string, init?: RequestInit) => Response | null) => vi.fn(async (input, init) => { + const url = String(input); + const handled = onDownload(url, init); + if (handled) return handled; + if (url.endsWith("/system")) return json({ status: "ok", preview: true, session_required: true, started_at: "2026-07-21T18:00:00Z" }); + if (url.endsWith("/chat-groups")) return json([]); + if (url.endsWith("/chats")) return json([]); + if (url.endsWith("/memories")) return json({ memos: [] }); + if (url.endsWith("/settings")) return json({ settings: { models: { chat: null, title: null }, appearance: { theme: "dark" } } }); + if (url.endsWith("/models")) return json({ required_models: [], optional_models: [], installed_models: [], connection: { success: true, status: "connected", message: "Ready" } }); + return json({ detail: "Unexpected test route." }, 404); + }); + + const startDownload = async () => { + const user = userEvent.setup(); + await user.click(screen.getByRole("link", { name: "Settings" })); + await user.click(await screen.findByRole("button", { name: "System" }, { timeout: 10_000 })); + await user.type(screen.getByLabelText(/Repo id/), "vendor/demo-GGUF"); + await user.type(screen.getByLabelText(/File name/), "demo.Q4_K_M.gguf"); + await user.click(screen.getByRole("button", { name: /Download model/ })); + }; + + it("keeps the failure reason on the form after the job reports it", async () => { + // The reason used to appear only in a toast, while the form told the + // person to look at that toast. + window.sessionStorage.setItem("cortex.session.token", "local-session"); + const reason = "Checksum mismatch: the downloaded file is not the published one."; + const fetcher = workspace((url, init) => { + if (url.endsWith("/models/gguf/downloads") && init?.method === "POST") { + return json({ job_id: "gguf-job", kind: "gguf_download", status: "queued" }, 202); + } + if (url.endsWith("/jobs/gguf-job/events")) { + const event = { id: 1, job_id: "gguf-job", kind: "error", status: "failed", phase: null, data: { message: reason } }; + return new Response(`data: ${JSON.stringify(event)}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); + } + return null; + }); + + render(); + expect(await screen.findByRole("heading", { name: "New thread" })).toBeVisible(); + await startDownload(); + + await waitFor(() => expect(screen.getAllByRole("alert").filter((node) => node.textContent?.includes(reason))).toHaveLength(2)); + const inline = screen.getAllByRole("alert").find((node) => node.classList.contains("field-error") && node.textContent?.includes(reason)); + expect(inline).toBeDefined(); + expect(inline?.textContent).toBe(reason); + expect(inline).not.toHaveTextContent("notification"); + // The toast is dismissible rather than timed. + const toast = screen.getAllByRole("alert").find((node) => node.classList.contains("toast")); + expect(toast).toBeDefined(); + expect(within(toast as HTMLElement).getByRole("button", { name: "Dismiss notification" })).toBeVisible(); + }); + + it("keeps the reason when the download cannot be started", async () => { + window.sessionStorage.setItem("cortex.session.token", "local-session"); + const fetcher = workspace((url, init) => { + if (url.endsWith("/models/gguf/downloads") && init?.method === "POST") { + return json({ detail: "That host is not on the download allow-list." }, 422); + } + return null; + }); + + render(); + expect(await screen.findByRole("heading", { name: "New thread" })).toBeVisible(); + await startDownload(); + + const inline = await waitFor(() => { + const node = screen.getAllByRole("alert").find((candidate) => candidate.classList.contains("field-error") && candidate.textContent?.includes("allow-list")); + expect(node).toBeDefined(); + return node as HTMLElement; + }); + expect(inline.textContent).toBe("That host is not on the download allow-list."); + }); +}); diff --git a/frontend/src/app/App.modelJobs.test.tsx b/frontend/src/app/App.modelJobs.test.tsx index 6190a9e..ca44cc0 100644 --- a/frontend/src/app/App.modelJobs.test.tsx +++ b/frontend/src/app/App.modelJobs.test.tsx @@ -250,7 +250,11 @@ describe("a model download", () => { await server.streams[0].push({ id: 2, kind: "error", status: "failed", data: { message: reason } }); await server.streams[0].close(); - expect(await screen.findByRole("alert", {}, WAIT)).toHaveTextContent(reason); + // A failure also raises an error toast, which is an alert of its own and + // carries the same sentence; this is the copy that stays beside the button. + const inline = await screen.findByText(reason, { selector: ".field-error" }, WAIT); + expect(inline).toHaveAttribute("role", "alert"); + expect(inline.textContent).toBe(reason); expect(screen.getByLabelText(/Repo id/)).toHaveValue("owner/name"); expect(remembered()).toBeNull(); }); diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 74f1a2e..515b634 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,4 +1,4 @@ -import { Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ChatResponse, CortexSettings, ExecutionApprovalDecisionRequest, ExecutionTaskSummary, JobAccepted, LlamaCppRuntimeStatus, MemoryResponse, ModelDownloadRequest, ModelResponse, SystemResponse } from "../../../contracts/cortex-api"; import { CortexApi, @@ -19,6 +19,7 @@ const SettingsPanel = lazyRoute(() => import("../features/se import type { SettingsPanelProps } from "../features/settings/SettingsPanel"; import type { MemoryLoadState } from "../features/settings/MemoryPanel"; import { blockStrayFileDrops } from "../lib/attachments"; +import { discardComposerDraft, pruneComposerDrafts } from "../lib/composerDraft"; import { displayModelName, isGGUFModel, localModelNames } from "../lib/localModels"; import { ModelJobCancelledError } from "../lib/modelJobs"; import { chatPath, navigate, parseAppRoute, useNavigate, usePathname } from "../lib/navigation"; @@ -36,9 +37,20 @@ import { resolveRuntimeAvailability } from "./runtimeAvailability"; type Props = { api?: CortexApi }; +/** + * How long "Chat deleted" offers Undo. The request is sent when this window + * closes, so it is also how long a deleted chat still exists on the backend. + */ +const CHAT_DELETE_UNDO_MS = 6000; + /** Statuses an execution task never leaves, so nothing more will change. */ const EXECUTION_TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled"]); +/** + * What the model inventory shows when the request for it failed. Distinct from + * "not answered yet", which is a null inventory in the store: a placeholder that + * looked like this one on every launch reported a fault that was not there. + */ const UNAVAILABLE_MODELS: ModelResponse = { required_models: [], optional_models: [], @@ -53,6 +65,16 @@ const UNAVAILABLE_MODELS: ModelResponse = { }, }; +/** Shown only while the first inventory request is in flight: empty, and no connection verdict either way. */ +const LOADING_MODELS: ModelResponse = { + required_models: [], + optional_models: [], + installed_models: [], + missing_models: [], + optional_missing_models: [], + models: [], +}; + const DEFAULT_LLAMACPP_STATUS: LlamaCppRuntimeStatus = { state: "idle", binary_present: false, @@ -286,7 +308,23 @@ function AuthenticatedWorkspace({ api, onSessionExpired }: { api: CortexApi; onS // derived from it (and painted by useAppliedTheme), never copied into state. const theme = settings?.appearance?.theme ?? DEFAULT_THEME_PREFERENCE; useAppliedTheme(settings ? theme : null); - const chatsRef = useRef(chats); + // A chat that was just deleted is hidden here at once but only removed from + // the backend when its Undo window closes (see `deleteChat`). Everything that + // lists or picks a chat reads `visibleChats`, so a hidden chat stays hidden + // even if the store's list is refilled from the server or a finishing + // generation upserts it, and Undo puts it back exactly as it was. + const pendingChatDeletesRef = useRef(new Set()); + const [pendingChatDeleteIds, setPendingChatDeleteIds] = useState>(() => new Set()); + const visibleChats = useMemo( + () => chats.filter((chat) => !pendingChatDeleteIds.has(chat.id)), + [chats, pendingChatDeleteIds], + ); + const setChatDeletePending = useCallback((id: string, pending: boolean) => { + if (pending) pendingChatDeletesRef.current.add(id); + else pendingChatDeletesRef.current.delete(id); + setPendingChatDeleteIds(new Set(pendingChatDeletesRef.current)); + }, []); + const chatsRef = useRef(visibleChats); const executionTaskRefreshRef = useRef | null>(null); // These guards cover requests whose results are deliberately loaded out of // band. A response can outlive both the load that started it and this @@ -311,8 +349,8 @@ function AuthenticatedWorkspace({ api, onSessionExpired }: { api: CortexApi; onS }, []); useEffect(() => { - chatsRef.current = chats; - }, [chats]); + chatsRef.current = visibleChats; + }, [visibleChats]); const loadWorkspace = useCallback(async () => { const loadGeneration = ++workspaceLoadGenerationRef.current; @@ -336,6 +374,8 @@ function AuthenticatedWorkspace({ api, onSessionExpired }: { api: CortexApi; onS setSystem(systemResponse); setLlamacppStatus(systemResponse.llamacpp ?? null); setChats(chatResponse); + // `chatResponse` is the whole list, so any other saved draft belongs to a chat that is gone. + pruneComposerDrafts(chatResponse.map((chat) => chat.id)); // Groups are organisation on top of the chats, so they load out of band // like the model inventory does: if the endpoint is unavailable the // library still opens with every chat present, just ungrouped. Blocking @@ -348,7 +388,8 @@ function AuthenticatedWorkspace({ api, onSessionExpired }: { api: CortexApi; onS if (isCurrentGroupLoad()) setGroups([]); }); setSettings(settingsResponse.settings); - if (isCurrentModelLoad()) setModels(UNAVAILABLE_MODELS); + // Nothing is known about the inventory until it answers (see LOADING_MODELS). + if (isCurrentModelLoad()) setModels(null); void api.models() .then((nextModels) => { if (isCurrentModelLoad()) setModels(nextModels); @@ -522,19 +563,43 @@ function AuthenticatedWorkspace({ api, onSessionExpired }: { api: CortexApi; onS } }; + // Deleting hides the chat at once and asks the backend only when the Undo + // window closes. Closing the app inside that window loses nothing: the + // request was never sent, so the chat is simply still there next launch. const deleteChat = async (id: string): Promise => { - try { - await api.deleteChat(id); - const fallbackChatId = chatsRef.current.find((chat) => chat.id !== id)?.id ?? null; - setChats((current) => current.filter((chat) => chat.id !== id)); - setSettingsReturnChatId((current) => current === id ? fallbackChatId : current); - const currentRoute = parseAppRoute(window.location.pathname); - if (currentRoute.kind === "chat" && currentRoute.threadId === id) { - navigate(fallbackChatId ? chatPath(fallbackChatId) : "/chat/new", { replace: true }); + if (pendingChatDeletesRef.current.has(id)) return true; + const fallbackChatId = chatsRef.current.find((chat) => chat.id !== id)?.id ?? null; + const fallbackPath = fallbackChatId ? chatPath(fallbackChatId) : "/chat/new"; + const currentRoute = parseAppRoute(window.location.pathname); + const wasOpen = currentRoute.kind === "chat" && currentRoute.threadId === id; + setChatDeletePending(id, true); + setSettingsReturnChatId((current) => current === id ? fallbackChatId : current); + if (wasOpen) navigate(fallbackPath, { replace: true }); + + const commit = async () => { + try { + await api.deleteChat(id); + discardComposerDraft(id); + // Take the row out of the list before it is un-hidden, so it cannot flash back. + setChats((current) => current.filter((chat) => chat.id !== id)); + setChatDeletePending(id, false); + } catch (error) { + setChatDeletePending(id, false); + notify(apiMessage(error, "Could not delete chat."), "error"); } - notify("Chat deleted.", "success"); - return true; - } catch (error) { notify(apiMessage(error, "Could not delete chat."), "error"); return false; } + }; + const undo = () => { + setChatDeletePending(id, false); + // Only follow the chat back if the person is still where deleting it left them. + if (wasOpen && window.location.pathname === fallbackPath) navigate(chatPath(id), { replace: true }); + notify("Chat restored.", "success"); + }; + notify("Chat deleted.", "success", { + action: { label: "Undo", onAction: undo }, + durationMs: CHAT_DELETE_UNDO_MS, + onClose: () => void commit(), + }); + return true; }; const createGroup = async (name: string): Promise => { @@ -722,8 +787,9 @@ function AuthenticatedWorkspace({ api, onSessionExpired }: { api: CortexApi; onS try { accepted = await api.downloadGGUFModel(request); } catch (error) { - notify(apiMessage(error, "Could not start the model download."), "error"); - throw error; + const message = apiMessage(error, "Could not start the model download."); + notify(message, "error"); + throw new Error(message, { cause: error }); } const result = await modelJobs.run(accepted, label, { checkOllamaConnection: false, notifyOnSuccess: false }); // The workspace closed first; the download carries on and is picked up @@ -767,14 +833,18 @@ function AuthenticatedWorkspace({ api, onSessionExpired }: { api: CortexApi; onS }; if (loading) return
Loading local workspace...
; - if (loadError || !system || !settings || !models) { + if (loadError || !system || !settings) { return

Workspace unavailable

{loadError ?? "Cortex returned an incomplete workspace."}

; } - const localModels = localModelNames(models); - const hasLocalInventory = Array.isArray(models.installed_models) || Array.isArray(models.models); + // The inventory is fetched after the workspace opens, so for a moment it is + // unknown rather than empty: an empty stand-in with no connection report. + const inventoryLoading = models === null; + const inventory = models ?? LOADING_MODELS; + const localModels = localModelNames(inventory); + const hasLocalInventory = Array.isArray(inventory.installed_models) || Array.isArray(inventory.models); const selectedModel = settings.models?.chat?.trim() || null; - const selectedModelSupportsVision = models.models?.find((model) => model.name === selectedModel)?.supports_vision ?? null; + const selectedModelSupportsVision = inventory.models?.find((model) => model.name === selectedModel)?.supports_vision ?? null; const selectedModelAvailable = Boolean(selectedModel && (!hasLocalInventory || localModels.includes(selectedModel))); // The initial system response seeds the store, while polling updates it as // the managed runtime starts/stops. Settings must consume that live value @@ -785,8 +855,9 @@ function AuthenticatedWorkspace({ api, onSessionExpired }: { api: CortexApi; onS const runtimeAvailability = resolveRuntimeAvailability({ selectedModel, selectedModelAvailable, - ollamaConnected: models.connection?.success ?? true, - ollamaMessage: models.connection?.message, + inventoryLoading, + ollamaConnected: inventory.connection?.success ?? true, + ollamaMessage: inventory.connection?.message, llamacppStatus, }); const routeChatId = route.kind === "chat" ? route.threadId : null; @@ -807,15 +878,15 @@ function AuthenticatedWorkspace({ api, onSessionExpired }: { api: CortexApi; onS <> {/* Above the route switch, so a generation keeps streaming into the store while Settings is open. */} - + Loading workspace...}> {route.kind === "settings" - ? + ? : } navigate("/chat/new")} diff --git a/frontend/src/app/ToastProvider.test.tsx b/frontend/src/app/ToastProvider.test.tsx new file mode 100644 index 0000000..9036b6e --- /dev/null +++ b/frontend/src/app/ToastProvider.test.tsx @@ -0,0 +1,97 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useUiStore } from "../stores/useUiStore"; +import { ToastProvider } from "./ToastProvider"; + +const notify = (...args: Parameters["notify"]>) => { + let id = 0; + act(() => { id = useUiStore.getState().notify(...args); }); + return id; +}; + +const advance = (ms: number) => act(() => { vi.advanceTimersByTime(ms); }); + +describe("ToastProvider", () => { + beforeEach(() => { + vi.useFakeTimers(); + useUiStore.setState({ toasts: [] }); + }); + + afterEach(() => { + act(() => { useUiStore.setState({ toasts: [] }); }); + vi.useRealTimers(); + }); + + it("keeps an error toast past ten seconds and removes it with its Dismiss button", () => { + render(

app

); + + notify("The download failed: the checksum did not match.", "error"); + advance(10_000); + + expect(screen.getByRole("alert")).toHaveTextContent("The download failed: the checksum did not match."); + fireEvent.click(screen.getByRole("button", { name: "Dismiss notification" })); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("expires a success toast and announces it as a status without a dismiss control", () => { + render(

app

); + + notify("Chat renamed.", "success"); + expect(screen.getByRole("status")).toHaveTextContent("Chat renamed."); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Dismiss notification" })).not.toBeInTheDocument(); + + advance(4500); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("does not make the region itself a live region, so nothing is read twice", () => { + const { container } = render(

app

); + notify("Saved.", "success"); + + const region = container.ownerDocument.querySelector(".toast-region"); + expect(region).not.toBeNull(); + expect(region).not.toHaveAttribute("aria-live"); + expect(region).not.toHaveAttribute("aria-atomic"); + }); + + it("waits while a toast is hovered, then finishes its countdown", () => { + render(

app

); + + notify("Model ready.", "info"); + advance(4000); + fireEvent.mouseEnter(screen.getByRole("status")); + advance(30_000); + expect(screen.getByRole("status")).toBeVisible(); + + fireEvent.mouseLeave(screen.getByRole("status")); + advance(499); + expect(screen.getByRole("status")).toBeVisible(); + advance(1); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("pauses while keyboard focus is on the toast's action and resumes when it leaves", () => { + render(); + + notify("Chat deleted.", "success", { action: { label: "Undo", onAction: vi.fn() }, durationMs: 6000 }); + act(() => { screen.getByRole("button", { name: "Undo" }).focus(); }); + advance(60_000); + expect(screen.getByRole("status")).toBeVisible(); + + act(() => { screen.getByRole("button", { name: "elsewhere" }).focus(); }); + advance(6000); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("runs a toast's action from its button", () => { + const onAction = vi.fn(); + render(

app

); + + notify("Chat deleted.", "success", { action: { label: "Undo", onAction } }); + fireEvent.click(screen.getByRole("button", { name: "Undo" })); + + expect(onAction).toHaveBeenCalledOnce(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/ToastProvider.tsx b/frontend/src/app/ToastProvider.tsx index f3f0654..0d80baf 100644 --- a/frontend/src/app/ToastProvider.tsx +++ b/frontend/src/app/ToastProvider.tsx @@ -1,7 +1,7 @@ /* eslint-disable react-refresh/only-export-components */ -import { Check, Info, X } from "lucide-react"; -import type { ReactNode } from "react"; -import { useUiStore } from "../stores/useUiStore"; +import { AlertTriangle, Check, Info, X } from "lucide-react"; +import type { FocusEvent, ReactNode } from "react"; +import { useUiStore, type Toast } from "../stores/useUiStore"; export { useToast } from "../stores/useUiStore"; @@ -11,16 +11,55 @@ export function ToastProvider({ children }: { children: ReactNode }) { return ( <> {children} -
- {toasts.map((toast) => ( -
- {toast.kind === "success" &&
- ))} + {/* + Each toast is its own live region (an alert for a failure, a status for + the rest), so the region itself announces nothing and a toast is never + read twice. + */} +
+ {toasts.map((toast) => )}
); } + +function ToastItem({ toast }: { toast: Toast }) { + const dismissToast = useUiStore((state) => state.dismissToast); + const pauseToast = useUiStore((state) => state.pauseToast); + const resumeToast = useUiStore((state) => state.resumeToast); + const runToastAction = useUiStore((state) => state.runToastAction); + const { id } = toast; + const isError = toast.kind === "error"; + + // A toast that is being hovered or has keyboard focus inside it is being + // read or used, so its countdown waits. + const resumeUnlessFocusStaysInside = (event: FocusEvent) => { + if (!event.currentTarget.contains(event.relatedTarget)) resumeToast(id); + }; + + return ( +
pauseToast(id)} + onMouseLeave={() => resumeToast(id)} + onFocus={() => pauseToast(id)} + onBlur={resumeUnlessFocusStaysInside} + > + {toast.kind === "success" &&
+ ); +} diff --git a/frontend/src/app/runtimeAvailability.test.ts b/frontend/src/app/runtimeAvailability.test.ts index e7f0234..7306cfc 100644 --- a/frontend/src/app/runtimeAvailability.test.ts +++ b/frontend/src/app/runtimeAvailability.test.ts @@ -63,3 +63,54 @@ describe("resolveRuntimeAvailability for a GGUF model", () => { ).toEqual({ ready: true, reason: null, message: null }); }); }); + +describe("resolveRuntimeAvailability while the model inventory is loading", () => { + const loading = (selectedModel: string | null, overrides: Partial[0]> = {}) => + resolveRuntimeAvailability({ + selectedModel, + // What the app computes from the empty stand-in inventory. + selectedModelAvailable: false, + inventoryLoading: true, + ollamaConnected: true, + llamacppStatus: { state: "idle" }, + ...overrides, + }); + + it("reports a neutral checking message instead of calling the model unavailable", () => { + expect(loading("qwen3:8b")).toEqual({ ready: false, reason: "inventory-loading", message: "Checking local models…" }); + expect(loading("gguf:demo.gguf").reason).toBe("inventory-loading"); + }); + + it("does not let a stale runtime verdict speak for the inventory that has not answered", () => { + expect(loading("gguf:demo.gguf", { llamacppStatus: { state: "failed", last_error: "Vulkan device lost." } }).reason).toBe("inventory-loading"); + expect(loading("qwen3:8b", { ollamaConnected: false, ollamaMessage: "Ollama is not running." }).reason).toBe("inventory-loading"); + }); + + it("still asks for a model when none is selected", () => { + expect(loading(null).reason).toBe("no-model-selected"); + }); + + it("reports a real unavailable model once the inventory has answered", () => { + const answered = resolveRuntimeAvailability({ + selectedModel: "qwen3:8b", + selectedModelAvailable: false, + inventoryLoading: false, + ollamaConnected: true, + llamacppStatus: { state: "idle" }, + }); + + expect(answered.reason).toBe("model-unavailable"); + expect(answered.message).toContain("unavailable"); + }); + + it("is off by default, so existing callers keep their behaviour", () => { + const result = resolveRuntimeAvailability({ + selectedModel: "qwen3:8b", + selectedModelAvailable: true, + ollamaConnected: true, + llamacppStatus: { state: "idle" }, + }); + + expect(result).toEqual({ ready: true, reason: null, message: null }); + }); +}); diff --git a/frontend/src/app/runtimeAvailability.ts b/frontend/src/app/runtimeAvailability.ts index 706f0fc..969b26b 100644 --- a/frontend/src/app/runtimeAvailability.ts +++ b/frontend/src/app/runtimeAvailability.ts @@ -3,6 +3,7 @@ import { isGGUFModel } from "../lib/localModels"; export type RuntimeDisableReason = | "no-model-selected" + | "inventory-loading" | "model-unavailable" | "ollama-unavailable" | "gguf-runtime-failed" @@ -17,12 +18,19 @@ export type RuntimeAvailability = { export function resolveRuntimeAvailability({ selectedModel, selectedModelAvailable, + inventoryLoading = false, ollamaConnected, ollamaMessage, llamacppStatus, }: { selectedModel: string | null; selectedModelAvailable: boolean; + /** + * The local model inventory has not answered yet. What is installed is not + * known, so the selected model is neither available nor unavailable -- and + * saying "unavailable" on every launch reported a fault that was not there. + */ + inventoryLoading?: boolean; ollamaConnected: boolean; ollamaMessage?: string | null; llamacppStatus: LlamaCppRuntimeStatus; @@ -30,6 +38,9 @@ export function resolveRuntimeAvailability({ if (!selectedModel) { return { ready: false, reason: "no-model-selected", message: "Select a local model before sending a message." }; } + if (inventoryLoading) { + return { ready: false, reason: "inventory-loading", message: "Checking local models…" }; + } if (!selectedModelAvailable) { return { ready: false, reason: "model-unavailable", message: "The selected local model is unavailable. Choose an installed model and try again." }; } diff --git a/frontend/src/features/chat/ChatPage.drafts.test.tsx b/frontend/src/features/chat/ChatPage.drafts.test.tsx new file mode 100644 index 0000000..107849c --- /dev/null +++ b/frontend/src/features/chat/ChatPage.drafts.test.tsx @@ -0,0 +1,110 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ChatResponse } from "../../../../contracts/cortex-api"; +import type { CortexApi } from "../../api/client"; +import { composerDraftKey } from "../../lib/composerDraft"; +import { useChatStore } from "../../stores/useChatStore"; +import { ChatPage } from "./ChatPage"; +import { GenerationStreamHost } from "./GenerationStreamHost"; + +const emptyChat = (id: string): ChatResponse => ({ + id, + title: "New Chat", + timestamp: "2026-01-01T00:00:00Z", + revision: 0, + messages: [], +}); + +function chatApi(overrides: Partial = {}): CortexApi { + return { + chat: vi.fn(async (id: string) => emptyChat(id)), + generate: vi.fn(), + streamGeneration: vi.fn((_jobId, _onEvent, options: { signal?: AbortSignal } = {}) => new Promise((_resolve, reject) => { + options.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + })), + generationStatus: vi.fn(), + cancelGeneration: vi.fn(), + stageChatAttachment: vi.fn(), + ...overrides, + } as unknown as CortexApi; +} + +function renderPage(api: CortexApi, threadId: string | null) { + return render( + <> + undefined} /> + true} + onRescanModels={async () => undefined} + onThreadCreated={vi.fn()} + onForked={vi.fn()} + /> + , + ); +} + +describe("ChatPage drafts across restarts", () => { + afterEach(() => { + window.sessionStorage.clear(); + window.localStorage.clear(); + useChatStore.setState({ generationOptionsByThread: {}, proposedMemoriesByMessage: {} }); + }); + + it("restores a draft an earlier run left in local storage", async () => { + // Nothing in session storage: the page is a fresh launch. + window.localStorage.setItem(composerDraftKey("thread-a"), "Unsent from yesterday"); + + renderPage(chatApi(), "thread-a"); + + expect(await screen.findByLabelText("Message Cortex")).toHaveValue("Unsent from yesterday"); + }); + + it("restores the new-chat draft on a fresh launch", async () => { + window.localStorage.setItem(composerDraftKey(null), "Started a new idea"); + + renderPage(chatApi(), null); + + expect(await screen.findByLabelText("Message Cortex")).toHaveValue("Started a new idea"); + }); + + it("keeps typing in local storage and clears it from both storages when the message is sent", async () => { + const user = userEvent.setup(); + const api = chatApi({ + generate: vi.fn().mockResolvedValue({ job_id: "job-1", kind: "generation", status: "queued", thread_id: "thread-a", user_message_id: "m-1" }), + }); + renderPage(api, "thread-a"); + + await user.type(await screen.findByLabelText("Message Cortex"), "typed and kept"); + expect(window.localStorage.getItem(composerDraftKey("thread-a"))).toBe("typed and kept"); + + await user.click(screen.getByRole("button", { name: "Send message" })); + + await waitFor(() => expect(api.generate).toHaveBeenCalled()); + await waitFor(() => expect(window.localStorage.getItem(composerDraftKey("thread-a"))).toBeNull()); + expect(window.sessionStorage.getItem(composerDraftKey("thread-a"))).toBeNull(); + }); + + it("does not lose the composer when storage is denied", async () => { + const user = userEvent.setup(); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("full", "QuotaExceededError"); }); + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("denied", "SecurityError"); }); + try { + renderPage(chatApi(), "thread-a"); + + const composer = await screen.findByLabelText("Message Cortex"); + await user.type(composer, "still works"); + + expect(composer).toHaveValue("still works"); + } finally { + vi.restoreAllMocks(); + } + }); +}); diff --git a/frontend/src/features/chat/ChatPage.newThread.test.tsx b/frontend/src/features/chat/ChatPage.newThread.test.tsx new file mode 100644 index 0000000..266dd7f --- /dev/null +++ b/frontend/src/features/chat/ChatPage.newThread.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ComponentProps } from "react"; +import type { ChatResponse } from "../../../../contracts/cortex-api"; +import type { CortexApi } from "../../api/client"; +import { useChatStore } from "../../stores/useChatStore"; +import { useUiStore } from "../../stores/useUiStore"; +import { ChatPage } from "./ChatPage"; +import { GenerationStreamHost } from "./GenerationStreamHost"; + +const emptyChat = (id: string): ChatResponse => ({ + id, + title: "New Chat", + timestamp: "2026-01-01T00:00:00Z", + revision: 0, + messages: [], +}); + +function chatApi(overrides: Partial = {}): CortexApi { + return { + chat: vi.fn(async (id: string) => emptyChat(id)), + generate: vi.fn(), + streamGeneration: vi.fn((_jobId, _onEvent, options: { signal?: AbortSignal } = {}) => new Promise((_resolve, reject) => { + options.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + })), + generationStatus: vi.fn(), + cancelGeneration: vi.fn(), + stageChatAttachment: vi.fn(), + ...overrides, + } as unknown as CortexApi; +} + +function renderPage(api: CortexApi, props: Partial> = {}) { + return render( + <> + undefined} /> + true} + onRescanModels={async () => undefined} + onThreadCreated={vi.fn()} + onForked={vi.fn()} + {...props} + /> + , + ); +} + +describe("ChatPage new thread", () => { + afterEach(() => { + window.sessionStorage.clear(); + window.localStorage.clear(); + useChatStore.setState({ generationOptionsByThread: {}, proposedMemoriesByMessage: {} }); + useUiStore.setState({ toasts: [] }); + }); + + it("shows which model will answer, three keyboard hints, and a line about attachments", async () => { + renderPage(chatApi()); + + expect(await screen.findByText("local-chat:7b", { selector: "strong" })).toBeVisible(); + expect(screen.getByText(/Chatting with/)).toBeVisible(); + const hints = screen.getByRole("list", { name: "Keyboard shortcuts" }); + expect(hints.querySelectorAll("li")).toHaveLength(3); + expect(hints).toHaveTextContent("Enter sends"); + expect(hints).toHaveTextContent("Ctrl/Cmd + K opens the command palette"); + expect(hints).toHaveTextContent("? lists every shortcut"); + expect(screen.getByText(/Drop, paste or attach images and text or code files/)).toBeVisible(); + // The copy the product deliberately dropped stays dropped (pinned by the e2e suite too). + expect(screen.queryByText("What will you make today?")).not.toBeInTheDocument(); + expect(screen.queryByText("LOCAL, PRIVATE, YOURS")).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "New thread" })).not.toBeInTheDocument(); + }); + + it("names the GGUF file rather than its internal id", async () => { + renderPage(chatApi(), { selectedModel: "gguf:demo.Q4_K_M.gguf", localModels: ["gguf:demo.Q4_K_M.gguf"] }); + + expect(await screen.findByText("demo.Q4_K_M.gguf", { selector: "strong" })).toBeVisible(); + expect(screen.queryByText(/gguf:/)).not.toBeInTheDocument(); + }); + + it("points at the model picker instead of naming a model when none is selected", async () => { + renderPage(chatApi(), { selectedModel: null, runtimeReady: false, runtimeMessage: "Select a local model before sending a message." }); + + expect(await screen.findByText(/No model is selected yet/)).toBeVisible(); + expect(screen.queryByText(/Chatting with/)).not.toBeInTheDocument(); + }); + + it("is not shown for a saved conversation, even an empty one", async () => { + renderPage(chatApi(), { threadId: "thread-a" }); + + await screen.findByLabelText("Message Cortex"); + expect(screen.queryByText(/Chatting with/)).not.toBeInTheDocument(); + expect(screen.queryByRole("list", { name: "Keyboard shortcuts" })).not.toBeInTheDocument(); + }); + + it("goes away when the first message starts", async () => { + const user = userEvent.setup(); + const api = chatApi({ + generate: vi.fn().mockResolvedValue({ job_id: "job-1", kind: "generation", status: "queued", thread_id: "thread-new", user_message_id: "m-1" }), + }); + renderPage(api); + + expect(await screen.findByText(/Chatting with/)).toBeVisible(); + await user.type(screen.getByLabelText("Message Cortex"), "hello"); + await user.click(screen.getByRole("button", { name: "Send message" })); + + await waitFor(() => expect(api.generate).toHaveBeenCalled()); + await waitFor(() => expect(screen.queryByText(/Chatting with/)).not.toBeInTheDocument()); + }); +}); diff --git a/frontend/src/features/chat/ChatPage.tsx b/frontend/src/features/chat/ChatPage.tsx index 678f8c6..7d3900e 100644 --- a/frontend/src/features/chat/ChatPage.tsx +++ b/frontend/src/features/chat/ChatPage.tsx @@ -14,6 +14,7 @@ import { useUiStore } from "../../stores/useUiStore"; import { Dialog, DialogContent } from "../../shared/ui/Dialog"; import { MessageComposer, type ComposerPhase } from "./MessageComposer"; import { MessageList, type MessageListHandle } from "./MessageList"; +import { NewChatGuide } from "./NewChatGuide"; import { PendingAssistantMessage } from "./PendingAssistantMessage"; const DEFAULT_GENERATION_SETTINGS = { @@ -877,6 +878,10 @@ export function ChatPage({ setShowJumpToLatest(false); }; + // A new thread has nothing to show yet, so it shows a short guide until the + // first message starts (a starting or streaming first turn has its own bubble). + const showNewChatGuide = threadId === null && messages.length === 0 && !starting; + if (chatLoad.threadId !== threadId || chatLoad.loading) return
Loading conversation...
; if (chatLoad.error) return

Conversation unavailable

{chatLoad.error}

; @@ -899,7 +904,9 @@ export function ChatPage({ onFork={forkFrom} onSaveMemory={saveProposedMemory} onNearEndChange={handleNearEndChange} - trailingContent={activeJobForCurrentThread ? : null} + trailingContent={activeJobForCurrentThread + ? + : showNewChatGuide ? : null} />
{showJumpToLatest && } diff --git a/frontend/src/features/chat/NewChatGuide.tsx b/frontend/src/features/chat/NewChatGuide.tsx new file mode 100644 index 0000000..642ae2a --- /dev/null +++ b/frontend/src/features/chat/NewChatGuide.tsx @@ -0,0 +1,25 @@ +import { displayModelName } from "../../lib/localModels"; + +/** + * What a new thread shows before the first message: which model will answer, + * the few keys worth knowing, and that files can be attached. Deliberately + * quiet -- no hero, no suggested prompts -- and it goes away as soon as the + * first message starts. + */ +export function NewChatGuide({ selectedModel }: { selectedModel: string | null }) { + return ( +
+

+ {selectedModel + ? <>Chatting with {displayModelName(selectedModel)} + : "No model is selected yet. Choose one with the model picker under the message box."} +

+
    +
  • Enter sends, Shift + Enter starts a new line
  • +
  • Ctrl/Cmd + K opens the command palette
  • +
  • ? lists every shortcut
  • +
+

Drop, paste or attach images and text or code files to include them.

+
+ ); +} diff --git a/frontend/src/features/models/ModelsPanel.test.tsx b/frontend/src/features/models/ModelsPanel.test.tsx index 2f0a5e1..6c403c7 100644 --- a/frontend/src/features/models/ModelsPanel.test.tsx +++ b/frontend/src/features/models/ModelsPanel.test.tsx @@ -383,3 +383,52 @@ describe("how a download ends", () => { expect(screen.getByRole("button", { name: "Downloading…" })).toBeDisabled(); }); }); + +describe("the reason a download failed, beside the form", () => { + // The reason used to reach the person only as a toast, and the form said + // "See the notification" after that toast had already gone. These render the + // panel without a file lister so the file name field is in plain view. + async function submitHuggingFaceDownload(user: ReturnType) { + await user.type(screen.getByLabelText(/Repo id/), "vendor/demo-GGUF"); + await user.type(fileNameInput(), "demo.Q4_K_M.gguf"); + await user.click(screen.getByRole("button", { name: /Download model/ })); + } + + it("shows the reason in the form itself, not only in a notification", async () => { + const reason = "The downloaded file did not match the published SHA-256 checksum"; + const { user, onDownload } = renderPanel({ onListFiles: null, onDownload: () => Promise.reject(new Error(reason)) }); + + await submitHuggingFaceDownload(user); + + const alert = await screen.findByText(/did not match the published SHA-256 checksum/); + expect(alert.textContent).toBe(reason); + expect(alert).toHaveAttribute("role", "alert"); + expect(alert).not.toHaveTextContent("notification"); + expect(onDownload).toHaveBeenCalledWith({ source: "huggingface", repo_id: "vendor/demo-GGUF", filename: "demo.Q4_K_M.gguf" }); + expect(screen.getByLabelText(/Repo id/)).toHaveValue("vendor/demo-GGUF"); + }); + + it("falls back to a generic sentence when the rejection carries no message", async () => { + const { user } = renderPanel({ onListFiles: null, onDownload: () => Promise.reject(undefined) }); + + await submitHuggingFaceDownload(user); + + expect(await screen.findByText("The download did not complete. Check the details above and try again.")).toHaveAttribute("role", "alert"); + }); + + it("clears the reason when the next download starts and empties the form when it succeeds", async () => { + const onDownload = vi.fn<(request: unknown) => Promise>() + .mockRejectedValueOnce(new Error("Connection reset")) + .mockResolvedValueOnce(); + const { user } = renderPanel({ onListFiles: null, onDownload }); + + await submitHuggingFaceDownload(user); + expect(await screen.findByText("Connection reset")).toBeVisible(); + + await user.click(screen.getByRole("button", { name: /Download model/ })); + + await waitFor(() => expect(screen.getByLabelText(/Repo id/)).toHaveValue("")); + expect(screen.queryByText(/Connection reset/)).not.toBeInTheDocument(); + expect(onDownload).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/features/settings/MemoryPanel.test.tsx b/frontend/src/features/settings/MemoryPanel.test.tsx index 72aaa3a..4e79dee 100644 --- a/frontend/src/features/settings/MemoryPanel.test.tsx +++ b/frontend/src/features/settings/MemoryPanel.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useState } from "react"; import { describe, expect, it, vi } from "vitest"; @@ -102,7 +102,6 @@ describe("MemoryPanel", () => { it("only clears the draft after clear succeeds", async () => { const user = userEvent.setup(); const onClear = vi.fn<() => Promise>().mockRejectedValue(new Error("clear failed")); - vi.spyOn(window, "confirm").mockReturnValue(true); render( { ); await user.click(screen.getByRole("button", { name: "Clear all" })); + await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Clear all memories" })); expect(onClear).toHaveBeenCalledOnce(); expect(screen.getByRole("textbox", { name: "Memory 1" })).toHaveValue("Keep this"); + // The failure is the workspace's to report; the dialog does not linger. + await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument()); }); it("clears the draft after clear succeeds", async () => { const user = userEvent.setup(); - vi.spyOn(window, "confirm").mockReturnValue(true); render( { ); await user.click(screen.getByRole("button", { name: "Clear all" })); + await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Clear all memories" })); expect(screen.queryByRole("textbox", { name: "Memory 1" })).not.toBeInTheDocument(); expect(screen.getByText("No permanent memories stored.")).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument()); + }); + + it("asks in the app's own dialog, never window.confirm, and clears nothing on Keep", async () => { + // window.confirm blocks the JavaScript thread until the native dialog is + // dismissed; ChatPage avoids it for the same reason. + const user = userEvent.setup(); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); + const onClear = vi.fn<() => Promise>().mockResolvedValue(); + render( + Promise>().mockResolvedValue()} + onReplace={vi.fn<(memos: string[]) => Promise>().mockResolvedValue()} + onClear={onClear} + />, + ); + + await user.click(screen.getByRole("button", { name: "Clear all" })); + + const dialog = screen.getByRole("alertdialog"); + expect(dialog).toHaveTextContent("Clear all memories?"); + expect(dialog).toHaveTextContent("It cannot be undone."); + await user.click(within(dialog).getByRole("button", { name: "Keep memories" })); + + expect(confirm).not.toHaveBeenCalled(); + expect(onClear).not.toHaveBeenCalled(); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Memory 1" })).toHaveValue("Remember this"); + confirm.mockRestore(); }); it("keeps edited rows available when saving changes fails", async () => { @@ -269,3 +302,165 @@ describe("MemoryPanel server reconciliation", () => { }); }); }); + +describe("MemoryPanel unsaved changes", () => { + const renderPanel = (memos: string[], onReplace = vi.fn<(memos: string[]) => Promise>().mockResolvedValue()) => { + render( + Promise>().mockResolvedValue()} + onReplace={onReplace} + onClear={vi.fn<() => Promise>().mockResolvedValue()} + />, + ); + return onReplace; + }; + + it("keeps Save disabled and quiet until something changes", async () => { + const user = userEvent.setup(); + renderPanel(["First", "Second"]); + + expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled(); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + + await user.type(screen.getByRole("textbox", { name: "Memory 1" }), "!"); + + expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled(); + expect(screen.getByRole("status")).toHaveTextContent("Unsaved changes"); + }); + + it("does not count whitespace the server would trim as a change", async () => { + // The server stores memories trimmed, so "First " saves as "First" and the + // list would otherwise keep reporting unsaved changes after a save. + const user = userEvent.setup(); + renderPanel(["First"]); + + await user.type(screen.getByRole("textbox", { name: "Memory 1" }), " "); + + expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled(); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); + + it("is clean again when an edit is typed back to what was saved", async () => { + const user = userEvent.setup(); + renderPanel(["First"]); + + const input = screen.getByRole("textbox", { name: "Memory 1" }); + await user.type(input, "x"); + expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled(); + await user.type(input, "{Backspace}"); + + expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled(); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); + + it("strikes a removed row through, offers Undo, and keeps it out of the save", async () => { + const user = userEvent.setup(); + const onReplace = renderPanel(["First", "Second"]); + + await user.click(screen.getByRole("button", { name: "Remove memory 1" })); + + const removed = screen.getByRole("textbox", { name: "Memory 1 (removed)" }); + expect(removed).toHaveValue("First"); + expect(removed).toHaveAttribute("readonly"); + expect(removed.closest("li")).toHaveClass("memory-list-item-removed"); + expect(screen.getByRole("status")).toHaveTextContent("Unsaved changes"); + // Nothing has been sent: removal is a draft until Save. + expect(onReplace).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect(onReplace).toHaveBeenCalledExactlyOnceWith(["Second"]); + }); + + it("puts a removed row back with Undo and is clean again", async () => { + const user = userEvent.setup(); + const onReplace = renderPanel(["First", "Second"]); + + await user.click(screen.getByRole("button", { name: "Remove memory 2" })); + await user.click(screen.getByRole("button", { name: "Undo removing memory 2" })); + + const restored = screen.getByRole("textbox", { name: "Memory 2" }); + expect(restored).toHaveValue("Second"); + expect(restored).not.toHaveAttribute("readonly"); + expect(restored.closest("li")).not.toHaveClass("memory-list-item-removed"); + expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled(); + expect(onReplace).not.toHaveBeenCalled(); + }); + + it("keeps a row's edit when its removal is undone", async () => { + const user = userEvent.setup(); + const onReplace = renderPanel(["First"]); + + await user.type(screen.getByRole("textbox", { name: "Memory 1" }), " edited"); + await user.click(screen.getByRole("button", { name: "Remove memory 1" })); + await user.click(screen.getByRole("button", { name: "Undo removing memory 1" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect(onReplace).toHaveBeenCalledExactlyOnceWith(["First edited"]); + }); + + it("leaves the marked rows in place when the save fails, and settles them once the server answers", async () => { + const user = userEvent.setup(); + const onReplace = vi.fn<(memos: string[]) => Promise>() + .mockRejectedValueOnce(new Error("save failed")) + .mockResolvedValueOnce(); + + function Host() { + const [memos, setMemos] = useState(["First", "Second"]); + return ( + Promise>().mockResolvedValue()} + onReplace={async (next) => { await onReplace(next); setMemos(next); }} + onClear={vi.fn<() => Promise>().mockResolvedValue()} + /> + ); + } + render(); + + await user.click(screen.getByRole("button", { name: "Remove memory 1" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + // The failed save changed nothing: still struck through, still unsaved. + expect(await screen.findByRole("textbox", { name: "Memory 1 (removed)" })).toBeVisible(); + expect(screen.getByRole("status")).toHaveTextContent("Unsaved changes"); + + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(screen.queryByRole("textbox", { name: "Memory 1 (removed)" })).not.toBeInTheDocument()); + expect(screen.getByRole("textbox", { name: "Memory 1" })).toHaveValue("Second"); + expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled(); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + expect(onReplace).toHaveBeenNthCalledWith(2, ["Second"]); + }); + + it("does not lose a pending removal when a memory is added meanwhile", async () => { + const user = userEvent.setup(); + const onReplace = vi.fn<(memos: string[]) => Promise>().mockResolvedValue(); + + function Host() { + const [memos, setMemos] = useState(["First", "Second"]); + return ( + { setMemos((current) => [...current, memo]); }} + onReplace={onReplace} + onClear={vi.fn<() => Promise>().mockResolvedValue()} + /> + ); + } + render(); + + await user.click(screen.getByRole("button", { name: "Remove memory 1" })); + await user.type(screen.getByRole("textbox", { name: "New memory" }), "Third"); + await user.click(screen.getByRole("button", { name: "Add memory" })); + await screen.findByRole("textbox", { name: "Memory 3" }); + + expect(screen.getByRole("textbox", { name: "Memory 1 (removed)" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect(onReplace).toHaveBeenCalledExactlyOnceWith(["Second", "Third"]); + }); +}); diff --git a/frontend/src/features/settings/MemoryPanel.tsx b/frontend/src/features/settings/MemoryPanel.tsx index 4ebad70..2337c19 100644 --- a/frontend/src/features/settings/MemoryPanel.tsx +++ b/frontend/src/features/settings/MemoryPanel.tsx @@ -1,5 +1,6 @@ -import { Eraser, Plus, RefreshCw, Trash2, Save } from "lucide-react"; +import { Eraser, Plus, RefreshCw, Trash2, Save, Undo2 } from "lucide-react"; import { useEffect, useRef, useState, type FormEvent } from "react"; +import { AlertDialog, DialogContent } from "../../shared/ui/Dialog"; /** Where the list came from. The store is read when Settings opens, and that read can fail. */ export type MemoryLoadState = @@ -25,6 +26,9 @@ type DraftRow = { // The server value this row was last seeded from. An edited row no longer // equals it, which is exactly why reconciliation cannot match on `value`. origin: string; + // Marked for removal but still listed (struck through, with an Undo) until + // the change is saved, so a slip of the mouse is not silently final. + removed?: boolean; }; export function MemoryPanel({ memos, load = READY, onRetry, busy, onAdd, onReplace, onClear }: Props) { @@ -82,12 +86,40 @@ export function MemoryPanel({ memos, load = READY, onRetry, busy, onAdd, onRepla }).catch(() => undefined); }; - const handleClear = () => { - if (window.confirm("Clear all permanent memories? This cannot be undone.")) { - void onClear().then(() => setDraft([])).catch(() => undefined); + const [clearDialogOpen, setClearDialogOpen] = useState(false); + const [clearing, setClearing] = useState(false); + + // The dialog stays up while the clear runs and closes either way: a failure + // is reported by the workspace, and the list is left exactly as it was. + const confirmClear = async () => { + if (clearing) return; + setClearing(true); + try { + await onClear(); + setDraft([]); + } catch { + // Reported by the workspace callback; the draft is kept. + } finally { + setClearing(false); + setClearDialogOpen(false); } }; + const setRemoved = (id: number, removed: boolean) => { + setDraft((current) => current.map((row) => row.id === id ? { ...row, removed } : row)); + }; + + // Anything the server has not seen: an edited value or a pending removal. + // Rows added through the form are saved by then and carry `value === origin`. + // The server trims what it stores, so a trailing space alone is no change -- + // and would otherwise stay flagged as unsaved after a save that trimmed it. + const dirty = draft.some((row) => row.removed || row.value.trim() !== row.origin); + + const saveChanges = () => { + // Failures are reported by the workspace callback; the draft is kept. + void onReplace(draft.filter((row) => !row.removed).map((row) => row.value)).catch(() => undefined); + }; + const heading = (
@@ -136,9 +168,19 @@ export function MemoryPanel({ memos, load = READY, onRetry, busy, onAdd, onRepla {draft.length ? (
    {draft.map((item, index) => ( -
  • - setDraft((current) => current.map((row) => row.id === item.id ? { ...row, value: event.target.value } : row))} /> - +
  • + setDraft((current) => current.map((row) => row.id === item.id ? { ...row, value: event.target.value } : row))} + /> + {item.removed ? ( + + ) : ( + + )}
  • ))}
@@ -146,9 +188,36 @@ export function MemoryPanel({ memos, load = READY, onRetry, busy, onAdd, onRepla

No permanent memories stored.

)}
- {memos.length > 0 && } - {draft.length > 0 && } + {memos.length > 0 && } + {draft.length > 0 && } + {/* Always present, so a screen reader hears the text appear. */} + {dirty ? "Unsaved changes" : ""}
+ {clearDialogOpen && setClearDialogOpen(false)} onConfirm={confirmClear} />} ); } + +/** + * The app's own confirmation rather than window.confirm(), which blocks the + * whole JavaScript thread until the native dialog is dismissed (ChatPage's + * MemoryClearConfirmDialog explains the same choice). + */ +function ClearMemoriesDialog({ busy, onClose, onConfirm }: { busy: boolean; onClose: () => void; onConfirm: () => Promise }) { + return ( + { if (!open && !busy) onClose(); }}> + + Clear all memories? + + This removes every remembered fact, including edits you have not saved. It cannot be undone. + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/shell/AppShell.test.tsx b/frontend/src/features/shell/AppShell.test.tsx index 5717a68..53fc1d6 100644 --- a/frontend/src/features/shell/AppShell.test.tsx +++ b/frontend/src/features/shell/AppShell.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ChatGroup, ChatSummary, ModelResponse } from "../../../../contracts/cortex-api"; @@ -87,7 +87,7 @@ describe("AppShell", () => { expect(window.location.pathname).toBe("/settings"); }); - it("requires the exact chat title before permanent deletion", async () => { + it("confirms a chat delete in one step, without typing the title", async () => { const user = userEvent.setup(); const chat: ChatSummary = { id: "chat-1", title: "Quarterly planning", timestamp: "2026-01-01T00:00:00Z" }; const onDeleteChat = vi.fn<(id: string) => Promise>().mockResolvedValue(); @@ -96,18 +96,29 @@ describe("AppShell", () => { await user.click(screen.getByRole("button", { name: "Delete Quarterly planning" })); - expect(screen.getByRole("alertdialog")).toHaveTextContent("Deleted chats cannot be recovered."); - const confirm = screen.getByRole("button", { name: "Delete permanently" }); - const verifier = screen.getByRole("textbox", { name: /Quarterly planning/ }); - expect(confirm).toBeDisabled(); + const dialog = screen.getByRole("alertdialog"); + expect(dialog).toHaveTextContent("You can undo it for a few seconds afterwards."); + expect(dialog).toHaveTextContent("Quarterly planning"); + expect(dialog).not.toHaveTextContent("cannot be recovered"); + expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument(); + expect(onDeleteChat).not.toHaveBeenCalled(); - await user.type(verifier, "Quarterly plan"); - expect(confirm).toBeDisabled(); - await user.type(verifier, "ning"); - expect(confirm).toBeEnabled(); - await user.click(confirm); + await user.click(within(dialog).getByRole("button", { name: "Delete chat" })); - expect(onDeleteChat).toHaveBeenCalledWith(chat.id); + expect(onDeleteChat).toHaveBeenCalledExactlyOnceWith(chat.id); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + }); + + it("leaves the chat alone when the delete is cancelled", async () => { + const user = userEvent.setup(); + const chat: ChatSummary = { id: "chat-1", title: "Quarterly planning", timestamp: "2026-01-01T00:00:00Z" }; + const onDeleteChat = vi.fn<(id: string) => Promise>().mockResolvedValue(); + + renderShell({ chats: [chat], onDeleteChat, onOpenSettings: vi.fn() }); + await user.click(screen.getByRole("button", { name: "Delete Quarterly planning" })); + await user.click(screen.getByRole("button", { name: "Keep chat" })); + + expect(onDeleteChat).not.toHaveBeenCalled(); expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); }); @@ -128,20 +139,31 @@ describe("AppShell", () => { expect(field).toHaveValue("Updated planning"); }); - it("keeps the delete dialog and confirmation when deletion fails", async () => { + it("keeps the delete dialog open when deletion is refused", async () => { const user = userEvent.setup(); const chat: ChatSummary = { id: "chat-1", title: "Quarterly planning", timestamp: "2026-01-01T00:00:00Z" }; const onDeleteChat = vi.fn<(id: string) => Promise>().mockResolvedValue(false); renderShell({ chats: [chat], onDeleteChat, onOpenSettings: vi.fn() }); await user.click(screen.getByRole("button", { name: "Delete Quarterly planning" })); - const verifier = screen.getByRole("textbox", { name: /Quarterly planning/ }); - await user.type(verifier, "Quarterly planning"); - await user.click(screen.getByRole("button", { name: "Delete permanently" })); + await user.click(screen.getByRole("button", { name: "Delete chat" })); expect(onDeleteChat).toHaveBeenCalledWith(chat.id); expect(screen.getByRole("alertdialog")).toBeVisible(); - expect(verifier).toHaveValue("Quarterly planning"); + expect(screen.getByRole("button", { name: "Delete chat" })).toBeEnabled(); + }); + + it("keeps the delete dialog open when the delete callback rejects", async () => { + const user = userEvent.setup(); + const chat: ChatSummary = { id: "chat-1", title: "Quarterly planning", timestamp: "2026-01-01T00:00:00Z" }; + const onDeleteChat = vi.fn<(id: string) => Promise>().mockRejectedValue(new Error("offline")); + + renderShell({ chats: [chat], onDeleteChat, onOpenSettings: vi.fn() }); + await user.click(screen.getByRole("button", { name: "Delete Quarterly planning" })); + await user.click(screen.getByRole("button", { name: "Delete chat" })); + + expect(screen.getByRole("alertdialog")).toBeVisible(); + expect(screen.getByRole("button", { name: "Delete chat" })).toBeEnabled(); }); it("filters the thread list by title as the user types a search query", async () => { diff --git a/frontend/src/features/shell/AppShell.tsx b/frontend/src/features/shell/AppShell.tsx index 9105e9d..2460d19 100644 --- a/frontend/src/features/shell/AppShell.tsx +++ b/frontend/src/features/shell/AppShell.tsx @@ -119,7 +119,7 @@ export function AppShell({ onClick={onOpenSettings} >
@@ -193,6 +193,12 @@ export function AppShell({ ); } +/** No verdict yet (the inventory is still loading) is neither connected nor an error. */ +function connectionIndicatorClass(connection: ModelResponse["connection"]): string { + if (!connection) return "connection-pending"; + return connection.success ? "connection-connected" : "connection-error"; +} + function isCompactWindow(): boolean { return typeof window !== "undefined" && typeof window.matchMedia === "function" @@ -233,18 +239,22 @@ function RenameDialog({ chat, onClose, onSave }: { chat: ChatSummary; onClose: ( ); } +/** + * A plain confirmation, not a type-the-title gauntlet: the delete can be undone + * for a few seconds afterwards (see `deleteChat` in App), so the friction a + * production database deserves would be out of proportion to a chat. + */ function DeleteChatDialog({ chat, onClose, onConfirm }: { chat: ChatSummary; onClose: () => void; onConfirm: () => Promise }) { - const [confirmation, setConfirmation] = useState(""); const [busy, setBusy] = useState(false); const title = displayChatTitle(chat.title); - const confirmed = confirmation.trim() === title; - const submit = async (event: FormEvent) => { - event.preventDefault(); - if (!confirmed || busy) return; + const confirm = async () => { + if (busy) return; setBusy(true); try { await onConfirm(); + } catch { + // Preserve the dialog on failure; the workspace callback reports it. } finally { setBusy(false); } @@ -255,22 +265,14 @@ function DeleteChatDialog({ chat, onClose, onConfirm }: { chat: ChatSummary; onC
-
-

PERMANENT ACTION

- Delete this chat? -
+ Delete this chat?
- This permanently removes the conversation and all of its messages. Deleted chats cannot be recovered. + This removes the conversation and all of its messages. You can undo it for a few seconds afterwards.
Chat to delete{title}
-
- -
- - -
-
+
+ + +
); diff --git a/frontend/src/features/shell/ExecutionTaskTray.test.tsx b/frontend/src/features/shell/ExecutionTaskTray.test.tsx index fc01a8b..b80596e 100644 --- a/frontend/src/features/shell/ExecutionTaskTray.test.tsx +++ b/frontend/src/features/shell/ExecutionTaskTray.test.tsx @@ -1,6 +1,6 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { ExecutionTaskSummary } from "../../../../contracts/cortex-api"; import { ExecutionTaskTray } from "./ExecutionTaskTray"; @@ -313,4 +313,108 @@ describe("ExecutionTaskTray", () => { expect(screen.getByRole("button", { name: "Allow background task code-1 once" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Deny background task code-1" })).toBeEnabled(); }); + + describe("approval window", () => { + const NOW = new Date("2026-07-21T18:00:00Z"); + const pendingCode = (expiresAt: string | null): ExecutionTaskSummary => ({ + ...codeTask, + status: "queued", + approval_state: "pending", + approval_expires_at: expiresAt, + result: null, + }); + const advance = (ms: number) => act(() => { vi.advanceTimersByTime(ms); }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows how long a pending code approval has left and recomputes it every 30 seconds", () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + render(); + + expect(screen.getByText("Approval needed · expires in 30 min")).toBeVisible(); + + // Nothing changes between ticks, and the first tick (29.5 minutes left) still rounds up to 30. + advance(30_000); + expect(screen.getByText("Approval needed · expires in 30 min")).toBeVisible(); + advance(4 * 60_000 + 30_000); + expect(screen.getByText("Approval needed · expires in 25 min")).toBeVisible(); + }); + + it("says under a minute in the last minute and expiring now at the deadline", () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + // 105 s out: the deadline falls between the 90 s and 120 s ticks. + render(); + expect(screen.getByText("Approval needed · expires in 2 min")).toBeVisible(); + + advance(91_000); + expect(screen.getByText("Approval needed · expires in under a minute")).toBeVisible(); + + // The deadline itself wakes the display; it does not wait for the next tick. + advance(14_000 + 60); + expect(screen.getByText("Approval needed · expiring now")).toBeVisible(); + }); + + it("keeps the approval actions while the window is open and leaves the status plain without an expiry", () => { + render(); + + expect(screen.getByText("Approval needed")).toBeVisible(); + expect(screen.queryByText(/expires|expiring/)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Deny background task code-1" })).toBeEnabled(); + }); + + it("ignores an expiry it cannot read", () => { + render(); + + expect(screen.getByText("Approval needed")).toBeVisible(); + expect(screen.queryByText(/expires|expiring/)).not.toBeInTheDocument(); + }); + + it("marks a code approval that expired, explains it, and offers no way to decide it", () => { + render( + , + ); + + expect(screen.getByText("Expired", { selector: ".execution-task-state" })).toBeVisible(); + expect(screen.getByText("Expired", { selector: ".execution-task-code-title span" })).toBeVisible(); + expect(screen.getByRole("note")).toHaveTextContent("The approval window closed before this task was allowed, so it did not run. Ask for it again to run it."); + expect(screen.queryByText("Cancelled")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Allow background task|Deny background task/ })).not.toBeInTheDocument(); + expect(screen.queryByText("Review generated source")).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("The latest task's approval expired before it was decided."); + }); + + it("calls an expired approval expired, not cancelled, for other task kinds too", () => { + render( + , + ); + + expect(screen.getByText("Expired")).toBeVisible(); + expect(screen.queryByText("Cancelled")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Allow background task|Deny background task/ })).not.toBeInTheDocument(); + }); + + it("still calls a task the person denied cancelled", () => { + render( + , + ); + + expect(screen.getAllByText("Cancelled").length).toBeGreaterThan(0); + expect(screen.queryByText("Expired")).not.toBeInTheDocument(); + expect(screen.queryByRole("note")).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/features/shell/ExecutionTaskTray.tsx b/frontend/src/features/shell/ExecutionTaskTray.tsx index 95ac0a7..732b26e 100644 --- a/frontend/src/features/shell/ExecutionTaskTray.tsx +++ b/frontend/src/features/shell/ExecutionTaskTray.tsx @@ -1,5 +1,5 @@ import { AlertTriangle, Check, ChevronDown, Code2, Download, X } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { CodeExecutionSourceResponse, ExecutionApprovalDecisionRequest, ExecutionTaskSummary } from "../../../../contracts/cortex-api"; type ExecutionApprovalDecision = ExecutionApprovalDecisionRequest["decision"]; @@ -57,11 +57,13 @@ export function ExecutionTaskTray({ tasks, onCancel, onDecideApproval, onLoadCod ? `${pendingApprovals.length} task${pendingApprovals.length === 1 ? " requires" : "s require"} your approval.` : activeTasks.length ? `${activeTasks.length} local task${activeTasks.length === 1 ? " is" : "s are"} running.` - : latestTerminalTask?.status === "failed" - ? "The latest local task failed." - : latestTerminalTask?.status === "cancelled" - ? "The latest local task was cancelled." - : "The latest local task is complete."; + : latestTerminalTask?.approval_state === "expired" + ? "The latest task's approval expired before it was decided." + : latestTerminalTask?.status === "failed" + ? "The latest local task failed." + : latestTerminalTask?.status === "cancelled" + ? "The latest local task was cancelled." + : "The latest local task is complete."; const stop = async (jobId: string) => { if (!onCancel || cancelling.has(jobId)) return; @@ -191,7 +193,7 @@ export function ExecutionTaskTray({ tasks, onCancel, onDecideApproval, onLoadCod {showsWorking &&
- {approvalPending ? formatApprovalMeta(task) : formatTaskStatus(task.status)} + {approvalPending ? formatApprovalMeta(task) : formatTaskLabel(task)} {task.result && }
)} @@ -241,8 +243,11 @@ function CodeTaskSummary({ onDecideApproval?: (decision: ExecutionApprovalDecision) => void; }) { const title = task.intent_summary || task.approval_reason || task.message || "Local Python task"; - const status = approvalPending ? "Approval needed" : formatTaskStatus(task.status); + const approvalExpired = task.approval_state === "expired"; const sourceReviewed = Boolean(codeSource && sourceMetadataMatchesTask(task, codeSource)); + const status = approvalPending + ? <>Approval needed + : formatTaskLabel(task); return (
@@ -269,8 +274,11 @@ function CodeTaskSummary({ {approvalPending && !sourceReviewed && (
)} + {approvalExpired && ( +
+ )} {task.result && } - {onLoadSource && ( + {onLoadSource && !approvalExpired && (
{ if (event.currentTarget.open) onLoadSource(); }}> {loadingSource && Loading source…} @@ -359,6 +367,9 @@ function CodeTaskState({ if (approvalPending) { return Review; } + if (task.approval_state === "expired") { + return Expired; + } if (showsWorking) { return Running; } @@ -462,6 +473,41 @@ function formatBytes(size: number): string { return `${(size / (1024 * 1024)).toFixed(1)} MB`; } +/** How often the remaining approval time is recomputed. */ +const APPROVAL_TICK_MS = 30_000; + +/** + * "· expires in N min" for a pending approval, kept current on a 30 second tick + * and once more at the deadline. It owns its clock, so it starts from the real + * time whenever an approval appears rather than from when the tray mounted. + */ +function ApprovalCountdown({ expiresAt }: { expiresAt?: string | null }) { + const deadline = expiresAt ? Date.parse(expiresAt) : Number.NaN; + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (Number.isNaN(deadline)) return undefined; + const tick = window.setInterval(() => setNow(Date.now()), APPROVAL_TICK_MS); + const untilDeadline = deadline - Date.now(); + // A hair past the deadline, so the wake-up finds it closed. Longer waits are + // covered by the tick; setTimeout cannot hold more than about 24 days. + const atDeadline = untilDeadline > 0 && untilDeadline < 2 ** 31 + ? window.setTimeout(() => setNow(Date.now()), untilDeadline + 50) + : undefined; + return () => { + window.clearInterval(tick); + if (atDeadline !== undefined) window.clearTimeout(atDeadline); + }; + }, [deadline]); + if (Number.isNaN(deadline)) return null; + return <> · {formatApprovalRemaining(deadline - now)}; +} + +function formatApprovalRemaining(remainingMs: number): string { + if (remainingMs <= 0) return "expiring now"; + if (remainingMs < 60_000) return "expires in under a minute"; + return `expires in ${Math.ceil(remainingMs / 60_000)} min`; +} + function formatApprovalMeta(task: ExecutionTaskSummary): string { const profile = task.profile.replace(/\.v\d+$/, "").replaceAll(".", " "); if (!task.approval_expires_at) return `Action required · ${profile}`; @@ -491,6 +537,14 @@ function formatTaskStatus(status: ExecutionTaskSummary["status"]): string { return status === "cancelling" ? "Stopping" : `${status[0].toUpperCase()}${status.slice(1)}`; } +/** + * An expired approval ends as a cancelled task, which read as "Cancelled" and + * gave no hint that the person's own window had closed. + */ +function formatTaskLabel(task: ExecutionTaskSummary): string { + return task.approval_state === "expired" ? "Expired" : formatTaskStatus(task.status); +} + function formatCodeFailure(task: ExecutionTaskSummary): string { const messages: Record = { worker_startup_timeout: "The isolated worker did not finish starting in time.", diff --git a/frontend/src/lib/composerDraft.test.ts b/frontend/src/lib/composerDraft.test.ts new file mode 100644 index 0000000..21188c8 --- /dev/null +++ b/frontend/src/lib/composerDraft.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatAttachment } from "../../../contracts/cortex-api"; +import { + composerAttachmentKey, + composerDraftKey, + discardComposerDraft, + MAX_PERSISTED_DRAFT_CHARS, + pruneComposerDrafts, + readComposerAttachments, + readComposerDraft, + writeComposerAttachments, + writeComposerDraft, +} from "./composerDraft"; + +const attachment: ChatAttachment = { + attachment_id: "att-1", + filename: "notes.txt", + mime_type: "text/plain", + size: 12, + sha256: "a".repeat(64), + kind: "document", + expires_at: "2099-01-01T00:00:00Z", +}; + +/** A page restart: session storage is gone, local storage is what is left. */ +const restart = () => window.sessionStorage.clear(); + +/** Make one of the two storages refuse writes (or reads), leaving the other alone. */ +function breakStorage(which: "local" | "session", method: "setItem" | "getItem" | "removeItem" | "key") { + const original = Storage.prototype[method] as (...args: unknown[]) => unknown; + const target = which === "local" ? window.localStorage : window.sessionStorage; + return vi.spyOn(Storage.prototype, method).mockImplementation(function (this: Storage, ...args: unknown[]) { + if (this === target) throw new DOMException("storage denied", "SecurityError"); + return original.apply(this, args); + } as never); +} + +describe("composer drafts", () => { + beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + window.sessionStorage.clear(); + }); + + it("keeps the storage keys the drafts have always used", () => { + expect(composerDraftKey(null)).toBe("cortex.composer.draft.new"); + expect(composerDraftKey("thread-a")).toBe("cortex.composer.draft.thread-a"); + expect(composerAttachmentKey("thread-a")).toBe("cortex.composer.attachments.thread-a"); + }); + + it("survives a restart, scoped to its own conversation", () => { + writeComposerDraft("thread-a", "half a thought"); + writeComposerDraft("thread-b", "another one"); + writeComposerDraft(null, "a brand new chat"); + + restart(); + + expect(readComposerDraft("thread-a")).toBe("half a thought"); + expect(readComposerDraft("thread-b")).toBe("another one"); + expect(readComposerDraft(null)).toBe("a brand new chat"); + expect(readComposerDraft("thread-c")).toBe(""); + }); + + it("reads back what was last written within a session", () => { + writeComposerDraft("thread-a", "first"); + writeComposerDraft("thread-a", "second"); + + expect(readComposerDraft("thread-a")).toBe("second"); + expect(window.localStorage.getItem(composerDraftKey("thread-a"))).toBe("second"); + }); + + it("is gone from both storages once it is cleared, as sending does", () => { + writeComposerDraft("thread-a", "sent it"); + writeComposerDraft("thread-a", ""); + + expect(window.localStorage.getItem(composerDraftKey("thread-a"))).toBeNull(); + expect(window.sessionStorage.getItem(composerDraftKey("thread-a"))).toBeNull(); + restart(); + expect(readComposerDraft("thread-a")).toBe(""); + }); + + describe("size limit", () => { + it("persists a draft at the limit and keeps a longer one for the session only", () => { + const atLimit = "x".repeat(MAX_PERSISTED_DRAFT_CHARS); + const overLimit = "y".repeat(MAX_PERSISTED_DRAFT_CHARS + 1); + + writeComposerDraft("thread-a", atLimit); + writeComposerDraft("thread-b", overLimit); + + expect(window.localStorage.getItem(composerDraftKey("thread-a"))).toBe(atLimit); + expect(window.localStorage.getItem(composerDraftKey("thread-b"))).toBeNull(); + // Still there for a reload of the page... + expect(readComposerDraft("thread-b")).toBe(overLimit); + // ...but not for a restart. + restart(); + expect(readComposerDraft("thread-a")).toBe(atLimit); + expect(readComposerDraft("thread-b")).toBe(""); + }); + + it("does not leave an older short copy behind to be restored over a draft that outgrew the limit", () => { + writeComposerDraft("thread-a", "short"); + writeComposerDraft("thread-a", "z".repeat(MAX_PERSISTED_DRAFT_CHARS + 1)); + + expect(window.localStorage.getItem(composerDraftKey("thread-a"))).toBeNull(); + restart(); + expect(readComposerDraft("thread-a")).toBe(""); + }); + }); + + describe("when storage refuses", () => { + it("still keeps the draft for the session when local storage is denied", () => { + breakStorage("local", "setItem"); + + expect(() => writeComposerDraft("thread-a", "typed anyway")).not.toThrow(); + + expect(readComposerDraft("thread-a")).toBe("typed anyway"); + }); + + it("falls back to the local copy when session storage cannot be read", () => { + writeComposerDraft("thread-a", "kept locally"); + breakStorage("session", "getItem"); + + expect(readComposerDraft("thread-a")).toBe("kept locally"); + }); + + it("returns an empty draft, and does not throw, when nothing can be read or written", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("denied", "SecurityError"); }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("full", "QuotaExceededError"); }); + vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { throw new DOMException("denied", "SecurityError"); }); + + expect(() => writeComposerDraft("thread-a", "text")).not.toThrow(); + expect(() => writeComposerDraft("thread-a", "")).not.toThrow(); + expect(readComposerDraft("thread-a")).toBe(""); + expect(() => discardComposerDraft("thread-a")).not.toThrow(); + }); + }); + + describe("attachments", () => { + it("keeps attachment metadata for the session only, never in local storage", () => { + writeComposerAttachments("thread-a", [attachment]); + writeComposerDraft("thread-a", "see the file"); + + expect(readComposerAttachments("thread-a")).toEqual([attachment]); + expect(Object.keys(window.localStorage).filter((key) => key.includes("attachments"))).toEqual([]); + // The only thing local storage holds is the text. + expect(Object.entries(window.localStorage)).toEqual([[composerDraftKey("thread-a"), "see the file"]]); + + restart(); + expect(readComposerAttachments("thread-a")).toEqual([]); + expect(readComposerDraft("thread-a")).toBe("see the file"); + }); + + it("stores no file content, only the fields that describe the upload", () => { + writeComposerAttachments("thread-a", [attachment]); + + const stored = JSON.parse(window.sessionStorage.getItem(composerAttachmentKey("thread-a")) ?? "[]") as Record[]; + expect(Object.keys(stored[0]).sort()).toEqual(["attachment_id", "expires_at", "filename", "kind", "mime_type", "sha256", "size"]); + }); + + it("ignores malformed stored attachments", () => { + window.sessionStorage.setItem(composerAttachmentKey("thread-a"), JSON.stringify([attachment, { attachment_id: 7 }, null])); + expect(readComposerAttachments("thread-a")).toEqual([attachment]); + + window.sessionStorage.setItem(composerAttachmentKey("thread-a"), "not json"); + expect(readComposerAttachments("thread-a")).toEqual([]); + }); + }); + + describe("pruning drafts of conversations that no longer exist", () => { + it("removes drafts and attachment metadata for missing chats and keeps the rest", () => { + writeComposerDraft("kept", "keep me"); + writeComposerDraft("gone", "orphaned text"); + writeComposerDraft(null, "the new-chat draft"); + writeComposerAttachments("gone", [attachment]); + writeComposerAttachments("kept", [attachment]); + window.localStorage.setItem("cortex.theme", "dark"); + + pruneComposerDrafts(["kept", "another"]); + + expect(window.localStorage.getItem(composerDraftKey("gone"))).toBeNull(); + expect(window.sessionStorage.getItem(composerDraftKey("gone"))).toBeNull(); + expect(window.sessionStorage.getItem(composerAttachmentKey("gone"))).toBeNull(); + expect(readComposerDraft("kept")).toBe("keep me"); + expect(readComposerAttachments("kept")).toEqual([attachment]); + expect(readComposerDraft(null)).toBe("the new-chat draft"); + expect(window.localStorage.getItem("cortex.theme")).toBe("dark"); + }); + + it("prunes drafts left in local storage by an earlier run", () => { + writeComposerDraft("old-chat", "typed last week"); + restart(); + + pruneComposerDrafts(["current-chat"]); + + expect(window.localStorage.getItem(composerDraftKey("old-chat"))).toBeNull(); + expect(readComposerDraft("old-chat")).toBe(""); + }); + + it("removes every conversation's draft when the backend has none, but never the new-chat one", () => { + writeComposerDraft("a", "1"); + writeComposerDraft("b", "2"); + writeComposerDraft(null, "3"); + + pruneComposerDrafts([]); + + expect(Object.keys(window.localStorage)).toEqual([composerDraftKey(null)]); + }); + + it("does not throw when storage cannot be listed or edited", () => { + writeComposerDraft("a", "1"); + breakStorage("local", "key"); + breakStorage("session", "removeItem"); + + expect(() => pruneComposerDrafts([])).not.toThrow(); + }); + }); + + it("forgets everything kept for a deleted conversation", () => { + writeComposerDraft("thread-a", "text"); + writeComposerAttachments("thread-a", [attachment]); + writeComposerDraft("thread-b", "other"); + + discardComposerDraft("thread-a"); + + expect(readComposerDraft("thread-a")).toBe(""); + expect(readComposerAttachments("thread-a")).toEqual([]); + expect(readComposerDraft("thread-b")).toBe("other"); + }); +}); diff --git a/frontend/src/lib/composerDraft.ts b/frontend/src/lib/composerDraft.ts index 8f5e0e3..c4bae4f 100644 --- a/frontend/src/lib/composerDraft.ts +++ b/frontend/src/lib/composerDraft.ts @@ -2,32 +2,76 @@ import type { ChatAttachment } from "../../../contracts/cortex-api"; const DRAFT_PREFIX = "cortex.composer.draft."; const ATTACHMENT_PREFIX = "cortex.composer.attachments."; +const NEW_THREAD_SUFFIX = "new"; /** - * Keeps unfinished messages local to the browser session and scoped to one - * conversation. These drafts are deliberately not sent to the backend. + * Longest draft kept across restarts, in characters. The composer accepts up to + * 100,000, and a browser's local storage is a few megabytes shared by + * everything else the page keeps. A longer draft still survives a reload of the + * page (session storage has no such limit here), just not a restart. + */ +export const MAX_PERSISTED_DRAFT_CHARS = 20_000; + +/** + * Keeps unfinished messages and scopes each to one conversation. These drafts + * are deliberately not sent to the backend. + * + * The text is written to both storages: `localStorage` so it survives the app + * being closed, `sessionStorage` so it survives a reload even when the draft is + * too long for the former. Reading prefers the session copy, which is never + * older than the local one. Attachments are another matter: they are staged + * uploads that expire, so only their metadata is kept, for the session only, + * and never any file bytes. */ export function readComposerDraft(threadId: string | null): string { - try { - return window.sessionStorage.getItem(composerDraftKey(threadId)) ?? ""; - } catch { - return ""; - } + const key = composerDraftKey(threadId); + return readItem("session", key) ?? readItem("local", key) ?? ""; } export function writeComposerDraft(threadId: string | null, value: string): void { - try { - const key = composerDraftKey(threadId); - if (value) window.sessionStorage.setItem(key, value); - else window.sessionStorage.removeItem(key); - } catch { - // Session storage is an optional resilience layer. The controlled input - // remains fully usable if a browser denies storage access. + const key = composerDraftKey(threadId); + if (!value) { + removeItem("session", key); + removeItem("local", key); + return; } + writeItem("session", key, value); + // A draft that no longer fits must not leave an older, shorter copy behind to + // be restored in its place. + if (value.length <= MAX_PERSISTED_DRAFT_CHARS) writeItem("local", key, value); + else removeItem("local", key); } export function composerDraftKey(threadId: string | null): string { - return `${DRAFT_PREFIX}${threadId ?? "new"}`; + return `${DRAFT_PREFIX}${threadId ?? NEW_THREAD_SUFFIX}`; +} + +/** Forget everything kept for one conversation, e.g. because it was deleted. */ +export function discardComposerDraft(threadId: string): void { + removeItem("session", composerDraftKey(threadId)); + removeItem("local", composerDraftKey(threadId)); + removeItem("session", composerAttachmentKey(threadId)); +} + +/** + * Drop drafts of conversations that no longer exist, so text typed into a chat + * that was deleted (in this or an earlier run) does not accumulate in storage. + * Call it only with the complete list the backend just returned. The new-thread + * draft belongs to no conversation and is always kept. + */ +export function pruneComposerDrafts(existingThreadIds: Iterable): void { + const existing = new Set(existingThreadIds); + for (const kind of ["local", "session"] as const) { + for (const key of listKeys(kind)) { + const suffix = key.startsWith(DRAFT_PREFIX) + ? key.slice(DRAFT_PREFIX.length) + : key.startsWith(ATTACHMENT_PREFIX) + ? key.slice(ATTACHMENT_PREFIX.length) + : null; + if (suffix === null || suffix === NEW_THREAD_SUFFIX || existing.has(suffix)) continue; + removeItem(kind, key); + } + } } export function readComposerAttachments(threadId: string | null): ChatAttachment[] { @@ -53,7 +97,55 @@ export function writeComposerAttachments(threadId: string | null, value: readonl } export function composerAttachmentKey(threadId: string | null): string { - return `${ATTACHMENT_PREFIX}${threadId ?? "new"}`; + return `${ATTACHMENT_PREFIX}${threadId ?? NEW_THREAD_SUFFIX}`; +} + +type StorageKind = "local" | "session"; + +// Storage is an optional resilience layer, and a browser may deny it outright +// (blocked site data, a private window), throw when it is full, or hand back +// nothing. The controlled input stays fully usable whatever happens here, so +// every access is guarded and a failure is simply a draft that is not kept. +function storageFor(kind: StorageKind): Storage { + return kind === "local" ? window.localStorage : window.sessionStorage; +} + +function readItem(kind: StorageKind, key: string): string | null { + try { + return storageFor(kind).getItem(key); + } catch { + return null; + } +} + +function writeItem(kind: StorageKind, key: string, value: string): void { + try { + storageFor(kind).setItem(key, value); + } catch { + // Denied or over quota: keep going without persistence. + } +} + +function removeItem(kind: StorageKind, key: string): void { + try { + storageFor(kind).removeItem(key); + } catch { + // Nothing useful to do; the entry is at worst pruned on a later launch. + } +} + +function listKeys(kind: StorageKind): string[] { + try { + const storage = storageFor(kind); + const keys: string[] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key !== null) keys.push(key); + } + return keys; + } catch { + return []; + } } function isChatAttachment(value: unknown): value is ChatAttachment { diff --git a/frontend/src/stores/useUiStore.test.ts b/frontend/src/stores/useUiStore.test.ts index f9212c5..62fe5dd 100644 --- a/frontend/src/stores/useUiStore.test.ts +++ b/frontend/src/stores/useUiStore.test.ts @@ -28,6 +28,105 @@ describe("useUiStore", () => { expect(useUiStore.getState().toasts).toEqual([]); }); + it("keeps an error toast until it is dismissed", () => { + // A failure reason is sometimes the only place it appears (a failed model + // download points the form at the notification), so it must outlive the + // time it takes to read a 240-character validation message. + const id = useUiStore.getState().notify("Checksum did not match the published file.", "error"); + + vi.advanceTimersByTime(10 * 60 * 1000); + expect(useUiStore.getState().toasts).toMatchObject([{ id, kind: "error" }]); + + useUiStore.getState().dismissToast(id); + expect(useUiStore.getState().toasts).toEqual([]); + }); + + it("lets a caller give an error a lifetime, and gives info toasts the default one", () => { + useUiStore.getState().notify("Transient failure.", "error", { durationMs: 1000 }); + useUiStore.getState().notify("Working on it.", "info"); + + vi.advanceTimersByTime(1000); + expect(useUiStore.getState().toasts.map((toast) => toast.message)).toEqual(["Working on it."]); + vi.advanceTimersByTime(3500); + expect(useUiStore.getState().toasts).toEqual([]); + }); + + it("does not stack an identical error while it is still showing", () => { + const first = useUiStore.getState().notify("Could not rename chat.", "error"); + const again = useUiStore.getState().notify("Could not rename chat.", "error"); + const other = useUiStore.getState().notify("Could not delete chat.", "error"); + + expect(again).toBe(first); + expect(other).not.toBe(first); + expect(useUiStore.getState().toasts.map((toast) => toast.message)).toEqual(["Could not rename chat.", "Could not delete chat."]); + + // Once dismissed, the same failure is worth showing again. + useUiStore.getState().dismissToast(first); + useUiStore.getState().notify("Could not rename chat.", "error"); + expect(useUiStore.getState().toasts.map((toast) => toast.message)).toEqual(["Could not delete chat.", "Could not rename chat."]); + }); + + it("holds a toast's countdown while it is paused and finishes the remainder on resume", () => { + const id = useUiStore.getState().notify("Saved.", "success"); + + vi.advanceTimersByTime(3000); + useUiStore.getState().pauseToast(id); + vi.advanceTimersByTime(60_000); + expect(useUiStore.getState().toasts).toHaveLength(1); + + useUiStore.getState().resumeToast(id); + vi.advanceTimersByTime(1499); + expect(useUiStore.getState().toasts).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(useUiStore.getState().toasts).toEqual([]); + }); + + it("runs a toast's action, removes the toast, and does not report it as closed", () => { + const onAction = vi.fn(); + const onClose = vi.fn(); + const id = useUiStore.getState().notify("Chat deleted.", "success", { action: { label: "Undo", onAction }, onClose }); + + useUiStore.getState().runToastAction(id); + expect(onAction).toHaveBeenCalledOnce(); + expect(useUiStore.getState().toasts).toEqual([]); + + // Its countdown was cleared with it, so nothing fires later. + vi.advanceTimersByTime(60_000); + expect(onClose).not.toHaveBeenCalled(); + useUiStore.getState().runToastAction(id); + expect(onAction).toHaveBeenCalledOnce(); + }); + + it("reports a toast as closed exactly once when it times out or is dismissed", () => { + const onExpire = vi.fn(); + useUiStore.getState().notify("Chat deleted.", "success", { durationMs: 6000, onClose: onExpire }); + vi.advanceTimersByTime(5999); + expect(onExpire).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(onExpire).toHaveBeenCalledOnce(); + vi.advanceTimersByTime(60_000); + expect(onExpire).toHaveBeenCalledOnce(); + + const onDismiss = vi.fn(); + const id = useUiStore.getState().notify("Chat deleted.", "success", { durationMs: 6000, onClose: onDismiss }); + useUiStore.getState().dismissToast(id); + useUiStore.getState().dismissToast(id); + vi.advanceTimersByTime(60_000); + expect(onDismiss).toHaveBeenCalledOnce(); + }); + + it("caps the stack, closing the oldest toast to make room", () => { + const onClose = vi.fn(); + useUiStore.getState().notify("First", "error", { onClose }); + for (const message of ["Second", "Third", "Fourth", "Fifth"]) useUiStore.getState().notify(message, "error"); + expect(useUiStore.getState().toasts).toHaveLength(5); + expect(onClose).not.toHaveBeenCalled(); + + useUiStore.getState().notify("Sixth", "error"); + expect(useUiStore.getState().toasts.map((toast) => toast.message)).toEqual(["Second", "Third", "Fourth", "Fifth", "Sixth"]); + expect(onClose).toHaveBeenCalledOnce(); + }); + it("command palette and shortcuts dialog visibility toggle independently", () => { useUiStore.getState().setCommandPaletteOpen(true); expect(useUiStore.getState().commandPaletteOpen).toBe(true); diff --git a/frontend/src/stores/useUiStore.ts b/frontend/src/stores/useUiStore.ts index 3cd6e64..e074f6d 100644 --- a/frontend/src/stores/useUiStore.ts +++ b/frontend/src/stores/useUiStore.ts @@ -1,38 +1,134 @@ import { create } from "zustand"; export type ToastKind = "success" | "error" | "info"; -export type Toast = { id: number; kind: ToastKind; message: string }; +export type ToastAction = { label: string; onAction: () => void }; +export type Toast = { id: number; kind: ToastKind; message: string; action?: ToastAction }; +/** How long a success or info toast stays up. Errors stay until dismissed. */ const TOAST_LIFETIME_MS = 4500; +/** Errors accumulate now that they persist, so the stack is capped. */ +const MAX_TOASTS = 5; + +export type NotifyOptions = { + /** A button on the toast, for an outcome the person can still reverse. */ + action?: ToastAction; + /** + * How long the toast stays up. Defaults to a few seconds for success and + * info toasts, and to "until dismissed" for errors: a failure reason is + * sometimes the only place the reason appears, so it must not vanish while + * it is still being read. + */ + durationMs?: number; + /** + * Called once when the toast goes away without its action having been used + * (it timed out, was dismissed, or was pushed off the stack). This is what + * lets a deferred change, such as a delete that can be undone, commit at + * exactly the moment the person can no longer take it back. + */ + onClose?: () => void; +}; interface UiStoreState { toasts: Toast[]; commandPaletteOpen: boolean; shortcutsDialogOpen: boolean; - notify: (message: string, kind?: ToastKind) => void; + /** Returns the toast's id. An identical error that is already showing is not stacked again. */ + notify: (message: string, kind?: ToastKind, options?: NotifyOptions) => number; dismissToast: (id: number) => void; + /** Stop a toast's countdown while it is being read or used. No effect on toasts that do not expire. */ + pauseToast: (id: number) => void; + resumeToast: (id: number) => void; + /** Run a toast's action and remove it. The action replaces `onClose`, which is not called. */ + runToastAction: (id: number) => void; setCommandPaletteOpen: (open: boolean) => void; setShortcutsDialogOpen: (open: boolean) => void; } -export const useUiStore = create((set) => ({ - toasts: [], - commandPaletteOpen: false, - shortcutsDialogOpen: false, - notify: (message, kind = "info") => { - const id = Date.now() + Math.random(); - set((state) => ({ toasts: [...state.toasts, { id, kind, message }] })); - window.setTimeout(() => { - set((state) => ({ toasts: state.toasts.filter((toast) => toast.id !== id) })); - }, TOAST_LIFETIME_MS); - }, - dismissToast: (id) => set((state) => ({ toasts: state.toasts.filter((toast) => toast.id !== id) })), - setCommandPaletteOpen: (commandPaletteOpen) => set({ commandPaletteOpen }), - setShortcutsDialogOpen: (shortcutsDialogOpen) => set({ shortcutsDialogOpen }), -})); +type ToastTimer = { + handle: number | null; + remainingMs: number; + startedAt: number; +}; + +// Timers live beside the store rather than in it: they are not state anything +// renders, and each one must be cleared when its toast leaves. +const toastTimers = new Map(); +const toastCloseHandlers = new Map void>(); +let nextToastId = 1; + +function clearToastTimer(id: number): void { + const timer = toastTimers.get(id); + if (timer?.handle != null) window.clearTimeout(timer.handle); + toastTimers.delete(id); +} + +function takeCloseHandler(id: number): (() => void) | undefined { + const handler = toastCloseHandlers.get(id); + toastCloseHandlers.delete(id); + return handler; +} + +export const useUiStore = create((set, get) => { + /** Remove a toast and, unless its action ran, tell its owner it is gone. */ + const closeToast = (id: number, runCloseHandler: boolean): void => { + clearToastTimer(id); + const onClose = takeCloseHandler(id); + if (!get().toasts.some((toast) => toast.id === id)) return; + set((state) => ({ toasts: state.toasts.filter((toast) => toast.id !== id) })); + if (runCloseHandler) onClose?.(); + }; + + const armToastTimer = (id: number, remainingMs: number): void => { + const handle = window.setTimeout(() => closeToast(id, true), remainingMs); + toastTimers.set(id, { handle, remainingMs, startedAt: Date.now() }); + }; + + return { + toasts: [], + commandPaletteOpen: false, + shortcutsDialogOpen: false, + notify: (message, kind = "info", options = {}) => { + const { action, onClose } = options; + if (kind === "error" && !action) { + const duplicate = get().toasts.find((toast) => toast.kind === "error" && !toast.action && toast.message === message); + if (duplicate) return duplicate.id; + } + const id = nextToastId++; + // Past the cap the oldest toast makes room. It counts as closed, so a + // pending undo commits rather than being left with no way to be used. + const overflow = get().toasts.length + 1 - MAX_TOASTS; + for (const evicted of get().toasts.slice(0, Math.max(0, overflow))) closeToast(evicted.id, true); + if (onClose) toastCloseHandlers.set(id, onClose); + set((state) => ({ toasts: [...state.toasts, action ? { id, kind, message, action } : { id, kind, message }] })); + const lifetime = options.durationMs ?? (kind === "error" ? null : TOAST_LIFETIME_MS); + if (lifetime !== null) armToastTimer(id, lifetime); + return id; + }, + dismissToast: (id) => closeToast(id, true), + pauseToast: (id) => { + const timer = toastTimers.get(id); + if (!timer || timer.handle === null) return; + window.clearTimeout(timer.handle); + timer.remainingMs = Math.max(0, timer.remainingMs - (Date.now() - timer.startedAt)); + timer.handle = null; + }, + resumeToast: (id) => { + const timer = toastTimers.get(id); + if (!timer || timer.handle !== null) return; + armToastTimer(id, timer.remainingMs); + }, + runToastAction: (id) => { + const action = get().toasts.find((toast) => toast.id === id)?.action; + closeToast(id, false); + action?.onAction(); + }, + setCommandPaletteOpen: (commandPaletteOpen) => set({ commandPaletteOpen }), + setShortcutsDialogOpen: (shortcutsDialogOpen) => set({ shortcutsDialogOpen }), + }; +}); /** Thin wrapper preserving the pre-Zustand useToast() API so call sites don't change. */ -export function useToast(): { notify: (message: string, kind?: ToastKind) => void } { +export function useToast(): { notify: (message: string, kind?: ToastKind, options?: NotifyOptions) => number } { const notify = useUiStore((state) => state.notify); return { notify }; } diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 7170882..5c3791f 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -330,6 +330,7 @@ a { color: inherit; text-decoration: none; } .connection-indicator { position: absolute; right: 7px; bottom: 7px; width: 7px; height: 7px; border: 2px solid var(--bg); border-radius: 50%; } .connection-connected { background: var(--success); } .connection-error { background: var(--danger); } +.connection-pending { background: var(--text-faint); } .workspace-body { position: relative; display: flex; min-width: 0; min-height: 0; overflow: hidden; } /* Chat library @@ -532,6 +533,12 @@ a { color: inherit; text-decoration: none; } .chat-empty-state { display: grid; justify-items: center; align-content: center; min-height: 100%; padding: 30px; color: var(--text-muted); text-align: center; } .chat-empty-state h2, .chat-empty-state h3 { margin: 0; color: var(--text); font-size: var(--text-xl); font-weight: var(--weight-semi); letter-spacing: -0.022em; } .chat-empty-state p { max-width: 420px; margin: 8px 0 0; font-size: var(--text-md); line-height: var(--leading-ui); } +.new-chat-guide { display: grid; justify-items: center; gap: 14px; max-width: 440px; margin: auto; padding: 24px 0; color: var(--text-muted); font-size: var(--text-sm); text-align: center; } +.new-chat-guide p { margin: 0; line-height: 1.5; } +.new-chat-guide strong { color: var(--text); font-weight: var(--weight-semi); } +.new-chat-guide-hints { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; } +.new-chat-guide-hints li { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 5px; } +.new-chat-guide-note { color: var(--text-faint); font-size: var(--text-xs); } /* Focused composer */ /* Same --gutter as .transcript, so the composer shares the message column's @@ -640,6 +647,7 @@ a { color: inherit; text-decoration: none; } .execution-task-state-complete { background: var(--success-soft); color: var(--success); } .execution-task-state-failed { background: var(--danger-soft); color: var(--danger); } .execution-task-state-cancelled { background: var(--surface-soft); color: var(--text-muted); } +.execution-task-state-expired { background: var(--warning-soft); color: var(--warning); } .execution-task-capability-row { display: flex; flex-wrap: wrap; gap: 5px; } .execution-task-capability { padding: 4px 6px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface-soft); color: var(--text-muted); font-size: var(--text-2xs); font-weight: 680; } .execution-task-result { display: grid; gap: 7px; padding: 9px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-soft); } @@ -922,7 +930,10 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .memory-list li { padding: 7px 8px; border: 1px solid var(--line); border-radius: 5px; color: var(--text-muted); font-size: 0.82rem; line-height: 1.45; } .memory-list-item { display: flex; align-items: center; gap: 7px; } .memory-list-item input { min-width: 0; flex: 1; border: 0; outline: 0; padding: 4px; background: transparent; color: var(--text); } -.memory-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.memory-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.memory-list-item-removed input { color: var(--text-faint); text-decoration: line-through; } +.memory-undo { min-height: 30px; padding: 0 9px; font-size: var(--text-xs); } +.memory-unsaved { color: var(--warning); font-size: var(--text-xs); font-weight: 650; } .models-panel { grid-column: 1 / -1; } .model-connection-row { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; margin-bottom: 10px; } .connection-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } @@ -1018,10 +1029,16 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } @keyframes caret-pulse { 0%, 100% { opacity: 0.62; } 50% { opacity: 0.12; } } @keyframes thinking-dot { 0%, 80%, 100% { opacity: 0.35; transform: scale(0.75); } 40% { opacity: 1; transform: scale(1); } } .toast-region { position: fixed; z-index: 100; right: 20px; bottom: 20px; display: grid; gap: 9px; width: min(360px, calc(100vw - 40px)); } -.toast { display: flex; align-items: center; gap: 9px; padding: 13px 14px; border: 1px solid var(--line-strong); border-left-width: 3px; border-radius: 6px; background: var(--surface-elevated); box-shadow: var(--shadow-md); color: var(--text); font-size: 0.82rem; } +.toast { display: flex; align-items: flex-start; gap: 9px; padding: 13px 14px; border: 1px solid var(--line-strong); border-left-width: 3px; border-radius: 6px; background: var(--surface-elevated); box-shadow: var(--shadow-md); color: var(--text); font-size: 0.82rem; } .toast-success { color: var(--success); } .toast-error { color: var(--danger); } .toast-info { color: var(--accent); } +.toast > svg { flex: 0 0 auto; margin-top: 2px; } +.toast-message { flex: 1 1 auto; min-width: 0; line-height: 1.4; overflow-wrap: anywhere; } +.toast-action, .toast-dismiss { flex: 0 0 auto; border: 0; border-radius: 5px; background: transparent; color: var(--text); font: inherit; cursor: pointer; } +.toast-action { padding: 2px 8px; font-weight: 700; text-decoration: underline; text-underline-offset: 3px; } +.toast-dismiss { display: grid; place-items: center; width: 22px; height: 22px; padding: 0; color: var(--text-muted); } +.toast-action:hover, .toast-dismiss:hover { background: var(--surface-hover); } /* * .dialog self-centers via fixed positioning rather than relying on * .dialog-backdrop as a centering flex/grid parent, because Base UI's @@ -1051,15 +1068,13 @@ kbd { display: inline-block; border: 1px solid var(--line-strong); border-radius .command-palette-item svg { flex: 0 0 auto; color: var(--text-faint); } .command-palette-current { margin-left: auto; color: var(--text-faint); font-size: var(--text-2xs); } .delete-dialog { width: min(100%, 480px); } -.delete-dialog-heading { display: flex; align-items: flex-start; gap: 12px; } -.delete-dialog-heading .eyebrow { margin-bottom: 4px; color: var(--danger); } +.delete-dialog-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; } .delete-dialog-heading h2 { margin-bottom: 0; } .delete-dialog-icon { display: grid; flex: 0 0 36px; place-items: center; width: 36px; height: 36px; border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent); border-radius: 5px; background: var(--danger-soft); color: var(--danger); } .delete-dialog-description { margin: 0 0 17px; color: var(--text-muted); font-size: 0.86rem; line-height: 1.55; } .delete-dialog-target { display: grid; gap: 4px; margin-bottom: 18px; padding: 12px; border: 1px solid color-mix(in srgb, var(--danger) 25%, var(--line)); border-radius: 5px; background: var(--danger-soft); } .delete-dialog-target span { color: var(--text-faint); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } .delete-dialog-target strong { overflow: hidden; color: var(--text); font-size: 0.86rem; text-overflow: ellipsis; white-space: nowrap; } -.delete-confirm-title { color: var(--text); font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace; font-size: 0.78rem; } @media (max-width: 920px) { .settings-dialog { width: min(100%, 860px); } diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 19909df..6fdc10a 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -46,6 +46,17 @@ const initialUiState = useUiStore.getState(); afterEach(() => { cleanup?.(); + // Composer drafts outlive the page (see lib/composerDraft.ts), so one test's + // unsent text would otherwise be restored into the next test's composer. + if (hasDom) { + try { + for (const key of Object.keys(window.localStorage)) { + if (key.startsWith("cortex.composer.")) window.localStorage.removeItem(key); + } + } catch { + // A test that replaced or broke storage has nothing to clean up here. + } + } useChatStore.setState(initialChatState, true); useModelStore.setState(initialModelState, true); useSettingsStore.setState(initialSettingsState, true);