Skip to content
Closed
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
65 changes: 65 additions & 0 deletions apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import {
EnvironmentId,
EventId,
ProjectId,
PreviewTabId,
ProviderInstanceId,
ThreadId,
type PreviewAutomationSnapshot,
type PreviewAutomationStatus,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
Expand Down Expand Up @@ -189,6 +193,7 @@ const asSession = (providerSessionId: string) => ({
thread: { ...testThread, providerSessionId },
});
const dependencies = Layer.mergeAll(
ProjectionStore.layerMemory,
Broker.layer,
Manager.layer,
Layer.succeed(ServerEnvironment.ServerEnvironment, {
Expand Down Expand Up @@ -1539,6 +1544,66 @@ it.live(
).pipe(Effect.provide(layer)),
);

it.live("agent opens honor project defaults and fall back for stale or incognito overrides", () =>
Effect.scoped(
Effect.gen(function* () {
const browser = yield* ServerBrowser.ServerBrowser;
const broker = yield* Broker.PreviewAutomationBroker;
const manager = yield* Manager.PreviewManager;
const store = yield* ProjectionStore.ProjectionStoreV2;
const now = yield* DateTime.now;
const threadId = scope.thread.threadId;
const projectId = ProjectId.make("browser-project");
yield* store.apply({
id: EventId.make("browser-thread-created"),
threadId,
occurredAt: now,
type: "thread.created",
payload: {
id: threadId,
projectId,
title: "Browser",
providerInstanceId: testThread.providerInstanceId,
modelSelection: { instanceId: testThread.providerInstanceId, model: "gpt-6" },
createdBy: "user",
creationSource: "web",
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
activeProviderThreadId: null,
lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
forkedFrom: null,
createdAt: now,
updatedAt: now,
archivedAt: null,
settledOverride: null,
settledAt: null,
lastVisitedAt: null,
deletedAt: null,
},
});
yield* Effect.yieldNow;
for (const override of ["profile-work", "deleted", "incognito", undefined]) {
yield* browser.reportProfiles({
profiles: [{ id: "profile-work", name: "Work", kind: "persistent" }],
defaultProfileId: "default",
projectProfileIds: override === undefined ? {} : { [projectId]: override },
});
const opened = yield* broker.invoke<PreviewAutomationStatus>({
scope,
operation: "open",
input: { reuseExistingTab: false, show: false },
});
const { sessions } = yield* manager.list({ threadId });
expect(sessions.find((session) => session.tabId === opened.tabId)?.profileId).toBe(
override === "profile-work" ? "profile-work" : "default",
);
}
}),
).pipe(Effect.provide(layer)),
);

it.live("an agent tab keeps throwaway storage when no client reported profiles", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
27 changes: 23 additions & 4 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import { resolveRootCliCommand } from "../cli/invocation.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as PreviewAutomationBroker from "../mcp/PreviewAutomationBroker.ts";
import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts";
import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
import * as PreviewManager from "./Manager.ts";
import * as ServerBrowserPage from "./ServerBrowserPage.ts";
import * as PreviewBrowser from "./PreviewBrowser.ts";
Expand Down Expand Up @@ -477,6 +478,7 @@ const CLIPBOARD_SCRIPT = `(() => {
const make = Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const host = { platform: yield* HostProcess.Platform, arch: yield* HostProcess.Architecture };
const projections = yield* ProjectionStore.ProjectionStoreV2;
const manager = yield* PreviewManager.PreviewManager;
const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
const environment = yield* ServerEnvironment.ServerEnvironment;
Expand Down Expand Up @@ -1271,13 +1273,15 @@ const make = Effect.gen(function* () {
let reportedProfiles: {
readonly profiles: ReadonlyArray<BrowserProfile>;
readonly defaultProfileId: string;
readonly projectProfileIds: ReadonlyMap<string, string>;
} | null = null;

const reportProfiles: ServerBrowser["Service"]["reportProfiles"] = (input) =>
Effect.sync(() => {
const profiles = resolveBrowserProfiles(input.profiles);
reportedProfiles = {
profiles,
projectProfileIds: new Map(Object.entries(input.projectProfileIds ?? {})),
defaultProfileId:
findBrowserProfile(profiles, input.defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID,
};
Expand All @@ -1295,9 +1299,22 @@ const make = Effect.gen(function* () {
};
};

/** Matches an id, then a case-insensitive name. Omitted means the reported default. */
const resolveOpenProfile = (requested: string | undefined): string | undefined => {
if (requested === undefined) return reportedProfiles?.defaultProfileId;
/** Matches an id, then a name. Omitted uses the project override or reported default. */
const resolveOpenProfile = async (
requested: string | undefined,
threadId: ThreadId,
): Promise<string | undefined> => {
if (requested === undefined) {
const report = reportedProfiles;
if (report && report.projectProfileIds.size > 0) {
const thread = await Effect.runPromise(projections.getThreadShell(threadId));
const profileId = thread ? report.projectProfileIds.get(thread.projectId) : undefined;
const profile =
profileId === undefined ? undefined : findBrowserProfile(report.profiles, profileId);
if (profile && profile.kind !== "incognito") return profile.id;
}
return report?.defaultProfileId;
}
const profiles = reportedProfiles?.profiles ?? BUILT_IN_BROWSER_PROFILES;
const wanted = requested.toLowerCase();
const match =
Expand Down Expand Up @@ -1732,7 +1749,9 @@ const make = Effect.gen(function* () {
? found
: undefined;
const navigationTimeout = Math.min(request.timeoutMs, NAVIGATION_TIMEOUT_MS);
const profileId = existing ? undefined : resolveOpenProfile(open.profileId);
const profileId = existing
? undefined
: await resolveOpenProfile(open.profileId, request.threadId);
if (!existing) {
closeIdleAgentTabs();
assertTabCapacity(request.agentSessionId);
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,12 @@ const layerMakeRoutes = Layer.mergeAll(
// after the guide service so it reaches that too.
Layer.provide(layerPullRequestService),
// The stream route and the WebSocket RPCs share one browser.
Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))),
Layer.provide(
ServerBrowser.layer.pipe(
Layer.provide(DesktopBrowserChannel.layer),
Layer.provide(ProjectionStoreV2.layer),
),
),
// Server browser tabs and HTML render previews install and run the same headless browser.
Layer.provide(PreviewBrowser.layer),
Layer.provide(PreviewAutomationBroker.layer),
Expand Down
35 changes: 32 additions & 3 deletions apps/web/src/browser/BrowserProfileReporter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ import { AuthPreviewOperateScope, type BrowserProfile } from "@t3tools/contracts
import { useEffect } from "react";

import { useClientSettings, useClientSettingsHydrated } from "~/hooks/useSettings";
import { useServerConfigs } from "~/state/entities";
import { useProjects, useServerConfigs } from "~/state/entities";
import { useEnvironments } from "~/state/environments";
import { previewEnvironment } from "~/state/preview";
import { useEnvironmentsWithScope } from "~/state/session";
import { derivePhysicalProjectKey } from "~/logicalProject";
import { useAtomCommand } from "~/state/use-atom-command";

const selectProfiles = (settings: { readonly browserProfiles: ReadonlyArray<BrowserProfile> }) =>
settings.browserProfiles;
const selectProjectProfileIds = (settings: {
readonly browserProjectProfileIds: Readonly<Record<string, string>>;
}) => settings.browserProjectProfileIds;
const selectDefaultProfileId = (settings: { readonly browserDefaultProfileId: string }) =>
settings.browserDefaultProfileId;

Expand All @@ -23,6 +27,8 @@ export function BrowserProfileReporter() {
const hydrated = useClientSettingsHydrated();
const profiles = useClientSettings(selectProfiles);
const defaultProfileId = useClientSettings(selectDefaultProfileId);
const projectProfileIds = useClientSettings(selectProjectProfileIds);
const projects = useProjects();
const { environments } = useEnvironments();
const serverConfigs = useServerConfigs();
const operable = useEnvironmentsWithScope(environments, AuthPreviewOperateScope);
Expand All @@ -32,9 +38,32 @@ export function BrowserProfileReporter() {
if (!hydrated) return;
for (const [environmentId, config] of serverConfigs) {
if (!config.environment.capabilities.serverBrowser || !operable.has(environmentId)) continue;
void report({ environmentId, input: { profiles, defaultProfileId } });
void report({
environmentId,
input: {
profiles,
defaultProfileId,
projectProfileIds: Object.fromEntries(
projects.flatMap((project) => {
const profileId = projectProfileIds[derivePhysicalProjectKey(project)];
return project.environmentId === environmentId && profileId !== undefined
? [[project.id, profileId] as const]
: [];
}),
),
},
});
}
}, [defaultProfileId, hydrated, operable, profiles, report, serverConfigs]);
}, [
defaultProfileId,
hydrated,
operable,
profiles,
projectProfileIds,
projects,
report,
serverConfigs,
]);

return null;
}
89 changes: 84 additions & 5 deletions apps/web/src/browser/browserDefaults.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import {
DEFAULT_BROWSER_PROFILE_ID,
INCOGNITO_BROWSER_PROFILE_ID,
type ScopedThreadRef,
} from "@t3tools/contracts";

import { ensureClientSettingsHydrated } from "~/hooks/useSettings";

Expand All @@ -11,16 +15,52 @@ vi.mock("~/hooks/useSettings", () => ({
ensureClientSettingsHydrated: vi.fn(async () => undefined),
}));

const { getBrowserDefaults, resolveBrowserDefaults } = await import("./browserDefaults");
const entities = vi.hoisted(() => ({
threadProjectId: null as string | null,
draftProjectId: null as string | null,
}));

vi.mock("~/state/entities", () => ({
readThreadShell: () =>
entities.threadProjectId === null ? null : { projectId: entities.threadProjectId },
readProject: ({ projectId }: { projectId: string }) => ({
environmentId: "local",
workspaceRoot: `/work/${projectId}`,
}),
}));

vi.mock("~/composerDraftStore", () => ({
useComposerDraftStore: {
getState: () => ({
getDraftThreadByRef: () =>
entities.draftProjectId === null ? null : { projectId: entities.draftProjectId },
}),
},
}));

const withDefaultProfile = (browserDefaultProfileId: string) => {
const { browserDefaultOpenProfileId, getBrowserDefaults, resolveBrowserDefaults } =
await import("./browserDefaults");

const threadRef = {
environmentId: "local" as ScopedThreadRef["environmentId"],
threadId: "thread-1" as ScopedThreadRef["threadId"],
};

const withDefaultProfile = (
browserDefaultProfileId: string,
browserProjectProfileIds: Record<string, string> = {},
) => {
settings.current = {
browserDefaultViewport: { _tag: "fill" },
browserDefaultZoomFactor: 1,
browserDefaultAppearance: "system",
browserAutoShowFloatingPreview: true,
browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
browserProfiles: [
{ id: "work", name: "Work", kind: "persistent" },
{ id: "personal", name: "Personal", kind: "persistent" },
],
browserDefaultProfileId,
browserProjectProfileIds,
};
return getBrowserDefaults();
};
Expand All @@ -44,6 +84,45 @@ describe("getBrowserDefaults profile resolution", () => {
});
});

describe("browserDefaultOpenProfileId", () => {
beforeEach(() => {
entities.threadProjectId = null;
entities.draftProjectId = null;
});

it("uses the thread's project profile over the global default", () => {
entities.threadProjectId = "client";
const defaults = withDefaultProfile("personal", { "local:/work/client": "work" });
expect(browserDefaultOpenProfileId(threadRef, defaults)).toBe("work");
});

it("uses the project profile for a draft thread", () => {
entities.draftProjectId = "client";
const defaults = withDefaultProfile("personal", { "local:/work/client": "work" });
expect(browserDefaultOpenProfileId(threadRef, defaults)).toBe("work");
});

it("falls back to the global default for other projects and unusable profiles", () => {
entities.threadProjectId = "other";
expect(
browserDefaultOpenProfileId(
threadRef,
withDefaultProfile("personal", { "local:/work/client": "work" }),
),
).toBe("personal");

entities.threadProjectId = "client";
for (const profileId of ["deleted", INCOGNITO_BROWSER_PROFILE_ID]) {
expect(
browserDefaultOpenProfileId(
threadRef,
withDefaultProfile("personal", { "local:/work/client": profileId }),
),
).toBe("personal");
}
});
});

describe("resolveBrowserDefaults", () => {
it("rejects failed reads and uses the saved profile after a successful retry", async () => {
withDefaultProfile("work");
Expand Down
Loading
Loading