# Files changed · PR \#7254 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/7254/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(desktop\): browser profiles for the preview browser](https://git.cafe/pingdotgg/t3code/pull/7254.md)

Version: 57

Head commit: 1a5aa8592169f1da2a1db4aaf9472887eb70b4a9

Base commit: 28ddaf75917140e5e4355d4386bc5d14d9dad7b6

Comparison base: 28ddaf75917140e5e4355d4386bc5d14d9dad7b6

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/desktop/src/ipc/methods/preview.test.ts

Status: modified; +43; −1

```diff
@@ -1,5 +1,9 @@
 import { it as effectIt } from "@effect/vitest";
+import {
+  DEFAULT_BROWSER_PROFILE_ID,
-import { PreviewAutomationStatus } from "@t3tools/contracts";
+  INCOGNITO_BROWSER_PROFILE_ID,
+  PreviewAutomationStatus,
+} from "@t3tools/contracts";
 import * as Cause from "effect/Cause";
 import * as Effect from "effect/Effect";
 import * as Exit from "effect/Exit";
@@ -38,6 +42,44 @@
     expect(fromPartition).not.toHaveBeenCalled();
   });
 
+  it("derives distinct partition scopes when identifiers contain the delimiter", () => {
+    const first = PreviewIpc.resolvePartitionScope("a", "b::c");
+    const second = PreviewIpc.resolvePartitionScope("a::b", "c");
+
+    expect(first).toEqual({ scope: '["a","b::c"]', persistent: true, namespace: "profile" });
+    expect(second).toEqual({ scope: '["a::b","c"]', persistent: true, namespace: "profile" });
+    expect(first.scope).not.toBe(second.scope);
+  });
+
+  it("preserves lone surrogates without collapsing them to replacement characters", () => {
+    const highSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\ud800");
+    const lowSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\udc00");
+    const replacement = PreviewIpc.resolvePartitionScope("environment", "profile-�");
+
+    expect(highSurrogate.scope).toBe('["environment","profile-\\ud800"]');
+    expect(lowSurrogate.scope).toBe('["environment","profile-\\udc00"]');
+    expect(highSurrogate.scope).not.toBe(lowSurrogate.scope);
+    expect(highSurrogate.scope).not.toBe(replacement.scope);
+    expect(lowSurrogate.scope).not.toBe(replacement.scope);
+  });
+
+  it("keeps the legacy default partition scope and incognito persistence", () => {
+    expect(PreviewIpc.resolvePartitionScope("environment::legacy", undefined)).toEqual({
+      scope: "environment::legacy",
+      persistent: true,
+    });
+    expect(
+      PreviewIpc.resolvePartitionScope("environment::legacy", DEFAULT_BROWSER_PROFILE_ID),
+    ).toEqual({ scope: "environment::legacy", persistent: true });
+    expect(
+      PreviewIpc.resolvePartitionScope("environment::legacy", INCOGNITO_BROWSER_PROFILE_ID),
+    ).toEqual({
+      scope: '["environment::legacy","incognito"]',
+      persistent: false,
+      namespace: "profile",
+    });
+  });
+
   effectIt.effect("rejects invalid webContents ids before resolving the preview service", () =>
     Effect.map(
       PreviewIpc.registerWebview
```

## apps/desktop/src/ipc/methods/preview.ts

Status: modified; +64; −9

```diff
@@ -16,11 +16,14 @@
   DesktopPreviewScreenshotArtifactSchema,
   DesktopPreviewSetAudioMutedInputSchema,
   DesktopPreviewSetColorSchemeInputSchema,
+  DesktopPreviewClearDataInputSchema,
   DesktopPreviewCreateTabInputSchema,
   DesktopPreviewTabInputSchema,
   DesktopPreviewWebviewConfigSchema,
   PreviewAnnotationSubmissionResultSchema,
   PreviewAutomationSnapshot,
+  DEFAULT_BROWSER_PROFILE_ID,
+  INCOGNITO_BROWSER_PROFILE_ID,
 } from "@t3tools/contracts";
 import * as Effect from "effect/Effect";
 import * as Schema from "effect/Schema";
@@ -196,33 +199,85 @@
 
 export const clearCookies = DesktopIpc.makeIpcMethod({
   channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL,
-  payload: Schema.Void,
+  payload: DesktopPreviewClearDataInputSchema,
   result: Schema.Void,
-  handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () {
+  handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* ({ environmentId, profileId }) {
     const manager = yield* PreviewManager.PreviewManager;
+    yield* manager.clearCookies(yield* resolveClearPartitions(manager, environmentId, profileId));
-    yield* manager.clearCookies();
   }),
 });
 
 export const clearCache = DesktopIpc.makeIpcMethod({
   channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL,
+  payload: DesktopPreviewClearDataInputSchema,
-  payload: Schema.Void,
   result: Schema.Void,
+  handler: Effect.fn("desktop.ipc.preview.clearCache")(function* ({ environmentId, profileId }) {
-  handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () {
     const manager = yield* PreviewManager.PreviewManager;
-    yield* manager.clearCache();
+    yield* manager.clearCache(yield* resolveClearPartitions(manager, environmentId, profileId));
   }),
 });
 
+/**
+ * Partition scope for an (environment, profile) pair.
+ *
+ * The default profile keeps the bare environment id it used before profiles
+ * existed, so upgrading does not strand anyone's existing logins in an
+ * orphaned partition. Incognito derives a non-persistent partition.
+ */
+export function resolvePartitionScope(
+  environmentId: string,
+  profileId: string | undefined,
+): {
+  readonly scope: string;
+  readonly persistent: boolean;
+  readonly namespace?: "profile";
+} {
+  if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) {
+    return { scope: environmentId, persistent: true };
+  }
+  // JSON's tuple framing is injective for strings, including lone UTF-16
+  // surrogates (which it escapes). URI encoding throws on those supported ids,
+  // while replacing them with U+FFFD would collapse distinct identities.
+  return {
+    scope: JSON.stringify([environmentId, profileId]),
+    persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID,
+    namespace: "profile" as const,
+  };
+}
+
+/**
+ * Clearing without a profile keeps the historical "everything" behaviour for
+ * an explicit all-profiles action; naming a profile confines it to that
+ * profile's partition so one profile's sign-out cannot reach the others.
+ */
+const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartitions")(function* (
+  manager: PreviewManager.PreviewManager["Service"],
+  environmentId: string,
+  profileId: string | undefined,
+) {
+  if (profileId === undefined) return undefined;
+  const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId);
+  // Loading the session is what puts the partition in the map the clear walks.
+  // Deriving the partition string alone leaves nothing to match, so clearing a
+  // profile with no tab open this run — after a restart, or when deleting a
+  // profile — would report success and delete nothing.
+  yield* manager.getBrowserSession(scope, persistent, namespace);
+  return [yield* manager.getBrowserPartition(scope, persistent, namespace)];
+});
+
 export const getPreviewConfig = DesktopIpc.makeIpcMethod({
   channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL,
   payload: DesktopPreviewConfigInputSchema,
   result: DesktopPreviewWebviewConfigSchema,
-  handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId }) {
+  handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId, profileId }) {
     const manager = yield* PreviewManager.PreviewManager;
+    const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId);
+    // Creating the session first is what installs the UA rewrite and permission
+    // handlers; a guest that attached to an untouched partition would run with
+    // Electron's default UA and Chromium's default permission behaviour.
-    yield* manager.getBrowserSession(environmentId);
+    yield* manager.getBrowserSession(scope, persistent, namespace);
     return {
-      partition: yield* manager.getBrowserPartition(environmentId),
+      partition: yield* manager.getBrowserPartition(scope, persistent, namespace),
       webPreferences: PREVIEW_WEBVIEW_PREFERENCES,
       preloadUrl: NodeURL.pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href,
     };
```

