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
This commit is contained in:
Miguel Ángel
2026-05-28 12:17:47 -04:00
committed by GitHub
parent b7b855845a
commit d625dc8509
11 changed files with 552 additions and 99 deletions
@@ -0,0 +1,199 @@
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>
);
});
@@ -3,6 +3,7 @@ import { NLELayout } from "./nle/NLELayout";
import { CaptionOverlay } from "../captions/components/CaptionOverlay";
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
import { DomEditOverlay } from "./editor/DomEditOverlay";
import { StudioFeedbackBar } from "./StudioFeedbackBar";
import type { TimelineElement } from "../player";
import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
import {
@@ -53,6 +54,7 @@ export interface StudioPreviewAreaProps {
blockPreview?: BlockPreviewInfo | null;
}
// fallow-ignore-next-line complexity
export function StudioPreviewArea({
timelineToolbar,
renderClipContent,
@@ -102,100 +104,103 @@ export function StudioPreviewArea({
} = useDomEditContext();
return (
<div className="flex-1 relative min-w-0">
<NLELayout
projectId={projectId}
refreshKey={refreshKey}
activeCompositionPath={activeCompPath}
timelineToolbar={timelineToolbar}
renderClipContent={renderClipContent}
onDeleteElement={handleTimelineElementDelete}
onAssetDrop={handleTimelineAssetDrop}
onBlockDrop={handleTimelineBlockDrop}
onPreviewBlockDrop={handlePreviewBlockDrop}
onFileDrop={handleTimelineFileDrop}
onMoveElement={handleTimelineElementMove}
onResizeElement={handleTimelineElementResize}
onBlockedEditAttempt={handleBlockedTimelineEdit}
onSelectTimelineElement={handleTimelineElementSelect}
onCompIdToSrcChange={setCompIdToSrc}
onCompositionLoadingChange={setCompositionLoading}
onCompositionChange={(compPath) => {
// Sync activeCompPath when user drills down via timeline double-click
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
// Guard against no-op updates to prevent circular refresh cascades
// between activeCompPath → compositionStack → onCompositionChange.
if (compPath !== activeCompPath) {
setActiveCompPath(compPath);
refreshPreviewDocumentVersion();
}
}}
onIframeRef={handlePreviewIframeRef}
previewOverlay={
blockPreview ? (
<div className="absolute inset-0 z-30 bg-black pointer-events-none">
{blockPreview.videoUrl ? (
<video
src={blockPreview.videoUrl}
autoPlay
muted
loop
playsInline
className="w-full h-full object-contain"
/>
) : blockPreview.posterUrl ? (
<img
src={blockPreview.posterUrl}
alt={blockPreview.title}
className="w-full h-full object-contain"
/>
) : null}
</div>
) : captionEditMode ? (
<CaptionOverlay iframeRef={previewIframeRef} />
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
<DomEditOverlay
iframeRef={previewIframeRef}
activeCompositionPath={activeCompPath}
hoverSelection={
STUDIO_PREVIEW_SELECTION_ENABLED &&
!captionEditMode &&
!compositionLoading &&
!isPlaying
? domEditHoverSelection
: null
}
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
onCanvasMouseDown={handlePreviewCanvasMouseDown}
onCanvasPointerMove={handlePreviewCanvasPointerMove}
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
onSelectionChange={applyDomSelection}
onBlockedMove={handleBlockedDomMove}
onManualDragStart={handleDomManualDragStart}
onPathOffsetCommit={handleDomPathOffsetCommit}
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
onBoxSizeCommit={handleDomBoxSizeCommit}
onRotationCommit={handleDomRotationCommit}
/>
) : null
}
timelineFooter={
captionEditMode ? (
<div className="border-t border-neutral-800/30 flex-shrink-0" style={{ height: 60 }}>
<div className="flex items-center gap-1.5 px-2 py-0.5">
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
Captions
</span>
<div className="flex-1 flex flex-col relative min-w-0">
<div className="flex-1 min-h-0 relative">
<NLELayout
projectId={projectId}
refreshKey={refreshKey}
activeCompositionPath={activeCompPath}
timelineToolbar={timelineToolbar}
renderClipContent={renderClipContent}
onDeleteElement={handleTimelineElementDelete}
onAssetDrop={handleTimelineAssetDrop}
onBlockDrop={handleTimelineBlockDrop}
onPreviewBlockDrop={handlePreviewBlockDrop}
onFileDrop={handleTimelineFileDrop}
onMoveElement={handleTimelineElementMove}
onResizeElement={handleTimelineElementResize}
onBlockedEditAttempt={handleBlockedTimelineEdit}
onSelectTimelineElement={handleTimelineElementSelect}
onCompIdToSrcChange={setCompIdToSrc}
onCompositionLoadingChange={setCompositionLoading}
onCompositionChange={(compPath) => {
// Sync activeCompPath when user drills down via timeline double-click
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
// Guard against no-op updates to prevent circular refresh cascades
// between activeCompPath → compositionStack → onCompositionChange.
if (compPath !== activeCompPath) {
setActiveCompPath(compPath);
refreshPreviewDocumentVersion();
}
}}
onIframeRef={handlePreviewIframeRef}
previewOverlay={
blockPreview ? (
<div className="absolute inset-0 z-30 bg-black pointer-events-none">
{blockPreview.videoUrl ? (
<video
src={blockPreview.videoUrl}
autoPlay
muted
loop
playsInline
className="w-full h-full object-contain"
/>
) : blockPreview.posterUrl ? (
<img
src={blockPreview.posterUrl}
alt={blockPreview.title}
className="w-full h-full object-contain"
/>
) : null}
</div>
<CaptionTimeline pixelsPerSecond={100} />
</div>
) : undefined
}
timelineVisible={timelineVisible}
onToggleTimeline={toggleTimelineVisibility}
/>
) : captionEditMode ? (
<CaptionOverlay iframeRef={previewIframeRef} />
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
<DomEditOverlay
iframeRef={previewIframeRef}
activeCompositionPath={activeCompPath}
hoverSelection={
STUDIO_PREVIEW_SELECTION_ENABLED &&
!captionEditMode &&
!compositionLoading &&
!isPlaying
? domEditHoverSelection
: null
}
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
onCanvasMouseDown={handlePreviewCanvasMouseDown}
onCanvasPointerMove={handlePreviewCanvasPointerMove}
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
onSelectionChange={applyDomSelection}
onBlockedMove={handleBlockedDomMove}
onManualDragStart={handleDomManualDragStart}
onPathOffsetCommit={handleDomPathOffsetCommit}
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
onBoxSizeCommit={handleDomBoxSizeCommit}
onRotationCommit={handleDomRotationCommit}
/>
) : null
}
timelineFooter={
captionEditMode ? (
<div className="border-t border-neutral-800/30 flex-shrink-0" style={{ height: 60 }}>
<div className="flex items-center gap-1.5 px-2 py-0.5">
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
Captions
</span>
</div>
<CaptionTimeline pixelsPerSecond={100} />
</div>
) : undefined
}
timelineVisible={timelineVisible}
onToggleTimeline={toggleTimelineVisibility}
/>
</div>
<StudioFeedbackBar />
</div>
);
}
+32
View File
@@ -25,3 +25,35 @@ export function trackStudioRenderStart(props: {
composition: props.composition,
});
}
function getBrowserDoctorSummary(): string {
try {
const nav = navigator as Navigator & {
deviceMemory?: number;
connection?: { effectiveType?: string };
userAgentData?: { platform?: string };
};
const platform = nav.userAgentData?.platform ?? navigator.platform ?? "unknown";
const parts = [
`ua=${platform}`,
`screen=${screen.width}x${screen.height}@${devicePixelRatio}x`,
`lang=${navigator.language}`,
];
if (nav.deviceMemory) parts.push(`mem=${nav.deviceMemory}GB`);
if (nav.connection?.effectiveType) parts.push(`net=${nav.connection.effectiveType}`);
if (navigator.hardwareConcurrency) parts.push(`cpu=${navigator.hardwareConcurrency}cores`);
return parts.join(" ");
} catch {
return "";
}
}
export function trackStudioFeedback(props: { rating: number; comment?: string }): void {
trackEvent("survey sent", {
$survey_id: "studio_experience",
$survey_response: props.rating,
...(props.comment ? { $survey_response_2: props.comment } : {}),
doctor_summary: getBrowserDoctorSummary(),
source: "studio",
});
}