Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions apps/server/src/mcp/toolkits/previewControls/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
}),
),
);
10 changes: 1 addition & 9 deletions apps/server/src/mcp/toolkits/previewControls/handlers.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
3 changes: 1 addition & 2 deletions apps/server/src/mcp/toolkits/previewControls/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>();
const previewPanelInlineSize = usePreviewPanelInlineSize();
const threadPanelPopoverAnchorRef = useRef<HTMLElement | null>(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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -10847,7 +10850,6 @@ export default function ChatView(props: ChatViewProps) {
{rightPanelPresent && !shouldUsePlanSidebarSheet && activeThreadRef ? (
<RightPanelTabs
mode="inline"
widthStorageKey={`t3code:preview-panel-width:${activeThreadKey}`}
open={rightPanelOpen}
maximized={rightPanelMaximized}
inlineSize={previewPanelInlineSize}
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/hooks/usePreviewPanelInlineSize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,15 @@ export function usePreviewPanelInlineSize(
readonly enabled?: boolean | undefined;
readonly widthStorageKey?: string | undefined;
readonly defaultWidth?: number | undefined;
/** Use the caller's existing row measurement instead of observing the panel's parent. */
readonly containerWidth?: number | undefined;
} = {},
): PreviewPanelInlineSize {
const maxWidth = useViewportClampedMaxWidth(hostRef, options.enabled ?? true);
const maxWidth = useViewportClampedMaxWidth(
hostRef,
options.enabled ?? true,
options.containerWidth,
);
return useResizableWidth({
storageKey: options.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY,
defaultWidth: options.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH,
Expand All @@ -48,9 +54,10 @@ export function usePreviewPanelInlineSize(
function useViewportClampedMaxWidth(
hostRef: RefObject<HTMLElement | null> | undefined,
enabled: boolean,
containerWidth?: number,
): number {
const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth));
const [containerWidth, setContainerWidth] = useState<number | undefined>(undefined);
const [measuredContainerWidth, setContainerWidth] = useState<number | undefined>(undefined);
useEffect(() => {
if (typeof window === "undefined") return;
let frame = 0;
Expand Down Expand Up @@ -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);
Expand Down
Loading