## apps/desktop/src/preload.ts

Status: modified; +6; −4

```diff
@@ -222,10 +222,12 @@
       ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }),
     openDevTools: (tabId) =>
       ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
-    clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),
+    clearCookies: (environmentId, profileId) =>
-    clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL),
-    getPreviewConfig: (environmentId) =>
-      ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }),
+      ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }),
+    clearCache: (environmentId, profileId) =>
+      ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, { environmentId, profileId }),
+    getPreviewConfig: (environmentId, profileId) =>
+      ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId, profileId }),
     setAnnotationTheme: (theme) =>
       ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }),
     pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),
```

## apps/desktop/src/preview/BrowserSession.test.ts

Status: modified; +61; −0

```diff
@@ -62,7 +62,46 @@
       assert.strictEqual(fromPartition.mock.calls.length, 1);
     }).pipe(Effect.provide(layer)),
   );
+
+  it.effect("keeps scopes that differ only by a lone surrogate in separate partitions", () =>
+    Effect.gen(function* () {
+      const browserSessions = yield* BrowserSession.BrowserSession;
 
+      // TextEncoder folds a lone surrogate to U+FFFD, so without escaping these
+      // two supported ids would hash to one partition and share every cookie.
+      const loneSurrogate = yield* browserSessions.getPartition("p\ud800");
+      const replacementChar = yield* browserSessions.getPartition("p\ufffd");
+      assert.notStrictEqual(loneSurrogate, replacementChar);
+
+      // The escape can't be forged with a literal backslash either.
+      const literal = yield* browserSessions.getPartition("p\\ud800");
+      assert.notStrictEqual(literal, loneSurrogate);
+
+      // And a well-formed scope still lands on its historical partition.
+      assert.strictEqual(
+        yield* browserSessions.getPartition("scope-a"),
+        "persist:t3code-preview-f051bb2c68cb7b2fe969",
+      );
+    }).pipe(Effect.provide(layer)),
+  );
+
+  it.effect("keeps legacy defaults disjoint from nondefault profile partitions", () =>
+    Effect.gen(function* () {
+      const browserSessions = yield* BrowserSession.BrowserSession;
+
+      // These share the same scope string: default environment `a::b`, and
+      // environment `a` with nondefault profile `b`.
+      const legacyDefault = yield* browserSessions.getPartition("a::b");
+      const nondefaultProfile = yield* browserSessions.getPartition("a::b", true, "profile");
+
+      assert.strictEqual(legacyDefault, "persist:t3code-preview-78f0be89237d77f7a70e");
+      assert.strictEqual(nondefaultProfile, "persist:t3code-preview-profile-78f0be89237d77f7a70e");
+      assert.notStrictEqual(nondefaultProfile, legacyDefault);
+      assert.isTrue(browserSessions.isPartition(legacyDefault));
+      assert.isTrue(browserSessions.isPartition(nondefaultProfile));
+    }).pipe(Effect.provide(layer)),
+  );
+
   it.effect("grants clipboard-sanitized-write through both the request and check handlers", () =>
     Effect.gen(function* () {
       const browserSessions = yield* BrowserSession.BrowserSession;
@@ -192,6 +231,28 @@
     }).pipe(Effect.provide(layer)),
   );
 
+  it.effect("clears a partition whose session has not been opened yet", () =>
+    Effect.gen(function* () {
+      const browserSessions = yield* BrowserSession.BrowserSession;
+      const partition = yield* browserSessions.getPartition("scope-untouched");
+
+      // Deriving the partition string does not create the session, and the
+      // clear only walks sessions it already holds. Without loading it first
+      // this reports success and deletes nothing — which is what a user
+      // clearing a profile after a restart would get.
+      assert.isUndefined(sessions.get(partition));
+      yield* browserSessions.clearCookies([partition]);
+      assert.isUndefined(sessions.get(partition));
+
+      yield* browserSessions.getSession("scope-untouched");
+      yield* browserSessions.clearCookies([partition]);
+
+      const created = sessions.get(partition);
+      assert.isDefined(created);
+      assert.strictEqual(created.clearStorageData.mock.calls.length, 1);
+    }).pipe(Effect.provide(layer)),
+  );
+
   it.effect("correlates clear failures while still attempting every session", () =>
     Effect.gen(function* () {
       const browserSessions = yield* BrowserSession.BrowserSession;
```

