| author | |
| committer | |
| log | 7175edcff4f250b583ebed8877e27e36dc48411e |
| tree | f870d7560ea4034710122be2b203eb59d4353476 |
| parent | 321c6f9d26e9240f6ed3d2a3a8f7fc11344c2f17 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 90 | export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources"; | 90 | export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources"; |
| 91 | export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies"; | 91 | export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies"; |
| 92 | export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme"; | 92 | export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme"; |
| 93 | export const PREVIEW_SET_ANNOTATION_SEND_ENABLED_CHANNEL = | ||
| 94 | "desktop:preview-set-annotation-send-enabled"; | ||
| 93 | export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; | 95 | export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; |
| 94 | export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; | 96 | export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; |
| 95 | export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; | 97 | export 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 @@ |
| 1 | import { | 1 | import { |
| 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 | }); |
| 379 | 380 | ||
| 381 | export 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 | |||
| 380 | export const captureScreenshot = DesktopIpc.makeIpcMethod({ | 394 | export 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 = ( |
| 15 | 15 | ||
| 16 | describe("resolveAnnotationSubmission", () => { | 16 | describe("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 | }); |
| 22 | 22 | ||
| 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 { |
| 10 | 10 | ||
| 11 | export function resolveAnnotationSubmission( | 11 | export 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"; |
| 3 | export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; | 3 | export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; |
| 4 | export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; | 4 | export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; |
| 5 | export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; | 5 | export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; |
| 6 | export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled"; | ||
| 6 | export const HUMAN_INPUT_CHANNEL = "preview:human-input"; | 7 | export const HUMAN_INPUT_CHANNEL = "preview:human-input"; |
| 7 | export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; | 8 | export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; |
| 8 | export const RECORDING_CURSOR_CHANNEL = "preview:recording-cursor"; | 9 | export 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 | ); |
| 3939 | 3939 | ||
| 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; |
| 3985 | 4064 | ||
| 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"; |
| 65 | import * as BrowserSession from "./BrowserSession.ts"; | 65 | import * as BrowserSession from "./BrowserSession.ts"; |
| 66 | import { | 66 | import { |
| 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 |
| 559 | 560 | ||
| 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 handed | 1817 | // 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 the | 1927 | // 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 | }); |
| 2141 | 2146 | ||
| 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-only | 2239 | // 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"; |
| 20 | import { DEFAULT_RECORDING_INPUT_OPTIONS } from "./RecordingInput.ts"; | 20 | import { DEFAULT_RECORDING_INPUT_OPTIONS } from "./RecordingInput.ts"; |
| 21 | import { | 21 | import { |
| 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 { |
| 129 | interface AnnotationSession { | 130 | interface 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 | } |
| 133 | 135 | ||
| 134 | let activeSession: AnnotationSession | null = null; | 136 | let 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 | } |
| 514 | 516 | ||
| 515 | function startAnnotation(): void { | 517 | function 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); |
| 590 | 592 | ||
| 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 | }; |
| 1346 | 1354 | ||
| 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 empty | 1416 | // 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 before | 1426 | // 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 | } |
| 1438 | 1457 | ||
| 1439 | ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { | 1458 | ipcRenderer.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 | ); | ||
| 1465 | ipcRenderer.on(ANNOTATION_SEND_ENABLED_CHANNEL, (_event, enabled: boolean) => { | ||
| 1466 | activeSession?.setSendEnabled(enabled === true); | ||
| 1442 | }); | 1467 | }); |
| 1443 | ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { | 1468 | ipcRenderer.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 | }); |
| 140 | 141 | ||
| 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { expect, it } from "@effect/vitest"; | 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import { | 3 | import { |
| 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"; |
| 21 | import * as Effect from "effect/Effect"; | 18 | import * as Effect from "effect/Effect"; |
| 22 | import * as Layer from "effect/Layer"; | ||
| 23 | import * as Exit from "effect/Exit"; | 19 | import * as Exit from "effect/Exit"; |
| 24 | import * as Deferred from "effect/Deferred"; | 20 | import * as Deferred from "effect/Deferred"; |
| 25 | import * as Fiber from "effect/Fiber"; | 21 | import * 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"; |
| 27 | import * as Scheduler from "effect/Scheduler"; | 23 | import * as Scheduler from "effect/Scheduler"; |
| 28 | import * as Stream from "effect/Stream"; | 24 | import * as Stream from "effect/Stream"; |
| 29 | import * as TestClock from "effect/testing/TestClock"; | 25 | import * as TestClock from "effect/testing/TestClock"; |
| 30 | import * as RpcGroup from "effect/rpc/RpcGroup"; | ||
| 31 | import * as RpcTest from "effect/rpc/RpcTest"; | ||
| 32 | 26 | ||
| 33 | import * as RpcAuthorization from "../auth/RpcAuthorization.ts"; | ||
| 34 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; | 27 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; |
| 35 | 28 | ||
| 36 | const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer)); | 29 | const 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"; |
| 2 | 2 | ||
| 3 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | 3 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 4 | import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; | 4 | import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; |
| 5 | import { useAtomValue } from "@effect/atom-react"; | 5 | import { useAtomValue } from "@effect/atom-react"; |
| 6 | import { useEffect, useMemo } from "react"; | 6 | import { type ComponentProps, useEffect, useMemo } from "react"; |
| 7 | 7 | ||
| 8 | import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; | 8 | import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; |
| 9 | 9 | ||
| 10 | import { isElectron } from "~/env"; | 10 | import { isElectron } from "~/env"; |
| 11 | import { useTheme } from "~/hooks/useTheme"; | 11 | import { useTheme } from "~/hooks/useTheme"; |
| 12 | import { useActivePreviewSessions } from "~/previewStateStore"; | 12 | import { useActivePreviewSessions } from "~/previewStateStore"; |
| 13 | import { useEnvironmentScope } from "~/state/session"; | ||
| 13 | 14 | ||
| 14 | import { readPreviewAnnotationTheme } from "./annotationTheme"; | 15 | import { readPreviewAnnotationTheme } from "./annotationTheme"; |
| 15 | import { useBrowserPointerStore } from "./browserPointerStore"; | 16 | import { 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 | <HostedBrowserWebview | 101 | <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 | |||
| 127 | function 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 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 1 | import { AuthPreviewOperateScope, type ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { | 2 | import { |
| 3 | isAtomCommandInterrupted, | 3 | isAtomCommandInterrupted, |
| 4 | squashAtomCommandFailure, | 4 | squashAtomCommandFailure, |
| ... | @@ -8,6 +8,7 @@ import { useCallback } from "react"; | ... | @@ -8,6 +8,7 @@ import { useCallback } from "react"; |
| 8 | import { recordVisitForThread } from "~/browserHistoryStore"; | 8 | import { recordVisitForThread } from "~/browserHistoryStore"; |
| 9 | import { readLocalApi } from "~/localApi"; | 9 | import { readLocalApi } from "~/localApi"; |
| 10 | import { previewEnvironment } from "~/state/preview"; | 10 | import { previewEnvironment } from "~/state/preview"; |
| 11 | import { readEnvironmentScope } from "~/state/session"; | ||
| 11 | import { useAtomCommand } from "~/state/use-atom-command"; | 12 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 12 | 13 | ||
| 13 | import { | 14 | import { |
| ... | @@ -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"; |
| 29 | import { Check, Copy, Maximize2, Minimize2 } from "lucide"; | 29 | import { Check, Copy, Maximize2, Minimize2 } from "lucide"; |
| 30 | import type { | 30 | import { |
| 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"; |
| 37 | import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; | 38 | import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; |
| 38 | import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; | 39 | import { 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 | <a | 3153 | <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> |
| 11637 | 11648 | ||
| 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 | <ThreadPreviewMiniPlayer | 11656 | <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- |
| 46 | import { CSS } from "@dnd-kit/utilities"; | 46 | import { CSS } from "@dnd-kit/utilities"; |
| 47 | import { | 47 | import { |
| 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 | <TooltipTrigger | 833 | <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"; |
| 21 | import { | 21 | import { |
| 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"; |
| 80 | import { useAttachedTerminalSession } from "../state/terminalSessions"; | 81 | import { useAttachedTerminalSession } from "../state/terminalSessions"; |
| 81 | import { serverEnvironment } from "../state/server"; | 82 | import { serverEnvironment } from "../state/server"; |
| 82 | import { previewEnvironment } from "../state/preview"; | 83 | import { previewEnvironment } from "../state/preview"; |
| 84 | import { readEnvironmentScope } from "../state/session"; | ||
| 83 | import { terminalEnvironment } from "../state/terminal"; | 85 | import { terminalEnvironment } from "../state/terminal"; |
| 84 | import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; | 86 | import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; |
| 85 | import { useAtomCommand } from "../state/use-atom-command"; | 87 | import { 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 @@ |
| 1 | import { Spinner } from "~/components/ui/spinner"; | 1 | import { Spinner } from "~/components/ui/spinner"; |
| 2 | import type { | 2 | import { |
| 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"; |
| 8 | import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; | 9 | import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; |
| 9 | import { AuthFilesystemWriteScope } from "@t3tools/contracts"; | 10 | import { 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 | ? setRenderTablePreferred | 1130 | ? 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 | }; |
| 1171 | 1174 | ||
| 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"; |
| 2 | 2 | ||
| 3 | import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts"; | 3 | import { |
| 4 | AuthPreviewOperateScope, | ||
| 5 | type PreviewAnnotationPayload, | ||
| 6 | type ScopedThreadRef, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 4 | 8 | ||
| 5 | import type { ComposerImageAttachment } from "~/composerDraftStore"; | 9 | import type { ComposerImageAttachment } from "~/composerDraftStore"; |
| 6 | import { usePreviewAvailable } from "~/browser/previewRuntime"; | 10 | import { usePreviewAvailable } from "~/browser/previewRuntime"; |
| 11 | import { useEnvironmentScope } from "~/state/session"; | ||
| 7 | 12 | ||
| 8 | import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; | 13 | import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; |
| 9 | import { PreviewView } from "./PreviewView"; | 14 | import { 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", () => ({ |
| 61 | vi.mock("~/state/session", async (importOriginal) => ({ | 64 | vi.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 | })); |
| 65 | 70 | ||
| 66 | // File-preview errors share a module with asset hooks. Keep the pure URL resolver | 71 | // 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 | }); |
| 590 | 600 | ||
| 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"; |
| 8 | import { | 8 | import { |
| 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"; |
| 46 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | ||
| 45 | import { useRightPanelStore } from "~/rightPanelStore"; | 47 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 46 | 48 | ||
| 47 | import { previewBridge } from "./previewBridge"; | 49 | import { 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 key | 125 | // 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 crop | 762 | // The structured annotation is still sendable when its optional crop |
| 741 | // stalls or fails, so tell the user what they lost and keep going | 763 | // 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 closed | 810 | // 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 the | 817 | // 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]); |
| 805 | 835 | ||
| 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 toggle | 844 | // 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 @@ |
| 1 | import { AuthPreviewOperateScope } from "@t3tools/contracts"; | ||
| 1 | import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router"; | 2 | import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { useEffect, useMemo } from "react"; | 4 | import { useEffect, useMemo } from "react"; |
| ... | @@ -10,6 +11,7 @@ import { openCommandPalette } from "../commandPaletteBus"; | ... | @@ -10,6 +11,7 @@ import { openCommandPalette } from "../commandPaletteBus"; |
| 10 | import { useProjects } from "../state/entities"; | 11 | import { useProjects } from "../state/entities"; |
| 11 | import { isPreviewAvailableFor } from "../browser/previewRuntime"; | 12 | import { isPreviewAvailableFor } from "../browser/previewRuntime"; |
| 12 | import { usePrimaryEnvironmentId } from "../state/environments"; | 13 | import { usePrimaryEnvironmentId } from "../state/environments"; |
| 14 | import { useEnvironmentScope } from "../state/session"; | ||
| 13 | import { selectProjectGroupingSettings } from "../logicalProject"; | 15 | import { selectProjectGroupingSettings } from "../logicalProject"; |
| 14 | import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; | 16 | import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; |
| 15 | import { dispatchPreviewAction } from "../components/preview/previewActionBus"; | 17 | import { 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; |
| 83 | export const AuthSettingsWriteScope = "settings:write" as const; | 83 | export const AuthSettingsWriteScope = "settings:write" as const; |
| 84 | export const AuthProvidersManageScope = "providers:manage" as const; | 84 | export const AuthProvidersManageScope = "providers:manage" as const; |
| 85 | export const AuthEnvironmentMaintainScope = "environment:maintain" as const; | 85 | export const AuthEnvironmentMaintainScope = "environment:maintain" as const; |
| 86 | export const AuthPreviewOperateScope = "preview:operate" as const; | ||
| 86 | export const AuthTerminalOperateScope = "terminal:operate" as const; | 87 | export const AuthTerminalOperateScope = "terminal:operate" as const; |
| 87 | export const AuthSourceControlWriteScope = "source-control:write" as const; | 88 | export const AuthSourceControlWriteScope = "source-control:write" as const; |
| 88 | export const AuthFilesystemReadScope = "filesystem:read" as const; | 89 | export 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 | }); |
| 1071 | 1071 | ||
| 1072 | export const DesktopPreviewAnnotationSendEnabledInputSchema = Schema.Struct({ | ||
| 1073 | tabId: DesktopPreviewTabIdSchema, | ||
| 1074 | enabled: Schema.Boolean, | ||
| 1075 | }); | ||
| 1076 | |||
| 1072 | export const DesktopPreviewArtifactInputSchema = Schema.Struct({ | 1077 | export 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 with | 1289 | * Activate the in-page element picker for the given tab. Resolves with |
| 1283 | * the picked annotation and its attach/send intent, or `null` when the | 1290 | * the picked annotation and its attach/send intent, or `null` when the |