feat(web): unify executor surfaces in the right-side popover
The desktop task top bar carried a small cloud icon (RemoteCloudTooltip) in the breadcrumb cluster while the right-side Executor popover showed the same kind of info — two surfaces for one concept. Remove the cloud icon from the desktop top bar; kanban cards, task list rows, and the mobile top bar keep theirs since they have no popover. The popover button now picks its icon by executor type (cloud for sprites, container for docker, server for local PC) using a shared getExecutorStatusIcon helper. While the prepare progress is preparing the icon swaps to a spinner so the user can see at a glance that work is in flight. The popover content gains a Preparing-environment section that streams "Step N of M: <step>", an amber inline note when the missing-sandbox fallback is in progress, an Environment-ready row with duration on success, and a failed-step summary on failure. Polling promotes to the active 3s cadence while preparing so the icon flips to ready without the user opening the popover.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionPrepareState } from "@/lib/state/slices/session-runtime/types";
|
||||
|
||||
let mockPrepareState: SessionPrepareState | null = null;
|
||||
let mockEnv: { executor_type: string; sandbox_id?: string; container_id?: string } | null = null;
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
prepareProgress: {
|
||||
bySessionId: mockPrepareState ? { [mockPrepareState.sessionId]: mockPrepareState } : {},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/task-environment-api", () => ({
|
||||
fetchTaskEnvironmentLive: vi.fn().mockImplementation(async () => ({
|
||||
environment: mockEnv ?? { executor_type: "" },
|
||||
container: null,
|
||||
})),
|
||||
resetTaskEnvironment: vi.fn().mockResolvedValue({ success: true }),
|
||||
}));
|
||||
|
||||
vi.mock("./task-reset-env-confirm-dialog", () => ({
|
||||
TaskResetEnvConfirmDialog: () => null,
|
||||
}));
|
||||
|
||||
import { ExecutorSettingsButton } from "./executor-settings-button";
|
||||
|
||||
const SESSION_ID = "session-1";
|
||||
const TASK_ID = "task-1";
|
||||
const STEP_CREATE_SANDBOX = "Creating cloud sandbox";
|
||||
|
||||
describe("ExecutorSettingsButton", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockPrepareState = null;
|
||||
mockEnv = null;
|
||||
});
|
||||
|
||||
it("shows the cloud icon when the executor type is sprites", async () => {
|
||||
mockEnv = { executor_type: "sprites", sandbox_id: "kandev-abc" };
|
||||
|
||||
render(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
|
||||
|
||||
// Wait a tick for the live fetch to resolve.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(await screen.findByTestId("executor-status-cloud-icon")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the container icon for both docker variants", async () => {
|
||||
mockEnv = { executor_type: "local_docker", container_id: "abcdef" };
|
||||
|
||||
render(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(await screen.findByTestId("executor-status-container-icon")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("swaps to a spinner while the prepare progress is preparing", async () => {
|
||||
mockEnv = { executor_type: "sprites", sandbox_id: "kandev-abc" };
|
||||
mockPrepareState = {
|
||||
sessionId: SESSION_ID,
|
||||
status: "preparing",
|
||||
steps: [{ name: STEP_CREATE_SANDBOX, status: "running" }],
|
||||
};
|
||||
|
||||
render(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
|
||||
|
||||
expect(screen.getByTestId("executor-settings-button-spinner")).toBeTruthy();
|
||||
expect(screen.queryByTestId("executor-status-cloud-icon")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the preparing section with current step copy", async () => {
|
||||
mockPrepareState = {
|
||||
sessionId: SESSION_ID,
|
||||
status: "preparing",
|
||||
steps: [
|
||||
{ name: STEP_CREATE_SANDBOX, status: "completed" },
|
||||
{ name: "Uploading agent controller", status: "running" },
|
||||
{ name: "Waiting for agent controller", status: "pending" },
|
||||
],
|
||||
};
|
||||
|
||||
render(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
|
||||
|
||||
// Open the popover by clicking the trigger.
|
||||
const trigger = screen.getByTestId("executor-settings-button");
|
||||
trigger.click();
|
||||
|
||||
expect(await screen.findByTestId("executor-prepare-status")).toHaveProperty(
|
||||
"dataset.phase",
|
||||
"preparing",
|
||||
);
|
||||
expect(screen.getByText(/Step 2 of 3: Uploading agent controller/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the fallback warning callout when the missing-sandbox notice is present", async () => {
|
||||
mockPrepareState = {
|
||||
sessionId: SESSION_ID,
|
||||
status: "preparing",
|
||||
steps: [
|
||||
{
|
||||
name: "Reconnecting cloud sandbox",
|
||||
status: "skipped",
|
||||
warning:
|
||||
"Previous sandbox is no longer available — provisioning a fresh one for this branch.",
|
||||
},
|
||||
{ name: STEP_CREATE_SANDBOX, status: "running" },
|
||||
],
|
||||
};
|
||||
|
||||
render(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
|
||||
screen.getByTestId("executor-settings-button").click();
|
||||
|
||||
const status = await screen.findByTestId("executor-prepare-status");
|
||||
expect(status.dataset.phase).toBe("preparing_fallback");
|
||||
expect(screen.getByTestId("executor-prepare-fallback-warning")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows an Environment ready row once preparation completes", async () => {
|
||||
mockPrepareState = {
|
||||
sessionId: SESSION_ID,
|
||||
status: "completed",
|
||||
steps: [{ name: "Creating cloud sandbox", status: "completed" }],
|
||||
durationMs: 12500,
|
||||
};
|
||||
|
||||
render(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
|
||||
screen.getByTestId("executor-settings-button").click();
|
||||
|
||||
const status = await screen.findByTestId("executor-prepare-status");
|
||||
expect(status.dataset.phase).toBe("ready");
|
||||
expect(screen.getByText(/Environment ready/)).toBeTruthy();
|
||||
expect(screen.getByText(/13s/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { IconBox, IconCopy, IconLoader, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBox,
|
||||
IconCheck,
|
||||
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";
|
||||
@@ -14,6 +22,12 @@ 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,
|
||||
@@ -24,9 +38,22 @@ import {
|
||||
|
||||
type ExecutorSettingsButtonProps = {
|
||||
taskId?: string | null;
|
||||
sessionId?: string | null;
|
||||
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;
|
||||
|
||||
@@ -130,10 +157,104 @@ function useTaskEnvironment(taskId: string | null | undefined, active: boolean)
|
||||
return { env, container, loading, isResetting, reset, status };
|
||||
}
|
||||
|
||||
export function ExecutorSettingsButton({ taskId, disabled }: ExecutorSettingsButtonProps) {
|
||||
/**
|
||||
* 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,
|
||||
disabled,
|
||||
}: ExecutorSettingsButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [resetDialogOpen, setResetDialogOpen] = useState(false);
|
||||
const { env, container, loading, isResetting, reset, status } = useTaskEnvironment(taskId, open);
|
||||
const prepare = usePrepareSummary(sessionId ?? null);
|
||||
const isPreparing = prepare.phase === "preparing" || prepare.phase === "preparing_fallback";
|
||||
// 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(
|
||||
taskId,
|
||||
open || isPreparing,
|
||||
);
|
||||
|
||||
const handleReset = useCallback(
|
||||
async (opts: { pushBranch: boolean }) => {
|
||||
@@ -148,6 +269,9 @@ export function ExecutorSettingsButton({ taskId, disabled }: ExecutorSettingsBut
|
||||
|
||||
if (!taskId) return null;
|
||||
|
||||
const executorType = env?.executor_type ?? null;
|
||||
const ariaLabel = computeAriaLabel(isPreparing, status);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -158,11 +282,9 @@ export function ExecutorSettingsButton({ taskId, disabled }: ExecutorSettingsBut
|
||||
className="relative cursor-pointer px-2"
|
||||
disabled={disabled}
|
||||
data-testid="executor-settings-button"
|
||||
aria-label={
|
||||
status ? `Executor settings, environment ${status.label}` : "Executor settings"
|
||||
}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<IconBox className="h-4 w-4" />
|
||||
<ExecutorButtonIcon executorType={executorType} preparing={isPreparing} />
|
||||
<ExecutorStatusDot status={status} loading={loading} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -171,6 +293,7 @@ export function ExecutorSettingsButton({ taskId, disabled }: ExecutorSettingsBut
|
||||
className="w-[340px] p-0 text-sm"
|
||||
data-testid="executor-settings-popover"
|
||||
>
|
||||
<PrepareStatusSection summary={prepare} />
|
||||
<EnvironmentInfo env={env} container={container} loading={loading} />
|
||||
<div className="border-t border-border p-2 flex items-center justify-end">
|
||||
<Button
|
||||
@@ -198,6 +321,37 @@ export function ExecutorSettingsButton({ taskId, disabled }: ExecutorSettingsBut
|
||||
);
|
||||
}
|
||||
|
||||
function computeAriaLabel(
|
||||
preparing: boolean,
|
||||
status: { label: string; tone: StatusTone } | null,
|
||||
): string {
|
||||
if (preparing) return "Executor settings, preparing environment";
|
||||
if (status) return `Executor settings, environment ${status.label}`;
|
||||
return "Executor settings";
|
||||
}
|
||||
|
||||
function ExecutorButtonIcon({
|
||||
executorType,
|
||||
preparing,
|
||||
}: {
|
||||
executorType: string | null;
|
||||
preparing: boolean;
|
||||
}) {
|
||||
if (preparing) {
|
||||
return (
|
||||
<IconLoader2
|
||||
className="h-4 w-4 animate-spin"
|
||||
data-testid="executor-settings-button-spinner"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!executorType) {
|
||||
return <IconBox className="h-4 w-4" data-testid="executor-status-box-icon" />;
|
||||
}
|
||||
const { Icon, testId } = getExecutorStatusIcon(executorType, false);
|
||||
return <Icon className="h-4 w-4" data-testid={testId} />;
|
||||
}
|
||||
|
||||
function maybeNotifyEnvironmentStatus(
|
||||
prev: EnvironmentStatusSnapshot | null,
|
||||
next: EnvironmentStatusSnapshot,
|
||||
@@ -233,6 +387,90 @@ 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 p-3 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 p-3 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,
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
IconCloud,
|
||||
IconCloudOff,
|
||||
IconContainer,
|
||||
IconContainerOff,
|
||||
IconServer,
|
||||
IconServerOff,
|
||||
} from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { getExecutorStatusIcon } from "@/lib/executor-icons";
|
||||
|
||||
type RemoteStatusData = {
|
||||
remote_name?: string;
|
||||
@@ -82,25 +75,6 @@ const CLOUD_STATE_CLASSES: Record<ReturnType<typeof getCloudState>, string> = {
|
||||
stale: "text-muted-foreground",
|
||||
};
|
||||
|
||||
function getExecutorStatusIcon(executorType: string | null | undefined, hasError: boolean) {
|
||||
if (executorType === "local_docker" || executorType === "remote_docker") {
|
||||
return {
|
||||
Icon: hasError ? IconContainerOff : IconContainer,
|
||||
testId: "executor-status-container-icon",
|
||||
};
|
||||
}
|
||||
if (executorType === "sprites") {
|
||||
return {
|
||||
Icon: hasError ? IconCloudOff : IconCloud,
|
||||
testId: "executor-status-cloud-icon",
|
||||
};
|
||||
}
|
||||
return {
|
||||
Icon: hasError ? IconServerOff : IconServer,
|
||||
testId: "executor-status-server-icon",
|
||||
};
|
||||
}
|
||||
|
||||
export function RemoteCloudTooltip({
|
||||
taskId,
|
||||
sessionId,
|
||||
|
||||
@@ -282,11 +282,6 @@ function buildTaskTopBarProps(params: {
|
||||
isRemoteExecutor: params.remote.isRemoteExecutor,
|
||||
isAgentctlReady: params.agentctlReady,
|
||||
remoteExecutorType: params.remote.remoteExecutorType,
|
||||
remoteExecutorName: params.remote.remoteExecutorName,
|
||||
remoteState: params.remote.remoteState,
|
||||
remoteCreatedAt: params.remote.remoteCreatedAt,
|
||||
remoteCheckedAt: params.remote.remoteCheckedAt,
|
||||
remoteStatusError: params.remote.remoteStatusError,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -94,10 +94,6 @@ vi.mock("@/components/task/branch-path-popover", () => ({
|
||||
BranchPathPopover: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/task/remote-cloud-tooltip", () => ({
|
||||
RemoteCloudTooltip: () => null,
|
||||
}));
|
||||
|
||||
describe("TaskTopBar executor environment controls", () => {
|
||||
it("hides the executor environment button for filesystem executors", () => {
|
||||
render(<TaskTopBar taskId="task-1" remoteExecutorType="worktree" />);
|
||||
|
||||
@@ -34,7 +34,6 @@ import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availabili
|
||||
import { PortForwardButton } from "@/components/task/port-forward-dialog";
|
||||
import { ExecutorSettingsButton } from "@/components/task/executor-settings-button";
|
||||
import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper";
|
||||
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
|
||||
import { QuickChatButton } from "@/components/task/quick-chat-button";
|
||||
import {
|
||||
TopbarActionOverflow,
|
||||
@@ -60,25 +59,11 @@ type TaskTopBarProps = {
|
||||
isArchived?: boolean;
|
||||
isRemoteExecutor?: boolean;
|
||||
isAgentctlReady?: boolean;
|
||||
remoteExecutorName?: string | null;
|
||||
remoteExecutorType?: string | null;
|
||||
remoteState?: string | null;
|
||||
remoteCreatedAt?: string | null;
|
||||
remoteCheckedAt?: string | null;
|
||||
remoteStatusError?: string | null;
|
||||
};
|
||||
|
||||
type TopBarLeftProps = {
|
||||
taskId?: string | null;
|
||||
activeSessionId?: string | null;
|
||||
taskTitle?: string;
|
||||
isRemoteExecutor?: boolean;
|
||||
remoteExecutorName?: string | null;
|
||||
remoteExecutorType?: string | null;
|
||||
remoteState?: string | null;
|
||||
remoteCreatedAt?: string | null;
|
||||
remoteCheckedAt?: string | null;
|
||||
remoteStatusError?: string | null;
|
||||
};
|
||||
|
||||
const TaskTopBar = memo(function TaskTopBar({
|
||||
@@ -94,30 +79,14 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
isArchived,
|
||||
isRemoteExecutor,
|
||||
isAgentctlReady,
|
||||
remoteExecutorName,
|
||||
remoteExecutorType,
|
||||
remoteState,
|
||||
remoteCreatedAt,
|
||||
remoteCheckedAt,
|
||||
remoteStatusError,
|
||||
}: TaskTopBarProps) {
|
||||
return (
|
||||
<header
|
||||
data-testid="task-topbar"
|
||||
className="@container/topbar grid grid-cols-[minmax(0,1fr)_minmax(0,auto)_minmax(0,1fr)] items-center gap-2 overflow-hidden px-3 py-1 border-b border-border"
|
||||
>
|
||||
<TopBarLeft
|
||||
taskId={taskId}
|
||||
activeSessionId={activeSessionId}
|
||||
taskTitle={taskTitle}
|
||||
isRemoteExecutor={isRemoteExecutor}
|
||||
remoteExecutorName={remoteExecutorName}
|
||||
remoteExecutorType={remoteExecutorType}
|
||||
remoteState={remoteState}
|
||||
remoteCreatedAt={remoteCreatedAt}
|
||||
remoteCheckedAt={remoteCheckedAt}
|
||||
remoteStatusError={remoteStatusError}
|
||||
/>
|
||||
<TopBarLeft taskTitle={taskTitle} />
|
||||
<div className="min-w-0 justify-self-center overflow-hidden">
|
||||
{workflowSteps && workflowSteps.length > 0 && (
|
||||
<WorkflowStepper
|
||||
@@ -179,19 +148,8 @@ function IssueTrackerButtons({
|
||||
);
|
||||
}
|
||||
|
||||
/** Left section: home → task name breadcrumb, integrations menu, remote indicator */
|
||||
function TopBarLeft({
|
||||
taskId,
|
||||
activeSessionId,
|
||||
taskTitle,
|
||||
isRemoteExecutor,
|
||||
remoteExecutorName,
|
||||
remoteExecutorType,
|
||||
remoteState,
|
||||
remoteCreatedAt,
|
||||
remoteCheckedAt,
|
||||
remoteStatusError,
|
||||
}: TopBarLeftProps) {
|
||||
/** Left section: home → task name breadcrumb, integrations menu */
|
||||
function TopBarLeft({ taskTitle }: TopBarLeftProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
|
||||
<Breadcrumb className="min-w-0">
|
||||
@@ -221,23 +179,6 @@ function TopBarLeft({
|
||||
</Breadcrumb>
|
||||
|
||||
<IntegrationsMenu />
|
||||
|
||||
{isRemoteExecutor && (
|
||||
<RemoteCloudTooltip
|
||||
taskId={taskId ?? ""}
|
||||
sessionId={activeSessionId}
|
||||
executorType={remoteExecutorType}
|
||||
fallbackName={remoteExecutorName ?? remoteExecutorType}
|
||||
iconClassName="h-4 w-4"
|
||||
status={{
|
||||
remote_name: remoteExecutorName ?? undefined,
|
||||
remote_state: remoteState ?? undefined,
|
||||
remote_created_at: remoteCreatedAt ?? undefined,
|
||||
remote_checked_at: remoteCheckedAt ?? undefined,
|
||||
remote_status_error: remoteStatusError ?? undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -350,7 +291,9 @@ function AttentionStatusGroup({
|
||||
<DocumentControls activeSessionId={activeSessionId ?? null} />
|
||||
{!isArchived && (
|
||||
<>
|
||||
{showExecutorSettings && <ExecutorSettingsButton taskId={taskId} />}
|
||||
{showExecutorSettings && (
|
||||
<ExecutorSettingsButton taskId={taskId} sessionId={activeSessionId ?? null} />
|
||||
)}
|
||||
<PortForwardButton
|
||||
isRemoteExecutor={isRemoteExecutor}
|
||||
sessionId={activeSessionId}
|
||||
|
||||
Reference in New Issue
Block a user