From ce49d6dfec10cbd889179ea3b7502b447538adb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Tue, 5 May 2026 23:14:28 +0100 Subject: [PATCH] feat(web): show "Resuming session" feedback in popover during reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom Resume button on the chat panel was leaving users staring at nothing for the 5-30s reconnect window: prepare-progress events are intentionally suppressed on resume (the workspace is already there), so the popover sat in the idle phase even while session.state was STARTING. Synthesize a "resuming" phase from session state when no prepare events are flowing, rendered as a spinner row in the popover with copy "Resuming session — Reconnecting to the existing environment…". The button icon also flips to the spinner so the toolbar advertises the in-flight work without the user having to open the popover. Extract the prepare-status reducer + section component into their own file so the main popover module stays under the 600-line ESLint cap. --- .../task/executor-prepare-status.tsx | 234 ++++++++++++++++++ .../task/executor-settings-button.test.tsx | 35 ++- .../task/executor-settings-button.tsx | 201 +-------------- 3 files changed, 270 insertions(+), 200 deletions(-) create mode 100644 apps/web/components/task/executor-prepare-status.tsx diff --git a/apps/web/components/task/executor-prepare-status.tsx b/apps/web/components/task/executor-prepare-status.tsx new file mode 100644 index 0000000000..dfff92cab5 --- /dev/null +++ b/apps/web/components/task/executor-prepare-status.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useMemo } from "react"; +import { IconAlertTriangle, IconCheck, IconLoader2 } from "@tabler/icons-react"; + +import { useAppStore } from "@/components/state-provider"; +import type { + PrepareStepInfo, + SessionPrepareState, +} from "@/lib/state/slices/session-runtime/types"; + +export type PreparePhase = + | "idle" + | "preparing" + | "preparing_fallback" + | "resuming" + | "ready" + | "failed"; + +export type PrepareSummary = { + phase: PreparePhase; + current: PrepareStepInfo | null; + currentIndex: number; + totalSteps: number; + fallbackWarning: string | null; + failedStep: PrepareStepInfo | null; + durationMs: number | null; +}; + +const IDLE_PREPARE_SUMMARY: PrepareSummary = { + phase: "idle", + current: null, + currentIndex: 0, + totalSteps: 0, + fallbackWarning: null, + failedStep: null, + durationMs: null, +}; + +/** + * Reduces the prepare-progress slice + session state for one session into a + * UI-friendly summary. The reducer is the only place that decides what counts + * as "still preparing" vs "ready" vs "failed", and it surfaces: + * - the missing-sandbox fallback as a first-class "preparing_fallback" phase + * so the popover renders dedicated UI for the recovery path; + * - a "resuming" phase synthesized from session.state == STARTING when the + * backend skips prepare events on resume, so the popover still shows a + * spinner instead of looking idle for the entire reconnect. + */ +export function usePrepareSummary(sessionId: string | null): PrepareSummary { + const prepareState = useAppStore((state) => + sessionId ? (state.prepareProgress?.bySessionId?.[sessionId] ?? null) : null, + ); + const sessionState = useAppStore((state) => + sessionId ? (state.taskSessions?.items?.[sessionId]?.state ?? null) : null, + ); + return useMemo(() => summarizePrepare(prepareState, sessionState), [prepareState, sessionState]); +} + +export function isPreparingPhase(phase: PreparePhase): boolean { + return phase === "preparing" || phase === "preparing_fallback" || phase === "resuming"; +} + +function summarizePrepare( + state: SessionPrepareState | null, + sessionState: string | null, +): PrepareSummary { + if (!state) { + if (sessionState === "STARTING") { + return { ...IDLE_PREPARE_SUMMARY, phase: "resuming" }; + } + return IDLE_PREPARE_SUMMARY; + } + + const steps = state.steps ?? []; + const fallbackStep = steps.find(isFallbackNoticeStep) ?? null; + const failedStep = steps.find((s) => s.status === "failed") ?? null; + const runningStep = steps.find((s) => s.status === "running") ?? null; + const { current, currentIndex } = pickCurrentStep(steps, runningStep); + + return { + phase: derivePhase(state.status, fallbackStep, failedStep), + current, + currentIndex, + totalSteps: steps.length, + fallbackWarning: fallbackStep?.warning ?? null, + failedStep, + durationMs: state.durationMs ?? null, + }; +} + +function pickCurrentStep( + steps: PrepareStepInfo[], + runningStep: PrepareStepInfo | null, +): { current: PrepareStepInfo | null; currentIndex: number } { + if (runningStep) { + return { current: runningStep, currentIndex: steps.indexOf(runningStep) }; + } + const lastCompletedIdx = lastIndexWhere( + steps, + (s) => s.status === "completed" || s.status === "skipped" || s.status === "failed", + ); + if (lastCompletedIdx >= 0) { + return { current: steps[lastCompletedIdx], currentIndex: lastCompletedIdx }; + } + return { current: steps[0] ?? null, currentIndex: 0 }; +} + +function derivePhase( + status: string, + fallbackStep: PrepareStepInfo | null, + failedStep: PrepareStepInfo | null, +): PreparePhase { + if (status === "preparing") return fallbackStep ? "preparing_fallback" : "preparing"; + if (status === "failed" || failedStep) return "failed"; + if (status === "completed") return "ready"; + return "idle"; +} + +function isFallbackNoticeStep(step: PrepareStepInfo): boolean { + return step.status === "skipped" && step.name === "Reconnecting cloud sandbox"; +} + +function lastIndexWhere(items: T[], pred: (item: T) => boolean): number { + for (let i = items.length - 1; i >= 0; i--) { + if (pred(items[i])) return i; + } + return -1; +} + +export function PrepareStatusSection({ summary }: { summary: PrepareSummary }) { + if (summary.phase === "idle") return null; + if (summary.phase === "resuming") return ; + if (summary.phase === "preparing" || summary.phase === "preparing_fallback") { + return ; + } + if (summary.phase === "failed") return ; + return ; +} + +function ResumingRow() { + return ( +
+
+ + Resuming session +
+
Reconnecting to the existing environment…
+
+ ); +} + +function PreparingRow({ summary }: { summary: PrepareSummary }) { + const stepLabel = summary.current?.name ?? "Preparing..."; + const stepNumber = Math.min(summary.currentIndex + 1, summary.totalSteps); + return ( +
+
+ + Preparing environment +
+
+ {summary.totalSteps > 0 + ? `Step ${stepNumber} of ${summary.totalSteps}: ${stepLabel}` + : stepLabel} +
+ {summary.phase === "preparing_fallback" && summary.fallbackWarning && ( +
+ + {summary.fallbackWarning} +
+ )} +
+ ); +} + +function FailedRow({ summary }: { summary: PrepareSummary }) { + return ( +
+
+ + Environment preparation failed +
+ {summary.failedStep && ( +
Failed at: {summary.failedStep.name}
+ )} + {summary.failedStep?.error && ( +
+          {summary.failedStep.error}
+        
+ )} +
+ ); +} + +function ReadyRow({ summary }: { summary: PrepareSummary }) { + return ( +
+ + + Environment ready + {summary.durationMs ? ` · ${formatDuration(summary.durationMs)}` : ""} + +
+ ); +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const secs = Math.round(ms / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + const remSecs = secs % 60; + return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`; +} diff --git a/apps/web/components/task/executor-settings-button.test.tsx b/apps/web/components/task/executor-settings-button.test.tsx index 01d2fdf989..114b0f4fb9 100644 --- a/apps/web/components/task/executor-settings-button.test.tsx +++ b/apps/web/components/task/executor-settings-button.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { SessionPrepareState } from "@/lib/state/slices/session-runtime/types"; let mockPrepareState: SessionPrepareState | null = null; +let mockSessionState: string | null = null; let mockEnv: { executor_type: string; sandbox_id?: string; container_id?: string } | null = null; vi.mock("@/components/state-provider", () => ({ @@ -11,6 +12,11 @@ vi.mock("@/components/state-provider", () => ({ prepareProgress: { bySessionId: mockPrepareState ? { [mockPrepareState.sessionId]: mockPrepareState } : {}, }, + taskSessions: { + items: mockSessionState + ? { [SESSION_ID]: { id: SESSION_ID, state: mockSessionState } } + : {}, + }, }), })); @@ -31,11 +37,14 @@ import { ExecutorSettingsButton } from "./executor-settings-button"; const SESSION_ID = "session-1"; const TASK_ID = "task-1"; const STEP_CREATE_SANDBOX = "Creating cloud sandbox"; +const PREPARE_STATUS_TESTID = "executor-prepare-status"; +const SETTINGS_BUTTON_TESTID = "executor-settings-button"; describe("ExecutorSettingsButton", () => { afterEach(() => { cleanup(); mockPrepareState = null; + mockSessionState = null; mockEnv = null; }); @@ -87,10 +96,10 @@ describe("ExecutorSettingsButton", () => { render(); // Open the popover by clicking the trigger. - const trigger = screen.getByTestId("executor-settings-button"); + const trigger = screen.getByTestId(SETTINGS_BUTTON_TESTID); trigger.click(); - expect(await screen.findByTestId("executor-prepare-status")).toHaveProperty( + expect(await screen.findByTestId(PREPARE_STATUS_TESTID)).toHaveProperty( "dataset.phase", "preparing", ); @@ -113,13 +122,27 @@ describe("ExecutorSettingsButton", () => { }; render(); - screen.getByTestId("executor-settings-button").click(); + screen.getByTestId(SETTINGS_BUTTON_TESTID).click(); - const status = await screen.findByTestId("executor-prepare-status"); + const status = await screen.findByTestId(PREPARE_STATUS_TESTID); expect(status.dataset.phase).toBe("preparing_fallback"); expect(screen.getByTestId("executor-prepare-fallback-warning")).toBeTruthy(); }); + it("renders the Resuming session row when session is STARTING with no prepare events", async () => { + mockSessionState = "STARTING"; + + render(); + + expect(screen.getByTestId("executor-settings-button-spinner")).toBeTruthy(); + screen.getByTestId(SETTINGS_BUTTON_TESTID).click(); + + const status = await screen.findByTestId(PREPARE_STATUS_TESTID); + expect(status.dataset.phase).toBe("resuming"); + expect(screen.getByText("Resuming session")).toBeTruthy(); + expect(screen.getByText(/Reconnecting to the existing environment/)).toBeTruthy(); + }); + it("shows an Environment ready row once preparation completes", async () => { mockPrepareState = { sessionId: SESSION_ID, @@ -129,9 +152,9 @@ describe("ExecutorSettingsButton", () => { }; render(); - screen.getByTestId("executor-settings-button").click(); + screen.getByTestId(SETTINGS_BUTTON_TESTID).click(); - const status = await screen.findByTestId("executor-prepare-status"); + const status = await screen.findByTestId(PREPARE_STATUS_TESTID); expect(status.dataset.phase).toBe("ready"); expect(screen.getByText(/Environment ready/)).toBeTruthy(); expect(screen.getByText(/13s/)).toBeTruthy(); diff --git a/apps/web/components/task/executor-settings-button.tsx b/apps/web/components/task/executor-settings-button.tsx index 338e35b5ef..21f82116e9 100644 --- a/apps/web/components/task/executor-settings-button.tsx +++ b/apps/web/components/task/executor-settings-button.tsx @@ -1,15 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - IconAlertTriangle, - IconBox, - IconCheck, - IconCopy, - IconLoader, - IconLoader2, - IconTrash, -} from "@tabler/icons-react"; +import { IconBox, IconCopy, IconLoader, IconLoader2, IconTrash } from "@tabler/icons-react"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; @@ -22,12 +14,7 @@ import { type TaskEnvironment, } from "@/lib/api/domains/task-environment-api"; import { ApiError } from "@/lib/api/client"; -import { useAppStore } from "@/components/state-provider"; import { getExecutorStatusIcon } from "@/lib/executor-icons"; -import type { - PrepareStepInfo, - SessionPrepareState, -} from "@/lib/state/slices/session-runtime/types"; import { TaskResetEnvConfirmDialog } from "./task-reset-env-confirm-dialog"; import { getEnvironmentStatusSnapshot, @@ -35,6 +22,11 @@ import { type StatusTone, type EnvironmentStatusSnapshot, } from "./executor-environment-status"; +import { + isPreparingPhase, + PrepareStatusSection, + usePrepareSummary, +} from "./executor-prepare-status"; type ExecutorSettingsButtonProps = { taskId?: string | null; @@ -42,18 +34,6 @@ type ExecutorSettingsButtonProps = { disabled?: boolean; }; -type PreparePhase = "idle" | "preparing" | "preparing_fallback" | "ready" | "failed"; - -type PrepareSummary = { - phase: PreparePhase; - current: PrepareStepInfo | null; - currentIndex: number; - totalSteps: number; - fallbackWarning: string | null; - failedStep: PrepareStepInfo | null; - durationMs: number | null; -}; - const ACTIVE_POLL_INTERVAL_MS = 3000; const BACKGROUND_POLL_INTERVAL_MS = 7000; @@ -157,89 +137,6 @@ function useTaskEnvironment(taskId: string | null | undefined, active: boolean) return { env, container, loading, isResetting, reset, status }; } -/** - * Reduces the full prepare-progress slice for one session into a UI-friendly - * summary. The reducer is the only place that decides what counts as "still - * preparing" vs "ready" vs "failed", and it surfaces the fallback notice as - * a first-class phase so the popover can render dedicated UI for the - * "previous sandbox went away" recovery path. - */ -function usePrepareSummary(sessionId: string | null): PrepareSummary { - const prepareState = useAppStore((state) => - sessionId ? (state.prepareProgress?.bySessionId?.[sessionId] ?? null) : null, - ); - return useMemo(() => summarizePrepare(prepareState), [prepareState]); -} - -const IDLE_PREPARE_SUMMARY: PrepareSummary = { - phase: "idle", - current: null, - currentIndex: 0, - totalSteps: 0, - fallbackWarning: null, - failedStep: null, - durationMs: null, -}; - -function summarizePrepare(state: SessionPrepareState | null): PrepareSummary { - if (!state) return IDLE_PREPARE_SUMMARY; - - const steps = state.steps ?? []; - const fallbackStep = steps.find(isFallbackNoticeStep) ?? null; - const failedStep = steps.find((s) => s.status === "failed") ?? null; - const runningStep = steps.find((s) => s.status === "running") ?? null; - const { current, currentIndex } = pickCurrentStep(steps, runningStep); - - return { - phase: derivePhase(state.status, fallbackStep, failedStep), - current, - currentIndex, - totalSteps: steps.length, - fallbackWarning: fallbackStep?.warning ?? null, - failedStep, - durationMs: state.durationMs ?? null, - }; -} - -function pickCurrentStep( - steps: PrepareStepInfo[], - runningStep: PrepareStepInfo | null, -): { current: PrepareStepInfo | null; currentIndex: number } { - if (runningStep) { - return { current: runningStep, currentIndex: steps.indexOf(runningStep) }; - } - const lastCompletedIdx = lastIndexWhere( - steps, - (s) => s.status === "completed" || s.status === "skipped" || s.status === "failed", - ); - if (lastCompletedIdx >= 0) { - return { current: steps[lastCompletedIdx], currentIndex: lastCompletedIdx }; - } - return { current: steps[0] ?? null, currentIndex: 0 }; -} - -function derivePhase( - status: string, - fallbackStep: PrepareStepInfo | null, - failedStep: PrepareStepInfo | null, -): PreparePhase { - if (status === "preparing") return fallbackStep ? "preparing_fallback" : "preparing"; - if (status === "failed" || failedStep) return "failed"; - if (status === "completed") return "ready"; - return "idle"; -} - -function isFallbackNoticeStep(step: PrepareStepInfo): boolean { - return step.status === "skipped" && step.name === "Reconnecting cloud sandbox"; -} - -function lastIndexWhere(items: T[], pred: (item: T) => boolean): number { - for (let i = items.length - 1; i >= 0; i--) { - if (pred(items[i])) return i; - } - return -1; -} - export function ExecutorSettingsButton({ taskId, sessionId, @@ -248,7 +145,7 @@ export function ExecutorSettingsButton({ const [open, setOpen] = useState(false); const [resetDialogOpen, setResetDialogOpen] = useState(false); const prepare = usePrepareSummary(sessionId ?? null); - const isPreparing = prepare.phase === "preparing" || prepare.phase === "preparing_fallback"; + const isPreparing = isPreparingPhase(prepare.phase); // Promote the foreground polling cadence while preparing so the icon flips // to "ready" without the user opening the popover. const { env, container, loading, isResetting, reset, status } = useTaskEnvironment( @@ -387,90 +284,6 @@ function ExecutorStatusDot({ ); } -function PrepareStatusSection({ summary }: { summary: PrepareSummary }) { - if (summary.phase === "idle") { - return null; - } - - if (summary.phase === "preparing" || summary.phase === "preparing_fallback") { - const stepLabel = summary.current?.name ?? "Preparing..."; - const stepNumber = Math.min(summary.currentIndex + 1, summary.totalSteps); - return ( -
-
- - Preparing environment -
-
- {summary.totalSteps > 0 - ? `Step ${stepNumber} of ${summary.totalSteps}: ${stepLabel}` - : stepLabel} -
- {summary.phase === "preparing_fallback" && summary.fallbackWarning && ( -
- - {summary.fallbackWarning} -
- )} -
- ); - } - - if (summary.phase === "failed") { - return ( -
-
- - Environment preparation failed -
- {summary.failedStep && ( -
Failed at: {summary.failedStep.name}
- )} - {summary.failedStep?.error && ( -
-            {summary.failedStep.error}
-          
- )} -
- ); - } - - // phase === "ready" - return ( -
- - - Environment ready - {summary.durationMs ? ` · ${formatDuration(summary.durationMs)}` : ""} - -
- ); -} - -function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; - const secs = Math.round(ms / 1000); - if (secs < 60) return `${secs}s`; - const mins = Math.floor(secs / 60); - const remSecs = secs % 60; - return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`; -} - function EnvironmentInfo({ env, container,