Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 0 additions & 2 deletions packages/studio/src/components/EditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
} from "./nle/useTimelineEditCallbacks";
import { NLEProvider, useNLEContext } from "./nle/NLEContext";
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
import { StudioFeedbackBar } from "./StudioFeedbackBar";
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
import { TimelineEditProvider } from "../contexts/TimelineEditContext";
Expand Down Expand Up @@ -189,7 +188,6 @@ export function EditorShell({
/>
</NLEProvider>
</TimelineEditProvider>
<StudioFeedbackBar />
</div>
);
}
Expand Down
97 changes: 97 additions & 0 deletions packages/studio/src/components/StudioErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// @vitest-environment happy-dom

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";

vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
vi.mock("../telemetry/policy", () => ({ browserTelemetryAllowed: () => true }));
vi.mock("../telemetry/events", () => ({
trackStudioFeedback: vi.fn(),
trackStudioFeedbackShown: vi.fn(),
trackStudioFeedbackDismissed: vi.fn(),
trackStudioFeedbackInterviewClick: vi.fn(),
}));

const { StudioErrorBoundary } = await import("./StudioErrorBoundary");
const { trackStudioFeedbackShown } = await import("../telemetry/events");

function Boom(): React.ReactElement {
throw new Error("timeline exploded");
}

let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
let consoleError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.clearAllMocks();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
// React logs the caught error itself; the boundary is the thing under test.
consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
act(() => root.unmount());
container.remove();
consoleError.mockRestore();
});

const renderCrashed = () =>
act(() => {
root.render(
<StudioErrorBoundary>
<Boom />
</StudioErrorBoundary>,
);
});

describe("StudioErrorBoundary", () => {
it("shows the crash screen with the error message and both recovery paths", () => {
renderCrashed();
expect(container.textContent).toContain("Something went wrong");
expect(container.textContent).toContain("timeline exploded");
expect(container.textContent).toContain("Try again");
expect(container.textContent).toContain("Reload Studio");
});

it("asks what the user was doing, since the stack trace cannot say", () => {
renderCrashed();
const card = container.querySelector('[aria-label="Send feedback to the HyperFrames team"]');
expect(card).not.toBeNull();
expect(card?.textContent).toContain("Studio crashed. What were you doing?");
});

it("offers one-tap answers instead of a 0-10 score", () => {
renderCrashed();
const card = container.querySelector('[aria-label="Send feedback to the HyperFrames team"]');
const chips = [...(card?.querySelectorAll("button") ?? [])]
.map((b) => b.textContent?.trim())
.filter((t) => t && t !== "Send");
expect(chips).toContain("Editing the timeline");
expect(chips).toContain("Just opened it");
// Rating a crash is a question with no useful answer.
expect(card?.querySelector('input[name="hf-studio-feedback-rating"]')).toBeNull();
});

it("reports the crash prompt to telemetry so the funnel is visible", () => {
renderCrashed();
expect(trackStudioFeedbackShown).toHaveBeenCalledWith(
expect.objectContaining({ reason: "crash" }),
);
});

it("stays quiet when the user already gave feedback recently", () => {
localStorage.setItem("hyperframes-studio:feedbackAnsweredAt", String(Date.now()));
renderCrashed();
// The crash screen itself still works; only the ask is suppressed.
expect(container.textContent).toContain("Something went wrong");
expect(
container.querySelector('[aria-label="Send feedback to the HyperFrames team"]'),
).toBeNull();
});
});
7 changes: 7 additions & 0 deletions packages/studio/src/components/StudioErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { CrashFeedbackPrompt } from "./feedback/CrashFeedbackPrompt";

interface Props {
children: ReactNode;
Expand Down Expand Up @@ -51,6 +52,12 @@ export class StudioErrorBoundary extends Component<Props, State> {
Reload Studio
</button>
</div>
{/* The crash report tells us what broke; only the user can tell us what
they were doing when it did. This is also the one screen where they
have nothing else to get on with. */}
<div className="mt-6">
<CrashFeedbackPrompt />
</div>
</div>
);
}
Expand Down
217 changes: 0 additions & 217 deletions packages/studio/src/components/StudioFeedbackBar.tsx

This file was deleted.

28 changes: 15 additions & 13 deletions packages/studio/src/components/StudioOverlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { LintModal } from "./LintModal";
import { AskAgentModal } from "./AskAgentModal";
import { StudioGlobalDragOverlay } from "./StudioGlobalDragOverlay";
import { StudioToast } from "./StudioToast";
import { StudioFeedbackCard } from "./feedback/StudioFeedbackCard";
import { buildAgentContextPreview } from "./editor/domEditingAgentPrompt";
import type { useDomEditSession } from "../hooks/useDomEditSession";
import type { useToast } from "../hooks/useToast";
Expand Down Expand Up @@ -78,19 +79,20 @@ export function StudioOverlays({
/>
)}
{dragOverlayActive && <StudioGlobalDragOverlay />}
{toasts.length > 0 && (
<div className="absolute bottom-6 right-6 z-[91] flex flex-col items-end gap-2">
{toasts.map((toast) => (
<StudioToast
key={toast.id}
message={toast.message}
tone={toast.tone}
leaving={toast.leaving}
onDismiss={() => dismissToast(toast.id)}
/>
))}
</div>
)}
{/* One bottom-right stack so the feedback card and toasts queue instead
of covering each other. Empty when nothing is showing. */}
<div className="absolute bottom-6 right-6 z-[91] flex flex-col items-end gap-2">
{toasts.map((toast) => (
<StudioToast
key={toast.id}
message={toast.message}
tone={toast.tone}
leaving={toast.leaving}
onDismiss={() => dismissToast(toast.id)}
/>
))}
<StudioFeedbackCard />
</div>
</>
);
}
Loading
Loading