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";...@@ -90,6 +90,8 @@ export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";
90export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources";90export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources";
91export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies";91export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies";
92export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme";92export 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";
93export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";95export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
94export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";96export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";
95export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot";97export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot";
apps/desktop/src/ipc/methods/preview.ts+15
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 DesktopPreviewAnnotationSendEnabledInputSchema,
2 DesktopPreviewAnnotationThemeInputSchema,3 DesktopPreviewAnnotationThemeInputSchema,
3 DesktopPreviewArtifactInputSchema,4 DesktopPreviewArtifactInputSchema,
4 DesktopPreviewConfigInputSchema,5 DesktopPreviewConfigInputSchema,
...@@ -377,6 +378,19 @@ export const pickElement = DesktopIpc.makeIpcMethod({...@@ -377,6 +378,19 @@ export const pickElement = DesktopIpc.makeIpcMethod({
377 }),378 }),
378});379});
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
380export const captureScreenshot = DesktopIpc.makeIpcMethod({394export const captureScreenshot = DesktopIpc.makeIpcMethod({
381 channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL,395 channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL,
382 payload: DesktopPreviewTabInputSchema,396 payload: DesktopPreviewTabInputSchema,
...@@ -438,6 +452,7 @@ export const methods = [...@@ -438,6 +452,7 @@ export const methods = [
438 clearCache,452 clearCache,
439 getPreviewConfig,453 getPreviewConfig,
440 setAnnotationTheme,454 setAnnotationTheme,
455 setAnnotationSendEnabled,
441 pickElement,456 pickElement,
442 cancelPickElement,457 cancelPickElement,
443 captureScreenshot,458 captureScreenshot,
apps/desktop/src/preload.ts+5
...@@ -339,6 +339,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -339,6 +339,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
339 ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId, profileId }),339 ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId, profileId }),
340 setAnnotationTheme: (theme) =>340 setAnnotationTheme: (theme) =>
341 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }),341 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 }),
342 pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),347 pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),
343 cancelPickElement: (tabId) =>348 cancelPickElement: (tabId) =>
344 ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }),349 ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }),
apps/desktop/src/preview/AnnotationKeyboard.test.ts+15-6
...@@ -15,14 +15,23 @@ const keyboardEvent = (...@@ -15,14 +15,23 @@ const keyboardEvent = (
1515
16describe("resolveAnnotationSubmission", () => {16describe("resolveAnnotationSubmission", () => {
17 it("attaches on Enter and sends on Cmd/Ctrl+Enter", () => {17 it("attaches on Enter and sends on Cmd/Ctrl+Enter", () => {
18 expect(resolveAnnotationSubmission(keyboardEvent())).toBe("attach");18 expect(resolveAnnotationSubmission(keyboardEvent(), true)).toBe("attach");
19 expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }))).toBe("send");19 expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }), true)).toBe("send");
20 expect(resolveAnnotationSubmission(keyboardEvent({ ctrlKey: true }))).toBe("send");20 expect(resolveAnnotationSubmission(keyboardEvent({ ctrlKey: true }), true)).toBe("send");
21 });21 });
2222
23 it("leaves Shift+Enter and composition events available for editing", () => {23 it("leaves Shift+Enter and composition events available for editing", () => {
24 expect(resolveAnnotationSubmission(keyboardEvent({ shiftKey: true }))).toBeNull();24 expect(resolveAnnotationSubmission(keyboardEvent({ shiftKey: true }), true)).toBeNull();
25 expect(resolveAnnotationSubmission(keyboardEvent({ isComposing: true }))).toBeNull();25 expect(resolveAnnotationSubmission(keyboardEvent({ isComposing: true }), true)).toBeNull();
26 expect(resolveAnnotationSubmission(keyboardEvent({ key: " " }))).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();
27 });36 });
28});37});
apps/desktop/src/preview/AnnotationKeyboard.ts+2-1
...@@ -10,7 +10,8 @@ interface AnnotationKeyboardEvent {...@@ -10,7 +10,8 @@ interface AnnotationKeyboardEvent {
1010
11export function resolveAnnotationSubmission(11export function resolveAnnotationSubmission(
12 event: AnnotationKeyboardEvent,12 event: AnnotationKeyboardEvent,
13 canSend: boolean,
13): PreviewAnnotationSubmission | null {14): PreviewAnnotationSubmission | null {
14 if (event.key !== "Enter" || event.shiftKey || event.isComposing) return null;15 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";
16}17}
apps/desktop/src/preview/GuestProtocol.ts+1
...@@ -3,6 +3,7 @@ export const CANCEL_PICK_CHANNEL = "preview:cancel-pick";...@@ -3,6 +3,7 @@ export const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
3export const ELEMENT_PICKED_CHANNEL = "preview:element-picked";3export const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
4export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";4export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
5export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";5export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";
6export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled";
6export const HUMAN_INPUT_CHANNEL = "preview:human-input";7export const HUMAN_INPUT_CHANNEL = "preview:human-input";
7export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate";8export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate";
8export const RECORDING_CURSOR_CHANNEL = "preview:recording-cursor";9export const RECORDING_CURSOR_CHANNEL = "preview:recording-cursor";
apps/desktop/src/preview/Manager.test.ts+80
...@@ -3937,6 +3937,85 @@ describe("PreviewManager", () => {...@@ -3937,6 +3937,85 @@ describe("PreviewManager", () => {
3937 ),3937 ),
3938 );3938 );
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
3940 effectIt.effect("settles the pick when the annotation screenshot never arrives", () =>4019 effectIt.effect("settles the pick when the annotation screenshot never arrives", () =>
3941 withManager((manager) =>4020 withManager((manager) =>
3942 Effect.gen(function* () {4021 Effect.gen(function* () {
...@@ -3983,6 +4062,7 @@ describe("PreviewManager", () => {...@@ -3983,6 +4062,7 @@ describe("PreviewManager", () => {
3983 const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);4062 const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);
3984 yield* Effect.yieldNow;4063 yield* Effect.yieldNow;
39854064
4065 yield* manager.setAnnotationSendEnabled("tab_1", true);
3986 onPicked?.(4066 onPicked?.(
3987 {},4067 {},
3988 {4068 {
apps/desktop/src/preview/Manager.ts+31-8
...@@ -65,6 +65,7 @@ import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";...@@ -65,6 +65,7 @@ import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
65import * as BrowserSession from "./BrowserSession.ts";65import * as BrowserSession from "./BrowserSession.ts";
66import {66import {
67 ANNOTATION_CAPTURED_CHANNEL,67 ANNOTATION_CAPTURED_CHANNEL,
68 ANNOTATION_SEND_ENABLED_CHANNEL,
68 ANNOTATION_THEME_CHANNEL,69 ANNOTATION_THEME_CHANNEL,
69 CANCEL_PICK_CHANNEL,70 CANCEL_PICK_CHANNEL,
70 ELEMENT_PICKED_CHANNEL,71 ELEMENT_PICKED_CHANNEL,
...@@ -559,6 +560,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -559,6 +560,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
559560
560 let forwardedShortcuts: ReadonlyArray<PreviewForwardedShortcut> = [];561 let forwardedShortcuts: ReadonlyArray<PreviewForwardedShortcut> = [];
561 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);562 const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);
563 const annotationSendEnabled = new Map<string, boolean>();
562 const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());564 const mainWindowRef = yield* Ref.make<Option.Option<BrowserWindow>>(Option.none());
563 const tabsRef = yield* SynchronizedRef.make<ReadonlyMap<string, PreviewTabState>>(new Map());565 const tabsRef = yield* SynchronizedRef.make<ReadonlyMap<string, PreviewTabState>>(new Map());
564 const attachedRef = yield* Ref.make<ReadonlyMap<number, ManagedListeners>>(new Map());566 const attachedRef = yield* Ref.make<ReadonlyMap<number, ManagedListeners>>(new Map());
...@@ -1713,6 +1715,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1713,6 +1715,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1713 const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) {1715 const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) {
1714 if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return;1716 if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return;
1715 clearPendingRecording(tabId);1717 clearPendingRecording(tabId);
1718 annotationSendEnabled.delete(tabId);
1716 yield* Effect.all(1719 yield* Effect.all(
1717 [1720 [
1718 cancelPickElement(tabId),1721 cancelPickElement(tabId),
...@@ -1814,9 +1817,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1814,9 +1817,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1814 // changed. Only push its zoom back down — Chromium may have just handed1817 // changed. Only push its zoom back down — Chromium may have just handed
1815 // this guest the app window's zoom level.1818 // this guest the app window's zoom level.
1816 yield* assertTabZoom(tabId);1819 yield* assertTabZoom(tabId);
1817 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>1820 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => {
1818 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),1821 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme);
1819 );1822 wc.send(ANNOTATION_SEND_ENABLED_CHANNEL, annotationSendEnabled.get(tabId) === true);
1823 });
1820 return;1824 return;
1821 }1825 }
1822 const replacedWebContentsId =1826 const replacedWebContentsId =
...@@ -1923,9 +1927,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1923,9 +1927,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1923 // syncTabAudible's ownership check, so re-read and reconcile through the1927 // syncTabAudible's ownership check, so re-read and reconcile through the
1924 // same path the event uses.1928 // same path the event uses.
1925 yield* syncTabAudible(tabId, wc, yield* readAudible);1929 yield* syncTabAudible(tabId, wc, yield* readAudible);
1926 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>1930 yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => {
1927 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),1931 wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme);
1928 );1932 wc.send(ANNOTATION_SEND_ENABLED_CHANNEL, annotationSendEnabled.get(tabId) === true);
1933 });
1929 const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus;1934 const latestNavStatus = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.navStatus;
1930 if (1935 if (
1931 pendingUrl &&1936 pendingUrl &&
...@@ -2139,6 +2144,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2139,6 +2144,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2139 );2144 );
2140 });2145 });
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
2142 const pickElement = Effect.fn("PreviewManager.pickElement")(function* (tabId: string) {2158 const pickElement = Effect.fn("PreviewManager.pickElement")(function* (tabId: string) {
2143 const wc = yield* requireWebContents(tabId);2159 const wc = yield* requireWebContents(tabId);
2144 yield* cancelPickElement(tabId);2160 yield* cancelPickElement(tabId);
...@@ -2216,7 +2232,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2216,7 +2232,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2216 return;2232 return;
2217 }2233 }
2218 const cropRect = normalizeCaptureRect(args[1]);2234 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";
2220 runFork(2237 runFork(
2221 captureAnnotationScreenshot(tabId, wc, cropRect).pipe(2238 captureAnnotationScreenshot(tabId, wc, cropRect).pipe(
2222 // The renderer cannot tell a dropped crop from a comment-only2239 // The renderer cannot tell a dropped crop from a comment-only
...@@ -2275,7 +2292,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2275,7 +2292,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2275 wc.once("destroyed", onDestroyed);2292 wc.once("destroyed", onDestroyed);
2276 wc.on("did-start-navigation", onNavigated);2293 wc.on("did-start-navigation", onNavigated);
2277 if (!wc.isFocused()) wc.focus();2294 if (!wc.isFocused()) wc.focus();
2278 wc.send(START_PICK_CHANNEL, annotationTheme);2295 wc.send(START_PICK_CHANNEL, annotationTheme, annotationSendEnabled.get(tabId) === true);
2279 });2296 });
2280 });2297 });
2281 runFork(2298 runFork(
...@@ -3356,6 +3373,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -3356,6 +3373,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
3356 revealArtifact,3373 revealArtifact,
3357 saveRecording,3374 saveRecording,
3358 setAnnotationTheme,3375 setAnnotationTheme,
3376 setAnnotationSendEnabled,
3359 setAudioMuted,3377 setAudioMuted,
3360 setColorScheme,3378 setColorScheme,
3361 setMainWindow,3379 setMainWindow,
...@@ -3580,6 +3598,10 @@ export class PreviewManager extends Context.Service<...@@ -3580,6 +3598,10 @@ export class PreviewManager extends Context.Service<
3580 readonly setAnnotationTheme: (3598 readonly setAnnotationTheme: (
3581 theme: DesktopPreviewAnnotationTheme,3599 theme: DesktopPreviewAnnotationTheme,
3582 ) => Effect.Effect<void, PreviewManagerError>;3600 ) => Effect.Effect<void, PreviewManagerError>;
3601 readonly setAnnotationSendEnabled: (
3602 tabId: string,
3603 enabled: boolean,
3604 ) => Effect.Effect<void, PreviewManagerError>;
3583 readonly pickElement: (3605 readonly pickElement: (
3584 tabId: string,3606 tabId: string,
3585 ) => Effect.Effect<PreviewAnnotationSubmissionResult | null, PreviewManagerError>;3607 ) => Effect.Effect<PreviewAnnotationSubmissionResult | null, PreviewManagerError>;
...@@ -3697,6 +3719,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -3697,6 +3719,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
3697 },3719 },
3698 ),3720 ),
3699 setAnnotationTheme: operations.setAnnotationTheme,3721 setAnnotationTheme: operations.setAnnotationTheme,
3722 setAnnotationSendEnabled: operations.setAnnotationSendEnabled,
3700 pickElement: operations.pickElement,3723 pickElement: operations.pickElement,
3701 cancelPickElement: operations.cancelPickElement,3724 cancelPickElement: operations.cancelPickElement,
3702 captureScreenshot: operations.captureScreenshot,3725 captureScreenshot: operations.captureScreenshot,
apps/desktop/src/preview/PickPreload.ts+31-6
...@@ -20,6 +20,7 @@ import { installRecordingCursor } from "./RecordingCursor.ts";...@@ -20,6 +20,7 @@ import { installRecordingCursor } from "./RecordingCursor.ts";
20import { DEFAULT_RECORDING_INPUT_OPTIONS } from "./RecordingInput.ts";20import { DEFAULT_RECORDING_INPUT_OPTIONS } from "./RecordingInput.ts";
21import {21import {
22 ANNOTATION_CAPTURED_CHANNEL,22 ANNOTATION_CAPTURED_CHANNEL,
23 ANNOTATION_SEND_ENABLED_CHANNEL,
23 ANNOTATION_THEME_CHANNEL,24 ANNOTATION_THEME_CHANNEL,
24 CANCEL_PICK_CHANNEL,25 CANCEL_PICK_CHANNEL,
25 ELEMENT_PICKED_CHANNEL,26 ELEMENT_PICKED_CHANNEL,
...@@ -129,6 +130,7 @@ interface SelectedElement {...@@ -129,6 +130,7 @@ interface SelectedElement {
129interface AnnotationSession {130interface AnnotationSession {
130 teardown: (notifyMain: boolean) => void;131 teardown: (notifyMain: boolean) => void;
131 applyTheme: (theme: DesktopPreviewAnnotationTheme) => void;132 applyTheme: (theme: DesktopPreviewAnnotationTheme) => void;
133 setSendEnabled: (enabled: boolean) => void;
132}134}
133135
134let activeSession: AnnotationSession | null = null;136let activeSession: AnnotationSession | null = null;
...@@ -512,7 +514,7 @@ function strokeBounds(...@@ -512,7 +514,7 @@ function strokeBounds(
512 return { x: left, y: top, width: right - left, height: bottom - top };514 return { x: left, y: top, width: right - left, height: bottom - top };
513}515}
514516
515function startAnnotation(): void {517function startAnnotation(sendEnabled: boolean): void {
516 activeSession?.teardown(false);518 activeSession?.teardown(false);
517 let finished = false;519 let finished = false;
518 const host = document.createElement("div");520 const host = document.createElement("div");
...@@ -589,6 +591,12 @@ function startAnnotation(): void {...@@ -589,6 +591,12 @@ function startAnnotation(): void {
589 composerRow.appendChild(dragHandle);591 composerRow.appendChild(dragHandle);
590592
591 const submit = createButton("Attach", "Attach annotation and screenshot (Enter)");593 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();
592 submit.className +=600 submit.className +=
593 " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90";601 " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90";
594 composerRow.appendChild(submit);602 composerRow.appendChild(submit);
...@@ -1345,6 +1353,7 @@ function startAnnotation(): void {...@@ -1345,6 +1353,7 @@ function startAnnotation(): void {
1345 };1353 };
13461354
1347 const submitAnnotation = (submission: PreviewAnnotationSubmission): void => {1355 const submitAnnotation = (submission: PreviewAnnotationSubmission): void => {
1356 if (submission === "send" && !sendEnabled) return;
1348 if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0))1357 if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0))
1349 return;1358 return;
1350 pendingCapture = true;1359 pendingCapture = true;
...@@ -1396,7 +1405,12 @@ function startAnnotation(): void {...@@ -1396,7 +1405,12 @@ function startAnnotation(): void {
1396 ...submittedRegions.map((region) => region.rect),1405 ...submittedRegions.map((region) => region.rect),
1397 ...submittedStrokes.map((stroke) => stroke.bounds),1406 ...submittedStrokes.map((stroke) => stroke.bounds),
1398 ]);1407 ]);
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 );
1400 })1414 })
1401 .catch(() => {1415 .catch(() => {
1402 // Last resort. Main is waiting on this message, so hand it an empty1416 // Last resort. Main is waiting on this message, so hand it an empty
...@@ -1407,7 +1421,8 @@ function startAnnotation(): void {...@@ -1407,7 +1421,8 @@ function startAnnotation(): void {
1407 };1421 };
1408 submit.addEventListener("click", () => submitAnnotation("attach"));1422 submit.addEventListener("click", () => submitAnnotation("attach"));
1409 root.addEventListener("keydown", (event) => {1423 root.addEventListener("keydown", (event) => {
1410 const submission = event.target === comment ? resolveAnnotationSubmission(event) : null;1424 const submission =
1425 event.target === comment ? resolveAnnotationSubmission(event, sendEnabled) : null;
1411 // Keep this in the bubble phase so editor inputs receive the event before1426 // Keep this in the bubble phase so editor inputs receive the event before
1412 // it is isolated from listeners installed by the inspected page.1427 // it is isolated from listeners installed by the inspected page.
1413 event.stopImmediatePropagation();1428 event.stopImmediatePropagation();
...@@ -1433,12 +1448,22 @@ function startAnnotation(): void {...@@ -1433,12 +1448,22 @@ function startAnnotation(): void {
1433 activeSession = {1448 activeSession = {
1434 teardown,1449 teardown,
1435 applyTheme: (theme) => applyAnnotationTheme(host, theme),1450 applyTheme: (theme) => applyAnnotationTheme(host, theme),
1451 setSendEnabled: (enabled) => {
1452 sendEnabled = enabled;
1453 updateSendHint();
1454 },
1436 };1455 };
1437}1456}
14381457
1439ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => {1458ipcRenderer.on(
1440 if (theme) annotationTheme = theme;1459 START_PICK_CHANNEL,
1441 startAnnotation();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);
1442});1467});
1443ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => {1468ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => {
1444 annotationTheme = theme;1469 annotationTheme = theme;
apps/server/src/auth/RpcAuthorization.test.ts+23
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 AuthOrchestrationOperateScope,9 AuthOrchestrationOperateScope,
10 AuthOrchestrationReadScope,10 AuthOrchestrationReadScope,
11 AuthSourceControlWriteScope,11 AuthSourceControlWriteScope,
12 AuthPreviewOperateScope,
12 AuthRelayReadScope,13 AuthRelayReadScope,
13 AuthRelayWriteScope,14 AuthRelayWriteScope,
14 WS_METHODS,15 WS_METHODS,
...@@ -138,6 +139,28 @@ describe("RPC authorization scopes", () => {...@@ -138,6 +139,28 @@ describe("RPC authorization scopes", () => {
138 );139 );
139 });140 });
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
141 it("rejects unknown RPC method names", () => {164 it("rejects unknown RPC method names", () => {
142 for (const method of ["server.notRegistered", "toString", "constructor"]) {165 for (const method of ["server.notRegistered", "toString", "constructor"]) {
143 expect(() => requiredScopeForRpcMethod(method)).toThrow(166 expect(() => requiredScopeForRpcMethod(method)).toThrow(
apps/server/src/auth/RpcAuthorization.ts+9-8
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 AuthFilesystemWriteScope,14 AuthFilesystemWriteScope,
15 AuthOrchestrationOperateScope,15 AuthOrchestrationOperateScope,
16 AuthOrchestrationReadScope,16 AuthOrchestrationReadScope,
17 AuthPreviewOperateScope,
17 AuthRelayReadScope,18 AuthRelayReadScope,
18 AuthRelayWriteScope,19 AuthRelayWriteScope,
19 AuthTerminalOperateScope,20 AuthTerminalOperateScope,
...@@ -167,15 +168,15 @@ export const RPC_REQUIRED_SCOPES = {...@@ -167,15 +168,15 @@ export const RPC_REQUIRED_SCOPES = {
167 [WS_METHODS.terminalClose]: AuthTerminalOperateScope,168 [WS_METHODS.terminalClose]: AuthTerminalOperateScope,
168 [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope,169 [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope,
169 [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope,170 [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope,
170 [WS_METHODS.previewOpen]: AuthOrchestrationOperateScope,171 [WS_METHODS.previewOpen]: AuthPreviewOperateScope,
171 [WS_METHODS.previewNavigate]: AuthOrchestrationOperateScope,172 [WS_METHODS.previewNavigate]: AuthPreviewOperateScope,
172 [WS_METHODS.previewResize]: AuthOrchestrationOperateScope,173 [WS_METHODS.previewResize]: AuthPreviewOperateScope,
173 [WS_METHODS.previewAdjust]: AuthOrchestrationOperateScope,174 [WS_METHODS.previewAdjust]: AuthPreviewOperateScope,
174 [WS_METHODS.previewRefresh]: AuthOrchestrationOperateScope,175 [WS_METHODS.previewRefresh]: AuthPreviewOperateScope,
175 [WS_METHODS.previewClose]: AuthOrchestrationOperateScope,176 [WS_METHODS.previewClose]: AuthPreviewOperateScope,
176 [WS_METHODS.previewList]: AuthOrchestrationReadScope,177 [WS_METHODS.previewList]: AuthOrchestrationReadScope,
177 [WS_METHODS.previewClearProfile]: AuthOrchestrationOperateScope,178 [WS_METHODS.previewClearProfile]: AuthPreviewOperateScope,
178 [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope,179 [WS_METHODS.previewReportStatus]: AuthPreviewOperateScope,
179 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,180 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
180 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,181 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
181 [WS_METHODS.deviceConfigure]: AuthSettingsWriteScope,182 [WS_METHODS.deviceConfigure]: AuthSettingsWriteScope,
apps/server/src/mcp/PreviewAutomationBroker.test.ts-7
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";2import { expect, it } from "@effect/vitest";
3import {3import {
4 AuthOrchestrationOperateScope,
5 EnvironmentId,4 EnvironmentId,
6 PreviewAutomationClientDisconnectedError,5 PreviewAutomationClientDisconnectedError,
7 PreviewAutomationInvalidSelectorError,6 PreviewAutomationInvalidSelectorError,
...@@ -11,15 +10,12 @@ import {...@@ -11,15 +10,12 @@ import {
11 PreviewTabId,10 PreviewTabId,
12 ProviderInstanceId,11 ProviderInstanceId,
13 ThreadId,12 ThreadId,
14 WS_METHODS,
15 WsRpcGroup,
16 type PreviewAutomationHost,13 type PreviewAutomationHost,
17 type PreviewAutomationRequest,14 type PreviewAutomationRequest,
18 type PreviewAutomationStreamEvent,15 type PreviewAutomationStreamEvent,
19 SERVER_BROWSER_AUTOMATION_CLIENT_ID,16 SERVER_BROWSER_AUTOMATION_CLIENT_ID,
20} from "@t3tools/contracts";17} from "@t3tools/contracts";
21import * as Effect from "effect/Effect";18import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";
23import * as Exit from "effect/Exit";19import * as Exit from "effect/Exit";
24import * as Deferred from "effect/Deferred";20import * as Deferred from "effect/Deferred";
25import * as Fiber from "effect/Fiber";21import * as Fiber from "effect/Fiber";
...@@ -27,10 +23,7 @@ import * as Result from "effect/Result";...@@ -27,10 +23,7 @@ import * as Result from "effect/Result";
27import * as Scheduler from "effect/Scheduler";23import * as Scheduler from "effect/Scheduler";
28import * as Stream from "effect/Stream";24import * as Stream from "effect/Stream";
29import * as TestClock from "effect/testing/TestClock";25import * 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";
34import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";27import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
3528
36const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer));29const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer));
apps/web/src/browser/ElectronBrowserHost.tsx+12-3
...@@ -1,15 +1,16 @@...@@ -1,15 +1,16 @@
1"use client";1"use client";
22
3import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";3import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
4import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";4import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
5import { useAtomValue } from "@effect/atom-react";5import { useAtomValue } from "@effect/atom-react";
6import { useEffect, useMemo } from "react";6import { type ComponentProps, useEffect, useMemo } from "react";
77
8import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";8import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
99
10import { isElectron } from "~/env";10import { isElectron } from "~/env";
11import { useTheme } from "~/hooks/useTheme";11import { useTheme } from "~/hooks/useTheme";
12import { useActivePreviewSessions } from "~/previewStateStore";12import { useActivePreviewSessions } from "~/previewStateStore";
13import { useEnvironmentScope } from "~/state/session";
1314
14import { readPreviewAnnotationTheme } from "./annotationTheme";15import { readPreviewAnnotationTheme } from "./annotationTheme";
15import { useBrowserPointerStore } from "./browserPointerStore";16import { useBrowserPointerStore } from "./browserPointerStore";
...@@ -97,7 +98,7 @@ export function ElectronBrowserHost() {...@@ -97,7 +98,7 @@ export function ElectronBrowserHost() {
97 {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => {98 {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => {
98 const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;99 const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
99 return (100 return (
100 <HostedBrowserWebview101 <AuthorizedBrowserWebview
101 key={runtimeTabId}102 key={runtimeTabId}
102 threadRef={threadRef}103 threadRef={threadRef}
103 tabId={snapshot.tabId}104 tabId={snapshot.tabId}
...@@ -122,3 +123,11 @@ export function ElectronBrowserHost() {...@@ -122,3 +123,11 @@ export function ElectronBrowserHost() {
122 </div>123 </div>
123 );124 );
124}125}
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 @@...@@ -1,4 +1,4 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";1import { AuthPreviewOperateScope, type ScopedThreadRef } from "@t3tools/contracts";
2import {2import {
3 isAtomCommandInterrupted,3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,4 squashAtomCommandFailure,
...@@ -8,6 +8,7 @@ import { useCallback } from "react";...@@ -8,6 +8,7 @@ import { useCallback } from "react";
8import { recordVisitForThread } from "~/browserHistoryStore";8import { recordVisitForThread } from "~/browserHistoryStore";
9import { readLocalApi } from "~/localApi";9import { readLocalApi } from "~/localApi";
10import { previewEnvironment } from "~/state/preview";10import { previewEnvironment } from "~/state/preview";
11import { readEnvironmentScope } from "~/state/session";
11import { useAtomCommand } from "~/state/use-atom-command";12import { useAtomCommand } from "~/state/use-atom-command";
1213
13import {14import {
...@@ -46,7 +47,10 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (...@@ -46,7 +47,10 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (
46 url,47 url,
47 event: options.event ?? NO_MODIFIER,48 event: options.event ?? NO_MODIFIER,
48 preference: await resolveBrowserLinkTargetPreference(),49 preference: await resolveBrowserLinkTargetPreference(),
49 canOpenInApp: canOpenLinksInApp(targetThreadRef),50 canOpenInApp:
51 targetThreadRef != null &&
52 readEnvironmentScope(targetThreadRef.environmentId, AuthPreviewOperateScope) &&
53 canOpenLinksInApp(targetThreadRef),
50 });54 });
51 if (target === "app" && targetThreadRef) {55 if (target === "app" && targetThreadRef) {
52 const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview });56 const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview });
apps/web/src/components/ChatMarkdown.tsx+20-12
...@@ -27,12 +27,13 @@ import {...@@ -27,12 +27,13 @@ import {
27 type LucideIcon,27 type LucideIcon,
28} from "lucide-react";28} from "lucide-react";
29import { Check, Copy, Maximize2, Minimize2 } from "lucide";29import { Check, Copy, Maximize2, Minimize2 } from "lucide";
30import type {30import {
31 AssetResource,31 AuthPreviewOperateScope,
32 EnvironmentId,32 type AssetResource,
33 ScopedThreadRef,33 type EnvironmentId,
34 ServerProviderSkill,34 type ScopedThreadRef,
35 ThreadPullRequestKey,35 type ServerProviderSkill,
36 type ThreadPullRequestKey,
36} from "@t3tools/contracts";37} from "@t3tools/contracts";
37import { faviconUrlForOrigin } from "@t3tools/shared/favicon";38import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
38import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia";39import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia";
...@@ -2518,6 +2519,7 @@ function useChatMarkdownState({...@@ -2518,6 +2519,7 @@ function useChatMarkdownState({
2518 const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId);2519 const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId);
2519 const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;2520 const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;
2520 const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);2521 const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
2522 const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope);
2521 const remoteOpen = useRemoteOpenResolution(environmentId);2523 const remoteOpen = useRemoteOpenResolution(environmentId);
2522 const canUseShellActions =2524 const canUseShellActions =
2523 canOperateHost &&2525 canOperateHost &&
...@@ -2701,12 +2703,12 @@ function useChatMarkdownState({...@@ -2701,12 +2703,12 @@ function useChatMarkdownState({
2701 );2703 );
2702 const openExternalLinkInPreview = useCallback(2704 const openExternalLinkInPreview = useCallback(
2703 (url: string) => {2705 (url: string) => {
2704 if (!threadRef) {2706 if (!threadRef || !canOperatePreview) {
2705 return Promise.resolve(2707 return Promise.resolve(
2706 AsyncResult.failure<void, BrowserPreviewUnavailableError>(2708 AsyncResult.failure<void, BrowserPreviewUnavailableError>(
2707 Cause.fail(2709 Cause.fail(
2708 new BrowserPreviewUnavailableError({2710 new BrowserPreviewUnavailableError({
2709 message: "Thread context is unavailable.",2711 message: "Preview access is unavailable for this client.",
2710 }),2712 }),
2711 ),2713 ),
2712 ),2714 ),
...@@ -2729,11 +2731,11 @@ function useChatMarkdownState({...@@ -2729,11 +2731,11 @@ function useChatMarkdownState({
2729 return result;2731 return result;
2730 });2732 });
2731 },2733 },
2732 [openPreview, threadRef],2734 [canOperatePreview, openPreview, threadRef],
2733 );2735 );
2734 const openMarkdownFileInPreview = useCallback(2736 const openMarkdownFileInPreview = useCallback(
2735 (path: string) => {2737 (path: string) => {
2736 if (!threadRef || preparedConnection._tag === "None") {2738 if (!threadRef || !canOperatePreview || preparedConnection._tag === "None") {
2737 return Promise.resolve(2739 return Promise.resolve(
2738 AsyncResult.failure<void, BrowserPreviewUnavailableError>(2740 AsyncResult.failure<void, BrowserPreviewUnavailableError>(
2739 Cause.fail(2741 Cause.fail(
...@@ -2753,7 +2755,7 @@ function useChatMarkdownState({...@@ -2753,7 +2755,7 @@ function useChatMarkdownState({
2753 openPreview,2755 openPreview,
2754 });2756 });
2755 },2757 },
2756 [createAssetUrl, cwd, openPreview, preparedConnection, threadRef],2758 [canOperatePreview, createAssetUrl, cwd, openPreview, preparedConnection, threadRef],
2757 );2759 );
2758 const findWorkspaceBasenameMatch = useCallback(2760 const findWorkspaceBasenameMatch = useCallback(
2759 async (workspaceRelativePath: string) => {2761 async (workspaceRelativePath: string) => {
...@@ -2866,6 +2868,7 @@ function useChatMarkdownState({...@@ -2866,6 +2868,7 @@ function useChatMarkdownState({
2866 revealLabel={revealInFileManagerLabel}2868 revealLabel={revealInFileManagerLabel}
2867 onOpenInBrowser={2869 onOpenInBrowser={
2868 threadRef &&2870 threadRef &&
2871 canOperatePreview &&
2869 isPreviewAvailableFor(threadRef.environmentId) &&2872 isPreviewAvailableFor(threadRef.environmentId) &&
2870 isBrowserPreviewFile(fileLinkMeta.filePath)2873 isBrowserPreviewFile(fileLinkMeta.filePath)
2871 ? () => openMarkdownFileInPreview(fileLinkMeta.filePath)2874 ? () => openMarkdownFileInPreview(fileLinkMeta.filePath)
...@@ -2876,6 +2879,7 @@ function useChatMarkdownState({...@@ -2876,6 +2879,7 @@ function useChatMarkdownState({
2876 },2879 },
2877 [2880 [
2878 canUseShellActions,2881 canUseShellActions,
2882 canOperatePreview,
2879 fileLinkParentSuffixByPath,2883 fileLinkParentSuffixByPath,
2880 openFileInPanel,2884 openFileInPanel,
2881 openInPreferredEditor,2885 openInPreferredEditor,
...@@ -2892,6 +2896,7 @@ function useChatMarkdownState({...@@ -2892,6 +2896,7 @@ function useChatMarkdownState({
2892 const componentState = useMemo(2896 const componentState = useMemo(
2893 () => ({2897 () => ({
2894 canOperateHost,2898 canOperateHost,
2899 canOperatePreview,
2895 cwd,2900 cwd,
2896 diffThemeName,2901 diffThemeName,
2897 environmentId,2902 environmentId,
...@@ -2924,6 +2929,7 @@ function useChatMarkdownState({...@@ -2924,6 +2929,7 @@ function useChatMarkdownState({
2924 }),2929 }),
2925 [2930 [
2926 canOperateHost,2931 canOperateHost,
2932 canOperatePreview,
2927 cwd,2933 cwd,
2928 diffThemeName,2934 diffThemeName,
2929 environmentId,2935 environmentId,
...@@ -3079,6 +3085,7 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3079,6 +3085,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
3079 a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) {3085 a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) {
3080 const {3086 const {
3081 canOperateHost,3087 canOperateHost,
3088 canOperatePreview,
3082 cwd,3089 cwd,
3083 environmentId,3090 environmentId,
3084 imageBaseDir,3091 imageBaseDir,
...@@ -3139,7 +3146,8 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3139,7 +3146,8 @@ const CHAT_MARKDOWN_COMPONENTS = {
3139 : null;3146 : null;
3140 const isSameDocumentLink = href?.startsWith("#") ?? false;3147 const isSameDocumentLink = href?.startsWith("#") ?? false;
3141 const onClick = props.onClick;3148 const onClick = props.onClick;
3142 const canOpenInPreview = Boolean(threadRef && isPreviewAvailableFor(threadRef.environmentId));3149 const canOpenInPreview =
3150 canOperatePreview && Boolean(threadRef && isPreviewAvailableFor(threadRef.environmentId));
3143 const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;3151 const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;
3144 const link = (3152 const link = (
3145 <a3153 <a
apps/web/src/components/ChatView.tsx+21-7
...@@ -51,6 +51,7 @@ import {...@@ -51,6 +51,7 @@ import {
51 AuthOrchestrationOperateScope,51 AuthOrchestrationOperateScope,
52 AuthSettingsWriteScope,52 AuthSettingsWriteScope,
53 AuthSourceControlWriteScope,53 AuthSourceControlWriteScope,
54 AuthPreviewOperateScope,
54 type AssistantCitation,55 type AssistantCitation,
55 type ChatFileAttachment,56 type ChatFileAttachment,
56 CommandId,57 CommandId,
...@@ -5302,9 +5303,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -5302,9 +5303,13 @@ export default function ChatView(props: ChatViewProps) {
5302 },5303 },
5303 [environmentId, navigate],5304 [environmentId, navigate],
5304 );5305 );
5306 const canOperatePreview = useEnvironmentScope(
5307 activeThreadRef?.environmentId ?? null,
5308 AuthPreviewOperateScope,
5309 );
5305 const createBrowserSurface = useCallback(5310 const createBrowserSurface = useCallback(
5306 (profileId?: string) => {5311 (profileId?: string) => {
5307 if (!activeThreadRef) return;5312 if (!activeThreadRef || !canOperatePreview) return;
5308 void addBrowserSurface({5313 void addBrowserSurface({
5309 threadRef: activeThreadRef,5314 threadRef: activeThreadRef,
5310 openPreview,5315 openPreview,
...@@ -5323,7 +5328,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5323,7 +5328,7 @@ export default function ChatView(props: ChatViewProps) {
5323 }5328 }
5324 });5329 });
5325 },5330 },
5326 [activeThreadRef, openPreview],5331 [activeThreadRef, canOperatePreview, openPreview],
5327 );5332 );
5328 const addDiffSurface = useCallback(() => {5333 const addDiffSurface = useCallback(() => {
5329 if (!activeThreadRef || !isServerThread || !isGitRepo) return;5334 if (!activeThreadRef || !isServerThread || !isGitRepo) return;
...@@ -5770,6 +5775,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5770,6 +5775,7 @@ export default function ChatView(props: ChatViewProps) {
5770 closePreviewPanel();5775 closePreviewPanel();
5771 return;5776 return;
5772 }5777 }
5778 if (!canOperatePreview) return;
5773 const activeTabId = activePreviewState.activeTabId;5779 const activeTabId = activePreviewState.activeTabId;
5774 if (activeTabId) {5780 if (activeTabId) {
5775 useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId);5781 useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId);
...@@ -5780,6 +5786,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5780,6 +5786,7 @@ export default function ChatView(props: ChatViewProps) {
5780 activePreviewState.activeTabId,5786 activePreviewState.activeTabId,
5781 activeThreadRef,5787 activeThreadRef,
5782 browserAvailable,5788 browserAvailable,
5789 canOperatePreview,
5783 closePreviewPanel,5790 closePreviewPanel,
5784 createBrowserSurface,5791 createBrowserSurface,
5785 previewPanelOpen,5792 previewPanelOpen,
...@@ -5938,7 +5945,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -5938,7 +5945,10 @@ export default function ChatView(props: ChatViewProps) {
5938 (surfaces: readonly RightPanelSurface[]) => {5945 (surfaces: readonly RightPanelSurface[]) => {
5939 if (!activeThreadRef) return;5946 if (!activeThreadRef) return;
5940 for (const surface of surfaces) {5947 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) {
5942 void closePreviewSession({5952 void closePreviewSession({
5943 closePreview,5953 closePreview,
5944 snapshot: activePreviewState.sessions[surface.resourceId] ?? null,5954 snapshot: activePreviewState.sessions[surface.resourceId] ?? null,
...@@ -5960,6 +5970,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5960,6 +5970,7 @@ export default function ChatView(props: ChatViewProps) {
5960 [5970 [
5961 activeThreadRef,5971 activeThreadRef,
5962 activePreviewState.sessions,5972 activePreviewState.sessions,
5973 canOperatePreview,
5963 closePreview,5974 closePreview,
5964 closeTerminalMutation,5975 closeTerminalMutation,
5965 storeCloseTerminal,5976 storeCloseTerminal,
...@@ -11635,10 +11646,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -11635,10 +11646,13 @@ export default function ChatView(props: ChatViewProps) {
11635 </div>11646 </div>
11636 </div>11647 </div>
1163711648
11638 {activeThreadRef && activeEnvironmentServerBrowser ? (11649 {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser ? (
11639 <PreviewSessionSync threadRef={activeThreadRef} />11650 <PreviewSessionSync threadRef={activeThreadRef} />
11640 ) : null}11651 ) : null}
11641 {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? (11652 {activeThreadRef &&
11653 activePreviewMiniPlayer &&
11654 previewMiniPlayerVisible &&
11655 (activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) ? (
11642 <ThreadPreviewMiniPlayer11656 <ThreadPreviewMiniPlayer
11643 key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}11657 key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}
11644 threadRef={activeThreadRef}11658 threadRef={activeThreadRef}
...@@ -11754,7 +11768,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11754,7 +11768,7 @@ export default function ChatView(props: ChatViewProps) {
11754 onAddPullRequest={addPullRequestSurface}11768 onAddPullRequest={addPullRequestSurface}
11755 onAddPullRequests={addPullRequestsSurface}11769 onAddPullRequests={addPullRequestsSurface}
11756 onAddDevice={addDeviceSurface}11770 onAddDevice={addDeviceSurface}
11757 browserAvailable={browserAvailable}11771 browserAvailable={canOperatePreview && browserAvailable}
11758 terminalAvailable={activeProject !== null}11772 terminalAvailable={activeProject !== null}
11759 diffAvailable={isServerThread && isGitRepo}11773 diffAvailable={isServerThread && isGitRepo}
11760 filesAvailable={activeProject !== null}11774 filesAvailable={activeProject !== null}
...@@ -11812,7 +11826,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11812,7 +11826,7 @@ export default function ChatView(props: ChatViewProps) {
11812 onAddPullRequest={addPullRequestSurface}11826 onAddPullRequest={addPullRequestSurface}
11813 onAddPullRequests={addPullRequestsSurface}11827 onAddPullRequests={addPullRequestsSurface}
11814 onAddDevice={addDeviceSurface}11828 onAddDevice={addDeviceSurface}
11815 browserAvailable={browserAvailable}11829 browserAvailable={canOperatePreview && browserAvailable}
11816 terminalAvailable={activeProject !== null}11830 terminalAvailable={activeProject !== null}
11817 diffAvailable={isServerThread && isGitRepo}11831 diffAvailable={isServerThread && isGitRepo}
11818 filesAvailable={activeProject !== null}11832 filesAvailable={activeProject !== null}
apps/web/src/components/LegacySidebar.tsx+5-3
...@@ -46,6 +46,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-...@@ -46,6 +46,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-
46import { CSS } from "@dnd-kit/utilities";46import { CSS } from "@dnd-kit/utilities";
47import {47import {
48 AuthOrchestrationOperateScope,48 AuthOrchestrationOperateScope,
49 AuthPreviewOperateScope,
49 type ContextMenuItem,50 type ContextMenuItem,
50 type EnvironmentId,51 type EnvironmentId,
51 ProjectId,52 ProjectId,
...@@ -405,6 +406,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -405,6 +406,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
405 thread,406 thread,
406 } = props;407 } = props;
407 const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);408 const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);
409 const canOperatePreview = useEnvironmentScope(thread.environmentId, AuthPreviewOperateScope);
408 const threadRef = scopeThreadRef(thread.environmentId, thread.id);410 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
409 const threadKey = scopedThreadKey(threadRef);411 const threadKey = scopedThreadKey(threadRef);
410 const [isFileDragOver, setIsFileDragOver] = useState(false);412 const [isFileDragOver, setIsFileDragOver] = useState(false);
...@@ -462,7 +464,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -462,7 +464,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
462 const handleOpenDiscoveredPort = useCallback(464 const handleOpenDiscoveredPort = useCallback(
463 (event: React.MouseEvent<HTMLButtonElement>) => {465 (event: React.MouseEvent<HTMLButtonElement>) => {
464 const port = discoveredPorts[0];466 const port = discoveredPorts[0];
465 if (!port) return;467 if (!port || !canOperatePreview) return;
466 event.preventDefault();468 event.preventDefault();
467 event.stopPropagation();469 event.stopPropagation();
468 navigateToThread(threadRef);470 navigateToThread(threadRef);
...@@ -482,7 +484,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -482,7 +484,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
482 );484 );
483 })();485 })();
484 },486 },
485 [discoveredPorts, navigateToThread, openPreview, threadRef],487 [canOperatePreview, discoveredPorts, navigateToThread, openPreview, threadRef],
486 );488 );
487 const isThreadRunning = !threadRuntimeCanArchive(thread.runtime);489 const isThreadRunning = !threadRuntimeCanArchive(thread.runtime);
488 const threadStatus = resolveThreadStatusPill({490 const threadStatus = resolveThreadStatusPill({
...@@ -826,7 +828,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -826,7 +828,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
826 )}828 )}
827 </div>829 </div>
828 <div className="ml-auto flex shrink-0 items-center gap-1.5">830 <div className="ml-auto flex shrink-0 items-center gap-1.5">
829 {discoveredPorts.length > 0 && (831 {canOperatePreview && discoveredPorts.length > 0 && (
830 <Tooltip>832 <Tooltip>
831 <TooltipTrigger833 <TooltipTrigger
832 render={834 render={
apps/web/src/components/ThreadTerminalDrawer.tsx+6
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 Trash2,19 Trash2,
20} from "lucide-react";20} from "lucide-react";
21import {21import {
22 AuthPreviewOperateScope,
22 type ContextMenuItem,23 type ContextMenuItem,
23 type ProviderInstanceId,24 type ProviderInstanceId,
24 type ResolvedKeybindingsConfig,25 type ResolvedKeybindingsConfig,
...@@ -80,6 +81,7 @@ import { useLocalStorage } from "../hooks/useLocalStorage";...@@ -80,6 +81,7 @@ import { useLocalStorage } from "../hooks/useLocalStorage";
80import { useAttachedTerminalSession } from "../state/terminalSessions";81import { useAttachedTerminalSession } from "../state/terminalSessions";
81import { serverEnvironment } from "../state/server";82import { serverEnvironment } from "../state/server";
82import { previewEnvironment } from "../state/preview";83import { previewEnvironment } from "../state/preview";
84import { readEnvironmentScope } from "../state/session";
83import { terminalEnvironment } from "../state/terminal";85import { terminalEnvironment } from "../state/terminal";
84import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";86import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
85import { useAtomCommand } from "../state/use-atom-command";87import { useAtomCommand } from "../state/use-atom-command";
...@@ -797,6 +799,10 @@ export function TerminalViewport({...@@ -797,6 +799,10 @@ export function TerminalViewport({
797 );799 );
798 });800 });
799 };801 };
802 if (!readEnvironmentScope(environmentId, AuthPreviewOperateScope)) {
803 fallbackToBrowser();
804 return;
805 }
800 void openTerminalLinkInPreview({806 void openTerminalLinkInPreview({
801 url: text,807 url: text,
802 threadRef,808 threadRef,
apps/web/src/components/files/FilePreviewPanel.tsx+10-6
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import { Spinner } from "~/components/ui/spinner";1import { Spinner } from "~/components/ui/spinner";
2import type {2import {
3 EditorId,3 AuthPreviewOperateScope,
4 EnvironmentId,4 type EditorId,
5 ResolvedKeybindingsConfig,5 type EnvironmentId,
6 ScopedThreadRef,6 type ResolvedKeybindingsConfig,
7 type ScopedThreadRef,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview";9import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview";
9import { AuthFilesystemWriteScope } from "@t3tools/contracts";10import { AuthFilesystemWriteScope } from "@t3tools/contracts";
...@@ -1021,6 +1022,7 @@ export default function FilePreviewPanel({...@@ -1021,6 +1022,7 @@ export default function FilePreviewPanel({
1021 attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath;1022 attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath;
1022 const { resolvedTheme } = useTheme();1023 const { resolvedTheme } = useTheme();
1023 const wordWrap = useClientSettings((settings) => settings.wordWrap);1024 const wordWrap = useClientSettings((settings) => settings.wordWrap);
1025 const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope);
1024 const primaryEnvironmentId = usePrimaryEnvironmentId();1026 const primaryEnvironmentId = usePrimaryEnvironmentId();
1025 const remoteOpenState = useRemoteOpenState(environmentId);1027 const remoteOpenState = useRemoteOpenState(environmentId);
1026 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);1028 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
...@@ -1128,6 +1130,7 @@ export default function FilePreviewPanel({...@@ -1128,6 +1130,7 @@ export default function FilePreviewPanel({
1128 ? setRenderTablePreferred1130 ? setRenderTablePreferred
1129 : setRenderBrowserFilePreferred;1131 : setRenderBrowserFilePreferred;
1130 const canOpenInBrowser =1132 const canOpenInBrowser =
1133 canOperatePreview &&
1131 previewPath !== null &&1134 previewPath !== null &&
1132 attachment === undefined &&1135 attachment === undefined &&
1133 !isVideo &&1136 !isVideo &&
...@@ -1170,7 +1173,7 @@ export default function FilePreviewPanel({...@@ -1170,7 +1173,7 @@ export default function FilePreviewPanel({
1170 };1173 };
11711174
1172 const handleOpenInBrowser = useCallback(() => {1175 const handleOpenInBrowser = useCallback(() => {
1173 if (!canReadFiles || !absolutePath || !environmentHttpBaseUrl) return;1176 if (!canReadFiles || !canOperatePreview || !absolutePath || !environmentHttpBaseUrl) return;
1174 void (async () => {1177 void (async () => {
1175 const result = await openFileInPreview({1178 const result = await openFileInPreview({
1176 threadRef,1179 threadRef,
...@@ -1195,6 +1198,7 @@ export default function FilePreviewPanel({...@@ -1195,6 +1198,7 @@ export default function FilePreviewPanel({
1195 }, [1198 }, [
1196 absolutePath,1199 absolutePath,
1197 canReadFiles,1200 canReadFiles,
1201 canOperatePreview,
1198 createAssetUrl,1202 createAssetUrl,
1199 cwd,1203 cwd,
1200 environmentHttpBaseUrl,1204 environmentHttpBaseUrl,
apps/web/src/components/preview/PreviewPanel.tsx+12-3
...@@ -1,9 +1,14 @@...@@ -1,9 +1,14 @@
1"use client";1"use client";
22
3import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts";3import {
4 AuthPreviewOperateScope,
5 type PreviewAnnotationPayload,
6 type ScopedThreadRef,
7} from "@t3tools/contracts";
48
5import type { ComposerImageAttachment } from "~/composerDraftStore";9import type { ComposerImageAttachment } from "~/composerDraftStore";
6import { usePreviewAvailable } from "~/browser/previewRuntime";10import { usePreviewAvailable } from "~/browser/previewRuntime";
11import { useEnvironmentScope } from "~/state/session";
712
8import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell";13import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell";
9import { PreviewView } from "./PreviewView";14import { PreviewView } from "./PreviewView";
...@@ -28,12 +33,16 @@ export function PreviewPanel({...@@ -28,12 +33,16 @@ export function PreviewPanel({
28 visible,33 visible,
29 onSendAnnotation,34 onSendAnnotation,
30}: Props) {35}: Props) {
31 if (!usePreviewAvailable(threadRef.environmentId)) {36 const available = usePreviewAvailable(threadRef.environmentId);
37 const canOperatePreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope);
38 if (!canOperatePreview || !available) {
32 return (39 return (
33 <PreviewPanelShell mode={mode}>40 <PreviewPanelShell mode={mode}>
34 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-8 text-center">41 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
35 <p className="max-w-sm text-sm text-muted-foreground">42 <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."}
37 </p>46 </p>
38 </div>47 </div>
39 </PreviewPanelShell>48 </PreviewPanelShell>
apps/web/src/components/preview/PreviewView.test.tsx+98
...@@ -15,6 +15,9 @@ const mocks = vi.hoisted(() => ({...@@ -15,6 +15,9 @@ const mocks = vi.hoisted(() => ({
15 navigate: vi.fn(async (_tabId: string, _url: string): Promise<void> => undefined),15 navigate: vi.fn(async (_tabId: string, _url: string): Promise<void> => undefined),
16 rememberPreviewUrl: vi.fn(),16 rememberPreviewUrl: vi.fn(),
17 readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })),17 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),
18 submittedUrl: null as ((url: string) => void) | null,21 submittedUrl: null as ((url: string) => void) | null,
19 emptyStateUrl: null as ((url: string) => void) | null,22 emptyStateUrl: null as ((url: string) => void) | null,
20 togglePictureInPicture: null as (() => void) | null,23 togglePictureInPicture: null as (() => void) | null,
...@@ -61,6 +64,8 @@ vi.mock("~/browserHistoryStore", () => ({...@@ -61,6 +64,8 @@ vi.mock("~/browserHistoryStore", () => ({
61vi.mock("~/state/session", async (importOriginal) => ({64vi.mock("~/state/session", async (importOriginal) => ({
62 ...(await importOriginal<typeof import("~/state/session")>()),65 ...(await importOriginal<typeof import("~/state/session")>()),
63 readPreparedConnection: mocks.readPreparedConnection,66 readPreparedConnection: mocks.readPreparedConnection,
67 readEnvironmentScope: mocks.readEnvironmentScope,
68 useEnvironmentScope: mocks.readEnvironmentScope,
64}));69}));
6570
66// File-preview errors share a module with asset hooks. Keep the pure URL resolver71// File-preview errors share a module with asset hooks. Keep the pure URL resolver
...@@ -214,6 +219,8 @@ vi.mock("./previewBridge", () => ({...@@ -214,6 +219,8 @@ vi.mock("./previewBridge", () => ({
214 previewBridge: {219 previewBridge: {
215 navigate: mocks.navigate,220 navigate: mocks.navigate,
216 pickElement: mocks.pickElement,221 pickElement: mocks.pickElement,
222 setAnnotationSendEnabled: mocks.setAnnotationSendEnabled,
223 cancelPickElement: mocks.cancelPickElement,
217 pictureInPicture: {224 pictureInPicture: {
218 open: mocks.openPictureInPicture,225 open: mocks.openPictureInPicture,
219 close: mocks.closePictureInPicture,226 close: mocks.closePictureInPicture,
...@@ -338,6 +345,9 @@ describe("PreviewView navigation", () => {...@@ -338,6 +345,9 @@ describe("PreviewView navigation", () => {
338 mocks.navigate.mockClear();345 mocks.navigate.mockClear();
339 mocks.rememberPreviewUrl.mockClear();346 mocks.rememberPreviewUrl.mockClear();
340 mocks.readPreparedConnection.mockClear();347 mocks.readPreparedConnection.mockClear();
348 mocks.readEnvironmentScope.mockReset().mockReturnValue(true);
349 mocks.setAnnotationSendEnabled.mockClear();
350 mocks.cancelPickElement.mockClear();
341 mocks.submittedUrl = null;351 mocks.submittedUrl = null;
342 mocks.emptyStateUrl = null;352 mocks.emptyStateUrl = null;
343 mocks.togglePictureInPicture = null;353 mocks.togglePictureInPicture = null;
...@@ -588,6 +598,94 @@ describe("PreviewView navigation", () => {...@@ -588,6 +598,94 @@ describe("PreviewView navigation", () => {
588 expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, annotation);598 expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, annotation);
589 });599 });
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
591 it("warns when main dropped the crop before handing over the pick", async () => {689 it("warns when main dropped the crop before handing over the pick", async () => {
592 const annotation = {690 const annotation = {
593 id: "annotation-3",691 id: "annotation-3",
apps/web/src/components/preview/PreviewView.tsx+47-9
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 squashAtomCommandFailure,6 squashAtomCommandFailure,
7} from "@t3tools/client-runtime/state/runtime";7} from "@t3tools/client-runtime/state/runtime";
8import {8import {
9 AuthOrchestrationOperateScope,
9 DEFAULT_BROWSER_PROFILE_ID,10 DEFAULT_BROWSER_PROFILE_ID,
10 FILL_PREVIEW_VIEWPORT,11 FILL_PREVIEW_VIEWPORT,
11 type PreviewAnnotationPayload,12 type PreviewAnnotationPayload,
...@@ -42,6 +43,7 @@ import {...@@ -42,6 +43,7 @@ import {
42 selectThreadPreviewMiniPlayerTabId,43 selectThreadPreviewMiniPlayerTabId,
43 usePreviewMiniPlayerStore,44 usePreviewMiniPlayerStore,
44} from "~/previewMiniPlayerStore";45} from "~/previewMiniPlayerStore";
46import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
45import { useRightPanelStore } from "~/rightPanelStore";47import { useRightPanelStore } from "~/rightPanelStore";
4648
47import { previewBridge } from "./previewBridge";49import { previewBridge } from "./previewBridge";
...@@ -114,8 +116,11 @@ export function PreviewView({...@@ -114,8 +116,11 @@ export function PreviewView({
114}: Props) {116}: Props) {
115 const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined);117 const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined);
116 const [pickActive, setPickActive] = useState(false);118 const [pickActive, setPickActive] = useState(false);
119 const canSendAnnotation =
120 useEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) &&
121 Boolean(onSendAnnotation);
117 const activeRecordingTabIds = useActiveBrowserRecordingTabIds();122 const activeRecordingTabIds = useActiveBrowserRecordingTabIds();
118 const pickActiveRef = useRef(false);123 const pickActiveRef = useRef<{ cancelled: boolean } | null>(null);
119 const isMountedRef = useRef(true);124 const isMountedRef = useRef(true);
120 // Kept in sync so the title effect can depend on the stable thread key125 // Kept in sync so the title effect can depend on the stable thread key
121 // instead of the thread object, which is recreated on every update.126 // instead of the thread object, which is recreated on every update.
...@@ -720,6 +725,7 @@ export function PreviewView({...@@ -720,6 +725,7 @@ export function PreviewView({
720 const handlePickElement = useCallback(() => {725 const handlePickElement = useCallback(() => {
721 if (!previewBridge || !runtimeTabId) return;726 if (!previewBridge || !runtimeTabId) return;
722 if (pickActiveRef.current) {727 if (pickActiveRef.current) {
728 pickActiveRef.current.cancelled = true;
723 void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined);729 void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined);
724 return;730 return;
725 }731 }
...@@ -730,12 +736,28 @@ export function PreviewView({...@@ -730,12 +736,28 @@ export function PreviewView({
730 // every pick they'd have to click back into the textarea.736 // every pick they'd have to click back into the textarea.
731 const previouslyFocused =737 const previouslyFocused =
732 typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null;738 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;
734 setPickActive(true);742 setPickActive(true);
735 void (async () => {743 void (async () => {
736 try {744 try {
745 await previewBridge.setAnnotationSendEnabled?.(
746 runtimeTabId,
747 Boolean(onSendAnnotation) &&
748 readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope),
749 );
750 if (pickRequest.cancelled) return;
737 const result = await previewBridge.pickElement(runtimeTabId);751 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 }
739 const { annotation: picked, submission, screenshotFailed = false } = result;761 const { annotation: picked, submission, screenshotFailed = false } = result;
740 // The structured annotation is still sendable when its optional crop762 // The structured annotation is still sendable when its optional crop
741 // stalls or fails, so tell the user what they lost and keep going763 // stalls or fails, so tell the user what they lost and keep going
...@@ -775,20 +797,28 @@ export function PreviewView({...@@ -775,20 +797,28 @@ export function PreviewView({
775 if (image) {797 if (image) {
776 addImage(threadRef, image);798 addImage(threadRef, image);
777 }799 }
778 if (submission === "send") {800 if (
801 submission === "send" &&
802 readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)
803 ) {
779 onSendAnnotation?.(annotation, image);804 onSendAnnotation?.(annotation, image);
780 }805 }
781 } catch {806 } catch {
782 // Picker failed (e.g. webview navigated). Treat as silent cancel.807 // Picker failed (e.g. webview navigated). Treat as silent cancel.
783 } finally {808 } finally {
784 pickActiveRef.current = false;809 // A submitted pick already released itself above; a cancelled or
785 // Avoid `setState on unmounted component` if the panel/thread closed810 // failed one releases here. Avoid `setState on unmounted component`
786 // while the pick was in flight.811 // if the panel/thread closed while the pick was in flight.
787 if (isMountedRef.current) setPickActive(false);812 const isCurrentPick = pickActiveRef.current === pickRequest;
813 if (isCurrentPick) {
814 pickActiveRef.current = null;
815 if (isMountedRef.current) setPickActive(false);
816 }
788 // Best-effort: restore focus to whatever the user had before the817 // Best-effort: restore focus to whatever the user had before the
789 // pick stole it into the guest webContents. Skip if the previously-818 // pick stole it into the guest webContents. Skip if the previously-
790 // focused element was unmounted or is no longer focusable.819 // focused element was unmounted or is no longer focusable.
791 if (820 if (
821 (isCurrentPick || submitted) &&
792 previouslyFocused &&822 previouslyFocused &&
793 previouslyFocused.isConnected &&823 previouslyFocused.isConnected &&
794 typeof previouslyFocused.focus === "function"824 typeof previouslyFocused.focus === "function"
...@@ -803,13 +833,21 @@ export function PreviewView({...@@ -803,13 +833,21 @@ export function PreviewView({
803 })();833 })();
804 }, [addImage, addPreviewAnnotation, onSendAnnotation, runtimeTabId, threadRef]);834 }, [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
806 // If the active tab changes mid-pick (close, thread switch, hot restart),843 // If the active tab changes mid-pick (close, thread switch, hot restart),
807 // tell main to tear down the in-flight session AND reset our local toggle844 // tell main to tear down the in-flight session AND reset our local toggle
808 // state so the button doesn't get stuck pressed against a stale tab id.845 // state so the button doesn't get stuck pressed against a stale tab id.
809 useEffect(() => {846 useEffect(() => {
810 return () => {847 return () => {
811 if (!pickActiveRef.current) return;848 if (!pickActiveRef.current) return;
812 pickActiveRef.current = false;849 pickActiveRef.current.cancelled = true;
850 pickActiveRef.current = null;
813 if (previewBridge && runtimeTabId) {851 if (previewBridge && runtimeTabId) {
814 void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined);852 void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined);
815 }853 }
apps/web/src/components/settings/ConnectionsSettings.tsx+6
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28 AuthEnvironmentMaintainScope,28 AuthEnvironmentMaintainScope,
29 AuthOrchestrationOperateScope,29 AuthOrchestrationOperateScope,
30 AuthOrchestrationReadScope,30 AuthOrchestrationReadScope,
31 AuthPreviewOperateScope,
31 AuthRelayReadScope,32 AuthRelayReadScope,
32 AuthRelayWriteScope,33 AuthRelayWriteScope,
33 AuthSourceControlWriteScope,34 AuthSourceControlWriteScope,
...@@ -257,6 +258,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{...@@ -257,6 +258,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
257 title: "Maintain environment",258 title: "Maintain environment",
258 description: "Update the server and control environment processes.",259 description: "Update the server and control environment processes.",
259 },260 },
261 {
262 scope: AuthPreviewOperateScope,
263 title: "Control previews",
264 description: "Open browser previews and host browser automation.",
265 },
260 {266 {
261 scope: AuthTerminalOperateScope,267 scope: AuthTerminalOperateScope,
262 title: "Use terminals",268 title: "Use terminals",
apps/web/src/routes/_chat.tsx+9-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { AuthPreviewOperateScope } from "@t3tools/contracts";
1import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router";2import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router";
2import { useAtomValue } from "@effect/atom-react";3import { useAtomValue } from "@effect/atom-react";
3import { useEffect, useMemo } from "react";4import { useEffect, useMemo } from "react";
...@@ -10,6 +11,7 @@ import { openCommandPalette } from "../commandPaletteBus";...@@ -10,6 +11,7 @@ import { openCommandPalette } from "../commandPaletteBus";
10import { useProjects } from "../state/entities";11import { useProjects } from "../state/entities";
11import { isPreviewAvailableFor } from "../browser/previewRuntime";12import { isPreviewAvailableFor } from "../browser/previewRuntime";
12import { usePrimaryEnvironmentId } from "../state/environments";13import { usePrimaryEnvironmentId } from "../state/environments";
14import { useEnvironmentScope } from "../state/session";
13import { selectProjectGroupingSettings } from "../logicalProject";15import { selectProjectGroupingSettings } from "../logicalProject";
14import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";16import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
15import { dispatchPreviewAction } from "../components/preview/previewActionBus";17import { dispatchPreviewAction } from "../components/preview/previewActionBus";
...@@ -33,6 +35,10 @@ function ChatRouteGlobalShortcuts() {...@@ -33,6 +35,10 @@ function ChatRouteGlobalShortcuts() {
33 const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size);35 const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size);
34 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =36 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
35 useHandleNewThread();37 useHandleNewThread();
38 const canOperatePreview = useEnvironmentScope(
39 routeThreadRef?.environmentId ?? null,
40 AuthPreviewOperateScope,
41 );
36 const keybindings = useAtomValue(primaryServerKeybindingsAtom);42 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
37 const legacySidebarEnabled = useLegacySidebarEnabled();43 const legacySidebarEnabled = useLegacySidebarEnabled();
38 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);44 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
...@@ -140,7 +146,7 @@ function ChatRouteGlobalShortcuts() {...@@ -140,7 +146,7 @@ function ChatRouteGlobalShortcuts() {
140 if (command === "preview.toggle") {146 if (command === "preview.toggle") {
141 event.preventDefault();147 event.preventDefault();
142 event.stopPropagation();148 event.stopPropagation();
143 if (!routeThreadRef) return;149 if (!routeThreadRef || (!canOperatePreview && !previewOpen)) return;
144 if (!isPreviewAvailableFor(routeThreadRef.environmentId)) {150 if (!isPreviewAvailableFor(routeThreadRef.environmentId)) {
145 toastManager.add(151 toastManager.add(
146 stackedThreadToast({152 stackedThreadToast({
...@@ -167,6 +173,7 @@ function ChatRouteGlobalShortcuts() {...@@ -167,6 +173,7 @@ function ChatRouteGlobalShortcuts() {
167 ) {173 ) {
168 event.preventDefault();174 event.preventDefault();
169 event.stopPropagation();175 event.stopPropagation();
176 if (!canOperatePreview) return;
170 const action =177 const action =
171 command === "preview.refresh"178 command === "preview.refresh"
172 ? "refresh"179 ? "refresh"
...@@ -189,6 +196,7 @@ function ChatRouteGlobalShortcuts() {...@@ -189,6 +196,7 @@ function ChatRouteGlobalShortcuts() {
189 activeDraftThread,196 activeDraftThread,
190 activeThread,197 activeThread,
191 clearSelection,198 clearSelection,
199 canOperatePreview,
192 handleNewThread,200 handleNewThread,
193 keybindings,201 keybindings,
194 defaultProjectRef,202 defaultProjectRef,
packages/contracts/src/auth.ts+3
...@@ -83,6 +83,7 @@ export const AuthOrchestrationOperateScope = "orchestration:operate" as const;...@@ -83,6 +83,7 @@ export const AuthOrchestrationOperateScope = "orchestration:operate" as const;
83export const AuthSettingsWriteScope = "settings:write" as const;83export const AuthSettingsWriteScope = "settings:write" as const;
84export const AuthProvidersManageScope = "providers:manage" as const;84export const AuthProvidersManageScope = "providers:manage" as const;
85export const AuthEnvironmentMaintainScope = "environment:maintain" as const;85export const AuthEnvironmentMaintainScope = "environment:maintain" as const;
86export const AuthPreviewOperateScope = "preview:operate" as const;
86export const AuthTerminalOperateScope = "terminal:operate" as const;87export const AuthTerminalOperateScope = "terminal:operate" as const;
87export const AuthSourceControlWriteScope = "source-control:write" as const;88export const AuthSourceControlWriteScope = "source-control:write" as const;
88export const AuthFilesystemReadScope = "filesystem:read" as const;89export const AuthFilesystemReadScope = "filesystem:read" as const;
...@@ -99,6 +100,7 @@ export const AuthEnvironmentScope = Schema.Literals([...@@ -99,6 +100,7 @@ export const AuthEnvironmentScope = Schema.Literals([
99 AuthSettingsWriteScope,100 AuthSettingsWriteScope,
100 AuthProvidersManageScope,101 AuthProvidersManageScope,
101 AuthEnvironmentMaintainScope,102 AuthEnvironmentMaintainScope,
103 AuthPreviewOperateScope,
102 AuthTerminalOperateScope,104 AuthTerminalOperateScope,
103 AuthFilesystemReadScope,105 AuthFilesystemReadScope,
104 AuthFilesystemWriteScope,106 AuthFilesystemWriteScope,
...@@ -126,6 +128,7 @@ export const AuthStandardClientScopes = [...@@ -126,6 +128,7 @@ export const AuthStandardClientScopes = [
126 AuthSettingsWriteScope,128 AuthSettingsWriteScope,
127 AuthProvidersManageScope,129 AuthProvidersManageScope,
128 AuthEnvironmentMaintainScope,130 AuthEnvironmentMaintainScope,
131 AuthPreviewOperateScope,
129 AuthTerminalOperateScope,132 AuthTerminalOperateScope,
130 AuthSourceControlWriteScope,133 AuthSourceControlWriteScope,
131 AuthFilesystemReadScope,134 AuthFilesystemReadScope,
packages/contracts/src/ipc.ts+7
...@@ -1069,6 +1069,11 @@ export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({...@@ -1069,6 +1069,11 @@ export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({
1069 theme: DesktopPreviewAnnotationThemeSchema,1069 theme: DesktopPreviewAnnotationThemeSchema,
1070});1070});
10711071
1072export const DesktopPreviewAnnotationSendEnabledInputSchema = Schema.Struct({
1073 tabId: DesktopPreviewTabIdSchema,
1074 enabled: Schema.Boolean,
1075});
1076
1072export const DesktopPreviewArtifactInputSchema = Schema.Struct({1077export const DesktopPreviewArtifactInputSchema = Schema.Struct({
1073 path: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()),1078 path: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()),
1074});1079});
...@@ -1278,6 +1283,8 @@ export interface DesktopPreviewBridge {...@@ -1278,6 +1283,8 @@ export interface DesktopPreviewBridge {
1278 readonly targetProfileId: string;1283 readonly targetProfileId: string;
1279 }) => Promise<BrowserImportResult>;1284 }) => Promise<BrowserImportResult>;
1280 setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>;1285 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>;
1281 /**1288 /**
1282 * Activate the in-page element picker for the given tab. Resolves with1289 * Activate the in-page element picker for the given tab. Resolves with
1283 * the picked annotation and its attach/send intent, or `null` when the1290 * the picked annotation and its attach/send intent, or `null` when the