Skip to content

feat(studio): ask for feedback when a render ends, not on a session counter - #3205

Merged
miguel-heygen merged 2 commits into
mainfrom
worktree-fix-studio-feedback
Aug 11, 2026
Merged

feat(studio): ask for feedback when a render ends, not on a session counter#3205
miguel-heygen merged 2 commits into
mainfrom
worktree-fix-studio-feedback

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

What

Studio's feedback prompt now fires when a render finishes or fails, instead of on a session counter, and the reports it collects carry enough context to act on.

  • Replaces the 32px inline bar with a card in the existing toast stack
  • Adds studio_feedback_shown / studio_feedback_dismissed / studio_feedback_interview_click, so the funnel is visible
  • A failed export and a crash skip the 0-10 score and ask what happened
  • Adds a crash prompt to the error boundary
  • Attaches a breadcrumb trail, render settings and outcome, and how the project was created

Before / after

Before: a 32px feedback strip pinned under the preview. After: the feedback card in the toast stack, with one-tap answers, and the red variant for a failed export.

The old bar's rating numbers are neutral-600 on neutral-900/80, which is why it reads as a disabled row rather than a control.

In the running Studio — bottom-right, sharing the toast stack, hovering a chip explains it on the line above the input:

The feedback card in the bottom-right of the running Studio, with the rotated follow-up question and a hovered chip explained inline.

On the crash screen — the prompt the error boundary renders, asking what the user was doing rather than what went wrong, since the stack trace already covers the latter:

The Studio crash screen with the feedback card below the Try again and Reload Studio buttons.
Before After
Trigger Every 10th session A render finishing, failing, or a crash
Placement 32px inline bar, pushes preview up Card in the toast stack, no layout shift
Rating targets 11 bare buttons, neutral-600 Native radios, resting fill, neutral-400
Follow-up Free text or nothing One rotated question, four to seven one-tap answers
Option help None Inline hint on hover and focus
Press feedback None active:scale-[0.97], 150ms ease-out
Keyboard No exit path Escape closes, Enter sends, arrows move the rating
Auto-dismiss 20s, always 30s, cancelled the moment you interact
Failure case Same NPS question Its own question, no score, error quoted back
Crash case Nothing Prompt on the crash screen
Visibility Submissions only Shown / dismissed (with reason) / submitted / interview click
Report content Rating, comment Plus breadcrumbs, render settings and outcome, project provenance

Why

The old bar fired on a session count, so it interrupted at a moment with no subject: nothing the user had just done, nothing to have an opinion about. It also emitted nothing when it appeared or when it was dismissed, which made the collection rate impossible to diagnose. A prompt nobody answers and a prompt that never renders looked identical from the outside.

Visually it read as a disabled row: 11 buttons at 11px in neutral-600 on a dark strip, with no resting affordance. And appearing mid-task pushed the whole preview stack up, which the old code carried a comment apologising for.

Separately, the reports it did collect were not actionable. A comment says what went wrong; it almost never says how to get there.

How

One trigger, one owner. feedbackTrigger owns eligibility and nothing else does: once per tab, thirty days after an answer, seven after a dismissal, never when telemetry is off (prompting someone whose response we would then drop wastes their attention). VITE_HYPERFRAMES_NO_FEEDBACK=1 still disables it entirely.

One hook, every failure path. The trigger watches the render job list rather than each of the four places a render can finish (server rejection, unreachable server, SSE terminal event, SSE connection drop), so paths added later are covered without touching the trigger. Renders loaded from disk history never fire it.

Reuses what exists. The card wears StudioToast's glass treatment and joins its stack, so there is no second visual language and no new CSS. The rating row is native radios, which gives arrow-key navigation, grouping and labels for free.

One question each, rotated across users. A corner card that asks three things gets answered by nobody. Each person gets one follow-up with one-tap answers, explained on a reserved line rather than a floating tooltip (the card is 340px in a corner; a bubble above the chips lands on the question, below lands on the input). Detractors are never given a rotated question, because they already have a specific complaint. Every option was checked against the code: an option naming a feature Studio already has would collect taps meaning "I could not find it", which is indistinguishable afterwards from "it does not exist".

Breadcrumbs cost one line. Every studio event already flows through trackEvent, so recording the trail there needs no new instrumentation and stays correct as events are added.

