Files
hyperframes/packages/studio/src/components/StudioFeedbackBar.tsx
T
Miguel Ángel d625dc8509 feat: post-render and Studio feedback collection via PostHog surveys (#1101)
* feat(cli): prompt for render satisfaction after successful renders

* feat: add text feedback, doctor context, and Studio render feedback UI

* feat(studio): replace render feedback with session-based Studio experience bar

Move the feedback prompt out of RenderQueueItem (where it triggered every
5th render) into a standalone StudioFeedbackBar mounted at the bottom of
the preview area. The new bar is session-gated (shows after the 5th studio
session), auto-dismisses after 20s, and respects a 30-day cooldown once
dismissed or submitted. Renames telemetry to trackStudioFeedback with a
"studio_experience" survey ID to reflect the broader scope.

* feat(studio): attach browser doctor summary to feedback events

* fix(studio): use recurring interval for feedback instead of one-time cooldown

* fix(cli): skip feedback prompt when an agent runtime is detected

* feat(cli): add hyperframes feedback command and agent render hint

- New `hyperframes feedback --rating <1-5> --comment "..."` command
  for submitting anonymous render satisfaction feedback via telemetry.
- When an AI agent runtime is detected after a render, print a dimmed
  hint to stdout so the agent can optionally call the command instead
  of silently skipping the readline prompt.
- Export getDoctorSummary from telemetry/feedback.ts to share the
  system-info collector between the interactive prompt and the CLI command.
- Register the command in cli.ts and help.ts under Settings.

* fix(studio): align feedback interval to every 15 sessions

* fix: show CLI feedback on first render, Studio every 10 sessions

* feat: add env flags to disable feedback prompts

* feat: env flags to configure feedback prompt frequency

* fix: address review — agent hint reachability, cadence gate, session debounce, deprecated API
2026-05-28 12:17:47 -04:00

200 lines
6.2 KiB
TypeScript

import { memo, useState, useCallback, useRef, useEffect } from "react";
import { trackStudioFeedback } from "../telemetry/events";
const DEFAULT_FEEDBACK_INTERVAL = 10;
const AUTO_DISMISS_MS = 20_000;
// fallow-ignore-next-line complexity
function getFeedbackInterval(): number {
try {
const v = import.meta.env.VITE_HYPERFRAMES_FEEDBACK_INTERVAL as string | undefined;
if (v) {
const n = parseInt(v, 10);
if (Number.isFinite(n) && n > 0) return n;
}
} catch {
// import.meta.env unavailable
}
return DEFAULT_FEEDBACK_INTERVAL;
}
const STORAGE_KEYS = {
sessionCount: "hyperframes-studio:feedbackSessionCount",
lastPromptedAt: "hyperframes-studio:feedbackLastPromptedAt",
} as const;
// fallow-ignore-next-line complexity
function shouldShowFeedback(): boolean {
try {
const count = parseInt(localStorage.getItem(STORAGE_KEYS.sessionCount) || "0", 10) || 0;
const lastAt = parseInt(localStorage.getItem(STORAGE_KEYS.lastPromptedAt) || "0", 10) || 0;
return count - lastAt >= getFeedbackInterval();
} catch {
return false;
}
}
const SESSION_COUNTED_KEY = "hyperframes-studio:feedbackSessionCounted";
// fallow-ignore-next-line complexity
function incrementSessionCount(): void {
try {
if (sessionStorage.getItem(SESSION_COUNTED_KEY)) return;
sessionStorage.setItem(SESSION_COUNTED_KEY, "1");
const count = parseInt(localStorage.getItem(STORAGE_KEYS.sessionCount) || "0", 10) || 0;
localStorage.setItem(STORAGE_KEYS.sessionCount, String(count + 1));
} catch {
// storage unavailable
}
}
function markPrompted(): void {
try {
const count = localStorage.getItem(STORAGE_KEYS.sessionCount) || "0";
localStorage.setItem(STORAGE_KEYS.lastPromptedAt, count);
} catch {
// localStorage unavailable
}
}
// fallow-ignore-next-line complexity
export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
const [visible, setVisible] = useState(false);
const [rating, setRating] = useState<number | null>(null);
const [comment, setComment] = useState("");
const [submitted, setSubmitted] = useState(false);
const [exiting, setExiting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// On mount: increment session count, check if we should show
useEffect(() => {
incrementSessionCount();
// Small delay so the bar doesn't flash on page load
const showTimer = setTimeout(() => {
if (shouldShowFeedback()) {
setVisible(true);
}
}, 3000);
return () => clearTimeout(showTimer);
}, []);
// Auto-dismiss timer — reset when user interacts (sets rating)
useEffect(() => {
if (!visible || rating !== null || submitted) return;
dismissTimerRef.current = setTimeout(() => {
handleDismiss();
}, AUTO_DISMISS_MS);
return () => {
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, rating, submitted]);
// Focus text input when rating is selected
useEffect(() => {
if (rating !== null && inputRef.current) {
inputRef.current.focus();
}
}, [rating]);
const handleDismiss = useCallback(() => {
setExiting(true);
markPrompted();
setTimeout(() => setVisible(false), 300);
}, []);
const handleSubmit = useCallback(() => {
if (rating === null) return;
trackStudioFeedback({
rating,
comment: comment.trim() || undefined,
});
setSubmitted(true);
markPrompted();
setTimeout(() => {
setExiting(true);
setTimeout(() => setVisible(false), 300);
}, 1500);
}, [rating, comment]);
const handleRating = useCallback((n: number) => {
setRating(n);
// Cancel auto-dismiss — user is engaged
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
dismissTimerRef.current = null;
}
}, []);
if (!visible) return null;
return (
<div
className={[
"flex items-center gap-3 px-4 h-8 border-t border-neutral-800/50 bg-neutral-900/80 text-[11px] transition-all duration-300",
exiting ? "opacity-0 translate-y-2" : "opacity-100 translate-y-0",
].join(" ")}
>
{submitted ? (
<span className="text-neutral-500">Thanks for the feedback!</span>
) : rating !== null ? (
<>
<input
ref={inputRef}
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
if (e.key === "Escape") handleDismiss();
}}
placeholder="Any details? (enter to send, esc to close)"
className="flex-1 bg-transparent border-none text-[11px] text-neutral-300 placeholder-neutral-600 outline-none"
maxLength={500}
/>
<button
onClick={handleSubmit}
className="text-[11px] text-neutral-500 hover:text-neutral-300 transition-colors flex-shrink-0"
>
send
</button>
</>
) : (
<>
<span className="text-neutral-500 flex-shrink-0">How's the Studio experience?</span>
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => handleRating(n)}
className="w-6 h-6 rounded text-[11px] text-neutral-600 hover:text-neutral-200 hover:bg-neutral-700/50 transition-colors"
>
{n}
</button>
))}
</div>
<div className="flex-1" />
<button
onClick={handleDismiss}
className="text-neutral-700 hover:text-neutral-400 transition-colors flex-shrink-0"
aria-label="Dismiss"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</>
)}
</div>
);
});