@hyperframes/studio 0.8.56 → 0.8.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/assets/hyperframes-player-zGyOcwwh.js +464 -0
  2. package/dist/assets/{index-DFlILHJe.js → index-BcE2OSVA.js} +1 -1
  3. package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
  4. package/dist/assets/{index-CxR7F2YQ.js → index-ZJaRCDYV.js} +1 -1
  5. package/dist/assets/{index-BlkJ_Axe.js → index-d8ferjRc.js} +194 -194
  6. package/dist/index.d.ts +124 -9
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +15686 -14882
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +4 -0
  13. package/src/components/EditorShell.tsx +3 -1
  14. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  15. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +0 -15
  17. package/src/components/editor/GridOverlay.tsx +12 -23
  18. package/src/components/editor/MotionPathOverlay.test.tsx +65 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  20. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  21. package/src/components/editor/PreviewGuides.tsx +8 -10
  22. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  23. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  24. package/src/components/editor/SnapToolbar.test.tsx +25 -12
  25. package/src/components/editor/SnapToolbar.tsx +30 -59
  26. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  27. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  28. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  29. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  30. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  31. package/src/components/editor/useVolumeAutomation.ts +57 -16
  32. package/src/components/nle/NLEContext.tsx +48 -6
  33. package/src/components/nle/NLEPreview.test.ts +67 -1
  34. package/src/components/nle/NLEPreview.tsx +88 -44
  35. package/src/components/nle/PreviewOverlays.tsx +4 -16
  36. package/src/components/nle/PreviewPane.tsx +17 -2
  37. package/src/components/nle/previewReadOnly.test.tsx +1 -0
  38. package/src/components/nle/useCompositionStack.ts +1 -1
  39. package/src/hooks/useCompositionDimensions.test.tsx +37 -0
  40. package/src/hooks/useCompositionDimensions.ts +17 -1
  41. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  42. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  43. package/src/hooks/useFileTree.test.tsx +56 -0
  44. package/src/hooks/useFileTree.ts +8 -1
  45. package/src/hooks/useSdkSession.lifecycle.test.tsx +108 -0
  46. package/src/hooks/useSdkSession.ts +77 -14
  47. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  48. package/src/hooks/useServerConnection.ts +81 -8
  49. package/src/index.ts +21 -1
  50. package/src/player/components/LayerDisclosureRow.tsx +3 -3
  51. package/src/player/components/Player.test.ts +175 -13
  52. package/src/player/components/Player.tsx +58 -2
  53. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  54. package/src/player/components/Timeline.tsx +23 -572
  55. package/src/player/components/TimelineCanvas.tsx +9 -30
  56. package/src/player/components/TimelineGroupHeader.tsx +4 -6
  57. package/src/player/components/TimelineOverlays.test.ts +67 -32
  58. package/src/player/components/TimelineOverlays.tsx +46 -107
  59. package/src/player/components/TimelineProvider.tsx +191 -0
  60. package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
  61. package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
  62. package/src/player/components/testRootHarness.ts +18 -0
  63. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  64. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  65. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  66. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  67. package/src/player/components/useTimelineProviderState.tsx +583 -0
  68. package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
  69. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  70. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  71. package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
  72. package/src/player/hooks/usePlayerHandle.ts +94 -0
  73. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  74. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  75. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  76. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  77. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  78. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
  79. package/src/player/index.ts +7 -0
  80. package/src/telemetry/events.test.ts +18 -0
  81. package/src/telemetry/events.ts +10 -0
  82. package/src/telemetry/system.test.ts +49 -0
  83. package/src/telemetry/system.ts +6 -0
  84. package/src/telemetry/tabId.test.ts +43 -0
  85. package/src/telemetry/tabId.ts +44 -0
  86. package/src/utils/previewCompositionSize.ts +26 -0
  87. package/src/utils/reloadDebug.ts +2 -4
  88. package/src/utils/sourcePatcher.ts +1 -1
  89. package/src/utils/studioTelemetry.test.ts +14 -0
  90. package/src/utils/studioTelemetry.ts +4 -0
  91. package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
