From 2c2d810388200a7b1d6adea3f5902982df2bb79a Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:01:48 +0200 Subject: [PATCH] refactor(hooks): burn down the 35 compiler-era react-hooks findings, rules bind as errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #451 downgraded five react-hooks rules to "warn" with a note to delete the block once the call sites were fixed. This is that burn-down: all 35 findings restructured, the downgrade block deleted, so set-state-in-effect / refs / purity / immutability / preserve-manual-memoization now bind at error severity (verified by mutation probe: a violating file fails lint with exit 1). By class: set-state-in-effect (25) — three recipes, chosen per call site: - Prop/URL→state mirror effects became guarded render-time adjustments (React's "adjusting state when props change" pattern): ProjectAutopilotToggle, ZellijLivePanel, LiveUrlField, ProjectWorkspaceHeader (x2), ProjectCard (capacity re-arm + agent-running transition), ControlPanel (selection reconciliation), CommandPalette (open-reset), LokiWorkspace (?q= prefill, selection seed, transcript switch), use-auto-continue, use-poll, use-project-card-actions. Same commit semantics, one paint earlier. - Async loaders called from mount effects were split into pure fetch cores whose every setState lives in a promise callback (.then/.catch/.finally), with thin event-context wrappers that prime spinners for buttons/timers: ActivityTimeline, PeekTabDrawer (fetchRemotePeek now returns content instead of applying it), PeopleBookPanel, LokiWorkspace reloads, use-control-data (fetchControl core + refresh wrapper). - Values readable at first render moved into useState lazy initializers: CommandPalette recents (sessionStorage), BillingSettings ?billing= notice. MermaidDiagram's matchMedia mirror became a useSyncExternalStore subscription. LogConversationButton clears results in the input's onChange (the actual event) instead of the debounce effect. refs (6): - ProjectOperationsView's frozen row order moved from a ref mutated inside useMemo to state adjusted during render behind the same setKey guard. - queue-list/queue-item-row: editRef existed only to focus the edit textarea after open; the textarea mounts with autoFocus instead and the ref plumbing is deleted. - TerminalView's fontOverrideRef is now mirrored via an effect (declared before the mount effect so declaration-order seeding still precedes the first read), matching the file's existing onLive/onGeometry pattern. purity (1): - ProjectCard no longer calls Date.now() in render; it receives nowS from ControlPanel's per-render clock via buildCardProps, so the card's staleness math now uses the same clock as the snapshots it renders. immutability (1): - BillingSettings navigates to Stripe Checkout via window.location.assign() (method call) instead of assigning location.href. preserve-manual-memoization (2): - TerminalSurface.switchAgent and AskLokiButton.ask captured whole context objects while declaring narrowed deps; the needed field (tabDir / workspaceKey) is now a local, making the manual deps match what the compiler infers. Two targeted eslint-disable-next-line react-hooks/set-state-in-effect remain, each with an inline justification: ProjectCard's auto-reroute automation (once-per-capacity-episode loop guard around a live agent-switching path) and ControlPanel's ?focus= deep-link handler (App Router param changes only surface as re-renders; splitting it would resolve the target twice against possibly-different data). Two unused directives (ready-banner, ShellWorkspace) were removed. Verified: npm run lint (0 errors; 3 pre-existing @next/next location warnings untouched), npx tsc --noEmit, npm run test:unit 124/124. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn --- eslint.config.mjs | 11 -- src/components/control/ActivityTimeline.tsx | 64 ++++++--- src/components/control/ControlPanel.tsx | 22 +-- src/components/control/PeekTabDrawer.tsx | 77 ++++++---- .../control/ProjectAutopilotToggle.tsx | 12 +- src/components/control/ProjectCard.tsx | 30 ++-- .../control/ProjectOperationsView.tsx | 69 ++++----- src/components/control/ZellijLivePanel.tsx | 13 +- .../control/control-panel-card-props.ts | 4 + src/components/control/queue-item-row.tsx | 6 +- src/components/control/queue-list.tsx | 7 +- src/components/control/ready-banner.tsx | 2 +- src/components/loki/LokiWorkspace.tsx | 132 +++++++++++------- src/components/people/PeopleBookPanel.tsx | 22 +-- src/components/projects/LiveUrlField.tsx | 15 +- .../projects/ProjectWorkspaceHeader.tsx | 16 ++- src/components/settings/BillingSettings.tsx | 18 ++- src/components/shell/AskLokiButton.tsx | 8 +- src/components/shell/CommandPalette.tsx | 54 ++++--- src/components/terminal/ShellWorkspace.tsx | 1 - src/components/terminal/TerminalSurface.tsx | 10 +- src/components/terminal/TerminalView.tsx | 7 +- src/components/thoughts/MermaidDiagram.tsx | 23 +-- .../today/LogConversationButton.tsx | 13 +- src/hooks/use-auto-continue.ts | 15 +- src/hooks/use-control-data.ts | 47 ++++--- src/hooks/use-poll.ts | 12 +- src/hooks/use-project-card-actions.ts | 11 +- 28 files changed, 447 insertions(+), 274 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 58f0a7cf..d47b5734 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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" — diff --git a/src/components/control/ActivityTimeline.tsx b/src/components/control/ActivityTimeline.tsx index 08738f02..272c2ec3 100644 --- a/src/components/control/ActivityTimeline.tsx +++ b/src/components/control/ActivityTimeline.tsx @@ -35,37 +35,57 @@ const REFRESH_MS = 5_000; export function ActivityTimeline({ tab }: { tab: string }) { const [events, setEvents] = useState(null); const [error, setError] = useState(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 ( diff --git a/src/components/control/ControlPanel.tsx b/src/components/control/ControlPanel.tsx index 4ba36151..4b9af27b 100644 --- a/src/components/control/ControlPanel.tsx +++ b/src/components/control/ControlPanel.tsx @@ -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); @@ -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); @@ -321,6 +326,7 @@ export function ControlPanel() { executionStalled: Boolean(data?.runnerExecutionStall?.stalled), automationMode: automationPolicy.mode, countdownSeconds: automationPolicy.countdownSeconds, + nowS, }); const livePanelProps = { diff --git a/src/components/control/PeekTabDrawer.tsx b/src/components/control/PeekTabDrawer.tsx index 0524e226..f2b921bf 100644 --- a/src/components/control/PeekTabDrawer.tsx +++ b/src/components/control/PeekTabDrawer.tsx @@ -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 => { const enqueue = await fetch("/api/control/peek-tab", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -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(() => ({})); @@ -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"); @@ -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(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(() => { diff --git a/src/components/control/ProjectAutopilotToggle.tsx b/src/components/control/ProjectAutopilotToggle.tsx index 487107b5..ad18bc03 100644 --- a/src/components/control/ProjectAutopilotToggle.tsx +++ b/src/components/control/ProjectAutopilotToggle.tsx @@ -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"; @@ -46,9 +46,15 @@ export function ProjectAutopilotToggle({ const [saving, setSaving] = useState(false); const [error, setError] = useState(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(currentOverride); + if (currentOverride !== prevOverride) { + setPrevOverride(currentOverride); setLocalOverride(currentOverride); - }, [currentOverride]); + } if (!projectId) return null; diff --git a/src/components/control/ProjectCard.tsx b/src/components/control/ProjectCard.tsx index 28778d32..ea339181 100644 --- a/src/components/control/ProjectCard.tsx +++ b/src/components/control/ProjectCard.tsx @@ -59,6 +59,7 @@ export function ProjectCard({ snapshot, automationMode = "on", countdownSeconds, + nowS, }: { project: ProjectState; prompts: PromptMeta[]; @@ -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(project.agentPref ?? null); @@ -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); @@ -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; } @@ -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 ?? diff --git a/src/components/control/ProjectOperationsView.tsx b/src/components/control/ProjectOperationsView.tsx index 83e57428..1a5142b7 100644 --- a/src/components/control/ProjectOperationsView.tsx +++ b/src/components/control/ProjectOperationsView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import { Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { compactRelativeDate } from "@/lib/dates"; @@ -73,26 +73,36 @@ export function ProjectOperationsView({ // card content but must not reshuffle rows mid-click (priority re-ranking // moved the target under the cursor twice on 2026-07-03 — one dispatch went // to the wrong project, one vanished). The ranking recomputes only when the - // sort mode, the query, or the SET of visible projects changes. - const frozenOrderRef = useRef<{ key: string; order: string[] }>({ key: "", order: [] }); - const visibleSnapshots = useMemo(() => { - const filtered = normalizedQuery - ? sourceSnapshots.filter((snapshot) => { - const haystack = [ - snapshot.project.tab, - snapshot.project.profile?.mission, - snapshot.project.profile?.description, - snapshot.project.dir, - snapshot.project.git?.branch, - snapshot.contextSummary, - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - return haystack.includes(normalizedQuery); - }) - : sourceSnapshots; + // sort mode, the query, or the SET of visible projects changes. The frozen + // order lives in state, adjusted during render behind the setKey guard + // (previously a ref, but refs must not be read or written during render). + const filtered = normalizedQuery + ? sourceSnapshots.filter((snapshot) => { + const haystack = [ + snapshot.project.tab, + snapshot.project.profile?.mission, + snapshot.project.profile?.description, + snapshot.project.dir, + snapshot.project.git?.branch, + snapshot.contextSummary, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(normalizedQuery); + }) + : sourceSnapshots; + const setKey = `${sort}|${normalizedQuery}|${filtered + .map((s) => s.project.tab) + .sort() + .join(",")}`; + const [frozenOrder, setFrozenOrder] = useState<{ key: string; order: string[] }>({ + key: "", + order: [], + }); + let order = frozenOrder.order; + if (frozenOrder.key !== setKey) { const ranked = [...filtered].sort((a, b) => { if (sort === "az") return a.project.tab.localeCompare(b.project.tab); if (sort === "recent") @@ -102,19 +112,12 @@ export function ProjectOperationsView({ ); return sourceSnapshots.indexOf(a) - sourceSnapshots.indexOf(b); }); - - const setKey = `${sort}|${normalizedQuery}|${filtered - .map((s) => s.project.tab) - .sort() - .join(",")}`; - if (frozenOrderRef.current.key !== setKey) { - frozenOrderRef.current = { key: setKey, order: ranked.map((s) => s.project.tab) }; - } - const order = frozenOrderRef.current.order; - return [...filtered].sort( - (a, b) => order.indexOf(a.project.tab) - order.indexOf(b.project.tab), - ); - }, [normalizedQuery, sourceSnapshots, sort]); + order = ranked.map((s) => s.project.tab); + setFrozenOrder({ key: setKey, order }); + } + const visibleSnapshots = [...filtered].sort( + (a, b) => order.indexOf(a.project.tab) - order.indexOf(b.project.tab), + ); const selected = sourceSnapshots.find((snapshot) => snapshot.project.tab === selectedTab) ?? visibleSnapshots[0] ?? diff --git a/src/components/control/ZellijLivePanel.tsx b/src/components/control/ZellijLivePanel.tsx index a47d9057..f9c0517f 100644 --- a/src/components/control/ZellijLivePanel.tsx +++ b/src/components/control/ZellijLivePanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState, type RefObject } from "react"; +import { useMemo, useState, type RefObject } from "react"; import { PanelsTopLeft, RefreshCw, Send, Terminal, Wrench } from "lucide-react"; import { cn } from "@/lib/utils"; import { postJson } from "@/lib/api/fetch"; @@ -42,11 +42,16 @@ export function ZellijLivePanel({ embedded?: boolean; }) { const insideRunner = useInsideFleetRunner(); - const [targetTab, setTargetTab] = useState(""); + const [targetTab, setTargetTab] = useState(initialTargetTab || ""); - useEffect(() => { + // Deep-link updates (e.g. a push notification while the panel is mounted) + // re-point the composer. Guarded render-time adjustment instead of an + // effect; the mount case is covered by the useState initializer above. + const [prevInitialTarget, setPrevInitialTarget] = useState(initialTargetTab); + if (initialTargetTab !== prevInitialTarget) { + setPrevInitialTarget(initialTargetTab); if (initialTargetTab) setTargetTab(initialTargetTab); - }, [initialTargetTab]); + } const [prompt, setPrompt] = useState(""); const [sendingPrompt, setSendingPrompt] = useState(false); const [sendError, setSendError] = useState(null); diff --git a/src/components/control/control-panel-card-props.ts b/src/components/control/control-panel-card-props.ts index 65e6e5f2..488e65c0 100644 --- a/src/components/control/control-panel-card-props.ts +++ b/src/components/control/control-panel-card-props.ts @@ -42,6 +42,9 @@ type Deps = { executionStalled: boolean; automationMode: AutoInjectMode; countdownSeconds: number | undefined; + /** ControlPanel's per-render clock (unix seconds) — one Date.now() per + * render tree, so the card's staleness math matches the snapshots'. */ + nowS: number; }; /** @@ -126,5 +129,6 @@ export function buildCardProps(deps: Deps) { executionStalled: deps.executionStalled, automationMode: deps.automationMode, countdownSeconds: deps.countdownSeconds, + nowS: deps.nowS, }); } diff --git a/src/components/control/queue-item-row.tsx b/src/components/control/queue-item-row.tsx index 8f2b3631..fa1f4335 100644 --- a/src/components/control/queue-item-row.tsx +++ b/src/components/control/queue-item-row.tsx @@ -20,7 +20,6 @@ export type RowProps = { dragHandleProps?: React.HTMLAttributes; editingIndex: number | null; editText: string; - editRef: React.RefObject; onSetEditText: (v: string) => void; onToggleSelect: () => void; onStartEdit: () => void; @@ -46,7 +45,6 @@ export function QueueItemRow({ dragHandleProps, editingIndex, editText, - editRef, onSetEditText, onToggleSelect, onStartEdit, @@ -103,7 +101,9 @@ export function QueueItemRow({ {/* Content */} {editing ? (