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