@@ -3,6 +3,7 @@ import { act } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { expect, it } from "vitest";
5
5
  import { useCompositionDimensions } from "./useCompositionDimensions";
6
+ import { usePreviewIframeStore } from "../player/store/previewIframeStore";
6
7
 
7
8
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
8
9
 
@@ -45,3 +46,39 @@ it("accepts only the current preview frame and rejects foreign or missing sender
45
46
  foreign.remove();
46
47
  }
47
48
  });
49
+
50
+ it("re-reads the size from the promoted iframe's document", () => {
51
+ const host = document.createElement("div");
52
+ const a = document.createElement("iframe");
53
+ const b = document.createElement("iframe");
54
+ document.body.append(host, a, b);
55
+ const ref = { current: a };
56
+ const root = createRoot(host);
57
+ const setRoot = (frame: HTMLIFrameElement, width: number, height: number) => {
58
+ const el = frame.contentDocument!.createElement("div");
59
+ el.setAttribute("data-composition-id", "main");
60
+ el.setAttribute("data-width", String(width));
61
+ el.setAttribute("data-height", String(height));
62
+ frame.contentDocument!.body.append(el);
63
+ };
64
+ setRoot(a, 1920, 1080);
65
+ setRoot(b, 1080, 1920);
66
+ function Harness() {
67
+ return JSON.stringify(useCompositionDimensions(ref));
68
+ }
69
+ try {
70
+ act(() => usePreviewIframeStore.getState().setIframe(a));
71
+ act(() => root.render(<Harness />));
72
+ expect(host.textContent).toBe('{"width":1920,"height":1080}');
73
+ act(() => {
74
+ usePreviewIframeStore.getState().setIframe(b);
75
+ });
76
+ expect(host.textContent).toBe('{"width":1080,"height":1920}');
77
+ } finally {
78
+ act(() => root.unmount());
79
+ usePreviewIframeStore.setState({ iframe: null });
80
+ host.remove();
81
+ a.remove();
82
+ b.remove();
83
+ }
84
+ });
@@ -1,4 +1,6 @@
1
- import { useState, type RefObject } from "react";
1
+ import { useEffect, useState, type RefObject } from "react";
2
+ import { useLivePreviewIframe } from "../player/store/previewIframeStore";
3
+ import { readPreviewCompositionSize } from "../utils/previewCompositionSize";
2
4
  import { useMountEffect } from "./useMountEffect";
3
5
  import type { CompositionDimensions } from "../components/renders/RenderQueue";
4
6
  import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
@@ -34,6 +36,20 @@ export function useCompositionDimensions(iframeRef: RefObject<HTMLIFrameElement
34
36
  null,
35
37
  );
36
38
 
