authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 20:30:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:30:12-07:00
log7175edcff4f250b583ebed8877e27e36dc48411e
treef870d7560ea4034710122be2b203eb59d4353476
parent321c6f9d26e9240f6ed3d2a3a8f7fc11344c2f17
signature Signed by PGP key B5690EEEBB952194

feat(auth): separate browser preview control permissions (#9789)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

26 files changed, 476 insertions(+), 82 deletions(-)

apps/desktop/src/ipc/channels.ts+2
......@@ -90,6 +90,8 @@ export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";
9090export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources";
9191export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies";
9292export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme";
93export const PREVIEW_SET_ANNOTATION_SEND_ENABLED_CHANNEL =
94 "desktop:preview-set-annotation-send-enabled";
9395export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
9496export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";
9597export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot";
apps/desktop/src/ipc/methods/preview.ts+15
......@@ -1,4 +1,5 @@
11import {
2 DesktopPreviewAnnotationSendEnabledInputSchema,
23 DesktopPreviewAnnotationThemeInputSchema,
34 DesktopPreviewArtifactInputSchema,
45 DesktopPreviewConfigInputSchema,
......@@ -377,6 +378,19 @@ export const pickElement = DesktopIpc.makeIpcMethod({
377378 }),
378379});
379380
381export const setAnnotationSendEnabled = DesktopIpc.makeIpcMethod({
382 channel: IpcChannels.PREVIEW_SET_ANNOTATION_SEND_ENABLED_CHANNEL,
383 payload: DesktopPreviewAnnotationSendEnabledInputSchema,
384 result: Schema.Void,
385 handler: Effect.fn("desktop.ipc.preview.setAnnotationSendEnabled")(function* ({
386 tabId,
387 enabled,
388 }) {
389 const manager = yield* PreviewManager.PreviewManager;
390 yield* manager.setAnnotationSendEnabled(tabId, enabled);
391 }),
392});
393
380394export const captureScreenshot = DesktopIpc.makeIpcMethod({
381395 channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL,
382396 payload: DesktopPreviewTabInputSchema,
......@@ -438,6 +452,7 @@ export const methods = [
438452 clearCache,
439453 getPreviewConfig,
440454 setAnnotationTheme,
455 setAnnotationSendEnabled,
441456 pickElement,
442457 cancelPickElement,
443458 captureScreenshot,
apps/desktop/src/preload.ts+5
......@@ -339,6 +339,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
339339 ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId, profileId }),
340340 setAnnotationTheme: (theme) =>
341341 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }),
342 setAnnotationSendEnabled: (tabId, enabled) =>
343 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_SEND_ENABLED_CHANNEL, {
344 tabId,
345 enabled,
346 }),
342347 pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),
343348 cancelPickElement: (tabId) =>
344349 ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }),
apps/desktop/src/preview/AnnotationKeyboard.test.ts+15-6
......@@ -15,14 +15,23 @@ const keyboardEvent = (
1515
1616describe("resolveAnnotationSubmission", () => {
1717 it("attaches on Enter and sends on Cmd/Ctrl+Enter", () => {
18 expect(resolveAnnotationSubmission(keyboardEvent())).toBe("attach");
19 expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }))).toBe("send");
20 expect(resolveAnnotationSubmission(keyboardEvent({ ctrlKey: true }))).toBe("send");
18 expect(resolveAnnotationSubmission(keyboardEvent(), true)).toBe("attach");
19 expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }), true)).toBe("send");
20 expect(resolveAnnotationSubmission(keyboardEvent({ ctrlKey: true }), true)).toBe("send");
2121 });
2222
2323 it("leaves Shift+Enter and composition events available for editing", () => {
24 expect(resolveAnnotationSubmission(keyboardEvent({ shiftKey: true }))).toBeNull();
25 expect(resolveAnnotationSubmission(keyboardEvent({ isComposing: true }))).toBeNull();
26 expect(resolveAnnotationSubmission(keyboardEvent({ key: " " }))).toBeNull();
24 expect(resolveAnnotationSubmission(keyboardEvent({ shiftKey: true }), true)).toBeNull();
25 expect(resolveAnnotationSubmission(keyboardEvent({ isComposing: true }), true)).toBeNull();
26 expect(resolveAnnotationSubmission(keyboardEvent({ key: " " }), true)).toBeNull();
27 });
28
29 it("keeps attach available while the send shortcut follows permission changes", () => {
30 const send = keyboardEvent({ ctrlKey: true });
31 expect(resolveAnnotationSubmission(send, false)).toBeNull();
32 expect(resolveAnnotationSubmission(keyboardEvent(), false)).toBe("attach");
33 expect(resolveAnnotationSubmission(send, true)).toBe("send");
34 expect(resolveAnnotationSubmission(send, false)).toBeNull();
35 expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }), false)).toBeNull();
2736 });
2837});
apps/desktop/src/preview/AnnotationKeyboard.ts+2-1
......@@ -10,7 +10,8 @@ interface AnnotationKeyboardEvent {
1010
1111export function resolveAnnotationSubmission(
1212 event: AnnotationKeyboardEvent,
13 canSend: boolean,
1314): PreviewAnnotationSubmission | null {
1415 if (event.key !== "Enter" || event.shiftKey || event.isComposing) return null;
15 return event.metaKey || event.ctrlKey ? "send" : "attach";
16 return event.metaKey || event.ctrlKey ? (canSend ? "send" : null) : "attach";
1617}
apps/desktop/src/preview/GuestProtocol.ts+1
......@@ -3,6 +3,7 @@ export const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
33export const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
44export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
55export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";
6export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled";
67export const HUMAN_INPUT_CHANNEL = "preview:human-input";
78export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate";
89export const RECORDING_CURSOR_CHANNEL = "preview:recording-cursor";
apps/desktop/src/preview/Manager.test.ts+80
......@@ -3937,6 +3937,85 @@ describe("PreviewManager", () => {
39373937 ),
39383938 );
39393939
3940 effectIt.effect("keeps annotation send grants isolated and updates an active pick", () =>
3941 withManager((manager) =>
3942 Effect.gen(function* () {
3943 const picked = new Map<number, (event: unknown, ...args: unknown[]) => void>();
3944 const capturePage = vi.fn(async () => ({
3945 toJPEG: () => Buffer.from("annotation"),
3946 toDataURL: () => "data:image/png;base64,YW5ub3RhdGlvbg==",
3947 getSize: () => ({ width: 10, height: 10 }),
3948 }));
3949 const guests = new Map(
3950 [42, 43, 44].map(
3951 (id) =>
3952 [
3953 id,
3954 Object.assign(makeTestPreviewWebContents(capturePage, id), {
3955 isFocused: () => true,
3956 once: vi.fn(),
3957 ipc: {
3958 on: vi.fn(
3959 (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => {
3960 if (channel === "preview:element-picked") picked.set(id, listener);
3961 },
3962 ),
3963 off: vi.fn(),
3964 removeListener: vi.fn(),
3965 },
3966 }),
3967 ] as const,
3968 ),
3969 );
3970 fromId.mockImplementation((id) => (id === undefined ? null : (guests.get(id) ?? null)));
3971 yield* manager.createTab("tab_a");
3972 yield* manager.createTab("tab_b");
3973 yield* manager.registerWebview("tab_a", 42);
3974 yield* manager.registerWebview("tab_b", 43);
3975 yield* manager.setAnnotationSendEnabled("tab_a", true);
3976 const annotation = {
3977 id: "annotation-scopes",
3978 pageUrl: "https://example.com",
3979 pageTitle: "Example",
3980 comment: "Tighten this spacing",
3981 elements: [],
3982 regions: [],
3983 strokes: [],
3984 styleChanges: [],
3985 screenshot: null,
3986 createdAt: "2026-09-05T00:00:00.000Z",
3987 };
3988 for (const [tabId, id, nextGrant, expected] of [
3989 ["tab_b", 43, undefined, "attach"],
3990 ["tab_a", 42, false, "attach"],
3991 ["tab_a", 42, true, "send"],
3992 ] as const) {
3993 const pick = yield* manager.pickElement(tabId).pipe(Effect.forkChild);
3994 yield* Effect.yieldNow;
3995 if (nextGrant !== undefined) {
3996 yield* manager.setAnnotationSendEnabled(tabId, nextGrant);
3997 }
3998 picked.get(id)?.({}, annotation, null, "send");
3999 const result = yield* Fiber.join(pick);
4000 expect(result?.annotation.id).toBe(annotation.id);
4001 expect(result?.submission).toBe(expected);
4002 }
4003 yield* manager.registerWebview("tab_a", 44);
4004 const replacementPick = yield* manager.pickElement("tab_a").pipe(Effect.forkChild);
4005 yield* Effect.yieldNow;
4006 picked.get(44)?.({}, annotation, null, "send");
4007 expect((yield* Fiber.join(replacementPick))?.submission).toBe("send");
4008 yield* manager.closeTab("tab_a");
4009 yield* manager.createTab("tab_a");
4010 yield* manager.registerWebview("tab_a", 44);
4011 const reopenedPick = yield* manager.pickElement("tab_a").pipe(Effect.forkChild);
4012 yield* Effect.yieldNow;
4013 picked.get(44)?.({}, annotation, null, "send");
4014 expect((yield* Fiber.join(reopenedPick))?.submission).toBe("attach");
4015 }),
4016 ),
4017 );
4018
39404019 effectIt.effect("settles the pick when the annotation screenshot never arrives", () =>
39414020 withManager((manager) =>
39424021 Effect.gen(function* () {
......@@ -3983,6 +4062,7 @@ describe("PreviewManager", () => {
39834062 const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);
39844063 yield* Effect.yieldNow;
39854064
4065 yield* manager.setAnnotationSendEnabled("tab_1", true);
39864066 onPicked?.(
39874067 {},
39884068 {
apps/desktop/src/preview/Manager.ts+31-8
......@@ -65,6 +65,7 @@ import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
6565import * as BrowserSession from "./BrowserSession.ts";
6666import {
6767 ANNOTATION_CAPTURED_CHANNEL,
68 ANNOTATION_SEND_ENABLED_CHANNEL,
6869 ANNOTATION_THEME_CHANNEL,
6970 CANCEL_PICK_CHANNEL,
7071 ELEMENT_PICKED_CHANNEL,
......@@ -559,6 +560,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
559560
560561 let forwardedShortcuts: ReadonlyArray<PreviewForwardedShortcut> = [];
561562 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);
563 const annotationSendEnabled = new Map<string, boolean>();
562564 const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());
563565 const tabsRef = yield* SynchronizedRef.make<ReadonlyMap<string, PreviewTabState>>(new Map());
564566 const attachedRef = yield* Ref.make<ReadonlyMap<number, ManagedListeners>>(new Map());
......@@ -1713,6 +1715,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
17131715 const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) {
17141716 if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return;
17151717 clearPendingRecording(tabId);
1718 annotationSendEnabled.delete(tabId);
17161719 yield* Effect.all(
17171720 [
17181721 cancelPickElement(tabId),
......@@ -1814,9 +1817,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
18141817 // changed. Only push its zoom back down — Chromium may have just handed
18151818 // this guest the app window's zoom level.
18161819 yield* assertTabZoom(tabId);
1817 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
1818 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
1819 );
1820 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => {
1821 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme);
1822 wc.send(ANNOTATION_SEND_ENABLED_CHANNEL, annotationSendEnabled.get(tabId) === true);
1823 });
18201824 return;
18211825 }
18221826 const replacedWebContentsId =
......@@ -1923,9 +1927,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
19231927 // syncTabAudible's ownership check, so re-read and reconcile through the
19241928 // same path the event uses.
19251929 yield* syncTabAudible(tabId, wc, yield* readAudible);
1926 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
1927 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
1928 );
1930 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => {
1931 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme);
1932 wc.send(ANNOTATION_SEND_ENABLED_CHANNEL, annotationSendEnabled.get(tabId) === true);
1933 });
19291934 const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus;
19301935 if (
19311936 pendingUrl &&
......@@ -2139,6 +2144,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
21392144 );
21402145 });
21412146
2147 const setAnnotationSendEnabled = Effect.fn("PreviewManager.setAnnotationSendEnabled")(function* (
2148 tabId: string,
2149 enabled: boolean,
2150 ) {
2151 const wc = yield* requireWebContents(tabId);
2152 yield* attempt({ operation: "setAnnotationSendEnabled", tabId, webContentsId: wc.id }, () => {
2153 annotationSendEnabled.set(tabId, enabled);
2154 wc.send(ANNOTATION_SEND_ENABLED_CHANNEL, enabled);
2155 });
2156 });
2157
21422158 const pickElement = Effect.fn("PreviewManager.pickElement")(function* (tabId: string) {
21432159 const wc = yield* requireWebContents(tabId);
21442160 yield* cancelPickElement(tabId);
......@@ -2216,7 +2232,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
22162232 return;
22172233 }
22182234 const cropRect = normalizeCaptureRect(args[1]);
2219 const submission = args[2] === "send" ? "send" : "attach";
2235 const submission =
2236 args[2] === "send" && annotationSendEnabled.get(tabId) === true ? "send" : "attach";
22202237 runFork(
22212238 captureAnnotationScreenshot(tabId, wc, cropRect).pipe(
22222239 // The renderer cannot tell a dropped crop from a comment-only
......@@ -2275,7 +2292,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
22752292 wc.once("destroyed", onDestroyed);
22762293 wc.on("did-start-navigation", onNavigated);
22772294 if (!wc.isFocused()) wc.focus();
2278 wc.send(START_PICK_CHANNEL, annotationTheme);
2295 wc.send(START_PICK_CHANNEL, annotationTheme, annotationSendEnabled.get(tabId) === true);
22792296 });
22802297 });
22812298 runFork(
......@@ -3356,6 +3373,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
33563373 revealArtifact,
33573374 saveRecording,
33583375 setAnnotationTheme,
3376 setAnnotationSendEnabled,
33593377 setAudioMuted,
33603378 setColorScheme,
33613379 setMainWindow,
......@@ -3580,6 +3598,10 @@ export class PreviewManager extends Context.Service<
35803598 readonly setAnnotationTheme: (
35813599 theme: DesktopPreviewAnnotationTheme,
35823600 ) => Effect.Effect<void, PreviewManagerError>;
3601 readonly setAnnotationSendEnabled: (
3602 tabId: string,
3603 enabled: boolean,
3604 ) => Effect.Effect<void, PreviewManagerError>;
35833605 readonly pickElement: (
35843606 tabId: string,
35853607 ) => Effect.Effect<PreviewAnnotationSubmissionResult | null, PreviewManagerError>;
......@@ -3697,6 +3719,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
36973719 },
36983720 ),
36993721 setAnnotationTheme: operations.setAnnotationTheme,
3722 setAnnotationSendEnabled: operations.setAnnotationSendEnabled,
37003723 pickElement: operations.pickElement,
37013724 cancelPickElement: operations.cancelPickElement,
37023725 captureScreenshot: operations.captureScreenshot,
apps/desktop/src/preview/PickPreload.ts+31-6
......@@ -20,6 +20,7 @@ import { installRecordingCursor } from "./RecordingCursor.ts";
2020import { DEFAULT_RECORDING_INPUT_OPTIONS } from "./RecordingInput.ts";
2121import {
2222 ANNOTATION_CAPTURED_CHANNEL,
23 ANNOTATION_SEND_ENABLED_CHANNEL,
2324 ANNOTATION_THEME_CHANNEL,
2425 CANCEL_PICK_CHANNEL,
2526 ELEMENT_PICKED_CHANNEL,
......@@ -129,6 +130,7 @@ interface SelectedElement {
129130interface AnnotationSession {
130131 teardown: (notifyMain: boolean) => void;
131132 applyTheme: (theme: DesktopPreviewAnnotationTheme) => void;
133 setSendEnabled: (enabled: boolean) => void;
132134}
133135
134136let activeSession: AnnotationSession | null = null;
......@@ -512,7 +514,7 @@ function strokeBounds(
512514 return { x: left, y: top, width: right - left, height: bottom - top };
513515}
514516
515function startAnnotation(): void {
517function startAnnotation(sendEnabled: boolean): void {
516518 activeSession?.teardown(false);
517519 let finished = false;
518520 const host = document.createElement("div");
......@@ -589,6 +591,12 @@ function startAnnotation(): void {
589591 composerRow.appendChild(dragHandle);
590592
591593 const submit = createButton("Attach", "Attach annotation and screenshot (Enter)");
594 const updateSendHint = () => {
595 submit.title = sendEnabled
596 ? "Attach annotation and screenshot (Enter). Send with Cmd/Ctrl+Enter."
597 : "Attach annotation and screenshot (Enter)";
598 };
599 updateSendHint();
592600 submit.className +=
593601 " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90";
594602 composerRow.appendChild(submit);
......@@ -1345,6 +1353,7 @@ function startAnnotation(): void {
13451353 };
13461354
13471355 const submitAnnotation = (submission: PreviewAnnotationSubmission): void => {
1356 if (submission === "send" && !sendEnabled) return;
13481357 if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0))
13491358 return;
13501359 pendingCapture = true;
......@@ -1396,7 +1405,12 @@ function startAnnotation(): void {
13961405 ...submittedRegions.map((region) => region.rect),
13971406 ...submittedStrokes.map((stroke) => stroke.bounds),
13981407 ]);
1399 ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect, submission);
1408 ipcRenderer.send(
1409 ELEMENT_PICKED_CHANNEL,
1410 annotation,
1411 screenshotRect,
1412 submission === "send" && !sendEnabled ? "attach" : submission,
1413 );
14001414 })
14011415 .catch(() => {
14021416 // Last resort. Main is waiting on this message, so hand it an empty
......@@ -1407,7 +1421,8 @@ function startAnnotation(): void {
14071421 };
14081422 submit.addEventListener("click", () => submitAnnotation("attach"));
14091423 root.addEventListener("keydown", (event) => {
1410 const submission = event.target === comment ? resolveAnnotationSubmission(event) : null;
1424 const submission =
1425 event.target === comment ? resolveAnnotationSubmission(event, sendEnabled) : null;
14111426 // Keep this in the bubble phase so editor inputs receive the event before
14121427 // it is isolated from listeners installed by the inspected page.
14131428 event.stopImmediatePropagation();
......@@ -1433,12 +1448,22 @@ function startAnnotation(): void {
14331448 activeSession = {
14341449 teardown,
14351450 applyTheme: (theme) => applyAnnotationTheme(host, theme),
1451 setSendEnabled: (enabled) => {
1452 sendEnabled = enabled;
1453 updateSendHint();
1454 },
14361455 };
14371456}
14381457
1439ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => {
1440 if (theme) annotationTheme = theme;
1441 startAnnotation();
1458ipcRenderer.on(
1459 START_PICK_CHANNEL,
1460 (_event, theme: DesktopPreviewAnnotationTheme | undefined, sendEnabled?: boolean) => {
1461 if (theme) annotationTheme = theme;
1462 startAnnotation(sendEnabled === true);
1463 },
1464);
1465ipcRenderer.on(ANNOTATION_SEND_ENABLED_CHANNEL, (_event, enabled: boolean) => {
1466 activeSession?.setSendEnabled(enabled === true);
14421467});
14431468ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => {
14441469 annotationTheme = theme;
apps/server/src/auth/RpcAuthorization.test.ts+23
......@@ -9,6 +9,7 @@ import {
99 AuthOrchestrationOperateScope,
1010 AuthOrchestrationReadScope,
1111 AuthSourceControlWriteScope,
12 AuthPreviewOperateScope,
1213 AuthRelayReadScope,
1314 AuthRelayWriteScope,
1415 WS_METHODS,
......@@ -138,6 +139,28 @@ describe("RPC authorization scopes", () => {
138139 );
139140 });
140141
142 it("separates preview control from observation", () => {
143 for (const method of [
144 WS_METHODS.previewOpen,
145 WS_METHODS.previewNavigate,
146 WS_METHODS.previewResize,
147 WS_METHODS.previewRefresh,
148 WS_METHODS.previewClose,
149 WS_METHODS.previewReportStatus,
150 WS_METHODS.previewAdjust,
151 WS_METHODS.previewClearProfile,
152 ]) {
153 expect(requiredScopeForRpcMethod(method)).toBe(AuthPreviewOperateScope);
154 }
155 for (const method of [
156 WS_METHODS.previewList,
157 WS_METHODS.subscribePreviewEvents,
158 WS_METHODS.subscribeDiscoveredLocalServers,
159 ]) {
160 expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope);
161 }
162 });
163
141164 it("rejects unknown RPC method names", () => {
142165 for (const method of ["server.notRegistered", "toString", "constructor"]) {
143166 expect(() => requiredScopeForRpcMethod(method)).toThrow(
apps/server/src/auth/RpcAuthorization.ts+9-8
......@@ -14,6 +14,7 @@ import {
1414 AuthFilesystemWriteScope,
1515 AuthOrchestrationOperateScope,
1616 AuthOrchestrationReadScope,
17 AuthPreviewOperateScope,
1718 AuthRelayReadScope,
1819 AuthRelayWriteScope,
1920 AuthTerminalOperateScope,
......@@ -167,15 +168,15 @@ export const RPC_REQUIRED_SCOPES = {
167168 [WS_METHODS.terminalClose]: AuthTerminalOperateScope,
168169 [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope,
169170 [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope,
170 [WS_METHODS.previewOpen]: AuthOrchestrationOperateScope,
171 [WS_METHODS.previewNavigate]: AuthOrchestrationOperateScope,
172 [WS_METHODS.previewResize]: AuthOrchestrationOperateScope,
173 [WS_METHODS.previewAdjust]: AuthOrchestrationOperateScope,
174 [WS_METHODS.previewRefresh]: AuthOrchestrationOperateScope,
175 [WS_METHODS.previewClose]: AuthOrchestrationOperateScope,
171 [WS_METHODS.previewOpen]: AuthPreviewOperateScope,
172 [WS_METHODS.previewNavigate]: AuthPreviewOperateScope,
173 [WS_METHODS.previewResize]: AuthPreviewOperateScope,
174 [WS_METHODS.previewAdjust]: AuthPreviewOperateScope,
175 [WS_METHODS.previewRefresh]: AuthPreviewOperateScope,
176 [WS_METHODS.previewClose]: AuthPreviewOperateScope,
176177 [WS_METHODS.previewList]: AuthOrchestrationReadScope,
177 [WS_METHODS.previewClearProfile]: AuthOrchestrationOperateScope,
178 [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope,
178 [WS_METHODS.previewClearProfile]: AuthPreviewOperateScope,
179 [WS_METHODS.previewReportStatus]: AuthPreviewOperateScope,
179180 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
180181 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
181182 [WS_METHODS.deviceConfigure]: AuthSettingsWriteScope,
apps/server/src/mcp/PreviewAutomationBroker.test.ts-7
......@@ -1,7 +1,6 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { expect, it } from "@effect/vitest";
33import {
4 AuthOrchestrationOperateScope,
54 EnvironmentId,
65 PreviewAutomationClientDisconnectedError,
76 PreviewAutomationInvalidSelectorError,
......@@ -11,15 +10,12 @@ import {
1110 PreviewTabId,
1211 ProviderInstanceId,
1312 ThreadId,
14 WS_METHODS,
15 WsRpcGroup,
1613 type PreviewAutomationHost,
1714 type PreviewAutomationRequest,
1815 type PreviewAutomationStreamEvent,
1916 SERVER_BROWSER_AUTOMATION_CLIENT_ID,
2017} from "@t3tools/contracts";
2118import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";
2319import * as Exit from "effect/Exit";
2420import * as Deferred from "effect/Deferred";
2521import * as Fiber from "effect/Fiber";
......@@ -27,10 +23,7 @@ import * as Result from "effect/Result";
2723import * as Scheduler from "effect/Scheduler";
2824import * as Stream from "effect/Stream";
2925import * as TestClock from "effect/testing/TestClock";
30import * as RpcGroup from "effect/rpc/RpcGroup";
31import * as RpcTest from "effect/rpc/RpcTest";
3226
33import * as RpcAuthorization from "../auth/RpcAuthorization.ts";
3427import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
3528
3629const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer));
apps/web/src/browser/ElectronBrowserHost.tsx+12-3
......@@ -1,15 +1,16 @@
11"use client";
22
33import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
4import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
4import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
55import { useAtomValue } from "@effect/atom-react";
6import { useEffect, useMemo } from "react";
6import { type ComponentProps, useEffect, useMemo } from "react";
77
88import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
99
1010import { isElectron } from "~/env";
1111import { useTheme } from "~/hooks/useTheme";
1212import { useActivePreviewSessions } from "~/previewStateStore";
13import { useEnvironmentScope } from "~/state/session";
1314
1415import { readPreviewAnnotationTheme } from "./annotationTheme";
1516import { useBrowserPointerStore } from "./browserPointerStore";
......@@ -97,7 +98,7 @@ export function ElectronBrowserHost() {
9798 {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => {
9899 const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
99100 return (
100 <HostedBrowserWebview
101 <AuthorizedBrowserWebview
101102 key={runtimeTabId}
102103 threadRef={threadRef}
103104 tabId={snapshot.tabId}
......@@ -122,3 +123,11 @@ export function ElectronBrowserHost() {
122123 </div>
123124 );
124125}
126
127function AuthorizedBrowserWebview(props: ComponentProps<typeof HostedBrowserWebview>) {
128 const canOperatePreview = useEnvironmentScope(
129 props.threadRef.environmentId,
130 AuthPreviewOperateScope,
131 );
132 return canOperatePreview ? <HostedBrowserWebview {...props} /> : null;
133}
apps/web/src/browser/useOpenLink.ts+6-2
......@@ -1,4 +1,4 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
1import { AuthPreviewOperateScope, type ScopedThreadRef } from "@t3tools/contracts";
22import {
33 isAtomCommandInterrupted,
44 squashAtomCommandFailure,
......@@ -8,6 +8,7 @@ import { useCallback } from "react";
88import { recordVisitForThread } from "~/browserHistoryStore";
99import { readLocalApi } from "~/localApi";
1010import { previewEnvironment } from "~/state/preview";
11import { readEnvironmentScope } from "~/state/session";
1112import { useAtomCommand } from "~/state/use-atom-command";
1213
1314import {
......@@ -46,7 +47,10 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (
4647 url,
4748 event: options.event ?? NO_MODIFIER,
4849 preference: await resolveBrowserLinkTargetPreference(),
49 canOpenInApp: canOpenLinksInApp(targetThreadRef),
50 canOpenInApp:
51 targetThreadRef != null &&
52 readEnvironmentScope(targetThreadRef.environmentId, AuthPreviewOperateScope) &&
53 canOpenLinksInApp(targetThreadRef),
5054 });
5155 if (target === "app" && targetThreadRef) {
5256 const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview });
apps/web/src/components/ChatMarkdown.tsx+20-12
......@@ -27,12 +27,13 @@ import {
2727 type LucideIcon,
2828} from "lucide-react";
2929import { Check, Copy, Maximize2, Minimize2 } from "lucide";
30import type {
31 AssetResource,
32 EnvironmentId,
33 ScopedThreadRef,
34 ServerProviderSkill,
35 ThreadPullRequestKey,
30import {
31 AuthPreviewOperateScope,
32 type AssetResource,
33 type EnvironmentId,
34 type ScopedThreadRef,
35 type ServerProviderSkill,
36 type ThreadPullRequestKey,
3637} from "@t3tools/contracts";
3738import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
3839import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia";
......@@ -2518,6 +2519,7 @@ function useChatMarkdownState({
25182519 const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId);
25192520 const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;
25202521 const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
2522 const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope);
25212523 const remoteOpen = useRemoteOpenResolution(environmentId);
25222524 const canUseShellActions =
25232525 canOperateHost &&
......@@ -2701,12 +2703,12 @@ function useChatMarkdownState({
27012703 );
27022704 const openExternalLinkInPreview = useCallback(
27032705 (url: string) => {
2704 if (!threadRef) {
2706 if (!threadRef || !canOperatePreview) {
27052707 return Promise.resolve(
27062708 AsyncResult.failure<void, BrowserPreviewUnavailableError>(
27072709 Cause.fail(
27082710 new BrowserPreviewUnavailableError({
2709 message: "Thread context is unavailable.",
2711 message: "Preview access is unavailable for this client.",
27102712 }),
27112713 ),
27122714 ),
......@@ -2729,11 +2731,11 @@ function useChatMarkdownState({
27292731 return result;
27302732 });
27312733 },
2732 [openPreview, threadRef],
2734 [canOperatePreview, openPreview, threadRef],
27332735 );
27342736 const openMarkdownFileInPreview = useCallback(
27352737 (path: string) => {
2736 if (!threadRef || preparedConnection._tag === "None") {
2738 if (!threadRef || !canOperatePreview || preparedConnection._tag === "None") {
27372739 return Promise.resolve(
27382740 AsyncResult.failure<void, BrowserPreviewUnavailableError>(
27392741 Cause.fail(
......@@ -2753,7 +2755,7 @@ function useChatMarkdownState({
27532755 openPreview,
27542756 });
27552757 },
2756 [createAssetUrl, cwd, openPreview, preparedConnection, threadRef],
2758 [canOperatePreview, createAssetUrl, cwd, openPreview, preparedConnection, threadRef],
27572759 );
27582760 const findWorkspaceBasenameMatch = useCallback(
27592761 async (workspaceRelativePath: string) => {
......@@ -2866,6 +2868,7 @@ function useChatMarkdownState({
28662868 revealLabel={revealInFileManagerLabel}
28672869 onOpenInBrowser={
28682870 threadRef &&
2871 canOperatePreview &&
28692872 isPreviewAvailableFor(threadRef.environmentId) &&
28702873 isBrowserPreviewFile(fileLinkMeta.filePath)
28712874 ? () => openMarkdownFileInPreview(fileLinkMeta.filePath)
......@@ -2876,6 +2879,7 @@ function useChatMarkdownState({
28762879 },
28772880 [
28782881 canUseShellActions,
2882 canOperatePreview,
28792883 fileLinkParentSuffixByPath,
28802884 openFileInPanel,
28812885 openInPreferredEditor,
......@@ -2892,6 +2896,7 @@ function useChatMarkdownState({
28922896 const componentState = useMemo(
28932897 () => ({
28942898 canOperateHost,
2899 canOperatePreview,
28952900 cwd,
28962901 diffThemeName,
28972902 environmentId,
......@@ -2924,6 +2929,7 @@ function useChatMarkdownState({
29242929 }),
29252930 [
29262931 canOperateHost,
2932 canOperatePreview,
29272933 cwd,
29282934 diffThemeName,
29292935 environmentId,
......@@ -3079,6 +3085,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
30793085 a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) {
30803086 const {
30813087 canOperateHost,
3088 canOperatePreview,
30823089 cwd,
30833090 environmentId,
30843091 imageBaseDir,
......@@ -3139,7 +3146,8 @@ const CHAT_MARKDOWN_COMPONENTS = {
31393146 : null;
31403147 const isSameDocumentLink = href?.startsWith("#") ?? false;
31413148 const onClick = props.onClick;
3142 const canOpenInPreview = Boolean(threadRef && isPreviewAvailableFor(threadRef.environmentId));
3149 const canOpenInPreview =
3150 canOperatePreview && Boolean(threadRef && isPreviewAvailableFor(threadRef.environmentId));
31433151 const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;
31443152 const link = (
31453153 <a
apps/web/src/components/ChatView.tsx+21-7
......@@ -51,6 +51,7 @@ import {
5151 AuthOrchestrationOperateScope,
5252 AuthSettingsWriteScope,
5353 AuthSourceControlWriteScope,
54 AuthPreviewOperateScope,
5455 type AssistantCitation,
5556 type ChatFileAttachment,
5657 CommandId,
......@@ -5302,9 +5303,13 @@ export default function ChatView(props: ChatViewProps) {
53025303 },
53035304 [environmentId, navigate],
53045305 );
5306 const canOperatePreview = useEnvironmentScope(
5307 activeThreadRef?.environmentId ?? null,
5308 AuthPreviewOperateScope,
5309 );
53055310 const createBrowserSurface = useCallback(
53065311 (profileId?: string) => {
5307 if (!activeThreadRef) return;
5312 if (!activeThreadRef || !canOperatePreview) return;
53085313 void addBrowserSurface({
53095314 threadRef: activeThreadRef,
53105315 openPreview,
......@@ -5323,7 +5328,7 @@ export default function ChatView(props: ChatViewProps) {
53235328 }
53245329 });
53255330 },
5326 [activeThreadRef, openPreview],
5331 [activeThreadRef, canOperatePreview, openPreview],
53275332 );
53285333 const addDiffSurface = useCallback(() => {
53295334 if (!activeThreadRef || !isServerThread || !isGitRepo) return;
......@@ -5770,6 +5775,7 @@ export default function ChatView(props: ChatViewProps) {
57705775 closePreviewPanel();
57715776 return;
57725777 }
5778 if (!canOperatePreview) return;
57735779 const activeTabId = activePreviewState.activeTabId;
57745780 if (activeTabId) {
57755781 useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId);
......@@ -5780,6 +5786,7 @@ export default function ChatView(props: ChatViewProps) {
57805786 activePreviewState.activeTabId,
57815787 activeThreadRef,
57825788 browserAvailable,
5789 canOperatePreview,
57835790 closePreviewPanel,
57845791 createBrowserSurface,
57855792 previewPanelOpen,
......@@ -5938,7 +5945,10 @@ export default function ChatView(props: ChatViewProps) {
59385945 (surfaces: readonly RightPanelSurface[]) => {
59395946 if (!activeThreadRef) return;
59405947 for (const surface of surfaces) {
5941 if (surface.kind === "preview" && surface.resourceId) {
5948 // Without preview access only the local surface goes away. The
5949 // server record is in-memory bookkeeping, not a live view, and stays
5950 // listed until a client with the scope closes it or the server restarts.
5951 if (canOperatePreview && surface.kind === "preview" && surface.resourceId) {
59425952 void closePreviewSession({
59435953 closePreview,
59445954 snapshot: activePreviewState.sessions[surface.resourceId] ?? null,
......@@ -5960,6 +5970,7 @@ export default function ChatView(props: ChatViewProps) {
59605970 [
59615971 activeThreadRef,
59625972 activePreviewState.sessions,
5973 canOperatePreview,
59635974 closePreview,
59645975 closeTerminalMutation,
59655976 storeCloseTerminal,
......@@ -11635,10 +11646,13 @@ export default function ChatView(props: ChatViewProps) {
1163511646 </div>
1163611647 </div>
1163711648
11638 {activeThreadRef && activeEnvironmentServerBrowser ? (
11649 {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser ? (
1163911650 <PreviewSessionSync threadRef={activeThreadRef} />
1164011651 ) : null}
11641 {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? (
11652 {activeThreadRef &&
11653 activePreviewMiniPlayer &&
11654 previewMiniPlayerVisible &&
11655 (activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) ? (
1164211656 <ThreadPreviewMiniPlayer
1164311657 key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}
1164411658 threadRef={activeThreadRef}
......@@ -11754,7 +11768,7 @@ export default function ChatView(props: ChatViewProps) {
1175411768 onAddPullRequest={addPullRequestSurface}
1175511769 onAddPullRequests={addPullRequestsSurface}
1175611770 onAddDevice={addDeviceSurface}
11757 browserAvailable={browserAvailable}
11771 browserAvailable={canOperatePreview && browserAvailable}
1175811772 terminalAvailable={activeProject !== null}
1175911773 diffAvailable={isServerThread && isGitRepo}
1176011774 filesAvailable={activeProject !== null}
......@@ -11812,7 +11826,7 @@ export default function ChatView(props: ChatViewProps) {
1181211826 onAddPullRequest={addPullRequestSurface}
1181311827 onAddPullRequests={addPullRequestsSurface}
1181411828 onAddDevice={addDeviceSurface}
11815 browserAvailable={browserAvailable}
11829 browserAvailable={canOperatePreview && browserAvailable}
1181611830 terminalAvailable={activeProject !== null}
1181711831 diffAvailable={isServerThread && isGitRepo}
1181811832 filesAvailable={activeProject !== null}
apps/web/src/components/LegacySidebar.tsx+5-3
......@@ -46,6 +46,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-
4646import { CSS } from "@dnd-kit/utilities";
4747import {
4848 AuthOrchestrationOperateScope,
49 AuthPreviewOperateScope,
4950 type ContextMenuItem,
5051 type EnvironmentId,
5152 ProjectId,
......@@ -405,6 +406,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
405406 thread,
406407 } = props;
407408 const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);
409 const canOperatePreview = useEnvironmentScope(thread.environmentId, AuthPreviewOperateScope);
408410 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
409411 const threadKey = scopedThreadKey(threadRef);
410412 const [isFileDragOver, setIsFileDragOver] = useState(false);
......@@ -462,7 +464,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
462464 const handleOpenDiscoveredPort = useCallback(
463465 (event: React.MouseEvent<HTMLButtonElement>) => {
464466 const port = discoveredPorts[0];
465 if (!port) return;
467 if (!port || !canOperatePreview) return;
466468 event.preventDefault();
467469 event.stopPropagation();
468470 navigateToThread(threadRef);
......@@ -482,7 +484,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
482484 );
483485 })();
484486 },
485 [discoveredPorts, navigateToThread, openPreview, threadRef],
487 [canOperatePreview, discoveredPorts, navigateToThread, openPreview, threadRef],
486488 );
487489 const isThreadRunning = !threadRuntimeCanArchive(thread.runtime);
488490 const threadStatus = resolveThreadStatusPill({
......@@ -826,7 +828,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
826828 )}
827829 </div>
828830 <div className="ml-auto flex shrink-0 items-center gap-1.5">
829 {discoveredPorts.length > 0 && (
831 {canOperatePreview && discoveredPorts.length > 0 && (
830832 <Tooltip>
831833 <TooltipTrigger
832834 render={
apps/web/src/components/ThreadTerminalDrawer.tsx+6
......@@ -19,6 +19,7 @@ import {
1919 Trash2,
2020} from "lucide-react";
2121import {
22 AuthPreviewOperateScope,
2223 type ContextMenuItem,
2324 type ProviderInstanceId,
2425 type ResolvedKeybindingsConfig,
......@@ -80,6 +81,7 @@ import { useLocalStorage } from "../hooks/useLocalStorage";
8081import { useAttachedTerminalSession } from "../state/terminalSessions";
8182import { serverEnvironment } from "../state/server";
8283import { previewEnvironment } from "../state/preview";
84import { readEnvironmentScope } from "../state/session";
8385import { terminalEnvironment } from "../state/terminal";
8486import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
8587import { useAtomCommand } from "../state/use-atom-command";
......@@ -797,6 +799,10 @@ export function TerminalViewport({
797799 );
798800 });
799801 };
802 if (!readEnvironmentScope(environmentId, AuthPreviewOperateScope)) {
803 fallbackToBrowser();
804 return;
805 }
800806 void openTerminalLinkInPreview({
801807 url: text,
802808 threadRef,
apps/web/src/components/files/FilePreviewPanel.tsx+10-6
......@@ -1,9 +1,10 @@
11import { Spinner } from "~/components/ui/spinner";
2import type {
3 EditorId,
4 EnvironmentId,
5 ResolvedKeybindingsConfig,
6 ScopedThreadRef,
2import {
3 AuthPreviewOperateScope,
4 type EditorId,
5 type EnvironmentId,
6 type ResolvedKeybindingsConfig,
7 type ScopedThreadRef,
78} from "@t3tools/contracts";
89import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview";
910import { AuthFilesystemWriteScope } from "@t3tools/contracts";
......@@ -1021,6 +1022,7 @@ export default function FilePreviewPanel({
10211022 attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath;
10221023 const { resolvedTheme } = useTheme();
10231024 const wordWrap = useClientSettings((settings) => settings.wordWrap);
1025 const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope);
10241026 const primaryEnvironmentId = usePrimaryEnvironmentId();
10251027 const remoteOpenState = useRemoteOpenState(environmentId);
10261028 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
......@@ -1128,6 +1130,7 @@ export default function FilePreviewPanel({
11281130 ? setRenderTablePreferred
11291131 : setRenderBrowserFilePreferred;
11301132 const canOpenInBrowser =
1133 canOperatePreview &&
11311134 previewPath !== null &&
11321135 attachment === undefined &&
11331136 !isVideo &&
......@@ -1170,7 +1173,7 @@ export default function FilePreviewPanel({
11701173 };
11711174
11721175 const handleOpenInBrowser = useCallback(() => {
1173 if (!canReadFiles || !absolutePath || !environmentHttpBaseUrl) return;
1176 if (!canReadFiles || !canOperatePreview || !absolutePath || !environmentHttpBaseUrl) return;
11741177 void (async () => {
11751178 const result = await openFileInPreview({
11761179 threadRef,
......@@ -1195,6 +1198,7 @@ export default function FilePreviewPanel({
11951198 }, [
11961199 absolutePath,
11971200 canReadFiles,
1201 canOperatePreview,
11981202 createAssetUrl,
11991203 cwd,
12001204 environmentHttpBaseUrl,
apps/web/src/components/preview/PreviewPanel.tsx+12-3
......@@ -1,9 +1,14 @@
11"use client";
22
3import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts";
3import {
4 AuthPreviewOperateScope,
5 type PreviewAnnotationPayload,
6 type ScopedThreadRef,
7} from "@t3tools/contracts";
48
59import type { ComposerImageAttachment } from "~/composerDraftStore";
610import { usePreviewAvailable } from "~/browser/previewRuntime";
11import { useEnvironmentScope } from "~/state/session";
712
813import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell";
914import { PreviewView } from "./PreviewView";
......@@ -28,12 +33,16 @@ export function PreviewPanel({
2833 visible,
2934 onSendAnnotation,
3035}: Props) {
31 if (!usePreviewAvailable(threadRef.environmentId)) {
36 const available = usePreviewAvailable(threadRef.environmentId);
37 const canOperatePreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope);
38 if (!canOperatePreview || !available) {
3239 return (
3340 <PreviewPanelShell mode={mode}>
3441 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
3542 <p className="max-w-sm text-sm text-muted-foreground">
36 Preview is only available in the T3 Code desktop app.
43 {canOperatePreview
44 ? "Preview is only available in the T3 Code desktop app."
45 : "Pair this client again with preview access to control browser previews."}
3746 </p>
3847 </div>
3948 </PreviewPanelShell>
apps/web/src/components/preview/PreviewView.test.tsx+98
......@@ -15,6 +15,9 @@ const mocks = vi.hoisted(() => ({
1515 navigate: vi.fn(async (_tabId: string, _url: string): Promise<void> => undefined),
1616 rememberPreviewUrl: vi.fn(),
1717 readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })),
18 readEnvironmentScope: vi.fn(() => true),
19 setAnnotationSendEnabled: vi.fn(async (): Promise<void> => undefined),
20 cancelPickElement: vi.fn(async () => undefined),
1821 submittedUrl: null as ((url: string) => void) | null,
1922 emptyStateUrl: null as ((url: string) => void) | null,
2023 togglePictureInPicture: null as (() => void) | null,
......@@ -61,6 +64,8 @@ vi.mock("~/browserHistoryStore", () => ({
6164vi.mock("~/state/session", async (importOriginal) => ({
6265 ...(await importOriginal<typeof import("~/state/session")>()),
6366 readPreparedConnection: mocks.readPreparedConnection,
67 readEnvironmentScope: mocks.readEnvironmentScope,
68 useEnvironmentScope: mocks.readEnvironmentScope,
6469}));
6570
6671// File-preview errors share a module with asset hooks. Keep the pure URL resolver
......@@ -214,6 +219,8 @@ vi.mock("./previewBridge", () => ({
214219 previewBridge: {
215220 navigate: mocks.navigate,
216221 pickElement: mocks.pickElement,
222 setAnnotationSendEnabled: mocks.setAnnotationSendEnabled,
223 cancelPickElement: mocks.cancelPickElement,
217224 pictureInPicture: {
218225 open: mocks.openPictureInPicture,
219226 close: mocks.closePictureInPicture,
......@@ -338,6 +345,9 @@ describe("PreviewView navigation", () => {
338345 mocks.navigate.mockClear();
339346 mocks.rememberPreviewUrl.mockClear();
340347 mocks.readPreparedConnection.mockClear();
348 mocks.readEnvironmentScope.mockReset().mockReturnValue(true);
349 mocks.setAnnotationSendEnabled.mockClear();
350 mocks.cancelPickElement.mockClear();
341351 mocks.submittedUrl = null;
342352 mocks.emptyStateUrl = null;
343353 mocks.togglePictureInPicture = null;
......@@ -588,6 +598,94 @@ describe("PreviewView navigation", () => {
588598 expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, annotation);
589599 });
590600
601 it("retains the annotation locally when its own environment loses access during capture", async () => {
602 const annotation = {
603 id: "annotation-revoked",
604 pageUrl: "https://example.com/dashboard",
605 pageTitle: "Dashboard",
606 comment: "Tighten this spacing",
607 elements: [],
608 regions: [],
609 strokes: [],
610 styleChanges: [],
611 screenshot: null,
612 createdAt: "2026-09-05T00:00:00.000Z",
613 };
614 const onSendAnnotation = vi.fn();
615 let submitPick: (() => void) | undefined;
616 mocks.pickElement.mockImplementationOnce(
617 () =>
618 new Promise((resolve) => {
619 submitPick = () => resolve({ annotation, submission: "send" });
620 }),
621 );
622 const document = installTestDom();
623 const { createRoot } = await import("react-dom/client");
624 const root = createRoot(document.createElement("div") as unknown as Element);
625 const view = () => (
626 <PreviewView
627 threadRef={TEST_THREAD_REF}
628 tabId="tab-1"
629 visible
630 onSendAnnotation={onSendAnnotation}
631 />
632 );
633 try {
634 await act(async () => root.render(view()));
635 await act(async () => mocks.toggleAnnotation?.());
636 expect(submitPick).toBeDefined();
637 expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, true);
638
639 mocks.readEnvironmentScope.mockImplementation(
640 (environmentId?: unknown) => environmentId !== TEST_THREAD_REF.environmentId,
641 );
642 await act(async () => root.render(view()));
643 expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, false);
644 await act(async () => submitPick!());
645 expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, annotation);
646 expect(onSendAnnotation).not.toHaveBeenCalled();
647
648 mocks.readEnvironmentScope.mockReturnValue(true);
649 mocks.pickElement.mockResolvedValue({ annotation, submission: "send" });
650 await act(async () => root.render(view()));
651 await act(async () => mocks.toggleAnnotation?.());
652 expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, true);
653 expect(onSendAnnotation).toHaveBeenCalledWith(annotation, null);
654 } finally {
655 await act(async () => root.unmount());
656 vi.unstubAllGlobals();
657 }
658 });
659
660 it("does not reopen a cancelled picker after the permission update finishes", async () => {
661 let finishUpdate: (() => void) | undefined;
662 mocks.setAnnotationSendEnabled.mockImplementationOnce(
663 () =>
664 new Promise<void>((resolve) => {
665 finishUpdate = resolve;
666 }),
667 );
668 const document = installTestDom();
669 const { createRoot } = await import("react-dom/client");
670 const root = createRoot(document.createElement("div") as unknown as Element);
671 try {
672 await act(async () =>
673 root.render(<PreviewView threadRef={TEST_THREAD_REF} tabId="tab-1" visible />),
674 );
675 await act(async () => mocks.toggleAnnotation?.());
676 expect(finishUpdate).toBeDefined();
677 expect(mocks.pickElement).not.toHaveBeenCalled();
678 await act(async () => mocks.toggleAnnotation?.());
679 expect(mocks.cancelPickElement).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID);
680 await act(async () => finishUpdate!());
681 expect(mocks.pickElement).not.toHaveBeenCalled();
682 expect(mocks.addPreviewAnnotation).not.toHaveBeenCalled();
683 } finally {
684 await act(async () => root.unmount());
685 vi.unstubAllGlobals();
686 }
687 });
688
591689 it("warns when main dropped the crop before handing over the pick", async () => {
592690 const annotation = {
593691 id: "annotation-3",
apps/web/src/components/preview/PreviewView.tsx+47-9
......@@ -6,6 +6,7 @@ import {
66 squashAtomCommandFailure,
77} from "@t3tools/client-runtime/state/runtime";
88import {
9 AuthOrchestrationOperateScope,
910 DEFAULT_BROWSER_PROFILE_ID,
1011 FILL_PREVIEW_VIEWPORT,
1112 type PreviewAnnotationPayload,
......@@ -42,6 +43,7 @@ import {
4243 selectThreadPreviewMiniPlayerTabId,
4344 usePreviewMiniPlayerStore,
4445} from "~/previewMiniPlayerStore";
46import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
4547import { useRightPanelStore } from "~/rightPanelStore";
4648
4749import { previewBridge } from "./previewBridge";
......@@ -114,8 +116,11 @@ export function PreviewView({
114116}: Props) {
115117 const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined);
116118 const [pickActive, setPickActive] = useState(false);
119 const canSendAnnotation =
120 useEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) &&
121 Boolean(onSendAnnotation);
117122 const activeRecordingTabIds = useActiveBrowserRecordingTabIds();
118 const pickActiveRef = useRef(false);
123 const pickActiveRef = useRef<{ cancelled: boolean } | null>(null);
119124 const isMountedRef = useRef(true);
120125 // Kept in sync so the title effect can depend on the stable thread key
121126 // instead of the thread object, which is recreated on every update.
......@@ -720,6 +725,7 @@ export function PreviewView({
720725 const handlePickElement = useCallback(() => {
721726 if (!previewBridge || !runtimeTabId) return;
722727 if (pickActiveRef.current) {
728 pickActiveRef.current.cancelled = true;
723729 void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined);
724730 return;
725731 }
......@@ -730,12 +736,28 @@ export function PreviewView({
730736 // every pick they'd have to click back into the textarea.
731737 const previouslyFocused =
732738 typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null;
733 pickActiveRef.current = true;
739 const pickRequest = { cancelled: false };
740 let submitted = false;
741 pickActiveRef.current = pickRequest;
734742 setPickActive(true);
735743 void (async () => {
736744 try {
745 await previewBridge.setAnnotationSendEnabled?.(
746 runtimeTabId,
747 Boolean(onSendAnnotation) &&
748 readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope),
749 );
750 if (pickRequest.cancelled) return;
737751 const result = await previewBridge.pickElement(runtimeTabId);
738 if (!result) return;
752 if (!result || pickRequest.cancelled) return;
753 // The user has submitted. Nothing that happens after this point (a
754 // second picker click, a tab change, unmount) may discard it, so the
755 // pick stops being cancellable here rather than in `finally`.
756 if (pickActiveRef.current === pickRequest) {
757 pickActiveRef.current = null;
758 if (isMountedRef.current) setPickActive(false);
759 submitted = true;
760 }
739761 const { annotation: picked, submission, screenshotFailed = false } = result;
740762 // The structured annotation is still sendable when its optional crop
741763 // stalls or fails, so tell the user what they lost and keep going
......@@ -775,20 +797,28 @@ export function PreviewView({
775797 if (image) {
776798 addImage(threadRef, image);
777799 }
778 if (submission === "send") {
800 if (
801 submission === "send" &&
802 readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)
803 ) {
779804 onSendAnnotation?.(annotation, image);
780805 }
781806 } catch {
782807 // Picker failed (e.g. webview navigated). Treat as silent cancel.
783808 } finally {
784 pickActiveRef.current = false;
785 // Avoid `setState on unmounted component` if the panel/thread closed
786 // while the pick was in flight.
787 if (isMountedRef.current) setPickActive(false);
809 // A submitted pick already released itself above; a cancelled or
810 // failed one releases here. Avoid `setState on unmounted component`
811 // if the panel/thread closed while the pick was in flight.
812 const isCurrentPick = pickActiveRef.current === pickRequest;
813 if (isCurrentPick) {
814 pickActiveRef.current = null;
815 if (isMountedRef.current) setPickActive(false);
816 }
788817 // Best-effort: restore focus to whatever the user had before the
789818 // pick stole it into the guest webContents. Skip if the previously-
790819 // focused element was unmounted or is no longer focusable.
791820 if (
821 (isCurrentPick || submitted) &&
792822 previouslyFocused &&
793823 previouslyFocused.isConnected &&
794824 typeof previouslyFocused.focus === "function"
......@@ -803,13 +833,21 @@ export function PreviewView({
803833 })();
804834 }, [addImage, addPreviewAnnotation, onSendAnnotation, runtimeTabId, threadRef]);
805835
836 useEffect(() => {
837 if (!pickActive || !previewBridge || !runtimeTabId) return;
838 void previewBridge
839 .setAnnotationSendEnabled?.(runtimeTabId, canSendAnnotation)
840 .catch(() => undefined);
841 }, [canSendAnnotation, pickActive, runtimeTabId]);
842
806843 // If the active tab changes mid-pick (close, thread switch, hot restart),
807844 // tell main to tear down the in-flight session AND reset our local toggle
808845 // state so the button doesn't get stuck pressed against a stale tab id.
809846 useEffect(() => {
810847 return () => {
811848 if (!pickActiveRef.current) return;
812 pickActiveRef.current = false;
849 pickActiveRef.current.cancelled = true;
850 pickActiveRef.current = null;
813851 if (previewBridge && runtimeTabId) {
814852 void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined);
815853 }
apps/web/src/components/settings/ConnectionsSettings.tsx+6
......@@ -28,6 +28,7 @@ import {
2828 AuthEnvironmentMaintainScope,
2929 AuthOrchestrationOperateScope,
3030 AuthOrchestrationReadScope,
31 AuthPreviewOperateScope,
3132 AuthRelayReadScope,
3233 AuthRelayWriteScope,
3334 AuthSourceControlWriteScope,
......@@ -257,6 +258,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
257258 title: "Maintain environment",
258259 description: "Update the server and control environment processes.",
259260 },
261 {
262 scope: AuthPreviewOperateScope,
263 title: "Control previews",
264 description: "Open browser previews and host browser automation.",
265 },
260266 {
261267 scope: AuthTerminalOperateScope,
262268 title: "Use terminals",
apps/web/src/routes/_chat.tsx+9-1
......@@ -1,3 +1,4 @@
1import { AuthPreviewOperateScope } from "@t3tools/contracts";
12import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router";
23import { useAtomValue } from "@effect/atom-react";
34import { useEffect, useMemo } from "react";
......@@ -10,6 +11,7 @@ import { openCommandPalette } from "../commandPaletteBus";
1011import { useProjects } from "../state/entities";
1112import { isPreviewAvailableFor } from "../browser/previewRuntime";
1213import { usePrimaryEnvironmentId } from "../state/environments";
14import { useEnvironmentScope } from "../state/session";
1315import { selectProjectGroupingSettings } from "../logicalProject";
1416import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
1517import { dispatchPreviewAction } from "../components/preview/previewActionBus";
......@@ -33,6 +35,10 @@ function ChatRouteGlobalShortcuts() {
3335 const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size);
3436 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
3537 useHandleNewThread();
38 const canOperatePreview = useEnvironmentScope(
39 routeThreadRef?.environmentId ?? null,
40 AuthPreviewOperateScope,
41 );
3642 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
3743 const legacySidebarEnabled = useLegacySidebarEnabled();
3844 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
......@@ -140,7 +146,7 @@ function ChatRouteGlobalShortcuts() {
140146 if (command === "preview.toggle") {
141147 event.preventDefault();
142148 event.stopPropagation();
143 if (!routeThreadRef) return;
149 if (!routeThreadRef || (!canOperatePreview && !previewOpen)) return;
144150 if (!isPreviewAvailableFor(routeThreadRef.environmentId)) {
145151 toastManager.add(
146152 stackedThreadToast({
......@@ -167,6 +173,7 @@ function ChatRouteGlobalShortcuts() {
167173 ) {
168174 event.preventDefault();
169175 event.stopPropagation();
176 if (!canOperatePreview) return;
170177 const action =
171178 command === "preview.refresh"
172179 ? "refresh"
......@@ -189,6 +196,7 @@ function ChatRouteGlobalShortcuts() {
189196 activeDraftThread,
190197 activeThread,
191198 clearSelection,
199 canOperatePreview,
192200 handleNewThread,
193201 keybindings,
194202 defaultProjectRef,
packages/contracts/src/auth.ts+3
......@@ -83,6 +83,7 @@ export const AuthOrchestrationOperateScope = "orchestration:operate" as const;
8383export const AuthSettingsWriteScope = "settings:write" as const;
8484export const AuthProvidersManageScope = "providers:manage" as const;
8585export const AuthEnvironmentMaintainScope = "environment:maintain" as const;
86export const AuthPreviewOperateScope = "preview:operate" as const;
8687export const AuthTerminalOperateScope = "terminal:operate" as const;
8788export const AuthSourceControlWriteScope = "source-control:write" as const;
8889export const AuthFilesystemReadScope = "filesystem:read" as const;
......@@ -99,6 +100,7 @@ export const AuthEnvironmentScope = Schema.Literals([
99100 AuthSettingsWriteScope,
100101 AuthProvidersManageScope,
101102 AuthEnvironmentMaintainScope,
103 AuthPreviewOperateScope,
102104 AuthTerminalOperateScope,
103105 AuthFilesystemReadScope,
104106 AuthFilesystemWriteScope,
......@@ -126,6 +128,7 @@ export const AuthStandardClientScopes = [
126128 AuthSettingsWriteScope,
127129 AuthProvidersManageScope,
128130 AuthEnvironmentMaintainScope,
131 AuthPreviewOperateScope,
129132 AuthTerminalOperateScope,
130133 AuthSourceControlWriteScope,
131134 AuthFilesystemReadScope,
packages/contracts/src/ipc.ts+7
......@@ -1069,6 +1069,11 @@ export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({
10691069 theme: DesktopPreviewAnnotationThemeSchema,
10701070});
10711071
1072export const DesktopPreviewAnnotationSendEnabledInputSchema = Schema.Struct({
1073 tabId: DesktopPreviewTabIdSchema,
1074 enabled: Schema.Boolean,
1075});
1076
10721077export const DesktopPreviewArtifactInputSchema = Schema.Struct({
10731078 path: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()),
10741079});
......@@ -1278,6 +1283,8 @@ export interface DesktopPreviewBridge {
12781283 readonly targetProfileId: string;
12791284 }) => Promise<BrowserImportResult>;
12801285 setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>;
1286 /** Keep an open annotation picker's send shortcut in sync with its thread grant. */
1287 setAnnotationSendEnabled: (tabId: string, enabled: boolean) => Promise<void>;
12811288 /**
12821289 * Activate the in-page element picker for the given tab. Resolves with
12831290 * the picked annotation and its attach/send intent, or `null` when the