Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Event>` 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 <other>` 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)
Expand Down
66 changes: 66 additions & 0 deletions __tests__/components/copy-button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<CopyButton text="test" />);
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(<CopyButton text="test" />);
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(<CopyButton text="test" className="custom-class" />);
const button = screen.getByTitle("Copy to clipboard");
Expand Down
28 changes: 22 additions & 6 deletions app/components/copy-button.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -25,6 +25,23 @@ interface CopyButtonProps {

export function CopyButton({ text, className }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const revertTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Fix TypeScript error on useRef initialization.

undefined is not assignable to ReturnType<typeof setTimeout>. You must explicitly include | undefined in the generic type parameter to avoid a compilation error.

🛠️ Proposed fix
-  const revertTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+  const revertTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const revertTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const revertTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/copy-button.tsx` at line 28, Update the revertTimerRef useRef
declaration to include undefined in its generic type, preserving the current
undefined initialization and timeout return type.


// 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 {
Expand All @@ -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 (
<button
Comment thread
hermes-exosphere marked this conversation as resolved.
type="button"
onClick={handleCopy}
title="Copy to clipboard"
className={cn(
Expand Down