diff --git a/CHANGELOG.md b/CHANGELOG.md index dc4631a6..17f15b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Redirect the 13 AgentEye pages the upstream syncs deleted (`/agenteye/deployment`, `/agenteye/kubernetes-deployment`, `/agenteye/troubleshooting`, …) to a live page instead of hard-404ing. (#556) ### Fixes +- Clear the dashboard `CopyButton`'s “copied ✓” revert timer properly: a rapid re-click now cancels the previous click's still-armed timer before arming its own (so the checkmark can't flip back to the copy icon early), and a `useEffect` cleanup cancels it on unmount — no more React state-update-on-an-unmounted-component warning when the sessions table filters a row away mid-window. The timer id lives in a `useRef`. (#528) - Stop shipping npm lifecycle scripts, removing the `npm warn` every install printed. npm 12 (`allowScripts`, released 2026-07-08 and now the `latest` dist-tag) blocks dependency install scripts by default, so `npm install failproofai` warned `1 package had install scripts blocked … failproofai@… (postinstall: node scripts/postinstall.mjs)` and silently skipped the script; npm 11.16+ prints the advisory `allow-scripts` variant and still runs it. `allowScripts` is purely consumer-side — a package cannot opt itself in (verified: a package declaring `allowScripts`/`trustedDependencies` for *itself* is ignored) — so the only fix is to ship no install scripts. The `postinstall` script's install telemetry (`first_install` / `version_changed` / `package_installed`, with identical event names and properties) now fires from the CLI's first non-hook invocation via `lib/install-check.ts`, reporting at most once per version and no-op'ing on the steady-state path; it is deliberately kept out of `bin/failproofai.mjs`'s `--hook` fast path, which runs on every tool call. This also *recovers* telemetry already being dropped for bun, pnpm, and Yarn ≥4.14 users, which have blocked install scripts for some time. The script's `server.js` check and shadowed-PATH diagnosis were redundant — `scripts/launch.ts` already performs both at launch, with a better error message. `package_installed` now measures install→activation rather than raw installs, and same-version reinstalls (`direction: "reinstall"`) are no longer reported, as the CLI has no signal to detect them. The install-time welcome message is dropped; the `config` wizard's first-run redirect already handles onboarding. (#560) - Remove `scripts/preuninstall.mjs`, which never ran. npm honours no uninstall lifecycle scripts (verified with a probe package: `postinstall` fired, `preuninstall` did not), so the hook cleanup it appeared to perform has never happened — uninstalling failproofai leaves `__failproofai_hook__` entries behind in settings files. Removing the dead code makes the gap visible; cleanup needs an explicit `failproofai policies --uninstall` before removing the package. (#560) - Stop this repo's dogfood hooks from silently no-op'ing when `bun` isn't on the hook's PATH. All 75 hook commands across the 8 project-level agent-CLI configs fired `bun bin/failproofai.mjs --hook ` directly, so whenever the agent CLI was handed a PATH without bun — `npm i -g bun` installs into a single nvm version's bin dir, so `nvm use ` drops it; a macOS GUI launch inherits a launchd PATH built without `~/.zshrc` — every event died with exit 127 and the session ran with **zero** policy enforcement, saying nothing. The configs now call a dev-only `node scripts/dev-hook.mjs` launcher that locates bun across `PATH`, `$BUN_INSTALL/bin`, `~/.bun/bin`, the node execPath sibling, Homebrew/`/usr/local`, and every `~/.nvm/versions/node/*/bin`; installs it via npm if it is genuinely absent; builds `dist/index.js` when missing so `.failproofai/policies/*.mjs` can resolve `import ... from 'failproofai'`; then re-execs the real binary with `stdio: "inherit"` (byte-exact stdout — the deny contracts are JSON on stdout) and propagates the exit code verbatim (2 still means deny; signals map to 128+signum as `sh` did). A `command -v node` pre-check fronts each command so a missing node is a loud one-liner rather than silence — exit 2 on tool events, exit 1 on stop-class events, where exit 2 would mean "retry" and loop forever. The `.opencode` dev shim shares the same resolver and no longer reports a never-run hook as `exitCode: 0` (a silent allow). Production users on `npx -y failproofai` are unaffected. A new drift-guard test reads every committed dogfood config and asserts the launcher form, guard, exit-code split, and per-file command counts — these configs are generated by nothing and read by nothing, which is how #337's opencode shim drifted and silently no-op'd `block-read-outside-cwd` repo-wide. (#564) diff --git a/__tests__/components/copy-button.test.tsx b/__tests__/components/copy-button.test.tsx index 09ffd94a..3072ca5a 100644 --- a/__tests__/components/copy-button.test.tsx +++ b/__tests__/components/copy-button.test.tsx @@ -65,6 +65,72 @@ describe("CopyButton", () => { vi.useRealTimers(); }); + it("keeps the checkmark for the full 2s window after the last of two rapid clicks", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + vi.useFakeTimers({ shouldAdvanceTime: true }); + + render(); + const button = screen.getByTitle("Copy to clipboard"); + + // Click at t=0 (timer A would revert at t=2.0s) + await user.click(button); + expect(screen.getByTestId("check-icon")).toBeInTheDocument(); + + // Click again at t=1.5s — should reset the window (arm timer B, clear timer A) + act(() => { + vi.advanceTimersByTime(1500); + }); + await user.click(button); + expect(screen.getByTestId("check-icon")).toBeInTheDocument(); + + // t=2.0s: stale timer A must NOT flip the icon back early + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByTestId("check-icon")).toBeInTheDocument(); + expect(screen.queryByTestId("copy-icon")).not.toBeInTheDocument(); + + // t=3.5s (2.0s after the LAST click at t=1.5s): timer B reverts it + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(screen.getByTestId("copy-icon")).toBeInTheDocument(); + expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it("clears the armed revert timer on unmount", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + vi.useFakeTimers({ shouldAdvanceTime: true }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { unmount } = render(); + await user.click(screen.getByTitle("Copy to clipboard")); + expect(screen.getByTestId("check-icon")).toBeInTheDocument(); + + // The click armed the 2s revert timer — it is pending mid-window. + act(() => { + vi.advanceTimersByTime(500); + }); + expect(vi.getTimerCount()).toBe(1); + + // Unmounting before the timer fires must clear it, so nothing is left + // pending to call `setCopied` on the gone component. This is the + // load-bearing assertion: it bites without the useEffect cleanup. + unmount(); + expect(vi.getTimerCount()).toBe(0); + + // Secondary: advancing past the original window logs no error (e.g. a + // "state update on unmounted component" warning), belt-and-suspenders. + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(errorSpy).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + it("applies custom className", () => { render(); const button = screen.getByTitle("Copy to clipboard"); diff --git a/app/components/copy-button.tsx b/app/components/copy-button.tsx index 7640ff4b..70c1a8d3 100644 --- a/app/components/copy-button.tsx +++ b/app/components/copy-button.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { Copy, Check } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -25,6 +25,23 @@ interface CopyButtonProps { export function CopyButton({ text, className }: CopyButtonProps) { const [copied, setCopied] = useState(false); + const revertTimerRef = useRef>(undefined); + + // Clear any pending revert timer on unmount so `setCopied` never fires + // after the component is gone. + useEffect(() => { + return () => { + if (revertTimerRef.current) clearTimeout(revertTimerRef.current); + }; + }, []); + + const armRevertTimer = useCallback(() => { + // A re-click resets the 2s window rather than being cut short by the + // previous (stale) timer. + if (revertTimerRef.current) clearTimeout(revertTimerRef.current); + setCopied(true); + revertTimerRef.current = setTimeout(() => setCopied(false), 2000); + }, []); const handleCopy = useCallback(async () => { try { @@ -33,24 +50,23 @@ export function CopyButton({ text, className }: CopyButtonProps) { } else if (!fallbackCopyText(text)) { throw new Error("Both clipboard methods failed"); } - setCopied(true); - setTimeout(() => setCopied(false), 2000); + armRevertTimer(); } catch (err) { console.error("Failed to copy to clipboard:", err); // Try fallback if the modern API threw try { if (fallbackCopyText(text)) { - setCopied(true); - setTimeout(() => setCopied(false), 2000); + armRevertTimer(); } } catch { // Both methods failed — do nothing } } - }, [text]); + }, [text, armRevertTimer]); return (