Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions frontend/e2e/execution.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
41 changes: 41 additions & 0 deletions frontend/src/app/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fetch>(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(<ToastProvider><App api={new CortexApi("/api/v1", fetcher)} /></ToastProvider>);

expect(await screen.findByText("Current attachment staged.")).toBeVisible();
expect(screen.queryByText("Old attachment staged.")).not.toBeInTheDocument();
});
});
22 changes: 21 additions & 1 deletion frontend/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -341,6 +343,24 @@ function updateChatSummary(setChats: Dispatch<SetStateAction<ChatSummary[]>>, ch
});
}

const ACTIVE_EXECUTION_STATUSES = new Set<ExecutionTaskSummary["status"]>([
"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;
}
14 changes: 14 additions & 0 deletions frontend/src/components/ExecutionTaskTray.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ExecutionTaskTray
tasks={[
{ ...task, status: "succeeded", can_cancel: false, message: "Chat attachment staged." },
{ ...task, job_id: "job-2", status: "succeeded", can_cancel: false, message: "Chat attachment staged." },
]}
/>,
);

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;
Expand Down
34 changes: 31 additions & 3 deletions frontend/src/components/ExecutionTaskTray.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@ type Props = {
onDecideApproval?: (jobId: string, decision: ExecutionApprovalDecision) => Promise<void>;
};

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<Set<string>>(() => new Set());
const [deciding, setDeciding] = useState<Map<string, ExecutionApprovalDecision>>(() => new Map());
const [dismissedCompletionKey, setDismissedCompletionKey] = useState<string | null>(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");
Expand Down Expand Up @@ -88,23 +95,26 @@ export function ExecutionTaskTray({ tasks, onCancel, onDecideApproval }: Props)
</div>
<div className="execution-task-tray-live" aria-live="polite" role="status">{announce}</div>
<ul className="execution-task-list">
{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 (
<li
className={`execution-task ${approvalPending ? "execution-task-approval" : ""}`}
key={task.job_id}
key={group.key}
aria-busy={approvalDecision ? "true" : undefined}
>
<div className="execution-task-copy">
<div className="execution-task-label">
{showsWorking && <span className="loading-spinner execution-task-spinner" aria-hidden="true" />}
<strong>{approvalPending ? task.approval_reason || "Approval required" : task.message || task.phase || "Working"}</strong>
<strong>{approvalPending ? task.approval_reason || "Approval required" : displayMessage}</strong>
</div>
<span>{approvalPending ? formatApprovalMeta(task) : formatTaskStatus(task.status)}</span>
</div>
Expand Down Expand Up @@ -159,6 +169,24 @@ function formatApprovalMeta(task: ExecutionTaskSummary): string {
return `Action required · ${profile} · ${expiry}`;
}

function groupTasks(tasks: ExecutionTaskSummary[]): TaskGroup[] {
const groups = new Map<string, TaskGroup>();
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)}`;
}
Loading