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 !activeTaskId && recentTasks.length > 0}
{:else if activeTaskId}
{/if}
@@ -646,9 +773,11 @@
{/if}
{#if taskStatus}
- {taskStatus}
+ {$t.tasks?.task_label || "Task"}
+ {getTaskStatusLabel(taskStatus)}
{/if}
{#if derivedActiveTaskValidation}
@@ -656,6 +785,7 @@
class={`text-xs font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full inline-flex items-center gap-1 ${llmValidationBadgeClass(derivedActiveTaskValidation.tone)}`}
title="Dashboard validation result"
>
+ LLM
@@ -667,17 +797,12 @@
-
@@ -685,6 +810,41 @@
{#if activeTaskId}
+ {#if activeTaskDetailsLoading && !activeTaskDetails}
+
+ {:else if activeTaskDetailsError}
+
+
+
+
+
{$t.tasks?.fetch_task_details_failed || "Failed to load task details"}
+
{activeTaskDetailsError}
+
+
+
+
+ {/if}
{#if derivedTaskSummary}
- {taskStatus}
+ {getTaskStatusLabel(taskStatus)}
{#if derivedTaskSummary.headline}
@@ -773,8 +933,15 @@
{:else if diffText}
{diffText}
+ {:else if diffError}
+
+
+ {$t.tasks?.no_diff_available || "Diff unavailable"}
+
+
{diffError}
+
{:else}
-
+
{$t.tasks?.no_diff_available || "No diff available"}
{/if}
@@ -787,13 +954,14 @@
taskId={activeTaskId}
{taskStatus}
{realTimeLogs}
+ showErrorSummary={true}
/>
{:else if loadingTasks}
@@ -804,7 +972,7 @@
{$t.tasks?.recent}
{#if loadingTasks}
-
+
{/if}
@@ -848,7 +1016,7 @@
task.status,
)}"
>
- {task.status || $t.common?.unknown}
+ {getTaskStatusLabel(task.status)}
@@ -898,13 +1066,13 @@
- {#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 @@