diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index da9d265d2..18527fcc4 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -7,7 +7,7 @@ import { Button, EmptyState } from "@corbits/react-ui"; import { WorkbenchLoadingState } from "@/chat"; import { QueryClientProvider } from "@tanstack/react-query"; import { BoldIconProvider, WarningCircle } from "@/lib/icons"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { AuthScreen } from "./auth-screen"; import { BenchProvider } from "./bench-context"; @@ -18,32 +18,13 @@ import { NotFoundPage } from "./pages/not-found-page"; import { OnboardingPage } from "./pages/onboarding-page"; import { ProvisioningErrorPage } from "./pages/provisioning-error-page"; import { createAppQueryClient } from "./query-client"; +import { Redirect } from "./redirect"; import { APP_ROUTES, LOGIN_PATH, matchesRoute, ONBOARDING_PATH } from "./routes"; import type { SessionState, SessionUser } from "./session"; import { AppShell } from "./shell/app-shell"; import { ComposerInsertionProvider } from "./shell/composer-insertion"; import { ShellChromeProvider } from "./shell/shell-chrome-provider"; -/** Any signed-out request for a path other than `/login` itself bounces - * there with `?next=` so a successful sign-in returns to where the visitor - * meant to go — the URL is the source of truth for "where was I headed", - * not an implicit conditional swap in `App`. */ -function LoginRedirect({ path, navigate }: { readonly path: string; readonly navigate: Navigate }) { - useEffect(() => { - navigate(buildLoginRedirect(path)); - }, [path, navigate]); - return null; -} - -/** An already-authed visit to `/login` (a stale tab, a bookmark) bounces - * home rather than showing the sign-in form to someone already signed in. */ -function LoginBounceHome({ navigate }: { readonly navigate: Navigate }) { - useEffect(() => { - navigate("/"); - }, [navigate]); - return null; -} - /** * Onboarding renders above the shell entirely — no rail, no col2, no bench * dock, nothing that implies a workbench already exists. An account the @@ -150,8 +131,10 @@ export function App({ ); case "signed-out": + // The URL is the source of truth for "where was I headed": a + // signed-out request for another path carries `?next=` to login. if (path !== LOGIN_PATH) { - return ; + return ; } return ; case "error": @@ -170,8 +153,10 @@ export function App({ ); case "signed-in": + // An already-authed visit to `/login` (a stale tab, a bookmark) + // bounces home rather than showing the sign-in form again. if (path === LOGIN_PATH) { - return ; + return ; } if (path === ONBOARDING_PATH) { return ; diff --git a/apps/web/src/auth/dither-background.tsx b/apps/web/src/auth/dither-background.tsx index 4db66d63a..d05deb457 100644 --- a/apps/web/src/auth/dither-background.tsx +++ b/apps/web/src/auth/dither-background.tsx @@ -1,5 +1,3 @@ -import { useEffect, useRef } from "react"; - // 8x8 ordered Bayer threshold matrix (same as the corbits dither shader). // prettier-ignore const BAYER = [ @@ -39,10 +37,9 @@ const ASSET = "/images/hero-dither.png"; // same-origin source image * static frame, re-evaluated when the OS setting toggles). */ export function DitherBackground({ className }: { className?: string }) { - const ref = useRef(null); - - useEffect(() => { - const canvas = ref.current; + // The animation belongs to the canvas element, so it starts and stops with + // it: a ref callback with a cleanup, never an effect reaching for a ref. + const attach = (canvas: HTMLCanvasElement | null) => { if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; @@ -254,11 +251,11 @@ export function DitherBackground({ className }: { className?: string }) { document.removeEventListener("visibilitychange", onVisibility); window.removeEventListener("pointermove", onMove); }; - }, []); + }; return ( { + // Picked and persisted once, as the card mounts — the rotation advances + // per page load, never while the page is open. + const [index] = useState(() => { + const next = nextIndex(); try { - localStorage.setItem(STORAGE_KEY, String(index)); + localStorage.setItem(STORAGE_KEY, String(next)); } catch { // localStorage unavailable (private mode / blocked) — rotation just // restarts from the first quote next load. } - }, [index]); + return next; + }); const current = QUOTES[index % QUOTES.length]; if (current === undefined) return null; diff --git a/apps/web/src/bench-context.tsx b/apps/web/src/bench-context.tsx index e4605fe5b..b0be365a3 100644 --- a/apps/web/src/bench-context.tsx +++ b/apps/web/src/bench-context.tsx @@ -6,7 +6,7 @@ import { isRawIdentifier } from "@/bench"; import { useQueryClient } from "@tanstack/react-query"; -import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { createContext, useContext, useMemo, useState } from "react"; import type { ReactNode } from "react"; import type { APIQuery } from "@/lib/api-query"; @@ -84,12 +84,12 @@ export function BenchProvider({ children }: { readonly children: ReactNode }) { const resolved = memberships.kind === "ready" ? resolveSelection(memberships.data.data, stored) : undefined; - useEffect(() => { - if (resolved !== undefined && resolved.tenantId !== stored) { - writeStoredTenantId(resolved.tenantId); - setStored(resolved.tenantId); - } - }, [resolved, stored]); + // The resolved bench is the stored one: written during render so no + // consumer reads a selection the store disagrees with. + if (resolved !== undefined && resolved.tenantId !== stored) { + writeStoredTenantId(resolved.tenantId); + setStored(resolved.tenantId); + } const value = useMemo( () => ({ diff --git a/apps/web/src/chat/blocks/approve-block.tsx b/apps/web/src/chat/blocks/approve-block.tsx index 20e1e2498..2ea790544 100644 --- a/apps/web/src/chat/blocks/approve-block.tsx +++ b/apps/web/src/chat/blocks/approve-block.tsx @@ -13,7 +13,8 @@ import { Button, toast } from "@corbits/react-ui"; import type { ApproveBlockData } from "../wire/blocks"; -import { useEffect, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; import { CHAT_STRINGS } from "../strings"; import { BlockCard } from "./block-card"; @@ -119,78 +120,90 @@ export function ApproveBlockView({ readonly data: ApproveBlockData; readonly actions?: ApprovalActions; }) { - const [live, setLive] = useState({ kind: "loading" }); - const [deciding, setDeciding] = useState(null); const [decisionError, setDecisionError] = useState(null); const [resolvedElsewhere, setResolvedElsewhere] = useState(false); - const [allowingStanding, setAllowingStanding] = useState(false); - useEffect(() => { - if (actions === undefined) return; - let cancelled = false; - setLive({ kind: "loading" }); - setResolvedElsewhere(false); - actions.getStatus(data.approvalId).then((result) => { - if (!cancelled) setLive(result); - }); - return () => { - cancelled = true; - }; - }, [actions, data.approvalId]); + const status = useQuery({ + queryKey: ["approval-status", data.approvalId], + queryFn: () => + actions === undefined + ? Promise.resolve({ kind: "loading" }) + : actions.getStatus(data.approvalId), + enabled: actions !== undefined, + }); + const live: ApprovalStatusQuery = status.data ?? { kind: "loading" }; + + // Never trust a decision response (or a local guess) over the platform's + // own state — every outcome, success or failure alike, re-reads the + // status above and renders only what comes back. + const decideMutation = useMutation({ + mutationFn: (kind: "approve" | "reject") => { + if (actions === undefined) throw new Error("approval actions unavailable"); + const call = kind === "approve" ? actions.approve : actions.reject; + return call(data.approvalId); + }, + onSuccess: (result, kind) => { + if (result.kind === "resolved") { + setResolvedElsewhere(false); + toast( + kind === "approve" + ? CHAT_STRINGS.blockApproveStatusApproved + : CHAT_STRINGS.blockApproveStatusRejected, + ); + } else if (result.kind === "conflict") { + // Someone/something else resolved this first. There is nothing to + // retry — the refreshed terminal status speaks, with a calmer note + // than a bare error. + setResolvedElsewhere(true); + } else { + setResolvedElsewhere(false); + setDecisionError( + result.kind === "forbidden" + ? CHAT_STRINGS.blockApproveActionForbidden + : CHAT_STRINGS.blockApproveActionError, + ); + } + }, + onSettled: () => { + void status.refetch(); + }, + }); + + const allowStandingMutation = useMutation({ + mutationFn: () => { + if (actions?.allowStanding === undefined) { + throw new Error("standing approval unavailable"); + } + return actions.allowStanding(data.approvalId); + }, + onSuccess: (result) => { + if (result.kind === "resolved") { + toast(CHAT_STRINGS.blockApproveStatusApproved); + } else if (result.kind !== "conflict") { + setDecisionError( + result.kind === "forbidden" + ? CHAT_STRINGS.blockApproveActionForbidden + : CHAT_STRINGS.blockApproveActionError, + ); + } + }, + onSettled: () => { + void status.refetch(); + }, + }); + + const deciding: DecisionInFlight = decideMutation.isPending ? decideMutation.variables : null; + const allowingStanding = allowStandingMutation.isPending; function decide(kind: "approve" | "reject") { if (actions === undefined) return; - setDeciding(kind); setDecisionError(null); - const call = kind === "approve" ? actions.approve : actions.reject; - call(data.approvalId) - .then((result) => { - if (result.kind === "resolved") { - setResolvedElsewhere(false); - toast( - kind === "approve" - ? CHAT_STRINGS.blockApproveStatusApproved - : CHAT_STRINGS.blockApproveStatusRejected, - ); - } else if (result.kind === "conflict") { - // Someone/something else resolved this first. There is nothing - // to retry -- re-sync below and let the refreshed terminal - // status speak, with a calmer note than a bare error. - setResolvedElsewhere(true); - } else { - setResolvedElsewhere(false); - setDecisionError( - result.kind === "forbidden" - ? CHAT_STRINGS.blockApproveActionForbidden - : CHAT_STRINGS.blockApproveActionError, - ); - } - // Never trust the decision response (or a local guess) over the - // platform's own state -- re-read it after every outcome, success - // or failure alike, and render only what comes back. - return actions.getStatus(data.approvalId).then(setLive); - }) - .finally(() => setDeciding(null)); + decideMutation.mutate(kind); } function allowStanding() { if (actions?.allowStanding === undefined) return; - setAllowingStanding(true); - actions - .allowStanding(data.approvalId) - .then((result) => { - if (result.kind === "resolved") { - toast(CHAT_STRINGS.blockApproveStatusApproved); - } else if (result.kind !== "conflict") { - setDecisionError( - result.kind === "forbidden" - ? CHAT_STRINGS.blockApproveActionForbidden - : CHAT_STRINGS.blockApproveActionError, - ); - } - return actions.getStatus(data.approvalId).then(setLive); - }) - .finally(() => setAllowingStanding(false)); + allowStandingMutation.mutate(); } const view = deriveApproveCardView({ diff --git a/apps/web/src/chat/blocks/connect-service-block-container.tsx b/apps/web/src/chat/blocks/connect-service-block-container.tsx index 25d64bc1c..0be54bf0e 100644 --- a/apps/web/src/chat/blocks/connect-service-block-container.tsx +++ b/apps/web/src/chat/blocks/connect-service-block-container.tsx @@ -7,7 +7,8 @@ // disconnected key-paste-free framing with a disabled-by-inaction // connect that goes nowhere, matching the "no port, no feature" // fallback every other block uses. -import { useEffect, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; import type { ConnectServiceBlockData } from "../wire/blocks"; import type { ConnectServiceActions, ConnectServiceQuery } from "./connect-service-actions"; @@ -20,24 +21,29 @@ export function ConnectServiceBlockContainer({ readonly data: ConnectServiceBlockData; readonly actions?: ConnectServiceActions; }) { - const [query, setQuery] = useState({ kind: "loading" }); + const queryClient = useQueryClient(); + // Keyed by connector, not by message: the connection is the tenant's, so + // every card for the same service shares one read. + const queryKey = ["connect-state", data.connectorId] as const; + const live = useQuery({ + queryKey, + queryFn: () => + actions === undefined + ? Promise.resolve({ kind: "loading" }) + : actions.getConnectState(data.connectorId), + enabled: actions !== undefined, + }); + const query: ConnectServiceQuery = live.data ?? { kind: "loading" }; + // The live fold is a subscription, so it writes into the cache the read + // above already owns rather than keeping a second copy beside it. useEffect(() => { if (actions === undefined) return; - let cancelled = false; - - function applyQuery(result: ConnectServiceQuery) { - if (cancelled) return; - setQuery(result); - } - - void actions.getConnectState(data.connectorId).then(applyQuery); - const unsubscribe = actions.subscribeConnectState(data.connectorId, applyQuery); - return () => { - cancelled = true; - unsubscribe(); - }; - }, [actions, data.connectorId]); + return actions.subscribeConnectState(data.connectorId, (result) => { + queryClient.setQueryData(queryKey, result); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- queryKey is derived from connectorId + }, [actions, data.connectorId, queryClient]); if (query.kind === "connected") { return ; diff --git a/apps/web/src/chat/turn-activity.tsx b/apps/web/src/chat/turn-activity.tsx index a6f379c77..1c380ecfa 100644 --- a/apps/web/src/chat/turn-activity.tsx +++ b/apps/web/src/chat/turn-activity.tsx @@ -338,9 +338,13 @@ export function useTurnActivity( } { const [activity, setActivity] = useState(null); - useEffect(() => { + // Another workbench's activity must never show for a frame, so the reset + // happens during render rather than after the paint that would leak it. + const [activityFor, setActivityFor] = useState(workbenchId); + if (activityFor !== workbenchId) { + setActivityFor(workbenchId); setActivity(null); - }, [workbenchId]); + } // Reset (clear + re-arm) on every event that actually changes the // activity object — an ignored event never resets the clock, since diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index e74631fc2..86f4c1c09 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -84,9 +84,13 @@ export function CommandPaletteProvider({ [selectedTenantId], ); - useEffect(() => { + // Recents are per bench: loaded during render when the store changes, so + // the palette never opens on the previous bench's entries. + const [recentsFor, setRecentsFor] = useState(recentsStore); + if (recentsFor !== recentsStore) { + setRecentsFor(recentsStore); setRecents(recentsStore?.load() ?? []); - }, [recentsStore]); + } const pushRecent = useCallback( (entry: RecentEntry) => { @@ -252,14 +256,14 @@ export function CommandPaletteProvider({ // content it was not opened from; a bench switch closes it too, dropping a // query whose results belonged to the bench being left. A tenant resolving // for the first time (null → a real bench, at boot) is not a switch. - const searchScope = useRef({ path, tenantId: selectedTenantId }); - useEffect(() => { - const previous = searchScope.current; - const routeChanged = previous.path !== path; - const benchSwitched = previous.tenantId !== null && previous.tenantId !== selectedTenantId; - searchScope.current = { path, tenantId: selectedTenantId }; + const [searchScope, setSearchScope] = useState({ path, tenantId: selectedTenantId }); + if (searchScope.path !== path || searchScope.tenantId !== selectedTenantId) { + const benchSwitched = + searchScope.tenantId !== null && searchScope.tenantId !== selectedTenantId; + const routeChanged = searchScope.path !== path; + setSearchScope({ path, tenantId: selectedTenantId }); if (routeChanged || benchSwitched) setCommandPaletteOpen(false); - }, [path, selectedTenantId]); + } const pageItems = useMemo( () => diff --git a/apps/web/src/command-palette/use-entity-search.ts b/apps/web/src/command-palette/use-entity-search.ts index c06a283fb..e307435b4 100644 --- a/apps/web/src/command-palette/use-entity-search.ts +++ b/apps/web/src/command-palette/use-entity-search.ts @@ -1,3 +1,4 @@ +import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { searchEntities } from "./entity-search"; @@ -57,14 +58,8 @@ export function useEntitySearch({ }: UseEntitySearchOptions): UseEntitySearchResult { const [debouncedQuery, setDebouncedQuery] = useState(""); const [offset, setOffset] = useState(0); - const [fetched, setFetched] = useState | null>( - null, - ); - const [fetching, setFetching] = useState(false); - const [error, setError] = useState(false); - const fetchToken = useRef(0); - // Hold the latest fetchers without making the fetch effect depend on - // their identity — callers (and tests) are free to hand in fresh arrow + // Hold the latest fetchers without making the search depend on their + // identity — callers (and tests) are free to hand in fresh arrow // functions each render without restarting the search or looping. const fetchersRef = useRef(sources); fetchersRef.current = sources; @@ -84,37 +79,27 @@ export function useEntitySearch({ return () => clearTimeout(timer); }, [query, enabled, debounceMs]); - useEffect(() => { - if (debouncedQuery.trim().length === 0) { - setFetched(null); - setFetching(false); - setError(false); - return; - } - const token = ++fetchToken.current; - setFetching(true); - setError(false); - const current = fetchersRef.current; - void Promise.all(current.map((source) => source.fetch())) - .then((results) => { - if (token !== fetchToken.current) return; - const map = new Map(); - for (let i = 0; i < current.length; i++) { - const source = current[i]; - if (!source) continue; - map.set(source.category, results[i] ?? []); - } - setFetched(map); - setFetching(false); - }) - .catch(() => { - if (token !== fetchToken.current) return; - setError(true); - setFetching(false); - }); - }, [debouncedQuery]); + // One fetch per committed search, cached across pages of it. A failure in + // any source surfaces as `error` rather than a partial result set. + const search = useQuery({ + queryKey: ["entity-search", debouncedQuery], + enabled: debouncedQuery.trim().length > 0, + queryFn: async (): Promise> => { + const current = fetchersRef.current; + const results = await Promise.all(current.map((source) => source.fetch())); + const map = new Map(); + for (let i = 0; i < current.length; i++) { + const source = current[i]; + if (!source) continue; + map.set(source.category, results[i] ?? []); + } + return map; + }, + }); - const loading = pending || fetching; + const fetched = debouncedQuery.trim().length === 0 ? null : (search.data ?? null); + const error = search.isError; + const loading = pending || (debouncedQuery.trim().length > 0 && search.isFetching); if (fetched === null || debouncedQuery.trim().length === 0) { // Nothing is fetched yet, so there is no next page to load — a no-op diff --git a/apps/web/src/library/artifact-text-editor.tsx b/apps/web/src/library/artifact-text-editor.tsx index 4c4a774ff..f8c0818b4 100644 --- a/apps/web/src/library/artifact-text-editor.tsx +++ b/apps/web/src/library/artifact-text-editor.tsx @@ -8,7 +8,7 @@ // This is a plain controlled textarea, debounced-saved through `onSave` // (the host wires that to the artifacts HTTP route's PUT). Single-user // editing only; no live co-viewer cursors, no shared doc, no awareness. -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { formatSaveStateLine, type ArtifactSaveState } from "./save-state"; @@ -42,16 +42,14 @@ export function ArtifactTextEditor({ const [value, setValue] = useState(content); const saveTimerRef = useRef | null>(null); - useEffect( - () => () => { - if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current); - }, - [], - ); - return (