From 9b4b642413922a57e05953f5cca77fabf5f5f0b0 Mon Sep 17 00:00:00 2001 From: Matthew Wesney Date: Thu, 30 Jul 2026 18:03:41 -0400 Subject: [PATCH] fix(ui): prevent replayed background task notifications --- frontend/e2e/execution.spec.ts | 90 +++++++++++++++++++ frontend/src/app/App.test.tsx | 41 +++++++++ frontend/src/app/App.tsx | 22 ++++- .../src/components/ExecutionTaskTray.test.tsx | 14 +++ frontend/src/components/ExecutionTaskTray.tsx | 34 ++++++- 5 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 frontend/e2e/execution.spec.ts diff --git a/frontend/e2e/execution.spec.ts b/frontend/e2e/execution.spec.ts new file mode 100644 index 0000000..8c7ea83 --- /dev/null +++ b/frontend/e2e/execution.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from "@playwright/test"; + +test("does not replay old completions and groups repeated task notifications", async ({ page }) => { + const task = { + profile: "chat.attachment.v1", + status: "succeeded", + sequence: 2, + phase: "completed", + message: "Chat attachment staged.", + can_cancel: false, + }; + + await page.route("**/api/v1/session/exchange", async (route) => { + await route.fulfill({ json: { session_token: "session-execution", expires_at: "2099-01-01T00:00:00Z" } }); + }); + await page.route("**/api/v1/system", async (route) => { + await route.fulfill({ json: { + api_version: "v1", + status: "ok", + preview: true, + session_required: true, + execution_preview_available: true, + started_at: "2026-07-21T18:00:00Z", + } }); + }); + await page.route("**/api/v1/chats", async (route) => { + await route.fulfill({ json: [] }); + }); + await page.route("**/api/v1/settings", async (route) => { + await route.fulfill({ json: { + source: "defaults", + settings: { + appearance: { theme: "dark" }, + models: { chat: "local-chat:7b", title: null, translation: "translategemma:4b" }, + generation: { temperature: 0.7, num_ctx: 4096, seed: -1 }, + memory: { enabled: true }, + translation: { enabled: false }, + suggestions: { enabled: true, model: null }, + }, + } }); + }); + await page.route("**/api/v1/memories", async (route) => { + await route.fulfill({ json: { memos: [] } }); + }); + await page.route("**/api/v1/models", async (route) => { + await route.fulfill({ json: { + required_models: [], + optional_models: [], + installed_models: ["local-chat:7b"], + missing_models: [], + optional_missing_models: [], + models: [{ name: "local-chat:7b" }], + connection: { success: true, status: "connected", message: "Connected to local runtime." }, + } }); + }); + await page.route("**/api/v1/execution/tasks**", async (route) => { + await route.fulfill({ json: { + tasks: [ + { + ...task, + job_id: "current-attachment-1", + created_at: "2026-07-21T18:00:01Z", + updated_at: "2026-07-21T18:00:02Z", + }, + { + ...task, + job_id: "current-attachment-2", + created_at: "2026-07-21T18:00:03Z", + updated_at: "2026-07-21T18:00:04Z", + }, + { + ...task, + job_id: "old-attachment", + message: "Old attachment staged.", + created_at: "2026-07-20T18:00:00Z", + updated_at: "2026-07-20T18:00:01Z", + }, + ], + } }); + }); + + await page.goto("/?bootstrap=launcher-token"); + await expect(page.getByLabel("Message Cortex")).toBeVisible(); + + const tray = page.getByRole("complementary", { name: "Background tasks" }); + await expect(tray).toBeVisible(); + await expect(tray.getByText("2 × Chat attachment staged.")).toBeVisible(); + await expect(tray.getByText("Old attachment staged.")).toHaveCount(0); + await expect(tray.getByRole("listitem")).toHaveCount(1); +}); diff --git a/frontend/src/app/App.test.tsx b/frontend/src/app/App.test.tsx index 570bf69..ebce376 100644 --- a/frontend/src/app/App.test.tsx +++ b/frontend/src/app/App.test.tsx @@ -133,4 +133,45 @@ describe("App", () => { expect(screen.getByRole("button", { name: "Allow background task approval-job once" })).toBeEnabled(); expect(screen.getByText("Create a larger staged image preview.")).toBeVisible(); }); + + it("does not replay terminal tasks from before the current backend session", async () => { + window.sessionStorage.setItem("cortex.session.token", "local-session"); + const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + const oldTask = { + job_id: "old-attachment", + profile: "chat.attachment.v1", + status: "succeeded", + sequence: 2, + phase: "completed", + message: "Old attachment staged.", + can_cancel: false, + created_at: "2026-07-20T18:00:00Z", + updated_at: "2026-07-20T18:00:01Z", + }; + const currentTask = { + ...oldTask, + job_id: "current-attachment", + message: "Current attachment staged.", + created_at: "2026-07-21T18:00:01Z", + updated_at: "2026-07-21T18:00:02Z", + }; + const fetcher = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith("/system")) return json({ status: "ok", preview: true, session_required: true, execution_preview_available: true, started_at: "2026-07-21T18:00:00Z" }); + if (url.endsWith("/chats")) return json([]); + if (url.endsWith("/settings")) return json({ settings: { models: { chat: "model-a", title: null }, appearance: { theme: "dark" } } }); + if (url.endsWith("/memories")) return json({ memos: [] }); + if (url.endsWith("/models")) return json({ required_models: [], optional_models: [], installed_models: ["model-a"], connection: { success: true, status: "connected", message: "Ready" } }); + if (url.includes("/execution/tasks")) return json({ tasks: [currentTask, oldTask] }); + return json({ detail: "Unexpected test route." }, 404); + }); + + render(); + + expect(await screen.findByText("Current attachment staged.")).toBeVisible(); + expect(screen.queryByText("Old attachment staged.")).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 69f245b..fc40afe 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -142,7 +142,9 @@ function AuthenticatedWorkspace({ api, onSessionExpired }: { api: CortexApi; onS }; }, [api, onSessionExpired, system?.execution_preview_available]); - const visibleExecutionTasks = system?.execution_preview_available ? executionTasks : []; + const visibleExecutionTasks = system?.execution_preview_available + ? executionTasks.filter((task) => shouldShowExecutionTask(task, system.started_at)) + : []; const cancelExecution = async (jobId: string) => { try { @@ -341,6 +343,24 @@ function updateChatSummary(setChats: Dispatch>, ch }); } +const ACTIVE_EXECUTION_STATUSES = new Set([ + "queued", + "running", + "cancelling", +]); + +function shouldShowExecutionTask(task: ExecutionTaskSummary, runtimeStartedAt: string): boolean { + if (ACTIVE_EXECUTION_STATUSES.has(task.status) || task.approval_state === "pending") { + return true; + } + const taskUpdatedAt = Date.parse(task.updated_at); + const runtimeStart = Date.parse(runtimeStartedAt); + // Keep malformed records visible rather than silently dropping a task the + // user may need to understand or dismiss. Valid terminal records are scoped + // to this backend lifetime so old completions are not replayed on startup. + return Number.isNaN(taskUpdatedAt) || Number.isNaN(runtimeStart) || taskUpdatedAt >= runtimeStart; +} + function apiMessage(error: unknown, fallback: string): string { return error instanceof ApiError ? error.detail : fallback; } diff --git a/frontend/src/components/ExecutionTaskTray.test.tsx b/frontend/src/components/ExecutionTaskTray.test.tsx index dfbbb28..a640edb 100644 --- a/frontend/src/components/ExecutionTaskTray.test.tsx +++ b/frontend/src/components/ExecutionTaskTray.test.tsx @@ -45,6 +45,20 @@ describe("ExecutionTaskTray", () => { expect(screen.getByRole("button", { name: "Dismiss completed background task notification" })).toBeVisible(); }); + it("groups repeated completed task notifications into one row", () => { + render( + , + ); + + expect(screen.getAllByRole("listitem")).toHaveLength(1); + expect(screen.getByText("2 × Chat attachment staged.")).toBeVisible(); + }); + it("renders pending approval as a non-modal action card without a spinner", async () => { const user = userEvent.setup(); let finishDecision: (() => void) | undefined; diff --git a/frontend/src/components/ExecutionTaskTray.tsx b/frontend/src/components/ExecutionTaskTray.tsx index b21f2bb..c77ef85 100644 --- a/frontend/src/components/ExecutionTaskTray.tsx +++ b/frontend/src/components/ExecutionTaskTray.tsx @@ -10,12 +10,19 @@ type Props = { onDecideApproval?: (jobId: string, decision: ExecutionApprovalDecision) => Promise; }; +type TaskGroup = { + key: string; + task: ExecutionTaskSummary; + count: number; +}; + const ACTIVE_STATUSES = new Set(["queued", "running", "cancelling"]); export function ExecutionTaskTray({ tasks, onCancel, onDecideApproval }: Props) { const [cancelling, setCancelling] = useState>(() => new Set()); const [deciding, setDeciding] = useState>(() => new Map()); const [dismissedCompletionKey, setDismissedCompletionKey] = useState(null); + const taskGroups = groupTasks(tasks); const activeTasks = tasks.filter((task) => ACTIVE_STATUSES.has(task.status) && !["pending", "denied", "expired"].includes(task.approval_state ?? "not_required")); const pendingApprovals = tasks.filter((task) => task.approval_state === "pending"); @@ -88,23 +95,26 @@ export function ExecutionTaskTray({ tasks, onCancel, onDecideApproval }: Props)
{announce}
    - {tasks.map((task) => { + {taskGroups.map((group) => { + const task = group.task; const approvalPending = task.approval_state === "pending"; const approvalDecision = deciding.get(task.job_id); const showsWorking = ACTIVE_STATUSES.has(task.status) && !["pending", "denied", "expired"].includes(task.approval_state ?? "not_required"); const canStop = !approvalPending && Boolean(task.can_cancel) && ACTIVE_STATUSES.has(task.status) && Boolean(onCancel); const isCancelling = cancelling.has(task.job_id) || task.status === "cancelling"; + const taskMessage = task.message || task.phase || "Working"; + const displayMessage = group.count > 1 ? `${group.count} × ${taskMessage}` : taskMessage; return (
  • {showsWorking &&
    {approvalPending ? formatApprovalMeta(task) : formatTaskStatus(task.status)}
    @@ -159,6 +169,24 @@ function formatApprovalMeta(task: ExecutionTaskSummary): string { return `Action required · ${profile} · ${expiry}`; } +function groupTasks(tasks: ExecutionTaskSummary[]): TaskGroup[] { + const groups = new Map(); + for (const task of tasks) { + const approvalPending = task.approval_state === "pending"; + const terminal = !ACTIVE_STATUSES.has(task.status) && !approvalPending; + const key = terminal + ? JSON.stringify(["terminal", task.profile, task.status, task.phase ?? "", task.message ?? ""]) + : `job:${task.job_id}`; + const existing = groups.get(key); + if (existing) { + existing.count += 1; + } else { + groups.set(key, { key, task, count: 1 }); + } + } + return [...groups.values()]; +} + function formatTaskStatus(status: ExecutionTaskSummary["status"]): string { return status === "cancelling" ? "Stopping" : `${status[0].toUpperCase()}${status.slice(1)}`; }