Skip to content
Merged
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
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