39
+ const livePreviewIframe = useLivePreviewIframe();
40
+
41
+ // The runtime posts stage-size once per document, so a promoted reload's message was sent
42
+ // before this listener could accept it. The size is authored on the document: read it there.
43
+ useEffect(() => {
44
+ const dimensions = readPreviewCompositionSize(livePreviewIframe);
45
+ if (!dimensions) return;
46
+ setCompositionDimensions((prev) =>
47
+ prev && prev.width === dimensions.width && prev.height === dimensions.height
48
+ ? prev
49
+ : dimensions,
50
+ );
51
+ }, [livePreviewIframe]);
52
+
37
53
  useMountEffect(() => {
38
54
  const handleMessage = (e: MessageEvent) => {
39
55
  if (!e.source || e.source !== iframeRef.current?.contentWindow) return;
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveOptimisticAttributeValue } from "./useDomEditAttributeCommits";
3
+
4
+ describe("resolveOptimisticAttributeValue", () => {
5
+ it("removes an HTML boolean attribute when the value is the literal string false", () => {
6
+ expect(resolveOptimisticAttributeValue("loop", "false")).toBeNull();
7
+ expect(resolveOptimisticAttributeValue("muted", "false")).toBeNull();
8
+ });
9
+
10
+ it("keeps the literal string false for a non-boolean attribute, matching what persist() writes", () => {
11
+ expect(resolveOptimisticAttributeValue("data-example", "false")).toBe("false");
12
+ });
13
+
14
+ it("always removes on null regardless of attribute", () => {
15
+ expect(resolveOptimisticAttributeValue("loop", null)).toBeNull();
16
+ expect(resolveOptimisticAttributeValue("data-example", null)).toBeNull();
17
+ });
18
+
19
+ it("passes any other value through unchanged", () => {
20
+ expect(resolveOptimisticAttributeValue("data-example", "true")).toBe("true");
21
+ expect(resolveOptimisticAttributeValue("loop", "true")).toBe("true");
22
+ });
23
+ });
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
- import type { PatchOperation } from "../utils/sourcePatcher";
2
+ import { HTML_BOOLEAN_ATTRIBUTES, type PatchOperation } from "../utils/sourcePatcher";
3
3
  import {
4
4
  findElementForSelection,
5
5
  getDomEditTargetKey,
@@ -55,6 +55,14 @@ function resolveFullAttrName(attr: string, prefixData: boolean | undefined): str
55
55
  return prefixData && !attr.startsWith("data-") ? `data-${attr}` : attr;
56
56
  }
57
57
 
58
+ // Matches sourcePatcher's own boolean handling: "false" means "remove" only
59
+ // for HTML_BOOLEAN_ATTRIBUTES (loop, muted, ...), so the live preview node
60
+ // ends up holding what persist() actually writes to disk.
61
+ export function resolveOptimisticAttributeValue(attr: string, value: string | null): string | null {
62
+ if (value === null) return null;
63
+ return value === "false" && HTML_BOOLEAN_ATTRIBUTES.has(attr) ? null : value;
64
+ }
65
+
58
66
  function setOrRemovePreviewAttribute(
59
67
  el: HTMLElement,
60
68
  fullAttr: string,
@@ -393,8 +401,11 @@ export function useDomEditAttributeCommits({
393
401
  },
394
402
  apply: () => {
395
403
  if (!editedElement) return;
396
- const nextValue = value === null || value === "false" ? null : value;
397
- setOrRemovePreviewAttribute(editedElement, attr, nextValue);
404
+ setOrRemovePreviewAttribute(
405
+ editedElement,
406
+ attr,
407
+ resolveOptimisticAttributeValue(attr, value),
408
+ );
398
409
  },
399
410
  persist: () =>
400
411
  persistDomEditOperations(domEditSelection, [op], {
@@ -5,6 +5,12 @@ import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { useFileTree } from "./useFileTree";
7
7
 
8
+ vi.mock("../components/feedback/projectProvenance", () => ({
9
+ captureProjectProvenance: vi.fn(),
10
+ }));
11
+
12
+ import { captureProjectProvenance } from "../components/feedback/projectProvenance";
13
+
8
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
15
 
10
16
  let root: Root | null = null;
@@ -52,6 +58,56 @@ async function renderHarness(projectId: string): Promise<{ current: Handle | nul
52
58
  return handleRef;
53
59
  }
54
60
 
61
+ describe("useFileTree unresolved project", () => {
62
+ const provenance = vi.mocked(captureProjectProvenance);
63
+
64
+ afterEach(() => {
65
+ provenance.mockClear();
66
+ });
67
+
68
+ /** Render the tree for `projectId` against one canned response. */
69
+ async function renderWithResponse(
70
+ projectId: string,
71
+ build: () => Response,
72
+ ): Promise<{ current: Handle | null }> {
73
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => build());
74
+ const handleRef = await renderHarness(projectId);
75
+ fetchSpy.mockRestore();
76
+ return handleRef;
77
+ }
78
+
79
+ const notFound = () =>
80
+ new Response(JSON.stringify({ error: "not found" }), {
81
+ status: 404,
82
+ headers: { "content-type": "application/json" },
83
+ });
84
+ const emptyListing = () =>
85
+ new Response(JSON.stringify({ files: [], compositions: [] }), { status: 200 });
86
+
87
+ // An unresolvable project answers 404 with a JSON body. Without an `r.ok`
88
+ // check that parsed cleanly and took the SUCCESS branch, recording an empty
89
+ // tree and an empty provenance snapshot for a project that was never read —
90
+ // so a bug report filed from that tab described a project that does not
91
+ // exist. The tree itself stays empty either way; the provenance lie is the
92
+ // part that leaves the browser.
93
+ it("does not snapshot provenance for a project the server could not resolve", async () => {
94
+ const handleRef = await renderWithResponse("gone-project", notFound);
95
+
96
+ expect(handleRef.current?.compositions).toEqual([]);
97
+ expect(provenance).not.toHaveBeenCalled();
98
+ });
99
+
100
+ // The counterpart, and the reason the check above cannot be collapsed into
101
+ // "skip provenance whenever the tree is empty": a project with no
102
+ // compositions yet is a real, successful read and must still be snapshotted.
103
+ it("still snapshots provenance when a resolved project has no compositions", async () => {
104
+ const handleRef = await renderWithResponse("empty-project", emptyListing);
105
+
106
+ expect(handleRef.current?.compositions).toEqual([]);
107
+ expect(provenance).toHaveBeenCalledWith("empty-project", [], []);
108
+ });
109
+ });
110
+
55
111
  describe("useFileTree.refreshFileTree", () => {
56
112
  it("updates compositions, not just the raw file list, on refresh", async () => {
57
113
  let call = 0;
@@ -39,7 +39,14 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
39
39
  let cancelled = false;
40
40
  setFetched({ projectId, loaded: false, fileTree: [], compositionPaths: [], projectDir: null });
41
41
  fetch(buildProjectApiPath(projectId))
42
- .then((r) => r.json())
42
+ // An unresolvable project answers 404 with a JSON body, so without this
43
+ // the error path parsed cleanly and the success branch below recorded an
44
+ // empty tree — and an empty provenance snapshot — for a project that was
45
+ // never read. Throwing hands it to the catch instead.
46
+ .then((r) => {
47
+ if (!r.ok) throw new Error(`tree fetch failed: ${r.status}`);
48
+ return r.json();
49
+ })
43
50
  .then((data: { files?: string[]; dir?: string; compositions?: string[] }) => {
44
51
  if (cancelled) return;
45
52
  setFetched({
@@ -147,6 +147,86 @@ describe("useSdkSession ownership", () => {
147
147
  });
148
148
  });
149
149
 
150
+ describe("useSdkSession unreachable project", () => {
151
+ beforeEach(() => {
152
+ openComposition.mockReset();
153
+ trackMock.mockClear();
154
+ class FakeEventSource {
155
+ addEventListener(): void {}
156
+ close(): void {}
157
+ }
158
+ vi.stubGlobal("EventSource", FakeEventSource);
159
+ });
160
+
161
+ afterEach(() => {
162
+ vi.unstubAllGlobals();
163
+ });
164
+
165
+ function probeHandle(projectId: string) {
166
+ const captured: { handle: SdkSessionHandle | null } = { handle: null };
167
+ function HandleProbe() {
168
+ captured.handle = useSdkSession(projectId, "index.html");
169
+ return null;
170
+ }
171
+ return { captured, HandleProbe };
172
+ }
173
+
174
+ const failedRead = (status: number) => async () =>
175
+ ({ ok: false, status, json: async () => ({}) }) as Response;
176
+
177
+ /** Render the hook for `projectId` against a stubbed read and let it settle. */
178
+ async function readOutcome(
179
+ projectId: string,
180
+ read: () => Promise<Response>,
181
+ ): Promise<{ captured: { handle: SdkSessionHandle | null }; unmount: () => Promise<void> }> {
182
+ vi.stubGlobal("fetch", vi.fn(read));
183
+ const { captured, HandleProbe } = probeHandle(projectId);
184
+ const root = createRoot(document.createElement("div"));
185
+ await act(async () => root.render(<HandleProbe />));
186
+ await flushAsyncEffects();
187
+ return { captured, unmount: () => act(async () => root.unmount()) };
188
+ }
189
+
190
+ // A 404 on the composition read is the one failure that says something about
191
+ // the PROJECT rather than the request: under the CLI host it means this
192
+ // Studio serves a different one. Every edit then fails silently, so the UI
193
+ // needs the id to explain that.
194
+ it("names the project when the read is a 404", async () => {
195
+ const { captured, unmount } = await readOutcome("gone-project", failedRead(404));
196
+
197
+ expect(captured.handle?.unreachableProject).toBe("gone-project");
198
+ await unmount();
199
+ });
200
+
201
+ // A 500 says the request failed, not that the project is elsewhere. Claiming
202
+ // otherwise would tell a user their tab is pointed at the wrong project when
203
+ // the server is merely unwell.
204
+ it("stays quiet on a failure that says nothing about the project", async () => {
205
+ const { captured, unmount } = await readOutcome("project-a", failedRead(500));
206
+
207
+ expect(captured.handle?.unreachableProject).toBeNull();
208
+ await unmount();
209
+ });
210
+
211
+ it("clears the state once the project resolves", async () => {
212
+ const { captured, unmount } = await readOutcome("gone-project", failedRead(404));
213
+ expect(captured.handle?.unreachableProject).toBe("gone-project");
214
+
215
+ openComposition.mockResolvedValue(fakeSession());
216
+ vi.stubGlobal(
217
+ "fetch",
218
+ vi.fn(async () => response("PROJECT_A")),
219
+ );
220
+ await act(async () => {
221
+ captured.handle?.forceReload();
222
+ });
223
+ await flushAsyncEffects();
224
+
225
+ expect(captured.handle?.unreachableProject).toBeNull();
226
+ await unmount();
227
+ });
228
+ });
229
+
150
230
  describe("useSdkSession unavailable telemetry", () => {
151
231
  beforeEach(() => {
152
232
  openComposition.mockReset();
@@ -187,6 +267,34 @@ describe("useSdkSession unavailable telemetry", () => {
187
267
  await act(async () => root.unmount());
188
268
  });
189
269
 
270
+ // A fetch that REJECTS produces no response at all. It used to escape this
271
+ // function and be caught by the effect's outer `.catch`, which reported it as
272
+ // `stage: "open"` — a label that means openComposition threw. Every `stage:
273
+ // open` event on 0.8.56/0.8.57 carries a fetch-rejection message, so the
274
+ // largest failure class was a network problem reported as a parser one and
275
+ // was unaddressable in that bucket.
276
+ it("reports a rejected request as a read failure, not an open failure", async () => {
277
+ vi.stubGlobal(
278
+ "fetch",
279
+ vi.fn(async () => {
280
+ throw new TypeError("Failed to fetch");
281
+ }),
282
+ );
283
+ const root = createRoot(document.createElement("div"));
284
+ await act(async () => root.render(<Probe projectId="project-a" />));
285
+ await flushAsyncEffects();
286
+
287
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
288
+ stage: "read",
289
+ reason: "network",
290
+ });
291
+ expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", {
292
+ stage: "open",
293
+ error: expect.anything(),
294
+ });
295
+ await act(async () => root.unmount());
296
+ });
297
+
190
298
  it("separates an unexpected response shape from a failed request", async () => {
191
299
  vi.stubGlobal(
192
300
  "fetch",
@@ -16,15 +16,54 @@ import { addExternalFileReloadListener } from "./externalFileReloadBus";
16
16
  *
17
17
  * Every reason lives in this union so the full surface is readable from one
18
18
  * place — `absent_or_empty` included, even though it is a 2xx.
19
+ *
20
+ * `network` is a fetch that REJECTED — the request never produced a response.
21
+ * It was not in this union until 2026-09-21, so it escaped `readProjectFileOptional`
22
+ * and was caught by the effect's outer `.catch`, landing in `stage: "open"`.
23
+ * That label means `openComposition` threw (unparseable composition, OOM), and
24
+ * it is what the largest failure class was reported as. Every `stage: open`
25
+ * event measured on 0.8.56 and 0.8.57 carries a fetch-rejection message —
26
+ * "Failed to fetch", "Load failed", "NetworkError when attempting to fetch
27
+ * resource." — so the class was a network problem filed under a parser one, and
28
+ * unaddressable in that bucket.
19
29
  */
20
30
  type ProjectFileReadFailure =
21
31
  | { ok: false; reason: "unsafe_path" }
22
32
  | { ok: false; reason: "http_error"; status: number }
23
33
  | { ok: false; reason: "missing_content" }
24
- | { ok: false; reason: "absent_or_empty" };
34
+ | { ok: false; reason: "absent_or_empty" }
35
+ | { ok: false; reason: "network" };
25
36
 
26
37
  type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
27
38
 
39
+ /**
40
+ * Record a read that produced no usable content, and answer which project — if
41
+ * any — the failure identifies as unreachable.
42
+ *
43
+ * No SDK session follows a failed read, so EVERY cutover chokepoint takes the
44
+ * server path and emits nothing — the shadow never runs either. A broken read
45
+ * would otherwise be a silent, total SDK bypass, which is why this is recorded
46
+ * at all.
47
+ *
48
+ * Only a 404 identifies the *project*: the server answered, and its answer was
49
+ * that it does not serve this id. A 5xx, a dropped request, an unexpected body
50
+ * and an empty file all say nothing about which project the server serves, so
51
+ * none of them claim it. See `unreachableProject` on the handle.
52
+ *
53
+ * A function rather than three more conditions inline: the read callback it is
54
+ * called from is a long pre-existing async body already near the complexity
55
+ * threshold, and this branch is one coherent unit.
56
+ */
57
+ function reportReadFailure(read: ProjectFileReadFailure, projectId: string): string | null {
58
+ trackStudioEvent("sdk_session_unavailable", {
59
+ stage: "read",
60
+ reason: read.reason,
61
+ ...(read.reason === "http_error" ? { status: read.status } : {}),
62
+ });
63
+ if (read.reason !== "http_error") return null;
64
+ return read.status === 404 ? projectId : null;
65
+ }
66
+
28
67
  /**
29
68
  * Read a project file's content (optional read — a missing file is not an
30
69
  * error). Replaces the removed SDK http adapter's `read()` — the only thing
@@ -40,9 +79,20 @@ async function readProjectFileOptional(
40
79
  // and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
41
80
  // already confines both values to single segments of this same-origin URL.
42
81
  if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
43
- const res = await fetch(
44
- `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
45
- );
82
+ let res: Response;
83
+ try {
84
+ res = await fetch(
85
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
86
+ );
87
+ } catch {
88
+ // The request never produced a response: offline, the dev server gone, a
89
+ // CSP/Private-Network-Access block, or an extension rewriting fetch. Caught
90
+ // here so it is reported as a READ failure — left to propagate, the effect's
91
+ // outer catch reported it as `stage: "open"`, which claims the composition
92
+ // failed to parse. Every `stage: open` event before this change was this
93
+ // branch, so the label made the largest class unaddressable.
94
+ return { ok: false, reason: "network" };
95
+ }
46
96
  if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
47
97
  const data = (await res.json()) as { content?: string };
48
98
  // `optional=1` answers a missing file with 200 + `content: ""`, so a
@@ -96,6 +146,19 @@ export interface SdkSessionHandle {
96
146
  * side of that path is covered by usePersistentEditHistory.test.ts.
97
147
  */
98
148
  forceReload: () => void;
149
+ /**
150
+ * Set when this server answered the composition read with a 404 for the
151
+ * project it was asked to open. In the CLI-embedded host — the only host
152
+ * that reports telemetry at all (`telemetry/policy.ts` suppresses Vite dev)
153
+ * — that does not mean the project is gone. It means this Studio is serving
154
+ * a *different* one; the project is untouched on disk. Either way every edit
155
+ * in this tab fails, and until now it failed silently.
156
+ *
157
+ * `null` for every other failure: a 500 or a dropped request says nothing
158
+ * about which project the server serves, and `absent_or_empty` /
159
+ * `missing_content` say nothing about the project at all.
160
+ */
161
+ unreachableProject: string | null;
99
162
  }
100
163
 
101
164
  interface SdkSessionOwner {
@@ -173,6 +236,7 @@ export function useSdkSession(
173
236
  const [reloadToken, setReloadToken] = useState(0);
174
237
  const reloadTokenRef = useRef(reloadToken);
175
238
  reloadTokenRef.current = reloadToken;
239
+ const [unreachableProject, setUnreachableProject] = useState<string | null>(null);
176
240
 
177
241
  useEffect(
178
242
  () =>
@@ -195,6 +259,7 @@ export function useSdkSession(
195
259
  if (previous) disposeSdkSession(previous.session);
196
260
 
197
261
  if (!projectId || !activeCompPath) {
262
+ setUnreachableProject(null);
198
263
  return () => {
199
264
  cancelled = true;
200
265
  };
@@ -211,17 +276,10 @@ export function useSdkSession(
211
276
  .then(async (read) => {
212
277
  if (cancelled) return;
213
278
  if (!read.ok) {
214
- // No SDK session follows, so EVERY cutover chokepoint below takes the
215
- // server path and emits nothing — the shadow never runs either. This
216
- // is the only place a missing session can originate, so a broken read
217
- // would otherwise be a silent, total SDK bypass.
218
- trackStudioEvent("sdk_session_unavailable", {
219
- stage: "read",
220
- reason: read.reason,
221
- ...(read.reason === "http_error" ? { status: read.status } : {}),
222
- });
279
+ setUnreachableProject(reportReadFailure(read, projectId));
223
280
  return;
224
281
  }
282
+ setUnreachableProject(null);
225
283
  const content = read.content;
226
284
  // No persist queue: Studio's writeProjectFile (via sdkCutover's
227
285
  // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
@@ -263,6 +321,11 @@ export function useSdkSession(
263
321
  // openComposition threw (unparseable composition, OOM) — same total
264
322
  // bypass as the read failure above, but this one is a real defect
265
323
  // rather than a missing file. Carry the message; it is the only clue.
324
+ //
325
+ // A rejected read no longer reaches here: `readProjectFileOptional`
326
+ // catches its own fetch rejection and answers `reason: "network"`, so
327
+ // this stage now means what it says. Before that, every event in this
328
+ // bucket was a network error wearing a parser's label.
266
329
  trackStudioEvent("sdk_session_unavailable", {
267
330
  stage: "open",
268
331
  error: error instanceof Error ? error.message : String(error),
@@ -316,5 +379,5 @@ export function useSdkSession(
316
379
  ownedSession.reloadToken === reloadToken
317
380
  ? ownedSession.session
318
381
  : null;
319
- return { session, publish, forceReload };
382
+ return { session, publish, forceReload, unreachableProject };
320
383
  }