diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts new file mode 100644 index 000000000000..8764dc55fd21 --- /dev/null +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts @@ -0,0 +1,78 @@ +import { expect, it } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import * as Preview from "../../../preview/Manager.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { PreviewControlsHandlersLive } from "./handlers.ts"; +import { PreviewControlsToolkit } from "./tools.ts"; + +it.effect.each([ + { name: "project opt-in", globalAccess: false, projectAccess: true }, + { name: "project opt-out", globalAccess: true, projectAccess: false }, +])("list and close respect the credential for a $name", ({ globalAccess, projectAccess }) => + Effect.scoped( + Effect.gen(function* () { + const projectId = ProjectId.make("preview-controls-project"); + const threadId = ThreadId.make("preview-controls-thread"); + const settings = { + ...DEFAULT_SERVER_SETTINGS, + enableAgentBrowserAccess: globalAccess, + projectSettingsOverrides: { + [projectId]: { enableAgentBrowserAccess: projectAccess }, + }, + }; + const effective = resolveProjectSettings(settings, projectId).settings; + const scope: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("preview-controls-environment"), + threadId, + providerSessionId: "preview-controls-provider-session", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []), + issuedAt: 0, + }; + const manager = yield* Preview.make; + const tab = yield* manager.open({ threadId, url: "http://localhost:3000" }); + const dependencies = Layer.mergeAll( + Layer.succeed(Preview.PreviewManager, manager), + Layer.succeed(McpInvocationContext.McpInvocationContext, scope), + Layer.mock(ServerSettings.ServerSettingsService)({ + getSettings: Effect.succeed(settings), + }), + ); + const toolkit = yield* PreviewControlsToolkit.pipe( + Effect.provide(PreviewControlsHandlersLive.pipe(Layer.provide(dependencies))), + ); + const listed = yield* toolkit + .handle("t3_preview_list", {}) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + const closed = yield* toolkit + .handle("t3_preview_close", { tabId: tab.tabId }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + if (projectAccess) { + expect(listed.at(-1)?.result).toMatchObject({ sessions: [tab], nextCursor: null }); + expect(closed.at(-1)?.result).toEqual({}); + expect((yield* manager.list({ threadId })).sessions).toEqual([]); + } else { + for (const result of [listed, closed]) { + expect(result.at(-1)?.result).toMatchObject({ + _tag: "PreviewAutomationUnavailableError", + capability: "preview", + threadId, + }); + } + expect((yield* manager.list({ threadId })).sessions).toEqual([tab]); + } + }), + ), +); diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.ts index 8ab4ebcd2490..213a7437353a 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.ts @@ -1,20 +1,12 @@ -import { OrchestratorMcpFailure } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Preview from "../../../preview/Manager.ts"; -import * as ServerSettings from "../../../serverSettings.ts"; import { requireMcpCapability } from "../../McpInvocationContext.ts"; import { unavailable } from "../../threadAccess.ts"; import { PreviewControlsToolkit } from "./tools.ts"; const access = Effect.gen(function* () { + // The preview capability already reflects the calling project's access setting. const scope = yield* requireMcpCapability("preview"); - const settings = yield* ServerSettings.ServerSettingsService; - const current = yield* settings.getSettings.pipe(Effect.mapError(unavailable)); - if (!current.enableAgentBrowserAccess) - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: "Agent browser access is disabled.", - }); return { scope, manager: yield* Preview.PreviewManager }; }); export const PreviewControlsHandlersLive = PreviewControlsToolkit.toLayer({ diff --git a/apps/server/src/mcp/toolkits/previewControls/tools.ts b/apps/server/src/mcp/toolkits/previewControls/tools.ts index 7706ffde3145..aa0ed1378433 100644 --- a/apps/server/src/mcp/toolkits/previewControls/tools.ts +++ b/apps/server/src/mcp/toolkits/previewControls/tools.ts @@ -8,13 +8,12 @@ import { import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/unstable/ai"; import { PreviewManager } from "../../../preview/Manager.ts"; -import { ServerSettingsService } from "../../../serverSettings.ts"; import { McpInvocationContext } from "../../McpInvocationContext.ts"; const shared = { failure: Schema.Union([OrchestratorMcpFailure, PreviewAutomationUnavailableError]), failureMode: "return" as const, - dependencies: [McpInvocationContext, PreviewManager, ServerSettingsService], + dependencies: [McpInvocationContext, PreviewManager], }; const PreviewListTool = Tool.make("t3_preview_list", { ...shared, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3d62e3f492ed..2f855d3de86e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1835,7 +1835,6 @@ export default function ChatView(props: ChatViewProps) { const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const isMobileViewport = useMediaQuery("max-sm"); const [workspaceLayoutRef, workspaceLayoutWidth] = useElementWidth(); - const previewPanelInlineSize = usePreviewPanelInlineSize(); const threadPanelPopoverAnchorRef = useRef(null); // Tracks whether the user explicitly dismissed the sidebar for the active turn. // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -2119,6 +2118,10 @@ export default function ChatView(props: ChatViewProps) { [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, { + containerWidth: workspaceLayoutWidth ?? undefined, + widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`, + }); const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; @@ -10847,7 +10850,6 @@ export default function ChatView(props: ChatViewProps) { {rightPanelPresent && !shouldUsePlanSidebarSheet && activeThreadRef ? ( | undefined, enabled: boolean, + containerWidth?: number, ): number { const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth)); - const [containerWidth, setContainerWidth] = useState(undefined); + const [measuredContainerWidth, setContainerWidth] = useState(undefined); useEffect(() => { if (typeof window === "undefined") return; let frame = 0; @@ -86,7 +93,7 @@ function useViewportClampedMaxWidth( observer.disconnect(); }; }, [hostRef, enabled]); - return getPreviewPanelMaxWidth(vw, containerWidth); + return getPreviewPanelMaxWidth(vw, containerWidth ?? measuredContainerWidth); } export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number { const fractionCap = Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION);