Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,6 @@ const eslintConfig = defineConfig([
// before them was overridden in a sibling repo).
settings: { react: { version: "19.2.8" } },
rules: {
// ESLint 10 + eslint-config-next 16 enable the compiler-era hooks rules
// as errors. They flag 35 long-standing call sites across ten control
// components — real findings, but fixing them is a functional refactor
// (effect restructuring, ref discipline), not a dependency bump. Kept
// visible as warnings so the count is on every lint run; burn it down
// in its own PR, then delete these two lines so the rules bind.
"react-hooks/set-state-in-effect": "warn",
"react-hooks/refs": "warn",
"react-hooks/purity": "warn",
"react-hooks/immutability": "warn",
"react-hooks/preserve-manual-memoization": "warn",
// An unused import is a warning by default, which means it prints on
// every commit and never blocks one. `os` sat unused in box-workspace.ts
// long enough that every pre-commit hook run ended in "✖ 1 problem" —
Expand Down
64 changes: 42 additions & 22 deletions src/components/control/ActivityTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,37 +35,57 @@ const REFRESH_MS = 5_000;
export function ActivityTimeline({ tab }: { tab: string }) {
const [events, setEvents] = useState<ProjectActivityEvent[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [refreshing, setRefreshing] = useState(true);
const seq = useRef(0);

const load = async () => {
// Fetch without touching state synchronously — every setState lives in a
// promise callback, so the mount effect can call this directly. The spinner
// is primed by useState(true) on mount, by the render-time adjustment below
// on tab change, and by load() for interval/retry refreshes.
const fetchEvents = () => {
const mine = ++seq.current;
setRefreshing(true);
try {
const res = await fetch(`/api/control/activity?tab=${encodeURIComponent(tab)}`, {
cache: "no-store",
return fetch(`/api/control/activity?tab=${encodeURIComponent(tab)}`, {
cache: "no-store",
})
.then(async (res) => {
if (mine !== seq.current) return;
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Activity request failed (${res.status})`);
}
const body = (await res.json()) as { events: ProjectActivityEvent[] };
if (mine !== seq.current) return;
setError(null);
setEvents(body.events);
})
.catch((e: unknown) => {
if (mine !== seq.current) return;
setError((e as Error).message || "Couldn't load activity");
})
.finally(() => {
if (mine === seq.current) setRefreshing(false);
});
if (mine !== seq.current) return;
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Activity request failed (${res.status})`);
}
const body = (await res.json()) as { events: ProjectActivityEvent[] };
setError(null);
setEvents(body.events);
} catch (e) {
if (mine !== seq.current) return;
setError((e as Error).message || "Couldn't load activity");
} finally {
if (mine === seq.current) setRefreshing(false);
}
};

// Event-context refresh (interval tick, retry button).
const load = () => {
setRefreshing(true);
return fetchEvents();
};

// Tab change re-arms the spinner in the same render pass (guarded
// adjustment); the effect below re-fetches.
const [prevTab, setPrevTab] = useState(tab);
if (tab !== prevTab) {
setPrevTab(tab);
setRefreshing(true);
}

useEffect(() => {
void load();
void fetchEvents();
const id = setInterval(() => void load(), REFRESH_MS);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchEvents/load close over `tab`, which is the effect's real input
}, [tab]);

return (
Expand Down
22 changes: 14 additions & 8 deletions src/components/control/ControlPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,15 +215,19 @@ export function ControlPanel() {

const failedCount = data?.failedCommands?.length ?? 0;

useEffect(() => {
if (!snapshots?.length) return;
// Keep the selection valid as the snapshot set changes. Guarded render-time
// adjustment (React's "adjusting state when props change" pattern) instead
// of an effect: it converges in one re-render because the fallback tab is
// always a member of the current snapshot set.
if (snapshots?.length) {
const currentValid = selectedTab && snapshots.some((s) => s.project.tab === selectedTab);
if (currentValid) return;
const priority = snapshots.find(
(s) => s.phase === "ready" || s.phase === "orchestration_ready" || s.attentionReason,
);
setSelectedTab(priority?.project.tab ?? snapshots[0].project.tab);
}, [snapshots, selectedTab]);
if (!currentValid) {
const priority = snapshots.find(
(s) => s.phase === "ready" || s.phase === "orchestration_ready" || s.attentionReason,
);
setSelectedTab(priority?.project.tab ?? snapshots[0].project.tab);
}
}

useEffect(() => {
if (selectedTab) rememberFleetProject(selectedTab);
Expand All @@ -247,6 +251,7 @@ export function ControlPanel() {
if (handledFocusRef.current === requestKey) return;
handledFocusRef.current = requestKey;

// eslint-disable-next-line react-hooks/set-state-in-effect -- Deep-link handler: an App Router param change only surfaces as a re-render, so this effect IS the event handler for /control?focus=…. It resolves the target once, atomically, then selects + highlights + scrolls + clears the params; the requestKey ref already guarantees it runs once per navigation. Splitting the setStates into render-time adjustments would resolve the target twice against possibly-different data mid-refresh.
if (snapshotTab) setSelectedTab(snapshotTab);
setHighlightTab(resolvedTab);
setLiveTargetTab(resolvedTab);
Expand Down Expand Up @@ -321,6 +326,7 @@ export function ControlPanel() {
executionStalled: Boolean(data?.runnerExecutionStall?.stalled),
automationMode: automationPolicy.mode,
countdownSeconds: automationPolicy.countdownSeconds,
nowS,
});

const livePanelProps = {
Expand Down
77 changes: 52 additions & 25 deletions src/components/control/PeekTabDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ export function PeekTabDrawer({ tab, onClose }: { tab: string; onClose: () => vo
});
};

const fetchRemotePeek = async (seq: number) => {
// Pure fetcher: resolves with the captured content, or null when a newer
// request superseded this one. It never touches state itself — callers apply
// the result from a .then callback, so effects can start it without setting
// state synchronously.
const fetchRemotePeek = async (seq: number): Promise<string | null> => {
const enqueue = await fetch("/api/control/peek-tab", {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand All @@ -52,7 +56,7 @@ export function PeekTabDrawer({ tab, onClose }: { tab: string; onClose: () => vo

const deadline = Date.now() + 45_000;
while (Date.now() < deadline) {
if (seq !== requestSeq.current) return;
if (seq !== requestSeq.current) return null;
const poll = await fetch(`/api/control/peek-tab/${peekId}`, { cache: "no-store" });
if (!poll.ok) {
const body = await poll.json().catch(() => ({}));
Expand All @@ -64,8 +68,7 @@ export function PeekTabDrawer({ tab, onClose }: { tab: string; onClose: () => vo
error?: string;
};
if (body.status === "done") {
applyContent(body.content ?? "");
return;
return body.content ?? "";
}
if (body.status === "error") {
throw new Error(body.error || "Peek failed");
Expand All @@ -75,36 +78,60 @@ export function PeekTabDrawer({ tab, onClose }: { tab: string; onClose: () => vo
throw new Error("Fleet Runner did not claim the peek request within 45s — is it running?");
};

const fetchPeek = async () => {
// Starts a capture without touching state synchronously — every setState
// lives in a promise callback, so the snapshot effect can call this directly.
const runPeek = () => {
const seq = requestSeq.current + 1;
requestSeq.current = seq;
const bridge = window.fleetRunner;
const work =
typeof bridge?.peekTab === "function"
? bridge.peekTab(tab).then((result) => {
if (seq !== requestSeq.current) return;
if (result.ok) {
applyContent(result.content);
} else {
setError(result.error || "Peek failed");
}
})
: fetchRemotePeek(seq).then((peeked) => {
if (peeked !== null && seq === requestSeq.current) applyContent(peeked);
});
return work
.catch((e: unknown) => {
if (seq !== requestSeq.current) return;
setError((e as Error).message || "Peek failed");
})
.finally(() => {
if (seq === requestSeq.current) setLoading(false);
});
};

// Event-context wrapper (refresh button, auto-refresh timer): prime the
// spinner, then capture.
const fetchPeek = () => {
setLoading(true);
setError(null);
try {
if (typeof bridge?.peekTab === "function") {
const result = await bridge.peekTab(tab);
if (seq !== requestSeq.current) return;
if (result.ok) {
applyContent(result.content);
} else {
setError(result.error || "Peek failed");
}
} else {
await fetchRemotePeek(seq);
}
} catch (e) {
if (seq !== requestSeq.current) return;
setError((e as Error).message || "Peek failed");
} finally {
if (seq === requestSeq.current) setLoading(false);
}
return runPeek();
};

// Entering snapshot view (or the tab changing while in it) primes the
// spinner via a guarded render-time adjustment; the effect below only kicks
// off the async capture.
const snapshotKey = view === "snapshot" ? tab : null;
const [prevSnapshotKey, setPrevSnapshotKey] = useState<string | null>(null);
if (snapshotKey !== prevSnapshotKey) {
setPrevSnapshotKey(snapshotKey);
if (snapshotKey !== null) {
setLoading(true);
setError(null);
}
}

useEffect(() => {
if (view !== "snapshot") return; // live streams via TerminalView; activity reads the DB
void fetchPeek();
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchPeek is recreated each render; tab/view are its real inputs
void runPeek();
// eslint-disable-next-line react-hooks/exhaustive-deps -- runPeek is recreated each render; tab/view are its real inputs
}, [tab, view]);

useEffect(() => {
Expand Down
12 changes: 9 additions & 3 deletions src/components/control/ProjectAutopilotToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// override instead of storing a redundant one, so "inherit" stays the default
// and the override count on the fleet hint only reflects real divergence.

import { useEffect, useState } from "react";
import { useState } from "react";
import { Loader2, Pause, Play } from "lucide-react";
import { patchJson } from "@/lib/api/fetch";
import { FLEETCROWN_REFRESH_EVENT } from "@/lib/client-events";
Expand Down Expand Up @@ -46,9 +46,15 @@ export function ProjectAutopilotToggle({
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
// Server truth wins: when a refetch delivers a new override, drop the local
// optimistic value. Guarded render-time adjustment (React's "adjusting state
// when a prop changes" pattern) instead of an effect, so the reset lands in
// the same render pass without an extra paint.
const [prevOverride, setPrevOverride] = useState<AutoInjectMode | null>(currentOverride);
if (currentOverride !== prevOverride) {
setPrevOverride(currentOverride);
setLocalOverride(currentOverride);
}, [currentOverride]);
}

if (!projectId) return null;

Expand Down
30 changes: 21 additions & 9 deletions src/components/control/ProjectCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function ProjectCard({
snapshot,
automationMode = "on",
countdownSeconds,
nowS,
}: {
project: ProjectState;
prompts: PromptMeta[];
Expand Down Expand Up @@ -86,6 +87,9 @@ export function ProjectCard({
snapshot?: ProjectOperationsSnapshot;
automationMode?: AutoInjectMode;
countdownSeconds?: number;
/** Parent-render clock (unix seconds) — one Date.now() per render tree, so
* the card's staleness math matches the snapshots' (and render stays pure). */
nowS: number;
}) {
const [profileOpen, setProfileOpen] = useState(false);
const [localAgent, setLocalAgent] = useState<string | null>(project.agentPref ?? null);
Expand All @@ -97,9 +101,16 @@ export function ProjectCard({
const capacityIssue = detectCapacityIssueFromProject(project);
const suggestedFallback = resolveNextFallbackAgent(outgoingAgent, installedAgentIds);

useEffect(() => {
// A (re)appearing capacity issue or a new session state re-arms the banner.
// Guarded render-time adjustment (React's "adjusting state when props
// change" pattern) instead of an effect.
const capacityResetSignature = `${capacityIssue}:${project.session?.mtime ?? ""}:${project.currentPrompt?.label ?? ""}`;
const [prevCapacityResetSignature, setPrevCapacityResetSignature] =
useState(capacityResetSignature);
if (capacityResetSignature !== prevCapacityResetSignature) {
setPrevCapacityResetSignature(capacityResetSignature);
if (capacityIssue) setCapacityDismissed(false);
}, [capacityIssue, project.session?.mtime, project.currentPrompt?.label]);
}

const performAgentSwitch = async (agentId: string) => {
const currentAgent = resolveOutgoingAgent(project, localAgent);
Expand Down Expand Up @@ -155,6 +166,7 @@ export function ProjectCard({
if (!capacityIssue) {
autoTriedAgentsRef.current.clear();
handledCapacitySignatureRef.current = null;
// eslint-disable-next-line react-hooks/set-state-in-effect -- Episodic automation: this effect reacts to polled session state and must reset/record the reroute banner exactly once per capacity episode (ref-tracked signature + tried-agent set as loop guards). Deriving that state during render would rewire the loop-safety guarantees of a live agent-switching path for no user-visible gain.
setAutoRerouteReason(null);
return;
}
Expand Down Expand Up @@ -212,14 +224,14 @@ export function ProjectCard({
mergeItems: mergeItemsInQueue,
} = usePromptQueue(project.tab, project.promptQueue, project.promptQueueRevision);

// Reset dismissed each time a new agent run begins so the ready banner fires once per cycle.
const prevAgentRunning = useRef(project.agentRunning);
useEffect(() => {
if (!prevAgentRunning.current && project.agentRunning) setDismissed(false);
prevAgentRunning.current = project.agentRunning;
}, [project.agentRunning]);
// Reset dismissed each time a new agent run begins so the ready banner fires
// once per cycle. Guarded render-time adjustment on the running transition.
const [prevAgentRunning, setPrevAgentRunning] = useState(project.agentRunning);
if (project.agentRunning !== prevAgentRunning) {
setPrevAgentRunning(project.agentRunning);
if (project.agentRunning) setDismissed(false);
}

const nowS = Math.floor(Date.now() / 1000);
const display = dismissed
? getProjectDisplayState(project, zellijTabs, nowS, true, runtimeStateKnown, runnerSyncStale)
: (snapshot?.display ??
Expand Down
Loading
Loading