feat(web): show "Resuming session" feedback in popover during reconnect

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.
This commit is contained in:
José Almeida
2026-05-05 23:14:28 +01:00
parent d35e601d42
commit ce49d6dfec
3 changed files with 270 additions and 200 deletions
@@ -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<T>(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 <ResumingRow />;
if (summary.phase === "preparing" || summary.phase === "preparing_fallback") {
return <PreparingRow summary={summary} />;
}
if (summary.phase === "failed") return <FailedRow summary={summary} />;
return <ReadyRow summary={summary} />;
}
function ResumingRow() {
return (
<div
data-testid="executor-prepare-status"
data-phase="resuming"
className="border-b border-border px-3 py-2.5 space-y-1"
>
<div className="flex items-center gap-2">
<IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="font-medium text-foreground">Resuming session</span>
</div>
<div className="text-xs text-muted-foreground">Reconnecting to the existing environment…</div>
</div>
);
}
function PreparingRow({ summary }: { summary: PrepareSummary }) {
const stepLabel = summary.current?.name ?? "Preparing...";
const stepNumber = Math.min(summary.currentIndex + 1, summary.totalSteps);
return (
<div
data-testid="executor-prepare-status"
data-phase={summary.phase}
className="border-b border-border px-3 py-2.5 space-y-1"
>
<div className="flex items-center gap-2">
<IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="font-medium text-foreground">Preparing environment</span>
</div>
<div className="text-xs text-muted-foreground">
{summary.totalSteps > 0
? `Step ${stepNumber} of ${summary.totalSteps}: ${stepLabel}`
: stepLabel}
</div>
{summary.phase === "preparing_fallback" && summary.fallbackWarning && (
<div
data-testid="executor-prepare-fallback-warning"
className="mt-1 flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400"
>
<IconAlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{summary.fallbackWarning}</span>
</div>
)}
</div>
);
}
function FailedRow({ summary }: { summary: PrepareSummary }) {
return (
<div
data-testid="executor-prepare-status"
data-phase="failed"
className="border-b border-border px-3 py-2.5 space-y-1"
>
<div className="flex items-center gap-2 text-destructive">
<IconAlertTriangle className="h-3.5 w-3.5" />
<span className="font-medium">Environment preparation failed</span>
</div>
{summary.failedStep && (
<div className="text-xs text-muted-foreground">Failed at: {summary.failedStep.name}</div>
)}
{summary.failedStep?.error && (
<pre className="text-[11px] text-destructive whitespace-pre-wrap max-h-16 overflow-auto">
{summary.failedStep.error}
</pre>
)}
</div>
);
}
function ReadyRow({ summary }: { summary: PrepareSummary }) {
return (
<div
data-testid="executor-prepare-status"
data-phase="ready"
className="border-b border-border p-3 flex items-center gap-2 text-xs text-muted-foreground"
>
<IconCheck className="h-3.5 w-3.5 text-green-500" />
<span>
Environment ready
{summary.durationMs ? ` · ${formatDuration(summary.durationMs)}` : ""}
</span>
</div>
);
}
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`;
}
@@ -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(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
// 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(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
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(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
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(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
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();
@@ -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<T>(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 (
<div
data-testid="executor-prepare-status"
data-phase={summary.phase}
className="border-b border-border px-3 py-2.5 space-y-1"
>
<div className="flex items-center gap-2">
<IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="font-medium text-foreground">Preparing environment</span>
</div>
<div className="text-xs text-muted-foreground">
{summary.totalSteps > 0
? `Step ${stepNumber} of ${summary.totalSteps}: ${stepLabel}`
: stepLabel}
</div>
{summary.phase === "preparing_fallback" && summary.fallbackWarning && (
<div
data-testid="executor-prepare-fallback-warning"
className="mt-1 flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400"
>
<IconAlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{summary.fallbackWarning}</span>
</div>
)}
</div>
);
}
if (summary.phase === "failed") {
return (
<div
data-testid="executor-prepare-status"
data-phase="failed"
className="border-b border-border px-3 py-2.5 space-y-1"
>
<div className="flex items-center gap-2 text-destructive">
<IconAlertTriangle className="h-3.5 w-3.5" />
<span className="font-medium">Environment preparation failed</span>
</div>
{summary.failedStep && (
<div className="text-xs text-muted-foreground">Failed at: {summary.failedStep.name}</div>
)}
{summary.failedStep?.error && (
<pre className="text-[11px] text-destructive whitespace-pre-wrap max-h-16 overflow-auto">
{summary.failedStep.error}
</pre>
)}
</div>
);
}
// phase === "ready"
return (
<div
data-testid="executor-prepare-status"
data-phase="ready"
className="border-b border-border p-3 flex items-center gap-2 text-xs text-muted-foreground"
>
<IconCheck className="h-3.5 w-3.5 text-green-500" />
<span>
Environment ready
{summary.durationMs ? ` · ${formatDuration(summary.durationMs)}` : ""}
</span>
</div>
);
}
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,