Provenance lives outside React. A crash unmounts the tree, so it is captured when the project loads and read from module scope when the crash prompt renders.

Privacy

Breadcrumbs and provenance carry names, enums and counts only. Values are copied from a fixed allowlist of short keys, and anything longer than a slug is dropped rather than truncated, so comments, file paths, stack traces and project titles cannot reach them even if a future event carries one. Tests assert this.

Where these responses land

Studio feedback goes to PostHog and nowhere else, which is what it did before this change too.

Worth stating because the CLI behaves differently: hyperframes feedback also forwards to the backend feedback endpoint via submitFeedback, on top of its PostHog event. Studio has never used that path, before or after this PR, so if you read CLI feedback anywhere other than PostHog, Studio responses will not show up there.

Nothing here changes that either way. Whether the two surfaces should share a delivery path is a product question, not a defect in this change, and closing it would need a field on the backend DTO: it is shaped around cli_version, and Studio reports from a crash or a failed export deliberately carry no rating.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)

Unit — 39 new tests: trigger eligibility and cooldowns, the detractor override, rotation, preset shape and the no-brands rule, breadcrumb rolling and privacy, provenance parsing and its failure modes, and the crash boundary rendering the prompt with no rating input.

Live — both render paths driven end to end against a running Studio on a production bundle, with real renders. Every PostHog request was intercepted and dropped, so nothing reached the project. Verified the emitted payload for a finished render, a failed export, the rotated follow-ups, each chip's hint, and the interview link.

Not covered — no live capture of a spontaneous crash. Three attempts to force one failed because Studio's guards held and it kept rendering, so the crash path is verified by component tests rather than by driving it. Touch devices see chip labels without hints, since the hint is revealed on hover and focus.

…ounter

The feedback bar fired on a session count, so it interrupted at a moment
with no subject, and it emitted nothing when it appeared or was dismissed.
That made the collection rate impossible to diagnose: a prompt nobody
answers and a prompt that never renders looked identical.

The prompt now fires when a render this tab started reaches its outcome,
which is the same moment the CLI asks. A failed export and a crash skip
the 0-10 score and ask what happened instead, since rating an export you
never received is a question with no useful answer.

Replaces the 32px inline bar with a card in the existing toast stack, so
it no longer pushes the preview up mid-task, and reuses StudioToast's
glass treatment rather than adding a second visual language.

Reports now carry what is needed to act on them: a breadcrumb trail of
the run-up, the render settings and outcome, and how the project was
made. Breadcrumbs come from one hook in trackEvent, so every existing and
future studio event joins the trail without its own instrumentation.

Eligibility lives in one module: once per tab, thirty days after an
answer, seven after a dismissal, and never when telemetry is off, because
prompting someone whose response would be dropped wastes their attention.
VITE_HYPERFRAMES_NO_FEEDBACK=1 still disables it entirely.

Breadcrumbs and provenance carry names, enums and counts only. Values
come from a fixed allowlist, so comments, paths, stack traces and project
titles cannot reach them.
if (!scaffolded) return;

