History

feat(desktop): browser profiles for the preview browser (#7254)

Julius Marminge committed 2 months ago

Commit 134d5101 parent 28ddaf740 files · +1535 −119Browse files

Unified · Split

40 files
  1. apps/desktop/src/ipc/methods/preview.test.ts +43 −1
  2. apps/desktop/src/ipc/methods/preview.ts +64 −9
  3. apps/desktop/src/preload.ts +6 −4
  4. apps/desktop/src/preview/BrowserSession.test.ts +61 −0
  5. apps/desktop/src/preview/BrowserSession.ts +81 −13
  6. apps/desktop/src/preview/Manager.ts +42 −26
  7. apps/desktop/src/settings/DesktopClientSettings.test.ts +2 −0
  8. apps/server/src/preview/Manager.test.ts +31 −0
  9. apps/server/src/preview/Manager.ts +18 −1
  10. apps/web/src/browser/ElectronBrowserHost.tsx +1 −0
  11. apps/web/src/browser/HostedBrowserWebview.tsx +16 −3
  12. apps/web/src/browser/browserDefaults.test.ts +43 −0
  13. apps/web/src/browser/browserDefaults.ts +39 −10
  14. apps/web/src/browser/openFileInPreview.ts +16 −1
  15. apps/web/src/browser/previewWebviewConfigState.test.ts +11 −8
  16. apps/web/src/browser/previewWebviewConfigState.ts +35 −7
  17. apps/web/src/components/ChatView.tsx +15 −6
  18. apps/web/src/components/RightPanelTabs.test.tsx +10 −0
  19. apps/web/src/components/RightPanelTabs.tsx +73 −1
  20. apps/web/src/components/preview/PreviewChromeRow.tsx +8 −0
  21. apps/web/src/components/preview/PreviewMoreMenu.tsx +48 −7
  22. apps/web/src/components/preview/PreviewView.test.tsx +21 −13
  23. apps/web/src/components/preview/PreviewView.tsx +41 −0
  24. apps/web/src/components/preview/addBrowserSurface.test.ts +20 −0
  25. apps/web/src/components/preview/addBrowserSurface.ts +3 −0
  26. apps/web/src/components/preview/openPreviewSession.test.ts +3 −0
  27. apps/web/src/components/preview/openPreviewSession.ts +12 −2
  28. apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +59 −1
  29. apps/web/src/components/preview/openTerminalLinkInPreview.ts +14 −1
  30. apps/web/src/components/settings/IntegrationsSettings.logic.test.ts +74 −0
  31. apps/web/src/components/settings/IntegrationsSettings.tsx +351 −1
  32. apps/web/src/components/settings/settingsSearch.ts +12 −0
  33. apps/web/src/components/ui/menu.tsx +6 −1
  34. apps/web/src/routes/_chat.pull-requests.tsx +1 −0
  35. packages/contracts/src/browserProfile.test.ts +111 −0
  36. packages/contracts/src/browserProfile.ts +99 −0
  37. packages/contracts/src/index.ts +1 −0
  38. packages/contracts/src/ipc.ts +20 −3
  39. packages/contracts/src/preview.ts +9 −0
  40. packages/contracts/src/settings.ts +15 −0
apps/desktop/src/ipc/methods/preview.test.ts+43−1
@@ -1,5 +1,9 @@
11 import { it as effectIt } from "@effect/vitest";
2−import { PreviewAutomationStatus } from "@t3tools/contracts";
2+import {
3+ DEFAULT_BROWSER_PROFILE_ID,
4+ INCOGNITO_BROWSER_PROFILE_ID,
5+ PreviewAutomationStatus,
6+} from "@t3tools/contracts";
37 import * as Cause from "effect/Cause";
48 import * as Effect from "effect/Effect";
59 import * as Exit from "effect/Exit";
@@ -38,6 +42,44 @@ describe("preview IPC methods", () => { · 32 unmodified lines
3842 expect(fromPartition).not.toHaveBeenCalled();
3943 });
4044
45+ it("derives distinct partition scopes when identifiers contain the delimiter", () => {
46+ const first = PreviewIpc.resolvePartitionScope("a", "b::c");
47+ const second = PreviewIpc.resolvePartitionScope("a::b", "c");
48+
49+ expect(first).toEqual({ scope: '["a","b::c"]', persistent: true, namespace: "profile" });
50+ expect(second).toEqual({ scope: '["a::b","c"]', persistent: true, namespace: "profile" });
51+ expect(first.scope).not.toBe(second.scope);
52+ });
53+
54+ it("preserves lone surrogates without collapsing them to replacement characters", () => {
55+ const highSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\ud800");
56+ const lowSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\udc00");
57+ const replacement = PreviewIpc.resolvePartitionScope("environment", "profile-�");
58+
59+ expect(highSurrogate.scope).toBe('["environment","profile-\\ud800"]');
60+ expect(lowSurrogate.scope).toBe('["environment","profile-\\udc00"]');
61+ expect(highSurrogate.scope).not.toBe(lowSurrogate.scope);
62+ expect(highSurrogate.scope).not.toBe(replacement.scope);
63+ expect(lowSurrogate.scope).not.toBe(replacement.scope);
64+ });
65+
66+ it("keeps the legacy default partition scope and incognito persistence", () => {
67+ expect(PreviewIpc.resolvePartitionScope("environment::legacy", undefined)).toEqual({
68+ scope: "environment::legacy",
69+ persistent: true,
70+ });
71+ expect(
72+ PreviewIpc.resolvePartitionScope("environment::legacy", DEFAULT_BROWSER_PROFILE_ID),
73+ ).toEqual({ scope: "environment::legacy", persistent: true });
74+ expect(
75+ PreviewIpc.resolvePartitionScope("environment::legacy", INCOGNITO_BROWSER_PROFILE_ID),
76+ ).toEqual({
77+ scope: '["environment::legacy","incognito"]',
78+ persistent: false,
79+ namespace: "profile",
80+ });
81+ });
82+
4183 effectIt.effect("rejects invalid webContents ids before resolving the preview service", () =>
4284 Effect.map(
4385 PreviewIpc.registerWebview
apps/desktop/src/ipc/methods/preview.ts+64−9
@@ -16,11 +16,14 @@ import { · 15 unmodified lines
1616 DesktopPreviewScreenshotArtifactSchema,
1717 DesktopPreviewSetAudioMutedInputSchema,
1818 DesktopPreviewSetColorSchemeInputSchema,
19+ DesktopPreviewClearDataInputSchema,
1920 DesktopPreviewCreateTabInputSchema,
2021 DesktopPreviewTabInputSchema,
2122 DesktopPreviewWebviewConfigSchema,
2223 PreviewAnnotationSubmissionResultSchema,
2324 PreviewAutomationSnapshot,
25+ DEFAULT_BROWSER_PROFILE_ID,
26+ INCOGNITO_BROWSER_PROFILE_ID,
2427 } from "@t3tools/contracts";
2528 import * as Effect from "effect/Effect";
2629 import * as Schema from "effect/Schema";
@@ -196,33 +199,85 @@ export const closePictureInPicture = tabMethod( · 169 unmodified lines
196199
197200 export const clearCookies = DesktopIpc.makeIpcMethod({
198201 channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL,
199− payload: Schema.Void,
202+ payload: DesktopPreviewClearDataInputSchema,
200203 result: Schema.Void,
201− handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () {
204+ handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* ({ environmentId, profileId }) {
202205 const manager = yield* PreviewManager.PreviewManager;
203− yield* manager.clearCookies();
206+ yield* manager.clearCookies(yield* resolveClearPartitions(manager, environmentId, profileId));
204207 }),
205208 });
206209
207210 export const clearCache = DesktopIpc.makeIpcMethod({
208211 channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL,
209− payload: Schema.Void,
212+ payload: DesktopPreviewClearDataInputSchema,
210213 result: Schema.Void,
211− handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () {
214+ handler: Effect.fn("desktop.ipc.preview.clearCache")(function* ({ environmentId, profileId }) {
212215 const manager = yield* PreviewManager.PreviewManager;
213− yield* manager.clearCache();
216+ yield* manager.clearCache(yield* resolveClearPartitions(manager, environmentId, profileId));
214217 }),
215218 });
216219
220+/**
221+ * Partition scope for an (environment, profile) pair.
222+ *
223+ * The default profile keeps the bare environment id it used before profiles
224+ * existed, so upgrading does not strand anyone's existing logins in an
225+ * orphaned partition. Incognito derives a non-persistent partition.
226+ */
227+export function resolvePartitionScope(
228+ environmentId: string,
229+ profileId: string | undefined,
230+): {
231+ readonly scope: string;
232+ readonly persistent: boolean;
233+ readonly namespace?: "profile";
234+} {
235+ if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) {
236+ return { scope: environmentId, persistent: true };
237+ }
238+ // JSON's tuple framing is injective for strings, including lone UTF-16
239+ // surrogates (which it escapes). URI encoding throws on those supported ids,
240+ // while replacing them with U+FFFD would collapse distinct identities.
241+ return {
242+ scope: JSON.stringify([environmentId, profileId]),
243+ persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID,
244+ namespace: "profile" as const,
245+ };
246+}
247+
248+/**
249+ * Clearing without a profile keeps the historical "everything" behaviour for
250+ * an explicit all-profiles action; naming a profile confines it to that
251+ * profile's partition so one profile's sign-out cannot reach the others.
252+ */
253+const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartitions")(function* (
254+ manager: PreviewManager.PreviewManager["Service"],
255+ environmentId: string,
256+ profileId: string | undefined,
257+) {
258+ if (profileId === undefined) return undefined;
259+ const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId);
260+ // Loading the session is what puts the partition in the map the clear walks.
261+ // Deriving the partition string alone leaves nothing to match, so clearing a
262+ // profile with no tab open this run — after a restart, or when deleting a
263+ // profile — would report success and delete nothing.
264+ yield* manager.getBrowserSession(scope, persistent, namespace);
265+ return [yield* manager.getBrowserPartition(scope, persistent, namespace)];
266+});
267+
217268 export const getPreviewConfig = DesktopIpc.makeIpcMethod({
218269 channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL,
219270 payload: DesktopPreviewConfigInputSchema,
220271 result: DesktopPreviewWebviewConfigSchema,
221− handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId }) {
272+ handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId, profileId }) {
222273 const manager = yield* PreviewManager.PreviewManager;
223− yield* manager.getBrowserSession(environmentId);
274+ const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId);
275+ // Creating the session first is what installs the UA rewrite and permission
276+ // handlers; a guest that attached to an untouched partition would run with
277+ // Electron's default UA and Chromium's default permission behaviour.
278+ yield* manager.getBrowserSession(scope, persistent, namespace);
224279 return {
225− partition: yield* manager.getBrowserPartition(environmentId),
280+ partition: yield* manager.getBrowserPartition(scope, persistent, namespace),
226281 webPreferences: PREVIEW_WEBVIEW_PREFERENCES,
227282 preloadUrl: NodeURL.pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href,
228283 };
apps/desktop/src/preload.ts+6−4
@@ -222,10 +222,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 221 unmodified lines
222222 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }),
223223 openDevTools: (tabId) =>
224224 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
225− clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),
226− clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL),
227− getPreviewConfig: (environmentId) =>
228− ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }),
225+ clearCookies: (environmentId, profileId) =>
226+ ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }),
227+ clearCache: (environmentId, profileId) =>
228+ ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, { environmentId, profileId }),
229+ getPreviewConfig: (environmentId, profileId) =>
230+ ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId, profileId }),
229231 setAnnotationTheme: (theme) =>
230232 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }),
231233 pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),
apps/desktop/src/preview/BrowserSession.test.ts+61−0
@@ -63,6 +63,45 @@ describe("BrowserSession", () => { · 62 unmodified lines
6363 }).pipe(Effect.provide(layer)),
6464 );
6565
66+ it.effect("keeps scopes that differ only by a lone surrogate in separate partitions", () =>
67+ Effect.gen(function* () {
68+ const browserSessions = yield* BrowserSession.BrowserSession;
69+
70+ // TextEncoder folds a lone surrogate to U+FFFD, so without escaping these
71+ // two supported ids would hash to one partition and share every cookie.
72+ const loneSurrogate = yield* browserSessions.getPartition("p\ud800");
73+ const replacementChar = yield* browserSessions.getPartition("p\ufffd");
74+ assert.notStrictEqual(loneSurrogate, replacementChar);
75+
76+ // The escape can't be forged with a literal backslash either.
77+ const literal = yield* browserSessions.getPartition("p\\ud800");
78+ assert.notStrictEqual(literal, loneSurrogate);
79+
80+ // And a well-formed scope still lands on its historical partition.
81+ assert.strictEqual(
82+ yield* browserSessions.getPartition("scope-a"),
83+ "persist:t3code-preview-f051bb2c68cb7b2fe969",
84+ );
85+ }).pipe(Effect.provide(layer)),
86+ );
87+
88+ it.effect("keeps legacy defaults disjoint from nondefault profile partitions", () =>
89+ Effect.gen(function* () {
90+ const browserSessions = yield* BrowserSession.BrowserSession;
91+
92+ // These share the same scope string: default environment `a::b`, and
93+ // environment `a` with nondefault profile `b`.
94+ const legacyDefault = yield* browserSessions.getPartition("a::b");
95+ const nondefaultProfile = yield* browserSessions.getPartition("a::b", true, "profile");
96+
97+ assert.strictEqual(legacyDefault, "persist:t3code-preview-78f0be89237d77f7a70e");
98+ assert.strictEqual(nondefaultProfile, "persist:t3code-preview-profile-78f0be89237d77f7a70e");
99+ assert.notStrictEqual(nondefaultProfile, legacyDefault);
100+ assert.isTrue(browserSessions.isPartition(legacyDefault));
101+ assert.isTrue(browserSessions.isPartition(nondefaultProfile));
102+ }).pipe(Effect.provide(layer)),
103+ );
104+
66105 it.effect("grants clipboard-sanitized-write through both the request and check handlers", () =>
67106 Effect.gen(function* () {
68107 const browserSessions = yield* BrowserSession.BrowserSession;
@@ -192,6 +231,28 @@ describe("BrowserSession", () => { · 123 unmodified lines
192231 }).pipe(Effect.provide(layer)),
193232 );
194233
234+ it.effect("clears a partition whose session has not been opened yet", () =>
235+ Effect.gen(function* () {
236+ const browserSessions = yield* BrowserSession.BrowserSession;
237+ const partition = yield* browserSessions.getPartition("scope-untouched");
238+
239+ // Deriving the partition string does not create the session, and the
240+ // clear only walks sessions it already holds. Without loading it first
241+ // this reports success and deletes nothing — which is what a user
242+ // clearing a profile after a restart would get.
243+ assert.isUndefined(sessions.get(partition));
244+ yield* browserSessions.clearCookies([partition]);
245+ assert.isUndefined(sessions.get(partition));
246+
247+ yield* browserSessions.getSession("scope-untouched");
248+ yield* browserSessions.clearCookies([partition]);
249+
250+ const created = sessions.get(partition);
251+ assert.isDefined(created);
252+ assert.strictEqual(created.clearStorageData.mock.calls.length, 1);
253+ }).pipe(Effect.provide(layer)),
254+ );
255+
195256 it.effect("correlates clear failures while still attempting every session", () =>
196257 Effect.gen(function* () {
197258 const browserSessions = yield* BrowserSession.BrowserSession;
apps/desktop/src/preview/BrowserSession.ts+81−13
@@ -10,6 +10,16 @@ import * as Schema from "effect/Schema"; · 9 unmodified lines
1010 import * as SynchronizedRef from "effect/SynchronizedRef";
1111
1212 const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
13+/**
14+ * Incognito partitions deliberately omit the `persist:` prefix, which is what
15+ * makes Chromium keep them in memory and discard them with the process. They
16+ * still carry the product prefix so `isPartition` can admit them — the
17+ * `will-attach-webview` gate rejects anything it does not recognise.
18+ */
19+const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-";
20+const PROFILE_PARTITION_MARKER = "profile-";
21+
22+export type BrowserSessionPartitionNamespace = "profile";
1323
1424 // Permissions granted to preview web content. `clipboard-sanitized-write` is the
1525 // Electron permission behind `navigator.clipboard.writeText()` — note it is NOT
@@ -99,20 +109,68 @@ export class BrowserSession extends Context.Service< · 83 unmodified lines
99109 {
100110 readonly getPartition: (
101111 scope?: string,
112+ persistent?: boolean,
113+ namespace?: BrowserSessionPartitionNamespace,
102114 ) => Effect.Effect<string, BrowserSessionPartitionDerivationError>;
103115 readonly isPartition: (partition: string) => boolean;
104− readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionGetSessionError>;
105− readonly clearCookies: () => Effect.Effect<void, BrowserSessionStorageClearError>;
106− readonly clearCache: () => Effect.Effect<void, BrowserSessionCacheClearError>;
116+ readonly getSession: (
117+ scope?: string,
118+ persistent?: boolean,
119+ namespace?: BrowserSessionPartitionNamespace,
120+ ) => Effect.Effect<Session, BrowserSessionGetSessionError>;
121+ /** Omit `partitions` to clear every known partition. */
122+ readonly clearCookies: (
123+ partitions?: ReadonlyArray<string>,
124+ ) => Effect.Effect<void, BrowserSessionStorageClearError>;
125+ readonly clearCache: (
126+ partitions?: ReadonlyArray<string>,
127+ ) => Effect.Effect<void, BrowserSessionCacheClearError>;
107128 }
108129 >()("@t3tools/desktop/preview/BrowserSession") {}
109130
131+/**
132+ * Restricts a clear to the given partitions. Omitting them keeps the historical
133+ * "every partition" behaviour, which callers now only use for an explicit
134+ * "all profiles" action — a per-profile clear must never reach across profiles.
135+ */
136+const selectSessions = (
137+ sessions: ReadonlyMap<string, Session>,
138+ partitions: ReadonlyArray<string> | undefined,
139+): ReadonlyArray<readonly [string, Session]> =>
140+ [...sessions.entries()].filter(
141+ ([partition]) => partitions === undefined || partitions.includes(partition),
142+ );
143+
144+/**
145+ * Scope bytes for the partition digest.
146+ *
147+ * `TextEncoder` replaces a lone UTF-16 surrogate with U+FFFD, so `"p\ud800"`
148+ * and `"p\ufffd"` would hash to the same partition and share cookies. Those
149+ * are distinct, supported ids, so lone surrogates are escaped to `\uXXXX`
150+ * first — and a literal backslash is doubled so the escape cannot be forged.
151+ * Every well-formed scope passes through byte-for-byte unchanged, which keeps
152+ * existing partitions (and the logins in them) where they are.
153+ */
154+const encodeScopeForDigest = (scope: string): Uint8Array =>
155+ new TextEncoder().encode(
156+ scope
157+ .replace(/\\/g, "\\\\")
158+ .replace(
159+ /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g,
160+ (unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`,
161+ ),
162+ );
163+
110164 export const make = Effect.gen(function* BrowserSessionMake() {
111165 const crypto = yield* Crypto.Crypto;
112166 const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());
113167
114− const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") {
115− const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe(
168+ const getPartition = Effect.fn("BrowserSession.getPartition")(function* (
169+ scope = "shared",
170+ persistent = true,
171+ namespace?: BrowserSessionPartitionNamespace,
172+ ) {
173+ const digest = yield* crypto.digest("SHA-256", encodeScopeForDigest(scope)).pipe(
116174 Effect.mapError(
117175 (cause) =>
118176 new BrowserSessionPartitionDerivationError({
@@ -121,11 +179,19 @@ export const make = Effect.gen(function* BrowserSessionMake() { · 2 unmodified lines
121179 }),
122180 ),
123181 );
124− return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`;
182+ const prefix = persistent ? PREVIEW_PARTITION_PREFIX : PREVIEW_EPHEMERAL_PARTITION_PREFIX;
183+ // Legacy/default partitions are prefix + hex digest. The non-hex profile
184+ // marker creates a disjoint namespace while leaving every legacy default
185+ // partition byte-for-byte unchanged.
186+ return `${prefix}${namespace === "profile" ? PROFILE_PARTITION_MARKER : ""}${Encoding.encodeHex(digest).slice(0, 20)}`;
125187 });
126188
127− const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") {
128− const partition = yield* getPartition(scope);
189+ const getSession = Effect.fn("BrowserSession.getSession")(function* (
190+ scope = "shared",
191+ persistent = true,
192+ namespace?: BrowserSessionPartitionNamespace,
193+ ) {
194+ const partition = yield* getPartition(scope, persistent, namespace);
129195 return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => {
130196 const existing = sessions.get(partition);
131197 if (existing) return Effect.succeed([existing, sessions] as const);
@@ -159,12 +225,14 @@ export const make = Effect.gen(function* BrowserSessionMake() { · 27 unmodified lines
159225
160226 return BrowserSession.of({
161227 getPartition,
162− isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX),
228+ isPartition: (partition) =>
229+ partition.startsWith(PREVIEW_PARTITION_PREFIX) ||
230+ partition.startsWith(PREVIEW_EPHEMERAL_PARTITION_PREFIX),
163231 getSession,
164− clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () {
232+ clearCookies: Effect.fn("BrowserSession.clearCookies")(function* (partitions?) {
165233 const sessions = yield* SynchronizedRef.get(sessionsRef);
166234 yield* Effect.all(
167− [...sessions.entries()].map(([partition, browserSession]) =>
235+ selectSessions(sessions, partitions).map(([partition, browserSession]) =>
168236 Effect.tryPromise({
169237 try: () =>
170238 browserSession.clearStorageData({
@@ -180,10 +248,10 @@ export const make = Effect.gen(function* BrowserSessionMake() { · 9 unmodified lines
180248 { concurrency: "unbounded", discard: true },
181249 );
182250 }),
183− clearCache: Effect.fn("BrowserSession.clearCache")(function* () {
251+ clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) {
184252 const sessions = yield* SynchronizedRef.get(sessionsRef);
185253 yield* Effect.all(
186− [...sessions.entries()].map(([partition, browserSession]) =>
254+ selectSessions(sessions, partitions).map(([partition, browserSession]) =>
187255 Effect.tryPromise({
188256 try: () => browserSession.clearCache(),
189257 catch: (cause) =>
apps/desktop/src/preview/Manager.ts+42−26
@@ -4409,7 +4409,11 @@ export class PreviewManager extends Context.Service< · 4408 unmodified lines
44094409 PreviewManager,
44104410 {
44114411 readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>;
4412− readonly getBrowserSession: (scope?: string) => Effect.Effect<Session, PreviewManagerError>;
4412+ readonly getBrowserSession: (
4413+ scope?: string,
4414+ persistent?: boolean,
4415+ namespace?: BrowserSession.BrowserSessionPartitionNamespace,
4416+ ) => Effect.Effect<Session, PreviewManagerError>;
44134417 readonly isBrowserPartition: (partition: string) => boolean;
44144418 readonly createTab: (
44154419 tabId: string,
@@ -4440,9 +4444,17 @@ export class PreviewManager extends Context.Service< · 24 unmodified lines
44404444 audioMuted: boolean,
44414445 ) => Effect.Effect<void, PreviewManagerError>;
44424446 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
4443− readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;
4444− readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;
4445− readonly getBrowserPartition: (scope?: string) => Effect.Effect<string, PreviewManagerError>;
4447+ readonly clearCookies: (
4448+ partitions?: ReadonlyArray<string>,
4449+ ) => Effect.Effect<void, PreviewManagerError>;
4450+ readonly clearCache: (
4451+ partitions?: ReadonlyArray<string>,
4452+ ) => Effect.Effect<void, PreviewManagerError>;
4453+ readonly getBrowserPartition: (
4454+ scope?: string,
4455+ persistent?: boolean,
4456+ namespace?: BrowserSession.BrowserSessionPartitionNamespace,
4457+ ) => Effect.Effect<string, PreviewManagerError>;
44464458 readonly setAnnotationTheme: (
44474459 theme: DesktopPreviewAnnotationTheme,
44484460 ) => Effect.Effect<void, PreviewManagerError>;
@@ -4514,15 +4526,17 @@ export const make = Effect.gen(function* PreviewManagerMake() { · 65 unmodified lines
45144526
45154527 return PreviewManager.of({
45164528 setMainWindow: operations.setMainWindow,
4517− getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) {
4518− return yield* browserSession
4519− .getSession(scope)
4520− .pipe(
4521− Effect.mapError(
4522− (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),
4523− ),
4524− );
4525− }),
4529+ getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(
4530+ function* (scope, persistent, namespace) {
4531+ return yield* browserSession
4532+ .getSession(scope, persistent, namespace)
4533+ .pipe(
4534+ Effect.mapError(
4535+ (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),
4536+ ),
4537+ );
4538+ },
4539+ ),
45264540 isBrowserPartition: browserSession.isPartition,
45274541 createTab: operations.createTab,
45284542 closeTab: operations.closeTab,
@@ -4539,31 +4553,33 @@ export const make = Effect.gen(function* PreviewManagerMake() { · 10 unmodified lines
45394553 setColorScheme: operations.setColorScheme,
45404554 setAudioMuted: operations.setAudioMuted,
45414555 openDevTools: operations.openDevTools,
4542− clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {
4556+ clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) {
45434557 yield* browserSession
4544− .clearCookies()
4558+ .clearCookies(partitions)
45454559 .pipe(
45464560 Effect.mapError(
45474561 (cause) => new PreviewOperationError({ operation: "clearCookies", cause }),
45484562 ),
45494563 );
45504564 }),
4551− clearCache: Effect.fn("PreviewManager.clearCache")(function* () {
4565+ clearCache: Effect.fn("PreviewManager.clearCache")(function* (partitions) {
45524566 yield* browserSession
4553− .clearCache()
4567+ .clearCache(partitions)
45544568 .pipe(
45554569 Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })),
45564570 );
45574571 }),
4558− getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) {
4559− return yield* browserSession
4560− .getPartition(scope)
4561− .pipe(
4562− Effect.mapError(
4563− (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }),
4564− ),
4565− );
4566− }),
4572+ getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(
4573+ function* (scope, persistent, namespace) {
4574+ return yield* browserSession
4575+ .getPartition(scope, persistent, namespace)
4576+ .pipe(
4577+ Effect.mapError(
4578+ (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }),
4579+ ),
4580+ );
4581+ },
4582+ ),
45674583 setAnnotationTheme: operations.setAnnotationTheme,
45684584 pickElement: operations.pickElement,
45694585 cancelPickElement: operations.cancelPickElement,
apps/desktop/src/settings/DesktopClientSettings.test.ts+2−0
@@ -19,6 +19,8 @@ const clientSettings: ClientSettings = { · 18 unmodified lines
1919 browserDefaultAppearance: "dark",
2020 browserRecordingFrameRate: 60,
2121 browserAutoShowFloatingPreview: false,
22+ browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
23+ browserDefaultProfileId: "work",
2224 confirmQuit: "double-click",
2325 confirmThreadArchive: true,
2426 confirmThreadDelete: false,
apps/server/src/preview/Manager.test.ts+31−0
@@ -58,6 +58,37 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { · 57 unmodified lines
5858 }),
5959 );
6060
61+ it.effect("keeps the tab's profile across navigation and status reports", () =>
62+ Effect.gen(function* () {
63+ const threadId = freshThreadId();
64+ const manager = yield* PreviewManager.PreviewManager;
65+
66+ const opened = yield* manager.open({ threadId, profileId: "work" });
67+ expect(opened.profileId).toBe("work");
68+
69+ // `navigate` and `reportStatus` rebuild the snapshot field by field
70+ // rather than spreading it, so a new field is dropped unless carried
71+ // explicitly — which would silently move the tab to another profile's
72+ // partition on its first navigation.
73+ const navigated = yield* manager.navigate({
74+ threadId,
75+ tabId: opened.tabId,
76+ url: "localhost:5173",
77+ });
78+ expect(navigated.profileId).toBe("work");
79+
80+ yield* manager.reportStatus({
81+ threadId,
82+ tabId: opened.tabId,
83+ navStatus: { _tag: "Success", url: "http://localhost:5173/", title: "Dev" },
84+ canGoBack: true,
85+ canGoForward: false,
86+ });
87+ const listed = yield* manager.list({ threadId });
88+ expect(listed.sessions.find((s) => s.tabId === opened.tabId)?.profileId).toBe("work");
89+ }),
90+ );
91+
6192 it.effect("opens an Idle tab when no URL is supplied", () =>
6293 Effect.gen(function* () {
6394 const threadId = freshThreadId();
apps/server/src/preview/Manager.ts+18−1
@@ -123,6 +123,7 @@ const buildLoadingSnapshot = (input: { · 122 unmodified lines
123123 readonly url: string;
124124 readonly title: string;
125125 readonly viewport: PreviewViewportSetting;
126+ readonly profileId?: string | undefined;
126127 readonly updatedAt: string;
127128 }): PreviewSessionSnapshot => ({
128129 threadId: input.threadId,
@@ -131,6 +132,7 @@ const buildLoadingSnapshot = (input: { · 2 unmodified lines
131132 canGoBack: false,
132133 canGoForward: false,
133134 viewport: input.viewport,
135+ ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
134136 updatedAt: input.updatedAt,
135137 });
136138
@@ -138,6 +140,7 @@ const buildIdleSnapshot = (input: { · 1 unmodified line
138140 readonly threadId: string;
139141 readonly tabId: string;
140142 readonly viewport: PreviewViewportSetting;
143+ readonly profileId?: string | undefined;
141144 readonly updatedAt: string;
142145 }): PreviewSessionSnapshot => ({
143146 threadId: input.threadId,
@@ -146,6 +149,7 @@ const buildIdleSnapshot = (input: { · 2 unmodified lines
146149 canGoBack: false,
147150 canGoForward: false,
148151 viewport: input.viewport,
152+ ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
149153 updatedAt: input.updatedAt,
150154 });
151155
@@ -229,9 +233,16 @@ export const make = Effect.gen(function* PreviewManagerMake() { · 77 unmodified lines
229233 url: yield* normalizeUrl(input.url),
230234 title: "",
231235 viewport,
236+ profileId: input.profileId,
232237 updatedAt,
233238 })
234− : buildIdleSnapshot({ threadId: input.threadId, tabId, viewport, updatedAt });
239+ : buildIdleSnapshot({
240+ threadId: input.threadId,
241+ tabId,
242+ viewport,
243+ profileId: input.profileId,
244+ updatedAt,
245+ });
235246 yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
236247 Effect.gen(function* () {
237248 const revision = state.revision + 1;
@@ -275,6 +286,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { · 37 unmodified lines
275286 canGoBack: session.snapshot.canGoBack,
276287 canGoForward: session.snapshot.canGoForward,
277288 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
289+ ...(session.snapshot.profileId === undefined
290+ ? {}
291+ : { profileId: session.snapshot.profileId }),
278292 updatedAt,
279293 };
280294 return {
@@ -308,6 +322,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { · 27 unmodified lines
308322 canGoBack: input.canGoBack,
309323 canGoForward: input.canGoForward,
310324 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
325+ ...(session.snapshot.profileId === undefined
326+ ? {}
327+ : { profileId: session.snapshot.profileId }),
311328 updatedAt,
312329 };
313330 const emit: PreviewEventDraft =
apps/web/src/browser/ElectronBrowserHost.tsx+1−0
@@ -93,6 +93,7 @@ export function ElectronBrowserHost() { · 92 unmodified lines
9393 initialUrl={url}
9494 viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}
9595 pictureInPicture={pictureInPicture}
96+ profileId={snapshot.profileId}
9697 zoomFactor={zoomFactor}
9798 />
9899 );
apps/web/src/browser/HostedBrowserWebview.tsx+16−3
@@ -49,11 +49,24 @@ export function HostedBrowserWebview(props: { · 48 unmodified lines
4949 readonly initialUrl: string | null;
5050 readonly viewport: PreviewViewportSetting;
5151 readonly pictureInPicture: boolean;
52+ /**
53+ * Fixed for the tab's lifetime: Electron only honours `partition` before the
54+ * guest attaches, so a live change here would not move the tab anyway.
55+ */
56+ readonly profileId: string | undefined;
5257 readonly zoomFactor: number;
5358 }) {
54− const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor } =
55− props;
56− const config = usePreviewWebviewConfig(threadRef.environmentId);
59+ const {
60+ threadRef,
61+ tabId,
62+ runtimeTabId,
63+ initialUrl,
64+ viewport,
65+ pictureInPicture,
66+ zoomFactor,
67+ profileId,
68+ } = props;
69+ const config = usePreviewWebviewConfig(threadRef.environmentId, profileId);
5770 const [initialSrc] = useState(() => initialUrl ?? "about:blank");
5871 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
5972 const wrapperRef = useRef<HTMLDivElement | null>(null);
apps/web/src/browser/browserDefaults.test.ts+43−0
@@ -0,0 +1,43 @@
1+import { describe, expect, it, vi } from "vite-plus/test";
2+import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
3+
4+const settings = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
5+
6+vi.mock("~/hooks/useSettings", () => ({
7+ getClientSettings: () => settings.current,
8+ useClientSettings: () => undefined,
9+ ensureClientSettingsHydrated: () => Promise.resolve(),
10+}));
11+
12+const { getBrowserDefaults } = await import("./browserDefaults");
13+
14+const withDefaultProfile = (browserDefaultProfileId: string) => {
15+ settings.current = {
16+ browserDefaultViewport: { _tag: "fill" },
17+ browserDefaultZoomFactor: 1,
18+ browserDefaultAppearance: "system",
19+ browserAutoShowFloatingPreview: true,
20+ browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
21+ browserDefaultProfileId,
22+ };
23+ return getBrowserDefaults();
24+};
25+
26+describe("getBrowserDefaults profile resolution", () => {
27+ it("keeps a configured persistent profile", () => {
28+ expect(withDefaultProfile("work").profileId).toBe("work");
29+ });
30+
31+ it("falls back for an unknown profile", () => {
32+ expect(withDefaultProfile("deleted").profileId).toBe(DEFAULT_BROWSER_PROFILE_ID);
33+ });
34+
35+ it("refuses incognito as the default", () => {
36+ // A stored incognito default would open every new tab into storage that is
37+ // discarded on close, and the settings list no longer offers it — so the
38+ // row badged "Default" must be the one tabs actually open under.
39+ expect(withDefaultProfile(INCOGNITO_BROWSER_PROFILE_ID).profileId).toBe(
40+ DEFAULT_BROWSER_PROFILE_ID,
41+ );
42+ });
43+});
apps/web/src/browser/browserDefaults.ts+39−10
@@ -13,10 +13,13 @@ · 12 unmodified lines
1313 *
1414 * @module browserDefaults
1515 */
16−import type {
17− DesktopPreviewTabDefaults,
18− PreviewAppearancePreference,
19− PreviewViewportSetting,
16+import {
17+ DEFAULT_BROWSER_PROFILE_ID,
18+ resolveBrowserProfiles,
19+ type BrowserProfile,
20+ type DesktopPreviewTabDefaults,
21+ type PreviewAppearancePreference,
22+ type PreviewViewportSetting,
2023 } from "@t3tools/contracts";
2124
2225 import {
@@ -32,6 +35,8 @@ export interface BrowserDefaults { · 9 unmodified lines
3235 readonly zoomFactor: number;
3336 readonly appearance: PreviewAppearancePreference;
3437 readonly autoShowFloatingPreview: boolean;
38+ readonly profiles: ReadonlyArray<BrowserProfile>;
39+ readonly profileId: string;
3540 }
3641
3742 const toBrowserDefaults = (settings: {
@@ -39,12 +44,29 @@ const toBrowserDefaults = (settings: { · 1 unmodified line
3944 readonly browserDefaultZoomFactor: number;
4045 readonly browserDefaultAppearance: PreviewAppearancePreference;
4146 readonly browserAutoShowFloatingPreview: boolean;
42−}): BrowserDefaults => ({
43− viewport: settings.browserDefaultViewport,
44− zoomFactor: settings.browserDefaultZoomFactor,
45− appearance: settings.browserDefaultAppearance,
46− autoShowFloatingPreview: settings.browserAutoShowFloatingPreview,
47−});
47+ readonly browserProfiles: ReadonlyArray<BrowserProfile>;
48+ readonly browserDefaultProfileId: string;
49+}): BrowserDefaults => {
50+ const profiles = resolveBrowserProfiles(settings.browserProfiles);
51+ return {
52+ viewport: settings.browserDefaultViewport,
53+ zoomFactor: settings.browserDefaultZoomFactor,
54+ appearance: settings.browserDefaultAppearance,
55+ autoShowFloatingPreview: settings.browserAutoShowFloatingPreview,
56+ profiles,
57+ // A default pointing at a deleted profile falls back rather than opening
58+ // tabs into a partition with no profile behind it.
59+ // Incognito is a per-tab choice, not a default: a profile that discards
60+ // everything on close would leave every new tab signed out. Excluding it
61+ // here keeps the resolved default equal to what the settings list offers,
62+ // so the row badged "Default" is the one tabs actually open under.
63+ profileId:
64+ profiles.find(
65+ (profile) =>
66+ profile.id === settings.browserDefaultProfileId && profile.kind !== "incognito",
67+ )?.id ?? DEFAULT_BROWSER_PROFILE_ID,
68+ };
69+};
4870
4971 /** Non-hook accessor for imperative open paths (menu actions, automation hosts). */
5072 export function getBrowserDefaults(): BrowserDefaults {
@@ -89,6 +111,13 @@ export function browserDefaultOpenViewport( · 38 unmodified lines
89111 return defaults.viewport;
90112 }
91113
114+/** Profile a tab opens under when the caller doesn't name one. */
115+export function browserDefaultOpenProfileId(
116+ defaults: BrowserDefaults = getBrowserDefaults(),
117+): string {
118+ return defaults.profileId;
119+}
120+
92121 /**
93122 * The viewport to switch to when the user turns the device toolbar on for a tab
94123 * currently in fill mode.
apps/web/src/browser/openFileInPreview.ts+16−1
@@ -23,6 +23,12 @@ import { · 22 unmodified lines
2323 } from "~/previewStateStore";
2424 import { useRightPanelStore } from "~/rightPanelStore";
2525
26+import {
27+ browserDefaultOpenProfileId,
28+ browserDefaultOpenViewport,
29+ resolveBrowserDefaults,
30+} from "./browserDefaults";
31+
2632 export const isBrowserPreviewFile = (path: string): boolean =>
2733 /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
2834
@@ -42,9 +48,18 @@ export async function openUrlInPreview<E>(input: { · 13 unmodified lines
4248 readonly url: string;
4349 readonly openPreview: OpenPreviewMutation<E>;
4450 }): Promise<AtomCommandResult<void, E>> {
51+ const defaults = await resolveBrowserDefaults();
4552 const result = await input.openPreview({
4653 environmentId: input.threadRef.environmentId,
47− input: { threadId: input.threadRef.threadId, url: input.url },
54+ input: {
55+ threadId: input.threadRef.threadId,
56+ url: input.url,
57+ // Built here rather than via `openPreviewSession` because this path
58+ // maps the result differently, so the configured defaults have to be
59+ // applied explicitly or file/link opens would ignore them.
60+ viewport: browserDefaultOpenViewport(defaults),
61+ profileId: browserDefaultOpenProfileId(defaults),
62+ },
4863 });
4964 return mapAtomCommandResult(result, (snapshot) => {
5065 applyPreviewServerSnapshot(input.threadRef, snapshot);
apps/web/src/browser/previewWebviewConfigState.test.ts+11−8
@@ -13,7 +13,9 @@ const environmentId = EnvironmentId.make("environment-1"); · 12 unmodified lines
1313 describe("loadPreviewWebviewConfig", () => {
1414 it.effect("reports a structurally distinct missing-bridge failure", () =>
1515 Effect.gen(function* () {
16− const error = yield* loadPreviewWebviewConfig(environmentId, null).pipe(Effect.flip);
16+ const error = yield* loadPreviewWebviewConfig(environmentId, undefined, null).pipe(
17+ Effect.flip,
18+ );
1719
1820 expect(error).toBeInstanceOf(PreviewWebviewBridgeUnavailableError);
1921 expect(error.environmentId).toBe(environmentId);
@@ -25,7 +27,7 @@ describe("loadPreviewWebviewConfig", () => { · 5 unmodified lines
2527 it.effect("preserves the bridge rejection as the load failure cause", () =>
2628 Effect.gen(function* () {
2729 const cause = new Error("ipc unavailable");
28− const error = yield* loadPreviewWebviewConfig(environmentId, {
30+ const error = yield* loadPreviewWebviewConfig(environmentId, undefined, {
2931 getPreviewConfig: () => Promise.reject(cause),
3032 }).pipe(Effect.flip);
3133
@@ -36,22 +38,23 @@ describe("loadPreviewWebviewConfig", () => { · 4 unmodified lines
3638 }),
3739 );
3840
39− it.effect("forwards the environment id to the bridge", () =>
41+ it.effect("forwards the environment id and profile to the bridge", () =>
4042 Effect.gen(function* () {
41− let requestedEnvironmentId: EnvironmentId | null = null;
43+ let requested: { environmentId: EnvironmentId; profileId: string | undefined } | null = null;
4244 const config = {
4345 partition: "persist:test-preview",
4446 webPreferences: "sandbox=yes",
4547 preloadUrl: null,
4648 };
47− const result = yield* loadPreviewWebviewConfig(environmentId, {
48− getPreviewConfig: (input) => {
49− requestedEnvironmentId = input;
49+ const result = yield* loadPreviewWebviewConfig(environmentId, "work", {
50+ getPreviewConfig: (requestedEnvironmentId, profileId) => {
51+ requested = { environmentId: requestedEnvironmentId, profileId };
5052 return Promise.resolve(config);
5153 },
5254 });
5355
54− expect(requestedEnvironmentId).toBe(environmentId);
56+ // The partition is derived in main from both, so both have to arrive.
57+ expect(requested).toEqual({ environmentId, profileId: "work" });
5558 expect(result).toEqual(config);
5659 }),
5760 );
apps/web/src/browser/previewWebviewConfigState.ts+35−7
@@ -45,6 +45,7 @@ type PreviewConfigBridge = Pick<DesktopPreviewBridge, "getPreviewConfig">; · 44 unmodified lines
4545
4646 export const loadPreviewWebviewConfig = (
4747 environmentId: EnvironmentId,
48+ profileId?: string,
4849 bridge: PreviewConfigBridge | null = previewBridge,
4950 ): Effect.Effect<DesktopPreviewWebviewConfig, PreviewWebviewConfigError> => {
5051 if (bridge === null) {
@@ -52,25 +53,52 @@ export const loadPreviewWebviewConfig = ( · 1 unmodified line
5253 }
5354
5455 return Effect.tryPromise({
55− try: () => bridge.getPreviewConfig(environmentId),
56+ try: () => bridge.getPreviewConfig(environmentId, profileId),
5657 catch: (cause) => new PreviewWebviewConfigLoadError({ environmentId, cause }),
5758 });
5859 };
5960
60−const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) =>
61− Atom.make(loadPreviewWebviewConfig(environmentId)).pipe(
61+/**
62+ * `Atom.family` keys on its argument, so the environment and profile are
63+ * folded into one string: passing an object would allocate a fresh entry on
64+ * every render.
65+ *
66+ * The profile is the tail rather than a second field, so an id containing the
67+ * delimiter round-trips whole instead of being truncated into a different
68+ * profile's key. `BrowserProfileId` rejects control characters, which is what
69+ * makes the environment side of the split unambiguous.
70+ */
71+const CONFIG_KEY_DELIMITER = "\u0000";
72+
73+const configKey = (environmentId: EnvironmentId, profileId: string | undefined): string =>
74+ `${environmentId}${CONFIG_KEY_DELIMITER}${profileId ?? ""}`;
75+
76+const parseConfigKey = (key: string): { environmentId: EnvironmentId; profileId?: string } => {
77+ const delimiter = key.indexOf(CONFIG_KEY_DELIMITER);
78+ const environmentId = (delimiter === -1 ? key : key.slice(0, delimiter)) as EnvironmentId;
79+ const profileId = delimiter === -1 ? "" : key.slice(delimiter + CONFIG_KEY_DELIMITER.length);
80+ return {
81+ environmentId,
82+ ...(profileId === "" ? {} : { profileId }),
83+ };
84+};
85+
86+const previewWebviewConfigAtom = Atom.family((key: string) => {
87+ const { environmentId, profileId } = parseConfigKey(key);
88+ return Atom.make(loadPreviewWebviewConfig(environmentId, profileId)).pipe(
6289 Atom.swr({
6390 staleTime: PREVIEW_CONFIG_STALE_TIME_MS,
6491 revalidateOnMount: true,
6592 }),
6693 Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS),
67− Atom.withLabel(`preview:webview-config:${environmentId}`),
68− ),
69−);
94+ Atom.withLabel(`preview:webview-config:${key}`),
95+ );
96+});
7097
7198 export function usePreviewWebviewConfig(
7299 environmentId: EnvironmentId,
100+ profileId?: string,
73101 ): DesktopPreviewWebviewConfig | null {
74− const result = useAtomValue(previewWebviewConfigAtom(environmentId));
102+ const result = useAtomValue(previewWebviewConfigAtom(configKey(environmentId, profileId)));
75103 return Option.getOrNull(AsyncResult.value(result));
76104 }
apps/web/src/components/ChatView.tsx+15−6
@@ -3608,10 +3608,17 @@ function ChatViewContent(props: ChatViewProps) { · 3607 unmodified lines
36083608 const toggleInteractionMode = useCallback(() => {
36093609 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
36103610 }, [handleInteractionModeChange, interactionMode]);
3611− const createBrowserSurface = useCallback(() => {
3612− if (!activeThreadRef) return;
3613− void addBrowserSurface({ threadRef: activeThreadRef, openPreview });
3614− }, [activeThreadRef, openPreview]);
3611+ const createBrowserSurface = useCallback(
3612+ (profileId?: string) => {
3613+ if (!activeThreadRef) return;
3614+ void addBrowserSurface({
3615+ threadRef: activeThreadRef,
3616+ openPreview,
3617+ ...(profileId === undefined ? {} : { profileId }),
3618+ });
3619+ },
3620+ [activeThreadRef, openPreview],
3621+ );
36153622 const addDiffSurface = useCallback(() => {
36163623 if (!activeThreadRef || !isServerThread || !isGitRepo) return;
36173624 useRightPanelStore.getState().open(activeThreadRef, "diff");
@@ -7664,7 +7671,8 @@ function ChatViewContent(props: ChatViewProps) { · 4046 unmodified lines
76647671 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
76657672 onCloseAllSurfaces={closeAllRightPanelSurfaces}
76667673 onCopyFilePath={copyRightPanelFilePath}
7667− onAddBrowser={createBrowserSurface}
7674+ onAddBrowser={() => createBrowserSurface()}
7675+ onAddBrowserInProfile={createBrowserSurface}
76687676 onAddTerminal={addTerminalSurface}
76697677 onAddDiff={addDiffSurface}
76707678 onAddFiles={addFilesSurface}
@@ -7704,7 +7712,8 @@ function ChatViewContent(props: ChatViewProps) { · 33 unmodified lines
77047712 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
77057713 onCloseAllSurfaces={closeAllRightPanelSurfaces}
77067714 onCopyFilePath={copyRightPanelFilePath}
7707− onAddBrowser={createBrowserSurface}
7715+ onAddBrowser={() => createBrowserSurface()}
7716+ onAddBrowserInProfile={createBrowserSurface}
77087717 onAddTerminal={addTerminalSurface}
77097718 onAddDiff={addDiffSurface}
77107719 onAddFiles={addFilesSurface}
apps/web/src/components/RightPanelTabs.test.tsx+10−0
@@ -4,11 +4,20 @@ import { describe, expect, it } from "vite-plus/test"; · 3 unmodified lines
44
55 import {
66 RightPanelTabs,
7+ shouldOpenDefaultBrowserProfileFromMenuClick,
78 surfaceShortcutActionForKey,
89 surfaceShortcutTargetsTypingContext,
910 tabMuteMenuItem,
1011 } from "./RightPanelTabs";
1112
13+describe("browser profile submenu", () => {
14+ it("reserves touch clicks for opening the choices while mouse clicks use the default", () => {
15+ expect(shouldOpenDefaultBrowserProfileFromMenuClick("touch")).toBe(false);
16+ expect(shouldOpenDefaultBrowserProfileFromMenuClick("mouse")).toBe(true);
17+ expect(shouldOpenDefaultBrowserProfileFromMenuClick(undefined)).toBe(true);
18+ });
19+});
20+
1221 function shortcutEvent(
1322 key: string,
1423 overrides: Partial<Parameters<typeof surfaceShortcutActionForKey>[1]> = {},
@@ -104,6 +113,7 @@ function renderTabs( · 89 unmodified lines
104113 onCloseAllSurfaces={() => undefined}
105114 onCopyFilePath={() => undefined}
106115 onAddBrowser={() => undefined}
116+ onAddBrowserInProfile={() => undefined}
107117 onAddTerminal={() => undefined}
108118 onAddPullRequest={() => undefined}
109119 onAddDiff={() => undefined}
apps/web/src/components/RightPanelTabs.tsx+73−1
@@ -30,7 +30,17 @@ import { readLocalApi } from "~/localApi"; · 29 unmodified lines
3030 import { Button } from "~/components/ui/button";
3131 import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
3232 import { Kbd } from "~/components/ui/kbd";
33−import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "~/components/ui/menu";
33+import {
34+ Menu,
35+ MenuItem,
36+ MenuPopup,
37+ MenuShortcut,
38+ MenuSub,
39+ MenuSubPopup,
40+ MenuSubTrigger,
41+ MenuTrigger,
42+} from "~/components/ui/menu";
43+import { useBrowserDefaults } from "~/browser/browserDefaults";
3444 import { ScrollArea } from "~/components/ui/scroll-area";
3545 import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
3646 import { faviconUrlForOrigin } from "~/lib/favicon";
@@ -69,6 +79,12 @@ interface RightPanelTabsProps { · 32 unmodified lines
6979 onCloseAllSurfaces: () => void;
7080 onCopyFilePath: (relativePath: string) => void;
7181 onAddBrowser: () => void;
82+ /**
83+ * Separate from `onAddBrowser` on purpose: that one is passed directly as a
84+ * DOM click handler, and a `(profileId?: string)` signature would silently
85+ * accept the MouseEvent as a profile id.
86+ */
87+ onAddBrowserInProfile: (profileId: string) => void;
7288 onAddTerminal: () => void;
7389 onAddDiff: () => void;
7490 onAddFiles: () => void;
@@ -94,6 +110,12 @@ export interface PullRequestTabStatus { · 19 unmodified lines
94110 isDraft: boolean;
95111 }
96112
113+export function shouldOpenDefaultBrowserProfileFromMenuClick(
114+ pointerType: string | undefined,
115+): boolean {
116+ return pointerType !== "touch";
117+}
118+
97119 const SURFACE_DISABLED_REASONS = {
98120 browser: "Browser previews are only available in the T3 Code desktop app.",
99121 terminal: "Terminal surfaces are only available from a project thread.",
@@ -600,6 +622,7 @@ function SurfaceIcon({ · 500 unmodified lines
600622
601623 export function RightPanelTabs(props: RightPanelTabsProps) {
602624 const ownsDesktopTitleBar = isElectron && props.mode === "inline";
625+ const browserProfiles = useBrowserDefaults().profiles;
603626 const { resolvedTheme } = useTheme();
604627 const tabListRef = useRef<HTMLDivElement>(null);
605628 const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);
@@ -911,6 +934,55 @@ export function RightPanelTabs(props: RightPanelTabsProps) { · 305 unmodified lines
911934 >
912935 {addSurfaceActions.map((action) => {
913936 const Icon = action.icon;
937+ // Browser collapses into one row: clicking the trigger opens
938+ // the default profile (the common case stays one click),
939+ // while hover or arrow reveals the profiles. The choice
940+ // lives at open time because a tab's profile is fixed then —
941+ // Electron only honours a partition before attach.
942+ if (action.label === "Browser" && action.available) {
943+ return (
944+ <MenuSub key={action.label}>
945+ <MenuSubTrigger
946+ className="[&>svg:last-child]:ms-0"
947+ aria-keyshortcuts={action.shortcut}
948+ onClick={(event) => {
949+ const pointerType =
950+ "pointerType" in event.nativeEvent &&
951+ typeof event.nativeEvent.pointerType === "string"
952+ ? event.nativeEvent.pointerType
953+ : undefined;
954+ // Touch has no hover path to the profile choices:
955+ // its first tap opens the submenu, then a profile
956+ // is selected there. Mouse click keeps the common
957+ // default-profile action at one click.
958+ if (!shouldOpenDefaultBrowserProfileFromMenuClick(pointerType))
959+ return;
960+ setAddSurfaceMenuOpen(false);
961+ action.onClick();
962+ }}
963+ >
964+ <Icon />
965+ {action.label}
966+ <MenuShortcut>{action.shortcut}</MenuShortcut>
967+ </MenuSubTrigger>
968+ {/*
969+ Capped and truncated: profile names are user-supplied
970+ and run to 48 characters, which would otherwise widen
971+ the popup to fit-content and wrap.
972+ */}
973+ <MenuSubPopup className="min-w-40 max-w-56">
974+ {browserProfiles.map((profile) => (
975+ <MenuItem
976+ key={profile.id}
977+ onClick={() => props.onAddBrowserInProfile(profile.id)}
978+ >
979+ <span className="min-w-0 truncate">{profile.name}</span>
980+ </MenuItem>
981+ ))}
982+ </MenuSubPopup>
983+ </MenuSub>
984+ );
985+ }
914986 return (
915987 <SurfaceMenuItem
916988 key={action.label}
apps/web/src/components/preview/PreviewChromeRow.tsx+8−0
@@ -57,6 +57,11 @@ interface Props { · 56 unmodified lines
5757 * to mount the three-dot menu (hard reload, devtools, zoom, clear data).
5858 */
5959 trailingActions?: ReactNode;
60+ /**
61+ * Slot between the nav buttons and the URL input. The preview view uses it
62+ * to name the tab's browser profile, which is otherwise invisible.
63+ */
64+ leadingActions?: ReactNode;
6065 }
6166
6267 const NOOP = () => {};
@@ -85,6 +90,7 @@ export function PreviewChromeRow({ · 22 unmodified lines
8590 pickDisabled,
8691 pickDisabledReason,
8792 trailingActions,
93+ leadingActions,
8894 }: Props) {
8995 const inputRef = useRef<HTMLInputElement | null>(null);
9096 const [draft, setDraft] = useState(url);
@@ -166,6 +172,8 @@ export function PreviewChromeRow({ · 75 unmodified lines
166172 </Tooltip>
167173 </div>
168174
175+ {leadingActions}
176+
169177 <InputGroup variant="ghost" className="group/address h-7 flex-1">
170178 <Tooltip>
171179 <TooltipTrigger
apps/web/src/components/preview/PreviewMoreMenu.tsx+48−7
@@ -1,6 +1,6 @@
11 "use client";
22
3−import type { DesktopPreviewColorScheme } from "@t3tools/contracts";
3+import type { DesktopPreviewColorScheme, EnvironmentId } from "@t3tools/contracts";
44 import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react";
55
66 import { Button } from "~/components/ui/button";
@@ -10,6 +10,8 @@ import { · 3 unmodified lines
1010 MenuPopup,
1111 MenuRadioGroup,
1212 MenuRadioItem,
13+ MenuGroup,
14+ MenuGroupLabel,
1315 MenuSeparator,
1416 MenuSub,
1517 MenuSubPopup,
@@ -50,6 +52,17 @@ interface Props { · 34 unmodified lines
5052 nativePictureInPicture: boolean;
5153 /** Toggles the optional native always-on-top preview window. */
5254 onNativePictureInPicture: () => void;
55+ /** Environment the tab belongs to; scopes storage clearing to its partitions. */
56+ environmentId: EnvironmentId;
57+ /** Profile the tab was opened under, if the server recorded one. */
58+ /**
59+ * Required: the IPC layer reads an absent profile as "every profile", so a
60+ * tab whose own profile is unknown must resolve the default before it gets
61+ * here rather than passing the gap along.
62+ */
63+ profileId: string;
64+ /** Profile display name, shown so the menu says which data is being cleared. */
65+ profileName: string | undefined;
5366 }
5467
5568 /**
@@ -66,6 +79,9 @@ export function PreviewMoreMenu({ · 10 unmodified lines
6679 onToggleDeviceToolbar,
6780 nativePictureInPicture,
6881 onNativePictureInPicture,
82+ environmentId,
83+ profileId,
84+ profileName,
6985 }: Props) {
7086 if (!previewBridge) return null;
7187 const bridge = previewBridge;
@@ -177,12 +193,37 @@ export function PreviewMoreMenu({ · 105 unmodified lines
177193 </span>
178194 </MenuItem>
179195 <MenuSeparator />
180− <MenuItem onClick={() => void bridge.clearCookies().catch(() => undefined)}>
181− Clear cookies
182− </MenuItem>
183− <MenuItem onClick={() => void bridge.clearCache().catch(() => undefined)}>
184− Clear cache
185− </MenuItem>
196+ {/*
197+ Grouped so the heading has a `MenuGroup` ancestor — `MenuGroupLabel`
198+ reads its context and throws without one. The heading also answers
199+ which profile the tab is in, which is otherwise invisible: it is fixed
200+ at open and nothing else in the chrome shows it.
201+ */}
202+ <MenuGroup>
203+ {/*
204+ The heading carries the profile so the actions below can keep
205+ fixed-length labels: repeating a name of up to 48 characters in
206+ each one drove the popup far past its width.
207+ */}
208+ {profileName ? (
209+ // Truncation sits on the label itself: it renders a block box, so
210+ // `text-overflow` on an inline child inside it never applies and a
211+ // long name would push the popup past its width instead.
212+ <MenuGroupLabel className="max-w-64 truncate">Profile: {profileName}</MenuGroupLabel>
213+ ) : null}
214+ <MenuItem
215+ onClick={() =>
216+ void bridge.clearCookies(environmentId, profileId).catch(() => undefined)
217+ }
218+ >
219+ Clear cookies
220+ </MenuItem>
221+ <MenuItem
222+ onClick={() => void bridge.clearCache(environmentId, profileId).catch(() => undefined)}
223+ >
224+ Clear cache
225+ </MenuItem>
226+ </MenuGroup>
186227 </MenuPopup>
187228 </Menu>
188229 );
apps/web/src/components/preview/PreviewView.test.tsx+21−13
@@ -1,4 +1,6 @@
11 import {
2+ BUILT_IN_BROWSER_PROFILES,
3+ DEFAULT_BROWSER_PROFILE_ID,
24 DEFAULT_PREVIEW_APPEARANCE,
35 DEFAULT_PREVIEW_ZOOM_FACTOR,
46 EnvironmentId,
@@ -37,6 +39,15 @@ const mocks = vi.hoisted(() => ({ · 32 unmodified lines
3739
3840 const EMPTY_HISTORY: never[] = [];
3941
42+const STUB_BROWSER_DEFAULTS = {
43+ viewport: FILL_PREVIEW_VIEWPORT,
44+ zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
45+ appearance: DEFAULT_PREVIEW_APPEARANCE,
46+ autoShowFloatingPreview: true,
47+ profiles: BUILT_IN_BROWSER_PROFILES,
48+ profileId: DEFAULT_BROWSER_PROFILE_ID,
49+};
50+
4051 vi.mock("~/browserHistoryStore", () => ({
4152 recordVisitForThread: mocks.recordVisitForThread,
4253 setTitleForThreadUrl: vi.fn(),
@@ -53,19 +64,10 @@ vi.mock("~/state/session", () => ({ · 10 unmodified lines
5364 // `useSettings` -> `state/server`, which would drag the whole settings and
5465 // connection graph into a test that only cares about the browser chrome.
5566 vi.mock("~/browser/browserDefaults", () => ({
56− useBrowserDefaults: () => ({
57− viewport: FILL_PREVIEW_VIEWPORT,
58− zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
59− appearance: DEFAULT_PREVIEW_APPEARANCE,
60− autoShowFloatingPreview: true,
61− }),
62− getBrowserDefaults: () => ({
63− viewport: FILL_PREVIEW_VIEWPORT,
64− zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
65− appearance: DEFAULT_PREVIEW_APPEARANCE,
66− autoShowFloatingPreview: true,
67− }),
67+ useBrowserDefaults: () => STUB_BROWSER_DEFAULTS,
68+ getBrowserDefaults: () => STUB_BROWSER_DEFAULTS,
6869 browserDefaultOpenViewport: () => FILL_PREVIEW_VIEWPORT,
70+ browserDefaultOpenProfileId: () => DEFAULT_BROWSER_PROFILE_ID,
6971 browserDefaultTabState: () => ({
7072 zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
7173 colorScheme: DEFAULT_PREVIEW_APPEARANCE,
@@ -249,7 +251,7 @@ vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); · 177 unmodified lines
249251 vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null }));
250252 vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() }));
251253
252−import { PreviewView } from "./PreviewView";
254+import { PreviewView, previewProfileName } from "./PreviewView";
253255 import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
254256
255257 const TEST_THREAD_REF = {
@@ -347,6 +349,12 @@ describe("PreviewView navigation", () => { · 91 unmodified lines
347349 mocks.recordVisitForThread.mockClear();
348350 });
349351
352+ it("labels a tab whose saved profile was removed", () => {
353+ expect(previewProfileName(BUILT_IN_BROWSER_PROFILES, "profile-removed")).toBe(
354+ "Removed profile",
355+ );
356+ });
357+
350358 it("does not rerender while loading time passes", async () => {
351359 vi.useFakeTimers();
352360 mocks.loading = true;
apps/web/src/components/preview/PreviewView.tsx+41−0
@@ -3,6 +3,7 @@ · 2 unmodified lines
33 import { scopedThreadKey } from "@t3tools/client-runtime/environment";
44 import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
55 import {
6+ DEFAULT_BROWSER_PROFILE_ID,
67 FILL_PREVIEW_VIEWPORT,
78 type PreviewAnnotationPayload,
89 type PreviewViewportSetting,
@@ -48,6 +49,7 @@ import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; · 39 unmodified lines
4849 import { PreviewUnreachable } from "./PreviewUnreachable";
4950 import { revealInFileExplorerLabel } from "./fileExplorerLabel";
5051 import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
52+import { Badge } from "~/components/ui/badge";
5153 import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
5254 import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
5355 import { usePreviewSession } from "./usePreviewSession";
@@ -61,6 +63,7 @@ import { · 7 unmodified lines
6163 useActiveBrowserRecordingTabIds,
6264 } from "~/browser/browserRecording";
6365 import { stackedThreadToast, toastManager } from "~/components/ui/toast";
66+import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
6467
6568 interface Props {
6669 threadRef: ScopedThreadRef;
@@ -73,6 +76,13 @@ interface Props { · 6 unmodified lines
7376 ) => void;
7477 }
7578
79+export function previewProfileName(
80+ profiles: ReadonlyArray<{ readonly id: string; readonly name: string }>,
81+ profileId: string,
82+): string {
83+ return profiles.find((profile) => profile.id === profileId)?.name ?? "Removed profile";
84+}
85+
7686 const localApi = typeof window === "undefined" ? null : ensureLocalApi();
7787
7888 /**
@@ -144,6 +154,14 @@ export function PreviewView({ · 65 unmodified lines
144154 const controller = desktopOverlay?.controller ?? "none";
145155 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
146156 const browserDefaults = useBrowserDefaults();
157+ // A tab created before profiles existed carries no profile of its own. It
158+ // runs in the built-in `default` partition — the scope the browser used
159+ // before profiles — not in whatever profile is configured as the default
160+ // now, so that is what its label names and its clear actions target.
161+ // Passing the snapshot's raw `undefined` through would reach the IPC layer
162+ // as "every profile".
163+ const activeProfileId = snapshot?.profileId ?? DEFAULT_BROWSER_PROFILE_ID;
164+ const activeProfileName = previewProfileName(browserDefaults.profiles, activeProfileId);
147165 const panelRect = useBrowserSurfaceStore((state) =>
148166 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,
149167 );
@@ -686,9 +704,32 @@ export function PreviewView({ · 536 unmodified lines
686704 pickDisabledReason={
687705 isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined
688706 }
707+ leadingActions={
708+ // Only when it differs from the default: labelling every tab
709+ // "Default" would be noise on the common case, while a tab in
710+ // another profile is exactly what needs calling out.
711+ activeProfileId !== browserDefaults.profileId ? (
712+ // Capped: profile names run to 48 characters, and an unbounded
713+ // badge in this row takes its width from the URL input, the only
714+ // flexible element in the compact chrome. The cap sits on the
715+ // badge and the truncation on an inner span, because `Badge` is an
716+ // `inline-flex` with `whitespace-nowrap` — `text-overflow` never
717+ // reaches a bare text node inside it, so the name would be cut off
718+ // at both ends with no ellipsis.
719+ <Tooltip>
720+ <TooltipTrigger render={<Badge variant="outline" className="max-w-28 shrink-0" />}>
721+ <span className="truncate">{activeProfileName}</span>
722+ </TooltipTrigger>
723+ <TooltipPopup side="top">{activeProfileName}</TooltipPopup>
724+ </Tooltip>
725+ ) : null
726+ }
689727 trailingActions={
690728 previewBridge ? (
691729 <PreviewMoreMenu
730+ environmentId={threadRef.environmentId}
731+ profileId={activeProfileId}
732+ profileName={activeProfileName}
692733 tabId={runtimeTabId}
693734 hasWebContents={desktopOverlay?.hasWebContents ?? false}
694735 zoomFactor={desktopOverlay?.zoomFactor ?? 1}
apps/web/src/components/preview/addBrowserSurface.test.ts+20−0
@@ -1,4 +1,5 @@
11 import {
2+ DEFAULT_BROWSER_PROFILE_ID,
23 FILL_PREVIEW_VIEWPORT,
34 type PreviewOpenInput,
45 type PreviewSessionSnapshot,
@@ -36,6 +37,24 @@ beforeEach(() => { · 31 unmodified lines
3637 });
3738
3839 describe("addBrowserSurface", () => {
40+ it("opens under the requested profile", async () => {
41+ const openPreview = vi.fn(async (_input: PreviewOpenInput) =>
42+ AsyncResult.success(snapshot("tab-1")),
43+ );
44+
45+ await addBrowserSurface({
46+ threadRef,
47+ openPreview: ({ input }) => openPreview(input),
48+ profileId: "profile-work",
49+ });
50+
51+ expect(openPreview).toHaveBeenCalledWith({
52+ threadId: "thread-1",
53+ viewport: FILL_PREVIEW_VIEWPORT,
54+ profileId: "profile-work",
55+ });
56+ });
57+
3958 it("creates another preview session when a browser tab is already active", async () => {
4059 const first = snapshot("tab-1");
4160 const second = snapshot("tab-2");
@@ -48,6 +67,7 @@ describe("addBrowserSurface", () => { · 6 unmodified lines
4867 expect(openPreview).toHaveBeenCalledWith({
4968 threadId: "thread-1",
5069 viewport: FILL_PREVIEW_VIEWPORT,
70+ profileId: DEFAULT_BROWSER_PROFILE_ID,
5171 });
5272 expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]);
5373 expect(
apps/web/src/components/preview/addBrowserSurface.ts+3−0
@@ -13,10 +13,13 @@ import { openPreviewSession } from "./openPreviewSession"; · 12 unmodified lines
1313 export async function addBrowserSurface<E>(input: {
1414 readonly threadRef: ScopedThreadRef;
1515 readonly openPreview: OpenPreviewMutation<E>;
16+ /** Omit to use the configured default profile. */
17+ readonly profileId?: string | undefined;
1618 }): Promise<AtomCommandResult<void, E>> {
1719 const result = await openPreviewSession({
1820 openPreview: input.openPreview,
1921 threadRef: input.threadRef,
22+ ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
2023 });
2124 return mapAtomCommandResult(result, (snapshot) => {
2225 useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId);
apps/web/src/components/preview/openPreviewSession.test.ts+3−0
@@ -1,4 +1,5 @@
11 import {
2+ DEFAULT_BROWSER_PROFILE_ID,
23 FILL_PREVIEW_VIEWPORT,
34 type PreviewOpenInput,
45 type PreviewSessionSnapshot,
@@ -49,6 +50,7 @@ describe("openPreviewSession", () => { · 44 unmodified lines
4950 expect(open).toHaveBeenCalledWith({
5051 threadId: "thread-1",
5152 viewport: FILL_PREVIEW_VIEWPORT,
53+ profileId: DEFAULT_BROWSER_PROFILE_ID,
5254 });
5355 expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot);
5456 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]);
@@ -67,6 +69,7 @@ describe("openPreviewSession", () => { · 12 unmodified lines
6769 threadId: "thread-1",
6870 url: "t3.chat",
6971 viewport: FILL_PREVIEW_VIEWPORT,
72+ profileId: DEFAULT_BROWSER_PROFILE_ID,
7073 });
7174 expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);
7275 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]);
apps/web/src/components/preview/openPreviewSession.ts+12−2
@@ -7,7 +7,11 @@ import type { · 6 unmodified lines
77 } from "@t3tools/contracts";
88 import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
99
10−import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults";
10+import {
11+ browserDefaultOpenProfileId,
12+ browserDefaultOpenViewport,
13+ resolveBrowserDefaults,
14+} from "~/browser/browserDefaults";
1115 import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";
1216
1317 interface OpenPreviewSessionInput<E> {
@@ -19,17 +23,23 @@ interface OpenPreviewSessionInput<E> { · 5 unmodified lines
1923 url?: string;
2024 /** Overrides the configured default; automation passes an explicit size. */
2125 viewport?: PreviewViewportSetting;
26+ /** Overrides the configured default profile. */
27+ profileId?: string;
2228 }
2329
2430 export async function openPreviewSession<E>(
2531 input: OpenPreviewSessionInput<E>,
2632 ): Promise<AtomCommandResult<PreviewSessionSnapshot, E>> {
33+ // Resolved once: a tab opened before client settings hydrate would otherwise
34+ // be born at the schema defaults and never corrected.
35+ const defaults = await resolveBrowserDefaults();
2736 const result = await input.openPreview({
2837 environmentId: input.threadRef.environmentId,
2938 input: {
3039 threadId: input.threadRef.threadId,
3140 ...(input.url === undefined ? {} : { url: input.url }),
32− viewport: input.viewport ?? browserDefaultOpenViewport(await resolveBrowserDefaults()),
41+ viewport: input.viewport ?? browserDefaultOpenViewport(defaults),
42+ profileId: input.profileId ?? browserDefaultOpenProfileId(defaults),
3343 },
3444 });
3545 if (result._tag === "Failure") {
apps/web/src/components/preview/openTerminalLinkInPreview.test.ts+59−1
@@ -1,7 +1,7 @@
11 import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
22 import * as Cause from "effect/Cause";
33 import { AsyncResult } from "effect/unstable/reactivity";
4−import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
55
66 import {
77 openTerminalLinkInPreview,
@@ -20,6 +20,21 @@ vi.mock("~/rightPanelStore", () => ({ · 12 unmodified lines
2020 },
2121 }));
2222
23+const browserDefaultsMocks = vi.hoisted(() => ({
24+ resolve: vi.fn(),
25+}));
26+
27+vi.mock("~/browser/browserDefaults", () => ({
28+ resolveBrowserDefaults: browserDefaultsMocks.resolve,
29+ browserDefaultOpenViewport: (defaults: { viewport: unknown }) => defaults.viewport,
30+ browserDefaultOpenProfileId: (defaults: { profileId: string }) => defaults.profileId,
31+}));
32+
33+const hydratedDefaults = {
34+ viewport: { _tag: "fixed", width: 1280, height: 720 } as const,
35+ profileId: "work",
36+};
37+
2338 const threadRef = {
2439 environmentId: "local" as ScopedThreadRef["environmentId"],
2540 threadId: "thread-1" as ScopedThreadRef["threadId"],
@@ -34,11 +49,54 @@ const snapshot: PreviewSessionSnapshot = { · 8 unmodified lines
3449 updatedAt: "2026-06-20T00:00:00.000Z",
3550 };
3651
52+beforeEach(() => {
53+ browserDefaultsMocks.resolve.mockResolvedValue(hydratedDefaults);
54+});
55+
3756 afterEach(() => {
3857 vi.restoreAllMocks();
3958 });
4059
4160 describe("openTerminalLinkInPreview", () => {
61+ it("waits for hydrated viewport and profile defaults before opening", async () => {
62+ let hydrate: ((defaults: typeof hydratedDefaults) => void) | undefined;
63+ browserDefaultsMocks.resolve.mockImplementationOnce(
64+ () =>
65+ new Promise<typeof hydratedDefaults>((resolve) => {
66+ hydrate = resolve;
67+ }),
68+ );
69+ const openPreview = vi.fn(async () => AsyncResult.success(snapshot));
70+
71+ const opening = openTerminalLinkInPreview({
72+ url: "http://localhost:3000/",
73+ position: { x: 12, y: 34 },
74+ threadRef,
75+ openPreview,
76+ localApi: {
77+ contextMenu: {
78+ show: vi.fn(async () => "open-in-preview"),
79+ },
80+ } as unknown as LocalApi,
81+ fallbackToBrowser: vi.fn(),
82+ });
83+
84+ await vi.waitFor(() => expect(browserDefaultsMocks.resolve).toHaveBeenCalledOnce());
85+ expect(openPreview).not.toHaveBeenCalled();
86+ hydrate?.(hydratedDefaults);
87+ await opening;
88+
89+ expect(openPreview).toHaveBeenCalledWith({
90+ environmentId: "local",
91+ input: {
92+ threadId: "thread-1",
93+ url: "http://localhost:3000/",
94+ viewport: hydratedDefaults.viewport,
95+ profileId: hydratedDefaults.profileId,
96+ },
97+ });
98+ });
99+
42100 it("preserves context-menu failures with terminal link context before falling back", async () => {
43101 const cause = new Error("menu unavailable");
44102 const fallbackToBrowser = vi.fn();
apps/web/src/components/preview/openTerminalLinkInPreview.ts+14−1
@@ -3,6 +3,11 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime" · 2 unmodified lines
33 import { isPreviewableUrl } from "@t3tools/shared/preview";
44 import * as Schema from "effect/Schema";
55
6+import {
7+ browserDefaultOpenProfileId,
8+ browserDefaultOpenViewport,
9+ resolveBrowserDefaults,
10+} from "~/browser/browserDefaults";
611 import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
712 import { recordVisitForThread } from "~/browserHistoryStore";
813 import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";
@@ -82,9 +87,17 @@ export async function openTerminalLinkInPreview<E>( · 73 unmodified lines
8287 }
8388
8489 if (choice === "open-in-preview") {
90+ const defaults = await resolveBrowserDefaults();
8591 const result = await input.openPreview({
8692 environmentId: input.threadRef.environmentId,
87− input: { threadId: input.threadRef.threadId, url: input.url },
93+ input: {
94+ threadId: input.threadRef.threadId,
95+ url: input.url,
96+ // Same reason as `openUrlInPreview`: this path handles its own result
97+ // mapping, so the configured defaults are applied explicitly.
98+ viewport: browserDefaultOpenViewport(defaults),
99+ profileId: browserDefaultOpenProfileId(defaults),
100+ },
88101 });
89102 if (result._tag === "Failure") {
90103 if (isAtomCommandInterrupted(result)) {
apps/web/src/components/settings/IntegrationsSettings.logic.test.ts+74−0
@@ -0,0 +1,74 @@
1+import { describe, expect, it, vi } from "vite-plus/test";
2+import type { EnvironmentId } from "@t3tools/contracts";
3+
4+import { browserProfileRemovalAvailable, clearBrowserProfileData } from "./IntegrationsSettings";
5+
6+const environmentId = "environment-a" as EnvironmentId;
7+const secondEnvironmentId = "environment-b" as EnvironmentId;
8+
9+describe("clearBrowserProfileData", () => {
10+ it("waits for cookie and cache cleanup", async () => {
11+ const clearCookies = vi.fn().mockResolvedValue(undefined);
12+ const clearCache = vi.fn().mockResolvedValue(undefined);
13+
14+ await clearBrowserProfileData({ clearCookies, clearCache }, [environmentId], "profile-a");
15+
16+ expect(clearCookies).toHaveBeenCalledWith(environmentId, "profile-a");
17+ expect(clearCache).toHaveBeenCalledWith(environmentId, "profile-a");
18+ });
19+
20+ it("clears every known environment before succeeding", async () => {
21+ const clearCookies = vi.fn().mockResolvedValue(undefined);
22+ const clearCache = vi.fn().mockResolvedValue(undefined);
23+
24+ await clearBrowserProfileData(
25+ { clearCookies, clearCache },
26+ [environmentId, secondEnvironmentId],
27+ "profile-a",
28+ );
29+
30+ expect(clearCookies.mock.calls).toEqual([
31+ [environmentId, "profile-a"],
32+ [secondEnvironmentId, "profile-a"],
33+ ]);
34+ expect(clearCache.mock.calls).toEqual([
35+ [environmentId, "profile-a"],
36+ [secondEnvironmentId, "profile-a"],
37+ ]);
38+ });
39+
40+ it("propagates cleanup failures", async () => {
41+ const failure = new Error("clear failed");
42+ await expect(
43+ clearBrowserProfileData(
44+ {
45+ clearCookies: vi.fn().mockRejectedValue(failure),
46+ clearCache: vi.fn().mockResolvedValue(undefined),
47+ },
48+ [environmentId],
49+ "profile-a",
50+ ),
51+ ).rejects.toBe(failure);
52+ });
53+
54+ it("does not report success without an environment or bridge", async () => {
55+ const bridge = {
56+ clearCookies: vi.fn().mockResolvedValue(undefined),
57+ clearCache: vi.fn().mockResolvedValue(undefined),
58+ };
59+
60+ await expect(clearBrowserProfileData(bridge, [], "profile-a")).rejects.toThrow();
61+ await expect(clearBrowserProfileData(null, [environmentId], "profile-a")).rejects.toThrow();
62+ expect(bridge.clearCookies).not.toHaveBeenCalled();
63+ expect(bridge.clearCache).not.toHaveBeenCalled();
64+ });
65+});
66+
67+describe("browserProfileRemovalAvailable", () => {
68+ it("requires a ready non-empty catalog and desktop bridge", () => {
69+ expect(browserProfileRemovalAvailable(true, true, 1)).toBe(true);
70+ expect(browserProfileRemovalAvailable(true, true, 0)).toBe(false);
71+ expect(browserProfileRemovalAvailable(true, false, 1)).toBe(false);
72+ expect(browserProfileRemovalAvailable(false, true, 1)).toBe(false);
73+ });
74+});
apps/web/src/components/settings/IntegrationsSettings.tsx+351−1
@@ -7,8 +7,13 @@ · 6 unmodified lines
77 * @module IntegrationsSettings
88 */
99 import {
10+ BROWSER_PROFILE_MAX_COUNT,
11+ type BrowserProfile,
12+ type EnvironmentId,
13+ BROWSER_PROFILE_NAME_MAX_LENGTH,
1014 BROWSER_RECORDING_FRAME_RATES,
1115 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
16+ DEFAULT_BROWSER_PROFILE_ID,
1217 DEFAULT_BROWSER_RECORDING_FRAME_RATE,
1318 DEFAULT_BROWSER_VIEWPORT,
1419 DEFAULT_PREVIEW_APPEARANCE,
@@ -19,17 +24,35 @@ import { · 4 unmodified lines
1924 PREVIEW_VIEWPORT_MAX_DIMENSION,
2025 PREVIEW_VIEWPORT_MIN_DIMENSION,
2126 PREVIEW_ZOOM_LEVELS,
27+ findBrowserProfile,
28+ isBuiltInBrowserProfileId,
29+ resolveBrowserProfiles,
2230 type PreviewAppearancePreference,
2331 type PreviewViewportSetting,
2432 } from "@t3tools/contracts";
2533 import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
26−import { InfoIcon } from "lucide-react";
34+import { InfoIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react";
35+import { useState } from "react";
2736 import type { ReactNode } from "react";
2837
2938 import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";
39+import { previewBridge } from "~/components/preview/previewBridge";
40+import { cn, randomUUID } from "~/lib/utils";
41+import { useEnvironments } from "~/state/environments";
3042 import { isElectron } from "../../env";
3143
44+import { Badge } from "../ui/badge";
45+import {
46+ AlertDialog,
47+ AlertDialogClose,
48+ AlertDialogDescription,
49+ AlertDialogFooter,
50+ AlertDialogHeader,
51+ AlertDialogPopup,
52+ AlertDialogTitle,
53+} from "../ui/alert-dialog";
3254 import { Button } from "../ui/button";
55+import { DraftInput } from "../ui/draft-input";
3356 import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field";
3457 import {
3558 Select,
@@ -43,7 +66,9 @@ import { · 7 unmodified lines
4366 import { Switch } from "../ui/switch";
4467 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4568 import {
69+ getClientSettings,
4670 useClientSettings,
71+ useClientSettingsHydrated,
4772 usePrimarySettings,
4873 useUpdatePrimarySettings,
4974 } from "~/hooks/useSettings";
@@ -54,11 +79,41 @@ import { · 4 unmodified lines
5479 SettingsRow,
5580 SettingsSection,
5681 } from "./settingsLayout";
82+import { ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
5783 import { searchableSetting } from "./settingsSearch";
5884
5985 const FILL_VALUE = "fill";
6086 const RESPONSIVE_VALUE = "responsive";
6187
88+type BrowserProfileDataBridge = Pick<
89+ NonNullable<typeof previewBridge>,
90+ "clearCookies" | "clearCache"
91+>;
92+
93+export async function clearBrowserProfileData(
94+ bridge: BrowserProfileDataBridge | null,
95+ environmentIds: ReadonlyArray<EnvironmentId>,
96+ profileId: string,
97+): Promise<void> {
98+ if (bridge === null || environmentIds.length === 0) {
99+ throw new Error("Browser profile data is not available to clear.");
100+ }
101+ await Promise.all(
102+ environmentIds.flatMap((environmentId) => [
103+ bridge.clearCookies(environmentId, profileId),
104+ bridge.clearCache(environmentId, profileId),
105+ ]),
106+ );
107+}
108+
109+export function browserProfileRemovalAvailable(
110+ bridgeAvailable: boolean,
111+ environmentsReady: boolean,
112+ environmentCount: number,
113+): boolean {
114+ return bridgeAvailable && environmentsReady && environmentCount > 0;
115+}
116+
62117 /**
63118 * The size a "Responsive" default falls back to when the user switches away
64119 * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry
@@ -501,11 +556,306 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode · 436 unmodified lines
501556 );
502557 }
503558
559+/**
560+ * Create, rename, and remove browser profiles.
561+ *
562+ * Built-ins render without controls: they are synthesized rather than stored,
563+ * so there is nothing to rename and removing them would strand every tab that
564+ * opened under them.
565+ */
566+function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
567+ const userProfiles = useClientSettings((settings) => settings.browserProfiles);
568+ const settingsHydrated = useClientSettingsHydrated();
569+ const updateSettings = useUpdatePrimarySettings();
570+ const { environments, isReady: environmentsReady } = useEnvironments();
571+ const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
572+ const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);
573+ const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);
574+ const removalAvailable = browserProfileRemovalAvailable(
575+ previewBridge !== null,
576+ environmentsReady,
577+ environments.length,
578+ );
579+ const profileWritesDisabled = disabled || !settingsHydrated;
580+
581+ const addProfile = () => {
582+ if (!settingsHydrated) return;
583+ const currentProfiles = getClientSettings().browserProfiles;
584+ if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
585+ const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name));
586+ let name = "New profile";
587+ for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;
588+ updateSettings({
589+ browserProfiles: [
590+ ...currentProfiles,
591+ { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },
592+ ],
593+ });
594+ };
595+
596+ const renameProfile = (id: string, next: string) => {
597+ if (!settingsHydrated) return;
598+ const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
599+ if (name === "") return;
600+ const currentProfiles = getClientSettings().browserProfiles;
601+ updateSettings({
602+ browserProfiles: currentProfiles.map((profile) =>
603+ profile.id === id ? { ...profile, name } : profile,
604+ ),
605+ });
606+ };
607+
608+ const removeProfile = async (id: string) => {
609+ if (!settingsHydrated) return;
610+ if (!removalAvailable) {
611+ setProfileRemovalError("Connect to an environment before removing this profile.");
612+ return;
613+ }
614+ setProfileRemovalError(null);
615+ setProfileRemovalInFlight(true);
616+ // Drop the partition's data too, otherwise a removed profile's cookies
617+ // stay on disk with nothing in the UI pointing at them.
618+ try {
619+ await clearBrowserProfileData(
620+ previewBridge,
621+ environmentsReady ? environments.map((environment) => environment.environmentId) : [],
622+ id,
623+ );
624+ } catch {
625+ setProfileRemovalError("Profile data could not be deleted. Try again.");
626+ setProfileRemovalInFlight(false);
627+ return;
628+ }
629+ const currentSettings = getClientSettings();
630+ updateSettings({
631+ browserProfiles: currentSettings.browserProfiles.filter((profile) => profile.id !== id),
632+ // Reassign the default rather than leaving it pointing at nothing.
633+ ...(currentSettings.browserDefaultProfileId === id
634+ ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }
635+ : {}),
636+ });
637+ setProfileRemovalInFlight(false);
638+ setProfilePendingRemoval(null);
639+ };
640+
641+ return (
642+ <SettingsRow
643+ {...searchableSetting("browser-profiles")}
644+ 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."
645+ control={
646+ <Button
647+ size="sm"
648+ variant="outline"
649+ disabled={profileWritesDisabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
650+ onClick={addProfile}
651+ >
652+ <PlusIcon />
653+ Add profile
654+ </Button>
655+ }
656+ >
657+ {/*
658+ Each profile is its own bounded row, and the list carries the bottom
659+ spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows
660+ stack on narrow viewports with a larger gap inside a row than between
661+ rows, which reads as the remove button belonging to the profile below.
662+ */}
663+ <div className="mt-2 space-y-1 pb-2">
664+ {resolveBrowserProfiles(userProfiles).map((profile) => {
665+ const builtIn = isBuiltInBrowserProfileId(profile.id);
666+ return (
667+ <div
668+ key={profile.id}
669+ className={cn(
670+ ITEM_ROW_INNER_CLASSNAME,
671+ "rounded-lg border border-border/60 px-3 py-2",
672+ )}
673+ >
674+ {builtIn ? (
675+ // Dimmed here rather than on the list, which is the only
676+ // content in the row without a disabled treatment of its own:
677+ // a wrapper-level dim would stack with the rename field's and
678+ // the remove button's, landing them near 0.41 while every
679+ // other disabled control in the block sits at 0.64.
680+ <span
681+ className={cn(
682+ "flex min-w-0 items-center gap-2 text-sm text-foreground",
683+ profileWritesDisabled && "opacity-64",
684+ )}
685+ >
686+ {profile.name}
687+ <Badge variant="outline">
688+ {profile.kind === "incognito" ? "Ephemeral" : "Built-in"}
689+ </Badge>
690+ </span>
691+ ) : (
692+ <DraftInput
693+ nativeInput
694+ size="sm"
695+ className="w-full sm:w-64"
696+ aria-label={`Rename ${profile.name}`}
697+ disabled={profileWritesDisabled}
698+ maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
699+ value={profile.name}
700+ onCommit={(next) => renameProfile(profile.id, next)}
701+ />
702+ )}
703+ {builtIn ? null : (
704+ <Tooltip>
705+ <TooltipTrigger
706+ render={
707+ <span className="inline-flex" {...(!removalAvailable ? { tabIndex: 0 } : {})}>
708+ <Button
709+ size="icon-sm"
710+ variant="ghost-muted"
711+ disabled={profileWritesDisabled || !removalAvailable}
712+ aria-label={`Remove ${profile.name}`}
713+ onClick={() => setProfilePendingRemoval(profile)}
714+ >
715+ <Trash2Icon />
716+ </Button>
717+ </span>
718+ }
719+ />
720+ <TooltipPopup side="top">
721+ {removalAvailable
722+ ? "Remove profile and its data"
723+ : "Connect to an environment to remove this profile"}
724+ </TooltipPopup>
725+ </Tooltip>
726+ )}
727+ </div>
728+ );
729+ })}
730+ </div>
731+ <AlertDialog
732+ open={profilePendingRemoval !== null}
733+ onOpenChange={(open) => {
734+ if (!open && !profileRemovalInFlight) {
735+ setProfilePendingRemoval(null);
736+ setProfileRemovalError(null);
737+ }
738+ }}
739+ >
740+ <AlertDialogPopup>
741+ <AlertDialogHeader>
742+ <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle>
743+ <AlertDialogDescription>
744+ Its cookies, logins, and cache are deleted with it. Tabs already open in this profile
745+ stay open until you close them.
746+ </AlertDialogDescription>
747+ {profileRemovalError ? (
748+ <p aria-live="polite" className="text-sm text-destructive">
749+ {profileRemovalError}
750+ </p>
751+ ) : null}
752+ {!removalAvailable ? (
753+ <p className="text-sm text-muted-foreground">
754+ Connect to an environment to remove this profile and its data.
755+ </p>
756+ ) : null}
757+ </AlertDialogHeader>
758+ <AlertDialogFooter>
759+ <AlertDialogClose
760+ disabled={profileRemovalInFlight}
761+ render={<Button variant="outline" disabled={profileRemovalInFlight} />}
762+ >
763+ Cancel
764+ </AlertDialogClose>
765+ <Button
766+ variant="destructive"
767+ disabled={profileRemovalInFlight || !settingsHydrated || !removalAvailable}
768+ onClick={() => {
769+ if (profilePendingRemoval && settingsHydrated && removalAvailable) {
770+ void removeProfile(profilePendingRemoval.id);
771+ }
772+ }}
773+ >
774+ {profileRemovalInFlight ? "Removing…" : "Remove profile"}
775+ </Button>
776+ </AlertDialogFooter>
777+ </AlertDialogPopup>
778+ </AlertDialog>
779+ </SettingsRow>
780+ );
781+}
782+
783+function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) {
784+ const userProfiles = useClientSettings((settings) => settings.browserProfiles);
785+ const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
786+ const settingsHydrated = useClientSettingsHydrated();
787+ const updateSettings = useUpdatePrimarySettings();
788+ const profileWritesDisabled = disabled || !settingsHydrated;
789+ // Incognito is deliberately absent: as a default it would open every tab
790+ // into storage that is discarded on close.
791+ const profiles = resolveBrowserProfiles(userProfiles).filter(
792+ (profile) => profile.kind !== "incognito",
793+ );
794+ const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0];
795+
796+ return (
797+ <SettingsRow
798+ {...searchableSetting("browser-default-profile")}
799+ description="Profile new browser tabs open under, including tabs an agent opens."
800+ resetAction={
801+ !profileWritesDisabled && defaultProfileId !== DEFAULT_BROWSER_PROFILE_ID ? (
802+ <SettingResetButton
803+ label="default browser profile"
804+ onClick={() => {
805+ if (settingsHydrated) {
806+ updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID });
807+ }
808+ }}
809+ />
810+ ) : null
811+ }
812+ control={
813+ <Select
814+ disabled={profileWritesDisabled}
815+ value={selected?.id ?? DEFAULT_BROWSER_PROFILE_ID}
816+ onValueChange={(value) => {
817+ if (settingsHydrated && value !== null) {
818+ updateSettings({ browserDefaultProfileId: value });
819+ }
820+ }}
821+ >
822+ <SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Default browser profile">
823+ <SelectValue>{selected?.name ?? "Default"}</SelectValue>
824+ </SelectTrigger>
825+ {/*
826+ Capped and truncated like the tab menu's profile list: names are
827+ user-supplied and run to 48 characters, which would otherwise
828+ widen the popup to fit the longest one. The cap goes on the glass
829+ shell (`popupClassName`), and the list fills that shell so it is
830+ never narrower than the trigger it opens from — the same floor
831+ every other settings select keeps. `ItemText` renders a block, so
832+ the label must be a block too for `truncate` to apply.
833+ */}
834+ <SelectPopup
835+ align="end"
836+ alignItemWithTrigger={false}
837+ popupClassName="max-w-64"
838+ className="w-full"
839+ >
840+ {profiles.map((profile) => (
841+ <SelectItem hideIndicator key={profile.id} value={profile.id}>
842+ <span className="block min-w-0 truncate">{profile.name}</span>
843+ </SelectItem>
844+ ))}
845+ </SelectPopup>
846+ </Select>
847+ }
848+ />
849+ );
850+}
851+
504852 export function IntegrationsSettingsPanel() {
505853 // Client-local preview defaults are editable only where the preview exists.
506854 const previewDefaultsDisabled = !isElectron;
507855 const previewDefaults = (
508856 <>
857+ <BrowserProfilesSetting disabled={previewDefaultsDisabled} />
858+ <BrowserDefaultProfileSetting disabled={previewDefaultsDisabled} />
509859 <BrowserViewportSetting disabled={previewDefaultsDisabled} />
510860 <BrowserZoomSetting disabled={previewDefaultsDisabled} />
511861 <BrowserAppearanceSetting disabled={previewDefaultsDisabled} />
apps/web/src/components/settings/settingsSearch.ts+12−0
@@ -312,6 +312,18 @@ export const SETTINGS_SEARCH_ITEMS = [ · 311 unmodified lines
312312 to: "/settings/integrations",
313313 searchTerms: ["allow open drive preview tools sessions"],
314314 },
315+ {
316+ id: "browser-profiles",
317+ title: "Browser profiles",
318+ to: "/settings/integrations",
319+ targetId: "browser",
320+ },
321+ {
322+ id: "browser-default-profile",
323+ title: "Default browser profile",
324+ to: "/settings/integrations",
325+ targetId: "browser",
326+ },
315327 {
316328 id: "browser-default-viewport",
317329 title: "Default browser viewport",
apps/web/src/components/ui/menu.tsx+6−1
@@ -235,7 +235,12 @@ function MenuSubTrigger({ · 234 unmodified lines
235235 return (
236236 <MenuPrimitive.SubmenuTrigger
237237 className={cn(
238− "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",
238+ // Leading-icon treatment matches `MenuItem`: a sub-trigger sits in the
239+ // same column as the items around it, so its icon has to align and dim
240+ // with theirs. Scoped away from the last child because the chevron is
241+ // also a direct svg — on a sub-trigger with no leading icon it is the
242+ // only one, and these rules would take away its `ms-auto` alignment.
243+ "[&>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",
239244 className,
240245 )}
241246 data-inset={inset}
apps/web/src/routes/_chat.pull-requests.tsx+1−0
@@ -1867,6 +1867,7 @@ function PullRequestsRouteView() { · 1866 unmodified lines
18671867 onCloseAllSurfaces={closeAllSurfaces}
18681868 onCopyFilePath={() => undefined}
18691869 onAddBrowser={() => undefined}
1870+ onAddBrowserInProfile={() => undefined}
18701871 onAddTerminal={() => undefined}
18711872 onAddDiff={() => undefined}
18721873 onAddFiles={() => undefined}
packages/contracts/src/browserProfile.test.ts+111−0
@@ -0,0 +1,111 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import * as Schema from "effect/Schema";
4+
5+import {
6+ BrowserProfileId,
7+ BUILT_IN_BROWSER_PROFILES,
8+ DEFAULT_BROWSER_PROFILE_ID,
9+ INCOGNITO_BROWSER_PROFILE_ID,
10+ findBrowserProfile,
11+ isBuiltInBrowserProfileId,
12+ resolveBrowserProfiles,
13+ type BrowserProfile,
14+} from "./browserProfile.ts";
15+
16+const work: BrowserProfile = { id: "profile-work", name: "Work", kind: "persistent" };
17+
18+describe("resolveBrowserProfiles", () => {
19+ it("lists built-ins ahead of the user's own profiles", () => {
20+ const resolved = resolveBrowserProfiles([work]);
21+
22+ expect(resolved.map((profile) => profile.id)).toEqual([
23+ DEFAULT_BROWSER_PROFILE_ID,
24+ INCOGNITO_BROWSER_PROFILE_ID,
25+ work.id,
26+ ]);
27+ });
28+
29+ it("drops stored entries that collide with a built-in id", () => {
30+ // Built-ins are synthesized rather than stored, so a hand-edited settings
31+ // file must not be able to shadow Default with a persistent partition of
32+ // its own — every tab already opened under Default would follow it.
33+ const resolved = resolveBrowserProfiles([
34+ { id: DEFAULT_BROWSER_PROFILE_ID, name: "Hijacked", kind: "persistent" },
35+ { id: INCOGNITO_BROWSER_PROFILE_ID, name: "Not incognito", kind: "persistent" },
36+ work,
37+ ]);
38+
39+ expect(resolved).toEqual([...BUILT_IN_BROWSER_PROFILES, work]);
40+ });
41+
42+ it("keeps incognito ephemeral", () => {
43+ const incognito = findBrowserProfile(resolveBrowserProfiles([]), INCOGNITO_BROWSER_PROFILE_ID);
44+
45+ expect(incognito?.kind).toBe("incognito");
46+ });
47+});
48+
49+describe("findBrowserProfile", () => {
50+ it("returns nothing for an id that no longer exists", () => {
51+ // The settings UI relies on this to fall back rather than opening tabs
52+ // into a partition with no profile behind it.
53+ expect(findBrowserProfile(resolveBrowserProfiles([]), work.id)).toBeUndefined();
54+ expect(findBrowserProfile(resolveBrowserProfiles([work]), undefined)).toBeUndefined();
55+ });
56+});
57+
58+describe("isBuiltInBrowserProfileId", () => {
59+ it("separates built-ins from user profiles", () => {
60+ expect(isBuiltInBrowserProfileId(DEFAULT_BROWSER_PROFILE_ID)).toBe(true);
61+ expect(isBuiltInBrowserProfileId(INCOGNITO_BROWSER_PROFILE_ID)).toBe(true);
62+ expect(isBuiltInBrowserProfileId(work.id)).toBe(false);
63+ });
64+});
65+
66+describe("resolveBrowserProfiles normalization", () => {
67+ it("keeps only the first entry for a repeated id", () => {
68+ // Both map to the same Electron partition, so presenting two would offer
69+ // isolated identities that in fact share every cookie.
70+ const resolved = resolveBrowserProfiles([
71+ { id: "work", name: "Work", kind: "persistent" },
72+ { id: "work", name: "Work (old)", kind: "persistent" },
73+ ]);
74+
75+ expect(resolved.filter((profile) => profile.id === "work")).toEqual([
76+ { id: "work", name: "Work", kind: "persistent" },
77+ ]);
78+ });
79+
80+ it("reports a custom incognito profile as persistent", () => {
81+ // Partition persistence is keyed off the built-in incognito id alone, so
82+ // a custom profile claiming that kind keeps its cookies across restarts.
83+ // Labelling it ephemeral would be a promise the partition layer breaks.
84+ const resolved = resolveBrowserProfiles([
85+ { id: "throwaway", name: "Throwaway", kind: "incognito" },
86+ ]);
87+
88+ expect(resolved.find((profile) => profile.id === "throwaway")).toEqual({
89+ id: "throwaway",
90+ name: "Throwaway",
91+ kind: "persistent",
92+ });
93+ });
94+
95+ it("still lets the built-in incognito profile stay ephemeral", () => {
96+ const incognito = resolveBrowserProfiles([]).find(
97+ (profile) => profile.id === INCOGNITO_BROWSER_PROFILE_ID,
98+ );
99+
100+ expect(incognito?.kind).toBe("incognito");
101+ });
102+});
103+
104+describe("BrowserProfileId", () => {
105+ it("rejects control characters", () => {
106+ // Ids are folded into delimiter-joined cache keys on the client, so one
107+ // carrying the delimiter would resolve to another profile's partition.
108+ expect(Schema.is(BrowserProfileId)("profile-a\u0000b")).toBe(false);
109+ expect(Schema.is(BrowserProfileId)("profile-a")).toBe(true);
110+ });
111+});
packages/contracts/src/browserProfile.ts+99−0
@@ -0,0 +1,99 @@
1+/**
2+ * Browser profiles - named identities for the in-app preview browser.
3+ *
4+ * Each profile maps to its own Electron session partition, so cookies and
5+ * storage are isolated between them: a tab opened under "Work" cannot see
6+ * "Personal"'s logins. Profiles are client-local, like the other browser
7+ * defaults, because the Chromium guest they configure is desktop-local.
8+ *
9+ * Two profiles are built in and cannot be edited or removed:
10+ * - `default` keeps the partition scope the browser used before profiles
11+ * existed, so upgrading does not sign anyone out.
12+ * - `incognito` maps to a non-persistent partition for throwaway sessions.
13+ *
14+ * @module BrowserProfile
15+ */
16+import * as Schema from "effect/Schema";
17+import { TrimmedNonEmptyString } from "./baseSchemas.ts";
18+
19+export const BROWSER_PROFILE_NAME_MAX_LENGTH = 48;
20+export const BROWSER_PROFILE_MAX_COUNT = 24;
21+
22+/**
23+ * Control characters are rejected because ids are folded into delimiter-joined
24+ * cache keys on the client; one carrying the delimiter would resolve to a
25+ * different profile's partition.
26+ */
27+export const BrowserProfileId = TrimmedNonEmptyString.check(
28+ Schema.isMaxLength(64),
29+ Schema.isPattern(/^[^\p{Cc}]+$/u),
30+);
31+export type BrowserProfileId = typeof BrowserProfileId.Type;
32+
33+export const BrowserProfileName = TrimmedNonEmptyString.check(
34+ Schema.isMaxLength(BROWSER_PROFILE_NAME_MAX_LENGTH),
35+);
36+
37+/**
38+ * `persistent` profiles keep cookies on disk across restarts; `incognito`
39+ * uses an in-memory partition that Chromium discards with the process.
40+ */
41+export const BrowserProfileKind = Schema.Literals(["persistent", "incognito"]);
42+export type BrowserProfileKind = typeof BrowserProfileKind.Type;
43+
44+export const BrowserProfile = Schema.Struct({
45+ id: BrowserProfileId,
46+ name: BrowserProfileName,
47+ kind: BrowserProfileKind,
48+});
49+export type BrowserProfile = typeof BrowserProfile.Type;
50+
51+export const DEFAULT_BROWSER_PROFILE_ID: BrowserProfileId = "default";
52+export const INCOGNITO_BROWSER_PROFILE_ID: BrowserProfileId = "incognito";
53+
54+/**
55+ * Built-ins are synthesized rather than stored, so they cannot be renamed out
56+ * of existence or deleted by editing the settings file by hand.
57+ */
58+export const BUILT_IN_BROWSER_PROFILES: ReadonlyArray<BrowserProfile> = [
59+ { id: DEFAULT_BROWSER_PROFILE_ID, name: "Default", kind: "persistent" },
60+ { id: INCOGNITO_BROWSER_PROFILE_ID, name: "Incognito", kind: "incognito" },
61+];
62+
63+export function isBuiltInBrowserProfileId(id: string): boolean {
64+ return BUILT_IN_BROWSER_PROFILES.some((profile) => profile.id === id);
65+}
66+
67+/**
68+ * The full picker list: built-ins first, then the user's own profiles.
69+ *
70+ * Three things are normalized away, because each would present a profile the
71+ * partition layer does not actually deliver:
72+ *
73+ * - Entries colliding with a built-in id, so a hand-edited settings file
74+ * cannot shadow "Default" or "Incognito".
75+ * - Repeated ids, which map to one partition and would otherwise appear as
76+ * two isolated identities sharing every cookie. First entry wins.
77+ * - `kind: "incognito"` on anything but the built-in, since persistence is
78+ * keyed off that one id; such a profile is labelled ephemeral while its
79+ * cookies survive restarts.
80+ */
81+export function resolveBrowserProfiles(
82+ userProfiles: ReadonlyArray<BrowserProfile>,
83+): ReadonlyArray<BrowserProfile> {
84+ const seen = new Set(BUILT_IN_BROWSER_PROFILES.map((profile) => profile.id));
85+ const resolved = [...BUILT_IN_BROWSER_PROFILES];
86+ for (const profile of userProfiles) {
87+ if (seen.has(profile.id)) continue;
88+ seen.add(profile.id);
89+ resolved.push(profile.kind === "persistent" ? profile : { ...profile, kind: "persistent" });
90+ }
91+ return resolved;
92+}
93+
94+export function findBrowserProfile(
95+ profiles: ReadonlyArray<BrowserProfile>,
96+ id: string | undefined,
97+): BrowserProfile | undefined {
98+ return id === undefined ? undefined : profiles.find((profile) => profile.id === id);
99+}
packages/contracts/src/index.ts+1−0
@@ -28,6 +28,7 @@ export * from "./project.ts"; · 27 unmodified lines
2828 export * from "./filesystem.ts";
2929 export * from "./assets.ts";
3030 export * from "./review.ts";
31+export * from "./browserProfile.ts";
3132 export * from "./preview.ts";
3233 export * from "./previewAutomation.ts";
3334 export * from "./resourceTelemetry.ts";
packages/contracts/src/ipc.ts+20−3
@@ -88,6 +88,7 @@ import type { · 87 unmodified lines
8888 OrchestrationThreadStreamItem,
8989 } from "./orchestration.ts";
9090 import { EnvironmentId } from "./baseSchemas.ts";
91+import { BrowserProfileId } from "./browserProfile.ts";
9192 import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";
9293 import { AdvertisedEndpoint } from "./remoteAccess.ts";
9394 import { ExecutionEnvironmentDescriptor } from "./environment.ts";
@@ -974,6 +975,19 @@ export const DesktopPreviewNavigateInputSchema = Schema.Struct({ · 880 unmodified lines
974975
975976 export const DesktopPreviewConfigInputSchema = Schema.Struct({
976977 environmentId: EnvironmentId,
978+ /**
979+ * Browser profile the partition is derived from. Derivation stays in main:
980+ * `will-attach-webview` only prefix-checks the partition string, so a
981+ * renderer-supplied partition could attach to a session that never had the
982+ * UA rewrite or permission handlers installed.
983+ */
984+ profileId: Schema.optional(BrowserProfileId),
985+});
986+
987+export const DesktopPreviewClearDataInputSchema = Schema.Struct({
988+ environmentId: EnvironmentId,
989+ /** Omit to clear every profile; otherwise only this profile's partition. */
990+ profileId: Schema.optional(BrowserProfileId),
977991 });
978992
979993 export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({
@@ -1158,16 +1172,19 @@ export interface DesktopPreviewBridge { · 178 unmodified lines
11581172 /** Open the guest webview's DevTools (detached). */
11591173 openDevTools: (tabId: string) => Promise<void>;
11601174 /** Drop cookies + storage data for the preview partition (all tabs). */
1161− clearCookies: () => Promise<void>;
1175+ clearCookies: (environmentId: EnvironmentId, profileId?: string) => Promise<void>;
11621176 /** Drop the HTTP cache for the preview partition (all tabs). */
1163− clearCache: () => Promise<void>;
1177+ clearCache: (environmentId: EnvironmentId, profileId?: string) => Promise<void>;
11641178 /**
11651179 * One-shot config for mounting a preview `<webview>`. Replaces three
11661180 * earlier round-trip calls (`getBrowserPartition`, `getWebviewPreferences`,
11671181 * `getPickPreloadPath`) so adding a new field here only requires touching
11681182 * the contract + main, not the renderer's mount logic.
11691183 */
1170− getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>;
1184+ getPreviewConfig: (
1185+ environmentId: EnvironmentId,
1186+ profileId?: string,
1187+ ) => Promise<DesktopPreviewWebviewConfig>;
11711188 setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>;
11721189 /**
11731190 * Activate the in-page element picker for the given tab. Resolves with
packages/contracts/src/preview.ts+9−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010 */
1111 import { Schema } from "effect";
1212 import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
13+import { BrowserProfileId } from "./browserProfile.ts";
1314
1415 export const PREVIEW_URL_MAX_LENGTH = 2_048;
1516 export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32;
@@ -169,6 +170,12 @@ export const PreviewSessionSnapshot = Schema.Struct({ · 153 unmodified lines
169170 canGoForward: Schema.Boolean,
170171 /** Missing snapshots from older servers are treated as fill-panel mode. */
171172 viewport: Schema.optional(PreviewViewportSetting),
173+ /**
174+ * Browser profile the tab's Chromium partition is derived from. Fixed at
175+ * open: Electron only honours a `<webview>`'s partition before attach, so
176+ * switching would require tearing the guest down and losing page state.
177+ */
178+ profileId: Schema.optional(BrowserProfileId),
172179 updatedAt: Schema.String,
173180 });
174181 export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;
@@ -184,6 +191,8 @@ export const PreviewOpenInput = Schema.Struct({ · 9 unmodified lines
184191 * later (which the user would see as a visible reflow).
185192 */
186193 viewport: Schema.optional(PreviewViewportSetting),
194+ /** Omit to open under the client's configured default profile. */
195+ profileId: Schema.optional(BrowserProfileId),
187196 });
188197 export type PreviewOpenInput = typeof PreviewOpenInput.Type;
189198
packages/contracts/src/settings.ts+15−0
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 ProviderOptionSelections,
1111 } from "./model.ts";
1212 import { ModelSelection } from "./orchestration.ts";
13+import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";
1314 import {
1415 DEFAULT_PREVIEW_APPEARANCE,
1516 DEFAULT_PREVIEW_ZOOM_FACTOR,
@@ -199,6 +200,18 @@ export const ClientSettingsSchema = Schema.Struct({ · 183 unmodified lines
199200 browserAutoShowFloatingPreview: Schema.Boolean.pipe(
200201 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)),
201202 ),
203+ /**
204+ * User-created browser profiles. The built-in Default and Incognito profiles
205+ * are synthesized by `resolveBrowserProfiles`, not stored here, so they
206+ * cannot be renamed away or deleted.
207+ */
208+ browserProfiles: Schema.Array(BrowserProfile).pipe(
209+ Schema.withDecodingDefault(Effect.succeed([])),
210+ ),
211+ /** Profile new tabs open under. Falls back to Default if it no longer exists. */
212+ browserDefaultProfileId: BrowserProfileId.pipe(
213+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_PROFILE_ID)),
214+ ),
202215 // Desktop-only. Boolean values from older settings files decode to their
203216 // equivalent mode and encode back as the canonical string value.
204217 confirmQuit: QuitConfirmationModeSetting.pipe(
@@ -958,6 +971,8 @@ export const ClientSettingsPatch = Schema.Struct({ · 753 unmodified lines
958971 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),
959972 browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate),
960973 browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean),
974+ browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)),
975+ browserDefaultProfileId: Schema.optionalKey(BrowserProfileId),
961976 confirmQuit: Schema.optionalKey(QuitConfirmationMode),
962977 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
963978 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t