From c0b75864badd4a7fc17f4dcaf7b06fd0299bcc46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Thu, 7 May 2026 00:49:43 +0100 Subject: [PATCH] refactor(web): split executor-settings-button by concern Slice the 471-line button file into three modules with single concerns: the popover shell stays in `executor-settings-button.tsx` (168 lines, just trigger + popover layout + icon/status dot), `executor-environment-info.tsx` owns the env detail rows + status badge, and `hooks/domains/session/use-task-environment.ts` owns the fetch/poll/reset lifecycle alongside other session hooks. --- .../task/executor-environment-info.tsx | 184 ++++++++++ .../task/executor-settings-button.tsx | 325 +----------------- .../domains/session/use-task-environment.ts | 132 +++++++ 3 files changed, 328 insertions(+), 313 deletions(-) create mode 100644 apps/web/components/task/executor-environment-info.tsx create mode 100644 apps/web/hooks/domains/session/use-task-environment.ts diff --git a/apps/web/components/task/executor-environment-info.tsx b/apps/web/components/task/executor-environment-info.tsx new file mode 100644 index 0000000000..29e81b36a5 --- /dev/null +++ b/apps/web/components/task/executor-environment-info.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useMemo } from "react"; +import { IconCopy, IconLoader } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { toast } from "sonner"; + +import { + type ContainerLiveStatus, + type TaskEnvironment, +} from "@/lib/api/domains/task-environment-api"; +import { resolveExecutorEnvironmentStatus, type StatusTone } from "./executor-environment-status"; + +const TONE_CLASSES: Record = { + running: "border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300", + stopped: "border-zinc-500/30 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300", + warn: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", + error: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300", + neutral: "border-muted text-muted-foreground", +}; + +export function EnvironmentInfo({ + env, + container, + loading, +}: { + env: TaskEnvironment | null; + container: ContainerLiveStatus | null; + loading: boolean; +}) { + if (loading && !env) { + return ( +
+ +
+ ); + } + + if (!env) { + return ( +
+

No environment yet

+

+ An environment is created when you start a session on this task. +

+
+ ); + } + + return ( +
+
+ {formatExecutorType(env.executor_type)} + +
+ +
+ ); +} + +function StatusBadge({ + env, + container, +}: { + env: TaskEnvironment; + container: ContainerLiveStatus | null; +}) { + // For container-backed envs the live state is the source of truth; for the + // others fall back to the recorded TaskEnvironment.status. + const { label, tone } = resolveExecutorEnvironmentStatus(env, container); + const className = TONE_CLASSES[tone]; + return ( + + {label} + + ); +} + +function EnvironmentFields({ + env, + container, +}: { + env: TaskEnvironment; + container: ContainerLiveStatus | null; +}) { + const fields = useMemo(() => buildFields(env, container), [env, container]); + if (fields.length === 0) { + return

No resource details available.

; + } + return ( +
+ {fields.map((f) => ( + + ))} +
+ ); +} + +function Field({ label, value, copy }: { label: string; value: string; copy?: boolean }) { + return ( +
+
{label}
+
+ {value} + {copy && ( + + )} +
+
+ ); +} + +type FieldRow = { label: string; value: string; copy?: boolean }; + +function buildFields(env: TaskEnvironment, container: ContainerLiveStatus | null): FieldRow[] { + const rows: FieldRow[] = []; + + if (env.worktree_path) { + rows.push({ label: "Worktree", value: env.worktree_path, copy: true }); + } + if (env.worktree_branch) { + rows.push({ label: "Branch", value: env.worktree_branch, copy: true }); + } + + if (env.container_id) { + const short = env.container_id.slice(0, 12); + rows.push({ label: "Container", value: short, copy: true }); + // Use `sh` rather than `bash` — user-built images may only ship + // /bin/sh (busybox/alpine/etc.), and the bootstrap entrypoint already + // assumes sh-only. + rows.push({ + label: "Shell", + value: `docker exec -it ${short} sh`, + copy: true, + }); + if (container?.started_at && container.state === "running") { + rows.push({ label: "Uptime", value: formatUptime(container.started_at) }); + } + } + + if (env.sandbox_id) { + rows.push({ label: "Sprite", value: env.sandbox_id, copy: true }); + } + + return rows; +} + +function formatUptime(startedAt: string): string { + const startedMs = Date.parse(startedAt); + if (Number.isNaN(startedMs)) return startedAt; + const elapsedSec = Math.max(0, Math.floor((Date.now() - startedMs) / 1000)); + if (elapsedSec < 60) return `${elapsedSec}s`; + const min = Math.floor(elapsedSec / 60); + if (min < 60) return `${min}m`; + const hr = Math.floor(min / 60); + return `${hr}h ${min % 60}m`; +} + +function formatExecutorType(type: string): string { + switch (type) { + case "local_pc": + case "worktree": + return "Local (worktree)"; + case "local_docker": + return "Local Docker"; + case "sprites": + return "Sprites sandbox"; + case "remote_docker": + return "Remote Docker"; + default: + return type || "Unknown executor"; + } +} diff --git a/apps/web/components/task/executor-settings-button.tsx b/apps/web/components/task/executor-settings-button.tsx index 8b086454e6..33344a870b 100644 --- a/apps/web/components/task/executor-settings-button.tsx +++ b/apps/web/components/task/executor-settings-button.tsx @@ -1,30 +1,18 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { IconBox, IconCopy, IconLoader, IconLoader2, IconTrash } from "@tabler/icons-react"; +import { useCallback, useState } from "react"; +import { IconBox, IconLoader2, IconTrash } from "@tabler/icons-react"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; import { Button } from "@kandev/ui/button"; -import { Badge } from "@kandev/ui/badge"; -import { toast } from "sonner"; -import { - fetchTaskEnvironmentLive, - resetTaskEnvironment, - type ContainerLiveStatus, - type TaskEnvironment, -} from "@/lib/api/domains/task-environment-api"; -import { ApiError } from "@/lib/api/client"; import { getExecutorStatusIcon } from "@/lib/executor-icons"; import { TaskResetEnvConfirmDialog } from "./task-reset-env-confirm-dialog"; -import { - getEnvironmentStatusSnapshot, - resolveExecutorEnvironmentStatus, - type StatusTone, - type EnvironmentStatusSnapshot, -} from "./executor-environment-status"; +import type { StatusTone } from "./executor-environment-status"; import { usePrepareSummary } from "@/hooks/domains/session/use-prepare-summary"; +import { useTaskEnvironment } from "@/hooks/domains/session/use-task-environment"; import { isPreparingPhase } from "@/lib/prepare/summarize"; import { PrepareStatusSection } from "./executor-prepare-status"; +import { EnvironmentInfo } from "./executor-environment-info"; type ExecutorSettingsButtonProps = { taskId?: string | null; @@ -32,109 +20,6 @@ type ExecutorSettingsButtonProps = { disabled?: boolean; }; -const ACTIVE_POLL_INTERVAL_MS = 3000; -const BACKGROUND_POLL_INTERVAL_MS = 7000; - -/** - * Owns the env+container fetch/poll lifecycle and the reset action so the - * popover component stays small. Polls more quickly while the popover is open - * and less frequently while closed so the toolbar icon can still reflect - * externally stopped/restarted containers. - */ -function useTaskEnvironment(taskId: string | null | undefined, active: boolean) { - const [env, setEnv] = useState(null); - const [container, setContainer] = useState(null); - const [loading, setLoading] = useState(false); - const [isResetting, setIsResetting] = useState(false); - const inFlight = useRef(false); - const lastStatusRef = useRef(null); - // hasLoadedRef tracks "have we ever fetched successfully" so the spinner - // only shows on the first open. Keeping it in a ref instead of state means - // `loadEnv` doesn't depend on `env` — without that, every successful fetch - // creates a new `env` reference, forces a new `loadEnv` identity, and the - // polling effect's cleanup+rerun fires immediately, turning the 3-second - // poll into a tight loop. - const hasLoadedRef = useRef(false); - - useEffect(() => { - hasLoadedRef.current = false; - lastStatusRef.current = null; - setEnv(null); - setContainer(null); - setLoading(false); - }, [taskId]); - - const updateState = useCallback( - (nextEnv: TaskEnvironment | null, nextContainer: ContainerLiveStatus | null) => { - const nextStatus = getEnvironmentStatusSnapshot(nextEnv, nextContainer); - maybeNotifyEnvironmentStatus(lastStatusRef.current, nextStatus); - lastStatusRef.current = nextStatus; - setEnv(nextEnv); - setContainer(nextContainer); - }, - [], - ); - - const loadEnv = useCallback(async () => { - if (!taskId || inFlight.current) return; - inFlight.current = true; - setLoading((prev) => prev || (active && !hasLoadedRef.current)); - try { - const data = await fetchTaskEnvironmentLive(taskId); - hasLoadedRef.current = true; - updateState(data.environment, data.container ?? null); - } catch (err) { - // Only treat 404 as "no environment yet" — a transient 500 / auth / - // network error should leave the last-known view in place rather than - // erase a valid environment and disable the Reset action. - if (err instanceof ApiError && err.status === 404) { - hasLoadedRef.current = true; - updateState(null, null); - } - } finally { - inFlight.current = false; - setLoading(false); - } - }, [active, taskId, updateState]); - - useEffect(() => { - if (!taskId) return; - void loadEnv(); - const intervalMs = active ? ACTIVE_POLL_INTERVAL_MS : BACKGROUND_POLL_INTERVAL_MS; - const interval = window.setInterval(() => void loadEnv(), intervalMs); - return () => window.clearInterval(interval); - }, [active, taskId, loadEnv]); - - const reset = useCallback( - async ({ pushBranch }: { pushBranch: boolean }) => { - if (!taskId) return false; - setIsResetting(true); - try { - await resetTaskEnvironment(taskId, { push_branch: pushBranch }); - toast.success("Environment reset"); - lastStatusRef.current = getEnvironmentStatusSnapshot(null, null); - setEnv(null); - setContainer(null); - return true; - } catch (err) { - const msg = err instanceof Error ? err.message : "Unknown error"; - toast.error(`Reset failed: ${msg}`); - return false; - } finally { - setIsResetting(false); - } - }, - [taskId], - ); - - const status = useMemo( - () => (env ? resolveExecutorEnvironmentStatus(env, container) : null), - [env, container], - ); - - return { env, container, loading, isResetting, reset, status }; -} - export function ExecutorSettingsButton({ taskId, sessionId, @@ -253,19 +138,13 @@ function ExecutorButtonIcon({ return ; } -function maybeNotifyEnvironmentStatus( - prev: EnvironmentStatusSnapshot | null, - next: EnvironmentStatusSnapshot, -) { - if (!prev || prev.key === next.key) return; - if (prev.tone === "running" && next.tone !== "running") { - toast.error("Executor environment stopped", { - description: `Current state: ${next.label}`, - }); - } else if (prev.tone !== "running" && next.tone === "running") { - toast.success("Executor environment running"); - } -} +const DOT_CLASSES: Record = { + running: "bg-green-500", + stopped: "bg-zinc-500", + warn: "bg-amber-500", + error: "bg-red-500", + neutral: "bg-muted-foreground", +}; function ExecutorStatusDot({ status, @@ -287,183 +166,3 @@ function ExecutorStatusDot({ /> ); } - -function EnvironmentInfo({ - env, - container, - loading, -}: { - env: TaskEnvironment | null; - container: ContainerLiveStatus | null; - loading: boolean; -}) { - if (loading && !env) { - return ( -
- -
- ); - } - - if (!env) { - return ( -
-

No environment yet

-

- An environment is created when you start a session on this task. -

-
- ); - } - - return ( -
-
- {formatExecutorType(env.executor_type)} - -
- -
- ); -} - -function StatusBadge({ - env, - container, -}: { - env: TaskEnvironment; - container: ContainerLiveStatus | null; -}) { - // For container-backed envs the live state is the source of truth; for the - // others fall back to the recorded TaskEnvironment.status. - const { label, tone } = resolveExecutorEnvironmentStatus(env, container); - const className = TONE_CLASSES[tone]; - return ( - - {label} - - ); -} - -const TONE_CLASSES: Record = { - running: "border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300", - stopped: "border-zinc-500/30 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300", - warn: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", - error: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300", - neutral: "border-muted text-muted-foreground", -}; - -const DOT_CLASSES: Record = { - running: "bg-green-500", - stopped: "bg-zinc-500", - warn: "bg-amber-500", - error: "bg-red-500", - neutral: "bg-muted-foreground", -}; - -function EnvironmentFields({ - env, - container, -}: { - env: TaskEnvironment; - container: ContainerLiveStatus | null; -}) { - const fields = useMemo(() => buildFields(env, container), [env, container]); - if (fields.length === 0) { - return

No resource details available.

; - } - return ( -
- {fields.map((f) => ( - - ))} -
- ); -} - -function Field({ label, value, copy }: { label: string; value: string; copy?: boolean }) { - return ( -
-
{label}
-
- {value} - {copy && ( - - )} -
-
- ); -} - -type FieldRow = { label: string; value: string; copy?: boolean }; - -function buildFields(env: TaskEnvironment, container: ContainerLiveStatus | null): FieldRow[] { - const rows: FieldRow[] = []; - - if (env.worktree_path) { - rows.push({ label: "Worktree", value: env.worktree_path, copy: true }); - } - if (env.worktree_branch) { - rows.push({ label: "Branch", value: env.worktree_branch, copy: true }); - } - - if (env.container_id) { - const short = env.container_id.slice(0, 12); - rows.push({ label: "Container", value: short, copy: true }); - // Use `sh` rather than `bash` — user-built images may only ship - // /bin/sh (busybox/alpine/etc.), and the bootstrap entrypoint already - // assumes sh-only. - rows.push({ - label: "Shell", - value: `docker exec -it ${short} sh`, - copy: true, - }); - if (container?.started_at && container.state === "running") { - rows.push({ label: "Uptime", value: formatUptime(container.started_at) }); - } - } - - if (env.sandbox_id) { - rows.push({ label: "Sprite", value: env.sandbox_id, copy: true }); - } - - return rows; -} - -function formatUptime(startedAt: string): string { - const startedMs = Date.parse(startedAt); - if (Number.isNaN(startedMs)) return startedAt; - const elapsedSec = Math.max(0, Math.floor((Date.now() - startedMs) / 1000)); - if (elapsedSec < 60) return `${elapsedSec}s`; - const min = Math.floor(elapsedSec / 60); - if (min < 60) return `${min}m`; - const hr = Math.floor(min / 60); - return `${hr}h ${min % 60}m`; -} - -function formatExecutorType(type: string): string { - switch (type) { - case "local_pc": - case "worktree": - return "Local (worktree)"; - case "local_docker": - return "Local Docker"; - case "sprites": - return "Sprites sandbox"; - case "remote_docker": - return "Remote Docker"; - default: - return type || "Unknown executor"; - } -} diff --git a/apps/web/hooks/domains/session/use-task-environment.ts b/apps/web/hooks/domains/session/use-task-environment.ts new file mode 100644 index 0000000000..2207d909b0 --- /dev/null +++ b/apps/web/hooks/domains/session/use-task-environment.ts @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { + fetchTaskEnvironmentLive, + resetTaskEnvironment, + type ContainerLiveStatus, + type TaskEnvironment, +} from "@/lib/api/domains/task-environment-api"; +import { ApiError } from "@/lib/api/client"; +import { + getEnvironmentStatusSnapshot, + resolveExecutorEnvironmentStatus, + type EnvironmentStatusSnapshot, +} from "@/components/task/executor-environment-status"; + +const ACTIVE_POLL_INTERVAL_MS = 3000; +const BACKGROUND_POLL_INTERVAL_MS = 7000; + +/** + * Owns the env+container fetch/poll lifecycle and the reset action so the + * popover component stays small. Polls more quickly while the popover is open + * (`active=true`) and less frequently while closed so the toolbar icon can + * still reflect externally stopped/restarted containers. + */ +export function useTaskEnvironment(taskId: string | null | undefined, active: boolean) { + const [env, setEnv] = useState(null); + const [container, setContainer] = useState(null); + const [loading, setLoading] = useState(false); + const [isResetting, setIsResetting] = useState(false); + const inFlight = useRef(false); + const lastStatusRef = useRef(null); + // hasLoadedRef tracks "have we ever fetched successfully" so the spinner + // only shows on the first open. Keeping it in a ref instead of state means + // `loadEnv` doesn't depend on `env` — without that, every successful fetch + // creates a new `env` reference, forces a new `loadEnv` identity, and the + // polling effect's cleanup+rerun fires immediately, turning the 3-second + // poll into a tight loop. + const hasLoadedRef = useRef(false); + + useEffect(() => { + hasLoadedRef.current = false; + lastStatusRef.current = null; + setEnv(null); + setContainer(null); + setLoading(false); + }, [taskId]); + + const updateState = useCallback( + (nextEnv: TaskEnvironment | null, nextContainer: ContainerLiveStatus | null) => { + const nextStatus = getEnvironmentStatusSnapshot(nextEnv, nextContainer); + maybeNotifyEnvironmentStatus(lastStatusRef.current, nextStatus); + lastStatusRef.current = nextStatus; + setEnv(nextEnv); + setContainer(nextContainer); + }, + [], + ); + + const loadEnv = useCallback(async () => { + if (!taskId || inFlight.current) return; + inFlight.current = true; + setLoading((prev) => prev || (active && !hasLoadedRef.current)); + try { + const data = await fetchTaskEnvironmentLive(taskId); + hasLoadedRef.current = true; + updateState(data.environment, data.container ?? null); + } catch (err) { + // Only treat 404 as "no environment yet" — a transient 500 / auth / + // network error should leave the last-known view in place rather than + // erase a valid environment and disable the Reset action. + if (err instanceof ApiError && err.status === 404) { + hasLoadedRef.current = true; + updateState(null, null); + } + } finally { + inFlight.current = false; + setLoading(false); + } + }, [active, taskId, updateState]); + + useEffect(() => { + if (!taskId) return; + void loadEnv(); + const intervalMs = active ? ACTIVE_POLL_INTERVAL_MS : BACKGROUND_POLL_INTERVAL_MS; + const interval = window.setInterval(() => void loadEnv(), intervalMs); + return () => window.clearInterval(interval); + }, [active, taskId, loadEnv]); + + const reset = useCallback( + async ({ pushBranch }: { pushBranch: boolean }) => { + if (!taskId) return false; + setIsResetting(true); + try { + await resetTaskEnvironment(taskId, { push_branch: pushBranch }); + toast.success("Environment reset"); + lastStatusRef.current = getEnvironmentStatusSnapshot(null, null); + setEnv(null); + setContainer(null); + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + toast.error(`Reset failed: ${msg}`); + return false; + } finally { + setIsResetting(false); + } + }, + [taskId], + ); + + const status = useMemo( + () => (env ? resolveExecutorEnvironmentStatus(env, container) : null), + [env, container], + ); + + return { env, container, loading, isResetting, reset, status }; +} + +function maybeNotifyEnvironmentStatus( + prev: EnvironmentStatusSnapshot | null, + next: EnvironmentStatusSnapshot, +) { + if (!prev || prev.key === next.key) return; + if (prev.tone === "running" && next.tone !== "running") { + toast.error("Executor environment stopped", { + description: `Current state: ${next.label}`, + }); + } else if (prev.tone !== "running" && next.tone === "running") { + toast.success("Executor environment running"); + } +}