try {
const res = await fetch(`/api/projects/${projectId}/files/${encodeURIComponent(CONFIG_FILE)}`);

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Findings

Overall this is a careful rewrite. Trigger, dedup, telemetry-opt-out, and privacy work are solid. All findings below are discussion-level, none block merge.

1. useRenderQueue trigger has no unit test. packages/studio/src/components/renders/useRenderQueue.ts:~383-413 (the new useEffect over jobs) encodes the core hypothesis of this PR: fire once per terminal outcome, skip cancelled, skip history-loaded jobs (via sessionJobs.current), never re-fire (via promptedJobIds.current). Verified only by manual live testing. A regression here silently breaks the whole premise. A dedicated test — synthetic jobs list transitioning rendering → complete, → failed, → cancelled, plus a history-loaded job whose id is absent from sessionJobs — is cheap insurance. Not blocking, strongly recommended for a follow-up.

2. Stale ids in trackStudioFeedback doc-comment. packages/studio/src/telemetry/events.ts — the new question field JSDoc lists "remove" | "borrow" | "fix" | "detractor" | "failure". Actual ids emitted are remove | workaround | fix | detractor | failure | crash. borrow is fabricated; workaround and crash are missing. Nit, but this is the contract for whoever writes the PostHog insight.

3. Cross-project provenance race. packages/studio/src/components/feedback/projectProvenance.ts uses a module-scope let snapshot keyed by nothing. packages/studio/src/hooks/useFileTree.ts fires void captureProjectProvenance(projectId, ...) on every project load without cancellation. If the user switches projects mid-fetch and renders quickly, feedback attaches the previous project's project_scaffolded / project_authoring_skill / counts. Rare, real. Cheap fix: gate the write with the current projectId (capture at call time, compare on resolve). Module scope is right for surviving the crash unmount; the projectId gate is the missing half.

4. Stranded storage keys from the old bar. Old StudioFeedbackBar wrote hyperframes-studio:feedbackSessionCount, feedbackLastPromptedAt, feedbackSessionCounted. The new trigger uses different keys (feedbackAnsweredAt, feedbackDismissedAt, feedbackAskedThisSession). No cleanup pass — every existing user carries dead bytes forever. Also no migration: someone who dismissed the old bar last week gets a fresh render-end prompt immediately (arguable this is intentional, since the old dismissal wasn't opinion about the new prompt). Consider a one-shot localStorage.removeItem sweep at trigger-module load. Nit.

5. Crash prompt suppressed by an earlier session dismissal — worth a discussion box. In CrashFeedbackPrompt.tsx and feedbackTrigger.ts isEligible, a crash goes through the same askedThisSession + cooldown gate as a render prompt. So a user who dismissed a render_complete ask this session gets no crash prompt when Studio then crashes. Inline rationale is consistency; the counter-argument is that a crash is a categorically higher-signal moment and dismissing "recommend HyperFrames?" is not consent to being asked about crashes. Consider letting reason === "crash" bypass the askedThisSession slot (but still honor answeredAt cooldown). Not blocking; worth naming the decision explicitly in the commit or a code comment.

6. sessionJobs.current and promptedJobIds.current grow unbounded. useRenderQueue.ts never clears these on project switch or across a long-lived tab with hundreds of renders. Small memory tail, real. Cheap sweep on projectId change (existing cleanup effect at bottom of the hook) is the natural spot.

7. role="dialog" without focus management. StudioFeedbackCard.tsx root uses role="dialog" with aria-label. It's a corner toast, not modal — no focus trap, no autofocus on open, root div isn't focusable so Escape only lands once a child owns focus. role="region" or role="complementary" matches reality and avoids the a11y contract violation. Nit.

CI note (non-blocking)

Detect changes reports FAILURE in 3 slots with exit code 1, but downstream shards ran (33 SUCCESS / 66 SKIPPED — the skips are exactly what a working Detect-changes produces). Appears to be a preflight-reporter infra issue, not a code failure. main at dc438311 is green on all checks. Worth spot-checking whether required-check gating trips on it.

Verdict

APPROVE. Trigger discipline (single owner, session cap, per-job dedup, telemetry-opt-out honored, cancelled skipped, history-loaded skipped) is well thought through. Privacy work is genuinely careful (breadcrumb allowlist + length gate, slug-only skill, no titles/paths, tests assert exclusion). The funnel events fix a real observability hole in the old bar. Findings above are quality tail — the untested render-queue effect (1) is the one I'd most like to see landed as a follow-up, and the doc-comment (2) plus provenance-race (3) are worth a five-minute pass. Nothing blocks merge.

— Via

The deletion guard is absolute by design, and the replacement shares too
little content with StudioFeedbackBar.tsx for git to pair the two as a
rename, so an intentional removal reads as loss.

Adds a named allowlist rather than a flag or an env var: a blanket
override would be reached for by the branch deleting something by
accident, which is the case the guard exists for. An entry has to name
the path and say why, so every intentional removal is a line in a diff
someone reviews.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed at d195692d.

Read the diff in five dimensions (trigger substitution, removed-check side effects, observability parity, privacy allowlist, wire-in + code quality). Vance's earlier review (approve at same SHA) covers most of what I'd otherwise be flagging inline — I strongly concur on:

  • useRenderQueue effect has no unit test (his #1) — this is the crown-jewel seam per your own PR body; a table-driven test over rendering → complete/failed/cancelled transitions + a history-loaded job is the highest-ROI follow-up.
  • question doc-comment drift at telemetry/events.ts:158 (his #2) — the doc says "remove" | "borrow" | "fix" | "detractor" | "failure" but the code emits remove | workaround | fix | detractor | failure | crash; borrow doesn't exist and workaround + crash are missing.
  • useFileTree.ts:34 provenance race (his #3) — void captureProjectProvenance(projectId, ...) fires without cancellation. Module scope is right for surviving the crash unmount, but a projectId re-check on resolve is the missing half.
  • Stranded old-bar storage keys (his #4) and role="dialog" a11y contract (his #7).
  • His #5 (crash prompt suppressed by an earlier session dismissal in the same tab) is genuinely worth naming as an explicit decision — a crash is a categorically higher-signal moment than a satisfied render, and the rationale for gating them behind the same askedThisSession cap deserves a ponytail: note in isEligible so a future reader doesn't quietly loosen it.

I've added inline comments only for the concerns I don't see in Vance's set. Two of them are cross-cutting enough to belong here in the body rather than a specific line:

Cross-cutting concerns

1. PostHog Surveys → plain product event is a silent break for existing dashboards. The old trackStudioFeedback emitted "survey sent" with $survey_id: "studio_experience", $survey_response, $survey_response_2 — the PostHog Surveys product's own event shape. The new code deliberately opts out ("Plain product event, not a PostHog survey response" at events.ts:170-172) and emits studio_feedback. That's a design call I think is right — the old event was Surveys-shaped but the old bar was never a real Survey — but any HeyGen PostHog Insight, Funnel, warehouse export, or the Surveys UI itself that filters on event = "survey sent" OR properties.$survey_id = "studio_experience" OR properties.$survey_response silently stops receiving data on rollout. Given the old bar has been running for months, this is worth doing before merge, not after: grep the analytics / dashboards / Snowflake config for survey sent and studio_experience, either recreate the consumers on studio_feedback or confirm nobody depended on it. If the funnel had five responses over three months (per Vance's read), the practical blast radius may be zero — but the audit is what tells you that, not the assumption.

2. Rating scale silently flips 1-5 → 0-10. Old bar wrote $survey_response: <1-5 int>; new event writes rating: <0-10 int> with an explicit rating_scale: 10 co-property (events.ts:181) — good defensive design, but the co-property is only load-bearing if downstream consumers read it. Any alert like "avg rating < 3", any anomaly detector fit to the old distribution, any dashboard's default numeric filter now means the opposite of what it did last week. The PR body enumerates the wire changes carefully in the "Before / after" table but doesn't call out the scale change specifically. Worth a line in the PR body / commit message so the analytics team can either update their queries or accept the flip explicitly.

Nits (inline)

  • Slug check on breadcrumbs is length-only, not shape — inline on breadcrumbs.ts.
  • Clock-skew cooldown lockout on future-dated timestamps — inline on feedbackTrigger.ts.
  • StrictMode: markAskedThisSession() fires before the listener actually receives the request — inline on feedbackTrigger.ts.
  • Dismiss events carry no context while submit does — inline on events.ts.
  • useRenderQueue effect re-scans on every SSE progress tick (idempotent, just wasteful) — inline.
  • _interview_click drops render_id while every other event carries it — inline.
  • Stale mock (body-only because the file isn't in the diff): packages/studio/src/components/EditorShell.selectionSync.test.tsx:55 still vi.mock("./StudioFeedbackBar", () => ({ StudioFeedbackBar: () => null })). StudioFeedbackBar is deleted in this PR. Vitest tolerates hoisted mocks for modules that are never imported, so this doesn't fail — but it silently rots. Cheap cleanup while the seam is fresh.

Minor PR-body observation

"The card wears StudioToast's glass treatment and joins its stack, so there is no second visual language and no new CSS." — the "no second visual language" claim holds by inspection, but the glass paint (linear-gradient(135deg, rgba(38,38,38,0.55)…) + backdropFilter: blur(16px) saturate(1.6)) is inlined in StudioFeedbackCard.tsx:214-228 as CSS-in-JS, not a shared token/util. Technically new CSS, just co-located. Minor.

What I didn't verify

  • Did not run Studio live against the running production bundle to confirm the reported end-to-end payload shape. Trusting Miguel's manual verification.
  • Did not audit HeyGen's PostHog dashboards / warehouse exports for consumers of "survey sent" — that's the ask in concern #1 and is yours to run.
  • Did not attempt to force a real crash to exercise the crash prompt end-to-end; the component test coverage there is thin (renders the card but doesn't drive submit through the boundary) — flagging as a follow-up rather than a blocker, agreeing with Vance's #1 shape.

Otherwise LGTM on the code. The concerns above are shipping-side (dashboards) and text (PR body scale mention) rather than code changes.

Review by Rames D Jusso

function detailFor(properties: Record<string, unknown>): string {
for (const key of DETAIL_KEYS) {
const value = properties[key];
if (typeof value === "string" && value.length > 0 && value.length <= 24) return `:${value}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Privacy: "slug" boundary is length-only, not shape.

The PR body promises that "comments, file paths, stack traces and project titles cannot reach [breadcrumbs] even if a future event carries one". The code enforces something narrower — an 8-key allowlist plus value.length <= 24 — which is genuinely strong against the four PII classes named IN their typical shape (long strings, newlines, paths). But it doesn't hold in general against short PII-shaped values under an allowlisted key. Concrete examples that would slip through today:

  • trackEvent("studio_x", { reason: "/etc/passwd" }) — 11 chars, key allowlisted → surfaces as x:/etc/passwd in the breadcrumb.
  • trackEvent("studio_x", { reason: "boss@corp.com" }) — 13 chars → surfaces.
  • trackEvent("studio_x", { via: "C:\\Users\\Alice" }) — 15 chars → surfaces.

This requires a future developer to actively assign PII to one of the eight discriminator keys, so the practical risk today is a callsite bug rather than a design hole. But two things to consider:

(a) The four PII classes tested (breadcrumbs.test.ts:41-59) are comment, error_message, stack_trace — three keys, none of which are in DETAIL_KEYS to begin with, so their exclusion is trivially true by the key allowlist. projectTitle (the fourth PII class in the PR body enumeration) isn't tested by name. A test that INJECTS a short PII-shaped value under an allowlisted key (e.g. reason: "/etc/passwd") would either lock the guarantee or document that this shape is knowingly permitted.

(b) Cheap tighten: replace the length gate with a slug regex (something like /^[a-z0-9][a-z0-9_-]{0,23}$/i) — matches the authoringSkill regex in projectProvenance.ts:22-23, brings the two mechanisms into parity, and makes the PR body promise literal.

Either resolution is fine (test-only, or tighten the check); the current state (strong promise + weaker check + partial tests) is what worries me. [[feedback_code_vs_stated_contract]].

— Rames D Jusso

// drop on the floor. Ask only the people we can actually hear.
if (!browserTelemetryAllowed()) return false;
if (askedThisSession()) return false;
if (now - readTimestamp(STORAGE_KEYS.answeredAt) < ANSWERED_COOLDOWN_MS) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Clock-skew cooldown lockout.

if (now - readTimestamp(STORAGE_KEYS.answeredAt) < ANSWERED_COOLDOWN_MS) return false;
if (now - readTimestamp(STORAGE_KEYS.dismissedAt) < DISMISSED_COOLDOWN_MS) return false;

Repro: user answers once with system clock set to a future date (accidental clock roll, calendar app + system time desync, timezone travel + wall-clock write, whatever) — writeTimestamp stores a future ms value. Later, back on real wall-clock: now - future is negative, which is always < ANSWERED_COOLDOWN_MS, so the cooldown is treated as still active until real time catches up to the future write. Since ANSWERED_COOLDOWN_MS = 30d, the effective lockout can be arbitrarily long depending on how far the future write landed.

Cheap fix: if (Math.max(0, now - stored) < COOLDOWN) return false; or normalize the write side (writeTimestamp(k, Math.min(now, ...))) or treat future stored values as 0 (never-answered) on read. Any of the three is a one-line change and closes the edge cleanly.

Low probability, but the failure mode is silent — the user just never sees the card again for months, which is exactly the population we DON'T want to lose feedback from.

— Rames D Jusso

*/
export function requestStudioFeedback(request: FeedbackRequest): void {
if (!listener) return;
if (!isEligible(Date.now())) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Mark-before-listener ordering is StrictMode-hostile and slightly wrong-shape even outside Strict.

if (!listener) return;
if (!isEligible(Date.now())) return;
markAskedThisSession();
listener(request);

The session slot is claimed before the listener actually delivers to the user. Two failure shapes:

  1. React 18 StrictMode double-invoke (dev + any Strict-wrapped test): first mount subscribes, requestStudioFeedback fires, markAskedThisSession() writes "1", listener(request) sets card state, StrictMode simulates unmount → unsubscribe() clears the listener, then second mount subscribes fresh. If the next request comes in immediately (e.g. from useRenderQueue's re-fired effect), askedThisSession() reads "1" and drops it at :237 — the card renders null. Prod is unaffected today because promptedJobIds on the caller side also dedupes and the request is not re-fired, but any Strict-wrapped integration test on this seam will flake.

  2. Even in prod, if listener throws (React error in the card's set-state during a subsequent render), the session slot is still marked — the user is effectively banned from feedback for the rest of the tab without ever having seen a card.

Fix shape: mark AFTER the listener returns, or fold the mark into the listener's own "I actually rendered" side effect. Something like:

listener(request);
markAskedThisSession();

or, safer:

listener({ ...request, onDelivered: markAskedThisSession });

and have StudioFeedbackCard call onDelivered in the effect that actually renders the request. Not blocking, but worth cleaning up while the seam is fresh.

— Rames D Jusso

},
): void {
trackEvent("studio_feedback_dismissed", {
reason: ctx.reason,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Dismiss events carry no context, while submit does.

trackStudioFeedbackDismissed sends reason + render_id + via + had_rating + source. trackStudioFeedback (submit) additionally spreads { ...projectProvenance(), ...ctx.context } (StudioFeedbackCard.tsx:181) — render settings, provenance flattened, breadcrumbs, doctor summary.

Given dismissals will 5-10× outnumber submits (the whole point of the funnel is that most people close the card), the largest cohort in the new instrumentation can only be broken down by reason (three buckets: complete / failed / crash) and via (three buckets: close / escape / timeout). Anyone asking "do dismissals correlate with render-duration percentile? / project scaffolding? / composition count?" needs context on the dismiss event too — otherwise the biggest slice of the funnel is opaque.

Suggested shape:

trackEvent("studio_feedback_dismissed", {
  reason: ctx.reason,
  render_id: ctx.render_id,
  via: ctx.via,
  had_rating: ctx.had_rating,
  source: "studio",
  ...projectProvenance(),
  ...(ctx.context ?? {}),
});

Dismiss context has the same privacy shape as submit context (allowlist + length gate), so no new PII risk. Not a blocker — the funnel works without it — but the analytics improvement is disproportionate for two lines.

— Rames D Jusso

},
});
}
}, [jobs]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Perf nit: the feedback-trigger effect re-scans all jobs on every SSE progress tick.

The useEffect(..., [jobs]) at :387-411 runs on every reference change of jobs. jobs gets a new reference on every progress event (setJobs(prev => ...) at :272-286 for progress + :288-305 for terminal + :293-303 for onerror), so during an active render this iterates the full job list ~10-60x/s to find nothing new (guarded by promptedJobIds).

Idempotent, so no correctness issue — just a hot path that Chrome DevTools' React profiler will notice if the tab is running many concurrent renders. Cheap fix: split into a useEffect gated on a memoized selector like useMemo(() => jobs.filter(j => TERMINAL_STATUSES.has(j.status)), [jobs]), so the effect only fires when at least one job transitions terminal.

Skippable if there's no observed perf issue in practice. Flagging so the option is on the table.

— Rames D Jusso

export function trackStudioFeedbackInterviewClick(ctx: { reason: string }): void {
trackEvent("studio_feedback_interview_click", {
reason: ctx.reason,
source: "studio",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 _interview_click drops render_id while every other event in the funnel carries it.

export function trackStudioFeedbackInterviewClick(ctx: { reason: string }): void {
  trackEvent("studio_feedback_interview_click", {
    reason: ctx.reason,
    source: "studio",
  });
}

Funnel queries that join shown → submit → interview_click for a given render outcome have to walk via distinct_id for that last hop, which is workable but noisier than the two-hop shown → submit join. Preserving render_id here would let the interview-click be attributed to a specific render outcome cleanly.

Nit — the interview link is a submit-flow tail and the caller already has the context via StudioFeedbackCard's state.

— Rames D Jusso

@miguel-heygen
miguel-heygen merged commit 20d9159 into main Aug 11, 2026
82 of 94 checks passed
@miguel-heygen
miguel-heygen deleted the worktree-fix-studio-feedback branch August 11, 2026 05:33
@miguel-heygen miguel-heygen mentioned this pull request Aug 11, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants