From 53eb2b1ccaeb67e546c75907d0434df3293adb69 Mon Sep 17 00:00:00 2001 From: busya Date: Fri, 3 Jul 2026 14:50:05 +0300 Subject: [PATCH] Improve reports UI and task drawer UX --- .../lib/components/layout/TaskDrawer.svelte | 268 ++++++++++++++---- .../lib/components/layout/TopNavbar.svelte | 10 +- .../lib/components/reports/ReportCard.svelte | 42 ++- .../lib/components/reports/ReportsList.svelte | 4 +- .../components/reports/SummaryPanel.svelte | 19 +- .../reports/__tests__/report_card.ux.test.ts | 4 +- .../reports/__tests__/reports_list.ux.test.ts | 1 + .../lib/components/tasks/LogEntryRow.svelte | 11 +- .../lib/components/tasks/LogFilterBar.svelte | 48 ++-- .../lib/components/tasks/TaskLogPanel.svelte | 53 +++- .../lib/components/tasks/TaskLogViewer.svelte | 93 +++++- .../tasks/__tests__/LogEntryRow.test.ts | 29 ++ .../tasks/__tests__/TaskLogViewer.test.ts | 87 +++++- frontend/src/lib/i18n/locales/en/tasks.json | 23 ++ frontend/src/lib/i18n/locales/ru/tasks.json | 23 ++ .../src/lib/models/TaskCenterModel.svelte.ts | 5 +- frontend/src/lib/ui/Input.svelte | 3 + frontend/src/lib/ui/Pagination.svelte | 8 +- frontend/src/lib/ui/Select.svelte | 3 + frontend/src/lib/ui/__tests__/Input.test.ts | 9 + .../src/lib/ui/__tests__/Pagination.test.ts | 45 +++ frontend/src/lib/ui/__tests__/Select.test.ts | 9 + frontend/src/routes/reports/+page.svelte | 146 ++++++---- .../__tests__/reports_layout_contract.test.ts | 30 ++ 24 files changed, 785 insertions(+), 188 deletions(-) create mode 100644 frontend/src/lib/components/tasks/__tests__/LogEntryRow.test.ts create mode 100644 frontend/src/lib/ui/__tests__/Pagination.test.ts create mode 100644 frontend/src/routes/reports/__tests__/reports_layout_contract.test.ts diff --git a/frontend/src/lib/components/layout/TaskDrawer.svelte b/frontend/src/lib/components/layout/TaskDrawer.svelte index 255d5cae..d577c18f 100644 --- a/frontend/src/lib/components/layout/TaskDrawer.svelte +++ b/frontend/src/lib/components/layout/TaskDrawer.svelte @@ -53,7 +53,7 @@ * @TEST_INVARIANT default_rendering -> verifies: [init_state] */ - import { onDestroy } from "svelte"; + import { onDestroy, onMount } from "svelte"; import { log } from "$lib/cot-logger"; import { taskDrawerStore, closeDrawer } from "$lib/stores/taskDrawer.svelte.js"; import { assistantChatStore } from "$lib/stores/assistantChat.svelte.js"; @@ -75,14 +75,22 @@ let recentTasks = $state([]); let loadingTasks = $state(false); let activeTaskDetails = $state(null); + let activeTaskDetailsLoading = $state(false); + let activeTaskDetailsError = $state(""); let environmentOptions = []; let diffText = $state(""); + let diffError = $state(""); let showDiff = $state(false); let isDiffLoading = $state(false); + let wsConnectionState = $state("idle"); + let taskDetailRequestSeq = 0; + let lastHandledDrawerKey = ""; + let unsubscribeTaskDrawer = null; + let drawerState = $state(taskDrawerStore.value); - let isOpen = $derived(Boolean(taskDrawerStore.value?.isOpen)); - let activeTaskId = $derived(taskDrawerStore.value?.activeTaskId || null); - let scrollToHint = $derived(taskDrawerStore.value?.scrollToHint || null); + let isOpen = $derived(Boolean(drawerState?.isOpen)); + let activeTaskId = $derived(drawerState?.activeTaskId || null); + let scrollToHint = $derived(drawerState?.scrollToHint || null); // Scroll to last error when opening a FAILED task $effect(() => { @@ -145,12 +153,13 @@ } function isTaskFinished(status) { - return ( - status === "SUCCESS" || - status === "FAILED" || - status === "ERROR" || - status === "PARTIAL_SUCCESS" - ); + const s = String(status || "").toUpperCase(); + return s === "SUCCESS" || s === "COMPLETED" || s === "FAILED" || s === "ERROR" || s === "PARTIAL_SUCCESS"; + } + + function isTaskRunning(status) { + const s = String(status || "").toUpperCase(); + return s === "RUNNING" || s === "IN_PROGRESS" || s === "PENDING" || s === "AWAITING_INPUT"; } function resolveLlmValidationStatus(task) { @@ -168,7 +177,7 @@ if (raw === "FAIL") return { label: "FAIL", tone: "fail", icon: "!" }; if (raw === "WARN") return { label: "WARN", tone: "warn", icon: "!" }; if (raw === "PASS") return { label: "PASS", tone: "pass", icon: "OK" }; - return { label: "UNKNOWN", tone: "unknown", icon: "?" }; + return null; } function llmValidationBadgeClass(tone) { @@ -194,6 +203,39 @@ return "bg-surface-muted text-text ring-1 ring-secondary-ring"; } + function getTaskStatusLabel(status) { + const s = status?.toLowerCase(); + if (s === "success" || s === "completed") + return $t.reports?.status_success || "Success"; + if (s === "failed" || s === "error") + return $t.reports?.status_failed || "Failed"; + if (s === "running" || s === "in_progress") + return $t.reports?.status_in_progress || "In progress"; + if (s === "partial" || s === "partial_success") + return $t.reports?.status_partial || "Partial"; + return status || $t.common?.unknown; + } + + function getConnectionLabel(state) { + if (state === "live") return $t.tasks?.live || "Live"; + if (state === "connecting") return $t.tasks?.connecting || "Connecting..."; + if (state === "reconnecting") return $t.tasks?.reconnecting || "Reconnecting..."; + if (state === "error") return $t.tasks?.disconnected || "Disconnected"; + if (state === "closed") return $t.tasks?.completed || "Completed"; + return $t.tasks?.waiting_logs || "Waiting for logs..."; + } + + function getConnectionDotClass(state) { + if (state === "live") return "bg-success"; + if (state === "connecting" || state === "reconnecting") return "bg-primary-ring animate-pulse"; + if (state === "error") return "bg-destructive"; + return "bg-text-subtle"; + } + + function normalizeTaskPayload(payload) { + return payload?.task || payload; + } + async function loadEnvironmentOptions() { try { const response = await api.getEnvironments(); @@ -377,6 +419,7 @@ showDiff = true; isDiffLoading = true; diffText = ""; + diffError = ""; try { const diffPayload = await gitService.getDiff( derivedTaskSummary.primaryDashboardId, @@ -390,7 +433,7 @@ diffText = ""; } } catch (err) { - addToast(err?.message || "Failed to load diff", "error"); + diffError = err?.message || ($t.tasks?.no_diff_available || "No diff available"); diffText = ""; } finally { isDiffLoading = false; @@ -426,23 +469,56 @@ ); } - // Connect to WebSocket for real-time logs - function connectWebSocket() { - if (!activeTaskId) return; + async function loadActiveTaskDetails(taskId, requestSeq) { + if (!taskId) return; + activeTaskDetailsLoading = true; + activeTaskDetailsError = ""; + try { + log("TaskDrawer", "REASON", "Load active task details", { taskId }); + const payload = await api.getTask(taskId); + if (requestSeq !== taskDetailRequestSeq || normalizeTaskId(activeTaskId) !== taskId) return; + const task = normalizeTaskPayload(payload); + activeTaskDetails = task; + if (task?.status) taskStatus = task.status; + log("TaskDrawer", "REFLECT", "Active task details loaded", { taskId, status: task?.status }); + } catch (err) { + if (requestSeq !== taskDetailRequestSeq || normalizeTaskId(activeTaskId) !== taskId) return; + activeTaskDetailsError = err instanceof Error + ? err.message + : ($t.tasks?.fetch_task_details_failed || "Failed to load task details"); + log("TaskDrawer", "EXPLORE", "Active task details failed", { taskId }, activeTaskDetailsError); + } finally { + if (requestSeq === taskDetailRequestSeq && normalizeTaskId(activeTaskId) === taskId) { + activeTaskDetailsLoading = false; + } + } + } - const taskId = normalizeTaskId(activeTaskId); + // Connect to WebSocket for real-time logs + function connectWebSocket(taskId, requestSeq) { + if (!taskId) return; const wsUrl = getWsUrl(taskId); log("TaskDrawer", "REASON", "Connecting to WebSocket", { wsUrl }); + wsConnectionState = "connecting"; ws = new WebSocket(wsUrl); ws.onopen = () => { + if (requestSeq !== taskDetailRequestSeq || normalizeTaskId(activeTaskId) !== taskId) return; + wsConnectionState = "live"; log("TaskDrawer", "REFLECT", "WebSocket connected"); }; ws.onmessage = (event) => { - const data = JSON.parse(event.data); + if (requestSeq !== taskDetailRequestSeq || normalizeTaskId(activeTaskId) !== taskId) return; + let data; + try { + data = JSON.parse(event.data); + } catch (err) { + log("TaskDrawer", "EXPLORE", "WebSocket message parse failed", { taskId }, err instanceof Error ? err.message : "Invalid JSON"); + return; + } // ── Task status update from WebSocket ── if (data.type === "task_status") { @@ -450,6 +526,9 @@ log("TaskDrawer", "REFLECT", "WebSocket status update", { status: taskData.status }); activeTaskDetails = taskData; taskStatus = taskData.status; + if (isTaskFinished(taskData.status)) { + void loadActiveTaskDetails(taskId, requestSeq); + } return; } @@ -459,28 +538,37 @@ if (data.message?.includes("Task completed successfully")) { taskStatus = "SUCCESS"; + void loadActiveTaskDetails(taskId, requestSeq); } else if (data.message?.includes("Task failed")) { taskStatus = "FAILED"; + void loadActiveTaskDetails(taskId, requestSeq); } }; ws.onerror = (error) => { + if (requestSeq !== taskDetailRequestSeq || normalizeTaskId(activeTaskId) !== taskId) return; + wsConnectionState = "error"; log("TaskDrawer", "EXPLORE", "WebSocket error", {}, error instanceof Error ? error.message : "Unknown websocket error"); }; ws.onclose = (event) => { + if (requestSeq !== taskDetailRequestSeq || normalizeTaskId(activeTaskId) !== taskId) return; // event.code 1000 = normal closure (task finished) — stop reconnecting // event.code 1006 = abnormal close (network issue) — reconnect const isNormalClosure = event.code === 1000; - const isTerminal = taskStatus === "SUCCESS" || taskStatus === "FAILED"; + const isTerminal = isTaskFinished(taskStatus); if (isNormalClosure || isTerminal) { + wsConnectionState = "closed"; log("TaskDrawer", "REFLECT", "WebSocket closed normally - stream ended", { code: event.code }); } else { + wsConnectionState = "reconnecting"; log("TaskDrawer", "REASON", "WebSocket disconnected, reconnecting in 10s", { code: event.code }); ws = null; wsReconnectTimer = setTimeout(() => { - if (activeTaskId) connectWebSocket(); + if (requestSeq === taskDetailRequestSeq && normalizeTaskId(activeTaskId) === taskId) { + connectWebSocket(taskId, requestSeq); + } }, 10000); } }; @@ -506,6 +594,7 @@ ws.close(); ws = null; } + wsConnectionState = "idle"; } // #endregion disconnectWebSocket:Function @@ -570,26 +659,59 @@ } // #endregion goBackToList:Function - // Reconnect when active task changes - $effect(() => { - if (isOpen) { - if (activeTaskId) { + function buildDrawerKey(state) { + const nextTaskId = normalizeTaskId(state?.activeTaskId || null); + return `${state?.isOpen ? "open" : "closed"}:${nextTaskId || "list"}`; + } + + onMount(() => { + unsubscribeTaskDrawer = taskDrawerStore.subscribe((state) => { + drawerState = state; + const drawerKey = buildDrawerKey(state); + if (drawerKey === lastHandledDrawerKey) return; + lastHandledDrawerKey = drawerKey; + handleDrawerKeyChange(drawerKey); + }); + }); + + function handleDrawerKeyChange(drawerKey) { + const [openState, taskId] = drawerKey.split(":"); + const drawerIsOpen = openState === "open"; + const hasActiveTask = drawerIsOpen && taskId && taskId !== "list"; + + if (drawerIsOpen) { + if (hasActiveTask) { + const requestSeq = ++taskDetailRequestSeq; disconnectWebSocket(); realTimeLogs = []; showDiff = false; diffText = ""; - taskStatus = "RUNNING"; + diffError = ""; + taskStatus = null; activeTaskDetails = null; - connectWebSocket(); + activeTaskDetailsError = ""; + activeTaskDetailsLoading = true; + void loadActiveTaskDetails(taskId, requestSeq); + connectWebSocket(taskId, requestSeq); void loadEnvironmentOptions(); } else { + taskDetailRequestSeq += 1; + disconnectWebSocket(); activeTaskDetails = null; + activeTaskDetailsError = ""; + activeTaskDetailsLoading = false; + taskStatus = null; void loadRecentTasks(); } } else { + taskDetailRequestSeq += 1; + disconnectWebSocket(); activeTaskDetails = null; + activeTaskDetailsError = ""; + activeTaskDetailsLoading = false; + taskStatus = null; } - }); + } let derivedTaskSummary = $derived(buildTaskSummary(activeTaskDetails)); let derivedActiveTaskValidation = $derived( @@ -598,6 +720,10 @@ // Cleanup on destroy onDestroy(() => { + if (unsubscribeTaskDrawer) { + unsubscribeTaskDrawer(); + unsubscribeTaskDrawer = null; + } if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); } @@ -609,17 +735,17 @@ {#if isOpen} - {#if taskStatus !== "SUCCESS" && taskStatus !== "FAILED"} + {#if activeTaskId && (isTaskRunning(taskStatus) || (!taskStatus && wsConnectionState !== "idle"))}
-
+

- {$t.tasks?.footer_text} + {getConnectionLabel(wsConnectionState)}

{/if} diff --git a/frontend/src/lib/components/layout/TopNavbar.svelte b/frontend/src/lib/components/layout/TopNavbar.svelte index 063d081a..a039199e 100644 --- a/frontend/src/lib/components/layout/TopNavbar.svelte +++ b/frontend/src/lib/components/layout/TopNavbar.svelte @@ -381,11 +381,11 @@