## apps/desktop/src/preview/BrowserSession.ts

Status: modified; +81; −13

```diff
@@ -10,7 +10,17 @@
 import * as SynchronizedRef from "effect/SynchronizedRef";
 
 const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
+/**
+ * Incognito partitions deliberately omit the `persist:` prefix, which is what
+ * makes Chromium keep them in memory and discard them with the process. They
+ * still carry the product prefix so `isPartition` can admit them — the
+ * `will-attach-webview` gate rejects anything it does not recognise.
+ */
+const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-";
+const PROFILE_PARTITION_MARKER = "profile-";
 
+export type BrowserSessionPartitionNamespace = "profile";
+
 // Permissions granted to preview web content. `clipboard-sanitized-write` is the
 // Electron permission behind `navigator.clipboard.writeText()` — note it is NOT
 // `clipboard-write`, which is not a valid Electron permission name. Async
@@ -99,20 +109,68 @@
   {
     readonly getPartition: (
       scope?: string,
+      persistent?: boolean,
+      namespace?: BrowserSessionPartitionNamespace,
     ) => Effect.Effect<string, BrowserSessionPartitionDerivationError>;
     readonly isPartition: (partition: string) => boolean;
+    readonly getSession: (
+      scope?: string,
+      persistent?: boolean,
+      namespace?: BrowserSessionPartitionNamespace,
+    ) => Effect.Effect<Session, BrowserSessionGetSessionError>;
+    /** Omit `partitions` to clear every known partition. */
+    readonly clearCookies: (
+      partitions?: ReadonlyArray<string>,
+    ) => Effect.Effect<void, BrowserSessionStorageClearError>;
+    readonly clearCache: (
+      partitions?: ReadonlyArray<string>,
+    ) => Effect.Effect<void, BrowserSessionCacheClearError>;
-    readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionGetSessionError>;
-    readonly clearCookies: () => Effect.Effect<void, BrowserSessionStorageClearError>;
-    readonly clearCache: () => Effect.Effect<void, BrowserSessionCacheClearError>;
   }
 >()("@t3tools/desktop/preview/BrowserSession") {}
 
+/**
+ * Restricts a clear to the given partitions. Omitting them keeps the historical
+ * "every partition" behaviour, which callers now only use for an explicit
+ * "all profiles" action — a per-profile clear must never reach across profiles.
+ */
+const selectSessions = (
+  sessions: ReadonlyMap<string, Session>,
+  partitions: ReadonlyArray<string> | undefined,
+): ReadonlyArray<readonly [string, Session]> =>
+  [...sessions.entries()].filter(
+    ([partition]) => partitions === undefined || partitions.includes(partition),
+  );
+
+/**
+ * Scope bytes for the partition digest.
+ *
+ * `TextEncoder` replaces a lone UTF-16 surrogate with U+FFFD, so `"p\ud800"`
+ * and `"p\ufffd"` would hash to the same partition and share cookies. Those
+ * are distinct, supported ids, so lone surrogates are escaped to `\uXXXX`
+ * first — and a literal backslash is doubled so the escape cannot be forged.
+ * Every well-formed scope passes through byte-for-byte unchanged, which keeps
+ * existing partitions (and the logins in them) where they are.
+ */
+const encodeScopeForDigest = (scope: string): Uint8Array =>
+  new TextEncoder().encode(
+    scope
+      .replace(/\\/g, "\\\\")
+      .replace(
+        /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g,
+        (unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`,
+      ),
+  );
+
 export const make = Effect.gen(function* BrowserSessionMake() {
   const crypto = yield* Crypto.Crypto;
   const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());
 
-  const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") {
-    const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe(
+  const getPartition = Effect.fn("BrowserSession.getPartition")(function* (
+    scope = "shared",
+    persistent = true,
+    namespace?: BrowserSessionPartitionNamespace,
+  ) {
+    const digest = yield* crypto.digest("SHA-256", encodeScopeForDigest(scope)).pipe(
       Effect.mapError(
         (cause) =>
           new BrowserSessionPartitionDerivationError({
@@ -121,11 +179,19 @@
           }),
       ),
     );
+    const prefix = persistent ? PREVIEW_PARTITION_PREFIX : PREVIEW_EPHEMERAL_PARTITION_PREFIX;
+    // Legacy/default partitions are prefix + hex digest. The non-hex profile
+    // marker creates a disjoint namespace while leaving every legacy default
+    // partition byte-for-byte unchanged.
+    return `${prefix}${namespace === "profile" ? PROFILE_PARTITION_MARKER : ""}${Encoding.encodeHex(digest).slice(0, 20)}`;
-    return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`;
   });
 
-  const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") {
+  const getSession = Effect.fn("BrowserSession.getSession")(function* (
-    const partition = yield* getPartition(scope);
+    scope = "shared",
+    persistent = true,
+    namespace?: BrowserSessionPartitionNamespace,
+  ) {
+    const partition = yield* getPartition(scope, persistent, namespace);
     return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => {
       const existing = sessions.get(partition);
       if (existing) return Effect.succeed([existing, sessions] as const);
@@ -159,12 +225,14 @@
 
   return BrowserSession.of({
     getPartition,
+    isPartition: (partition) =>
+      partition.startsWith(PREVIEW_PARTITION_PREFIX) ||
+      partition.startsWith(PREVIEW_EPHEMERAL_PARTITION_PREFIX),
-    isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX),
     getSession,
-    clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () {
+    clearCookies: Effect.fn("BrowserSession.clearCookies")(function* (partitions?) {
       const sessions = yield* SynchronizedRef.get(sessionsRef);
       yield* Effect.all(
+        selectSessions(sessions, partitions).map(([partition, browserSession]) =>
-        [...sessions.entries()].map(([partition, browserSession]) =>
           Effect.tryPromise({
             try: () =>
               browserSession.clearStorageData({
@@ -180,10 +248,10 @@
         { concurrency: "unbounded", discard: true },
       );
     }),
+    clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) {
-    clearCache: Effect.fn("BrowserSession.clearCache")(function* () {
       const sessions = yield* SynchronizedRef.get(sessionsRef);
       yield* Effect.all(
-        [...sessions.entries()].map(([partition, browserSession]) =>
+        selectSessions(sessions, partitions).map(([partition, browserSession]) =>
           Effect.tryPromise({
             try: () => browserSession.clearCache(),
             catch: (cause) =>
```

## apps/desktop/src/preview/Manager.ts

Status: modified; +42; −26

```diff
@@ -4409,7 +4409,11 @@
   PreviewManager,
   {
     readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>;
+    readonly getBrowserSession: (
+      scope?: string,
+      persistent?: boolean,
+      namespace?: BrowserSession.BrowserSessionPartitionNamespace,
+    ) => Effect.Effect<Session, PreviewManagerError>;
-    readonly getBrowserSession: (scope?: string) => Effect.Effect<Session, PreviewManagerError>;
     readonly isBrowserPartition: (partition: string) => boolean;
     readonly createTab: (
       tabId: string,
@@ -4440,9 +4444,17 @@
       audioMuted: boolean,
     ) => Effect.Effect<void, PreviewManagerError>;
     readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
+    readonly clearCookies: (
+      partitions?: ReadonlyArray<string>,
+    ) => Effect.Effect<void, PreviewManagerError>;
-    readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;
-    readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;
-    readonly getBrowserPartition: (scope?: string) => Effect.Effect<string, PreviewManagerError>;
+    readonly clearCache: (
+      partitions?: ReadonlyArray<string>,
+    ) => Effect.Effect<void, PreviewManagerError>;
+    readonly getBrowserPartition: (
+      scope?: string,
+      persistent?: boolean,
+      namespace?: BrowserSession.BrowserSessionPartitionNamespace,
+    ) => Effect.Effect<string, PreviewManagerError>;
     readonly setAnnotationTheme: (
       theme: DesktopPreviewAnnotationTheme,
     ) => Effect.Effect<void, PreviewManagerError>;
@@ -4514,15 +4526,17 @@
 
   return PreviewManager.of({
     setMainWindow: operations.setMainWindow,
+    getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(
+      function* (scope, persistent, namespace) {
-    getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) {
+        return yield* browserSession
+          .getSession(scope, persistent, namespace)
+          .pipe(
-      return yield* browserSession
-        .getSession(scope)
-        .pipe(
-          Effect.mapError(
-            (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),
-          ),
-        );
-    }),
+            Effect.mapError(
+              (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),
+            ),
+          );
+      },
+    ),
     isBrowserPartition: browserSession.isPartition,
     createTab: operations.createTab,
     closeTab: operations.closeTab,
@@ -4539,31 +4553,33 @@
     setColorScheme: operations.setColorScheme,
     setAudioMuted: operations.setAudioMuted,
     openDevTools: operations.openDevTools,
-    clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {
+    clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) {
       yield* browserSession
+        .clearCookies(partitions)
-        .clearCookies()
         .pipe(
           Effect.mapError(
             (cause) => new PreviewOperationError({ operation: "clearCookies", cause }),
           ),
         );
     }),
-    clearCache: Effect.fn("PreviewManager.clearCache")(function* () {
+    clearCache: Effect.fn("PreviewManager.clearCache")(function* (partitions) {
       yield* browserSession
-        .clearCache()
+        .clearCache(partitions)
         .pipe(
           Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })),
         );
     }),
-    getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) {
-      return yield* browserSession
-        .getPartition(scope)
+    getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(
+      function* (scope, persistent, namespace) {
+        return yield* browserSession
+          .getPartition(scope, persistent, namespace)
+          .pipe(
+            Effect.mapError(
+              (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }),
+            ),
+          );
-        .pipe(
-          Effect.mapError(
-            (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }),
-          ),
-        );
-    }),
+      },
+    ),
     setAnnotationTheme: operations.setAnnotationTheme,
     pickElement: operations.pickElement,
     cancelPickElement: operations.cancelPickElement,
```

## apps/desktop/src/settings/DesktopClientSettings.test.ts

Status: modified; +2; −0

```diff
@@ -19,6 +19,8 @@
   browserDefaultAppearance: "dark",
   browserRecordingFrameRate: 60,
   browserAutoShowFloatingPreview: false,
+  browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
+  browserDefaultProfileId: "work",
   confirmQuit: "double-click",
   confirmThreadArchive: true,
   confirmThreadDelete: false,
```

## apps/server/src/preview/Manager.test.ts

Status: modified; +31; −0

```diff
@@ -57,7 +57,38 @@
       }
     }),
   );
+
+  it.effect("keeps the tab's profile across navigation and status reports", () =>
+    Effect.gen(function* () {
+      const threadId = freshThreadId();
+      const manager = yield* PreviewManager.PreviewManager;
+
+      const opened = yield* manager.open({ threadId, profileId: "work" });
+      expect(opened.profileId).toBe("work");
 
+      // `navigate` and `reportStatus` rebuild the snapshot field by field
+      // rather than spreading it, so a new field is dropped unless carried
+      // explicitly — which would silently move the tab to another profile's
+      // partition on its first navigation.
+      const navigated = yield* manager.navigate({
+        threadId,
+        tabId: opened.tabId,
+        url: "localhost:5173",
+      });
+      expect(navigated.profileId).toBe("work");
+
+      yield* manager.reportStatus({
+        threadId,
+        tabId: opened.tabId,
+        navStatus: { _tag: "Success", url: "http://localhost:5173/", title: "Dev" },
+        canGoBack: true,
+        canGoForward: false,
+      });
+      const listed = yield* manager.list({ threadId });
+      expect(listed.sessions.find((s) => s.tabId === opened.tabId)?.profileId).toBe("work");
+    }),
+  );
+
   it.effect("opens an Idle tab when no URL is supplied", () =>
     Effect.gen(function* () {
       const threadId = freshThreadId();
```

## apps/server/src/preview/Manager.ts

Status: modified; +18; −1

```diff
@@ -123,6 +123,7 @@
   readonly url: string;
   readonly title: string;
   readonly viewport: PreviewViewportSetting;
+  readonly profileId?: string | undefined;
   readonly updatedAt: string;
 }): PreviewSessionSnapshot => ({
   threadId: input.threadId,
@@ -131,6 +132,7 @@
   canGoBack: false,
   canGoForward: false,
   viewport: input.viewport,
+  ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
   updatedAt: input.updatedAt,
 });
 
@@ -138,6 +140,7 @@
   readonly threadId: string;
   readonly tabId: string;
   readonly viewport: PreviewViewportSetting;
+  readonly profileId?: string | undefined;
   readonly updatedAt: string;
 }): PreviewSessionSnapshot => ({
   threadId: input.threadId,
@@ -146,6 +149,7 @@
   canGoBack: false,
   canGoForward: false,
   viewport: input.viewport,
+  ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
   updatedAt: input.updatedAt,
 });
 
@@ -229,9 +233,16 @@
             url: yield* normalizeUrl(input.url),
             title: "",
             viewport,
+            profileId: input.profileId,
             updatedAt,
           })
+        : buildIdleSnapshot({
+            threadId: input.threadId,
+            tabId,
+            viewport,
+            profileId: input.profileId,
+            updatedAt,
+          });
-        : buildIdleSnapshot({ threadId: input.threadId, tabId, viewport, updatedAt });
       yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
         Effect.gen(function* () {
           const revision = state.revision + 1;
@@ -275,6 +286,9 @@
             canGoBack: session.snapshot.canGoBack,
             canGoForward: session.snapshot.canGoForward,
             viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
+            ...(session.snapshot.profileId === undefined
+              ? {}
+              : { profileId: session.snapshot.profileId }),
             updatedAt,
           };
           return {
@@ -308,6 +322,9 @@
           canGoBack: input.canGoBack,
           canGoForward: input.canGoForward,
           viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
+          ...(session.snapshot.profileId === undefined
+            ? {}
+            : { profileId: session.snapshot.profileId }),
           updatedAt,
         };
         const emit: PreviewEventDraft =
```

## apps/web/src/browser/ElectronBrowserHost.tsx

Status: modified; +1; −0

```diff
@@ -93,6 +93,7 @@
             initialUrl={url}
             viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}
             pictureInPicture={pictureInPicture}
+            profileId={snapshot.profileId}
             zoomFactor={zoomFactor}
           />
         );
```

## apps/web/src/browser/HostedBrowserWebview.tsx

Status: modified; +16; −3

```diff
@@ -49,11 +49,24 @@
   readonly initialUrl: string | null;
   readonly viewport: PreviewViewportSetting;
   readonly pictureInPicture: boolean;
+  /**
+   * Fixed for the tab's lifetime: Electron only honours `partition` before the
+   * guest attaches, so a live change here would not move the tab anyway.
+   */
+  readonly profileId: string | undefined;
   readonly zoomFactor: number;
 }) {
+  const {
+    threadRef,
+    tabId,
+    runtimeTabId,
+    initialUrl,
+    viewport,
+    pictureInPicture,
+    zoomFactor,
+    profileId,
+  } = props;
-  const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor } =
-    props;
-  const config = usePreviewWebviewConfig(threadRef.environmentId);
+  const config = usePreviewWebviewConfig(threadRef.environmentId, profileId);
   const [initialSrc] = useState(() => initialUrl ?? "about:blank");
   const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
   const wrapperRef = useRef<HTMLDivElement | null>(null);
```

## apps/web/src/browser/browserDefaults.test.ts

Status: added; +43; −0

```diff
@@ -1,0 +1,43 @@
+import { describe, expect, it, vi } from "vite-plus/test";
+import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
+
+const settings = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
+
+vi.mock("~/hooks/useSettings", () => ({
+  getClientSettings: () => settings.current,
+  useClientSettings: () => undefined,
+  ensureClientSettingsHydrated: () => Promise.resolve(),
+}));
+
+const { getBrowserDefaults } = await import("./browserDefaults");
+
+const withDefaultProfile = (browserDefaultProfileId: string) => {
+  settings.current = {
+    browserDefaultViewport: { _tag: "fill" },
+    browserDefaultZoomFactor: 1,
+    browserDefaultAppearance: "system",
+    browserAutoShowFloatingPreview: true,
+    browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
+    browserDefaultProfileId,
+  };
+  return getBrowserDefaults();
+};
+
+describe("getBrowserDefaults profile resolution", () => {
+  it("keeps a configured persistent profile", () => {
+    expect(withDefaultProfile("work").profileId).toBe("work");
+  });
+
+  it("falls back for an unknown profile", () => {
+    expect(withDefaultProfile("deleted").profileId).toBe(DEFAULT_BROWSER_PROFILE_ID);
+  });
+
+  it("refuses incognito as the default", () => {
+    // A stored incognito default would open every new tab into storage that is
+    // discarded on close, and the settings list no longer offers it — so the
+    // row badged "Default" must be the one tabs actually open under.
+    expect(withDefaultProfile(INCOGNITO_BROWSER_PROFILE_ID).profileId).toBe(
+      DEFAULT_BROWSER_PROFILE_ID,
+    );
+  });
+});
```

## apps/web/src/browser/browserDefaults.ts

Status: modified; +39; −10

```diff
@@ -13,10 +13,13 @@
  *
  * @module browserDefaults
  */
+import {
+  DEFAULT_BROWSER_PROFILE_ID,
+  resolveBrowserProfiles,
+  type BrowserProfile,
+  type DesktopPreviewTabDefaults,
+  type PreviewAppearancePreference,
+  type PreviewViewportSetting,
-import type {
-  DesktopPreviewTabDefaults,
-  PreviewAppearancePreference,
-  PreviewViewportSetting,
 } from "@t3tools/contracts";
 
 import {
@@ -32,6 +35,8 @@
   readonly zoomFactor: number;
   readonly appearance: PreviewAppearancePreference;
   readonly autoShowFloatingPreview: boolean;
+  readonly profiles: ReadonlyArray<BrowserProfile>;
+  readonly profileId: string;
 }
 
 const toBrowserDefaults = (settings: {
@@ -39,12 +44,29 @@
   readonly browserDefaultZoomFactor: number;
   readonly browserDefaultAppearance: PreviewAppearancePreference;
   readonly browserAutoShowFloatingPreview: boolean;
+  readonly browserProfiles: ReadonlyArray<BrowserProfile>;
+  readonly browserDefaultProfileId: string;
+}): BrowserDefaults => {
+  const profiles = resolveBrowserProfiles(settings.browserProfiles);
+  return {
+    viewport: settings.browserDefaultViewport,
+    zoomFactor: settings.browserDefaultZoomFactor,
+    appearance: settings.browserDefaultAppearance,
+    autoShowFloatingPreview: settings.browserAutoShowFloatingPreview,
+    profiles,
+    // A default pointing at a deleted profile falls back rather than opening
+    // tabs into a partition with no profile behind it.
-}): BrowserDefaults => ({
-  viewport: settings.browserDefaultViewport,
-  zoomFactor: settings.browserDefaultZoomFactor,
-  appearance: settings.browserDefaultAppearance,
-  autoShowFloatingPreview: settings.browserAutoShowFloatingPreview,
-});
+    // Incognito is a per-tab choice, not a default: a profile that discards
+    // everything on close would leave every new tab signed out. Excluding it
+    // here keeps the resolved default equal to what the settings list offers,
+    // so the row badged "Default" is the one tabs actually open under.
+    profileId:
+      profiles.find(
+        (profile) =>
+          profile.id === settings.browserDefaultProfileId && profile.kind !== "incognito",
+      )?.id ?? DEFAULT_BROWSER_PROFILE_ID,
+  };
+};
 
 /** Non-hook accessor for imperative open paths (menu actions, automation hosts). */
 export function getBrowserDefaults(): BrowserDefaults {
@@ -89,6 +111,13 @@
   return defaults.viewport;
 }
 
+/** Profile a tab opens under when the caller doesn't name one. */
+export function browserDefaultOpenProfileId(
+  defaults: BrowserDefaults = getBrowserDefaults(),
+): string {
+  return defaults.profileId;
+}
+
 /**
  * The viewport to switch to when the user turns the device toolbar on for a tab
  * currently in fill mode.
```

## apps/web/src/browser/openFileInPreview.ts

Status: modified; +16; −1

```diff
@@ -23,6 +23,12 @@
 } from "~/previewStateStore";
 import { useRightPanelStore } from "~/rightPanelStore";
 
+import {
+  browserDefaultOpenProfileId,
+  browserDefaultOpenViewport,
+  resolveBrowserDefaults,
+} from "./browserDefaults";
+
 export const isBrowserPreviewFile = (path: string): boolean =>
   /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
 
@@ -42,9 +48,18 @@
   readonly url: string;
   readonly openPreview: OpenPreviewMutation<E>;
 }): Promise<AtomCommandResult<void, E>> {
+  const defaults = await resolveBrowserDefaults();
   const result = await input.openPreview({
     environmentId: input.threadRef.environmentId,
+    input: {
+      threadId: input.threadRef.threadId,
-    input: { threadId: input.threadRef.threadId, url: input.url },
+      url: input.url,
+      // Built here rather than via `openPreviewSession` because this path
+      // maps the result differently, so the configured defaults have to be
+      // applied explicitly or file/link opens would ignore them.
+      viewport: browserDefaultOpenViewport(defaults),
+      profileId: browserDefaultOpenProfileId(defaults),
+    },
   });
   return mapAtomCommandResult(result, (snapshot) => {
     applyPreviewServerSnapshot(input.threadRef, snapshot);
```

## apps/web/src/browser/previewWebviewConfigState.test.ts

Status: modified; +11; −8

```diff
@@ -13,7 +13,9 @@
 describe("loadPreviewWebviewConfig", () => {
   it.effect("reports a structurally distinct missing-bridge failure", () =>
     Effect.gen(function* () {
+      const error = yield* loadPreviewWebviewConfig(environmentId, undefined, null).pipe(
+        Effect.flip,
+      );
-      const error = yield* loadPreviewWebviewConfig(environmentId, null).pipe(Effect.flip);
 
       expect(error).toBeInstanceOf(PreviewWebviewBridgeUnavailableError);
       expect(error.environmentId).toBe(environmentId);
@@ -25,7 +27,7 @@
   it.effect("preserves the bridge rejection as the load failure cause", () =>
     Effect.gen(function* () {
       const cause = new Error("ipc unavailable");
-      const error = yield* loadPreviewWebviewConfig(environmentId, {
+      const error = yield* loadPreviewWebviewConfig(environmentId, undefined, {
         getPreviewConfig: () => Promise.reject(cause),
       }).pipe(Effect.flip);
 
@@ -36,22 +38,23 @@
     }),
   );
 
+  it.effect("forwards the environment id and profile to the bridge", () =>
-  it.effect("forwards the environment id to the bridge", () =>
     Effect.gen(function* () {
-      let requestedEnvironmentId: EnvironmentId | null = null;
+      let requested: { environmentId: EnvironmentId; profileId: string | undefined } | null = null;
       const config = {
         partition: "persist:test-preview",
         webPreferences: "sandbox=yes",
         preloadUrl: null,
       };
+      const result = yield* loadPreviewWebviewConfig(environmentId, "work", {
+        getPreviewConfig: (requestedEnvironmentId, profileId) => {
+          requested = { environmentId: requestedEnvironmentId, profileId };
-      const result = yield* loadPreviewWebviewConfig(environmentId, {
-        getPreviewConfig: (input) => {
-          requestedEnvironmentId = input;
           return Promise.resolve(config);
         },
       });
 
-      expect(requestedEnvironmentId).toBe(environmentId);
+      // The partition is derived in main from both, so both have to arrive.
+      expect(requested).toEqual({ environmentId, profileId: "work" });
       expect(result).toEqual(config);
     }),
   );
```

## apps/web/src/browser/previewWebviewConfigState.ts

Status: modified; +35; −7

```diff
@@ -45,6 +45,7 @@
 
 export const loadPreviewWebviewConfig = (
   environmentId: EnvironmentId,
+  profileId?: string,
   bridge: PreviewConfigBridge | null = previewBridge,
 ): Effect.Effect<DesktopPreviewWebviewConfig, PreviewWebviewConfigError> => {
   if (bridge === null) {
@@ -52,25 +53,52 @@
   }
 
   return Effect.tryPromise({
+    try: () => bridge.getPreviewConfig(environmentId, profileId),
-    try: () => bridge.getPreviewConfig(environmentId),
     catch: (cause) => new PreviewWebviewConfigLoadError({ environmentId, cause }),
   });
 };
 
+/**
+ * `Atom.family` keys on its argument, so the environment and profile are
+ * folded into one string: passing an object would allocate a fresh entry on
+ * every render.
+ *
+ * The profile is the tail rather than a second field, so an id containing the
+ * delimiter round-trips whole instead of being truncated into a different
+ * profile's key. `BrowserProfileId` rejects control characters, which is what
+ * makes the environment side of the split unambiguous.
+ */
+const CONFIG_KEY_DELIMITER = "\u0000";
-const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) =>
-  Atom.make(loadPreviewWebviewConfig(environmentId)).pipe(
+
+const configKey = (environmentId: EnvironmentId, profileId: string | undefined): string =>
+  `${environmentId}${CONFIG_KEY_DELIMITER}${profileId ?? ""}`;
+
+const parseConfigKey = (key: string): { environmentId: EnvironmentId; profileId?: string } => {
+  const delimiter = key.indexOf(CONFIG_KEY_DELIMITER);
+  const environmentId = (delimiter === -1 ? key : key.slice(0, delimiter)) as EnvironmentId;
+  const profileId = delimiter === -1 ? "" : key.slice(delimiter + CONFIG_KEY_DELIMITER.length);
+  return {
+    environmentId,
+    ...(profileId === "" ? {} : { profileId }),
+  };
+};
+
+const previewWebviewConfigAtom = Atom.family((key: string) => {
+  const { environmentId, profileId } = parseConfigKey(key);
+  return Atom.make(loadPreviewWebviewConfig(environmentId, profileId)).pipe(
     Atom.swr({
       staleTime: PREVIEW_CONFIG_STALE_TIME_MS,
       revalidateOnMount: true,
     }),
     Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS),
+    Atom.withLabel(`preview:webview-config:${key}`),
-    Atom.withLabel(`preview:webview-config:${environmentId}`),
-  ),
-);
+  );
+});
 
 export function usePreviewWebviewConfig(
   environmentId: EnvironmentId,
+  profileId?: string,
 ): DesktopPreviewWebviewConfig | null {
-  const result = useAtomValue(previewWebviewConfigAtom(environmentId));
+  const result = useAtomValue(previewWebviewConfigAtom(configKey(environmentId, profileId)));
   return Option.getOrNull(AsyncResult.value(result));
 }
```

## apps/web/src/components/ChatView.tsx

Status: modified; +15; −6

```diff
@@ -3608,10 +3608,17 @@
   const toggleInteractionMode = useCallback(() => {
     handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
   }, [handleInteractionModeChange, interactionMode]);
+  const createBrowserSurface = useCallback(
+    (profileId?: string) => {
+      if (!activeThreadRef) return;
+      void addBrowserSurface({
+        threadRef: activeThreadRef,
+        openPreview,
+        ...(profileId === undefined ? {} : { profileId }),
+      });
+    },
+    [activeThreadRef, openPreview],
+  );
-  const createBrowserSurface = useCallback(() => {
-    if (!activeThreadRef) return;
-    void addBrowserSurface({ threadRef: activeThreadRef, openPreview });
-  }, [activeThreadRef, openPreview]);
   const addDiffSurface = useCallback(() => {
     if (!activeThreadRef || !isServerThread || !isGitRepo) return;
     useRightPanelStore.getState().open(activeThreadRef, "diff");
@@ -7664,7 +7671,8 @@
           onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
           onCloseAllSurfaces={closeAllRightPanelSurfaces}
           onCopyFilePath={copyRightPanelFilePath}
-          onAddBrowser={createBrowserSurface}
+          onAddBrowser={() => createBrowserSurface()}
+          onAddBrowserInProfile={createBrowserSurface}
           onAddTerminal={addTerminalSurface}
           onAddDiff={addDiffSurface}
           onAddFiles={addFilesSurface}
@@ -7704,7 +7712,8 @@
             onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
             onCloseAllSurfaces={closeAllRightPanelSurfaces}
             onCopyFilePath={copyRightPanelFilePath}
+            onAddBrowser={() => createBrowserSurface()}
-            onAddBrowser={createBrowserSurface}
+            onAddBrowserInProfile={createBrowserSurface}
             onAddTerminal={addTerminalSurface}
             onAddDiff={addDiffSurface}
             onAddFiles={addFilesSurface}
```

## apps/web/src/components/RightPanelTabs.test.tsx

Status: modified; +10; −0

```diff
@@ -4,11 +4,20 @@
 
 import {
   RightPanelTabs,
+  shouldOpenDefaultBrowserProfileFromMenuClick,
   surfaceShortcutActionForKey,
   surfaceShortcutTargetsTypingContext,
   tabMuteMenuItem,
 } from "./RightPanelTabs";
 
+describe("browser profile submenu", () => {
+  it("reserves touch clicks for opening the choices while mouse clicks use the default", () => {
+    expect(shouldOpenDefaultBrowserProfileFromMenuClick("touch")).toBe(false);
+    expect(shouldOpenDefaultBrowserProfileFromMenuClick("mouse")).toBe(true);
+    expect(shouldOpenDefaultBrowserProfileFromMenuClick(undefined)).toBe(true);
+  });
+});
+
 function shortcutEvent(
   key: string,
   overrides: Partial<Parameters<typeof surfaceShortcutActionForKey>[1]> = {},
@@ -104,6 +113,7 @@
       onCloseAllSurfaces={() => undefined}
       onCopyFilePath={() => undefined}
       onAddBrowser={() => undefined}
+      onAddBrowserInProfile={() => undefined}
       onAddTerminal={() => undefined}
       onAddPullRequest={() => undefined}
       onAddDiff={() => undefined}
```

## apps/web/src/components/RightPanelTabs.tsx

Status: modified; +73; −1

```diff
@@ -30,7 +30,17 @@
 import { Button } from "~/components/ui/button";
 import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
 import { Kbd } from "~/components/ui/kbd";
+import {
+  Menu,
+  MenuItem,
+  MenuPopup,
+  MenuShortcut,
+  MenuSub,
+  MenuSubPopup,
+  MenuSubTrigger,
+  MenuTrigger,
+} from "~/components/ui/menu";
-import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "~/components/ui/menu";
+import { useBrowserDefaults } from "~/browser/browserDefaults";
 import { ScrollArea } from "~/components/ui/scroll-area";
 import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
 import { faviconUrlForOrigin } from "~/lib/favicon";
@@ -69,6 +79,12 @@
   onCloseAllSurfaces: () => void;
   onCopyFilePath: (relativePath: string) => void;
   onAddBrowser: () => void;
+  /**
+   * Separate from `onAddBrowser` on purpose: that one is passed directly as a
+   * DOM click handler, and a `(profileId?: string)` signature would silently
+   * accept the MouseEvent as a profile id.
+   */
+  onAddBrowserInProfile: (profileId: string) => void;
   onAddTerminal: () => void;
   onAddDiff: () => void;
   onAddFiles: () => void;
@@ -94,6 +110,12 @@
   isDraft: boolean;
 }
 
+export function shouldOpenDefaultBrowserProfileFromMenuClick(
+  pointerType: string | undefined,
+): boolean {
+  return pointerType !== "touch";
+}
+
 const SURFACE_DISABLED_REASONS = {
   browser: "Browser previews are only available in the T3 Code desktop app.",
   terminal: "Terminal surfaces are only available from a project thread.",
@@ -600,6 +622,7 @@
 
 export function RightPanelTabs(props: RightPanelTabsProps) {
   const ownsDesktopTitleBar = isElectron && props.mode === "inline";
+  const browserProfiles = useBrowserDefaults().profiles;
   const { resolvedTheme } = useTheme();
   const tabListRef = useRef<HTMLDivElement>(null);
   const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);
@@ -911,6 +934,55 @@
                 >
                   {addSurfaceActions.map((action) => {
                     const Icon = action.icon;
+                    // Browser collapses into one row: clicking the trigger opens
+                    // the default profile (the common case stays one click),
+                    // while hover or arrow reveals the profiles. The choice
+                    // lives at open time because a tab's profile is fixed then —
+                    // Electron only honours a partition before attach.
+                    if (action.label === "Browser" && action.available) {
+                      return (
+                        <MenuSub key={action.label}>
+                          <MenuSubTrigger
+                            className="[&>svg:last-child]:ms-0"
+                            aria-keyshortcuts={action.shortcut}
+                            onClick={(event) => {
+                              const pointerType =
+                                "pointerType" in event.nativeEvent &&
+                                typeof event.nativeEvent.pointerType === "string"
+                                  ? event.nativeEvent.pointerType
+                                  : undefined;
+                              // Touch has no hover path to the profile choices:
+                              // its first tap opens the submenu, then a profile
+                              // is selected there. Mouse click keeps the common
+                              // default-profile action at one click.
+                              if (!shouldOpenDefaultBrowserProfileFromMenuClick(pointerType))
+                                return;
+                              setAddSurfaceMenuOpen(false);
+                              action.onClick();
+                            }}
+                          >
+                            <Icon />
+                            {action.label}
+                            <MenuShortcut>{action.shortcut}</MenuShortcut>
+                          </MenuSubTrigger>
+                          {/*
+                            Capped and truncated: profile names are user-supplied
+                            and run to 48 characters, which would otherwise widen
+                            the popup to fit-content and wrap.
+                          */}
+                          <MenuSubPopup className="min-w-40 max-w-56">
+                            {browserProfiles.map((profile) => (
+                              <MenuItem
+                                key={profile.id}
+                                onClick={() => props.onAddBrowserInProfile(profile.id)}
+                              >
+                                <span className="min-w-0 truncate">{profile.name}</span>
+                              </MenuItem>
+                            ))}
+                          </MenuSubPopup>
+                        </MenuSub>
+                      );
+                    }
                     return (
                       <SurfaceMenuItem
                         key={action.label}
```

## apps/web/src/components/preview/PreviewChromeRow.tsx

Status: modified; +8; −0

```diff
@@ -57,6 +57,11 @@
    * to mount the three-dot menu (hard reload, devtools, zoom, clear data).
    */
   trailingActions?: ReactNode;
+  /**
+   * Slot between the nav buttons and the URL input. The preview view uses it
+   * to name the tab's browser profile, which is otherwise invisible.
+   */
+  leadingActions?: ReactNode;
 }
 
 const NOOP = () => {};
@@ -85,6 +90,7 @@
   pickDisabled,
   pickDisabledReason,
   trailingActions,
+  leadingActions,
 }: Props) {
   const inputRef = useRef<HTMLInputElement | null>(null);
   const [draft, setDraft] = useState(url);
@@ -166,6 +172,8 @@
           </Tooltip>
         </div>
 
+        {leadingActions}
+
         <InputGroup variant="ghost" className="group/address h-7 flex-1">
           <Tooltip>
             <TooltipTrigger
```

[Next page](https://git.cafe/pingdotgg/t3code/pull/7254/diffs?after=ImFwcHMvd2ViL3NyYy9jb21wb25lbnRzL3ByZXZpZXcvUHJldmlld0Nocm9tZVJvdy50c3gi&expectedVersion=57&format=markdown)
