fix: refresh executor indicators and restore status details (#3821)

This commit is contained in:
José Almeida
2026-09-19 19:14:25 +01:00
committed by GitHub
parent 9e48a71dbe
commit 390ca6adc8
17 changed files with 889 additions and 76 deletions
@@ -0,0 +1,59 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { RemoteCloudTooltip } from "./remote-cloud-tooltip";
const pointer = vi.hoisted(() => ({ touch: false }));
vi.mock("@/hooks/use-compact-task-chrome", () => ({ useTouchDrawer: () => pointer.touch }));
afterEach(() => {
cleanup();
pointer.touch = false;
});
// @covers AC-EXECUTORS-TASK-STATUS-001.3
it("links the actual tooltip to the focused DOM trigger and dismisses on Escape", async () => {
render(
<TooltipProvider>
<RemoteCloudTooltip
taskId="task"
executorType="k8s"
status={{ remote_name: "test-pod", remote_state: "running" }}
/>
</TooltipProvider>,
);
const trigger = screen.getByTestId("remote-executor-status-trigger");
act(() => trigger.focus());
const tooltip = await screen.findByRole("tooltip");
expect(trigger.getAttribute("aria-describedby")).toBe(tooltip.id);
fireEvent.keyDown(trigger, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull());
});
// @covers AC-EXECUTORS-TASK-STATUS-001.4
// Reviewer-requested coverage of the existing click and keyboard activation contract.
it.each(["click", "Enter", " "])(
"opens and closes the real drawer via %s without selecting its task",
async (activation) => {
pointer.touch = true;
const selectTask = vi.fn();
render(
<div onClick={selectTask}>
<RemoteCloudTooltip
taskId="task"
executorType="k8s"
status={{ remote_name: "touch-pod", remote_state: "running" }}
/>
</div>,
);
const trigger = screen.getByTestId("remote-executor-status-trigger");
act(() => trigger.focus());
if (activation === "click") fireEvent.click(trigger);
else fireEvent.keyDown(trigger, { key: activation });
const drawer = await screen.findByRole("dialog");
expect(trigger.getAttribute("aria-controls")).toBe(drawer.id);
expect(selectTask).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => expect(trigger.getAttribute("aria-expanded")).toBe("false"));
expect(selectTask).not.toHaveBeenCalled();
},
);
@@ -16,7 +16,7 @@ vi.mock("@/lib/api/domains/kubernetes-api", () => ({
}));
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({ request: mocks.wsRequest }),
getWebSocketClient: () => ({ request: mocks.wsRequest, getStatus: () => "connected" }),
}));
beforeEach(() => {
@@ -2,6 +2,7 @@
import {
useState,
type ComponentPropsWithRef,
type KeyboardEvent,
type MouseEvent,
type ReactNode,
@@ -35,7 +36,7 @@ type RemoteCloudTooltipProps = {
executorType?: string | null;
fallbackName?: string | null;
iconClassName?: string;
/** When provided, uses this data directly instead of fetching via WS on hover. */
/** When provided, uses this data directly instead of subscribing to live status. */
status?: RemoteExecutorStatusData | null;
};
@@ -55,7 +56,7 @@ const CLOUD_STATE_CLASSES: Record<ReturnType<typeof getCloudState>, string> = {
stale: "text-muted-foreground",
};
type StatusTriggerProps = {
type StatusTriggerProps = Omit<ComponentPropsWithRef<"span">, "onFocus" | "onBlur"> & {
touch: boolean;
expanded: boolean;
label: string;
@@ -83,10 +84,12 @@ function StatusTrigger({
onBlur,
onTouchClick,
onTouchKeyDown,
...triggerProps
}: StatusTriggerProps) {
const Icon = icon.Icon;
return (
<span
{...triggerProps}
data-testid="remote-executor-status-trigger"
role={touch ? "button" : "img"}
tabIndex={0}
@@ -97,11 +100,18 @@ function StatusTrigger({
onPointerLeave={onPointerLeave}
onFocus={onFocus}
onBlur={onBlur}
onClick={onTouchClick}
onKeyDown={onTouchKeyDown}
onClick={(event) => {
onTouchClick?.(event);
triggerProps.onClick?.(event);
}}
onKeyDown={(event) => {
onTouchKeyDown?.(event);
triggerProps.onKeyDown?.(event);
}}
className={cn(
"relative inline-flex shrink-0 items-center justify-center",
CLOUD_STATE_CLASSES[cloudState],
triggerProps.className,
touch &&
"cursor-pointer transition-transform duration-150 ease-out active:scale-[0.96] after:absolute after:left-1/2 after:top-1/2 after:size-11 after:-translate-x-1/2 after:-translate-y-1/2 after:content-['']",
)}
@@ -28,6 +28,19 @@ async function expectStatusFailure(icon: Locator, page: Page) {
await trigger.hover();
const tooltip = openTooltip(page);
await expect(tooltip.getByTestId("remote-executor-status-summary")).toBeVisible();
const panel = page.locator('[data-slot="tooltip-content"]:not([data-state="closed"])');
const panelBox = await panel.boundingBox();
const triggerBox = await trigger.boundingBox();
expect(panelBox).not.toBeNull();
expect(triggerBox).not.toBeNull();
expect(panelBox!.x).toBeGreaterThanOrEqual(0);
expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(page.viewportSize()!.width);
expect(
Math.min(
Math.abs(panelBox!.y + panelBox!.height - triggerBox!.y),
Math.abs(triggerBox!.y + triggerBox!.height - panelBox!.y),
),
).toBeLessThan(30);
await expect(tooltip.getByTestId("remote-executor-status-identity")).toHaveText(INITIAL_POD_NAME);
await expect(tooltip.getByTestId("remote-executor-status-error")).toContainText("Unauthorized");
}
@@ -39,6 +52,19 @@ async function expectStatusHealthy(icon: Locator, page: Page) {
await trigger.hover();
const tooltip = openTooltip(page);
await expect(tooltip.getByTestId("remote-executor-status-summary")).toBeVisible();
const panel = page.locator('[data-slot="tooltip-content"]:not([data-state="closed"])');
const panelBox = await panel.boundingBox();
const triggerBox = await trigger.boundingBox();
expect(panelBox).not.toBeNull();
expect(triggerBox).not.toBeNull();
expect(panelBox!.x).toBeGreaterThanOrEqual(0);
expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(page.viewportSize()!.width);
expect(
Math.min(
Math.abs(panelBox!.y + panelBox!.height - triggerBox!.y),
Math.abs(triggerBox!.y + triggerBox!.height - panelBox!.y),
),
).toBeLessThan(30);
await expect(tooltip.getByTestId("remote-executor-status-identity")).toHaveText(INITIAL_POD_NAME);
await expect(tooltip.getByTestId("remote-executor-status-state")).toContainText("running");
await expect(tooltip.getByTestId("remote-executor-status-restarts")).toContainText("3");
@@ -146,6 +172,7 @@ test("Kubernetes task icons hydrate eagerly and publish the structured Pod summa
},
);
await testPage.clock.install();
const kanban = new KanbanPage(testPage);
let readsBeforeRender = sessionReads;
await kanban.goto();
@@ -154,7 +181,8 @@ test("Kubernetes task icons hydrate eagerly and publish the structured Pod summa
await expectStatusFailure(kanbanIcon, testPage);
statusUnauthorized = false;
readsBeforeRender = sessionReads;
await testPage.reload();
await testPage.mouse.move(0, 0);
await testPage.clock.fastForward(90_001);
await expectEagerStatusRead(() => sessionReads, readsBeforeRender);
await expectStatusHealthy(
kanban.taskCard(task.id).getByTestId("executor-status-kubernetes-icon"),
@@ -175,8 +203,8 @@ test("Kubernetes task icons hydrate eagerly and publish the structured Pod summa
);
statusUnauthorized = false;
readsBeforeRender = sessionReads;
await testPage.reload();
await session.waitForLoad(30_000);
await testPage.mouse.move(0, 0);
await testPage.clock.fastForward(90_001);
await expectEagerStatusRead(() => sessionReads, readsBeforeRender);
await expectStatusHealthy(
session.sidebar
@@ -52,6 +52,7 @@ test("Kubernetes task disclosure exposes live Pod details and safe actions by to
let taskId = "";
let navigationTaskId = "";
let sessionReads = 0;
let statusUnauthorized = true;
try {
const task = await apiClient.createTaskWithAgent(
@@ -110,6 +111,7 @@ test("Kubernetes task disclosure exposes live Pod details and safe actions by to
pod_phase: "Running",
container_state: "running",
restarts: 0,
failure_reason: statusUnauthorized ? "Unauthorized" : undefined,
workspace_kind: "empty_dir",
created_at: "2026-08-25T10:00:00Z",
},
@@ -118,6 +120,7 @@ test("Kubernetes task disclosure exposes live Pod details and safe actions by to
},
);
await testPage.clock.install();
await testPage.goto(`/t/${navigationTaskId}`);
await new SessionPage(testPage).waitForLoad(30_000);
@@ -126,6 +129,11 @@ test("Kubernetes task disclosure exposes live Pod details and safe actions by to
const taskRow = taskSwitcher.locator(`[data-task-row-id="${task.id}"]`);
const statusTrigger = taskRow.getByTestId("remote-executor-status-trigger");
await expect.poll(() => sessionReads).toBeGreaterThan(0);
await expect(statusTrigger).toHaveClass(/text-destructive/);
statusUnauthorized = false;
const beforeRefresh = sessionReads;
await testPage.clock.fastForward(90_001);
await expect.poll(() => sessionReads).toBeGreaterThan(beforeRefresh);
await expect(statusTrigger).toHaveClass(/text-emerald-500/);
await expect(statusTrigger).toHaveAttribute("aria-haspopup", "dialog");
await expectExpandedTouchTarget(statusTrigger, "Mobile task executor status action");
@@ -140,6 +148,8 @@ test("Kubernetes task disclosure exposes live Pod details and safe actions by to
await expect(testPage).toHaveURL(new RegExp(`/t/${navigationTaskId}$`));
await statusDrawer.getByRole("button", { name: "Close" }).tap();
await expect(statusDrawer).toBeHidden();
await expect(statusTrigger).toBeFocused();
await assertNoDocumentHorizontalOverflow(testPage);
await expect(testPage).toHaveURL(new RegExp(`/t/${navigationTaskId}$`));
await testPage.goto(`/t/${task.id}`);
await new SessionPage(testPage).waitForLoad(30_000);
@@ -0,0 +1,45 @@
type RefreshEntry = {
timer?: ReturnType<typeof setTimeout>;
dueAt: number;
refresh: () => void;
};
/** Schedules only subscribed scopes while the document is visible. */
export function createRemoteExecutorStatusRefresh(interval: number) {
const active = new Map<string, RefreshEntry>();
function schedule(entry: RefreshEntry) {
clearTimeout(entry.timer);
if (typeof document === "undefined" || document.visibilityState === "hidden") return;
entry.timer = setTimeout(entry.refresh, Math.max(0, entry.dueAt - Date.now()));
}
function onVisibilityChange() {
active.forEach(schedule);
}
return {
activate(scope: string, refresh: () => void, dueAt: number) {
if (active.has(scope)) return;
if (active.size === 0 && typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibilityChange);
}
const entry = { refresh, dueAt };
active.set(scope, entry);
schedule(entry);
},
settled(scope: string) {
const entry = active.get(scope);
if (!entry) return;
entry.dueAt = Date.now() + interval;
schedule(entry);
},
deactivate(scope: string) {
clearTimeout(active.get(scope)?.timer);
active.delete(scope);
if (active.size === 0 && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibilityChange);
}
},
};
}
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createRemoteExecutorStatusResource,
remoteExecutorStatusScope,
type RemoteExecutorStatusData,
type RemoteExecutorStatusRequest,
} from "./remote-executor-status-resource";
@@ -117,3 +118,159 @@ describe("remote executor status resource", () => {
expect(requester).toHaveBeenCalledTimes(130);
});
});
// @covers AC-EXECUTORS-TASK-STATUS-001.1 and AC-EXECUTORS-TASK-STATUS-001.2
describe("mounted status refresh", () => {
it("refreshes shared consumers without interaction and stops after the last unsubscribe", async () => {
vi.useFakeTimers();
const requester = vi
.fn()
.mockResolvedValueOnce(healthyStatus("old"))
.mockResolvedValue(healthyStatus("new"));
const resource = createRemoteExecutorStatusResource(requester);
const scope = remoteExecutorStatusScope(REQUEST);
const first = resource.subscribe(scope, () => undefined);
const second = resource.subscribe(scope, () => undefined);
try {
await resource.load(REQUEST);
first();
await vi.advanceTimersByTimeAsync(90_000);
expect(resource.getSnapshot(scope).status?.remote_name).toBe("new");
expect(requester).toHaveBeenCalledTimes(2);
second();
await vi.advanceTimersByTimeAsync(180_000);
expect(requester).toHaveBeenCalledTimes(2);
} finally {
first();
second();
}
});
it("retries unavailable transport and failed reads automatically", async () => {
vi.useFakeTimers();
const requester = vi
.fn()
.mockReturnValueOnce(null)
.mockRejectedValueOnce(new Error("secret"))
.mockResolvedValue(healthyStatus("recovered"));
const resource = createRemoteExecutorStatusResource(requester);
const scope = remoteExecutorStatusScope(REQUEST);
const stop = resource.subscribe(scope, () => undefined);
try {
await resource.load(REQUEST);
await vi.advanceTimersByTimeAsync(90_000);
expect(resource.getSnapshot(scope).status?.remote_status_error).toBe(
"Remote executor status is unavailable.",
);
await vi.advanceTimersByTimeAsync(90_000);
expect(resource.getSnapshot(scope).status?.remote_name).toBe("recovered");
} finally {
stop();
}
});
it("pauses hidden documents and refreshes expired scopes on return", async () => {
vi.useFakeTimers();
const requester = vi.fn().mockResolvedValue(healthyStatus("pod"));
const resource = createRemoteExecutorStatusResource(requester);
const stop = resource.subscribe(remoteExecutorStatusScope(REQUEST), () => undefined);
const visibility = vi.spyOn(document, "visibilityState", "get");
try {
await resource.load(REQUEST);
visibility.mockReturnValue("hidden");
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(180_000);
expect(requester).toHaveBeenCalledTimes(1);
visibility.mockReturnValue("visible");
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(0);
expect(requester).toHaveBeenCalledTimes(2);
} finally {
stop();
visibility.mockRestore();
}
});
});
describe("refresh lifecycle cleanup", () => {
it("joins an in-flight refresh and resets the schedule after settlement", async () => {
vi.useFakeTimers();
let resolve!: (value: RemoteExecutorStatusData) => void;
const requester = vi
.fn()
.mockResolvedValueOnce(healthyStatus("initial"))
.mockImplementationOnce(
() =>
new Promise<RemoteExecutorStatusData>((done) => {
resolve = done;
}),
)
.mockResolvedValue(healthyStatus("latest"));
const resource = createRemoteExecutorStatusResource(requester);
const scope = remoteExecutorStatusScope(REQUEST);
const stop = resource.subscribe(scope, () => undefined);
try {
await resource.load(REQUEST);
await vi.advanceTimersByTimeAsync(90_000);
const pending = resource.load(REQUEST, true);
expect(requester).toHaveBeenCalledTimes(2);
expect(resource.getSnapshot(scope).status?.remote_name).toBe("initial");
await vi.advanceTimersByTimeAsync(180_000);
expect(requester).toHaveBeenCalledTimes(2);
resolve(healthyStatus("updated"));
await pending;
await vi.advanceTimersByTimeAsync(89_999);
expect(requester).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1);
expect(resource.getSnapshot(scope).status?.remote_name).toBe("latest");
} finally {
stop();
}
});
it("preserves fresh results on visibility return and detaches its listener on cleanup", async () => {
vi.useFakeTimers();
const requester = vi.fn().mockResolvedValue(healthyStatus("pod"));
const resource = createRemoteExecutorStatusResource(requester);
const stop = resource.subscribe(remoteExecutorStatusScope(REQUEST), () => undefined);
const visibility = vi.spyOn(document, "visibilityState", "get");
const removed = vi.spyOn(document, "removeEventListener");
try {
await resource.load(REQUEST);
visibility.mockReturnValue("hidden");
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(30_000);
visibility.mockReturnValue("visible");
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(59_999);
expect(requester).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(requester).toHaveBeenCalledTimes(2);
stop();
expect(removed).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
} finally {
stop();
visibility.mockRestore();
removed.mockRestore();
}
});
it("does not restart polling when an abandoned read settles", async () => {
vi.useFakeTimers();
let resolve!: (value: RemoteExecutorStatusData) => void;
const requester = vi.fn(
() =>
new Promise<RemoteExecutorStatusData>((done) => {
resolve = done;
}),
);
const resource = createRemoteExecutorStatusResource(requester);
const stop = resource.subscribe(remoteExecutorStatusScope(REQUEST), () => undefined);
const pending = resource.load(REQUEST);
stop();
resolve(healthyStatus("old scope"));
await pending;
await vi.advanceTimersByTimeAsync(180_000);
expect(requester).toHaveBeenCalledTimes(1);
});
});
@@ -1,3 +1,4 @@
import { createRemoteExecutorStatusRefresh } from "./remote-executor-status-refresh";
import { getKubernetesTaskSession } from "@/lib/api/domains/kubernetes-api";
import { t } from "@/lib/i18n";
import type { KubernetesSession } from "@/lib/types/http-kubernetes";
@@ -28,6 +29,7 @@ export type RemoteExecutorStatusSnapshot = {
type Requester = (request: RemoteExecutorStatusRequest) => Promise<RemoteExecutorStatusData> | null;
type Entry = {
request?: RemoteExecutorStatusRequest;
snapshot: RemoteExecutorStatusSnapshot;
promise: Promise<RemoteExecutorStatusSnapshot> | null;
listeners: Set<() => void>;
@@ -77,12 +79,12 @@ function requestRemoteStatus(
if (executorType === "k8s" && executorId) {
return getKubernetesTaskSession(executorId, taskId, sessionId).then(projectKubernetesStatus);
}
return (
getWebSocketClient()?.request<RemoteExecutorStatusData>(
"task.session.status",
{ task_id: taskId, session_id: sessionId },
10000,
) ?? null
const client = getWebSocketClient();
if (client?.getStatus() !== "connected") return null;
return client.request<RemoteExecutorStatusData>(
"task.session.status",
{ task_id: taskId, session_id: sessionId },
10000,
);
}
@@ -104,38 +106,50 @@ function notify(entry: Entry): void {
entry.listeners.forEach((listener) => listener());
}
function publishUnavailable(entry: Entry): void {
entry.snapshot = { status: statusError(), loading: false };
entry.failed = true;
entry.completedAt = 0;
notify(entry);
}
function prune(entries: Map<string, Entry>, current: Entry): void {
const candidates = [...entries.entries()]
.filter(([, entry]) => entry !== current && !entry.promise && entry.listeners.size === 0)
.sort((left, right) => left[1].lastUsed - right[1].lastUsed);
for (const [scope] of candidates) {
if (entries.size <= MAX_ENTRIES) break;
entries.delete(scope);
}
}
function createEntry(): Entry {
return {
snapshot: EMPTY_SNAPSHOT,
promise: null,
listeners: new Set(),
completedAt: 0,
failed: false,
lastUsed: 0,
};
}
export function createRemoteExecutorStatusResource(requester: Requester = requestRemoteStatus) {
const entries = new Map<string, Entry>();
let usageCounter = 0;
const refresh = createRemoteExecutorStatusRefresh(SUCCESS_FRESHNESS_MS);
function touch(entry: Entry): void {
entry.lastUsed = ++usageCounter;
}
function prune(current: Entry): void {
const candidates = [...entries.entries()]
.filter(([, entry]) => entry !== current && !entry.promise && entry.listeners.size === 0)
.sort((left, right) => left[1].lastUsed - right[1].lastUsed);
for (const [scope] of candidates) {
if (entries.size <= MAX_ENTRIES) break;
entries.delete(scope);
}
}
function entryFor(scope: string): Entry {
const existing = entries.get(scope);
if (existing) {
touch(existing);
return existing;
}
const entry: Entry = {
snapshot: EMPTY_SNAPSHOT,
promise: null,
listeners: new Set(),
completedAt: 0,
failed: false,
lastUsed: 0,
};
const entry = createEntry();
entries.set(scope, entry);
touch(entry);
return entry;
@@ -146,14 +160,21 @@ export function createRemoteExecutorStatusResource(requester: Requester = reques
force = false,
): Promise<RemoteExecutorStatusSnapshot> | null {
if (!isValidRemoteExecutorStatusRequest(request)) return null;
const entry = entryFor(remoteExecutorStatusScope(request));
const scope = remoteExecutorStatusScope(request);
const entry = entryFor(scope);
entry.request = request;
if (entry.listeners.size) activate(scope, entry);
if (entry.promise) return entry.promise;
const successIsFresh = Date.now() - entry.completedAt < SUCCESS_FRESHNESS_MS;
if (!force && entry.snapshot.status && !entry.failed && successIsFresh) {
return Promise.resolve(entry.snapshot);
}
const pending = requester(request);
if (!pending) return null;
if (!pending) {
publishUnavailable(entry);
refresh.settled(scope);
return null;
}
entry.snapshot = { status: entry.snapshot.status, loading: true };
notify(entry);
const promise = pending
@@ -173,16 +194,29 @@ export function createRemoteExecutorStatusResource(requester: Requester = reques
entry.completedAt = failed ? 0 : Date.now();
touch(entry);
notify(entry);
prune(entry);
prune(entries, entry);
return snapshot;
})
.finally(() => {
if (entry.promise === promise) entry.promise = null;
refresh.settled(scope);
});
entry.promise = promise;
return promise;
}
function activate(scope: string, entry: Entry) {
if (!entry.request) return;
const request = entry.request;
refresh.activate(
scope,
() => void load(request, true),
entry.completedAt
? entry.completedAt + SUCCESS_FRESHNESS_MS
: Date.now() + SUCCESS_FRESHNESS_MS,
);
}
return {
getSnapshot(scope: string): RemoteExecutorStatusSnapshot {
if (!scope) return EMPTY_SNAPSHOT;
@@ -196,7 +230,11 @@ export function createRemoteExecutorStatusResource(requester: Requester = reques
if (!scope) return () => undefined;
const entry = entryFor(scope);
entry.listeners.add(listener);
return () => entry.listeners.delete(listener);
activate(scope, entry);
return () => {
entry.listeners.delete(listener);
if (entry.listeners.size === 0) refresh.deactivate(scope);
};
},
};
}
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createRemoteExecutorStatusResource,
remoteExecutorStatusScope,
} from "./remote-executor-status-resource";
const mocks = vi.hoisted(() => ({
getStatus: vi.fn(() => "disconnected"),
request: vi.fn().mockResolvedValue({ remote_state: "running" }),
}));
vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: () => mocks }));
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
describe("remote executor status transport", () => {
it("does not queue offline polling reads and resumes after reconnect", async () => {
vi.useFakeTimers();
const request = {
executorType: "sprites",
executorId: "executor",
taskId: "task",
sessionId: "session",
};
const resource = createRemoteExecutorStatusResource();
const scope = remoteExecutorStatusScope(request);
const unsubscribe = resource.subscribe(scope, () => undefined);
try {
resource.load(request);
expect(resource.getSnapshot(scope).status?.remote_status_error).toBe(
"Remote executor status is unavailable.",
);
await vi.advanceTimersByTimeAsync(270_000);
expect(mocks.request).not.toHaveBeenCalled();
expect(resource.getSnapshot(scope).loading).toBe(false);
mocks.getStatus.mockReturnValue("connected");
await vi.advanceTimersByTimeAsync(90_000);
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(resource.getSnapshot(scope).status?.remote_state).toBe("running");
} finally {
unsubscribe();
}
});
});
+167
View File
@@ -0,0 +1,167 @@
---
created: 2026-09-19
status: complete
requirements:
- REQ-EXECUTORS-TASK-STATUS-001
system_design:
- ../../specs/executors/system-design/task-status-indicators.md
legacy_specs:
- ../../specs/kubernetes-executor/spec.md
---
# Implementation Plan: Sidebar executor status
## Overview and evidence
Source tracing establishes two defects: `useRemoteExecutorStatus` loads on mount
or identity change only, and the resource has no timer or visibility listener.
Hover/focus explicitly refreshes, explaining a mounted icon staying stale until
interaction. `StatusTrigger` destructures a fixed prop list and drops the Radix
DOM ref and injected properties. The component tests mock Tooltip/Drawer as
fragments, so they do not verify anchoring or real trigger linkage. This is a
confirmed composition defect; its exact rendered symptom still requires E2E.
At design time frontend dependencies were absent. Implementation installed them
and verified the real primitive linkage and browser behavior below.
The earlier completed
[preview repair](../kubernetes-executor-task-status-preview-repair/plan.md)
introduced eager hydration and explicitly excluded periodic polling. This package
supersedes that exclusion for mounted consumers in visible documents and repairs
primitive composition. Its historical test results are not evidence for this fix.
## Scope and technical approach
One sequential vertical work order repairs the shared resource lifecycle and
trigger composition, adds real-primitive and lifecycle regressions, and verifies
desktop/mobile flows. Reuse existing summaries, localized labels, exact-scope
requests, and safe error projection. No new API, executor lifecycle behavior,
settings UI or unrelated tooltip refactor is in scope.
## ASCII UI preview
UI-01: Sidebar indicator, desktop hover/focus (AC .1/.3).
```text
Task title [executor glyph]
+---------------------------+
| Pod / executor name |
| State Running |
| Restarts 0 |
| Workspace Persistent |
| Last check just now |
+---------------------------+
```
UI-02: Phone task-list indicator, tapped (AC .4).
```text
Task list remains behind drawer
+--------------------------------+
| Executor name [X] | fixed header
| State / restarts / workspace | scrolling body
| Created / last check |
| Error or updating, when needed |
+--------------------------------+
safe area
```
The anchored desktop surface and short touch drawer are required. Labels and
spacing are illustrative and localized. Loading retains facts; failures use a
text row. Preserve dense desktop row height and a non-overlapping 44 px touch
hit area. No hover is required for ongoing status updates.
## Tests
AC .1/.2: extend `remote-executor-status-resource.test.ts` with fake-clock cases
for no-interaction refresh, sharing, failed reads, absent transport, visibility,
unmount and obsolete scope settlement. Assert request counts as well as outcomes.
AC .3/.4: new `remote-cloud-tooltip-primitives.test.tsx` uses actual TooltipProvider,
Tooltip and Drawer; asserts trigger aria linkage, focus opening, Escape, content
and focus return. Existing mocked tests remain useful for summary projections.
## E2E tests
Extend existing `settings/kubernetes-task-environment.spec.ts` and
`settings/mobile-kubernetes-task-environment.spec.ts`. Change the exact-session
response while mounted, advance controlled time, await the causal request, and
assert tone before pointer interaction. Desktop hover/focus must show a panel
anchored near the icon, inside the viewport. Mobile taps must open the drawer,
show the same facts and preserve task selection on dismissal. Verify hit area,
adjacent controls, internal scroll, safe area and no horizontal overflow.
## Work orders
- [x] [Task 01: Refresh and disclose executor status](task-01-refresh-and-disclose.md)
## Verification commands
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/web && pnpm exec vitest run hooks/domains/session/remote-executor-status-transport.test.ts hooks/domains/session/remote-executor-status-resource.test.ts components/task/remote-cloud-tooltip.test.tsx components/task/remote-cloud-tooltip-primitives.test.tsx)
(cd apps/web && NODE_OPTIONS=--max-old-space-size=3072 pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && PATH=/usr/local/go/bin:$PATH pnpm e2e:run --host --project=chromium tests/settings/kubernetes-task-environment.spec.ts -- --retries=0)
(cd apps/web && PATH=/usr/local/go/bin:$PATH pnpm e2e:run --host --no-build --project=mobile-chrome tests/settings/mobile-kubernetes-task-environment.spec.ts -- --retries=0)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
## Verification results
Implementation and targeted verification complete.
- RED: no-interaction refresh, automatic retry, and visibility-return tests
failed on stale/missing status. The real Tooltip regression failed because
the DOM trigger had no `aria-describedby` linkage.
- GREEN: the listed focused Vitest command passes 29 tests in 4 files.
- Changed-file ESLint with `--max-warnings=0` passes.
- Fresh managed production build passes. Desktop Chromium E2E: 1/1, 4.3 seconds;
mobile Chrome E2E: 1/1, 5.7 seconds; both with `--retries=0`. These verify
automatic updates before interaction, anchored desktop details, touch drawer
facts, no task selection, focus return, and horizontal containment.
- `pnpm run i18n:check` passes. Public-doc validation passes for 47 pages.
Catalog validation, specification lint, and `git diff --check` pass.
- Full `pnpm run typecheck` could not complete: Node exhausted both its default
2 GB heap and a 3 GB heap in this 4 GB environment. This remains an explicit
full-project validation limitation, not a passing check.
- Focused typecheck passes with the repository tsconfig, changed production/test
roots, actual transitive dependencies, web ambient declaration files, and
explicit Node/React ambient types. It used `/tmp/executor-status-tsconfig.json`
with incremental checking disabled and a 3 GB heap. No compiler errors were
suppressed. This does not replace full-project typecheck evidence.
- Ready PR delivery was explicitly requested after user testing. Full-project
typecheck remains an explicit validation limitation for CI.
Managed E2E instances and the isolated user preview were stopped; the main
instance was untouched. Generated typecheck crash dumps were removed.
## Risks
- Polling scales with distinct mounted scopes; share requests, stop on hidden
documents/unmount, and preserve existing cache bounds.
- Real primitives may expose additional focus/portal issues hidden by mocks.
- Browser throttling can delay timers; returning to visibility must recover.
## Public documentation
At implementation, update `docs/public/k8s.md` and `docs/public/executors.md`
with automatic refresh while visible. Preserve their how-to/reference structure.
No public claim changes during this design-only turn.
### PR review remediation
Greptile identified obsolete queued WebSocket reads during outages. The default
requester now treats a disconnected client as unavailable transport. The new
transport regression failed with four offline requests before the guard, then
passed with zero offline reads and a healthy result after reconnect. All 27
focused tests and changed-file ESLint pass. No UI, API or public-doc contract
changed; the system design records the transport guard. Remote CI/review
verification remains pending for the remediation commit.
CodeRabbit summary findings: unavailable transport now immediately publishes the
sanitized unavailable snapshot; primitive classes and touch handlers are
composed. The unavailable-state assertion failed before the fix. Actual-drawer
coverage now exercises click, Enter and Space, with no task selection. All 29
focused tests and changed-file lint pass; mobile E2E passes 1/1 without retries
after a fresh build. Desktop E2E also passes 1/1 without retries, and the
focused TypeScript check passes.
@@ -0,0 +1,160 @@
---
id: "01-refresh-and-disclose"
title: "Refresh and disclose executor status"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-EXECUTORS-TASK-STATUS-001
acceptance_criteria:
- AC-EXECUTORS-TASK-STATUS-001.1
- AC-EXECUTORS-TASK-STATUS-001.2
- AC-EXECUTORS-TASK-STATUS-001.3
- AC-EXECUTORS-TASK-STATUS-001.4
system_design:
- ../../specs/executors/system-design/task-status-indicators.md
---
# Task 01: Refresh and disclose executor status
## Summary and scope
Implement the [plan](plan.md) as one sequential vertical repair. Own refresh
lifecycle, primitive composition, regression coverage and public documentation.
Exclude backend/API changes, executor lifecycle and unrelated tooltip consumers.
## Acceptance
1. Fake-clock tests first fail on the missing automatic refresh, then pass with
one lifecycle per exact scope, including failures, visibility and cleanup.
2. Real-primitive regression first fails on missing trigger linkage; after the
correction desktop details anchor to the glyph and mobile focus returns.
3. Desktop/mobile E2E prove fresh status without hover, visible details and
touch containment without accidental task selection.
## ASCII UI preview
UI-01: Sidebar indicator, desktop hover/focus (AC .1/.3).
```text
Task title [executor glyph]
+---------------------------+
| Pod / executor name |
| State Running |
| Restarts 0 |
| Workspace Persistent |
| Last check just now |
+---------------------------+
```
UI-02: Phone task-list indicator, tapped (AC .4).
```text
Task list remains behind drawer
+--------------------------------+
| Executor name [X] | fixed header
| State / restarts / workspace | scrolling body
| Created / last check |
| Error or updating, when needed |
+--------------------------------+
safe area
```
The anchored desktop surface and short touch drawer are required. Labels and
spacing are illustrative and localized. Loading retains facts; failures use a
text row. Preserve dense desktop row height and a non-overlapping 44 px touch
hit area. No hover is required for ongoing status updates.
Full composition and evidence: [plan](plan.md#ascii-ui-preview).
## Verification
Run tests red before production edits, then all checks below after correction.
The new primitive test file is created by this work order.
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/web && pnpm exec vitest run hooks/domains/session/remote-executor-status-transport.test.ts hooks/domains/session/remote-executor-status-resource.test.ts components/task/remote-cloud-tooltip.test.tsx components/task/remote-cloud-tooltip-primitives.test.tsx)
(cd apps/web && NODE_OPTIONS=--max-old-space-size=3072 pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && PATH=/usr/local/go/bin:$PATH pnpm e2e:run --host --project=chromium tests/settings/kubernetes-task-environment.spec.ts -- --retries=0)
(cd apps/web && PATH=/usr/local/go/bin:$PATH pnpm e2e:run --host --no-build --project=mobile-chrome tests/settings/mobile-kubernetes-task-environment.spec.ts -- --retries=0)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
## Files likely touched
- `apps/web/hooks/domains/session/remote-executor-status-resource.ts` and `.test.ts`
- `apps/web/hooks/domains/session/remote-executor-status-refresh.ts` (new scheduler)
- `apps/web/components/task/remote-cloud-tooltip.tsx` and `.test.tsx`
- `apps/web/components/task/remote-cloud-tooltip-primitives.test.tsx` (new)
- `apps/web/e2e/tests/settings/kubernetes-task-environment.spec.ts`
- `apps/web/e2e/tests/settings/mobile-kubernetes-task-environment.spec.ts`
- `docs/public/k8s.md` and `docs/public/executors.md`
## Dependencies
None. Install workspace dependencies before testing.
## Risks
Increased reads for many scopes; strict unsubscribe/visibility cleanup is required.
Do not replace real primitives with mocks in the composition regression.
## Parallelism
`sequential`
## Inputs
The requirement and design above, current shared-resource tests, task PR tooltip
interaction pattern, and mobile-menu-sheet layout exemplar.
## Results
Implementation and targeted verification complete.
- RED: no-interaction refresh, automatic retry, and visibility-return tests
failed on stale/missing status. The real Tooltip regression failed because
the DOM trigger had no `aria-describedby` linkage.
- GREEN: the listed focused Vitest command passes 29 tests in 4 files.
- Changed-file ESLint with `--max-warnings=0` passes.
- Fresh managed production build passes. Desktop Chromium E2E: 1/1, 4.3 seconds;
mobile Chrome E2E: 1/1, 5.7 seconds; both with `--retries=0`. These verify
automatic updates before interaction, anchored desktop details, touch drawer
facts, no task selection, focus return, and horizontal containment.
- `pnpm run i18n:check` passes. Public-doc validation passes for 47 pages.
Catalog validation, specification lint, and `git diff --check` pass.
- Full `pnpm run typecheck` could not complete: Node exhausted both its default
2 GB heap and a 3 GB heap in this 4 GB environment. This remains an explicit
full-project validation limitation, not a passing check.
- Focused typecheck passes with the repository tsconfig, changed production/test
roots, actual transitive dependencies, web ambient declaration files, and
explicit Node/React ambient types. It used `/tmp/executor-status-tsconfig.json`
with incremental checking disabled and a 3 GB heap. No compiler errors were
suppressed. This does not replace full-project typecheck evidence.
- Ready PR delivery was explicitly requested after user testing. Full-project
typecheck remains an explicit validation limitation for CI.
Managed E2E instances and the isolated user preview were stopped; the main
instance was untouched. Generated typecheck crash dumps were removed.
### PR review remediation
Greptile identified obsolete queued WebSocket reads during outages. The default
requester now treats a disconnected client as unavailable transport. The new
transport regression failed with four offline requests before the guard, then
passed with zero offline reads and a healthy result after reconnect. All 27
focused tests and changed-file ESLint pass. No UI, API or public-doc contract
changed; the system design records the transport guard. Remote CI/review
verification remains pending for the remediation commit.
CodeRabbit summary findings: unavailable transport now immediately publishes the
sanitized unavailable snapshot; primitive classes and touch handlers are
composed. The unavailable-state assertion failed before the fix. Actual-drawer
coverage now exercises click, Enter and Space, with no task selection. All 29
focused tests and changed-file lint pass; mobile E2E passes 1/1 without retries
after a fresh build. Desktop E2E also passes 1/1 without retries, and the
focused TypeScript check passes.
+1 -1
View File
@@ -325,7 +325,7 @@ Choose **Settings > Executors > Kubernetes**. Only an administrator can create,
Opening a saved Kubernetes profile puts the shared cluster connection editor, connection test, and executor-wide active sessions before workload settings. The test uses current unsaved connection and profile values. Administrators edit both resources through one Save/Reset flow, while members see the same hierarchy read-only. Configured executor rows and task settings icons open the selected profile directly; the standalone connection route remains only for an executor with no profiles.
Kubernetes Pod glyphs on Kanban cards and in task lists hydrate from the exact task/session status when they render, before any hover. Fine-pointer hover or keyboard focus shows a compact structured Pod summary; touch opens the same summary in a bottom Drawer without activating the task row. Duplicate indicators for the same session share the current read instead of issuing parallel requests.
Kubernetes Pod glyphs on Kanban cards and in task lists hydrate from the exact task/session status when they render, before any hover. While the page is visible, mounted indicators refresh automatically every 90 seconds and retry failed reads. Fine-pointer hover or keyboard focus shows a compact structured Pod summary; touch opens the same summary in a bottom Drawer without activating the task row. Duplicate indicators for the same session share the current read instead of issuing parallel requests.
Each executor fixes one namespace and connection configuration. Each profile supplies one strict `core/v1` `PodTemplate`, a main-container name, `linux/amd64` or `linux/arm64`, and one workspace mode:
+1 -1
View File
@@ -201,7 +201,7 @@ The **Active sessions** card refreshes every 90 seconds and reads only Kandev's
On a Kubernetes task, use the Pod control in the task header to inspect the exact authorized Pod's live phase, container state, restart count, workspace mode, creation time, and sanitized failure reason. Desktop opens this disclosure on hover; touch and coarse-pointer layouts provide a named 44 px button that opens a Drawer with the same structured Pod summary. Quiet Refresh and Settings icons sit in the summary header. Refresh visibly stays busy until the immediate status read settles, and Settings opens the exact selected profile. Kubernetes does not show the generic **Reset environment** action because Pod and PVC lifecycle remains owned by Stop, Resume, Archive, and Delete.
The smaller Pod glyphs on Kanban cards and in task lists request their exact session status as soon as they appear, so their healthy or failed color does not depend on a first hover. On a fine pointer, hover or keyboard focus opens a compact summary with the Pod identity and aligned rows for state, restarts, workspace mode, creation time, last check, and any sanitized failure. On touch layouts, the same glyph has an expanded 44 px hit target and opens that summary in a bottom Drawer without selecting the task row.
The smaller Pod glyphs on Kanban cards and in task lists request their exact session status as soon as they appear, so their healthy or failed color does not depend on a first hover. While the page is visible, mounted indicators refresh automatically every 90 seconds and retry failed reads. On a fine pointer, hover or keyboard focus opens a compact summary with the Pod identity and aligned rows for state, restarts, workspace mode, creation time, last check, and any sanitized failure. On touch layouts, the same glyph has an expanded 44 px hit target and opens that summary in a bottom Drawer without selecting the task row.
### Understand template ownership
+5
View File
@@ -80,3 +80,8 @@ replace the foundation's resource ownership, recovery, or cleanup rules.
- [Agents](../agents/README.md): supplies the agent command and profile.
- [Tasks](../tasks/README.md): owns task-scoped execution lifecycle.
The compact task indicator contract is extracted into
[requirements](requirements/task-status-indicators.md) and
[design](system-design/task-status-indicators.md), including automatic freshness
and desktop/touch disclosure. The foundation retains task-page controls.
@@ -0,0 +1,51 @@
---
status: active
system: executors
created: 2026-09-19
owners:
- kandev
---
# Task executor status indicators
## Overview
Executor indicators let users inspect the current execution environment without
opening a task. Executors owns the status contract; task lists consume it.
This document extracts the compact indicator contract from the
[Kubernetes foundation](../../kubernetes-executor/spec.md) and extends it with
continued automatic refresh. Task-page executor controls remain in that foundation.
## Requirements
### REQ-EXECUTORS-TASK-STATUS-001: Fresh, inspectable task indicators
Sidebar and Kanban remote executor indicators shall expose current exact-session
status without requiring pointer interaction.
#### Acceptance criteria
- **AC-EXECUTORS-TASK-STATUS-001.1:** A valid rendered indicator shall read its
exact executor/task/session status before hover. While the document is visible
and the indicator remains mounted, it shall start another read within 90 seconds
of the previous read settling, without user interaction. Returning to a visible
document shall refresh an expired result. Timing excludes browser suspension.
- **AC-EXECUTORS-TASK-STATUS-001.2:** Duplicate indicators for the same exact scope
shall share an in-flight read and recent result. Invalid scope shall issue no
request and show unavailable. A changed session shall never display a late
result belonging to its predecessor. Externally supplied status remains authoritative.
- **AC-EXECUTORS-TASK-STATUS-001.3:** Hover or keyboard focus shall open an anchored,
viewport-contained details surface and refresh status, joining any in-flight
read. Escape shall dismiss it. The summary shall identify the executor and show
labelled state, creation and check times, plus Kubernetes restart count and
workspace mode when available. Errors shall be sanitized, visible as text, and
retry automatically; loading shall retain previously known facts.
- **AC-EXECUTORS-TASK-STATUS-001.4:** Touch users shall open the same facts in a
bounded bottom drawer through an effective target of at least 44 px, without
selecting or opening the underlying task. The drawer shall support dismissal,
focus return, internal scrolling, and bottom safe-area clearance.
## Exclusions
No executor lifecycle changes, new backend APIs, persisted live status, credential
changes, or continuous reads while the document is hidden or no consumer remains.
@@ -0,0 +1,69 @@
---
status: current
system: executors
requirements:
- REQ-EXECUTORS-TASK-STATUS-001
---
# Task executor status indicators
## Boundaries and mapping
REQ-EXECUTORS-TASK-STATUS-001 maps to the shared resource lifecycle (AC .1/.2),
responsive disclosure (AC .3/.4), and failure handling below. The existing
Kubernetes foundation retains API authorization and resource ownership.
## Shared resource lifecycle
Extend `apps/web/hooks/domains/session/remote-executor-status-resource.ts` and
`use-remote-executor-status.ts`. Keep the scope tuple of executor type, executor
ID, task ID and session ID, stable snapshots, in-flight deduplication, 90-second
successful-result reuse, and bounded eviction of unused entries.
The resource owns one refresh timer per actively subscribed valid scope, not one
per mounted icon. Start an eager read on subscription through the hook. Schedule
subsequent reads 90 seconds after settlement, including failures and unavailable
transport. An explicit disclosure refresh joins a pending request or starts a new
one and resets the schedule after settlement. Do not create overlapping reads.
On last unsubscribe cancel the timer. Late settlement may update its old cache
entry but must not resurrect polling. Hidden documents pause scheduled reads;
visibility restoration loads expired active scopes and reschedules fresh ones.
Manage the visibility listener only while active consumers exist. No new timer
for the authoritative external-status path or an invalid scope.
Retain `getKubernetesTaskSession` for Kubernetes and `task.session.status` for
other remote executors. A disconnected WebSocket is unavailable transport: skip
the read, publish sanitized unavailable status immediately, and retry on the
existing schedule without queuing obsolete requests.
No backend schema or persistence change is required.
## Responsive disclosure
`RemoteCloudTooltip` retains the existing summary and `useTaskIconTooltipState`.
`StatusTrigger` must forward the DOM ref and all primitive-injected DOM props to
its span, preserving composed event handlers, aria attributes and positioning
attributes. Use the repository React version's supported ref pattern. Test the
real `@kandev/ui` Tooltip and Drawer rather than replacing them with fragments.
Keep Escape dismissal and hover/focus behavior consistent with task PR indicators.
Desktop retains the small glyph and bounded anchored summary. On coarse pointers,
`useTouchDrawer` selects the existing short, temporary bottom disclosure. The
nearest layout exemplar is `components/kanban/mobile-menu-sheet.tsx`: fixed header,
scrolling body, inset shape and safe-area clearance. Reuse shared facts and loading
state. Keep the compact visible glyph, but ensure its touch hit area does not
intercept adjacent task controls. The drawer owns vertical scrolling, uses dynamic
viewport bounds and restores focus; no document horizontal overflow is allowed.
## Failure and recovery
Retain old facts during refresh, publish sanitized read failures, and recover on
the next scheduled or explicit read. Unknown status must not imply ready. Existing
credential authorization and sanitization are unchanged. Do not log provider
credentials or raw errors. No new telemetry or persistence is needed.
## Verification
Use fake-clock resource tests for scheduling, duplicate consumers, hidden/visible
transitions, failure recovery, transport absence, unmount and late settlement.
Use actual primitive integration tests for DOM trigger linkage and focus/Escape.
Desktop and mobile Kubernetes E2E prove no-hover updates and real visible details.
+5 -38
View File
@@ -93,23 +93,9 @@ first-class Pod with an administrator-reviewed workload template.
current credential view. Rotating or repairing kubeconfig credentials must not
leave list/card chrome pinned to a launch-time `Unauthorized` result after the
current executor configuration can inspect the recorded Pod successfully.
- Sidebar and Kanban executor indicators hydrate their exact task/session status
as soon as a valid indicator is rendered. The user does not need to hover the
icon before its healthy, failed, or unavailable tone becomes truthful. Exact
duplicate scopes share one in-flight read and one recent result; a later
hover or keyboard focus refreshes that scope without clearing the last known
facts while the refresh is pending.
- A fine-pointer task-list or Kanban executor indicator uses the same compact,
structured disclosure language as the task Pull Request indicator: a
focusable icon trigger, a bounded summary surface, a clear identity header,
and aligned labelled status rows. It does not fall back to an unstructured
stack of sentences. Kubernetes rows include state, restart count, workspace
mode, creation time, last check, and a sanitized failure when present.
- On a coarse pointer, the same compact executor indicator is tappable and
opens a bottom Drawer containing the same status facts. Its touch target is
at least 44 px without making dense task rows visually taller, and the Drawer
owns bounded scrolling plus safe-area padding. Hover is never the only way to
discover the status.
- Compact sidebar and Kanban indicator hydration and responsive disclosure are
owned by [task executor status indicators](../executors/requirements/task-status-indicators.md)
and their [system design](../executors/system-design/task-status-indicators.md).
- The raw PodTemplate YAML field grows and shrinks with its content from a compact
minimum height. It has no independent vertical scrollbar; the settings page is
the vertical scroll owner, while unwrapped YAML retains contained horizontal
@@ -354,27 +340,8 @@ identity check or inventory lookup stops cleanup without deleting anything.
kubeconfig credentials can inspect the exact recorded Pod and the indicator
mounts or its disclosure opens again, **THEN** the status is re-read, the
error clears, and the Pod icon uses the current healthy tone.
- **GIVEN** Kanban renders a Kubernetes card with an executor, task, and primary
session identity, **WHEN** the card enters the document, **THEN** its exact Pod
status is requested before any hover and the Pod icon updates to the returned
semantic tone. Another mounted indicator for that exact scope joins the same
request instead of issuing a duplicate.
- **GIVEN** an eager status read is already in flight, **WHEN** the user hovers
or keyboard-focuses the executor indicator, **THEN** the disclosure opens with
visible loading feedback, retains any last known facts, and joins the causal
read. A later open after settlement performs a fresh exact read.
- **GIVEN** the rendered task has no primary session or lacks the executor
identity required by its status source, **WHEN** its indicator renders,
**THEN** no malformed request is sent and the icon plus disclosure report an
unavailable state rather than `ready`.
- **GIVEN** a fine-pointer user opens a Kubernetes executor indicator in Kanban
or the task sidebar, **WHEN** the summary appears, **THEN** it uses a bounded
Pull Request-style identity header and aligned labelled rows for state,
restarts, workspace, created time, and last check, with the failure reason as
a distinct sanitized status row when present.
- **GIVEN** the same indicator is rendered for a coarse pointer, **WHEN** the
user taps its 44 px target, **THEN** a safe-area-aware bottom Drawer presents
the same facts and can be dismissed without selecting or opening the task row.
- Compact indicator acceptance criteria are maintained in
[REQ-EXECUTORS-TASK-STATUS-001](../executors/requirements/task-status-indicators.md).
- **GIVEN** the recorded Kubernetes Pod is pending, failed, missing, or cannot
be inspected, **WHEN** the task disclosure refreshes, **THEN** it shows the
sanitized live state or unavailable/error state and never reports `ready`