37 unresolved threads · Jump to file
Selected thread: apps/web/src/components/settings/IntegrationsSettings.tsx:499 — <!-- MURMUR_IGNORE --> 🟡 **Medium** `settings/IntegrationsSettings.tsx:499` Clicking **Add profile** twice before React rerenders creates only one profile instead of two, because both calls build `browserProfiles` from the same render-captured `userProfiles` array and the second `updateSettings` replaces the first patch. Disable or debounce the action while updating, or use an atomic updater based on the latest settings snapshot. No longer relevant as of 2d101531aec510d521de30e8772dd011b05b7232 <!-- macroscope-meta: {"kind":"code_review"} -->
0 / 40 viewed40 files changedLine totals unavailable

apps/desktop/src/preview/BrowserSession.ts · Earlier revision 56bf2ce8 · right 153

Original diff
@@ -99,19 +109,47 @@ export class BrowserSession extends Context.Service<
   {
     readonly getPartition: (
       scope?: string,
+      persistent?: boolean,
+      namespace?: BrowserSessionPartitionNamespace,
     ) => Effect.Effect<string, BrowserSessionPartitionDerivationError>;
     readonly isPartition: (partition: string) => boolean;
-    readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionGetSessionError>;
-    readonly clearCookies: () => Effect.Effect<void, BrowserSessionStorageClearError>;
-    readonly clearCache: () => Effect.Effect<void, BrowserSessionCacheClearError>;
+    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>;
   }
 >()("@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),
+  );
+
 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 getPartition = Effect.fn("BrowserSession.getPartition")(function* (
+    scope = "shared",
+    persistent = true,
+    namespace?: BrowserSessionPartitionNamespace,
+  ) {
     const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe(
M
macroscopeappBotSep 2, 2026

🟠 High preview/BrowserSession.ts:153

getPartition assigns the same partition to distinct scopes such as "p\ud800" and "p�", so those profiles share cookies and site storage. TextEncoder().encode(scope) replaces lone surrogates with U+FFFD before hashing; use an encoding that preserves UTF-16 code units when deriving the digest.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserSession.ts around line 153:
2
3`getPartition` assigns the same partition to distinct scopes such as `"p\ud800"` and `"p�"`, so those profiles share cookies and site storage. `TextEncoder().encode(scope)` replaces lone surrogates with U+FFFD before hashing; use an encoding that preserves UTF-16 code units when deriving the digest.
J
Julius MarmingeSep 2, 2026

Fixed in 4414469f35. Lone UTF-16 surrogates are escaped to \\uXXXX before the digest (with a literal backslash doubled so the escape can't be forged), so "p\\ud800" and "p\\ufffd" hash to different partitions. Every well-formed scope passes through byte-for-byte unchanged, so existing partitions and the logins in them stay put — the test pins both the separation and the historical scope-a hash.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 91f93342 · right 651–663

Original diff
@@ -453,11 +507,277 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const updateSettings = useUpdatePrimarySettings();
+  const { environments, isReady: environmentsReady } = useEnvironments();
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+  const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);
+  const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);
+  const removalAvailable = browserProfileRemovalAvailable(
+    previewBridge !== null,
+    environmentsReady,
+    environments.length,
+  );
+
+  const addProfile = () => {
+    const currentProfiles = getClientSettings().browserProfiles;
+    if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...currentProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    const currentProfiles = getClientSettings().browserProfiles;
+    updateSettings({
+      browserProfiles: currentProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = async (id: string) => {
+    if (!removalAvailable) {
+      setProfileRemovalError("Connect to an environment before removing this profile.");
+      return;
+    }
+    setProfileRemovalError(null);
+    setProfileRemovalInFlight(true);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    try {
+      await clearBrowserProfileData(
+        previewBridge,
+        environmentsReady ? environments.map((environment) => environment.environmentId) : [],
+        id,
+      );
+    } catch {
+      setProfileRemovalError("Profile data could not be deleted. Try again.");
+      setProfileRemovalInFlight(false);
+      return;
+    }
+    const currentSettings = getClientSettings();
+    updateSettings({
+      browserProfiles: currentSettings.browserProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(currentSettings.browserDefaultProfileId === id
+        ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }
+        : {}),
+    });
+    setProfileRemovalInFlight(false);
+    setProfilePendingRemoval(null);
+  };
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-profiles")}
+      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      control={
+        <Button
+          size="sm"
+          variant="outline"
+          disabled={disabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
+          onClick={addProfile}
+        >
+          <PlusIcon />
+          Add profile
+        </Button>
+      }
+    >
+      {/*
+        Each profile is its own bounded row, and the list carries the bottom
+        spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows
+        stack on narrow viewports with a larger gap inside a row than between
+        rows, which reads as the remove button belonging to the profile below.
+      */}
+      <div className="mt-2 space-y-1 pb-2">
+        {resolveBrowserProfiles(userProfiles).map((profile) => {
+          const builtIn = isBuiltInBrowserProfileId(profile.id);
+          return (
+            <div
+              key={profile.id}
+              className={cn(
+                ITEM_ROW_INNER_CLASSNAME,
+                "rounded-lg border border-border/60 px-3 py-2",
+              )}
+            >
+              {builtIn ? (
+                // Dimmed here rather than on the list, which is the only
+                // content in the row without a disabled treatment of its own:
+                // a wrapper-level dim would stack with the rename field's and
+                // the remove button's, landing them near 0.41 while every
+                // other disabled control in the block sits at 0.64.
+                <span
+                  className={cn(
+                    "flex min-w-0 items-center gap-2 text-sm text-foreground",
+                    disabled && "opacity-64",
+                  )}
+                >
+                  {profile.name}
+                  <Badge variant="outline">
+                    {profile.kind === "incognito" ? "Ephemeral" : "Built-in"}
+                  </Badge>
+                </span>
+              ) : (
+                <DraftInput
+                  nativeInput
+                  size="sm"
+                  className="w-full sm:w-64"
+                  aria-label={`Rename ${profile.name}`}
+                  disabled={disabled}
+                  maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
+                  value={profile.name}
+                  onCommit={(next) => renameProfile(profile.id, next)}
+                />
+              )}
+              {builtIn ? null : (
+                <Tooltip>
+                  <TooltipTrigger
+                    render={
+                      <Button
+                        size="icon-sm"
+                        variant="ghost-muted"
+                        disabled={disabled || !removalAvailable}
+                        aria-label={`Remove ${profile.name}`}
+                        onClick={() => setProfilePendingRemoval(profile)}
+                      >
+                        <Trash2Icon />
+                      </Button>
+                    }
+                  />

apps/web/src/components/settings/IntegrationsSettings.tsx · right 651–663

M
macroscopeappBotAug 29, 2026

The tooltip's !removalAvailable branch ("Connect to an environment to remove this profile") is unreachable: buttonVariants carries disabled:pointer-events-none, and a natively disabled button also can't be hovered or focused — so exactly when the explanation is needed, the trigger emits no events. Settings already handles this by making a wrapper the trigger (ConnectionsSettings.tsx's CloudLinkSwitch renders <TooltipTrigger render={<span className="inline-flex">{control}</span>} /> for its disabled-reason tooltip).

Suggest wrapping the button the same way so the reason stays reachable:

suggestion
1 <TooltipTrigger
2 render={
3 <span className="inline-flex">
4 <Button
5 size="icon-sm"
6 variant="ghost-muted"
7 disabled={disabled || !removalAvailable}
8 aria-label={`Remove ${profile.name}`}
9 onClick={() => setProfilePendingRemoval(profile)}
10 >
11 <Trash2Icon />
12 </Button>
13 </span>
14 }
15 />

Posted via Macroscope — UI Consistency

J
Julius MarmingeAug 29, 2026

Fixed in 11dc366428c. An enabled, focusable wrapper now owns the unavailable-reason tooltip around the disabled removal button, so mouse and keyboard users can reach the explanation.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 2d101531 · right 677–677

Original diff
@@ -453,11 +499,252 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const updateSettings = useUpdatePrimarySettings();
+  const { environments, isReady: environmentsReady } = useEnvironments();
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+  const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);
+  const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);
+
+  const addProfile = () => {
+    const currentProfiles = getClientSettings().browserProfiles;
+    if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...currentProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    const currentProfiles = getClientSettings().browserProfiles;
+    updateSettings({
+      browserProfiles: currentProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = async (id: string) => {
+    setProfileRemovalError(null);
+    setProfileRemovalInFlight(true);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    try {
+      await clearBrowserProfileData(
+        previewBridge,
+        environmentsReady ? environments.map((environment) => environment.environmentId) : [],
+        id,
+      );
+    } catch {
+      setProfileRemovalError("Profile data could not be deleted. Try again.");
+      setProfileRemovalInFlight(false);
+      return;
+    }
+    const currentSettings = getClientSettings();
+    updateSettings({
+      browserProfiles: currentSettings.browserProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(currentSettings.browserDefaultProfileId === id
+        ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }
+        : {}),
+    });
+    setProfileRemovalInFlight(false);
+    setProfilePendingRemoval(null);
+  };
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-profiles")}
+      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      control={
+        <Button
+          size="sm"
+          variant="outline"
+          disabled={disabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
+          onClick={addProfile}
+        >
+          <PlusIcon />
+          Add profile
+        </Button>
+      }
+    >
+      {/*
+        Each profile is its own bounded row, and the list carries the bottom
+        spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows
+        stack on narrow viewports with a larger gap inside a row than between
+        rows, which reads as the remove button belonging to the profile below.
+      */}
+      <div className="mt-2 space-y-1 pb-2">
+        {resolveBrowserProfiles(userProfiles).map((profile) => {
+          const builtIn = isBuiltInBrowserProfileId(profile.id);
+          return (
+            <div
+              key={profile.id}
+              className={cn(
+                ITEM_ROW_INNER_CLASSNAME,
+                "rounded-lg border border-border/60 px-3 py-2",
+              )}
+            >
+              {builtIn ? (
+                // Dimmed here rather than on the list, which is the only
+                // content in the row without a disabled treatment of its own:
+                // a wrapper-level dim would stack with the rename field's and
+                // the remove button's, landing them near 0.41 while every
+                // other disabled control in the block sits at 0.64.
+                <span
+                  className={cn(
+                    "flex min-w-0 items-center gap-2 text-sm text-foreground",
+                    disabled && "opacity-64",
+                  )}
+                >
+                  {profile.name}
+                  <Badge variant="outline">
+                    {profile.kind === "incognito" ? "Ephemeral" : "Built-in"}
+                  </Badge>
+                </span>
+              ) : (
+                <DraftInput
+                  nativeInput
+                  size="sm"
+                  className="w-full sm:w-64"
+                  aria-label={`Rename ${profile.name}`}
+                  disabled={disabled}
+                  maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
+                  value={profile.name}
+                  onCommit={(next) => renameProfile(profile.id, next)}
+                />
+              )}
+              {builtIn ? null : (
+                <Tooltip>
+                  <TooltipTrigger
+                    render={
+                      <Button
+                        size="icon-sm"
+                        variant="ghost-muted"
+                        disabled={disabled || !environmentsReady}
+                        aria-label={`Remove ${profile.name}`}
+                        onClick={() => setProfilePendingRemoval(profile)}
+                      >
+                        <Trash2Icon />
+                      </Button>
+                    }
+                  />
+                  <TooltipPopup side="top">Remove profile and its data</TooltipPopup>
+                </Tooltip>
+              )}
+            </div>
+          );
+        })}
+      </div>
+      <AlertDialog
+        open={profilePendingRemoval !== null}
+        onOpenChange={(open) => {
+          if (!open && !profileRemovalInFlight) {
+            setProfilePendingRemoval(null);
+            setProfileRemovalError(null);
+          }
+        }}
+      >
+        <AlertDialogPopup>
+          <AlertDialogHeader>
+            <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle>
+            <AlertDialogDescription>
+              Its cookies, logins, and cache are deleted with it. Tabs open in this profile move to
+              the default one.
+            </AlertDialogDescription>
+            {profileRemovalError ? (
+              <p aria-live="polite" className="text-sm text-destructive">
+                {profileRemovalError}
+              </p>
+            ) : null}
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>

apps/web/src/components/settings/IntegrationsSettings.tsx · right 677–677

M
macroscopeappBotAug 29, 2026

While removal is in flight the onOpenChange guard above swallows every close request, so this Cancel button stays enabled, focusable, and hoverable but does nothing (same for Esc). The other in-flight confirmations in settings disable the close explicitly — ConnectionsSettings does this for the Tailscale disable, WSL backend, and server-exposure dialogs — so the pending state reads the same everywhere.

suggestion
1 <AlertDialogClose
2 disabled={profileRemovalInFlight}
3 render={<Button variant="outline" disabled={profileRemovalInFlight} />}
4 >
5 Cancel
6 </AlertDialogClose>

Posted via Macroscope — UI Consistency

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 1cb5acff · right 834–840

Original diff
@@ -501,11 +556,307 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const settingsHydrated = useClientSettingsHydrated();
+  const updateSettings = useUpdatePrimarySettings();
+  const { environments, isReady: environmentsReady } = useEnvironments();
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+  const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);
+  const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);
+  const removalAvailable = browserProfileRemovalAvailable(
+    previewBridge !== null,
+    environmentsReady,
+    environments.length,
+  );
+  const profileWritesDisabled = disabled || !settingsHydrated;
+
+  const addProfile = () => {
+    if (!settingsHydrated) return;
+    const currentProfiles = getClientSettings().browserProfiles;
+    if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...currentProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    if (!settingsHydrated) return;
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    const currentProfiles = getClientSettings().browserProfiles;
+    updateSettings({
+      browserProfiles: currentProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = async (id: string) => {
+    if (!settingsHydrated) return;
+    if (!removalAvailable) {
+      setProfileRemovalError("Connect to an environment before removing this profile.");
+      return;
+    }
+    setProfileRemovalError(null);
+    setProfileRemovalInFlight(true);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    try {
+      await clearBrowserProfileData(
+        previewBridge,
+        environmentsReady ? environments.map((environment) => environment.environmentId) : [],
+        id,
+      );
+    } catch {
+      setProfileRemovalError("Profile data could not be deleted. Try again.");
+      setProfileRemovalInFlight(false);
+      return;
+    }
+    const currentSettings = getClientSettings();
+    updateSettings({
+      browserProfiles: currentSettings.browserProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(currentSettings.browserDefaultProfileId === id
+        ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }
+        : {}),
+    });
+    setProfileRemovalInFlight(false);
+    setProfilePendingRemoval(null);
+  };
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-profiles")}
+      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      control={
+        <Button
+          size="sm"
+          variant="outline"
+          disabled={profileWritesDisabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
+          onClick={addProfile}
+        >
+          <PlusIcon />
+          Add profile
+        </Button>
+      }
+    >
+      {/*
+        Each profile is its own bounded row, and the list carries the bottom
+        spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows
+        stack on narrow viewports with a larger gap inside a row than between
+        rows, which reads as the remove button belonging to the profile below.
+      */}
+      <div className="mt-2 space-y-1 pb-2">
+        {resolveBrowserProfiles(userProfiles).map((profile) => {
+          const builtIn = isBuiltInBrowserProfileId(profile.id);
+          return (
+            <div
+              key={profile.id}
+              className={cn(
+                ITEM_ROW_INNER_CLASSNAME,
+                "rounded-lg border border-border/60 px-3 py-2",
+              )}
+            >
+              {builtIn ? (
+                // Dimmed here rather than on the list, which is the only
+                // content in the row without a disabled treatment of its own:
+                // a wrapper-level dim would stack with the rename field's and
+                // the remove button's, landing them near 0.41 while every
+                // other disabled control in the block sits at 0.64.
+                <span
+                  className={cn(
+                    "flex min-w-0 items-center gap-2 text-sm text-foreground",
+                    profileWritesDisabled && "opacity-64",
+                  )}
+                >
+                  {profile.name}
+                  <Badge variant="outline">
+                    {profile.kind === "incognito" ? "Ephemeral" : "Built-in"}
+                  </Badge>
+                </span>
+              ) : (
+                <DraftInput
+                  nativeInput
+                  size="sm"
+                  className="w-full sm:w-64"
+                  aria-label={`Rename ${profile.name}`}
+                  disabled={profileWritesDisabled}
+                  maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
+                  value={profile.name}
+                  onCommit={(next) => renameProfile(profile.id, next)}
+                />
+              )}
+              {builtIn ? null : (
+                <Tooltip>
+                  <TooltipTrigger
+                    render={
+                      <span className="inline-flex" {...(!removalAvailable ? { tabIndex: 0 } : {})}>
+                        <Button
+                          size="icon-sm"
+                          variant="ghost-muted"
+                          disabled={profileWritesDisabled || !removalAvailable}
+                          aria-label={`Remove ${profile.name}`}
+                          onClick={() => setProfilePendingRemoval(profile)}
+                        >
+                          <Trash2Icon />
+                        </Button>
+                      </span>
+                    }
+                  />
+                  <TooltipPopup side="top">
+                    {removalAvailable
+                      ? "Remove profile and its data"
+                      : "Connect to an environment to remove this profile"}
+                  </TooltipPopup>
+                </Tooltip>
+              )}
+            </div>
+          );
+        })}
+      </div>
+      <AlertDialog
+        open={profilePendingRemoval !== null}
+        onOpenChange={(open) => {
+          if (!open && !profileRemovalInFlight) {
+            setProfilePendingRemoval(null);
+            setProfileRemovalError(null);
+          }
+        }}
+      >
+        <AlertDialogPopup>
+          <AlertDialogHeader>
+            <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle>
+            <AlertDialogDescription>
+              Its cookies, logins, and cache are deleted with it. Tabs already open in this profile
+              stay open until you close them.
+            </AlertDialogDescription>
+            {profileRemovalError ? (
+              <p aria-live="polite" className="text-sm text-destructive">
+                {profileRemovalError}
+              </p>
+            ) : null}
+            {!removalAvailable ? (
+              <p className="text-sm text-muted-foreground">
+                Connect to an environment to remove this profile and its data.
+              </p>
+            ) : null}
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogClose
+              disabled={profileRemovalInFlight}
+              render={<Button variant="outline" disabled={profileRemovalInFlight} />}
+            >
+              Cancel
+            </AlertDialogClose>
+            <Button
+              variant="destructive"
+              disabled={profileRemovalInFlight || !settingsHydrated || !removalAvailable}
+              onClick={() => {
+                if (profilePendingRemoval && settingsHydrated && removalAvailable) {
+                  void removeProfile(profilePendingRemoval.id);
+                }
+              }}
+            >
+              {profileRemovalInFlight ? "Removing…" : "Remove profile"}
+            </Button>
+          </AlertDialogFooter>
+        </AlertDialogPopup>
+      </AlertDialog>
+    </SettingsRow>
+  );
+}
+
+function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const settingsHydrated = useClientSettingsHydrated();
+  const updateSettings = useUpdatePrimarySettings();
+  const profileWritesDisabled = disabled || !settingsHydrated;
+  // Incognito is deliberately absent: as a default it would open every tab
+  // into storage that is discarded on close.
+  const profiles = resolveBrowserProfiles(userProfiles).filter(
+    (profile) => profile.kind !== "incognito",
+  );
+  const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0];
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-default-profile")}
+      description="Profile new browser tabs open under, including tabs an agent opens."
+      resetAction={
+        !profileWritesDisabled && defaultProfileId !== DEFAULT_BROWSER_PROFILE_ID ? (
+          <SettingResetButton
+            label="default browser profile"
+            onClick={() => {
+              if (settingsHydrated) {
+                updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID });
+              }
+            }}
+          />
+        ) : null
+      }
+      control={
+        <Select
+          disabled={profileWritesDisabled}
+          value={selected?.id ?? DEFAULT_BROWSER_PROFILE_ID}
+          onValueChange={(value) => {
+            if (settingsHydrated && value !== null) {
+              updateSettings({ browserDefaultProfileId: value });
+            }
+          }}
+        >
+          <SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Default browser profile">
+            <SelectValue>{selected?.name ?? "Default"}</SelectValue>
+          </SelectTrigger>
+          {/*
+            Capped and truncated like the tab menu's profile list: names are
+            user-supplied and run to 48 characters, which would otherwise
+            widen the popup to fit the longest one. The cap goes on the glass
+            shell (`popupClassName`) and not just the list, and the trigger
+            width is not matched, or a full-width trigger on small screens
+            would leave empty chrome beside a 16rem list. `ItemText` renders a
+            block, so the label must be a block too for `truncate` to apply.
+          */}
+          <SelectPopup
+            align="end"
+            alignItemWithTrigger={false}
+            matchTriggerWidth={false}
+            popupClassName="max-w-64"
+            className="max-w-64"
+          >

apps/web/src/components/settings/IntegrationsSettings.tsx · right 834–840

M
macroscopeappBotSep 2, 2026

matchTriggerWidth={false} drops the only min-width the popup shell has: dropdown-glass sets none, and with alignItemWithTrigger={false} the in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] on SelectItem never applies either. So this popup is purely content-sized — with just "Default" in the list it renders ~80px wide under a sm:w-44 trigger, while every other settings select (BrowserViewportSetting, zoom, appearance, frame rate, SettingsPanels, ConnectionsSettings) keeps the default matchTriggerWidth, so its popup is never narrower than the control it opens from.

The empty-chrome case the comment above describes comes from the list not filling the shell, not from matching the trigger: with w-full on the list, the shell stays ≥ trigger width (and ≤ max-w-64 wherever the trigger is narrower than 16rem) and the items truncate against whatever width the shell ends up with.

suggestion
1 <SelectPopup
2 align="end"
3 alignItemWithTrigger={false}
4 popupClassName="max-w-64"
5 className="w-full"
6 >

The explanatory comment just above would need its trigger-width sentence updated to match.

Posted via Macroscope — UI Consistency

J
Julius MarmingeSep 2, 2026

Fixed in 06e1411676. Restored the trigger-width match and put w-full on the list, so the shell is never narrower than the trigger (and never wider than 16rem); comment updated.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 5b85b1db · right 553–559

Original diff
@@ -453,11 +476,211 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const updateSettings = useUpdatePrimarySettings();
+  const environmentId = usePrimaryEnvironment()?.environmentId;
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+
+  const addProfile = () => {
+    if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...userProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    updateSettings({
+      browserProfiles: userProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = (id: string) => {
+    setProfilePendingRemoval(null);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    if (environmentId) {
+      void previewBridge?.clearCookies(environmentId, id).catch(() => undefined);
+      void previewBridge?.clearCache(environmentId, id).catch(() => undefined);
+    }
+    updateSettings({
+      browserProfiles: userProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(defaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}),
+    });
+  };
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-profiles")}
+      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      control={
+        <Button
+          size="sm"
+          variant="outline"
+          disabled={disabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
+          onClick={addProfile}
+        >
+          <PlusIcon />
+          Add profile
+        </Button>
+      }
+    >
+      {/*
+        Dimmed as a whole when the section is unavailable. The built-in rows
+        are a plain span and a badge rather than `h3`/`p` or disabled controls,
+        so the block's own dimming does not reach them and they would be the
+        only full-contrast content inside "only available in the desktop app".
+      */}
+      <div className={cn("mt-1 space-y-1", disabled && "opacity-64")}>
+        {resolveBrowserProfiles(userProfiles).map((profile) => {
+          const builtIn = isBuiltInBrowserProfileId(profile.id);
+          return (
+            <div key={profile.id} className={ITEM_ROW_INNER_CLASSNAME}>
+              {builtIn ? (
+                <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">

apps/web/src/components/settings/IntegrationsSettings.tsx · right 553–559

M
macroscopeappBotAug 17, 2026

settings/IntegrationsSettings.tsx:559

The container-level opacity-64 stacks with each control's own disabled treatment, so the user-profile rows end up dimmer than every other row in the block. Input's wrapper carries has-disabled:opacity-64 and Button carries disabled:opacity-64, and both children here also receive disabled, so the rename field and the remove button composite to ~0.41 alpha while the Select, Switch, and NumberField in the neighbouring rows sit at 0.64.

Only the built-in rows lack a self-dimming treatment, so scoping the dim to that span keeps the whole block at one contrast level (the note above can move with it):

suggestion
1 <div className="mt-1 space-y-1">
2 {resolveBrowserProfiles(userProfiles).map((profile) => {
3 const builtIn = isBuiltInBrowserProfileId(profile.id);
4 return (
5 <div key={profile.id} className={ITEM_ROW_INNER_CLASSNAME}>
6 {builtIn ? (
7 <span
8 className={cn(
9 "flex min-w-0 items-center gap-2 text-sm text-foreground",
10 disabled && "opacity-64",
11 )}
12 >

No longer relevant as of a7801b6d775068e26dfe115a25bb11c3a7aa27d5

apps/web/src/components/RightPanelTabs.tsx · Earlier revision 2d101531 · right 944–947

Original diff
@@ -909,6 +932,54 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
                 >
                   {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
+                            aria-keyshortcuts={action.shortcut}

apps/web/src/components/RightPanelTabs.tsx · right 944–947

M
macroscopeappBotAug 29, 2026

Putting MenuShortcut inside a MenuSubTrigger gives this row two auto inline-start margins: the kbd (ms-auto) and the primitive's chevron (-me-0.5 ms-auto). Flexbox distributes the leftover space equally between auto margins, so "B" lands roughly halfway between the label and the chevron and its position shifts with the popup width — while Terminal/Files/Diff/Pull request/Agents in the same popup render their shortcut flush against the right padding edge (single ms-auto in MenuItem).

Neutralizing the chevron's auto margin lets the shortcut absorb the free space, so the shortcut/chevron pair sits flush right and the shortcut column stays stable:

suggestion
1 className="[&>svg:last-child]:ms-0"
2 aria-keyshortcuts={action.shortcut}

Posted via Macroscope — UI Consistency

apps/web/src/components/preview/PreviewMoreMenu.tsx · Earlier revision b7395b6c · right 201

Original diff
@@ -177,12 +188,27 @@ export function PreviewMoreMenu({
           </span>
         </MenuItem>
         <MenuSeparator />
-        <MenuItem onClick={() => void bridge.clearCookies().catch(() => undefined)}>
-          Clear cookies
-        </MenuItem>
-        <MenuItem onClick={() => void bridge.clearCache().catch(() => undefined)}>
-          Clear cache
-        </MenuItem>
+        {/*
+          Grouped so the heading has a `MenuGroup` ancestor — `MenuGroupLabel`
+          reads its context and throws without one. The heading also answers
+          which profile the tab is in, which is otherwise invisible: it is fixed
+          at open and nothing else in the chrome shows it.
+        */}
+        <MenuGroup>
+          {profileName ? <MenuGroupLabel>Profile: {profileName}</MenuGroupLabel> : null}
+          <MenuItem
+            onClick={() =>
+              void bridge.clearCookies(environmentId, profileId).catch(() => undefined)
M
macroscopeappBotAug 16, 2026

🟠 High preview/PreviewMoreMenu.tsx:201

The clear actions target the built-in Default partition when profileId is omitted, even though the menu displays the user's configured default profile. Because PreviewMoreMenu passes the raw optional ID to clearCookies and clearCache, profile-less legacy tabs can clear the wrong profile's data. Resolve the missing ID to DEFAULT_BROWSER_PROFILE_ID before invoking either action.

Also found in 1 other location(s)

apps/web/src/components/preview/PreviewView.tsx:150

activeProfile treats a missing snapshot.profileId as the user's currently configured default profile. However, desktop partition resolution treats undefined as the built-in Default profile, and PreviewMoreMenu still receives the original undefined ID. After upgrading with an existing profile-less tab and selecting a custom default profile, the menu labels the clear actions with that custom profile's name while the actions actually clear the built-in Default profile's cookies/cache. Resolve an absent snapshot ID as DEFAULT_BROWSER_PROFILE_ID so the displayed target matches the cleared partition.

No longer relevant as of 61d5a1136cade87d21fb98f42d5e93d91b3fdad5

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 61d5a113 · right 515

Original diff
@@ -453,11 +465,172 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const updateSettings = useUpdatePrimarySettings();
+  const environmentId = usePrimaryEnvironment()?.environmentId;
+
+  const addProfile = () => {
+    if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...userProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    updateSettings({
+      browserProfiles: userProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = (id: string) => {
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    if (environmentId) {
+      void previewBridge?.clearCookies(environmentId, id).catch(() => undefined);
+      void previewBridge?.clearCache(environmentId, id).catch(() => undefined);
+    }
+    updateSettings({
+      browserProfiles: userProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(defaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}),
+    });
C
cursorBotAug 16, 2026

Profile delete skips other environments

Medium Severity

Removing a profile clears cookies/cache only for the primary environmentId. Partitions are scoped per environment, so other connected environments keep that profile’s data on disk after the profile disappears from settings.

Triggered by learned rule: Multi-environment identity scoping: keys and lookups must carry environmentId

Reviewed by Cursor Bugbot for commit 61d5a1136cade87d21fb98f42d5e93d91b3fdad5. Configure here.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision fe1bc48d · right 515

Original diff
@@ -453,11 +465,172 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const updateSettings = useUpdatePrimarySettings();
+  const environmentId = usePrimaryEnvironment()?.environmentId;
+
+  const addProfile = () => {
+    if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...userProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    updateSettings({
+      browserProfiles: userProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = (id: string) => {
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    if (environmentId) {
+      void previewBridge?.clearCookies(environmentId, id).catch(() => undefined);
+      void previewBridge?.clearCache(environmentId, id).catch(() => undefined);
+    }
+    updateSettings({
+      browserProfiles: userProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(defaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}),
+    });
C
cursorBotAug 16, 2026

Deleted profile cookies can remain

Medium Severity

Removing a profile calls clear cookies/cache for that profile, but clear only touches sessions already in the in-memory map. resolveClearPartitions derives the partition string without creating the session first, so after a restart—or if no tab under that profile was opened this process—the clear is a no-op and persistent cookies stay on disk with no UI entry left.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit fe1bc48d0b0d5c13e5282191ae42e0973f15d539. Configure here.

apps/web/src/components/preview/PreviewMoreMenu.tsx · Earlier revision 30459491 · right 209–211

Original diff
@@ -177,12 +193,36 @@ export function PreviewMoreMenu({
           </span>
         </MenuItem>
         <MenuSeparator />
-        <MenuItem onClick={() => void bridge.clearCookies().catch(() => undefined)}>
-          Clear cookies
-        </MenuItem>
-        <MenuItem onClick={() => void bridge.clearCache().catch(() => undefined)}>
-          Clear cache
-        </MenuItem>
+        {/*
+          Grouped so the heading has a `MenuGroup` ancestor — `MenuGroupLabel`
+          reads its context and throws without one. The heading also answers
+          which profile the tab is in, which is otherwise invisible: it is fixed
+          at open and nothing else in the chrome shows it.
+        */}
+        <MenuGroup>
+          {/*
+            The heading carries the profile so the actions below can keep
+            fixed-length labels: repeating a name of up to 48 characters in
+            each one drove the popup far past its width.
+          */}
+          {profileName ? (
+            <MenuGroupLabel>
+              <span className="min-w-0 truncate">Profile: {profileName}</span>
+            </MenuGroupLabel>

apps/web/src/components/preview/PreviewMoreMenu.tsx · right 209–211

M
macroscopeappBotAug 17, 2026

preview/PreviewMoreMenu.tsx:211

truncate on an inner <span> has no effect here: MenuGroupLabel renders a block div, so the span stays an inline box, and overflow/text-overflow don't apply to inline non-replaced boxes. Only white-space: nowrap takes effect, so a 48-character profile name can't wrap and expands the popup's max-content width well past min-w-56 — the exact widening the comment above says this avoids.

Moving the truncation onto the label itself (a block box) with a max width both caps the popup and yields a real ellipsis, matching the max-w-56 + truncating-item pattern used for the profile submenu in RightPanelTabs:

suggestion
1 <MenuGroupLabel className="max-w-64 truncate">Profile: {profileName}</MenuGroupLabel>

No longer relevant as of 7aa90aad3cfd7b8c54854e2f404989a2ce390355

apps/desktop/src/ipc/methods/preview.ts · Earlier revision e5a0a33d · right 241–241

Original diff
@@ -196,33 +199,84 @@ export const closePictureInPicture = tabMethod(
 
 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* manager.clearCookies(yield* resolveClearPartitions(manager, environmentId, profileId));
   }),
 });
 
 export const clearCache = DesktopIpc.makeIpcMethod({
   channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL,
-  payload: Schema.Void,
+  payload: DesktopPreviewClearDataInputSchema,
   result: Schema.Void,
-  handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () {
+  handler: Effect.fn("desktop.ipc.preview.clearCache")(function* ({ environmentId, profileId }) {
     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 };
+  }
+  // Encode each component independently so delimiters inside either id cannot
+  // make two distinct pairs hash to the same Electron partition.
+  return {
+    scope: `${encodeURIComponent(environmentId)}::${encodeURIComponent(profileId)}`,

apps/desktop/src/ipc/methods/preview.ts · right 241–241

M
macroscopeappBotAug 29, 2026

🟠 High methods/preview.ts:241

resolvePartitionScope throws URIError for a profile or environment ID containing a lone surrogate, so getPreviewConfig and profile-scoped data clearing fail even though the IPC schema accepts the ID. Normalize the strings to well-formed Unicode before calling encodeURIComponent so these requests still derive a partition.

diff
1- scope: `${encodeURIComponent(environmentId)}::${encodeURIComponent(profileId)}`,
2+ scope: `${encodeURIComponent(environmentId.toWellFormed())}::${encodeURIComponent(profileId.toWellFormed())}`,
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/ipc/methods/preview.ts around line 241:
2
3`resolvePartitionScope` throws `URIError` for a profile or environment ID containing a lone surrogate, so `getPreviewConfig` and profile-scoped data clearing fail even though the IPC schema accepts the ID. Normalize the strings to well-formed Unicode before calling `encodeURIComponent` so these requests still derive a partition.

packages/contracts/src/browserProfile.ts · Earlier revision fe1bc48d · right 67–70

Original diff
@@ -0,0 +1,78 @@
+/**
+ * Browser profiles - named identities for the in-app preview browser.
+ *
+ * Each profile maps to its own Electron session partition, so cookies and
+ * storage are isolated between them: a tab opened under "Work" cannot see
+ * "Personal"'s logins. Profiles are client-local, like the other browser
+ * defaults, because the Chromium guest they configure is desktop-local.
+ *
+ * Two profiles are built in and cannot be edited or removed:
+ * - `default` keeps the partition scope the browser used before profiles
+ *   existed, so upgrading does not sign anyone out.
+ * - `incognito` maps to a non-persistent partition for throwaway sessions.
+ *
+ * @module BrowserProfile
+ */
+import { Schema } from "effect";
+import { TrimmedNonEmptyString } from "./baseSchemas.ts";
+
+export const BROWSER_PROFILE_NAME_MAX_LENGTH = 48;
+export const BROWSER_PROFILE_MAX_COUNT = 24;
+
+export const BrowserProfileId = TrimmedNonEmptyString.check(Schema.isMaxLength(64));
+export type BrowserProfileId = typeof BrowserProfileId.Type;
+
+export const BrowserProfileName = TrimmedNonEmptyString.check(
+  Schema.isMaxLength(BROWSER_PROFILE_NAME_MAX_LENGTH),
+);
+
+/**
+ * `persistent` profiles keep cookies on disk across restarts; `incognito`
+ * uses an in-memory partition that Chromium discards with the process.
+ */
+export const BrowserProfileKind = Schema.Literals(["persistent", "incognito"]);
+export type BrowserProfileKind = typeof BrowserProfileKind.Type;
+
+export const BrowserProfile = Schema.Struct({
+  id: BrowserProfileId,
+  name: BrowserProfileName,
+  kind: BrowserProfileKind,
+});
+export type BrowserProfile = typeof BrowserProfile.Type;
+
+export const DEFAULT_BROWSER_PROFILE_ID: BrowserProfileId = "default";
+export const INCOGNITO_BROWSER_PROFILE_ID: BrowserProfileId = "incognito";
+
+/**
+ * Built-ins are synthesized rather than stored, so they cannot be renamed out
+ * of existence or deleted by editing the settings file by hand.
+ */
+export const BUILT_IN_BROWSER_PROFILES: ReadonlyArray<BrowserProfile> = [
+  { id: DEFAULT_BROWSER_PROFILE_ID, name: "Default", kind: "persistent" },
+  { id: INCOGNITO_BROWSER_PROFILE_ID, name: "Incognito", kind: "incognito" },
+];
+
+export function isBuiltInBrowserProfileId(id: string): boolean {
+  return BUILT_IN_BROWSER_PROFILES.some((profile) => profile.id === id);
+}
+
+/**
+ * The full picker list: built-ins first, then the user's own profiles with any
+ * entry that collides with a built-in id dropped, so a hand-edited settings
+ * file cannot shadow "Default" or "Incognito".
+ */
+export function resolveBrowserProfiles(
+  userProfiles: ReadonlyArray<BrowserProfile>,
+): ReadonlyArray<BrowserProfile> {
+  return [
+    ...BUILT_IN_BROWSER_PROFILES,
+    ...userProfiles.filter((profile) => !isBuiltInBrowserProfileId(profile.id)),
+  ];

packages/contracts/src/browserProfile.ts · right 67–70

M
macroscopeappBotAug 16, 2026

🟡 Medium src/browserProfile.ts:67

When userProfiles contains two distinct entries with the same id, resolveBrowserProfiles returns both identities even though they map to the same Electron partition, so cookies and logins leak between profiles the picker presents as isolated and profile lookup becomes ambiguous. The filter removes only built-in collisions; deduplicate user IDs (or reject duplicate IDs during settings validation) before returning the resolved list.

Also found in 1 other location(s)

packages/contracts/src/settings.ts:156

browserProfiles validates each entry but does not enforce unique BrowserProfile.id values. A hand-edited or otherwise persisted settings array containing two profiles with the same ID decodes successfully; both profiles are then presented separately but resolve to the same Electron partition, so cookies/logins unexpectedly leak between profiles that the UI represents as isolated.

No longer relevant as of 61d5a1136cade87d21fb98f42d5e93d91b3fdad5

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 11dc3664 · right 531

Original diff
@@ -453,11 +507,279 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const updateSettings = useUpdatePrimarySettings();
+  const { environments, isReady: environmentsReady } = useEnvironments();
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+  const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);
+  const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);
+  const removalAvailable = browserProfileRemovalAvailable(
+    previewBridge !== null,
+    environmentsReady,
+    environments.length,
+  );
+
+  const addProfile = () => {
+    const currentProfiles = getClientSettings().browserProfiles;
M
macroscopeappBotAug 29, 2026

🟡 Medium settings/IntegrationsSettings.tsx:531

Clicking Add profile before client settings hydration completes writes DEFAULT_CLIENT_SETTINGS plus the new profile, overwriting persisted profiles and other client-local preferences. addProfile reads getClientSettings() during the hydration race; disable the action until hydration finishes or apply the update only after using the hydrated snapshot.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/settings/IntegrationsSettings.tsx around line 531:
2
3Clicking `Add profile` before client settings hydration completes writes `DEFAULT_CLIENT_SETTINGS` plus the new profile, overwriting persisted profiles and other client-local preferences. `addProfile` reads `getClientSettings()` during the hydration race; disable the action until hydration finishes or apply the update only after using the hydrated snapshot.
J
Julius MarmingeAug 29, 2026

Fixed at #7254 fe5480b817 and preserved through the table rewrite in #7255 d95c81c935. Profile creation, rename, removal, default selection/reset, and import setup now use the existing client-settings hydration gate, including handler guards. The upper import path reads latest hydrated settings for registration after await. Lower web typecheck and 50 focused tests pass.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 0009cb1d · right 524

Original diff
@@ -453,11 +476,205 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const updateSettings = useUpdatePrimarySettings();
+  const environmentId = usePrimaryEnvironment()?.environmentId;
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+
+  const addProfile = () => {
+    if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...userProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    updateSettings({
+      browserProfiles: userProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = (id: string) => {
+    setProfilePendingRemoval(null);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    if (environmentId) {
+      void previewBridge?.clearCookies(environmentId, id).catch(() => undefined);
+      void previewBridge?.clearCache(environmentId, id).catch(() => undefined);
+    }
+    updateSettings({
M
macroscopeappBotAug 16, 2026

🟡 Medium settings/IntegrationsSettings.tsx:524

Removing a profile leaves existing tabs attached to its Electron partition, so those tabs can recreate cookies/cache after clearCookies and clearCache, leaving data with no profile UI to manage it. The dialog promises that open tabs move to the default profile, but this code only changes browserDefaultProfileId for future tabs; close or migrate the profile's existing tabs before clearing and removing it.

No longer relevant as of 3045949155d67b9a31a9426eb832c3d0c24ecf64

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 316224d9 · right 547–547

Original diff
@@ -453,11 +476,205 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const updateSettings = useUpdatePrimarySettings();
+  const environmentId = usePrimaryEnvironment()?.environmentId;
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+
+  const addProfile = () => {
+    if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...userProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    updateSettings({
+      browserProfiles: userProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = (id: string) => {
+    setProfilePendingRemoval(null);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    if (environmentId) {
+      void previewBridge?.clearCookies(environmentId, id).catch(() => undefined);
+      void previewBridge?.clearCache(environmentId, id).catch(() => undefined);
+    }
+    updateSettings({
+      browserProfiles: userProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(defaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}),
+    });
+  };
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-profiles")}
+      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      control={
+        <Button
+          size="sm"
+          variant="outline"
+          disabled={disabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
+          onClick={addProfile}
+        >
+          <PlusIcon />
+          Add profile
+        </Button>
+      }
+    >
+      <div className="mt-1 space-y-1">

apps/web/src/components/settings/IntegrationsSettings.tsx · right 547–547

M
macroscopeappBotAug 17, 2026

settings/IntegrationsSettings.tsx:547

On non-Electron clients this row renders inside DesktopOnlyBrowserDefaults, whose unavailability treatment only dims h3/p ([&_h3]:opacity-64 [&_p]:opacity-64) and relies on each control's own disabled opacity for the rest. The built-in rows here are neither: <span className="… text-foreground"> plus Badge render at full contrast, and with no stored profiles they are the only content in the block, so "Default / Incognito" reads as active while every neighbouring row is dimmed and disabled.

Suggest dimming the list when disabled, e.g. className={cn("mt-1 space-y-1", disabled && "opacity-64")} (needs cn added to the ~/lib/utils import), so the profile rows follow the same unavailable treatment as the rest of the block.

No longer relevant as of 5b85b1db9841333a84c08e3dff77ebaa59fbb923

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 316224d9 · right 499

Original diff
@@ -453,11 +476,205 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const updateSettings = useUpdatePrimarySettings();
+  const environmentId = usePrimaryEnvironment()?.environmentId;
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+
+  const addProfile = () => {
+    if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
M
macroscopeappBotAug 17, 2026

🟡 Medium settings/IntegrationsSettings.tsx:499

Clicking Add profile twice before React rerenders creates only one profile instead of two, because both calls build browserProfiles from the same render-captured userProfiles array and the second updateSettings replaces the first patch. Disable or debounce the action while updating, or use an atomic updater based on the latest settings snapshot.

No longer relevant as of 2d101531aec510d521de30e8772dd011b05b7232

J
Julius MarmingeAug 29, 2026

Fixed in 2d101531ae. Add, rename, and post-cleanup removal re-read the current settings snapshot before writing, avoiding stale render snapshots.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 05ae35c3 · right 528

Original diff
@@ -453,11 +476,227 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const updateSettings = useUpdatePrimarySettings();
+  const environmentId = usePrimaryEnvironment()?.environmentId;
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+
+  const addProfile = () => {
+    if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...userProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    updateSettings({
+      browserProfiles: userProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = (id: string) => {
+    setProfilePendingRemoval(null);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    if (environmentId) {
+      void previewBridge?.clearCookies(environmentId, id).catch(() => undefined);
+      void previewBridge?.clearCache(environmentId, id).catch(() => undefined);
+    }
+    updateSettings({
+      browserProfiles: userProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(defaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}),
+    });
C
cursorBotAug 28, 2026

Deleted tabs do not switch profiles

Medium Severity

Removing a profile only wipes that partition and drops it from settings. Open tabs keep the deleted profileId and stay attached to the now-empty partition. The confirmation dialog still says those tabs move to the default profile, which does not happen.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 05ae35c3b79430e2247eaba8101036fb860a29aa. Configure here.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 316224d9 · right 551–551

Original diff
@@ -453,11 +476,205 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const updateSettings = useUpdatePrimarySettings();
+  const environmentId = usePrimaryEnvironment()?.environmentId;
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+
+  const addProfile = () => {
+    if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...userProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    updateSettings({
+      browserProfiles: userProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = (id: string) => {
+    setProfilePendingRemoval(null);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    if (environmentId) {
+      void previewBridge?.clearCookies(environmentId, id).catch(() => undefined);
+      void previewBridge?.clearCache(environmentId, id).catch(() => undefined);
+    }
+    updateSettings({
+      browserProfiles: userProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(defaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}),
+    });
+  };
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-profiles")}
+      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      control={
+        <Button
+          size="sm"
+          variant="outline"
+          disabled={disabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
+          onClick={addProfile}
+        >
+          <PlusIcon />
+          Add profile
+        </Button>
+      }
+    >
+      <div className="mt-1 space-y-1">
+        {resolveBrowserProfiles(userProfiles).map((profile) => {
+          const builtIn = isBuiltInBrowserProfileId(profile.id);
+          return (
+            <div key={profile.id} className={ITEM_ROW_INNER_CLASSNAME}>

apps/web/src/components/settings/IntegrationsSettings.tsx · right 551–551

M
macroscopeappBotAug 17, 2026

settings/IntegrationsSettings.tsx:551

ITEM_ROW_INNER_CLASSNAME stacks (flex flex-col gap-3) below sm, and it is written for rows that also carry ITEM_ROW_CLASSNAME's py-3 (see ConnectionsSettings.tsx and CloudEnvironmentConnectList.tsx, which always pair the two). Here the rows are bare inside a space-y-1 list, so on a narrow viewport each profile's Remove button drops 12px below its own name input while the next profile's input is only 4px away — the button visually groups with the wrong profile.

These rows are one input plus one icon button, so a plain single-line row avoids the stacking entirely:

diff
1- <div key={profile.id} className={ITEM_ROW_INNER_CLASSNAME}>
2+ <div key={profile.id} className="flex items-center justify-between gap-2">

This is the only use of ITEM_ROW_INNER_CLASSNAME in the file, so its import can go with it.

No longer relevant as of 3045949155d67b9a31a9426eb832c3d0c24ecf64

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision cd654b4c · right 833

Original diff
@@ -501,11 +556,297 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const settingsHydrated = useClientSettingsHydrated();
+  const updateSettings = useUpdatePrimarySettings();
+  const { environments, isReady: environmentsReady } = useEnvironments();
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+  const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);
+  const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);
+  const removalAvailable = browserProfileRemovalAvailable(
+    previewBridge !== null,
+    environmentsReady,
+    environments.length,
+  );
+  const profileWritesDisabled = disabled || !settingsHydrated;
+
+  const addProfile = () => {
+    if (!settingsHydrated) return;
+    const currentProfiles = getClientSettings().browserProfiles;
+    if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...currentProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    if (!settingsHydrated) return;
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    const currentProfiles = getClientSettings().browserProfiles;
+    updateSettings({
+      browserProfiles: currentProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = async (id: string) => {
+    if (!settingsHydrated) return;
+    if (!removalAvailable) {
+      setProfileRemovalError("Connect to an environment before removing this profile.");
+      return;
+    }
+    setProfileRemovalError(null);
+    setProfileRemovalInFlight(true);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    try {
+      await clearBrowserProfileData(
+        previewBridge,
+        environmentsReady ? environments.map((environment) => environment.environmentId) : [],
+        id,
+      );
+    } catch {
+      setProfileRemovalError("Profile data could not be deleted. Try again.");
+      setProfileRemovalInFlight(false);
+      return;
+    }
+    const currentSettings = getClientSettings();
+    updateSettings({
+      browserProfiles: currentSettings.browserProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(currentSettings.browserDefaultProfileId === id
+        ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }
+        : {}),
+    });
+    setProfileRemovalInFlight(false);
+    setProfilePendingRemoval(null);
+  };
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-profiles")}
+      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      control={
+        <Button
+          size="sm"
+          variant="outline"
+          disabled={profileWritesDisabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
+          onClick={addProfile}
+        >
+          <PlusIcon />
+          Add profile
+        </Button>
+      }
+    >
+      {/*
+        Each profile is its own bounded row, and the list carries the bottom
+        spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows
+        stack on narrow viewports with a larger gap inside a row than between
+        rows, which reads as the remove button belonging to the profile below.
+      */}
+      <div className="mt-2 space-y-1 pb-2">
+        {resolveBrowserProfiles(userProfiles).map((profile) => {
+          const builtIn = isBuiltInBrowserProfileId(profile.id);
+          return (
+            <div
+              key={profile.id}
+              className={cn(
+                ITEM_ROW_INNER_CLASSNAME,
+                "rounded-lg border border-border/60 px-3 py-2",
+              )}
+            >
+              {builtIn ? (
+                // Dimmed here rather than on the list, which is the only
+                // content in the row without a disabled treatment of its own:
+                // a wrapper-level dim would stack with the rename field's and
+                // the remove button's, landing them near 0.41 while every
+                // other disabled control in the block sits at 0.64.
+                <span
+                  className={cn(
+                    "flex min-w-0 items-center gap-2 text-sm text-foreground",
+                    profileWritesDisabled && "opacity-64",
+                  )}
+                >
+                  {profile.name}
+                  <Badge variant="outline">
+                    {profile.kind === "incognito" ? "Ephemeral" : "Built-in"}
+                  </Badge>
+                </span>
+              ) : (
+                <DraftInput
+                  nativeInput
+                  size="sm"
+                  className="w-full sm:w-64"
+                  aria-label={`Rename ${profile.name}`}
+                  disabled={profileWritesDisabled}
+                  maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
+                  value={profile.name}
+                  onCommit={(next) => renameProfile(profile.id, next)}
+                />
+              )}
+              {builtIn ? null : (
+                <Tooltip>
+                  <TooltipTrigger
+                    render={
+                      <span className="inline-flex" {...(!removalAvailable ? { tabIndex: 0 } : {})}>
+                        <Button
+                          size="icon-sm"
+                          variant="ghost-muted"
+                          disabled={profileWritesDisabled || !removalAvailable}
+                          aria-label={`Remove ${profile.name}`}
+                          onClick={() => setProfilePendingRemoval(profile)}
+                        >
+                          <Trash2Icon />
+                        </Button>
+                      </span>
+                    }
+                  />
+                  <TooltipPopup side="top">
+                    {removalAvailable
+                      ? "Remove profile and its data"
+                      : "Connect to an environment to remove this profile"}
+                  </TooltipPopup>
+                </Tooltip>
+              )}
+            </div>
+          );
+        })}
+      </div>
+      <AlertDialog
+        open={profilePendingRemoval !== null}
+        onOpenChange={(open) => {
+          if (!open && !profileRemovalInFlight) {
+            setProfilePendingRemoval(null);
+            setProfileRemovalError(null);
+          }
+        }}
+      >
+        <AlertDialogPopup>
+          <AlertDialogHeader>
+            <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle>
+            <AlertDialogDescription>
+              Its cookies, logins, and cache are deleted with it. Tabs already open in this profile
+              stay open until you close them.
+            </AlertDialogDescription>
+            {profileRemovalError ? (
+              <p aria-live="polite" className="text-sm text-destructive">
+                {profileRemovalError}
+              </p>
+            ) : null}
+            {!removalAvailable ? (
+              <p className="text-sm text-muted-foreground">
+                Connect to an environment to remove this profile and its data.
+              </p>
+            ) : null}
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogClose
+              disabled={profileRemovalInFlight}
+              render={<Button variant="outline" disabled={profileRemovalInFlight} />}
+            >
+              Cancel
+            </AlertDialogClose>
+            <Button
+              variant="destructive"
+              disabled={profileRemovalInFlight || !settingsHydrated || !removalAvailable}
+              onClick={() => {
+                if (profilePendingRemoval && settingsHydrated && removalAvailable) {
+                  void removeProfile(profilePendingRemoval.id);
+                }
+              }}
+            >
+              {profileRemovalInFlight ? "Removing…" : "Remove profile"}
+            </Button>
+          </AlertDialogFooter>
+        </AlertDialogPopup>
+      </AlertDialog>
+    </SettingsRow>
+  );
+}
+
+function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const settingsHydrated = useClientSettingsHydrated();
+  const updateSettings = useUpdatePrimarySettings();
+  const profileWritesDisabled = disabled || !settingsHydrated;
+  // Incognito is deliberately absent: as a default it would open every tab
+  // into storage that is discarded on close.
+  const profiles = resolveBrowserProfiles(userProfiles).filter(
+    (profile) => profile.kind !== "incognito",
+  );
+  const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0];
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-default-profile")}
+      description="Profile new browser tabs open under, including tabs an agent opens."
+      resetAction={
+        !profileWritesDisabled && defaultProfileId !== DEFAULT_BROWSER_PROFILE_ID ? (
+          <SettingResetButton
+            label="default browser profile"
+            onClick={() => {
+              if (settingsHydrated) {
+                updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID });
+              }
+            }}
+          />
+        ) : null
+      }
+      control={
+        <Select
+          disabled={profileWritesDisabled}
+          value={selected?.id ?? DEFAULT_BROWSER_PROFILE_ID}
+          onValueChange={(value) => {
+            if (settingsHydrated && value !== null) {
+              updateSettings({ browserDefaultProfileId: value });
+            }
+          }}
+        >
+          <SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Default browser profile">
+            <SelectValue>{selected?.name ?? "Default"}</SelectValue>
+          </SelectTrigger>
+          {/*
+            Capped and truncated like the tab menu's profile list: names are
+            user-supplied and run to 48 characters, which would otherwise
+            widen the popup to fit the longest one.
+          */}
+          <SelectPopup align="end" alignItemWithTrigger={false} className="max-w-64">
+            {profiles.map((profile) => (
+              <SelectItem hideIndicator key={profile.id} value={profile.id}>
+                <span className="min-w-0 truncate">{profile.name}</span>
C
cursorBotSep 2, 2026

Profile names do not truncate

Medium Severity

The default-profile picker caps the list at max-w-64 and wraps names in a truncate span, but SelectItem renders children inside ItemText, so that span is an inline descendant rather than a flex item. text-overflow never applies, and whitespace-nowrap from truncate stops wrapping. Long profile names (up to 48 characters) overflow or clip without an ellipsis after the width cap.

Reviewed by Cursor Bugbot for commit cd654b4cf8597af890c8311852acda84bacde5e5. Configure here.

apps/web/src/components/preview/PreviewView.tsx · Earlier revision 05ae35c3 · right 708–708

Original diff
@@ -683,9 +693,29 @@ export function PreviewView({
         pickDisabledReason={
           isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined
         }
+        leadingActions={
+          // Only when it differs from the default: labelling every tab
+          // "Default" would be noise on the common case, while a tab in
+          // another profile is exactly what needs calling out.
+          activeProfile && activeProfile.id !== browserDefaults.profileId ? (
+            // Capped: profile names run to 48 characters, and an unbounded
+            // badge in this row takes its width from the URL input, the only
+            // flexible element in the compact chrome. The cap sits on the
+            // badge and the truncation on an inner span, because `Badge` is an
+            // `inline-flex` with `whitespace-nowrap` — `text-overflow` never
+            // reaches a bare text node inside it, so the name would be cut off
+            // at both ends with no ellipsis.
+            <Badge variant="outline" className="max-w-28 shrink-0" title={activeProfile.name}>

apps/web/src/components/preview/PreviewView.tsx · right 708–708

M
macroscopeappBotAug 28, 2026

preview/PreviewView.tsx:708

The full profile name is surfaced with a native title, which the repo has moved away from (t3code/no-native-title-tooltip, "Use Tooltip + TooltipTrigger + TooltipPopup from components/ui/tooltip"). It only escapes the rule because Badge is a capitalized component — Badge renders a <span> and forwards the prop straight through, so the DOM ends up with exactly the banned native tooltip. Every other hover label in this same chrome row (PreviewChromeRow's nav buttons) and every other truncated label elsewhere (ServerUpdateAction, ProviderModelPicker, DiffPanel) uses the styled Tooltip, which is themed and shows on keyboard focus; the native one is neither.

Suggest wrapping the badge in Tooltip (also needs import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";):

diff
1- <Badge variant="outline" className="max-w-28 shrink-0" title={activeProfile.name}>
2- <span className="truncate">{activeProfile.name}</span>
3- </Badge>
4+ <Tooltip>
5+ <TooltipTrigger render={<Badge variant="outline" className="max-w-28 shrink-0" />}>
6+ <span className="truncate">{activeProfile.name}</span>
7+ </TooltipTrigger>
8+ <TooltipPopup side="bottom">{activeProfile.name}</TooltipPopup>
9+ </Tooltip>

No longer relevant as of 2d101531aec510d521de30e8772dd011b05b7232

J
Julius MarmingeAug 29, 2026

Fixed in ee363dafcd3. The profile badge uses the shared Tooltip components and preserves truncation.

apps/desktop/src/ipc/methods/preview.ts · Earlier revision 05ae35c3 · right 235–235

Original diff
@@ -196,33 +199,77 @@ export const closePictureInPicture = tabMethod(
 
 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* manager.clearCookies(yield* resolveClearPartitions(manager, environmentId, profileId));
   }),
 });
 
 export const clearCache = DesktopIpc.makeIpcMethod({
   channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL,
-  payload: Schema.Void,
+  payload: DesktopPreviewClearDataInputSchema,
   result: Schema.Void,
-  handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () {
+  handler: Effect.fn("desktop.ipc.preview.clearCache")(function* ({ environmentId, profileId }) {
     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.
+ */
+function resolvePartitionScope(
+  environmentId: string,
+  profileId: string | undefined,
+): { readonly scope: string; readonly persistent: boolean } {
+  if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) {
+    return { scope: environmentId, persistent: true };
+  }
+  return {
+    scope: `${environmentId}::${profileId}`,

apps/desktop/src/ipc/methods/preview.ts · right 235–235

M
macroscopeappBotAug 28, 2026

🟠 High methods/preview.ts:235

Distinct environment/profile pairs can resolve to the same Electron partition, causing tabs to share cookies and storage across supposedly isolated contexts. Because EnvironmentId and BrowserProfileId permit :, the ::-joined scope is ambiguous; encode each identifier before joining them.

Also found in 1 other location(s)

packages/contracts/src/browserProfile.ts:29

BrowserProfileId allows : (including ::), while the desktop partition scope is constructed as ${environmentId}::${profileId}; EnvironmentId is also only a trimmed nonempty string. Consequently distinct pairs such as environment a/profile b::c and environment a::b/profile c generate the same Chromium partition. Tabs from those environments then share cookies and storage, defeating the profile/environment isolation this feature introduces.

✅ Resolved in 2d101531aec510d521de30e8772dd011b05b7232

J
Julius MarmingeAug 29, 2026

Fixed in 4271e1541ac and 082c63134a. Scope components are encoded independently, and nondefault profiles use a disjoint partition namespace. Tests cover delimiter-pair collisions, default environment a::b versus environment a/profile b, and unchanged legacy Default partition names.

apps/web/src/browser/openFileInPreview.ts · Earlier revision 61d5a113 · right 54

Original diff
@@ -43,7 +45,15 @@ export async function openUrlInPreview<E>(input: {
 }): Promise<AtomCommandResult<void, E>> {
   const result = await input.openPreview({
     environmentId: input.threadRef.environmentId,
-    input: { threadId: input.threadRef.threadId, url: input.url },
+    input: {
+      threadId: input.threadRef.threadId,
+      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(),
M
macroscopeappBotAug 16, 2026

🟡 Medium browser/openFileInPreview.ts:54

Opening a file or link before client-settings hydration permanently creates the tab with the schema-default viewport and profile instead of the user's configured defaults. browserDefaultOpenViewport() and browserDefaultOpenProfileId() read the pre-hydration snapshot synchronously; resolve the hydrated defaults first, as openPreviewSession does, before invoking openPreview.

Also found in 1 other location(s)

apps/web/src/components/preview/openTerminalLinkInPreview.ts:93

openTerminalLinkInPreview reads the viewport/profile via synchronous browserDefaultOpenViewport() and browserDefaultOpenProfileId(). Those accessors use the current settings snapshot, while resolveBrowserDefaults() explicitly waits for client-settings hydration because pre-hydration values are schema defaults. If a terminal link is opened before hydration finishes, the new tab is permanently created with the schema-default viewport/profile instead of the user's configured defaults. This path should resolve hydrated defaults before invoking openPreview.

No longer relevant as of 2d101531aec510d521de30e8772dd011b05b7232

J
Julius MarmingeAug 29, 2026

Fixed in 4dca51e1f5e. File/URL and terminal-link opens await settings hydration and use viewport/profile from the same hydrated snapshot; focused tests cover a deferred hydration promise.

apps/web/src/browser/previewWebviewConfigState.ts · Earlier revision b7395b6c · right 66–75

Original diff
@@ -45,32 +45,51 @@ type PreviewConfigBridge = Pick<DesktopPreviewBridge, "getPreviewConfig">;
 
 export const loadPreviewWebviewConfig = (
   environmentId: EnvironmentId,
+  profileId?: string,
   bridge: PreviewConfigBridge | null = previewBridge,
 ): Effect.Effect<DesktopPreviewWebviewConfig, PreviewWebviewConfigError> => {
   if (bridge === null) {
     return Effect.fail(new PreviewWebviewBridgeUnavailableError({ environmentId }));
   }
 
   return Effect.tryPromise({
-    try: () => bridge.getPreviewConfig(environmentId),
+    try: () => bridge.getPreviewConfig(environmentId, profileId),
     catch: (cause) => new PreviewWebviewConfigLoadError({ environmentId, cause }),
   });
 };
 
-const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) =>
-  Atom.make(loadPreviewWebviewConfig(environmentId)).pipe(
+/**
+ * `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.
+ */
+const configKey = (environmentId: EnvironmentId, profileId: string | undefined): string =>
+  `${environmentId}\u0000${profileId ?? ""}`;
+
+const parseConfigKey = (key: string): { environmentId: EnvironmentId; profileId?: string } => {
+  const [environmentId = "", profileId = ""] = key.split("\u0000");
+  return {
+    environmentId: environmentId as EnvironmentId,
+    ...(profileId === "" ? {} : { profileId }),
+  };
+};

apps/web/src/browser/previewWebviewConfigState.ts · right 66–75

M
macroscopeappBotAug 16, 2026

🟡 Medium browser/previewWebviewConfigState.ts:66

A valid profile ID containing \u0000, such as foo\u0000bar, is truncated to foo, so getPreviewConfig loads and returns the partition for the wrong profile. parseConfigKey splits the key on the delimiter even though the ID schemas allow that character; encode the environment/profile pair unambiguously, such as a JSON tuple, instead of splitting on a raw delimiter.

No longer relevant as of 0009cb1daf2c6fea1dbe1643f7ea9fcf298640f1

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 2d101531 · right 639

Original diff
@@ -453,11 +499,252 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const updateSettings = useUpdatePrimarySettings();
+  const { environments, isReady: environmentsReady } = useEnvironments();
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+  const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);
+  const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);
+
+  const addProfile = () => {
+    const currentProfiles = getClientSettings().browserProfiles;
+    if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...currentProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    const currentProfiles = getClientSettings().browserProfiles;
+    updateSettings({
+      browserProfiles: currentProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = async (id: string) => {
+    setProfileRemovalError(null);
+    setProfileRemovalInFlight(true);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    try {
+      await clearBrowserProfileData(
+        previewBridge,
+        environmentsReady ? environments.map((environment) => environment.environmentId) : [],
+        id,
+      );
+    } catch {
+      setProfileRemovalError("Profile data could not be deleted. Try again.");
+      setProfileRemovalInFlight(false);
+      return;
+    }
+    const currentSettings = getClientSettings();
+    updateSettings({
+      browserProfiles: currentSettings.browserProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(currentSettings.browserDefaultProfileId === id
+        ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }
+        : {}),
+    });
+    setProfileRemovalInFlight(false);
+    setProfilePendingRemoval(null);
+  };
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-profiles")}
+      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      control={
+        <Button
+          size="sm"
+          variant="outline"
+          disabled={disabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
+          onClick={addProfile}
+        >
+          <PlusIcon />
+          Add profile
+        </Button>
+      }
+    >
+      {/*
+        Each profile is its own bounded row, and the list carries the bottom
+        spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows
+        stack on narrow viewports with a larger gap inside a row than between
+        rows, which reads as the remove button belonging to the profile below.
+      */}
+      <div className="mt-2 space-y-1 pb-2">
+        {resolveBrowserProfiles(userProfiles).map((profile) => {
+          const builtIn = isBuiltInBrowserProfileId(profile.id);
+          return (
+            <div
+              key={profile.id}
+              className={cn(
+                ITEM_ROW_INNER_CLASSNAME,
+                "rounded-lg border border-border/60 px-3 py-2",
+              )}
+            >
+              {builtIn ? (
+                // Dimmed here rather than on the list, which is the only
+                // content in the row without a disabled treatment of its own:
+                // a wrapper-level dim would stack with the rename field's and
+                // the remove button's, landing them near 0.41 while every
+                // other disabled control in the block sits at 0.64.
+                <span
+                  className={cn(
+                    "flex min-w-0 items-center gap-2 text-sm text-foreground",
+                    disabled && "opacity-64",
+                  )}
+                >
+                  {profile.name}
+                  <Badge variant="outline">
+                    {profile.kind === "incognito" ? "Ephemeral" : "Built-in"}
+                  </Badge>
+                </span>
+              ) : (
+                <DraftInput
+                  nativeInput
+                  size="sm"
+                  className="w-full sm:w-64"
+                  aria-label={`Rename ${profile.name}`}
+                  disabled={disabled}
+                  maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
+                  value={profile.name}
+                  onCommit={(next) => renameProfile(profile.id, next)}
+                />
+              )}
+              {builtIn ? null : (
+                <Tooltip>
+                  <TooltipTrigger
+                    render={
+                      <Button
+                        size="icon-sm"
+                        variant="ghost-muted"
+                        disabled={disabled || !environmentsReady}
C
cursorBotAug 29, 2026

Empty catalog blocks profile removal

Low Severity

The remove control only waits for environmentsReady, but clearBrowserProfileData rejects an empty environment list. A ready catalog with no entries still enables Remove, then every confirmation fails and the profile stays.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 2d101531aec510d521de30e8772dd011b05b7232. Configure here.

J
Julius MarmingeAug 29, 2026

Fixed in fccf30726a8. Removal availability includes a desktop bridge and a ready, non-empty environment catalog. Both the row action and an already-open confirmation reflect unavailable cleanup, with an explanation; the handler retains its guard instead of deleting metadata without clearing data. Focused availability tests pass.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 438fc61e · right 520–523

Original diff
@@ -453,11 +476,227 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const updateSettings = useUpdatePrimarySettings();
+  const environmentId = usePrimaryEnvironment()?.environmentId;
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+
+  const addProfile = () => {
+    if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...userProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    updateSettings({
+      browserProfiles: userProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = (id: string) => {
+    setProfilePendingRemoval(null);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    if (environmentId) {
+      void previewBridge?.clearCookies(environmentId, id).catch(() => undefined);
+      void previewBridge?.clearCache(environmentId, id).catch(() => undefined);
+    }

apps/web/src/components/settings/IntegrationsSettings.tsx · right 520–523

M
macroscopeappBotAug 28, 2026

settings/IntegrationsSettings.tsx:523

The partition scope is ${environmentId}::${profileId}, but this settings panel has no environment scope of its own and falls back to the primary environment, so removal only clears the profile's partition in that one environment. A tab opened under this profile from a thread in another environment (PreviewMoreMenu passes threadRef.environmentId) keeps its cookies and cache on disk, and when environmentId is still null nothing is cleared at all — while the confirmation dialog states "Its cookies, logins, and cache are deleted with it."

Suggest either clearing across the client's known environments (useEnvironments()) so the promise holds, or softening the dialog copy to match what is actually deleted rather than guessing a single environment.

No longer relevant as of 2d101531aec510d521de30e8772dd011b05b7232

J
Julius MarmingeAug 29, 2026

Fixed in c8840bc05b. Profile removal clears the partition in every known environment before removing the global profile record.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 0009cb1d · right 520

Original diff
@@ -453,11 +476,205 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const updateSettings = useUpdatePrimarySettings();
+  const environmentId = usePrimaryEnvironment()?.environmentId;
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+
+  const addProfile = () => {
+    if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...userProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    updateSettings({
+      browserProfiles: userProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = (id: string) => {
+    setProfilePendingRemoval(null);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    if (environmentId) {
M
macroscopeappBotAug 16, 2026

🟡 Medium settings/IntegrationsSettings.tsx:520

Deleting a browser profile removes its global entry but clears cookies and cache only for the current primary environmentId, leaving data orphaned in every other environment that used the profile. Clear the profile partition for all environments where it may exist, or retain cleanup state until those partitions have been removed.

No longer relevant as of 3045949155d67b9a31a9426eb832c3d0c24ecf64

packages/contracts/src/browserProfile.ts · Earlier revision fe1bc48d · right 33

Original diff
@@ -0,0 +1,78 @@
+/**
+ * Browser profiles - named identities for the in-app preview browser.
+ *
+ * Each profile maps to its own Electron session partition, so cookies and
+ * storage are isolated between them: a tab opened under "Work" cannot see
+ * "Personal"'s logins. Profiles are client-local, like the other browser
+ * defaults, because the Chromium guest they configure is desktop-local.
+ *
+ * Two profiles are built in and cannot be edited or removed:
+ * - `default` keeps the partition scope the browser used before profiles
+ *   existed, so upgrading does not sign anyone out.
+ * - `incognito` maps to a non-persistent partition for throwaway sessions.
+ *
+ * @module BrowserProfile
+ */
+import { Schema } from "effect";
+import { TrimmedNonEmptyString } from "./baseSchemas.ts";
+
+export const BROWSER_PROFILE_NAME_MAX_LENGTH = 48;
+export const BROWSER_PROFILE_MAX_COUNT = 24;
+
+export const BrowserProfileId = TrimmedNonEmptyString.check(Schema.isMaxLength(64));
+export type BrowserProfileId = typeof BrowserProfileId.Type;
+
+export const BrowserProfileName = TrimmedNonEmptyString.check(
+  Schema.isMaxLength(BROWSER_PROFILE_NAME_MAX_LENGTH),
+);
+
+/**
+ * `persistent` profiles keep cookies on disk across restarts; `incognito`
+ * uses an in-memory partition that Chromium discards with the process.
+ */
+export const BrowserProfileKind = Schema.Literals(["persistent", "incognito"]);
M
macroscopeappBotAug 16, 2026

🟡 Medium src/browserProfile.ts:33

A custom profile with kind: "incognito" is shown as ephemeral but its cookies are persisted across restarts, because partition creation uses the special incognito ID rather than BrowserProfileKind. Reject kind: "incognito" for non-built-in IDs, or make partition creation derive persistence from kind.

No longer relevant as of 61d5a1136cade87d21fb98f42d5e93d91b3fdad5

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision b7395b6c · right 562–567

Original diff
@@ -453,11 +465,172 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const updateSettings = useUpdatePrimarySettings();
+  const environmentId = usePrimaryEnvironment()?.environmentId;
+
+  const addProfile = () => {
+    if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...userProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    updateSettings({
+      browserProfiles: userProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = (id: string) => {
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    if (environmentId) {
+      void previewBridge?.clearCookies(environmentId, id).catch(() => undefined);
+      void previewBridge?.clearCache(environmentId, id).catch(() => undefined);
+    }
+    updateSettings({
+      browserProfiles: userProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(defaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}),
+    });
+  };
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-profiles")}
+      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      control={
+        <Button
+          size="sm"
+          variant="outline"
+          disabled={disabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
+          onClick={addProfile}
+        >
+          <PlusIcon />
+          Add profile
+        </Button>
+      }
+    >
+      <div className="mt-1 space-y-1">
+        {resolveBrowserProfiles(userProfiles).map((profile) => {
+          const builtIn = isBuiltInBrowserProfileId(profile.id);
+          return (
+            <div key={profile.id} className={ITEM_ROW_INNER_CLASSNAME}>
+              {builtIn ? (
+                <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">
+                  {profile.name}
+                  <Badge variant="outline">
+                    {profile.kind === "incognito" ? "Ephemeral" : "Built-in"}
+                  </Badge>
+                </span>
+              ) : (
+                <DraftInput
+                  nativeInput
+                  size="sm"
+                  className="w-full sm:w-64"
+                  aria-label={`Rename ${profile.name}`}
+                  disabled={disabled}
+                  maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
+                  value={profile.name}
+                  onCommit={(next) => renameProfile(profile.id, next)}
+                />
+              )}
+              {builtIn ? null : (
+                <Tooltip>
+                  <TooltipTrigger
+                    render={
+                      <Button
+                        size="icon-sm"
+                        variant="ghost-muted"
+                        disabled={disabled}
+                        aria-label={`Remove ${profile.name}`}
+                        onClick={() => removeProfile(profile.id)}

apps/web/src/components/settings/IntegrationsSettings.tsx · right 562–567

M
macroscopeappBotAug 16, 2026

settings/IntegrationsSettings.tsx:567

This icon-only control deletes the profile and wipes its cookies/cache immediately and irreversibly, while every comparable destructive action in settings confirms first (ThemeSettings.tsx theme removal, ConnectionsSettings.tsx session revocation / Tailscale disable all route through AlertDialog). A stray click here silently destroys stored logins.

Suggest gating removeProfile behind the same AlertDialog confirmation used elsewhere in settings, naming the profile in the title, so the destructive path matches the established pattern.

No longer relevant as of 0009cb1daf2c6fea1dbe1643f7ea9fcf298640f1

apps/web/src/components/RightPanelTabs.tsx · Earlier revision a7801b6d · right 735–744

Original diff
@@ -695,14 +714,45 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
                   <Plus className="size-3.5" />
                 </MenuTrigger>
                 <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44">
-                  <SurfaceMenuItem
-                    available={props.browserAvailable}
-                    disabledReason={SURFACE_DISABLED_REASONS.browser}
-                    onClick={props.onAddBrowser}
-                  >
-                    <Globe2 />
-                    Browser
-                  </SurfaceMenuItem>
+                  {props.browserAvailable ? (
+                    <MenuSub>
+                      {/*
+                        Clicking the trigger opens the default profile, so the
+                        common case stays one click and there is no second row;
+                        hover or arrow reveals the rest. The choice lives at
+                        open time because a tab's profile is fixed then —
+                        Electron only honours a partition before attach.
+                      */}
+                      <MenuSubTrigger
+                        onClick={() => {
+                          setAddSurfaceMenuOpen(false);
+                          props.onAddBrowser();
+                        }}
+                      >
+                        <Globe2 />
+                        Browser
+                      </MenuSubTrigger>
+                      <MenuSubPopup className="min-w-40">
+                        {browserProfiles.map((profile) => (
+                          <MenuItem
+                            key={profile.id}
+                            onClick={() => props.onAddBrowserInProfile(profile.id)}
+                          >
+                            {profile.name}
+                          </MenuItem>
+                        ))}
+                      </MenuSubPopup>

apps/web/src/components/RightPanelTabs.tsx · right 735–744

M
macroscopeappBotAug 17, 2026

components/RightPanelTabs.tsx:744

Profile names are user-supplied up to BROWSER_PROFILE_NAME_MAX_LENGTH (48), and these rows render the name bare in an unbounded popup. Menu rows carrying user text elsewhere cap and ellipsize instead (ProjectScriptsControl uses <span className="truncate"> inside MenuItem, PullRequestDetailPanel and ComposerStashMenu use min-w-0 flex-1 truncate), so a long name here widens the submenu to fit-content and wraps the row rather than ellipsizing like every other name-bearing menu.

Suggest capping the sub-popup and truncating the label:

suggestion
1 <MenuSubPopup className="min-w-40 max-w-56">
2 {browserProfiles.map((profile) => (
3 <MenuItem
4 key={profile.id}
5 onClick={() => props.onAddBrowserInProfile(profile.id)}
6 >
7 <span className="min-w-0 truncate">{profile.name}</span>
8 </MenuItem>
9 ))}
10 </MenuSubPopup>

No longer relevant as of 7aa90aad3cfd7b8c54854e2f404989a2ce390355

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 1adce5c9 · right 521

Original diff
@@ -453,11 +476,205 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const updateSettings = useUpdatePrimarySettings();
+  const environmentId = usePrimaryEnvironment()?.environmentId;
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+
+  const addProfile = () => {
+    if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...userProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    updateSettings({
+      browserProfiles: userProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = (id: string) => {
+    setProfilePendingRemoval(null);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    if (environmentId) {
+      void previewBridge?.clearCookies(environmentId, id).catch(() => undefined);
M
macroscopeappBotAug 17, 2026

🟡 Medium settings/IntegrationsSettings.tsx:521

The profile is removed from settings even when clearCookies or clearCache fails, so its cookies or cache can remain on disk with no profile entry available for retry. Both IPC calls are fire-and-forget and their errors are suppressed; await the deletions and only update settings after they succeed, while surfacing failure to the user.

No longer relevant as of 2d101531aec510d521de30e8772dd011b05b7232

J
Julius MarmingeAug 29, 2026

Fixed in 210d0f82b3 and c8840bc05b. Removal awaits cookie/cache cleanup across all known environments, retains the profile on failure, and exposes an inline retryable error.

apps/web/src/components/RightPanelTabs.tsx · Earlier revision 438fc61e · right 937–945

Original diff
@@ -909,6 +926,41 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
                 >
                   {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
+                            onClick={() => {
+                              setAddSurfaceMenuOpen(false);
+                              action.onClick();
+                            }}
+                          >
+                            <Icon />
+                            {action.label}
+                          </MenuSubTrigger>

apps/web/src/components/RightPanelTabs.tsx · right 937–945

M
macroscopeappBotAug 28, 2026

components/RightPanelTabs.tsx:945

Routing Browser through MenuSubTrigger drops the shortcut affordance that SurfaceMenuItem gave it: the row no longer renders <MenuShortcut>B</MenuShortcut> and no longer carries aria-keyshortcuts="B", while Terminal/Files/Diff/Pull request/Agents in the same popup still show theirs (and the launcher empty state still shows a Kbd "B"). The shortcut itself still fires via handleAddSurfaceMenuKeyDown, so the hint disappeared while the behaviour stayed — the one row users can't discover is the one that now has a submenu.

Wrapping the label in a flex-1 span keeps the kbd flush right ahead of the chevron (whose own ms-auto would otherwise split the free space with the kbd's):

suggestion
1 <MenuSubTrigger
2 aria-keyshortcuts={action.shortcut}
3 onClick={() => {
4 setAddSurfaceMenuOpen(false);
5 action.onClick();
6 }}
7 >
8 <Icon />
9 <span className="flex-1">{action.label}</span>
10 <MenuShortcut className="ms-0">{action.shortcut}</MenuShortcut>
11 </MenuSubTrigger>

No longer relevant as of 2d101531aec510d521de30e8772dd011b05b7232

J
Julius MarmingeAug 29, 2026

Fixed in f5bb21e27d6 and ee989a8c07. Browser displays its B shortcut and aria-keyshortcuts; the shortcut and chevron share a stable right-aligned group.

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision cd654b4c · right 830

Original diff
@@ -501,11 +556,297 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const settingsHydrated = useClientSettingsHydrated();
+  const updateSettings = useUpdatePrimarySettings();
+  const { environments, isReady: environmentsReady } = useEnvironments();
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+  const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);
+  const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);
+  const removalAvailable = browserProfileRemovalAvailable(
+    previewBridge !== null,
+    environmentsReady,
+    environments.length,
+  );
+  const profileWritesDisabled = disabled || !settingsHydrated;
+
+  const addProfile = () => {
+    if (!settingsHydrated) return;
+    const currentProfiles = getClientSettings().browserProfiles;
+    if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...currentProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    if (!settingsHydrated) return;
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    const currentProfiles = getClientSettings().browserProfiles;
+    updateSettings({
+      browserProfiles: currentProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = async (id: string) => {
+    if (!settingsHydrated) return;
+    if (!removalAvailable) {
+      setProfileRemovalError("Connect to an environment before removing this profile.");
+      return;
+    }
+    setProfileRemovalError(null);
+    setProfileRemovalInFlight(true);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    try {
+      await clearBrowserProfileData(
+        previewBridge,
+        environmentsReady ? environments.map((environment) => environment.environmentId) : [],
+        id,
+      );
+    } catch {
+      setProfileRemovalError("Profile data could not be deleted. Try again.");
+      setProfileRemovalInFlight(false);
+      return;
+    }
+    const currentSettings = getClientSettings();
+    updateSettings({
+      browserProfiles: currentSettings.browserProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(currentSettings.browserDefaultProfileId === id
+        ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }
+        : {}),
+    });
+    setProfileRemovalInFlight(false);
+    setProfilePendingRemoval(null);
+  };
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-profiles")}
+      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      control={
+        <Button
+          size="sm"
+          variant="outline"
+          disabled={profileWritesDisabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
+          onClick={addProfile}
+        >
+          <PlusIcon />
+          Add profile
+        </Button>
+      }
+    >
+      {/*
+        Each profile is its own bounded row, and the list carries the bottom
+        spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows
+        stack on narrow viewports with a larger gap inside a row than between
+        rows, which reads as the remove button belonging to the profile below.
+      */}
+      <div className="mt-2 space-y-1 pb-2">
+        {resolveBrowserProfiles(userProfiles).map((profile) => {
+          const builtIn = isBuiltInBrowserProfileId(profile.id);
+          return (
+            <div
+              key={profile.id}
+              className={cn(
+                ITEM_ROW_INNER_CLASSNAME,
+                "rounded-lg border border-border/60 px-3 py-2",
+              )}
+            >
+              {builtIn ? (
+                // Dimmed here rather than on the list, which is the only
+                // content in the row without a disabled treatment of its own:
+                // a wrapper-level dim would stack with the rename field's and
+                // the remove button's, landing them near 0.41 while every
+                // other disabled control in the block sits at 0.64.
+                <span
+                  className={cn(
+                    "flex min-w-0 items-center gap-2 text-sm text-foreground",
+                    profileWritesDisabled && "opacity-64",
+                  )}
+                >
+                  {profile.name}
+                  <Badge variant="outline">
+                    {profile.kind === "incognito" ? "Ephemeral" : "Built-in"}
+                  </Badge>
+                </span>
+              ) : (
+                <DraftInput
+                  nativeInput
+                  size="sm"
+                  className="w-full sm:w-64"
+                  aria-label={`Rename ${profile.name}`}
+                  disabled={profileWritesDisabled}
+                  maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
+                  value={profile.name}
+                  onCommit={(next) => renameProfile(profile.id, next)}
+                />
+              )}
+              {builtIn ? null : (
+                <Tooltip>
+                  <TooltipTrigger
+                    render={
+                      <span className="inline-flex" {...(!removalAvailable ? { tabIndex: 0 } : {})}>
+                        <Button
+                          size="icon-sm"
+                          variant="ghost-muted"
+                          disabled={profileWritesDisabled || !removalAvailable}
+                          aria-label={`Remove ${profile.name}`}
+                          onClick={() => setProfilePendingRemoval(profile)}
+                        >
+                          <Trash2Icon />
+                        </Button>
+                      </span>
+                    }
+                  />
+                  <TooltipPopup side="top">
+                    {removalAvailable
+                      ? "Remove profile and its data"
+                      : "Connect to an environment to remove this profile"}
+                  </TooltipPopup>
+                </Tooltip>
+              )}
+            </div>
+          );
+        })}
+      </div>
+      <AlertDialog
+        open={profilePendingRemoval !== null}
+        onOpenChange={(open) => {
+          if (!open && !profileRemovalInFlight) {
+            setProfilePendingRemoval(null);
+            setProfileRemovalError(null);
+          }
+        }}
+      >
+        <AlertDialogPopup>
+          <AlertDialogHeader>
+            <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle>
+            <AlertDialogDescription>
+              Its cookies, logins, and cache are deleted with it. Tabs already open in this profile
+              stay open until you close them.
+            </AlertDialogDescription>
+            {profileRemovalError ? (
+              <p aria-live="polite" className="text-sm text-destructive">
+                {profileRemovalError}
+              </p>
+            ) : null}
+            {!removalAvailable ? (
+              <p className="text-sm text-muted-foreground">
+                Connect to an environment to remove this profile and its data.
+              </p>
+            ) : null}
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogClose
+              disabled={profileRemovalInFlight}
+              render={<Button variant="outline" disabled={profileRemovalInFlight} />}
+            >
+              Cancel
+            </AlertDialogClose>
+            <Button
+              variant="destructive"
+              disabled={profileRemovalInFlight || !settingsHydrated || !removalAvailable}
+              onClick={() => {
+                if (profilePendingRemoval && settingsHydrated && removalAvailable) {
+                  void removeProfile(profilePendingRemoval.id);
+                }
+              }}
+            >
+              {profileRemovalInFlight ? "Removing…" : "Remove profile"}
+            </Button>
+          </AlertDialogFooter>
+        </AlertDialogPopup>
+      </AlertDialog>
+    </SettingsRow>
+  );
+}
+
+function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const settingsHydrated = useClientSettingsHydrated();
+  const updateSettings = useUpdatePrimarySettings();
+  const profileWritesDisabled = disabled || !settingsHydrated;
+  // Incognito is deliberately absent: as a default it would open every tab
+  // into storage that is discarded on close.
+  const profiles = resolveBrowserProfiles(userProfiles).filter(
+    (profile) => profile.kind !== "incognito",
+  );
+  const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0];
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-default-profile")}
+      description="Profile new browser tabs open under, including tabs an agent opens."
+      resetAction={
+        !profileWritesDisabled && defaultProfileId !== DEFAULT_BROWSER_PROFILE_ID ? (
+          <SettingResetButton
+            label="default browser profile"
+            onClick={() => {
+              if (settingsHydrated) {
+                updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID });
+              }
+            }}
+          />
+        ) : null
+      }
+      control={
+        <Select
+          disabled={profileWritesDisabled}
+          value={selected?.id ?? DEFAULT_BROWSER_PROFILE_ID}
+          onValueChange={(value) => {
+            if (settingsHydrated && value !== null) {
+              updateSettings({ browserDefaultProfileId: value });
+            }
+          }}
+        >
+          <SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Default browser profile">
+            <SelectValue>{selected?.name ?? "Default"}</SelectValue>
+          </SelectTrigger>
+          {/*
+            Capped and truncated like the tab menu's profile list: names are
+            user-supplied and run to 48 characters, which would otherwise
+            widen the popup to fit the longest one.
+          */}
+          <SelectPopup align="end" alignItemWithTrigger={false} className="max-w-64">
C
cursorBotSep 2, 2026

Width cap misses the popup shell

Low Severity

max-w-64 is passed as SelectPopup className, which styles the list, not the glass wrapper. matchTriggerWidth still sets min-w-(--anchor-width) on that wrapper. When the trigger is w-full (settings rows stack below sm), the shell stays as wide as the trigger while the list is capped at 16rem, leaving empty popup chrome.

Reviewed by Cursor Bugbot for commit cd654b4cf8597af890c8311852acda84bacde5e5. Configure here.

J
Julius MarmingeSep 2, 2026

Fixed in 1cb5acff41. The cap now goes on the glass shell via popupClassName as well as the list, and matchTriggerWidth={false} stops the shell tracking a full-width trigger — same shape as the keybinding pickers.

apps/web/src/components/ui/menu.tsx · Earlier revision 61d5a113 · right 238–242

Original diff
@@ -235,7 +235,11 @@ function MenuSubTrigger({
   return (
     <MenuPrimitive.SubmenuTrigger
       className={cn(
-        "flex min-h-8 items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none",
+        // Leading-icon treatment matches `MenuItem`: a sub-trigger sits in the
+        // same column as the items around it, so its icon has to align and dim
+        // with theirs. Scoped to the first svg because the chevron below is
+        // also a direct child, and `-mx-0.5` would override its `-me-0.5`.
+        "[&>svg:first-of-type]:-mx-0.5 flex min-h-8 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:cursor-not-allowed data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&>svg:first-of-type:not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&>svg]:shrink-0",

apps/web/src/components/ui/menu.tsx · right 238–242

M
macroscopeappBotAug 16, 2026

ui/menu.tsx:242

:first-of-type doesn't exclude the chevron when the sub-trigger has no leading icon — in that case the chevron is the first (and only) svg child, so it picks up [&>svg:first-of-type]:-mx-0.5. That compound selector has specificity (0,2,1) versus .ms-auto's (0,1,0), so margin-inline: -0.125rem beats the chevron's own ms-auto regardless of class order and the chevron stops being pushed to the right edge. That regresses the two existing icon-less sub-triggers: Appearance in PreviewMoreMenu.tsx:127 and Turn in DiffPanel.tsx:557, whose chevrons would sit flush against the label instead of right-aligned.

Since the chevron is always rendered last, scoping to :not(:last-child) matches only a real leading icon and leaves the chevron's own margins intact.

suggestion
1 // Leading-icon treatment matches `MenuItem`: a sub-trigger sits in the
2 // same column as the items around it, so its icon has to align and dim
3 // with theirs. Scoped to non-last children because the chevron below is
4 // also a direct child and must keep its own `ms-auto`/`-me-0.5`.
5 "[&>svg:not(:last-child)]:-mx-0.5 flex min-h-8 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:cursor-not-allowed data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&>svg:not(:last-child):not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&>svg]:shrink-0",

No longer relevant as of 0009cb1daf2c6fea1dbe1643f7ea9fcf298640f1

apps/web/src/browser/openFileInPreview.ts · Earlier revision 316224d9 · right 56

Original diff
@@ -43,7 +45,15 @@ export async function openUrlInPreview<E>(input: {
 }): Promise<AtomCommandResult<void, E>> {
   const result = await input.openPreview({
     environmentId: input.threadRef.environmentId,
-    input: { threadId: input.threadRef.threadId, url: input.url },
+    input: {
+      threadId: input.threadRef.threadId,
+      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(),
+      profileId: browserDefaultOpenProfileId(),
+    },
C
cursorBotAug 17, 2026

Sync opens skip settings hydration

Medium Severity

File and terminal-link preview opens newly apply browserDefaultOpenViewport / browserDefaultOpenProfileId via the sync getters. Unlike openPreviewSession, they never await resolveBrowserDefaults(), so a tab opened before client settings hydrate is born on schema Default and never corrected.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 316224d9edd554e1126e946505f7f4fa26472673. Configure here.

apps/web/src/components/preview/PreviewMoreMenu.tsx · Earlier revision a7801b6d · right 204–215

Original diff
@@ -177,12 +193,27 @@ export function PreviewMoreMenu({
           </span>
         </MenuItem>
         <MenuSeparator />
-        <MenuItem onClick={() => void bridge.clearCookies().catch(() => undefined)}>
-          Clear cookies
-        </MenuItem>
-        <MenuItem onClick={() => void bridge.clearCache().catch(() => undefined)}>
-          Clear cache
-        </MenuItem>
+        {/*
+          Grouped so the heading has a `MenuGroup` ancestor — `MenuGroupLabel`
+          reads its context and throws without one. The heading also answers
+          which profile the tab is in, which is otherwise invisible: it is fixed
+          at open and nothing else in the chrome shows it.
+        */}
+        <MenuGroup>
+          {profileName ? <MenuGroupLabel>Profile: {profileName}</MenuGroupLabel> : null}
+          <MenuItem
+            onClick={() =>
+              void bridge.clearCookies(environmentId, profileId).catch(() => undefined)
+            }
+          >
+            {profileName ? `Clear cookies (${profileName})` : "Clear cookies"}
+          </MenuItem>
+          <MenuItem
+            onClick={() => void bridge.clearCache(environmentId, profileId).catch(() => undefined)}
+          >
+            {profileName ? `Clear cache (${profileName})` : "Clear cache"}
+          </MenuItem>

apps/web/src/components/preview/PreviewMoreMenu.tsx · right 204–215

M
macroscopeappBotAug 17, 2026

preview/PreviewMoreMenu.tsx:215

The parenthetical repeats the profile name that the MenuGroupLabel directly above already states, and it is the width driver for this popup: with a 48-character name the item label reaches ~65 characters, so the popup grows well past its min-w-56 and the rows wrap (menu rows here have no truncating span, unlike the name-bearing rows in ProjectScriptsControl/ComposerStashMenu).

Suggest letting the group heading carry the profile and keeping the action labels fixed-length:

suggestion
1 <MenuItem
2 onClick={() =>
3 void bridge.clearCookies(environmentId, profileId).catch(() => undefined)
4 }
5 >
6 Clear cookies
7 </MenuItem>
8 <MenuItem
9 onClick={() => void bridge.clearCache(environmentId, profileId).catch(() => undefined)}
10 >
11 Clear cache
12 </MenuItem>

No longer relevant as of 3045949155d67b9a31a9426eb832c3d0c24ecf64

apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 1c013aba · right 825–831

Original diff
@@ -501,11 +556,292 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
   );
 }
 
+/**
+ * Create, rename, and remove browser profiles.
+ *
+ * Built-ins render without controls: they are synthesized rather than stored,
+ * so there is nothing to rename and removing them would strand every tab that
+ * opened under them.
+ */
+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const settingsHydrated = useClientSettingsHydrated();
+  const updateSettings = useUpdatePrimarySettings();
+  const { environments, isReady: environmentsReady } = useEnvironments();
+  const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
+  const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);
+  const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);
+  const removalAvailable = browserProfileRemovalAvailable(
+    previewBridge !== null,
+    environmentsReady,
+    environments.length,
+  );
+  const profileWritesDisabled = disabled || !settingsHydrated;
+
+  const addProfile = () => {
+    if (!settingsHydrated) return;
+    const currentProfiles = getClientSettings().browserProfiles;
+    if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
+    const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name));
+    let name = "New profile";
+    for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
+    updateSettings({
+      browserProfiles: [
+        ...currentProfiles,
+        { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
+      ],
+    });
+  };
+
+  const renameProfile = (id: string, next: string) => {
+    if (!settingsHydrated) return;
+    const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
+    if (name === "") return;
+    const currentProfiles = getClientSettings().browserProfiles;
+    updateSettings({
+      browserProfiles: currentProfiles.map((profile) =>
+        profile.id === id ? { ...profile, name } : profile,
+      ),
+    });
+  };
+
+  const removeProfile = async (id: string) => {
+    if (!settingsHydrated) return;
+    if (!removalAvailable) {
+      setProfileRemovalError("Connect to an environment before removing this profile.");
+      return;
+    }
+    setProfileRemovalError(null);
+    setProfileRemovalInFlight(true);
+    // Drop the partition's data too, otherwise a removed profile's cookies
+    // stay on disk with nothing in the UI pointing at them.
+    try {
+      await clearBrowserProfileData(
+        previewBridge,
+        environmentsReady ? environments.map((environment) => environment.environmentId) : [],
+        id,
+      );
+    } catch {
+      setProfileRemovalError("Profile data could not be deleted. Try again.");
+      setProfileRemovalInFlight(false);
+      return;
+    }
+    const currentSettings = getClientSettings();
+    updateSettings({
+      browserProfiles: currentSettings.browserProfiles.filter((profile) => profile.id !== id),
+      // Reassign the default rather than leaving it pointing at nothing.
+      ...(currentSettings.browserDefaultProfileId === id
+        ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }
+        : {}),
+    });
+    setProfileRemovalInFlight(false);
+    setProfilePendingRemoval(null);
+  };
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-profiles")}
+      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      control={
+        <Button
+          size="sm"
+          variant="outline"
+          disabled={profileWritesDisabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
+          onClick={addProfile}
+        >
+          <PlusIcon />
+          Add profile
+        </Button>
+      }
+    >
+      {/*
+        Each profile is its own bounded row, and the list carries the bottom
+        spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows
+        stack on narrow viewports with a larger gap inside a row than between
+        rows, which reads as the remove button belonging to the profile below.
+      */}
+      <div className="mt-2 space-y-1 pb-2">
+        {resolveBrowserProfiles(userProfiles).map((profile) => {
+          const builtIn = isBuiltInBrowserProfileId(profile.id);
+          return (
+            <div
+              key={profile.id}
+              className={cn(
+                ITEM_ROW_INNER_CLASSNAME,
+                "rounded-lg border border-border/60 px-3 py-2",
+              )}
+            >
+              {builtIn ? (
+                // Dimmed here rather than on the list, which is the only
+                // content in the row without a disabled treatment of its own:
+                // a wrapper-level dim would stack with the rename field's and
+                // the remove button's, landing them near 0.41 while every
+                // other disabled control in the block sits at 0.64.
+                <span
+                  className={cn(
+                    "flex min-w-0 items-center gap-2 text-sm text-foreground",
+                    profileWritesDisabled && "opacity-64",
+                  )}
+                >
+                  {profile.name}
+                  <Badge variant="outline">
+                    {profile.kind === "incognito" ? "Ephemeral" : "Built-in"}
+                  </Badge>
+                </span>
+              ) : (
+                <DraftInput
+                  nativeInput
+                  size="sm"
+                  className="w-full sm:w-64"
+                  aria-label={`Rename ${profile.name}`}
+                  disabled={profileWritesDisabled}
+                  maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
+                  value={profile.name}
+                  onCommit={(next) => renameProfile(profile.id, next)}
+                />
+              )}
+              {builtIn ? null : (
+                <Tooltip>
+                  <TooltipTrigger
+                    render={
+                      <span className="inline-flex" {...(!removalAvailable ? { tabIndex: 0 } : {})}>
+                        <Button
+                          size="icon-sm"
+                          variant="ghost-muted"
+                          disabled={profileWritesDisabled || !removalAvailable}
+                          aria-label={`Remove ${profile.name}`}
+                          onClick={() => setProfilePendingRemoval(profile)}
+                        >
+                          <Trash2Icon />
+                        </Button>
+                      </span>
+                    }
+                  />
+                  <TooltipPopup side="top">
+                    {removalAvailable
+                      ? "Remove profile and its data"
+                      : "Connect to an environment to remove this profile"}
+                  </TooltipPopup>
+                </Tooltip>
+              )}
+            </div>
+          );
+        })}
+      </div>
+      <AlertDialog
+        open={profilePendingRemoval !== null}
+        onOpenChange={(open) => {
+          if (!open && !profileRemovalInFlight) {
+            setProfilePendingRemoval(null);
+            setProfileRemovalError(null);
+          }
+        }}
+      >
+        <AlertDialogPopup>
+          <AlertDialogHeader>
+            <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle>
+            <AlertDialogDescription>
+              Its cookies, logins, and cache are deleted with it. Tabs already open in this profile
+              stay open until you close them.
+            </AlertDialogDescription>
+            {profileRemovalError ? (
+              <p aria-live="polite" className="text-sm text-destructive">
+                {profileRemovalError}
+              </p>
+            ) : null}
+            {!removalAvailable ? (
+              <p className="text-sm text-muted-foreground">
+                Connect to an environment to remove this profile and its data.
+              </p>
+            ) : null}
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogClose
+              disabled={profileRemovalInFlight}
+              render={<Button variant="outline" disabled={profileRemovalInFlight} />}
+            >
+              Cancel
+            </AlertDialogClose>
+            <Button
+              variant="destructive"
+              disabled={profileRemovalInFlight || !settingsHydrated || !removalAvailable}
+              onClick={() => {
+                if (profilePendingRemoval && settingsHydrated && removalAvailable) {
+                  void removeProfile(profilePendingRemoval.id);
+                }
+              }}
+            >
+              {profileRemovalInFlight ? "Removing…" : "Remove profile"}
+            </Button>
+          </AlertDialogFooter>
+        </AlertDialogPopup>
+      </AlertDialog>
+    </SettingsRow>
+  );
+}
+
+function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) {
+  const userProfiles = useClientSettings((settings) => settings.browserProfiles);
+  const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
+  const settingsHydrated = useClientSettingsHydrated();
+  const updateSettings = useUpdatePrimarySettings();
+  const profileWritesDisabled = disabled || !settingsHydrated;
+  // Incognito is deliberately absent: as a default it would open every tab
+  // into storage that is discarded on close.
+  const profiles = resolveBrowserProfiles(userProfiles).filter(
+    (profile) => profile.kind !== "incognito",
+  );
+  const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0];
+
+  return (
+    <SettingsRow
+      {...searchableSetting("browser-default-profile")}
+      description="Profile new browser tabs open under, including tabs an agent opens."
+      resetAction={
+        !profileWritesDisabled && defaultProfileId !== DEFAULT_BROWSER_PROFILE_ID ? (
+          <SettingResetButton
+            label="default browser profile"
+            onClick={() => {
+              if (settingsHydrated) {
+                updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID });
+              }
+            }}
+          />
+        ) : null
+      }
+      control={
+        <Select
+          disabled={profileWritesDisabled}
+          value={selected?.id ?? DEFAULT_BROWSER_PROFILE_ID}
+          onValueChange={(value) => {
+            if (settingsHydrated && value !== null) {
+              updateSettings({ browserDefaultProfileId: value });
+            }
+          }}
+        >
+          <SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Default browser profile">
+            <SelectValue>{selected?.name ?? "Default"}</SelectValue>
+          </SelectTrigger>
+          <SelectPopup align="end" alignItemWithTrigger={false}>
+            {profiles.map((profile) => (
+              <SelectItem hideIndicator key={profile.id} value={profile.id}>
+                {profile.name}
+              </SelectItem>
+            ))}
+          </SelectPopup>

apps/web/src/components/settings/IntegrationsSettings.tsx · right 825–831

M
macroscopeappBotSep 2, 2026

These rows render user-supplied profile names (up to BROWSER_PROFILE_NAME_MAX_LENGTH, 48) bare in an uncapped popup, so the popup takes its width from the longest name — the same profile list in RightPanelTabs' submenu now caps and ellipsizes (min-w-40 max-w-56 + <span className="min-w-0 truncate">), and KeybindingsSettings does the same for its long option values (w-fit min-w-44 + truncating span). The trigger already truncates via SelectValue, so today the selected name ellipsizes in the row while the same name blows the popup out.

Suggest capping the list and truncating the item text so both profile pickers behave the same:

suggestion
1 <SelectPopup align="end" alignItemWithTrigger={false} className="max-w-64">
2 {profiles.map((profile) => (
3 <SelectItem hideIndicator key={profile.id} value={profile.id}>
4 <span className="min-w-0 truncate">{profile.name}</span>
5 </SelectItem>
6 ))}
7 </SelectPopup>

Posted via Macroscope — UI Consistency