Skip to content

Commit 37fdb1e

Browse files
Merge pull request #324 from corbitsdev/cl-5351-react-hooks
Remove unnecessary React hooks (CL-5351 slice B)
2 parents 6cc8104 + 41481f5 commit 37fdb1e

7 files changed

Lines changed: 75 additions & 45 deletions

File tree

src/tui/app.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,9 @@ export function App({
351351
const [agentModalUsage, setAgentModalUsage] = useState<string | null>(null);
352352
const [permissionsOpen, setPermissionsOpen] = useState(false);
353353
const [settingsOpen, setSettingsOpen] = useState(false);
354+
// Mount-only seeds from runner props. Runner does not re-render App when these
355+
// change; Settings updates flow through the local setters + onChange* callbacks
356+
// that mutate runner-held live values. No prop→state sync effect needed.
354357
const [liveTelemetryEnabled, setLiveTelemetryEnabled] = useState(telemetryEnabled);
355358
const [waitForApproval, setWaitForApproval] = useState(waitForApprovalProp);
356359
const [compactionMode, setCompactionMode] = useState<CompactionMode>(
@@ -555,13 +558,12 @@ export function App({
555558

556559
const { goalActive, goalPhase, showAcceptance, workPrimary } = resolveGoalChrome({ goalSnapshot });
557560
// Default-expand Work when entering implementing; Ctrl+T can still collapse.
561+
// Adjust during render so the panel opens in the same paint as the phase flip.
558562
const wasWorkPrimary = useRef(false);
559-
useEffect(() => {
560-
if (workPrimary && !wasWorkPrimary.current) {
561-
setTasksExpanded(true);
562-
}
563-
wasWorkPrimary.current = workPrimary;
564-
}, [workPrimary]);
563+
if (workPrimary && !wasWorkPrimary.current) {
564+
setTasksExpanded(true);
565+
}
566+
wasWorkPrimary.current = workPrimary;
565567
// Drop the /goal one-shot once Goal chrome is live so it does not stack on
566568
// the brief / Work checklist (and blow the reserved chrome rows).
567569
useEffect(() => {

src/tui/components/chat-input.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Box, Text, useInput, usePaste } from "ink";
2-
import { useState, useMemo, useEffect, useRef } from "react";
2+
import { useState, useMemo, useRef } from "react";
33
import type { ReactNode } from "react";
44
import { getCommand, listCommands } from "../commands/registry.js";
55
import type { CommandContext, CommandResult, SubcommandDefinition } from "../commands/registry.js";
@@ -426,10 +426,15 @@ export function ChatInput({
426426
const selfSetValue = useRef<string | null>(null);
427427

428428
// Reset the cursor to the end only when value changes from the OUTSIDE.
429-
useEffect(() => {
430-
if (value === selfSetValue.current) return;
431-
setCursor(value.length);
432-
}, [value]);
429+
// Adjust during render (not an effect) so the caret lands in the same paint
430+
// as the external value update — no extra commit for the cursor alone.
431+
const [prevValue, setPrevValue] = useState(value);
432+
if (value !== prevValue) {
433+
setPrevValue(value);
434+
if (value !== selfSetValue.current) {
435+
setCursor(value.length);
436+
}
437+
}
433438

434439
const atMention = useAtSuggestions(cwd);
435440

src/tui/components/plugins-manager.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Box, Text, useInput } from "ink";
2-
import { useState, useRef, useEffect } from "react";
2+
import { useState, useRef } from "react";
33
import type { ReactNode } from "react";
44
import { color } from "../theme.js";
55
import { listPathSuggestions } from "./at-mention/index.js";
@@ -86,14 +86,15 @@ export function PluginsManager({ admin, onClose, cwd }: PluginsManagerProps): Re
8686
const isEnabled = (id: string): boolean => config[id]?.enabled === true;
8787
const isConsented = (id: string): boolean => config[id]?.consented === true;
8888

89-
useEffect(() => {
90-
if (addingPath === null) {
91-
pathGeneration.current++;
92-
lastPathPrefix.current = null;
93-
setPathSuggestions([]);
94-
setPathSelectedIdx(0);
95-
}
96-
}, [addingPath]);
89+
// Clear path-suggestion state when leaving add-by-path (call at every exit site
90+
// instead of watching addingPath with an effect).
91+
const clearAddingPath = (): void => {
92+
pathGeneration.current++;
93+
lastPathPrefix.current = null;
94+
setPathSuggestions([]);
95+
setPathSelectedIdx(0);
96+
setAddingPath(null);
97+
};
9798

9899
const fetchPathSuggestions = (prefix: string, gen: number) => {
99100
void listPathSuggestions(prefix, cwd).then((results) => {
@@ -142,7 +143,7 @@ export function PluginsManager({ admin, onClose, cwd }: PluginsManagerProps): Re
142143
void Promise.resolve(admin.addPath(path)).then(
143144
(result) => {
144145
setAddStatus({ ok: result.ok, message: result.message });
145-
if (result.ok) { setAddingPath(null); setVersion((v) => v + 1); }
146+
if (result.ok) { clearAddingPath(); setVersion((v) => v + 1); }
146147
},
147148
(err: unknown) => setAddStatus({ ok: false, message: err instanceof Error ? err.message : String(err) }),
148149
);
@@ -160,7 +161,7 @@ export function PluginsManager({ admin, onClose, cwd }: PluginsManagerProps): Re
160161

161162
if (addingPath !== null) {
162163
if (key.escape) {
163-
setAddingPath(null);
164+
clearAddingPath();
164165
setAddStatus(null);
165166
return;
166167
}

src/tui/components/session-resume-picker.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Box, Text, useApp, useInput } from "ink";
22
import type { ReactNode } from "react";
3-
import { useEffect, useMemo, useRef, useState } from "react";
3+
import { useMemo, useRef, useState } from "react";
44

55
import type { SessionSummary } from "../../session/index.js";
66
import { formatRelativeTime } from "../format-relative-time.js";
@@ -21,10 +21,9 @@ function formatLabel(session: SessionSummary): string {
2121
export function SessionResumePicker({ sessions, onSelect, onCancel }: SessionResumePickerProps): ReactNode {
2222
const { exit } = useApp();
2323
const [cursor, setCursor] = useState(0);
24-
const cursorRef = useRef(0);
25-
useEffect(() => {
26-
cursorRef.current = cursor;
27-
}, [cursor]);
24+
// Keep the latest cursor available to useInput without an effect round-trip.
25+
const cursorRef = useRef(cursor);
26+
cursorRef.current = cursor;
2827
const rows = useMemo(() => sessions.map((s) => ({ session: s, label: formatLabel(s) })), [sessions]);
2928
const clamped = rows.length > 0 ? Math.min(cursor, rows.length - 1) : 0;
3029

src/tui/hooks/use-gates.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,10 @@ export function useGates({
138138
activationBlocked = false,
139139
}: UseGatesArgs): GateController {
140140
const [activeApproval, setActiveApproval] = useState<ActiveApproval | null>(null);
141-
const [permissionQueueDepth, setPermissionQueueDepth] = useState(0);
142141
const [queuedApprovals, setQueuedApprovals] = useState<readonly QueuedApprovalSummary[]>([]);
143142
const queue = useRef<GateQueueEntry[]>([]);
143+
// Depth is the length of the permission summary list — one source of truth.
144+
const permissionQueueDepth = queuedApprovals.length;
144145

145146
function syncQueuedApprovals(): void {
146147
setQueuedApprovals(
@@ -194,7 +195,6 @@ export function useGates({
194195
clearEntryTimer(entry);
195196
detachEntryAbort(entry);
196197
if (entry.kind === "permission") {
197-
setPermissionQueueDepth((depth) => Math.max(0, depth - 1));
198198
syncQueuedApprovals();
199199
}
200200
setGatePendingRef.current(false);
@@ -245,7 +245,6 @@ export function useGates({
245245
function enqueue(entry: GateQueueEntry): void {
246246
queue.current.push(entry);
247247
if (entry.kind === "permission") {
248-
setPermissionQueueDepth((depth) => depth + 1);
249248
syncQueuedApprovals();
250249
}
251250
setGatePendingRef.current(true);
@@ -256,7 +255,6 @@ export function useGates({
256255
const remaining = queue.current.splice(0);
257256
activeId.current = null;
258257
setActiveApproval(null);
259-
setPermissionQueueDepth(0);
260258
setQueuedApprovals([]);
261259
for (const entry of remaining) {
262260
clearEntryTimer(entry);

src/tui/hooks/use-message-pipeline.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ export function useMessagePipeline({
114114
const sendCounterRef = useRef(0);
115115
const lastSentMessageRef = useRef<string>("");
116116
const quotaAutoRetryFiredRef = useRef(false);
117+
// Bumped on every sent-history load (and on effect cleanup) so only the latest
118+
// loadSentMessages result can write browse state — startNewSession and the
119+
// hydrate effect share this so neither path can apply a stale session's history.
120+
const sentHistoryLoadGenRef = useRef(0);
117121

118122
sendMessageRef.current = (message: OutboundUserMessage) => {
119123
lastSentMessageRef.current = message.text;
@@ -208,6 +212,17 @@ export function useMessagePipeline({
208212

209213
requestStopRef.current = requestStop;
210214

215+
// Start a sent-history load; only the newest generation may apply. Shared by
216+
// startNewSession and the hydrate effect so rapid /clear or session switches
217+
// cannot write browse from a prior id after a newer load has begun.
218+
const loadSentHistoryBrowse = (sessionId: string) => {
219+
const gen = ++sentHistoryLoadGenRef.current;
220+
void loadSentMessages(cwd, sessionId).then((sent) => {
221+
if (gen !== sentHistoryLoadGenRef.current) return;
222+
setSentHistoryBrowse(createSentHistoryBrowse(sent));
223+
});
224+
};
225+
211226
const startNewSessionRef = useRef<() => void>(() => undefined);
212227
startNewSessionRef.current = () => {
213228
sendAbortRef.current?.abort();
@@ -230,24 +245,28 @@ export function useMessagePipeline({
230245
subAgentSessions?.clear();
231246
onNewSession?.();
232247
if (getSessionId !== undefined) {
233-
void loadSentMessages(cwd, getSessionId()).then((sent) => {
234-
setSentHistoryBrowse(createSentHistoryBrowse(sent));
235-
});
248+
loadSentHistoryBrowse(getSessionId());
236249
} else {
250+
// Invalidate any in-flight load before clearing browse for a no-session path.
251+
sentHistoryLoadGenRef.current++;
237252
setSentHistoryBrowse(createSentHistoryBrowse([]));
238253
}
239254
scroll.scrollToBottom();
240255
forceRender((n) => n + 1);
241256
};
242257
const startNewSession = () => startNewSessionRef.current();
243258

244-
// Send the initial task once the App (and its gate listeners) is mounted, so
245-
// the run is driven through the same abortable path as interactive sends.
259+
// Hydrate sent-message history for the active session. Cancel stale loads so a
260+
// session switch or unmount cannot write history from a prior session id.
246261
useEffect(() => {
247262
if (getSessionId === undefined) return;
248-
void loadSentMessages(cwd, getSessionId()).then((sent) => {
249-
setSentHistoryBrowse(createSentHistoryBrowse(sent));
250-
});
263+
loadSentHistoryBrowse(getSessionId());
264+
return () => {
265+
sentHistoryLoadGenRef.current++;
266+
};
267+
// loadSentHistoryBrowse closes over cwd/setSentHistoryBrowse; re-run when the
268+
// session identity source or cwd changes.
269+
// eslint-disable-next-line react-hooks/exhaustive-deps
251270
}, [cwd, getSessionId]);
252271

253272
useEffect(() => {

src/tui/use-stream.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1323,6 +1323,10 @@ export function useAgentStream(
13231323
setDisplayRevision((r) => r + 1);
13241324
};
13251325

1326+
// `state` is a stable store object from useState — never recreated. Effects
1327+
// that re-arm on status/quota changes depend only on those fields; listing
1328+
// `state` itself would be noise (always same identity).
1329+
13261330
// ~30fps drain makes streaming feel metronomic rather than bursty. Gated to
13271331
// running/blocked so an idle session schedules no periodic timer.
13281332
useEffect(() => {
@@ -1334,7 +1338,7 @@ export function useAgentStream(
13341338
}
13351339
}, 33);
13361340
return () => clearInterval(interval);
1337-
}, [state, state.status]);
1341+
}, [state.status]);
13381342

13391343
// Line layout is heavier than chrome updates; coalesce it during token
13401344
// streaming. Gated to running/blocked so an idle session schedules no
@@ -1347,7 +1351,7 @@ export function useAgentStream(
13471351
}
13481352
}, 100);
13491353
return () => clearInterval(interval);
1350-
}, [state, state.status]);
1354+
}, [state.status]);
13511355

13521356
// requestStop()/clear() can transition status out of running/blocked with a
13531357
// token delta still buffered in pendingRenderRef/pendingLineRevisionRef —
@@ -1361,7 +1365,7 @@ export function useAgentStream(
13611365
setTick((t) => t + 1);
13621366
bumpDisplayRevision();
13631367
}
1364-
}, [state, state.status]);
1368+
}, [state.status]);
13651369

13661370
useEffect(() => {
13671371
const handler = (event: ReactorEmittedEvent) => {
@@ -1406,7 +1410,8 @@ export function useAgentStream(
14061410
emitter.off("subagent.progress", progressHandler);
14071411
emitter.off("history.hydrate", hydrateHandler);
14081412
};
1409-
}, [emitter, state]);
1413+
// state is a stable store; only re-bind when the emitter instance changes.
1414+
}, [emitter]);
14101415

14111416
useEffect(() => {
14121417
if (state.status !== "running" && state.status !== "blocked" && state.quotaError === null) return;
@@ -1416,12 +1421,13 @@ export function useAgentStream(
14161421
return () => {
14171422
clearInterval(interval);
14181423
};
1419-
}, [state, state.status, state.quotaError]);
1424+
}, [state.status, state.quotaError]);
14201425

14211426
void tick;
14221427

1428+
// state identity is stable; re-wrap only when displayRevision advances.
14231429
return useMemo(
14241430
() => Object.assign(Object.create(state), { displayRevision }),
1425-
[state, displayRevision],
1431+
[displayRevision],
14261432
);
14271433
}

0 commit comments

Comments
 (0)