diff --git a/apps/desktop/src/renderer/lab/scratches/chat-performance.tsx b/apps/desktop/src/renderer/lab/scratches/chat-performance.tsx index 5a59b3090..ccf9fb876 100644 --- a/apps/desktop/src/renderer/lab/scratches/chat-performance.tsx +++ b/apps/desktop/src/renderer/lab/scratches/chat-performance.tsx @@ -1279,6 +1279,7 @@ function useStableList( * 3000 of them cost more than 3000 memo compares. */ const CountedTurn = React.memo(function CountedTurn(props: React.ComponentProps) { + // oxlint-disable-next-line react/globals -- counting renders is this probe's measurement turnRenders += 1; return ; }); diff --git a/apps/desktop/src/renderer/lab/scratches/host-connection.tsx b/apps/desktop/src/renderer/lab/scratches/host-connection.tsx index 03365c51c..afd5b9396 100644 --- a/apps/desktop/src/renderer/lab/scratches/host-connection.tsx +++ b/apps/desktop/src/renderer/lab/scratches/host-connection.tsx @@ -176,6 +176,8 @@ export default function HostConnectionScratch() { const retryRef = React.useRef(retrySucceeds); retryRef.current = retrySucceeds; const timers = React.useRef([]); + // The seeded hosts went offline relative to when the scratch opened. + const [openedAt] = React.useState(() => Date.now()); const source = React.useMemo(() => { const later = (ms: number, act: () => void) => { @@ -225,17 +227,17 @@ export default function HostConnectionScratch() { return createFakeHostSource( hostSnapshot( [ - remoteHost(HETZNER, "hetzner-1", hetzner("offline", Date.now())), + remoteHost(HETZNER, "hetzner-1", hetzner("offline", openedAt)), remoteHost(MINI, "mac-mini", { os: "macos", - link: { status: "offline", since: Date.now() - 2 * 3_600_000, retryAt: null }, + link: { status: "offline", since: openedAt - 2 * 3_600_000, retryAt: null }, }), ], { "prj-voltaic": HETZNER, "prj-harbor": MINI }, ), onCall, ); - }, []); + }, [openedAt]); React.useEffect(() => { const local = createFakeHostSource( diff --git a/apps/desktop/src/renderer/lab/scratches/model-tiers.tsx b/apps/desktop/src/renderer/lab/scratches/model-tiers.tsx index bd451fa21..44e53b08b 100644 --- a/apps/desktop/src/renderer/lab/scratches/model-tiers.tsx +++ b/apps/desktop/src/renderer/lab/scratches/model-tiers.tsx @@ -303,14 +303,21 @@ function labModelAccess(): ModelAccessClient { /* ------------------------------------------------------------------ scratch */ export default function ModelTiersScratch() { - const [generation, setGeneration] = React.useState(0); - const client = React.useMemo(labModelAccess, [generation]); + // Reset swaps in a fresh client and remounts the provider around it. + const [{ generation, client }, setLab] = React.useState(() => ({ + generation: 0, + client: labModelAccess(), + })); return (
- diff --git a/apps/desktop/src/renderer/lab/scratches/split-tabs.tsx b/apps/desktop/src/renderer/lab/scratches/split-tabs.tsx index 249823b15..37218c510 100644 --- a/apps/desktop/src/renderer/lab/scratches/split-tabs.tsx +++ b/apps/desktop/src/renderer/lab/scratches/split-tabs.tsx @@ -407,7 +407,7 @@ function PaneBody({ onFocus, }: PaneBodyProps): React.JSX.Element { const token = React.useRef({}); - const started = React.useRef(Date.now()); + const [started] = React.useState(() => Date.now()); React.useEffect(() => { onInstance(pane.id, token.current); @@ -477,7 +477,7 @@ function PaneBody({ className="font-mono text-label tabular-nums text-muted-foreground" title="How long this pane has been running. Back to 00:00 means it was killed and rebuilt." > - {elapsed(now - started.current)} + {elapsed(now - started)} message.id), ); for (const [id, label] of deliveryLabels) if (label === "Pending") ids.add(id); + // Each id is stamped with the time it was first seen pending and keeps + // that stamp on every later pass, so re-running this memo cannot move a + // clock; only a new id reads the time. + // oxlint-disable-next-line react/purity -- first-seen stamp, idempotent per id const now = Date.now(); const next = new Map([...ids].map((id) => [id, waitTimes.current.get(id) ?? now])); waitTimes.current = next; @@ -3007,7 +3011,13 @@ export const ChatTurn = React.memo(function ChatTurn({ * Refusals remain immediate; ordinary round trips never show waiting copy. */ function MessageDelivery({ label, since }: { label: string; since: number | undefined }) { const failed = label === "Not delivered" || label === "Delivery unconfirmed"; - const remaining = since === undefined ? 600 : Math.max(0, 600 - (Date.now() - since)); + // The clock is read once, at mount, so the wait does not restart per render. + // A stamp present at mount ends the wait at `since + 600`. A stamp that + // arrives later can end it before `since + 600`, but never sooner than + // 600ms after the tab mounted. + const [mountedAt] = React.useState(() => Date.now()); + const remaining = + since === undefined ? 600 : Math.min(600, Math.max(0, 600 - (mountedAt - since))); const waited = useDelayedPresence(!failed, remaining, remaining === 0); if (!failed && !waited) return null; const Icon = failed ? WarningIcon : ClockIcon; diff --git a/apps/desktop/src/renderer/src/components/chat/chat-plane.window.test.tsx b/apps/desktop/src/renderer/src/components/chat/chat-plane.window.test.tsx index df1d06480..003d0af97 100644 --- a/apps/desktop/src/renderer/src/components/chat/chat-plane.window.test.tsx +++ b/apps/desktop/src/renderer/src/components/chat/chat-plane.window.test.tsx @@ -2522,6 +2522,74 @@ describe("VC-769 one progress indicator through the live ChatPlane projection", ); }); + const submitted = (sequence: number, commandId: string, message: UIMessage) => + frame( + sequence, + { + kind: "command.recorded", + command: { + id: commandId, + sessionId: SESSION, + createdAt: 0, + route: null, + intent: { + kind: "message.submit", + reference: { id: `artifact-${sequence}`, mediaType: null, digest: null }, + }, + }, + }, + message, + ); + + it("keeps the wait's clock from the hold when ownership hands off part-way through it", async () => { + vi.useFakeTimers(); + const store = chatStore([]); + const message: UIMessage = { + id: "handoff-mid-wait", + role: "user", + parts: [{ type: "text", text: "Halfway there" }], + }; + useChatDraftsStore + .getState() + .holdMessage(SESSION, { id: message.id, text: "Halfway there", delivery: "send" }); + await mountPlane(store); + await advanceProgressTime(300); + await ingest(store, [submitted(1, "held-command", message)]); + expect(container?.querySelectorAll(".is-user")).toHaveLength(1); + await advanceProgressTime(299); + expect(container?.querySelector('[data-slot="message-delivery"]')).toBeNull(); + await advanceProgressTime(1); + expect(container?.querySelector('[data-slot="message-delivery"]')?.textContent).toBe( + "Sending…", + ); + }); + + it("never holds a wait longer than 600ms after the tab mounted when its stamp arrives late", async () => { + vi.useFakeTimers(); + const store = chatStore([]); + const drafts = useChatDraftsStore.getState(); + drafts.holdMessage(SESSION, { id: "late-stamp", text: "Line up", delivery: "queue" }); + // Accepted before the tab mounts: "Queued", with no pending stamp yet. + drafts.amendHeldMessage(SESSION, "late-stamp", (held) => ({ ...held, deliveryAccepted: true })); + await mountPlane(store); + expect(container?.querySelector('[data-slot="message-delivery"]')).toBeNull(); + await advanceProgressTime(300); + // Pending again, so the message is stamped now — 300ms after the tab mounted. + await act(async () => + drafts.amendHeldMessage(SESSION, "late-stamp", (held) => { + const next = { ...held }; + delete next.deliveryAccepted; + return next; + }), + ); + await advanceProgressTime(299); + expect(container?.querySelector('[data-slot="message-delivery"]')).toBeNull(); + await advanceProgressTime(1); + expect(container?.querySelector('[data-slot="message-delivery"]')?.textContent).toBe( + "Queuing…", + ); + }); + it.each(["reasoning", "text", "tool"] as const)( "restores Working during a transport retry with stale %s output", async (kind) => { diff --git a/apps/desktop/src/renderer/src/components/chat/empty/streak-visual.tsx b/apps/desktop/src/renderer/src/components/chat/empty/streak-visual.tsx index a4376127d..98d06d59a 100644 --- a/apps/desktop/src/renderer/src/components/chat/empty/streak-visual.tsx +++ b/apps/desktop/src/renderer/src/components/chat/empty/streak-visual.tsx @@ -36,7 +36,7 @@ export function StreakVisual() { // The window is a fact about now, so it is taken once per mount rather than // per render — a grid whose first cell slid while the pointer was over it // would be a different chart under the same tooltip. - const now = React.useMemo(() => Date.now(), []); + const [now] = React.useState(() => Date.now()); React.useEffect(() => { void refresh(streakWindowStart(now)); }, [now, refresh]); diff --git a/apps/desktop/src/renderer/src/components/ui/sidebar.tsx b/apps/desktop/src/renderer/src/components/ui/sidebar.tsx index 77b99eea5..b0f602076 100644 --- a/apps/desktop/src/renderer/src/components/ui/sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/ui/sidebar.tsx @@ -491,10 +491,8 @@ function SidebarMenuSkeleton({ }: React.ComponentProps<"div"> & { showIcon?: boolean; }) { - // Random width between 50 to 90%. - const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%`; - }, []); + // Random width between 50 to 90%, drawn once when the skeleton mounts. + const [width] = React.useState(() => `${Math.floor(Math.random() * 40) + 50}%`); return (
Date.now()); + const askedAt = React.useRef(mountedAt); React.useEffect( () => () => { requestId.current += 1; diff --git a/vite.config.ts b/vite.config.ts index 19a1f4a9d..13d2fe12a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -79,10 +79,12 @@ export default defineConfig({ // React Compiler rules remain individually disabled pending adoption // (VC-216, VC-211). oxlint 1.79 split the inactive compiler rule into - // these ten rules (oxc #25500, #25830, #25840). Keep the React plugin and - // error categories enabled so other rules and newly added rules still - // enforce. Remove each override only with its corresponding behavior - // fixes and tests; `vp lint` reports the current findings. + // ten rules (oxc #25500, #25830, #25840); `globals`, `purity` and + // `use-memo` are adopted, the seven below are not yet (VC-784). Keep the + // React plugin and error categories enabled so other rules and newly + // added rules still enforce. Remove each override only with its + // corresponding behavior fixes and tests; `vp lint` reports the current + // findings. // Keep the override set scoped to compiler adoption (VC-216); preserve // existing error categories for unrelated rules (VC-211). Each enabled // rule needs its behavior regression coverage (VC-216). Remove obsolete @@ -94,10 +96,20 @@ export default defineConfig({ "react/immutability": "off", "react/memo-dependencies": "off", "react/preserve-manual-memoization": "off", - "react/globals": "off", - "react/purity": "off", - "react/use-memo": "off", }, + overrides: [ + { + // A test's probe component exists to hand render-time values to the + // assertions around it: `function Probe() { handle = useThing(); }` + // captures a hook's result, and `renders += 1` counts renders because + // the render count IS what the test measures. Writing an outer + // variable during render is the point of those probes, not a hazard, + // and no test is compiled by React Compiler. Product code keeps the + // rule at error. + files: ["**/*.test.ts", "**/*.test.tsx"], + rules: { "react/globals": "off" }, + }, + ], }, staged: { // `vp fmt` (Oxfmt) aborts with "Expected at least one target file" when