From 75bd1028e76d2f508bae224f57dd65786266b488 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 13:42:33 -0700 Subject: [PATCH 1/7] feat(auth): separate browser preview control scope --- apps/server/src/auth/RpcAuthorization.test.ts | 24 +++++++ apps/server/src/auth/RpcAuthorization.ts | 19 +++--- apps/server/src/server.test.ts | 68 +++++++++++++++++++ apps/web/src/browser/ElectronBrowserHost.tsx | 15 +++- apps/web/src/browser/useOpenLink.ts | 8 ++- apps/web/src/components/ChatMarkdown.tsx | 32 +++++---- apps/web/src/components/ChatView.tsx | 30 ++++++-- apps/web/src/components/LegacySidebar.tsx | 8 ++- .../src/components/ThreadTerminalDrawer.tsx | 6 ++ .../src/components/files/FilePreviewPanel.tsx | 19 ++++-- .../preview/PreviewAutomationHosts.tsx | 9 ++- .../src/components/preview/PreviewPanel.tsx | 10 ++- .../settings/ConnectionsSettings.tsx | 6 ++ apps/web/src/routes/_chat.tsx | 10 ++- packages/contracts/src/auth.ts | 3 + 15 files changed, 219 insertions(+), 48 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 62f7bbed6cf7..7c6797d7f790 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -2,6 +2,7 @@ import { AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthPreviewOperateScope, AuthRelayReadScope, AuthRelayWriteScope, WS_METHODS, @@ -64,6 +65,29 @@ describe("RPC authorization scopes", () => { ); }); + it("separates preview control from observation", () => { + for (const method of [ + WS_METHODS.previewOpen, + WS_METHODS.previewNavigate, + WS_METHODS.previewResize, + WS_METHODS.previewRefresh, + WS_METHODS.previewClose, + WS_METHODS.previewReportStatus, + WS_METHODS.previewAutomationConnect, + WS_METHODS.previewAutomationRespond, + WS_METHODS.previewAutomationFocusHost, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthPreviewOperateScope); + } + for (const method of [ + WS_METHODS.previewList, + WS_METHODS.subscribePreviewEvents, + WS_METHODS.subscribeDiscoveredLocalServers, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope); + } + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 2dfe6d595042..0f58554afc67 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -7,6 +7,7 @@ import { AuthFilesystemWriteScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthPreviewOperateScope, AuthRelayReadScope, AuthRelayWriteScope, AuthSourceControlWriteScope, @@ -134,16 +135,16 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.terminalClose]: AuthTerminalOperateScope, [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope, [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope, - [WS_METHODS.previewOpen]: AuthOrchestrationOperateScope, - [WS_METHODS.previewNavigate]: AuthOrchestrationOperateScope, - [WS_METHODS.previewResize]: AuthOrchestrationOperateScope, - [WS_METHODS.previewRefresh]: AuthOrchestrationOperateScope, - [WS_METHODS.previewClose]: AuthOrchestrationOperateScope, + [WS_METHODS.previewOpen]: AuthPreviewOperateScope, + [WS_METHODS.previewNavigate]: AuthPreviewOperateScope, + [WS_METHODS.previewResize]: AuthPreviewOperateScope, + [WS_METHODS.previewRefresh]: AuthPreviewOperateScope, + [WS_METHODS.previewClose]: AuthPreviewOperateScope, [WS_METHODS.previewList]: AuthOrchestrationReadScope, - [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope, - [WS_METHODS.previewAutomationConnect]: AuthOrchestrationOperateScope, - [WS_METHODS.previewAutomationRespond]: AuthOrchestrationOperateScope, - [WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope, + [WS_METHODS.previewReportStatus]: AuthPreviewOperateScope, + [WS_METHODS.previewAutomationConnect]: AuthPreviewOperateScope, + [WS_METHODS.previewAutomationRespond]: AuthPreviewOperateScope, + [WS_METHODS.previewAutomationFocusHost]: AuthPreviewOperateScope, [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope, [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 413bcda37c5e..442fe66000de 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -9,6 +9,7 @@ import { AuthAdministrativeScopes, AuthOrchestrationOperateScope, AuthSourceControlWriteScope, + AuthPreviewOperateScope, AuthStandardClientScopes, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, @@ -524,6 +525,7 @@ const buildAppUnderTest = (options?: { ProviderSessionDirectory.ProviderSessionDirectory["Service"] >; terminalManager?: Partial; + previewManager?: Partial; orchestrationEngine?: Partial; threadDeletionReactor?: Partial; analyticsService?: Partial; @@ -925,6 +927,7 @@ const buildAppUnderTest = (options?: { subscribeEvents: Effect.flatMap(PubSub.unbounded(), (pubsub) => PubSub.subscribe(pubsub), ), + ...options?.layers?.previewManager, }), Layer.mock(PortScanner.PortDiscovery)({ scan: () => Effect.succeed([]), @@ -5777,6 +5780,71 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("requires an explicit preview grant for control and automation streams", () => + Effect.gen(function* () { + let refreshes = 0; + yield* buildAppUnderTest({ + layers: { + previewManager: { + refresh: () => + Effect.sync(() => { + refreshes += 1; + }), + }, + }, + }); + const threadId = ThreadId.makeUnsafe("preview-scope-thread"); + const host = { + clientId: "preview-scope-host", + environmentId: testEnvironmentDescriptor.environmentId, + } as const; + const legacyScopes = "orchestration:read orchestration:operate"; + for (const scope of [legacyScopes, `${legacyScopes} ${AuthPreviewOperateScope}`]) { + const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { scope }); + assert.equal(token.response.status, 200); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, + }); + const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const previews = yield* client[WS_METHODS.previewList]({ threadId }); + assert.deepEqual(previews.sessions, []); + if (scope === legacyScopes) { + const errors = [ + yield* client[WS_METHODS.previewRefresh]({ threadId, tabId: "tab" }).pipe( + Effect.flip, + ), + yield* client[WS_METHODS.previewAutomationConnect](host).pipe( + Stream.runHead, + Effect.flip, + ), + ]; + for (const error of errors) { + assert.equal(error._tag, "EnvironmentAuthorizationError"); + if (error._tag === "EnvironmentAuthorizationError") { + assert.equal(error.requiredScope, AuthPreviewOperateScope); + } + } + assert.equal(refreshes, 0); + } else { + yield* client[WS_METHODS.previewRefresh]({ threadId, tabId: "tab" }); + const connected = yield* client[WS_METHODS.previewAutomationConnect](host).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(connected.type, "connected"); + assert.equal(refreshes, 1); + } + }), + ), + ); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("shares one preview automation broker across websocket sessions", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index de7e23603298..381b1788cf05 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,12 +1,13 @@ "use client"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; -import { FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; -import { useEffect, useMemo } from "react"; +import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; +import { type ComponentProps, useEffect, useMemo } from "react"; import { isElectron } from "~/env"; import { useTheme } from "~/hooks/useTheme"; import { useActivePreviewSessions } from "~/previewStateStore"; +import { useEnvironmentScope } from "~/state/session"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserPointerStore } from "./browserPointerStore"; @@ -85,7 +86,7 @@ export function ElectronBrowserHost() { {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => { const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; return ( - ); } + +function AuthorizedBrowserWebview(props: ComponentProps) { + const canOperatePreview = useEnvironmentScope( + props.threadRef.environmentId, + AuthPreviewOperateScope, + ); + return canOperatePreview ? : null; +} diff --git a/apps/web/src/browser/useOpenLink.ts b/apps/web/src/browser/useOpenLink.ts index 2a1d122eedbf..0cfffa2393ad 100644 --- a/apps/web/src/browser/useOpenLink.ts +++ b/apps/web/src/browser/useOpenLink.ts @@ -1,4 +1,4 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; +import { AuthPreviewOperateScope, type ScopedThreadRef } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -8,6 +8,7 @@ import { useCallback } from "react"; import { recordVisitForThread } from "~/browserHistoryStore"; import { readLocalApi } from "~/localApi"; import { previewEnvironment } from "~/state/preview"; +import { readEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { @@ -46,7 +47,10 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( url, event: options.event ?? NO_MODIFIER, preference: await resolveBrowserLinkTargetPreference(), - canOpenInApp: canOpenLinksInApp(Boolean(targetThreadRef)), + canOpenInApp: + targetThreadRef != null && + readEnvironmentScope(targetThreadRef.environmentId, AuthPreviewOperateScope) && + canOpenLinksInApp(true), }); if (target === "app" && targetThreadRef) { const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 89f66134b140..fe0c60880797 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -22,12 +22,13 @@ import { WrapTextIcon, type LucideIcon, } from "lucide-react"; -import type { - AssetResource, - EnvironmentId, - ScopedThreadRef, - ServerProviderSkill, - ThreadLinkedPullRequest, +import { + AuthPreviewOperateScope, + type AssetResource, + type EnvironmentId, + type ScopedThreadRef, + type ServerProviderSkill, + type ThreadLinkedPullRequest, } from "@t3tools/contracts"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { @@ -2164,6 +2165,7 @@ function useChatMarkdownState({ }); const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); + const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); const remoteOpen = useRemoteOpenResolution(environmentId); const canUseShellActions = canOperateHost && @@ -2353,12 +2355,12 @@ function useChatMarkdownState({ ); const openExternalLinkInPreview = useCallback( (url: string) => { - if (!threadRef) { + if (!threadRef || !canOperatePreview) { return Promise.resolve( AsyncResult.failure( Cause.fail( new BrowserPreviewUnavailableError({ - message: "Thread context is unavailable.", + message: "Preview access is unavailable for this client.", }), ), ), @@ -2381,11 +2383,11 @@ function useChatMarkdownState({ return result; }); }, - [openPreview, threadRef], + [canOperatePreview, openPreview, threadRef], ); const openMarkdownFileInPreview = useCallback( (path: string) => { - if (!threadRef || preparedConnection._tag === "None") { + if (!threadRef || !canOperatePreview || preparedConnection._tag === "None") { return Promise.resolve( AsyncResult.failure( Cause.fail( @@ -2405,7 +2407,7 @@ function useChatMarkdownState({ openPreview, }); }, - [createAssetUrl, cwd, openPreview, preparedConnection, threadRef], + [canOperatePreview, createAssetUrl, cwd, openPreview, preparedConnection, threadRef], ); const findWorkspaceBasenameMatch = useCallback( async (workspaceRelativePath: string) => { @@ -2523,6 +2525,7 @@ function useChatMarkdownState({ revealLabel={revealInFileManagerLabel} onOpenInBrowser={ threadRef && + canOperatePreview && isPreviewSupportedInRuntime() && isBrowserPreviewFile(fileLinkMeta.filePath) ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) @@ -2534,6 +2537,7 @@ function useChatMarkdownState({ }, [ canUseShellActions, + canOperatePreview, fileLinkParentSuffixByPath, openFileInPanel, openInPreferredEditor, @@ -2550,6 +2554,7 @@ function useChatMarkdownState({ const componentState = useMemo( () => ({ canOperateHost, + canOperatePreview, cwd, diffThemeName, environmentId, @@ -2577,6 +2582,7 @@ function useChatMarkdownState({ }), [ canOperateHost, + canOperatePreview, cwd, diffThemeName, environmentId, @@ -2701,6 +2707,7 @@ const CHAT_MARKDOWN_COMPONENTS = { a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) { const { canOperateHost, + canOperatePreview, cwd, environmentId, imageBaseDir, @@ -2766,7 +2773,8 @@ const CHAT_MARKDOWN_COMPONENTS = { }; const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; - const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); + const canOpenInPreview = + canOperatePreview && Boolean(threadRef) && isPreviewSupportedInRuntime(); const linkChildren = {children}; const link = ( { - if (!activeThreadRef) return; + if (!activeThreadRef || !canOperatePreview) return; void addBrowserSurface({ threadRef: activeThreadRef, openPreview, @@ -3805,7 +3810,7 @@ export default function ChatView(props: ChatViewProps) { } }); }, - [activeThreadRef, openPreview], + [activeThreadRef, canOperatePreview, openPreview], ); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; @@ -4105,13 +4110,20 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().close(activeThreadRef); return; } + if (!canOperatePreview) return; const activeTabId = activePreviewState.activeTabId; if (activeTabId) { useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); } else { createBrowserSurface(); } - }, [activePreviewState.activeTabId, activeThreadRef, createBrowserSurface, previewPanelOpen]); + }, [ + activePreviewState.activeTabId, + activeThreadRef, + canOperatePreview, + createBrowserSurface, + previewPanelOpen, + ]); const closePreviewPanel = useCallback(() => { if (activeThreadRef) { setMaximizedRightPanelThreadKey(null); @@ -4268,7 +4280,7 @@ export default function ChatView(props: ChatViewProps) { (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; for (const surface of surfaces) { - if (surface.kind === "preview" && surface.resourceId) { + if (canOperatePreview && surface.kind === "preview" && surface.resourceId) { void closePreviewSession({ closePreview, snapshot: activePreviewState.sessions[surface.resourceId] ?? null, @@ -4290,6 +4302,7 @@ export default function ChatView(props: ChatViewProps) { [ activeThreadRef, activePreviewState.sessions, + canOperatePreview, closePreview, closeTerminalMutation, storeCloseTerminal, @@ -8219,7 +8232,10 @@ export default function ChatView(props: ChatViewProps) { - {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( + {canOperatePreview && + activeThreadRef && + activePreviewMiniPlayer && + previewMiniPlayerVisible ? ( ) => { const port = discoveredPorts[0]; - if (!port) return; + if (!port || !canOperatePreview) return; event.preventDefault(); event.stopPropagation(); navigateToThread(threadRef); @@ -472,7 +474,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ); })(); }, - [discoveredPorts, navigateToThread, openPreview, threadRef], + [canOperatePreview, discoveredPorts, navigateToThread, openPreview, threadRef], ); const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; @@ -797,7 +799,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr )}
- {discoveredPorts.length > 0 && ( + {canOperatePreview && discoveredPorts.length > 0 && ( settings.wordWrap); + const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); const primaryEnvironmentId = usePrimaryEnvironmentId(); const remoteOpenState = useRemoteOpenState(environmentId); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); @@ -1047,6 +1050,7 @@ export default function FilePreviewPanel({ ? setRenderMarkdownPreferred : setRenderBrowserFilePreferred; const canOpenInBrowser = + canOperatePreview && relativePath !== null && attachment === undefined && !isVideo && @@ -1087,7 +1091,7 @@ export default function FilePreviewPanel({ }; const handleOpenInBrowser = useCallback(() => { - if (!canReadFiles || !absolutePath || !environmentHttpBaseUrl) return; + if (!canReadFiles || !canOperatePreview || !absolutePath || !environmentHttpBaseUrl) return; void (async () => { const result = await openFileInPreview({ threadRef, @@ -1112,6 +1116,7 @@ export default function FilePreviewPanel({ }, [ absolutePath, canReadFiles, + canOperatePreview, createAssetUrl, cwd, environmentHttpBaseUrl, diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index fd87f7e80c79..d467028d9e06 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -3,6 +3,7 @@ import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { + AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT, PREVIEW_AUTOMATION_OPERATIONS, type EnvironmentId, @@ -51,6 +52,7 @@ import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { isElectron } from "~/env"; import { useEnvironments } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; +import { useEnvironmentScope } from "~/state/session"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -275,7 +277,7 @@ export function PreviewAutomationHosts() { * lets the subscription runtime own reconnects for every saved target. */} {environments.map((environment) => ( - @@ -284,6 +286,11 @@ export function PreviewAutomationHosts() { ); } +function AuthorizedPreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { + const canOperatePreview = useEnvironmentScope(props.environmentId, AuthPreviewOperateScope); + return canOperatePreview ? : null; +} + function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { const { environmentId } = props; const registry = useContext(RegistryContext); diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/components/preview/PreviewPanel.tsx index eb3912fb5b67..6e4a1ed229a5 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/components/preview/PreviewPanel.tsx @@ -1,9 +1,10 @@ "use client"; -import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts"; +import { AuthPreviewOperateScope, type PreviewAnnotationPayload, type ScopedThreadRef } from "@t3tools/contracts"; import type { ComposerImageAttachment } from "~/composerDraftStore"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { useEnvironmentScope } from "~/state/session"; import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; import { PreviewView } from "./PreviewView"; @@ -28,12 +29,15 @@ export function PreviewPanel({ visible, onSendAnnotation, }: Props) { - if (!isPreviewSupportedInRuntime()) { + const canOperatePreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope); + if (!canOperatePreview || !isPreviewSupportedInRuntime()) { return (

- Preview is only available in the T3 Code desktop app. + {canOperatePreview + ? "Preview is only available in the T3 Code desktop app." + : "Pair this client again with preview access to control browser previews."}

diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 96f45a31e95a..f94d366eaa2d 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -19,6 +19,7 @@ import { AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthPreviewOperateScope, AuthRelayReadScope, AuthRelayWriteScope, AuthSourceControlWriteScope, @@ -218,6 +219,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ title: "Maintain environment", description: "Update the server and control environment processes.", }, + { + scope: AuthPreviewOperateScope, + title: "Control previews", + description: "Open browser previews and host browser automation.", + }, { scope: AuthTerminalOperateScope, title: "Use terminals", diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index e084e22c2cbb..7cfc7e9a997f 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,3 +1,4 @@ +import { AuthPreviewOperateScope } from "@t3tools/contracts"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import { useEffect, useMemo } from "react"; @@ -7,6 +8,7 @@ import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; +import { useEnvironmentScope } from "../state/session"; import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; @@ -27,6 +29,10 @@ function ChatRouteGlobalShortcuts() { const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); + const canOperatePreview = useEnvironmentScope( + routeThreadRef?.environmentId ?? null, + AuthPreviewOperateScope, + ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const legacySidebarEnabled = useLegacySidebarEnabled(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -111,7 +117,7 @@ function ChatRouteGlobalShortcuts() { if (command === "preview.toggle") { event.preventDefault(); event.stopPropagation(); - if (!routeThreadRef) return; + if (!routeThreadRef || (!canOperatePreview && !previewOpen)) return; if (!isPreviewSupportedInRuntime()) { toastManager.add( stackedThreadToast({ @@ -136,6 +142,7 @@ function ChatRouteGlobalShortcuts() { command === "preview.zoomOut" || command === "preview.resetZoom" ) { + if (!canOperatePreview) return; event.preventDefault(); event.stopPropagation(); const action = @@ -160,6 +167,7 @@ function ChatRouteGlobalShortcuts() { activeDraftThread, activeThread, clearSelection, + canOperatePreview, handleNewThread, keybindings, defaultProjectRef, diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 90156f2d1570..a5385e9cf525 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -83,6 +83,7 @@ export const AuthOrchestrationOperateScope = "orchestration:operate" as const; export const AuthSettingsWriteScope = "settings:write" as const; export const AuthProvidersManageScope = "providers:manage" as const; export const AuthEnvironmentMaintainScope = "environment:maintain" as const; +export const AuthPreviewOperateScope = "preview:operate" as const; export const AuthTerminalOperateScope = "terminal:operate" as const; export const AuthSourceControlWriteScope = "source-control:write" as const; export const AuthFilesystemReadScope = "filesystem:read" as const; @@ -99,6 +100,7 @@ export const AuthEnvironmentScope = Schema.Literals([ AuthSettingsWriteScope, AuthProvidersManageScope, AuthEnvironmentMaintainScope, + AuthPreviewOperateScope, AuthTerminalOperateScope, AuthFilesystemReadScope, AuthFilesystemWriteScope, @@ -126,6 +128,7 @@ export const AuthStandardClientScopes = [ AuthSettingsWriteScope, AuthProvidersManageScope, AuthEnvironmentMaintainScope, + AuthPreviewOperateScope, AuthTerminalOperateScope, AuthSourceControlWriteScope, AuthFilesystemReadScope, From b3644d20fac826bd71e4f9f8c24cc32acf1ebf1a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:00:09 -0700 Subject: [PATCH 2/7] fix(auth): repair preview integration types --- apps/server/src/server.test.ts | 2 +- apps/web/src/components/files/FilePreviewPanel.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 442fe66000de..2f32a483ca7d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5793,7 +5793,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, }, }); - const threadId = ThreadId.makeUnsafe("preview-scope-thread"); + const threadId = ThreadId.make("preview-scope-thread"); const host = { clientId: "preview-scope-host", environmentId: testEnvironmentDescriptor.environmentId, diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 6facb35daa13..8ae2965604ce 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -25,7 +25,6 @@ import { Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useEnvironmentScope } from "~/state/session"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; From 8e8275e865345e6a2617aae64828aae7f829ead0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:06:17 -0700 Subject: [PATCH 3/7] style(web): format preview panel import --- apps/web/src/components/preview/PreviewPanel.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/components/preview/PreviewPanel.tsx index 6e4a1ed229a5..9c19ef2b81d0 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/components/preview/PreviewPanel.tsx @@ -1,6 +1,10 @@ "use client"; -import { AuthPreviewOperateScope, type PreviewAnnotationPayload, type ScopedThreadRef } from "@t3tools/contracts"; +import { + AuthPreviewOperateScope, + type PreviewAnnotationPayload, + type ScopedThreadRef, +} from "@t3tools/contracts"; import type { ComposerImageAttachment } from "~/composerDraftStore"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; From 8cdbdd59d695b40dcc2ea15d3e7e316c5aa2b671 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:13:41 -0700 Subject: [PATCH 4/7] fix(web): consume unavailable preview shortcuts --- apps/web/src/routes/_chat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 7cfc7e9a997f..8a63c525d809 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -142,9 +142,9 @@ function ChatRouteGlobalShortcuts() { command === "preview.zoomOut" || command === "preview.resetZoom" ) { - if (!canOperatePreview) return; event.preventDefault(); event.stopPropagation(); + if (!canOperatePreview) return; const action = command === "preview.refresh" ? "refresh" From 45dadd22afcf6999abbf491c5ac0e781e3ef0064 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 22:45:50 -0700 Subject: [PATCH 5/7] fix(desktop): gate annotation sending by thread permissions --- apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/preview.ts | 15 +++ apps/desktop/src/preload.ts | 5 + .../src/preview/AnnotationKeyboard.test.ts | 21 ++-- .../desktop/src/preview/AnnotationKeyboard.ts | 3 +- apps/desktop/src/preview/GuestProtocol.ts | 1 + apps/desktop/src/preview/Manager.test.ts | 68 +++++++++++++ apps/desktop/src/preview/Manager.ts | 24 ++++- apps/desktop/src/preview/PickPreload.ts | 37 +++++-- .../components/preview/PreviewView.test.tsx | 98 +++++++++++++++++++ .../src/components/preview/PreviewView.tsx | 43 ++++++-- packages/contracts/src/ipc.ts | 7 ++ 12 files changed, 302 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5b2c815eaa42..0b7466bc5bbc 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -68,6 +68,8 @@ export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources"; export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies"; export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme"; +export const PREVIEW_SET_ANNOTATION_SEND_ENABLED_CHANNEL = + "desktop:preview-set-annotation-send-enabled"; export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 5fb7eff99fc6..4733c1b3e5c5 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,4 +1,5 @@ import { + DesktopPreviewAnnotationSendEnabledInputSchema, DesktopPreviewAnnotationThemeInputSchema, DesktopPreviewArtifactInputSchema, DesktopPreviewAutomationClickInputSchema, @@ -347,6 +348,19 @@ export const pickElement = DesktopIpc.makeIpcMethod({ }), }); +export const setAnnotationSendEnabled = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_ANNOTATION_SEND_ENABLED_CHANNEL, + payload: DesktopPreviewAnnotationSendEnabledInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setAnnotationSendEnabled")(function* ({ + tabId, + enabled, + }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setAnnotationSendEnabled(tabId, enabled); + }), +}); + export const captureScreenshot = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, payload: DesktopPreviewTabInputSchema, @@ -486,6 +500,7 @@ export const methods = [ clearCache, getPreviewConfig, setAnnotationTheme, + setAnnotationSendEnabled, pickElement, cancelPickElement, captureScreenshot, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 74001dd785d3..2fac3441527a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -236,6 +236,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId, profileId }), setAnnotationTheme: (theme) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }), + setAnnotationSendEnabled: (tabId, enabled) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_SEND_ENABLED_CHANNEL, { + tabId, + enabled, + }), pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), cancelPickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), diff --git a/apps/desktop/src/preview/AnnotationKeyboard.test.ts b/apps/desktop/src/preview/AnnotationKeyboard.test.ts index f49c1cb79f1a..4124e548bec1 100644 --- a/apps/desktop/src/preview/AnnotationKeyboard.test.ts +++ b/apps/desktop/src/preview/AnnotationKeyboard.test.ts @@ -15,14 +15,23 @@ const keyboardEvent = ( describe("resolveAnnotationSubmission", () => { it("attaches on Enter and sends on Cmd/Ctrl+Enter", () => { - expect(resolveAnnotationSubmission(keyboardEvent())).toBe("attach"); - expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }))).toBe("send"); - expect(resolveAnnotationSubmission(keyboardEvent({ ctrlKey: true }))).toBe("send"); + expect(resolveAnnotationSubmission(keyboardEvent(), true)).toBe("attach"); + expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }), true)).toBe("send"); + expect(resolveAnnotationSubmission(keyboardEvent({ ctrlKey: true }), true)).toBe("send"); }); it("leaves Shift+Enter and composition events available for editing", () => { - expect(resolveAnnotationSubmission(keyboardEvent({ shiftKey: true }))).toBeNull(); - expect(resolveAnnotationSubmission(keyboardEvent({ isComposing: true }))).toBeNull(); - expect(resolveAnnotationSubmission(keyboardEvent({ key: " " }))).toBeNull(); + expect(resolveAnnotationSubmission(keyboardEvent({ shiftKey: true }), true)).toBeNull(); + expect(resolveAnnotationSubmission(keyboardEvent({ isComposing: true }), true)).toBeNull(); + expect(resolveAnnotationSubmission(keyboardEvent({ key: " " }), true)).toBeNull(); + }); + + it("keeps attach available while the send shortcut follows permission changes", () => { + const send = keyboardEvent({ ctrlKey: true }); + expect(resolveAnnotationSubmission(send, false)).toBeNull(); + expect(resolveAnnotationSubmission(keyboardEvent(), false)).toBe("attach"); + expect(resolveAnnotationSubmission(send, true)).toBe("send"); + expect(resolveAnnotationSubmission(send, false)).toBeNull(); + expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }), false)).toBeNull(); }); }); diff --git a/apps/desktop/src/preview/AnnotationKeyboard.ts b/apps/desktop/src/preview/AnnotationKeyboard.ts index 6c694ccd2ed5..8b935bfbec1d 100644 --- a/apps/desktop/src/preview/AnnotationKeyboard.ts +++ b/apps/desktop/src/preview/AnnotationKeyboard.ts @@ -10,7 +10,8 @@ interface AnnotationKeyboardEvent { export function resolveAnnotationSubmission( event: AnnotationKeyboardEvent, + canSend: boolean, ): PreviewAnnotationSubmission | null { if (event.key !== "Enter" || event.shiftKey || event.isComposing) return null; - return event.metaKey || event.ctrlKey ? "send" : "attach"; + return event.metaKey || event.ctrlKey ? (canSend ? "send" : null) : "attach"; } diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index e63597b71efc..456509dfe2ff 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -3,5 +3,6 @@ export const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; +export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 79c7fd1725e1..1c4262129c10 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -3415,6 +3415,73 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("keeps annotation send grants isolated and updates an active pick", () => + withManager((manager) => + Effect.gen(function* () { + const picked = new Map void>(); + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("annotation"), + toDataURL: () => "data:image/png;base64,YW5ub3RhdGlvbg==", + getSize: () => ({ width: 10, height: 10 }), + })); + const guests = new Map( + [42, 43].map( + (id) => + [ + id, + Object.assign(makeTestPreviewWebContents(capturePage, id), { + isFocused: () => true, + once: vi.fn(), + ipc: { + on: vi.fn( + (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => { + if (channel === "preview:element-picked") picked.set(id, listener); + }, + ), + off: vi.fn(), + removeListener: vi.fn(), + }, + }), + ] as const, + ), + ); + fromId.mockImplementation((id) => (id === undefined ? null : (guests.get(id) ?? null))); + yield* manager.createTab("tab_a"); + yield* manager.createTab("tab_b"); + yield* manager.registerWebview("tab_a", 42); + yield* manager.registerWebview("tab_b", 43); + yield* manager.setAnnotationSendEnabled("tab_a", true); + const annotation = { + id: "annotation-scopes", + pageUrl: "https://example.com", + pageTitle: "Example", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-09-05T00:00:00.000Z", + }; + for (const [tabId, id, nextGrant, expected] of [ + ["tab_b", 43, undefined, "attach"], + ["tab_a", 42, false, "attach"], + ["tab_a", 42, true, "send"], + ] as const) { + const pick = yield* manager.pickElement(tabId).pipe(Effect.forkChild); + yield* Effect.yieldNow; + if (nextGrant !== undefined) { + yield* manager.setAnnotationSendEnabled(tabId, nextGrant); + } + picked.get(id)?.({}, annotation, null, "send"); + const result = yield* Fiber.join(pick); + expect(result?.annotation.id).toBe(annotation.id); + expect(result?.submission).toBe(expected); + } + }), + ), + ); + effectIt.effect("settles the pick when the annotation screenshot never arrives", () => withManager((manager) => Effect.gen(function* () { @@ -3461,6 +3528,7 @@ describe("PreviewManager", () => { const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); yield* Effect.yieldNow; + yield* manager.setAnnotationSendEnabled("tab_1", true); onPicked?.( {}, { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 900ba5fe983c..71d7f9e131e2 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -57,6 +57,7 @@ import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, + ANNOTATION_SEND_ENABLED_CHANNEL, ANNOTATION_THEME_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, @@ -589,6 +590,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME); + const annotationSendEnabled = new WeakMap(); const mainWindowRef = yield* Ref.make>(Option.none()); const tabsRef = yield* SynchronizedRef.make>(new Map()); const attachedRef = yield* Ref.make>(new Map()); @@ -2393,6 +2395,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const setAnnotationSendEnabled = Effect.fn("PreviewManager.setAnnotationSendEnabled")(function* ( + tabId: string, + enabled: boolean, + ) { + const wc = yield* requireWebContents(tabId); + yield* attempt({ operation: "setAnnotationSendEnabled", tabId, webContentsId: wc.id }, () => { + annotationSendEnabled.set(wc, enabled); + wc.send(ANNOTATION_SEND_ENABLED_CHANNEL, enabled); + }); + }); + const pickElement = Effect.fn("PreviewManager.pickElement")(function* (tabId: string) { const wc = yield* requireWebContents(tabId); yield* cancelPickElement(tabId); @@ -2470,7 +2483,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } const cropRect = normalizeCaptureRect(args[1]); - const submission = args[2] === "send" ? "send" : "attach"; + const submission = + args[2] === "send" && annotationSendEnabled.get(wc) === true ? "send" : "attach"; runFork( captureAnnotationScreenshot(tabId, wc, cropRect).pipe( // The renderer cannot tell a dropped crop from a comment-only @@ -2532,7 +2546,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.once("destroyed", onDestroyed); wc.once("did-start-navigation", onNavigated); if (!wc.isFocused()) wc.focus(); - wc.send(START_PICK_CHANNEL, annotationTheme); + wc.send(START_PICK_CHANNEL, annotationTheme, annotationSendEnabled.get(wc) === true); }); }); runFork( @@ -4170,6 +4184,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function revealArtifact, saveRecording, setAnnotationTheme, + setAnnotationSendEnabled, setAudioMuted, setColorScheme, setMainWindow, @@ -4534,6 +4549,10 @@ export class PreviewManager extends Context.Service< readonly setAnnotationTheme: ( theme: DesktopPreviewAnnotationTheme, ) => Effect.Effect; + readonly setAnnotationSendEnabled: ( + tabId: string, + enabled: boolean, + ) => Effect.Effect; readonly pickElement: ( tabId: string, ) => Effect.Effect; @@ -4657,6 +4676,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { }, ), setAnnotationTheme: operations.setAnnotationTheme, + setAnnotationSendEnabled: operations.setAnnotationSendEnabled, pickElement: operations.pickElement, cancelPickElement: operations.cancelPickElement, captureScreenshot: operations.captureScreenshot, diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 6155c4119ec8..1b2f852e3de1 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -18,6 +18,7 @@ import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; import { ANNOTATION_CAPTURED_CHANNEL, + ANNOTATION_SEND_ENABLED_CHANNEL, ANNOTATION_THEME_CHANNEL, CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, @@ -48,6 +49,7 @@ interface SelectedElement { interface AnnotationSession { teardown: (notifyMain: boolean) => void; applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; + setSendEnabled: (enabled: boolean) => void; } let activeSession: AnnotationSession | null = null; @@ -431,7 +433,7 @@ function strokeBounds( return { x: left, y: top, width: right - left, height: bottom - top }; } -function startAnnotation(): void { +function startAnnotation(sendEnabled: boolean): void { activeSession?.teardown(false); let finished = false; const host = document.createElement("div"); @@ -508,6 +510,12 @@ function startAnnotation(): void { composerRow.appendChild(dragHandle); const submit = createButton("Attach", "Attach annotation and screenshot (Enter)"); + const updateSendHint = () => { + submit.title = sendEnabled + ? "Attach annotation and screenshot (Enter). Send with Cmd/Ctrl+Enter." + : "Attach annotation and screenshot (Enter)"; + }; + updateSendHint(); submit.className += " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; composerRow.appendChild(submit); @@ -1264,6 +1272,7 @@ function startAnnotation(): void { }; const submitAnnotation = (submission: PreviewAnnotationSubmission): void => { + if (submission === "send" && !sendEnabled) return; if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) return; pendingCapture = true; @@ -1315,7 +1324,12 @@ function startAnnotation(): void { ...submittedRegions.map((region) => region.rect), ...submittedStrokes.map((stroke) => stroke.bounds), ]); - ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect, submission); + ipcRenderer.send( + ELEMENT_PICKED_CHANNEL, + annotation, + screenshotRect, + submission === "send" && !sendEnabled ? "attach" : submission, + ); }) .catch(() => { // Last resort. Main is waiting on this message, so hand it an empty @@ -1326,7 +1340,8 @@ function startAnnotation(): void { }; submit.addEventListener("click", () => submitAnnotation("attach")); root.addEventListener("keydown", (event) => { - const submission = event.target === comment ? resolveAnnotationSubmission(event) : null; + const submission = + event.target === comment ? resolveAnnotationSubmission(event, sendEnabled) : null; // Keep this in the bubble phase so editor inputs receive the event before // it is isolated from listeners installed by the inspected page. event.stopImmediatePropagation(); @@ -1352,12 +1367,22 @@ function startAnnotation(): void { activeSession = { teardown, applyTheme: (theme) => applyAnnotationTheme(host, theme), + setSendEnabled: (enabled) => { + sendEnabled = enabled; + updateSendHint(); + }, }; } -ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { - if (theme) annotationTheme = theme; - startAnnotation(); +ipcRenderer.on( + START_PICK_CHANNEL, + (_event, theme: DesktopPreviewAnnotationTheme | undefined, sendEnabled?: boolean) => { + if (theme) annotationTheme = theme; + startAnnotation(sendEnabled === true); + }, +); +ipcRenderer.on(ANNOTATION_SEND_ENABLED_CHANNEL, (_event, enabled: boolean) => { + activeSession?.setSendEnabled(enabled === true); }); ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { annotationTheme = theme; diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 626fe03e77b8..6a25e40acdf6 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -15,6 +15,9 @@ const mocks = vi.hoisted(() => ({ navigate: vi.fn(async (_tabId: string, _url: string): Promise => undefined), rememberPreviewUrl: vi.fn(), readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })), + readEnvironmentScope: vi.fn(() => true), + setAnnotationSendEnabled: vi.fn(async () => undefined), + cancelPickElement: vi.fn(async () => undefined), submittedUrl: null as ((url: string) => void) | null, emptyStateUrl: null as ((url: string) => void) | null, togglePictureInPicture: null as (() => void) | null, @@ -58,6 +61,8 @@ vi.mock("~/browserHistoryStore", () => ({ vi.mock("~/state/session", () => ({ readPreparedConnection: mocks.readPreparedConnection, + readEnvironmentScope: mocks.readEnvironmentScope, + useEnvironmentScope: mocks.readEnvironmentScope, })); // File-preview errors share a module with asset hooks. Keep the pure URL resolver @@ -213,6 +218,8 @@ vi.mock("./previewBridge", () => ({ previewBridge: { navigate: mocks.navigate, pickElement: mocks.pickElement, + setAnnotationSendEnabled: mocks.setAnnotationSendEnabled, + cancelPickElement: mocks.cancelPickElement, pictureInPicture: { open: mocks.openPictureInPicture, close: mocks.closePictureInPicture, @@ -335,6 +342,9 @@ describe("PreviewView navigation", () => { mocks.navigate.mockClear(); mocks.rememberPreviewUrl.mockClear(); mocks.readPreparedConnection.mockClear(); + mocks.readEnvironmentScope.mockReset().mockReturnValue(true); + mocks.setAnnotationSendEnabled.mockClear(); + mocks.cancelPickElement.mockClear(); mocks.submittedUrl = null; mocks.emptyStateUrl = null; mocks.togglePictureInPicture = null; @@ -555,6 +565,94 @@ describe("PreviewView navigation", () => { expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, annotation); }); + it("retains the annotation locally when its own environment loses access during capture", async () => { + const annotation = { + id: "annotation-revoked", + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-09-05T00:00:00.000Z", + }; + const onSendAnnotation = vi.fn(); + mocks.pickElement.mockResolvedValue({ annotation, submission: "send" }); + let finishCapture: (() => void) | undefined; + mocks.capturePreviewAnnotationScreenshot.mockImplementationOnce( + () => + new Promise((resolve) => { + finishCapture = () => resolve({ status: "none" }); + }), + ); + const document = installTestDom(); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.createElement("div") as unknown as Element); + const view = () => ( + + ); + try { + await act(async () => root.render(view())); + await act(async () => mocks.toggleAnnotation?.()); + expect(finishCapture).toBeDefined(); + expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, true); + + mocks.readEnvironmentScope.mockImplementation( + (environmentId?: unknown) => environmentId !== TEST_THREAD_REF.environmentId, + ); + await act(async () => root.render(view())); + expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, false); + await act(async () => finishCapture!()); + expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, annotation); + expect(onSendAnnotation).not.toHaveBeenCalled(); + + mocks.readEnvironmentScope.mockReturnValue(true); + await act(async () => root.render(view())); + await act(async () => mocks.toggleAnnotation?.()); + expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, true); + expect(onSendAnnotation).toHaveBeenCalledWith(annotation, null); + } finally { + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + } + }); + + it("does not reopen a cancelled picker after the permission update finishes", async () => { + let finishUpdate: (() => void) | undefined; + mocks.setAnnotationSendEnabled.mockImplementationOnce( + () => + new Promise((resolve) => { + finishUpdate = resolve; + }), + ); + const document = installTestDom(); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.createElement("div") as unknown as Element); + try { + await act(async () => + root.render(), + ); + await act(async () => mocks.toggleAnnotation?.()); + expect(finishUpdate).toBeDefined(); + expect(mocks.pickElement).not.toHaveBeenCalled(); + await act(async () => mocks.toggleAnnotation?.()); + expect(mocks.cancelPickElement).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID); + await act(async () => finishUpdate!()); + expect(mocks.pickElement).not.toHaveBeenCalled(); + expect(mocks.addPreviewAnnotation).not.toHaveBeenCalled(); + } finally { + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + } + }); + it("warns when main dropped the crop before handing over the pick", async () => { const annotation = { id: "annotation-3", diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index e6ad2758bc48..4d1895817389 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -6,6 +6,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { + AuthOrchestrationOperateScope, DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, type PreviewAnnotationPayload, @@ -34,6 +35,7 @@ import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import { useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useAtomCommand } from "~/state/use-atom-command"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { useRightPanelStore } from "~/rightPanelStore"; @@ -102,8 +104,11 @@ export function PreviewView({ }: Props) { const [focusUrlNonce, setFocusUrlNonce] = useState(undefined); const [pickActive, setPickActive] = useState(false); + const canSendAnnotation = + useEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) && + Boolean(onSendAnnotation); const activeRecordingTabIds = useActiveBrowserRecordingTabIds(); - const pickActiveRef = useRef(false); + const pickActiveRef = useRef<{ cancelled: boolean } | null>(null); const isMountedRef = useRef(true); // Kept in sync so the title effect can depend on the stable thread key // instead of the thread object, which is recreated on every update. @@ -577,6 +582,7 @@ export function PreviewView({ const handlePickElement = useCallback(() => { if (!previewBridge || !runtimeTabId) return; if (pickActiveRef.current) { + pickActiveRef.current.cancelled = true; void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined); return; } @@ -587,12 +593,19 @@ export function PreviewView({ // every pick they'd have to click back into the textarea. const previouslyFocused = typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null; - pickActiveRef.current = true; + const pickRequest = { cancelled: false }; + pickActiveRef.current = pickRequest; setPickActive(true); void (async () => { try { + await previewBridge.setAnnotationSendEnabled?.( + runtimeTabId, + Boolean(onSendAnnotation) && + readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), + ); + if (pickRequest.cancelled) return; const result = await previewBridge.pickElement(runtimeTabId); - if (!result) return; + if (!result || pickRequest.cancelled) return; const { annotation: picked, submission, screenshotFailed = false } = result; // The structured annotation is still sendable when its optional crop // stalls or fails, so tell the user what they lost and keep going @@ -600,6 +613,7 @@ export function PreviewView({ // The stored copy drops the screenshot on failure, otherwise the prompt // would tell the agent a crop is attached when none was sent. const capture = await capturePreviewAnnotationScreenshot(picked); + if (pickRequest.cancelled) return; // Main reports a crop that failed or timed out on its side; the local // conversion can fail too. Either way the user should hear about it. const cropDropped = screenshotFailed || capture.status === "failed"; @@ -632,20 +646,27 @@ export function PreviewView({ if (image) { addImage(threadRef, image); } - if (submission === "send") { + if ( + submission === "send" && + readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) + ) { onSendAnnotation?.(annotation, image); } } catch { // Picker failed (e.g. webview navigated). Treat as silent cancel. } finally { - pickActiveRef.current = false; + const isCurrentPick = pickActiveRef.current === pickRequest; // Avoid `setState on unmounted component` if the panel/thread closed // while the pick was in flight. - if (isMountedRef.current) setPickActive(false); + if (isCurrentPick) { + pickActiveRef.current = null; + if (isMountedRef.current) setPickActive(false); + } // Best-effort: restore focus to whatever the user had before the // pick stole it into the guest webContents. Skip if the previously- // focused element was unmounted or is no longer focusable. if ( + isCurrentPick && previouslyFocused && previouslyFocused.isConnected && typeof previouslyFocused.focus === "function" @@ -660,13 +681,21 @@ export function PreviewView({ })(); }, [addImage, addPreviewAnnotation, onSendAnnotation, runtimeTabId, threadRef]); + useEffect(() => { + if (!pickActive || !previewBridge || !runtimeTabId) return; + void previewBridge + .setAnnotationSendEnabled?.(runtimeTabId, canSendAnnotation) + .catch(() => undefined); + }, [canSendAnnotation, pickActive, runtimeTabId]); + // If the active tab changes mid-pick (close, thread switch, hot restart), // tell main to tear down the in-flight session AND reset our local toggle // state so the button doesn't get stuck pressed against a stale tab id. useEffect(() => { return () => { if (!pickActiveRef.current) return; - pickActiveRef.current = false; + pickActiveRef.current.cancelled = true; + pickActiveRef.current = null; if (previewBridge && runtimeTabId) { void previewBridge.cancelPickElement(runtimeTabId).catch(() => undefined); } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a14a4ed0a08a..2f8e4b1fb43e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1012,6 +1012,11 @@ export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ theme: DesktopPreviewAnnotationThemeSchema, }); +export const DesktopPreviewAnnotationSendEnabledInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + enabled: Schema.Boolean, +}); + export const DesktopPreviewArtifactInputSchema = Schema.Struct({ path: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()), }); @@ -1216,6 +1221,8 @@ export interface DesktopPreviewBridge { readonly targetProfileId: string; }) => Promise; setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise; + /** Keep an open annotation picker's send shortcut in sync with its thread grant. */ + setAnnotationSendEnabled: (tabId: string, enabled: boolean) => Promise; /** * Activate the in-page element picker for the given tab. Resolves with * the picked annotation and its attach/send intent, or `null` when the From 588ab448350b1816d4f186834a39a437ab16d871 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:53:11 -0700 Subject: [PATCH 6/7] test(desktop): type annotation permission acknowledgements --- apps/web/src/components/preview/PreviewView.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 6a25e40acdf6..2571b56b51cd 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -16,7 +16,7 @@ const mocks = vi.hoisted(() => ({ rememberPreviewUrl: vi.fn(), readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })), readEnvironmentScope: vi.fn(() => true), - setAnnotationSendEnabled: vi.fn(async () => undefined), + setAnnotationSendEnabled: vi.fn(async (): Promise => undefined), cancelPickElement: vi.fn(async () => undefined), submittedUrl: null as ((url: string) => void) | null, emptyStateUrl: null as ((url: string) => void) | null, From 93918981e1d2e791a1f02792e73293d029a45033 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 03:04:13 -0700 Subject: [PATCH 7/7] test(web): grant preview automation fixtures their required scope --- .../src/components/preview/PreviewAutomationHosts.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx index 75ed20cc4fe7..b310f3d63880 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx @@ -1,4 +1,5 @@ import { + AuthPreviewOperateScope, DEFAULT_CLIENT_SETTINGS, EnvironmentId, ThreadId, @@ -16,6 +17,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings"; import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; +import * as Session from "~/state/session"; import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; @@ -108,6 +110,10 @@ let renderer: ReactTestRenderer | null = null; beforeEach(async () => { vi.clearAllMocks(); + vi.spyOn(Session, "useEnvironmentScope").mockImplementation( + (targetEnvironmentId, scope) => + targetEnvironmentId === environmentId && scope === AuthPreviewOperateScope, + ); mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings); mocks.respond.mockReset(); __resetClientSettingsPersistenceForTests();