authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 14:56:45-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 14:56:45-07:00
log134d51096ea0d00a53a499e8f0c87e31fafb0006
tree64e14e1a4c031ac06770dc989fe27c85797fc787
parent28ddaf75917140e5e4355d4386bc5d14d9dad7b6
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

40 files changed, 1535 insertions(+), 119 deletions(-)

apps/desktop/src/ipc/methods/preview.test.ts+43-1
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import { it as effectIt } from "@effect/vitest";1import { it as effectIt } from "@effect/vitest";
2import { PreviewAutomationStatus } from "@t3tools/contracts";2import {
3 DEFAULT_BROWSER_PROFILE_ID,
4 INCOGNITO_BROWSER_PROFILE_ID,
5 PreviewAutomationStatus,
6} from "@t3tools/contracts";
3import * as Cause from "effect/Cause";7import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";9import * as Exit from "effect/Exit";
...@@ -38,6 +42,44 @@ describe("preview IPC methods", () => {...@@ -38,6 +42,44 @@ describe("preview IPC methods", () => {
38 expect(fromPartition).not.toHaveBeenCalled();42 expect(fromPartition).not.toHaveBeenCalled();
39 });43 });
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
41 effectIt.effect("rejects invalid webContents ids before resolving the preview service", () =>83 effectIt.effect("rejects invalid webContents ids before resolving the preview service", () =>
42 Effect.map(84 Effect.map(
43 PreviewIpc.registerWebview85 PreviewIpc.registerWebview
apps/desktop/src/ipc/methods/preview.ts+64-9
...@@ -16,11 +16,14 @@ import {...@@ -16,11 +16,14 @@ import {
16 DesktopPreviewScreenshotArtifactSchema,16 DesktopPreviewScreenshotArtifactSchema,
17 DesktopPreviewSetAudioMutedInputSchema,17 DesktopPreviewSetAudioMutedInputSchema,
18 DesktopPreviewSetColorSchemeInputSchema,18 DesktopPreviewSetColorSchemeInputSchema,
19 DesktopPreviewClearDataInputSchema,
19 DesktopPreviewCreateTabInputSchema,20 DesktopPreviewCreateTabInputSchema,
20 DesktopPreviewTabInputSchema,21 DesktopPreviewTabInputSchema,
21 DesktopPreviewWebviewConfigSchema,22 DesktopPreviewWebviewConfigSchema,
22 PreviewAnnotationSubmissionResultSchema,23 PreviewAnnotationSubmissionResultSchema,
23 PreviewAutomationSnapshot,24 PreviewAutomationSnapshot,
25 DEFAULT_BROWSER_PROFILE_ID,
26 INCOGNITO_BROWSER_PROFILE_ID,
24} from "@t3tools/contracts";27} from "@t3tools/contracts";
25import * as Effect from "effect/Effect";28import * as Effect from "effect/Effect";
26import * as Schema from "effect/Schema";29import * as Schema from "effect/Schema";
...@@ -196,33 +199,85 @@ export const closePictureInPicture = tabMethod(...@@ -196,33 +199,85 @@ export const closePictureInPicture = tabMethod(
196199
197export const clearCookies = DesktopIpc.makeIpcMethod({200export const clearCookies = DesktopIpc.makeIpcMethod({
198 channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL,201 channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL,
199 payload: Schema.Void,202 payload: DesktopPreviewClearDataInputSchema,
200 result: Schema.Void,203 result: Schema.Void,
201 handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () {204 handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* ({ environmentId, profileId }) {
202 const manager = yield* PreviewManager.PreviewManager;205 const manager = yield* PreviewManager.PreviewManager;
203 yield* manager.clearCookies();206 yield* manager.clearCookies(yield* resolveClearPartitions(manager, environmentId, profileId));
204 }),207 }),
205});208});
206209
207export const clearCache = DesktopIpc.makeIpcMethod({210export const clearCache = DesktopIpc.makeIpcMethod({
208 channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL,211 channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL,
209 payload: Schema.Void,212 payload: DesktopPreviewClearDataInputSchema,
210 result: Schema.Void,213 result: Schema.Void,
211 handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () {214 handler: Effect.fn("desktop.ipc.preview.clearCache")(function* ({ environmentId, profileId }) {
212 const manager = yield* PreviewManager.PreviewManager;215 const manager = yield* PreviewManager.PreviewManager;
213 yield* manager.clearCache();216 yield* manager.clearCache(yield* resolveClearPartitions(manager, environmentId, profileId));
214 }),217 }),
215});218});
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 */
227export 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 */
253const 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
217export const getPreviewConfig = DesktopIpc.makeIpcMethod({268export const getPreviewConfig = DesktopIpc.makeIpcMethod({
218 channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL,269 channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL,
219 payload: DesktopPreviewConfigInputSchema,270 payload: DesktopPreviewConfigInputSchema,
220 result: DesktopPreviewWebviewConfigSchema,271 result: DesktopPreviewWebviewConfigSchema,
221 handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId }) {272 handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId, profileId }) {
222 const manager = yield* PreviewManager.PreviewManager;273 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);
224 return {279 return {
225 partition: yield* manager.getBrowserPartition(environmentId),280 partition: yield* manager.getBrowserPartition(scope, persistent, namespace),
226 webPreferences: PREVIEW_WEBVIEW_PREFERENCES,281 webPreferences: PREVIEW_WEBVIEW_PREFERENCES,
227 preloadUrl: NodeURL.pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href,282 preloadUrl: NodeURL.pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href,
228 };283 };
apps/desktop/src/preload.ts+6-4
...@@ -222,10 +222,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -222,10 +222,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
222 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }),222 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }),
223 openDevTools: (tabId) =>223 openDevTools: (tabId) =>
224 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),224 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
225 clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),225 clearCookies: (environmentId, profileId) =>
226 clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL),226 ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }),
227 getPreviewConfig: (environmentId) =>227 clearCache: (environmentId, profileId) =>
228 ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }),228 ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, { environmentId, profileId }),
229 getPreviewConfig: (environmentId, profileId) =>
230 ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId, profileId }),
229 setAnnotationTheme: (theme) =>231 setAnnotationTheme: (theme) =>
230 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }),232 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }),
231 pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),233 pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),
apps/desktop/src/preview/BrowserSession.test.ts+61
...@@ -63,6 +63,45 @@ describe("BrowserSession", () => {...@@ -63,6 +63,45 @@ describe("BrowserSession", () => {
63 }).pipe(Effect.provide(layer)),63 }).pipe(Effect.provide(layer)),
64 );64 );
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
66 it.effect("grants clipboard-sanitized-write through both the request and check handlers", () =>105 it.effect("grants clipboard-sanitized-write through both the request and check handlers", () =>
67 Effect.gen(function* () {106 Effect.gen(function* () {
68 const browserSessions = yield* BrowserSession.BrowserSession;107 const browserSessions = yield* BrowserSession.BrowserSession;
...@@ -192,6 +231,28 @@ describe("BrowserSession", () => {...@@ -192,6 +231,28 @@ describe("BrowserSession", () => {
192 }).pipe(Effect.provide(layer)),231 }).pipe(Effect.provide(layer)),
193 );232 );
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
195 it.effect("correlates clear failures while still attempting every session", () =>256 it.effect("correlates clear failures while still attempting every session", () =>
196 Effect.gen(function* () {257 Effect.gen(function* () {
197 const browserSessions = yield* BrowserSession.BrowserSession;258 const browserSessions = yield* BrowserSession.BrowserSession;
apps/desktop/src/preview/BrowserSession.ts+81-13
...@@ -10,6 +10,16 @@ import * as Schema from "effect/Schema";...@@ -10,6 +10,16 @@ import * as Schema from "effect/Schema";
10import * as SynchronizedRef from "effect/SynchronizedRef";10import * as SynchronizedRef from "effect/SynchronizedRef";
1111
12const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";12const 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 */
19const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-";
20const PROFILE_PARTITION_MARKER = "profile-";
21
22export type BrowserSessionPartitionNamespace = "profile";
1323
14// Permissions granted to preview web content. `clipboard-sanitized-write` is the24// Permissions granted to preview web content. `clipboard-sanitized-write` is the
15// Electron permission behind `navigator.clipboard.writeText()` — note it is NOT25// Electron permission behind `navigator.clipboard.writeText()` — note it is NOT
...@@ -99,20 +109,68 @@ export class BrowserSession extends Context.Service<...@@ -99,20 +109,68 @@ export class BrowserSession extends Context.Service<
99 {109 {
100 readonly getPartition: (110 readonly getPartition: (
101 scope?: string,111 scope?: string,
112 persistent?: boolean,
113 namespace?: BrowserSessionPartitionNamespace,
102 ) => Effect.Effect<string, BrowserSessionPartitionDerivationError>;114 ) => Effect.Effect<string, BrowserSessionPartitionDerivationError>;
103 readonly isPartition: (partition: string) => boolean;115 readonly isPartition: (partition: string) => boolean;
104 readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionGetSessionError>;116 readonly getSession: (
105 readonly clearCookies: () => Effect.Effect<void, BrowserSessionStorageClearError>;117 scope?: string,
106 readonly clearCache: () => Effect.Effect<void, BrowserSessionCacheClearError>;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>;
107 }128 }
108>()("@t3tools/desktop/preview/BrowserSession") {}129>()("@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 */
136const 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 */
154const 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
110export const make = Effect.gen(function* BrowserSessionMake() {164export const make = Effect.gen(function* BrowserSessionMake() {
111 const crypto = yield* Crypto.Crypto;165 const crypto = yield* Crypto.Crypto;
112 const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());166 const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());
113167
114 const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") {168 const getPartition = Effect.fn("BrowserSession.getPartition")(function* (
115 const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe(169 scope = "shared",
170 persistent = true,
171 namespace?: BrowserSessionPartitionNamespace,
172 ) {
173 const digest = yield* crypto.digest("SHA-256", encodeScopeForDigest(scope)).pipe(
116 Effect.mapError(174 Effect.mapError(
117 (cause) =>175 (cause) =>
118 new BrowserSessionPartitionDerivationError({176 new BrowserSessionPartitionDerivationError({
...@@ -121,11 +179,19 @@ export const make = Effect.gen(function* BrowserSessionMake() {...@@ -121,11 +179,19 @@ export const make = Effect.gen(function* BrowserSessionMake() {
121 }),179 }),
122 ),180 ),
123 );181 );
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)}`;
125 });187 });
126188
127 const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") {189 const getSession = Effect.fn("BrowserSession.getSession")(function* (
128 const partition = yield* getPartition(scope);190 scope = "shared",
191 persistent = true,
192 namespace?: BrowserSessionPartitionNamespace,
193 ) {
194 const partition = yield* getPartition(scope, persistent, namespace);
129 return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => {195 return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => {
130 const existing = sessions.get(partition);196 const existing = sessions.get(partition);
131 if (existing) return Effect.succeed([existing, sessions] as const);197 if (existing) return Effect.succeed([existing, sessions] as const);
...@@ -159,12 +225,14 @@ export const make = Effect.gen(function* BrowserSessionMake() {...@@ -159,12 +225,14 @@ export const make = Effect.gen(function* BrowserSessionMake() {
159225
160 return BrowserSession.of({226 return BrowserSession.of({
161 getPartition,227 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),
163 getSession,231 getSession,
164 clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () {232 clearCookies: Effect.fn("BrowserSession.clearCookies")(function* (partitions?) {
165 const sessions = yield* SynchronizedRef.get(sessionsRef);233 const sessions = yield* SynchronizedRef.get(sessionsRef);
166 yield* Effect.all(234 yield* Effect.all(
167 [...sessions.entries()].map(([partition, browserSession]) =>235 selectSessions(sessions, partitions).map(([partition, browserSession]) =>
168 Effect.tryPromise({236 Effect.tryPromise({
169 try: () =>237 try: () =>
170 browserSession.clearStorageData({238 browserSession.clearStorageData({
...@@ -180,10 +248,10 @@ export const make = Effect.gen(function* BrowserSessionMake() {...@@ -180,10 +248,10 @@ export const make = Effect.gen(function* BrowserSessionMake() {
180 { concurrency: "unbounded", discard: true },248 { concurrency: "unbounded", discard: true },
181 );249 );
182 }),250 }),
183 clearCache: Effect.fn("BrowserSession.clearCache")(function* () {251 clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) {
184 const sessions = yield* SynchronizedRef.get(sessionsRef);252 const sessions = yield* SynchronizedRef.get(sessionsRef);
185 yield* Effect.all(253 yield* Effect.all(
186 [...sessions.entries()].map(([partition, browserSession]) =>254 selectSessions(sessions, partitions).map(([partition, browserSession]) =>
187 Effect.tryPromise({255 Effect.tryPromise({
188 try: () => browserSession.clearCache(),256 try: () => browserSession.clearCache(),
189 catch: (cause) =>257 catch: (cause) =>
apps/desktop/src/preview/Manager.ts+42-26
...@@ -4409,7 +4409,11 @@ export class PreviewManager extends Context.Service<...@@ -4409,7 +4409,11 @@ export class PreviewManager extends Context.Service<
4409 PreviewManager,4409 PreviewManager,
4410 {4410 {
4411 readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>;4411 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>;
4413 readonly isBrowserPartition: (partition: string) => boolean;4417 readonly isBrowserPartition: (partition: string) => boolean;
4414 readonly createTab: (4418 readonly createTab: (
4415 tabId: string,4419 tabId: string,
...@@ -4440,9 +4444,17 @@ export class PreviewManager extends Context.Service<...@@ -4440,9 +4444,17 @@ export class PreviewManager extends Context.Service<
4440 audioMuted: boolean,4444 audioMuted: boolean,
4441 ) => Effect.Effect<void, PreviewManagerError>;4445 ) => Effect.Effect<void, PreviewManagerError>;
4442 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;4446 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
4443 readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;4447 readonly clearCookies: (
4444 readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;4448 partitions?: ReadonlyArray<string>,
4445 readonly getBrowserPartition: (scope?: string) => Effect.Effect<string, PreviewManagerError>;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>;
4446 readonly setAnnotationTheme: (4458 readonly setAnnotationTheme: (
4447 theme: DesktopPreviewAnnotationTheme,4459 theme: DesktopPreviewAnnotationTheme,
4448 ) => Effect.Effect<void, PreviewManagerError>;4460 ) => Effect.Effect<void, PreviewManagerError>;
...@@ -4514,15 +4526,17 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -4514,15 +4526,17 @@ export const make = Effect.gen(function* PreviewManagerMake() {
45144526
4515 return PreviewManager.of({4527 return PreviewManager.of({
4516 setMainWindow: operations.setMainWindow,4528 setMainWindow: operations.setMainWindow,
4517 getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) {4529 getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(
4518 return yield* browserSession4530 function* (scope, persistent, namespace) {
4519 .getSession(scope)4531 return yield* browserSession
4520 .pipe(4532 .getSession(scope, persistent, namespace)
4521 Effect.mapError(4533 .pipe(
4522 (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),4534 Effect.mapError(
4523 ),4535 (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }),
4524 );4536 ),
4525 }),4537 );
4538 },
4539 ),
4526 isBrowserPartition: browserSession.isPartition,4540 isBrowserPartition: browserSession.isPartition,
4527 createTab: operations.createTab,4541 createTab: operations.createTab,
4528 closeTab: operations.closeTab,4542 closeTab: operations.closeTab,
...@@ -4539,31 +4553,33 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -4539,31 +4553,33 @@ export const make = Effect.gen(function* PreviewManagerMake() {
4539 setColorScheme: operations.setColorScheme,4553 setColorScheme: operations.setColorScheme,
4540 setAudioMuted: operations.setAudioMuted,4554 setAudioMuted: operations.setAudioMuted,
4541 openDevTools: operations.openDevTools,4555 openDevTools: operations.openDevTools,
4542 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {4556 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) {
4543 yield* browserSession4557 yield* browserSession
4544 .clearCookies()4558 .clearCookies(partitions)
4545 .pipe(4559 .pipe(
4546 Effect.mapError(4560 Effect.mapError(
4547 (cause) => new PreviewOperationError({ operation: "clearCookies", cause }),4561 (cause) => new PreviewOperationError({ operation: "clearCookies", cause }),
4548 ),4562 ),
4549 );4563 );
4550 }),4564 }),
4551 clearCache: Effect.fn("PreviewManager.clearCache")(function* () {4565 clearCache: Effect.fn("PreviewManager.clearCache")(function* (partitions) {
4552 yield* browserSession4566 yield* browserSession
4553 .clearCache()4567 .clearCache(partitions)
4554 .pipe(4568 .pipe(
4555 Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })),4569 Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })),
4556 );4570 );
4557 }),4571 }),
4558 getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) {4572 getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(
4559 return yield* browserSession4573 function* (scope, persistent, namespace) {
4560 .getPartition(scope)4574 return yield* browserSession
4561 .pipe(4575 .getPartition(scope, persistent, namespace)
4562 Effect.mapError(4576 .pipe(
4563 (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }),4577 Effect.mapError(
4564 ),4578 (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }),
4565 );4579 ),
4566 }),4580 );
4581 },
4582 ),
4567 setAnnotationTheme: operations.setAnnotationTheme,4583 setAnnotationTheme: operations.setAnnotationTheme,
4568 pickElement: operations.pickElement,4584 pickElement: operations.pickElement,
4569 cancelPickElement: operations.cancelPickElement,4585 cancelPickElement: operations.cancelPickElement,
apps/desktop/src/settings/DesktopClientSettings.test.ts+2
...@@ -19,6 +19,8 @@ const clientSettings: ClientSettings = {...@@ -19,6 +19,8 @@ const clientSettings: ClientSettings = {
19 browserDefaultAppearance: "dark",19 browserDefaultAppearance: "dark",
20 browserRecordingFrameRate: 60,20 browserRecordingFrameRate: 60,
21 browserAutoShowFloatingPreview: false,21 browserAutoShowFloatingPreview: false,
22 browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
23 browserDefaultProfileId: "work",
22 confirmQuit: "double-click",24 confirmQuit: "double-click",
23 confirmThreadArchive: true,25 confirmThreadArchive: true,
24 confirmThreadDelete: false,26 confirmThreadDelete: false,
apps/server/src/preview/Manager.test.ts+31
...@@ -58,6 +58,37 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => {...@@ -58,6 +58,37 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => {
58 }),58 }),
59 );59 );
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
61 it.effect("opens an Idle tab when no URL is supplied", () =>92 it.effect("opens an Idle tab when no URL is supplied", () =>
62 Effect.gen(function* () {93 Effect.gen(function* () {
63 const threadId = freshThreadId();94 const threadId = freshThreadId();
apps/server/src/preview/Manager.ts+18-1
...@@ -123,6 +123,7 @@ const buildLoadingSnapshot = (input: {...@@ -123,6 +123,7 @@ const buildLoadingSnapshot = (input: {
123 readonly url: string;123 readonly url: string;
124 readonly title: string;124 readonly title: string;
125 readonly viewport: PreviewViewportSetting;125 readonly viewport: PreviewViewportSetting;
126 readonly profileId?: string | undefined;
126 readonly updatedAt: string;127 readonly updatedAt: string;
127}): PreviewSessionSnapshot => ({128}): PreviewSessionSnapshot => ({
128 threadId: input.threadId,129 threadId: input.threadId,
...@@ -131,6 +132,7 @@ const buildLoadingSnapshot = (input: {...@@ -131,6 +132,7 @@ const buildLoadingSnapshot = (input: {
131 canGoBack: false,132 canGoBack: false,
132 canGoForward: false,133 canGoForward: false,
133 viewport: input.viewport,134 viewport: input.viewport,
135 ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
134 updatedAt: input.updatedAt,136 updatedAt: input.updatedAt,
135});137});
136138
...@@ -138,6 +140,7 @@ const buildIdleSnapshot = (input: {...@@ -138,6 +140,7 @@ const buildIdleSnapshot = (input: {
138 readonly threadId: string;140 readonly threadId: string;
139 readonly tabId: string;141 readonly tabId: string;
140 readonly viewport: PreviewViewportSetting;142 readonly viewport: PreviewViewportSetting;
143 readonly profileId?: string | undefined;
141 readonly updatedAt: string;144 readonly updatedAt: string;
142}): PreviewSessionSnapshot => ({145}): PreviewSessionSnapshot => ({
143 threadId: input.threadId,146 threadId: input.threadId,
...@@ -146,6 +149,7 @@ const buildIdleSnapshot = (input: {...@@ -146,6 +149,7 @@ const buildIdleSnapshot = (input: {
146 canGoBack: false,149 canGoBack: false,
147 canGoForward: false,150 canGoForward: false,
148 viewport: input.viewport,151 viewport: input.viewport,
152 ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
149 updatedAt: input.updatedAt,153 updatedAt: input.updatedAt,
150});154});
151155
...@@ -229,9 +233,16 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -229,9 +233,16 @@ export const make = Effect.gen(function* PreviewManagerMake() {
229 url: yield* normalizeUrl(input.url),233 url: yield* normalizeUrl(input.url),
230 title: "",234 title: "",
231 viewport,235 viewport,
236 profileId: input.profileId,
232 updatedAt,237 updatedAt,
233 })238 })
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 });
235 yield* SynchronizedRef.modifyEffect(stateRef, (state) =>246 yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
236 Effect.gen(function* () {247 Effect.gen(function* () {
237 const revision = state.revision + 1;248 const revision = state.revision + 1;
...@@ -275,6 +286,9 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -275,6 +286,9 @@ export const make = Effect.gen(function* PreviewManagerMake() {
275 canGoBack: session.snapshot.canGoBack,286 canGoBack: session.snapshot.canGoBack,
276 canGoForward: session.snapshot.canGoForward,287 canGoForward: session.snapshot.canGoForward,
277 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,288 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
289 ...(session.snapshot.profileId === undefined
290 ? {}
291 : { profileId: session.snapshot.profileId }),
278 updatedAt,292 updatedAt,
279 };293 };
280 return {294 return {
...@@ -308,6 +322,9 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -308,6 +322,9 @@ export const make = Effect.gen(function* PreviewManagerMake() {
308 canGoBack: input.canGoBack,322 canGoBack: input.canGoBack,
309 canGoForward: input.canGoForward,323 canGoForward: input.canGoForward,
310 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,324 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
325 ...(session.snapshot.profileId === undefined
326 ? {}
327 : { profileId: session.snapshot.profileId }),
311 updatedAt,328 updatedAt,
312 };329 };
313 const emit: PreviewEventDraft =330 const emit: PreviewEventDraft =
apps/web/src/browser/ElectronBrowserHost.tsx+1
...@@ -93,6 +93,7 @@ export function ElectronBrowserHost() {...@@ -93,6 +93,7 @@ export function ElectronBrowserHost() {
93 initialUrl={url}93 initialUrl={url}
94 viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}94 viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}
95 pictureInPicture={pictureInPicture}95 pictureInPicture={pictureInPicture}
96 profileId={snapshot.profileId}
96 zoomFactor={zoomFactor}97 zoomFactor={zoomFactor}
97 />98 />
98 );99 );
apps/web/src/browser/HostedBrowserWebview.tsx+16-3
...@@ -49,11 +49,24 @@ export function HostedBrowserWebview(props: {...@@ -49,11 +49,24 @@ export function HostedBrowserWebview(props: {
49 readonly initialUrl: string | null;49 readonly initialUrl: string | null;
50 readonly viewport: PreviewViewportSetting;50 readonly viewport: PreviewViewportSetting;
51 readonly pictureInPicture: boolean;51 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;
52 readonly zoomFactor: number;57 readonly zoomFactor: number;
53}) {58}) {
54 const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor } =59 const {
55 props;60 threadRef,
56 const config = usePreviewWebviewConfig(threadRef.environmentId);61 tabId,
62 runtimeTabId,
63 initialUrl,
64 viewport,
65 pictureInPicture,
66 zoomFactor,
67 profileId,
68 } = props;
69 const config = usePreviewWebviewConfig(threadRef.environmentId, profileId);
57 const [initialSrc] = useState(() => initialUrl ?? "about:blank");70 const [initialSrc] = useState(() => initialUrl ?? "about:blank");
58 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);71 const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
59 const wrapperRef = useRef<HTMLDivElement | null>(null);72 const wrapperRef = useRef<HTMLDivElement | null>(null);
apps/web/src/browser/browserDefaults.test.ts created+43
...@@ -0,0 +1,43 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
3
4const settings = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
5
6vi.mock("~/hooks/useSettings", () => ({
7 getClientSettings: () => settings.current,
8 useClientSettings: () => undefined,
9 ensureClientSettingsHydrated: () => Promise.resolve(),
10}));
11
12const { getBrowserDefaults } = await import("./browserDefaults");
13
14const 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
26describe("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 @@...@@ -13,10 +13,13 @@
13 *13 *
14 * @module browserDefaults14 * @module browserDefaults
15 */15 */
16import type {16import {
17 DesktopPreviewTabDefaults,17 DEFAULT_BROWSER_PROFILE_ID,
18 PreviewAppearancePreference,18 resolveBrowserProfiles,
19 PreviewViewportSetting,19 type BrowserProfile,
20 type DesktopPreviewTabDefaults,
21 type PreviewAppearancePreference,
22 type PreviewViewportSetting,
20} from "@t3tools/contracts";23} from "@t3tools/contracts";
2124
22import {25import {
...@@ -32,6 +35,8 @@ export interface BrowserDefaults {...@@ -32,6 +35,8 @@ export interface BrowserDefaults {
32 readonly zoomFactor: number;35 readonly zoomFactor: number;
33 readonly appearance: PreviewAppearancePreference;36 readonly appearance: PreviewAppearancePreference;
34 readonly autoShowFloatingPreview: boolean;37 readonly autoShowFloatingPreview: boolean;
38 readonly profiles: ReadonlyArray<BrowserProfile>;
39 readonly profileId: string;
35}40}
3641
37const toBrowserDefaults = (settings: {42const toBrowserDefaults = (settings: {
...@@ -39,12 +44,29 @@ const toBrowserDefaults = (settings: {...@@ -39,12 +44,29 @@ const toBrowserDefaults = (settings: {
39 readonly browserDefaultZoomFactor: number;44 readonly browserDefaultZoomFactor: number;
40 readonly browserDefaultAppearance: PreviewAppearancePreference;45 readonly browserDefaultAppearance: PreviewAppearancePreference;
41 readonly browserAutoShowFloatingPreview: boolean;46 readonly browserAutoShowFloatingPreview: boolean;
42}): BrowserDefaults => ({47 readonly browserProfiles: ReadonlyArray<BrowserProfile>;
43 viewport: settings.browserDefaultViewport,48 readonly browserDefaultProfileId: string;
44 zoomFactor: settings.browserDefaultZoomFactor,49}): BrowserDefaults => {
45 appearance: settings.browserDefaultAppearance,50 const profiles = resolveBrowserProfiles(settings.browserProfiles);
46 autoShowFloatingPreview: settings.browserAutoShowFloatingPreview,51 return {
47});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
49/** Non-hook accessor for imperative open paths (menu actions, automation hosts). */71/** Non-hook accessor for imperative open paths (menu actions, automation hosts). */
50export function getBrowserDefaults(): BrowserDefaults {72export function getBrowserDefaults(): BrowserDefaults {
...@@ -89,6 +111,13 @@ export function browserDefaultOpenViewport(...@@ -89,6 +111,13 @@ export function browserDefaultOpenViewport(
89 return defaults.viewport;111 return defaults.viewport;
90}112}
91113
114/** Profile a tab opens under when the caller doesn't name one. */
115export function browserDefaultOpenProfileId(
116 defaults: BrowserDefaults = getBrowserDefaults(),
117): string {
118 return defaults.profileId;
119}
120
92/**121/**
93 * The viewport to switch to when the user turns the device toolbar on for a tab122 * The viewport to switch to when the user turns the device toolbar on for a tab
94 * currently in fill mode.123 * currently in fill mode.
apps/web/src/browser/openFileInPreview.ts+16-1
...@@ -23,6 +23,12 @@ import {...@@ -23,6 +23,12 @@ import {
23} from "~/previewStateStore";23} from "~/previewStateStore";
24import { useRightPanelStore } from "~/rightPanelStore";24import { useRightPanelStore } from "~/rightPanelStore";
2525
26import {
27 browserDefaultOpenProfileId,
28 browserDefaultOpenViewport,
29 resolveBrowserDefaults,
30} from "./browserDefaults";
31
26export const isBrowserPreviewFile = (path: string): boolean =>32export const isBrowserPreviewFile = (path: string): boolean =>
27 /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? "");33 /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
2834
...@@ -42,9 +48,18 @@ export async function openUrlInPreview<E>(input: {...@@ -42,9 +48,18 @@ export async function openUrlInPreview<E>(input: {
42 readonly url: string;48 readonly url: string;
43 readonly openPreview: OpenPreviewMutation<E>;49 readonly openPreview: OpenPreviewMutation<E>;
44}): Promise<AtomCommandResult<void, E>> {50}): Promise<AtomCommandResult<void, E>> {
51 const defaults = await resolveBrowserDefaults();
45 const result = await input.openPreview({52 const result = await input.openPreview({
46 environmentId: input.threadRef.environmentId,53 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 },
48 });63 });
49 return mapAtomCommandResult(result, (snapshot) => {64 return mapAtomCommandResult(result, (snapshot) => {
50 applyPreviewServerSnapshot(input.threadRef, snapshot);65 applyPreviewServerSnapshot(input.threadRef, snapshot);
apps/web/src/browser/previewWebviewConfigState.test.ts+11-8
...@@ -13,7 +13,9 @@ const environmentId = EnvironmentId.make("environment-1");...@@ -13,7 +13,9 @@ const environmentId = EnvironmentId.make("environment-1");
13describe("loadPreviewWebviewConfig", () => {13describe("loadPreviewWebviewConfig", () => {
14 it.effect("reports a structurally distinct missing-bridge failure", () =>14 it.effect("reports a structurally distinct missing-bridge failure", () =>
15 Effect.gen(function* () {15 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
18 expect(error).toBeInstanceOf(PreviewWebviewBridgeUnavailableError);20 expect(error).toBeInstanceOf(PreviewWebviewBridgeUnavailableError);
19 expect(error.environmentId).toBe(environmentId);21 expect(error.environmentId).toBe(environmentId);
...@@ -25,7 +27,7 @@ describe("loadPreviewWebviewConfig", () => {...@@ -25,7 +27,7 @@ describe("loadPreviewWebviewConfig", () => {
25 it.effect("preserves the bridge rejection as the load failure cause", () =>27 it.effect("preserves the bridge rejection as the load failure cause", () =>
26 Effect.gen(function* () {28 Effect.gen(function* () {
27 const cause = new Error("ipc unavailable");29 const cause = new Error("ipc unavailable");
28 const error = yield* loadPreviewWebviewConfig(environmentId, {30 const error = yield* loadPreviewWebviewConfig(environmentId, undefined, {
29 getPreviewConfig: () => Promise.reject(cause),31 getPreviewConfig: () => Promise.reject(cause),
30 }).pipe(Effect.flip);32 }).pipe(Effect.flip);
3133
...@@ -36,22 +38,23 @@ describe("loadPreviewWebviewConfig", () => {...@@ -36,22 +38,23 @@ describe("loadPreviewWebviewConfig", () => {
36 }),38 }),
37 );39 );
3840
39 it.effect("forwards the environment id to the bridge", () =>41 it.effect("forwards the environment id and profile to the bridge", () =>
40 Effect.gen(function* () {42 Effect.gen(function* () {
41 let requestedEnvironmentId: EnvironmentId | null = null;43 let requested: { environmentId: EnvironmentId; profileId: string | undefined } | null = null;
42 const config = {44 const config = {
43 partition: "persist:test-preview",45 partition: "persist:test-preview",
44 webPreferences: "sandbox=yes",46 webPreferences: "sandbox=yes",
45 preloadUrl: null,47 preloadUrl: null,
46 };48 };
47 const result = yield* loadPreviewWebviewConfig(environmentId, {49 const result = yield* loadPreviewWebviewConfig(environmentId, "work", {
48 getPreviewConfig: (input) => {50 getPreviewConfig: (requestedEnvironmentId, profileId) => {
49 requestedEnvironmentId = input;51 requested = { environmentId: requestedEnvironmentId, profileId };
50 return Promise.resolve(config);52 return Promise.resolve(config);
51 },53 },
52 });54 });
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" });
55 expect(result).toEqual(config);58 expect(result).toEqual(config);
56 }),59 }),
57 );60 );
apps/web/src/browser/previewWebviewConfigState.ts+35-7
...@@ -45,6 +45,7 @@ type PreviewConfigBridge = Pick<DesktopPreviewBridge, "getPreviewConfig">;...@@ -45,6 +45,7 @@ type PreviewConfigBridge = Pick<DesktopPreviewBridge, "getPreviewConfig">;
4545
46export const loadPreviewWebviewConfig = (46export const loadPreviewWebviewConfig = (
47 environmentId: EnvironmentId,47 environmentId: EnvironmentId,
48 profileId?: string,
48 bridge: PreviewConfigBridge | null = previewBridge,49 bridge: PreviewConfigBridge | null = previewBridge,
49): Effect.Effect<DesktopPreviewWebviewConfig, PreviewWebviewConfigError> => {50): Effect.Effect<DesktopPreviewWebviewConfig, PreviewWebviewConfigError> => {
50 if (bridge === null) {51 if (bridge === null) {
...@@ -52,25 +53,52 @@ export const loadPreviewWebviewConfig = (...@@ -52,25 +53,52 @@ export const loadPreviewWebviewConfig = (
52 }53 }
5354
54 return Effect.tryPromise({55 return Effect.tryPromise({
55 try: () => bridge.getPreviewConfig(environmentId),56 try: () => bridge.getPreviewConfig(environmentId, profileId),
56 catch: (cause) => new PreviewWebviewConfigLoadError({ environmentId, cause }),57 catch: (cause) => new PreviewWebviewConfigLoadError({ environmentId, cause }),
57 });58 });
58};59};
5960
60const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) =>61/**
61 Atom.make(loadPreviewWebviewConfig(environmentId)).pipe(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 */
71const CONFIG_KEY_DELIMITER = "\u0000";
72
73const configKey = (environmentId: EnvironmentId, profileId: string | undefined): string =>
74 `${environmentId}${CONFIG_KEY_DELIMITER}${profileId ?? ""}`;
75
76const 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
86const previewWebviewConfigAtom = Atom.family((key: string) => {
87 const { environmentId, profileId } = parseConfigKey(key);
88 return Atom.make(loadPreviewWebviewConfig(environmentId, profileId)).pipe(
62 Atom.swr({89 Atom.swr({
63 staleTime: PREVIEW_CONFIG_STALE_TIME_MS,90 staleTime: PREVIEW_CONFIG_STALE_TIME_MS,
64 revalidateOnMount: true,91 revalidateOnMount: true,
65 }),92 }),
66 Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS),93 Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS),
67 Atom.withLabel(`preview:webview-config:${environmentId}`),94 Atom.withLabel(`preview:webview-config:${key}`),
68 ),95 );
69);96});
7097
71export function usePreviewWebviewConfig(98export function usePreviewWebviewConfig(
72 environmentId: EnvironmentId,99 environmentId: EnvironmentId,
100 profileId?: string,
73): DesktopPreviewWebviewConfig | null {101): DesktopPreviewWebviewConfig | null {
74 const result = useAtomValue(previewWebviewConfigAtom(environmentId));102 const result = useAtomValue(previewWebviewConfigAtom(configKey(environmentId, profileId)));
75 return Option.getOrNull(AsyncResult.value(result));103 return Option.getOrNull(AsyncResult.value(result));
76}104}
apps/web/src/components/ChatView.tsx+15-6
...@@ -3608,10 +3608,17 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3608,10 +3608,17 @@ function ChatViewContent(props: ChatViewProps) {
3608 const toggleInteractionMode = useCallback(() => {3608 const toggleInteractionMode = useCallback(() => {
3609 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");3609 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
3610 }, [handleInteractionModeChange, interactionMode]);3610 }, [handleInteractionModeChange, interactionMode]);
3611 const createBrowserSurface = useCallback(() => {3611 const createBrowserSurface = useCallback(
3612 if (!activeThreadRef) return;3612 (profileId?: string) => {
3613 void addBrowserSurface({ threadRef: activeThreadRef, openPreview });3613 if (!activeThreadRef) return;
3614 }, [activeThreadRef, openPreview]);3614 void addBrowserSurface({
3615 threadRef: activeThreadRef,
3616 openPreview,
3617 ...(profileId === undefined ? {} : { profileId }),
3618 });
3619 },
3620 [activeThreadRef, openPreview],
3621 );
3615 const addDiffSurface = useCallback(() => {3622 const addDiffSurface = useCallback(() => {
3616 if (!activeThreadRef || !isServerThread || !isGitRepo) return;3623 if (!activeThreadRef || !isServerThread || !isGitRepo) return;
3617 useRightPanelStore.getState().open(activeThreadRef, "diff");3624 useRightPanelStore.getState().open(activeThreadRef, "diff");
...@@ -7664,7 +7671,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7664,7 +7671,8 @@ function ChatViewContent(props: ChatViewProps) {
7664 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}7671 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
7665 onCloseAllSurfaces={closeAllRightPanelSurfaces}7672 onCloseAllSurfaces={closeAllRightPanelSurfaces}
7666 onCopyFilePath={copyRightPanelFilePath}7673 onCopyFilePath={copyRightPanelFilePath}
7667 onAddBrowser={createBrowserSurface}7674 onAddBrowser={() => createBrowserSurface()}
7675 onAddBrowserInProfile={createBrowserSurface}
7668 onAddTerminal={addTerminalSurface}7676 onAddTerminal={addTerminalSurface}
7669 onAddDiff={addDiffSurface}7677 onAddDiff={addDiffSurface}
7670 onAddFiles={addFilesSurface}7678 onAddFiles={addFilesSurface}
...@@ -7704,7 +7712,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7704,7 +7712,8 @@ function ChatViewContent(props: ChatViewProps) {
7704 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}7712 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
7705 onCloseAllSurfaces={closeAllRightPanelSurfaces}7713 onCloseAllSurfaces={closeAllRightPanelSurfaces}
7706 onCopyFilePath={copyRightPanelFilePath}7714 onCopyFilePath={copyRightPanelFilePath}
7707 onAddBrowser={createBrowserSurface}7715 onAddBrowser={() => createBrowserSurface()}
7716 onAddBrowserInProfile={createBrowserSurface}
7708 onAddTerminal={addTerminalSurface}7717 onAddTerminal={addTerminalSurface}
7709 onAddDiff={addDiffSurface}7718 onAddDiff={addDiffSurface}
7710 onAddFiles={addFilesSurface}7719 onAddFiles={addFilesSurface}
apps/web/src/components/RightPanelTabs.test.tsx+10
...@@ -4,11 +4,20 @@ import { describe, expect, it } from "vite-plus/test";...@@ -4,11 +4,20 @@ import { describe, expect, it } from "vite-plus/test";
44
5import {5import {
6 RightPanelTabs,6 RightPanelTabs,
7 shouldOpenDefaultBrowserProfileFromMenuClick,
7 surfaceShortcutActionForKey,8 surfaceShortcutActionForKey,
8 surfaceShortcutTargetsTypingContext,9 surfaceShortcutTargetsTypingContext,
9 tabMuteMenuItem,10 tabMuteMenuItem,
10} from "./RightPanelTabs";11} from "./RightPanelTabs";
1112
13describe("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
12function shortcutEvent(21function shortcutEvent(
13 key: string,22 key: string,
14 overrides: Partial<Parameters<typeof surfaceShortcutActionForKey>[1]> = {},23 overrides: Partial<Parameters<typeof surfaceShortcutActionForKey>[1]> = {},
...@@ -104,6 +113,7 @@ function renderTabs(...@@ -104,6 +113,7 @@ function renderTabs(
104 onCloseAllSurfaces={() => undefined}113 onCloseAllSurfaces={() => undefined}
105 onCopyFilePath={() => undefined}114 onCopyFilePath={() => undefined}
106 onAddBrowser={() => undefined}115 onAddBrowser={() => undefined}
116 onAddBrowserInProfile={() => undefined}
107 onAddTerminal={() => undefined}117 onAddTerminal={() => undefined}
108 onAddPullRequest={() => undefined}118 onAddPullRequest={() => undefined}
109 onAddDiff={() => undefined}119 onAddDiff={() => undefined}
apps/web/src/components/RightPanelTabs.tsx+73-1
...@@ -30,7 +30,17 @@ import { readLocalApi } from "~/localApi";...@@ -30,7 +30,17 @@ import { readLocalApi } from "~/localApi";
30import { Button } from "~/components/ui/button";30import { Button } from "~/components/ui/button";
31import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";31import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
32import { Kbd } from "~/components/ui/kbd";32import { Kbd } from "~/components/ui/kbd";
33import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "~/components/ui/menu";33import {
34 Menu,
35 MenuItem,
36 MenuPopup,
37 MenuShortcut,
38 MenuSub,
39 MenuSubPopup,
40 MenuSubTrigger,
41 MenuTrigger,
42} from "~/components/ui/menu";
43import { useBrowserDefaults } from "~/browser/browserDefaults";
34import { ScrollArea } from "~/components/ui/scroll-area";44import { ScrollArea } from "~/components/ui/scroll-area";
35import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";45import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
36import { faviconUrlForOrigin } from "~/lib/favicon";46import { faviconUrlForOrigin } from "~/lib/favicon";
...@@ -69,6 +79,12 @@ interface RightPanelTabsProps {...@@ -69,6 +79,12 @@ interface RightPanelTabsProps {
69 onCloseAllSurfaces: () => void;79 onCloseAllSurfaces: () => void;
70 onCopyFilePath: (relativePath: string) => void;80 onCopyFilePath: (relativePath: string) => void;
71 onAddBrowser: () => void;81 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;
72 onAddTerminal: () => void;88 onAddTerminal: () => void;
73 onAddDiff: () => void;89 onAddDiff: () => void;
74 onAddFiles: () => void;90 onAddFiles: () => void;
...@@ -94,6 +110,12 @@ export interface PullRequestTabStatus {...@@ -94,6 +110,12 @@ export interface PullRequestTabStatus {
94 isDraft: boolean;110 isDraft: boolean;
95}111}
96112
113export function shouldOpenDefaultBrowserProfileFromMenuClick(
114 pointerType: string | undefined,
115): boolean {
116 return pointerType !== "touch";
117}
118
97const SURFACE_DISABLED_REASONS = {119const SURFACE_DISABLED_REASONS = {
98 browser: "Browser previews are only available in the T3 Code desktop app.",120 browser: "Browser previews are only available in the T3 Code desktop app.",
99 terminal: "Terminal surfaces are only available from a project thread.",121 terminal: "Terminal surfaces are only available from a project thread.",
...@@ -600,6 +622,7 @@ function SurfaceIcon({...@@ -600,6 +622,7 @@ function SurfaceIcon({
600622
601export function RightPanelTabs(props: RightPanelTabsProps) {623export function RightPanelTabs(props: RightPanelTabsProps) {
602 const ownsDesktopTitleBar = isElectron && props.mode === "inline";624 const ownsDesktopTitleBar = isElectron && props.mode === "inline";
625 const browserProfiles = useBrowserDefaults().profiles;
603 const { resolvedTheme } = useTheme();626 const { resolvedTheme } = useTheme();
604 const tabListRef = useRef<HTMLDivElement>(null);627 const tabListRef = useRef<HTMLDivElement>(null);
605 const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);628 const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);
...@@ -911,6 +934,55 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -911,6 +934,55 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
911 >934 >
912 {addSurfaceActions.map((action) => {935 {addSurfaceActions.map((action) => {
913 const Icon = action.icon;936 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 }
914 return (986 return (
915 <SurfaceMenuItem987 <SurfaceMenuItem
916 key={action.label}988 key={action.label}
apps/web/src/components/preview/PreviewChromeRow.tsx+8
...@@ -57,6 +57,11 @@ interface Props {...@@ -57,6 +57,11 @@ interface Props {
57 * to mount the three-dot menu (hard reload, devtools, zoom, clear data).57 * to mount the three-dot menu (hard reload, devtools, zoom, clear data).
58 */58 */
59 trailingActions?: ReactNode;59 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;
60}65}
6166
62const NOOP = () => {};67const NOOP = () => {};
...@@ -85,6 +90,7 @@ export function PreviewChromeRow({...@@ -85,6 +90,7 @@ export function PreviewChromeRow({
85 pickDisabled,90 pickDisabled,
86 pickDisabledReason,91 pickDisabledReason,
87 trailingActions,92 trailingActions,
93 leadingActions,
88}: Props) {94}: Props) {
89 const inputRef = useRef<HTMLInputElement | null>(null);95 const inputRef = useRef<HTMLInputElement | null>(null);
90 const [draft, setDraft] = useState(url);96 const [draft, setDraft] = useState(url);
...@@ -166,6 +172,8 @@ export function PreviewChromeRow({...@@ -166,6 +172,8 @@ export function PreviewChromeRow({
166 </Tooltip>172 </Tooltip>
167 </div>173 </div>
168174
175 {leadingActions}
176
169 <InputGroup variant="ghost" className="group/address h-7 flex-1">177 <InputGroup variant="ghost" className="group/address h-7 flex-1">
170 <Tooltip>178 <Tooltip>
171 <TooltipTrigger179 <TooltipTrigger
apps/web/src/components/preview/PreviewMoreMenu.tsx+48-7
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1"use client";1"use client";
22
3import type { DesktopPreviewColorScheme } from "@t3tools/contracts";3import type { DesktopPreviewColorScheme, EnvironmentId } from "@t3tools/contracts";
4import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react";4import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react";
55
6import { Button } from "~/components/ui/button";6import { Button } from "~/components/ui/button";
...@@ -10,6 +10,8 @@ import {...@@ -10,6 +10,8 @@ import {
10 MenuPopup,10 MenuPopup,
11 MenuRadioGroup,11 MenuRadioGroup,
12 MenuRadioItem,12 MenuRadioItem,
13 MenuGroup,
14 MenuGroupLabel,
13 MenuSeparator,15 MenuSeparator,
14 MenuSub,16 MenuSub,
15 MenuSubPopup,17 MenuSubPopup,
...@@ -50,6 +52,17 @@ interface Props {...@@ -50,6 +52,17 @@ interface Props {
50 nativePictureInPicture: boolean;52 nativePictureInPicture: boolean;
51 /** Toggles the optional native always-on-top preview window. */53 /** Toggles the optional native always-on-top preview window. */
52 onNativePictureInPicture: () => void;54 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;
53}66}
5467
55/**68/**
...@@ -66,6 +79,9 @@ export function PreviewMoreMenu({...@@ -66,6 +79,9 @@ export function PreviewMoreMenu({
66 onToggleDeviceToolbar,79 onToggleDeviceToolbar,
67 nativePictureInPicture,80 nativePictureInPicture,
68 onNativePictureInPicture,81 onNativePictureInPicture,
82 environmentId,
83 profileId,
84 profileName,
69}: Props) {85}: Props) {
70 if (!previewBridge) return null;86 if (!previewBridge) return null;
71 const bridge = previewBridge;87 const bridge = previewBridge;
...@@ -177,12 +193,37 @@ export function PreviewMoreMenu({...@@ -177,12 +193,37 @@ export function PreviewMoreMenu({
177 </span>193 </span>
178 </MenuItem>194 </MenuItem>
179 <MenuSeparator />195 <MenuSeparator />
180 <MenuItem onClick={() => void bridge.clearCookies().catch(() => undefined)}>196 {/*
181 Clear cookies197 Grouped so the heading has a `MenuGroup` ancestor — `MenuGroupLabel`
182 </MenuItem>198 reads its context and throws without one. The heading also answers
183 <MenuItem onClick={() => void bridge.clearCache().catch(() => undefined)}>199 which profile the tab is in, which is otherwise invisible: it is fixed
184 Clear cache200 at open and nothing else in the chrome shows it.
185 </MenuItem>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>
186 </MenuPopup>227 </MenuPopup>
187 </Menu>228 </Menu>
188 );229 );
apps/web/src/components/preview/PreviewView.test.tsx+21-13
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import {1import {
2 BUILT_IN_BROWSER_PROFILES,
3 DEFAULT_BROWSER_PROFILE_ID,
2 DEFAULT_PREVIEW_APPEARANCE,4 DEFAULT_PREVIEW_APPEARANCE,
3 DEFAULT_PREVIEW_ZOOM_FACTOR,5 DEFAULT_PREVIEW_ZOOM_FACTOR,
4 EnvironmentId,6 EnvironmentId,
...@@ -37,6 +39,15 @@ const mocks = vi.hoisted(() => ({...@@ -37,6 +39,15 @@ const mocks = vi.hoisted(() => ({
3739
38const EMPTY_HISTORY: never[] = [];40const EMPTY_HISTORY: never[] = [];
3941
42const 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
40vi.mock("~/browserHistoryStore", () => ({51vi.mock("~/browserHistoryStore", () => ({
41 recordVisitForThread: mocks.recordVisitForThread,52 recordVisitForThread: mocks.recordVisitForThread,
42 setTitleForThreadUrl: vi.fn(),53 setTitleForThreadUrl: vi.fn(),
...@@ -53,19 +64,10 @@ vi.mock("~/state/session", () => ({...@@ -53,19 +64,10 @@ vi.mock("~/state/session", () => ({
53// `useSettings` -> `state/server`, which would drag the whole settings and64// `useSettings` -> `state/server`, which would drag the whole settings and
54// connection graph into a test that only cares about the browser chrome.65// connection graph into a test that only cares about the browser chrome.
55vi.mock("~/browser/browserDefaults", () => ({66vi.mock("~/browser/browserDefaults", () => ({
56 useBrowserDefaults: () => ({67 useBrowserDefaults: () => STUB_BROWSER_DEFAULTS,
57 viewport: FILL_PREVIEW_VIEWPORT,68 getBrowserDefaults: () => STUB_BROWSER_DEFAULTS,
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 }),
68 browserDefaultOpenViewport: () => FILL_PREVIEW_VIEWPORT,69 browserDefaultOpenViewport: () => FILL_PREVIEW_VIEWPORT,
70 browserDefaultOpenProfileId: () => DEFAULT_BROWSER_PROFILE_ID,
69 browserDefaultTabState: () => ({71 browserDefaultTabState: () => ({
70 zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,72 zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
71 colorScheme: DEFAULT_PREVIEW_APPEARANCE,73 colorScheme: DEFAULT_PREVIEW_APPEARANCE,
...@@ -249,7 +251,7 @@ vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null }));...@@ -249,7 +251,7 @@ vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null }));
249vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null }));251vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null }));
250vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() }));252vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() }));
251253
252import { PreviewView } from "./PreviewView";254import { PreviewView, previewProfileName } from "./PreviewView";
253import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";255import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
254256
255const TEST_THREAD_REF = {257const TEST_THREAD_REF = {
...@@ -347,6 +349,12 @@ describe("PreviewView navigation", () => {...@@ -347,6 +349,12 @@ describe("PreviewView navigation", () => {
347 mocks.recordVisitForThread.mockClear();349 mocks.recordVisitForThread.mockClear();
348 });350 });
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
350 it("does not rerender while loading time passes", async () => {358 it("does not rerender while loading time passes", async () => {
351 vi.useFakeTimers();359 vi.useFakeTimers();
352 mocks.loading = true;360 mocks.loading = true;
apps/web/src/components/preview/PreviewView.tsx+41
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3import { scopedThreadKey } from "@t3tools/client-runtime/environment";3import { scopedThreadKey } from "@t3tools/client-runtime/environment";
4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import {5import {
6 DEFAULT_BROWSER_PROFILE_ID,
6 FILL_PREVIEW_VIEWPORT,7 FILL_PREVIEW_VIEWPORT,
7 type PreviewAnnotationPayload,8 type PreviewAnnotationPayload,
8 type PreviewViewportSetting,9 type PreviewViewportSetting,
...@@ -48,6 +49,7 @@ import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";...@@ -48,6 +49,7 @@ import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
48import { PreviewUnreachable } from "./PreviewUnreachable";49import { PreviewUnreachable } from "./PreviewUnreachable";
49import { revealInFileExplorerLabel } from "./fileExplorerLabel";50import { revealInFileExplorerLabel } from "./fileExplorerLabel";
50import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";51import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
52import { Badge } from "~/components/ui/badge";
51import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";53import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
52import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";54import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
53import { usePreviewSession } from "./usePreviewSession";55import { usePreviewSession } from "./usePreviewSession";
...@@ -61,6 +63,7 @@ import {...@@ -61,6 +63,7 @@ import {
61 useActiveBrowserRecordingTabIds,63 useActiveBrowserRecordingTabIds,
62} from "~/browser/browserRecording";64} from "~/browser/browserRecording";
63import { stackedThreadToast, toastManager } from "~/components/ui/toast";65import { stackedThreadToast, toastManager } from "~/components/ui/toast";
66import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
6467
65interface Props {68interface Props {
66 threadRef: ScopedThreadRef;69 threadRef: ScopedThreadRef;
...@@ -73,6 +76,13 @@ interface Props {...@@ -73,6 +76,13 @@ interface Props {
73 ) => void;76 ) => void;
74}77}
7578
79export 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
76const localApi = typeof window === "undefined" ? null : ensureLocalApi();86const localApi = typeof window === "undefined" ? null : ensureLocalApi();
7787
78/**88/**
...@@ -144,6 +154,14 @@ export function PreviewView({...@@ -144,6 +154,14 @@ export function PreviewView({
144 const controller = desktopOverlay?.controller ?? "none";154 const controller = desktopOverlay?.controller ?? "none";
145 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;155 const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT;
146 const browserDefaults = useBrowserDefaults();156 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);
147 const panelRect = useBrowserSurfaceStore((state) =>165 const panelRect = useBrowserSurfaceStore((state) =>
148 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,166 runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null,
149 );167 );
...@@ -686,9 +704,32 @@ export function PreviewView({...@@ -686,9 +704,32 @@ export function PreviewView({
686 pickDisabledReason={704 pickDisabledReason={
687 isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined705 isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined
688 }706 }
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 }
689 trailingActions={727 trailingActions={
690 previewBridge ? (728 previewBridge ? (
691 <PreviewMoreMenu729 <PreviewMoreMenu
730 environmentId={threadRef.environmentId}
731 profileId={activeProfileId}
732 profileName={activeProfileName}
692 tabId={runtimeTabId}733 tabId={runtimeTabId}
693 hasWebContents={desktopOverlay?.hasWebContents ?? false}734 hasWebContents={desktopOverlay?.hasWebContents ?? false}
694 zoomFactor={desktopOverlay?.zoomFactor ?? 1}735 zoomFactor={desktopOverlay?.zoomFactor ?? 1}
apps/web/src/components/preview/addBrowserSurface.test.ts+20
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 DEFAULT_BROWSER_PROFILE_ID,
2 FILL_PREVIEW_VIEWPORT,3 FILL_PREVIEW_VIEWPORT,
3 type PreviewOpenInput,4 type PreviewOpenInput,
4 type PreviewSessionSnapshot,5 type PreviewSessionSnapshot,
...@@ -36,6 +37,24 @@ beforeEach(() => {...@@ -36,6 +37,24 @@ beforeEach(() => {
36});37});
3738
38describe("addBrowserSurface", () => {39describe("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
39 it("creates another preview session when a browser tab is already active", async () => {58 it("creates another preview session when a browser tab is already active", async () => {
40 const first = snapshot("tab-1");59 const first = snapshot("tab-1");
41 const second = snapshot("tab-2");60 const second = snapshot("tab-2");
...@@ -48,6 +67,7 @@ describe("addBrowserSurface", () => {...@@ -48,6 +67,7 @@ describe("addBrowserSurface", () => {
48 expect(openPreview).toHaveBeenCalledWith({67 expect(openPreview).toHaveBeenCalledWith({
49 threadId: "thread-1",68 threadId: "thread-1",
50 viewport: FILL_PREVIEW_VIEWPORT,69 viewport: FILL_PREVIEW_VIEWPORT,
70 profileId: DEFAULT_BROWSER_PROFILE_ID,
51 });71 });
52 expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]);72 expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]);
53 expect(73 expect(
apps/web/src/components/preview/addBrowserSurface.ts+3
...@@ -13,10 +13,13 @@ import { openPreviewSession } from "./openPreviewSession";...@@ -13,10 +13,13 @@ import { openPreviewSession } from "./openPreviewSession";
13export async function addBrowserSurface<E>(input: {13export async function addBrowserSurface<E>(input: {
14 readonly threadRef: ScopedThreadRef;14 readonly threadRef: ScopedThreadRef;
15 readonly openPreview: OpenPreviewMutation<E>;15 readonly openPreview: OpenPreviewMutation<E>;
16 /** Omit to use the configured default profile. */
17 readonly profileId?: string | undefined;
16}): Promise<AtomCommandResult<void, E>> {18}): Promise<AtomCommandResult<void, E>> {
17 const result = await openPreviewSession({19 const result = await openPreviewSession({
18 openPreview: input.openPreview,20 openPreview: input.openPreview,
19 threadRef: input.threadRef,21 threadRef: input.threadRef,
22 ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
20 });23 });
21 return mapAtomCommandResult(result, (snapshot) => {24 return mapAtomCommandResult(result, (snapshot) => {
22 useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId);25 useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId);
apps/web/src/components/preview/openPreviewSession.test.ts+3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 DEFAULT_BROWSER_PROFILE_ID,
2 FILL_PREVIEW_VIEWPORT,3 FILL_PREVIEW_VIEWPORT,
3 type PreviewOpenInput,4 type PreviewOpenInput,
4 type PreviewSessionSnapshot,5 type PreviewSessionSnapshot,
...@@ -49,6 +50,7 @@ describe("openPreviewSession", () => {...@@ -49,6 +50,7 @@ describe("openPreviewSession", () => {
49 expect(open).toHaveBeenCalledWith({50 expect(open).toHaveBeenCalledWith({
50 threadId: "thread-1",51 threadId: "thread-1",
51 viewport: FILL_PREVIEW_VIEWPORT,52 viewport: FILL_PREVIEW_VIEWPORT,
53 profileId: DEFAULT_BROWSER_PROFILE_ID,
52 });54 });
53 expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot);55 expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot);
54 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]);56 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]);
...@@ -67,6 +69,7 @@ describe("openPreviewSession", () => {...@@ -67,6 +69,7 @@ describe("openPreviewSession", () => {
67 threadId: "thread-1",69 threadId: "thread-1",
68 url: "t3.chat",70 url: "t3.chat",
69 viewport: FILL_PREVIEW_VIEWPORT,71 viewport: FILL_PREVIEW_VIEWPORT,
72 profileId: DEFAULT_BROWSER_PROFILE_ID,
70 });73 });
71 expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);74 expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);
72 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]);75 expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]);
apps/web/src/components/preview/openPreviewSession.ts+12-2
...@@ -7,7 +7,11 @@ import type {...@@ -7,7 +7,11 @@ import type {
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";8import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
99
10import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults";10import {
11 browserDefaultOpenProfileId,
12 browserDefaultOpenViewport,
13 resolveBrowserDefaults,
14} from "~/browser/browserDefaults";
11import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";15import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";
1216
13interface OpenPreviewSessionInput<E> {17interface OpenPreviewSessionInput<E> {
...@@ -19,17 +23,23 @@ interface OpenPreviewSessionInput<E> {...@@ -19,17 +23,23 @@ interface OpenPreviewSessionInput<E> {
19 url?: string;23 url?: string;
20 /** Overrides the configured default; automation passes an explicit size. */24 /** Overrides the configured default; automation passes an explicit size. */
21 viewport?: PreviewViewportSetting;25 viewport?: PreviewViewportSetting;
26 /** Overrides the configured default profile. */
27 profileId?: string;
22}28}
2329
24export async function openPreviewSession<E>(30export async function openPreviewSession<E>(
25 input: OpenPreviewSessionInput<E>,31 input: OpenPreviewSessionInput<E>,
26): Promise<AtomCommandResult<PreviewSessionSnapshot, E>> {32): 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();
27 const result = await input.openPreview({36 const result = await input.openPreview({
28 environmentId: input.threadRef.environmentId,37 environmentId: input.threadRef.environmentId,
29 input: {38 input: {
30 threadId: input.threadRef.threadId,39 threadId: input.threadRef.threadId,
31 ...(input.url === undefined ? {} : { url: input.url }),40 ...(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),
33 },43 },
34 });44 });
35 if (result._tag === "Failure") {45 if (result._tag === "Failure") {
apps/web/src/components/preview/openTerminalLinkInPreview.test.ts+59-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";1import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";2import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/unstable/reactivity";3import { AsyncResult } from "effect/unstable/reactivity";
4import { afterEach, describe, expect, it, vi } from "vite-plus/test";4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
55
6import {6import {
7 openTerminalLinkInPreview,7 openTerminalLinkInPreview,
...@@ -20,6 +20,21 @@ vi.mock("~/rightPanelStore", () => ({...@@ -20,6 +20,21 @@ vi.mock("~/rightPanelStore", () => ({
20 },20 },
21}));21}));
2222
23const browserDefaultsMocks = vi.hoisted(() => ({
24 resolve: vi.fn(),
25}));
26
27vi.mock("~/browser/browserDefaults", () => ({
28 resolveBrowserDefaults: browserDefaultsMocks.resolve,
29 browserDefaultOpenViewport: (defaults: { viewport: unknown }) => defaults.viewport,
30 browserDefaultOpenProfileId: (defaults: { profileId: string }) => defaults.profileId,
31}));
32
33const hydratedDefaults = {
34 viewport: { _tag: "fixed", width: 1280, height: 720 } as const,
35 profileId: "work",
36};
37
23const threadRef = {38const threadRef = {
24 environmentId: "local" as ScopedThreadRef["environmentId"],39 environmentId: "local" as ScopedThreadRef["environmentId"],
25 threadId: "thread-1" as ScopedThreadRef["threadId"],40 threadId: "thread-1" as ScopedThreadRef["threadId"],
...@@ -34,11 +49,54 @@ const snapshot: PreviewSessionSnapshot = {...@@ -34,11 +49,54 @@ const snapshot: PreviewSessionSnapshot = {
34 updatedAt: "2026-06-20T00:00:00.000Z",49 updatedAt: "2026-06-20T00:00:00.000Z",
35};50};
3651
52beforeEach(() => {
53 browserDefaultsMocks.resolve.mockResolvedValue(hydratedDefaults);
54});
55
37afterEach(() => {56afterEach(() => {
38 vi.restoreAllMocks();57 vi.restoreAllMocks();
39});58});
4059
41describe("openTerminalLinkInPreview", () => {60describe("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
42 it("preserves context-menu failures with terminal link context before falling back", async () => {100 it("preserves context-menu failures with terminal link context before falling back", async () => {
43 const cause = new Error("menu unavailable");101 const cause = new Error("menu unavailable");
44 const fallbackToBrowser = vi.fn();102 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"...@@ -3,6 +3,11 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"
3import { isPreviewableUrl } from "@t3tools/shared/preview";3import { isPreviewableUrl } from "@t3tools/shared/preview";
4import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
55
6import {
7 browserDefaultOpenProfileId,
8 browserDefaultOpenViewport,
9 resolveBrowserDefaults,
10} from "~/browser/browserDefaults";
6import type { OpenPreviewMutation } from "~/browser/openFileInPreview";11import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
7import { recordVisitForThread } from "~/browserHistoryStore";12import { recordVisitForThread } from "~/browserHistoryStore";
8import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";13import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";
...@@ -82,9 +87,17 @@ export async function openTerminalLinkInPreview<E>(...@@ -82,9 +87,17 @@ export async function openTerminalLinkInPreview<E>(
82 }87 }
8388
84 if (choice === "open-in-preview") {89 if (choice === "open-in-preview") {
90 const defaults = await resolveBrowserDefaults();
85 const result = await input.openPreview({91 const result = await input.openPreview({
86 environmentId: input.threadRef.environmentId,92 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 },
88 });101 });
89 if (result._tag === "Failure") {102 if (result._tag === "Failure") {
90 if (isAtomCommandInterrupted(result)) {103 if (isAtomCommandInterrupted(result)) {
apps/web/src/components/settings/IntegrationsSettings.logic.test.ts created+74
...@@ -0,0 +1,74 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2import type { EnvironmentId } from "@t3tools/contracts";
3
4import { browserProfileRemovalAvailable, clearBrowserProfileData } from "./IntegrationsSettings";
5
6const environmentId = "environment-a" as EnvironmentId;
7const secondEnvironmentId = "environment-b" as EnvironmentId;
8
9describe("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
67describe("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 @@...@@ -7,8 +7,13 @@
7 * @module IntegrationsSettings7 * @module IntegrationsSettings
8 */8 */
9import {9import {
10 BROWSER_PROFILE_MAX_COUNT,
11 type BrowserProfile,
12 type EnvironmentId,
13 BROWSER_PROFILE_NAME_MAX_LENGTH,
10 BROWSER_RECORDING_FRAME_RATES,14 BROWSER_RECORDING_FRAME_RATES,
11 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,15 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
16 DEFAULT_BROWSER_PROFILE_ID,
12 DEFAULT_BROWSER_RECORDING_FRAME_RATE,17 DEFAULT_BROWSER_RECORDING_FRAME_RATE,
13 DEFAULT_BROWSER_VIEWPORT,18 DEFAULT_BROWSER_VIEWPORT,
14 DEFAULT_PREVIEW_APPEARANCE,19 DEFAULT_PREVIEW_APPEARANCE,
...@@ -19,17 +24,35 @@ import {...@@ -19,17 +24,35 @@ import {
19 PREVIEW_VIEWPORT_MAX_DIMENSION,24 PREVIEW_VIEWPORT_MAX_DIMENSION,
20 PREVIEW_VIEWPORT_MIN_DIMENSION,25 PREVIEW_VIEWPORT_MIN_DIMENSION,
21 PREVIEW_ZOOM_LEVELS,26 PREVIEW_ZOOM_LEVELS,
27 findBrowserProfile,
28 isBuiltInBrowserProfileId,
29 resolveBrowserProfiles,
22 type PreviewAppearancePreference,30 type PreviewAppearancePreference,
23 type PreviewViewportSetting,31 type PreviewViewportSetting,
24} from "@t3tools/contracts";32} from "@t3tools/contracts";
25import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";33import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
26import { InfoIcon } from "lucide-react";34import { InfoIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react";
35import { useState } from "react";
27import type { ReactNode } from "react";36import type { ReactNode } from "react";
2837
29import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";38import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";
39import { previewBridge } from "~/components/preview/previewBridge";
40import { cn, randomUUID } from "~/lib/utils";
41import { useEnvironments } from "~/state/environments";
30import { isElectron } from "../../env";42import { isElectron } from "../../env";
3143
44import { Badge } from "../ui/badge";
45import {
46 AlertDialog,
47 AlertDialogClose,
48 AlertDialogDescription,
49 AlertDialogFooter,
50 AlertDialogHeader,
51 AlertDialogPopup,
52 AlertDialogTitle,
53} from "../ui/alert-dialog";
32import { Button } from "../ui/button";54import { Button } from "../ui/button";
55import { DraftInput } from "../ui/draft-input";
33import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field";56import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field";
34import {57import {
35 Select,58 Select,
...@@ -43,7 +66,9 @@ import {...@@ -43,7 +66,9 @@ import {
43import { Switch } from "../ui/switch";66import { Switch } from "../ui/switch";
44import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";67import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
45import {68import {
69 getClientSettings,
46 useClientSettings,70 useClientSettings,
71 useClientSettingsHydrated,
47 usePrimarySettings,72 usePrimarySettings,
48 useUpdatePrimarySettings,73 useUpdatePrimarySettings,
49} from "~/hooks/useSettings";74} from "~/hooks/useSettings";
...@@ -54,11 +79,41 @@ import {...@@ -54,11 +79,41 @@ import {
54 SettingsRow,79 SettingsRow,
55 SettingsSection,80 SettingsSection,
56} from "./settingsLayout";81} from "./settingsLayout";
82import { ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
57import { searchableSetting } from "./settingsSearch";83import { searchableSetting } from "./settingsSearch";
5884
59const FILL_VALUE = "fill";85const FILL_VALUE = "fill";
60const RESPONSIVE_VALUE = "responsive";86const RESPONSIVE_VALUE = "responsive";
6187
88type BrowserProfileDataBridge = Pick<
89 NonNullable<typeof previewBridge>,
90 "clearCookies" | "clearCache"
91>;
92
93export 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
109export function browserProfileRemovalAvailable(
110 bridgeAvailable: boolean,
111 environmentsReady: boolean,
112 environmentCount: number,
113): boolean {
114 return bridgeAvailable && environmentsReady && environmentCount > 0;
115}
116
62/**117/**
63 * The size a "Responsive" default falls back to when the user switches away118 * The size a "Responsive" default falls back to when the user switches away
64 * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry119 * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry
...@@ -501,11 +556,306 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode...@@ -501,11 +556,306 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
501 );556 );
502}557}
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 */
566function 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
783function 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
504export function IntegrationsSettingsPanel() {852export function IntegrationsSettingsPanel() {
505 // Client-local preview defaults are editable only where the preview exists.853 // Client-local preview defaults are editable only where the preview exists.
506 const previewDefaultsDisabled = !isElectron;854 const previewDefaultsDisabled = !isElectron;
507 const previewDefaults = (855 const previewDefaults = (
508 <>856 <>
857 <BrowserProfilesSetting disabled={previewDefaultsDisabled} />
858 <BrowserDefaultProfileSetting disabled={previewDefaultsDisabled} />
509 <BrowserViewportSetting disabled={previewDefaultsDisabled} />859 <BrowserViewportSetting disabled={previewDefaultsDisabled} />
510 <BrowserZoomSetting disabled={previewDefaultsDisabled} />860 <BrowserZoomSetting disabled={previewDefaultsDisabled} />
511 <BrowserAppearanceSetting disabled={previewDefaultsDisabled} />861 <BrowserAppearanceSetting disabled={previewDefaultsDisabled} />
apps/web/src/components/settings/settingsSearch.ts+12
...@@ -312,6 +312,18 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -312,6 +312,18 @@ export const SETTINGS_SEARCH_ITEMS = [
312 to: "/settings/integrations",312 to: "/settings/integrations",
313 searchTerms: ["allow open drive preview tools sessions"],313 searchTerms: ["allow open drive preview tools sessions"],
314 },314 },
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 },
315 {327 {
316 id: "browser-default-viewport",328 id: "browser-default-viewport",
317 title: "Default browser viewport",329 title: "Default browser viewport",
apps/web/src/components/ui/menu.tsx+6-1
...@@ -235,7 +235,12 @@ function MenuSubTrigger({...@@ -235,7 +235,12 @@ function MenuSubTrigger({
235 return (235 return (
236 <MenuPrimitive.SubmenuTrigger236 <MenuPrimitive.SubmenuTrigger
237 className={cn(237 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",
239 className,244 className,
240 )}245 )}
241 data-inset={inset}246 data-inset={inset}
apps/web/src/routes/_chat.pull-requests.tsx+1
...@@ -1867,6 +1867,7 @@ function PullRequestsRouteView() {...@@ -1867,6 +1867,7 @@ function PullRequestsRouteView() {
1867 onCloseAllSurfaces={closeAllSurfaces}1867 onCloseAllSurfaces={closeAllSurfaces}
1868 onCopyFilePath={() => undefined}1868 onCopyFilePath={() => undefined}
1869 onAddBrowser={() => undefined}1869 onAddBrowser={() => undefined}
1870 onAddBrowserInProfile={() => undefined}
1870 onAddTerminal={() => undefined}1871 onAddTerminal={() => undefined}
1871 onAddDiff={() => undefined}1872 onAddDiff={() => undefined}
1872 onAddFiles={() => undefined}1873 onAddFiles={() => undefined}
packages/contracts/src/browserProfile.test.ts created+111
...@@ -0,0 +1,111 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import * as Schema from "effect/Schema";
4
5import {
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
16const work: BrowserProfile = { id: "profile-work", name: "Work", kind: "persistent" };
17
18describe("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
49describe("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
58describe("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
66describe("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
104describe("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 created+99
...@@ -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 */
16import * as Schema from "effect/Schema";
17import { TrimmedNonEmptyString } from "./baseSchemas.ts";
18
19export const BROWSER_PROFILE_NAME_MAX_LENGTH = 48;
20export 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 */
27export const BrowserProfileId = TrimmedNonEmptyString.check(
28 Schema.isMaxLength(64),
29 Schema.isPattern(/^[^\p{Cc}]+$/u),
30);
31export type BrowserProfileId = typeof BrowserProfileId.Type;
32
33export 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 */
41export const BrowserProfileKind = Schema.Literals(["persistent", "incognito"]);
42export type BrowserProfileKind = typeof BrowserProfileKind.Type;
43
44export const BrowserProfile = Schema.Struct({
45 id: BrowserProfileId,
46 name: BrowserProfileName,
47 kind: BrowserProfileKind,
48});
49export type BrowserProfile = typeof BrowserProfile.Type;
50
51export const DEFAULT_BROWSER_PROFILE_ID: BrowserProfileId = "default";
52export 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 */
58export 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
63export 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 */
81export 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
94export 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
...@@ -28,6 +28,7 @@ export * from "./project.ts";...@@ -28,6 +28,7 @@ export * from "./project.ts";
28export * from "./filesystem.ts";28export * from "./filesystem.ts";
29export * from "./assets.ts";29export * from "./assets.ts";
30export * from "./review.ts";30export * from "./review.ts";
31export * from "./browserProfile.ts";
31export * from "./preview.ts";32export * from "./preview.ts";
32export * from "./previewAutomation.ts";33export * from "./previewAutomation.ts";
33export * from "./resourceTelemetry.ts";34export * from "./resourceTelemetry.ts";
packages/contracts/src/ipc.ts+20-3
...@@ -88,6 +88,7 @@ import type {...@@ -88,6 +88,7 @@ import type {
88 OrchestrationThreadStreamItem,88 OrchestrationThreadStreamItem,
89} from "./orchestration.ts";89} from "./orchestration.ts";
90import { EnvironmentId } from "./baseSchemas.ts";90import { EnvironmentId } from "./baseSchemas.ts";
91import { BrowserProfileId } from "./browserProfile.ts";
91import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";92import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";
92import { AdvertisedEndpoint } from "./remoteAccess.ts";93import { AdvertisedEndpoint } from "./remoteAccess.ts";
93import { ExecutionEnvironmentDescriptor } from "./environment.ts";94import { ExecutionEnvironmentDescriptor } from "./environment.ts";
...@@ -974,6 +975,19 @@ export const DesktopPreviewNavigateInputSchema = Schema.Struct({...@@ -974,6 +975,19 @@ export const DesktopPreviewNavigateInputSchema = Schema.Struct({
974975
975export const DesktopPreviewConfigInputSchema = Schema.Struct({976export const DesktopPreviewConfigInputSchema = Schema.Struct({
976 environmentId: EnvironmentId,977 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
987export const DesktopPreviewClearDataInputSchema = Schema.Struct({
988 environmentId: EnvironmentId,
989 /** Omit to clear every profile; otherwise only this profile's partition. */
990 profileId: Schema.optional(BrowserProfileId),
977});991});
978992
979export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({993export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({
...@@ -1158,16 +1172,19 @@ export interface DesktopPreviewBridge {...@@ -1158,16 +1172,19 @@ export interface DesktopPreviewBridge {
1158 /** Open the guest webview's DevTools (detached). */1172 /** Open the guest webview's DevTools (detached). */
1159 openDevTools: (tabId: string) => Promise<void>;1173 openDevTools: (tabId: string) => Promise<void>;
1160 /** Drop cookies + storage data for the preview partition (all tabs). */1174 /** Drop cookies + storage data for the preview partition (all tabs). */
1161 clearCookies: () => Promise<void>;1175 clearCookies: (environmentId: EnvironmentId, profileId?: string) => Promise<void>;
1162 /** Drop the HTTP cache for the preview partition (all tabs). */1176 /** Drop the HTTP cache for the preview partition (all tabs). */
1163 clearCache: () => Promise<void>;1177 clearCache: (environmentId: EnvironmentId, profileId?: string) => Promise<void>;
1164 /**1178 /**
1165 * One-shot config for mounting a preview `<webview>`. Replaces three1179 * One-shot config for mounting a preview `<webview>`. Replaces three
1166 * earlier round-trip calls (`getBrowserPartition`, `getWebviewPreferences`,1180 * earlier round-trip calls (`getBrowserPartition`, `getWebviewPreferences`,
1167 * `getPickPreloadPath`) so adding a new field here only requires touching1181 * `getPickPreloadPath`) so adding a new field here only requires touching
1168 * the contract + main, not the renderer's mount logic.1182 * the contract + main, not the renderer's mount logic.
1169 */1183 */
1170 getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>;1184 getPreviewConfig: (
1185 environmentId: EnvironmentId,
1186 profileId?: string,
1187 ) => Promise<DesktopPreviewWebviewConfig>;
1171 setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>;1188 setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>;
1172 /**1189 /**
1173 * Activate the in-page element picker for the given tab. Resolves with1190 * Activate the in-page element picker for the given tab. Resolves with
packages/contracts/src/preview.ts+9
...@@ -10,6 +10,7 @@...@@ -10,6 +10,7 @@
10 */10 */
11import { Schema } from "effect";11import { Schema } from "effect";
12import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";12import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
13import { BrowserProfileId } from "./browserProfile.ts";
1314
14export const PREVIEW_URL_MAX_LENGTH = 2_048;15export const PREVIEW_URL_MAX_LENGTH = 2_048;
15export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32;16export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32;
...@@ -169,6 +170,12 @@ export const PreviewSessionSnapshot = Schema.Struct({...@@ -169,6 +170,12 @@ export const PreviewSessionSnapshot = Schema.Struct({
169 canGoForward: Schema.Boolean,170 canGoForward: Schema.Boolean,
170 /** Missing snapshots from older servers are treated as fill-panel mode. */171 /** Missing snapshots from older servers are treated as fill-panel mode. */
171 viewport: Schema.optional(PreviewViewportSetting),172 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),
172 updatedAt: Schema.String,179 updatedAt: Schema.String,
173});180});
174export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;181export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;
...@@ -184,6 +191,8 @@ export const PreviewOpenInput = Schema.Struct({...@@ -184,6 +191,8 @@ export const PreviewOpenInput = Schema.Struct({
184 * later (which the user would see as a visible reflow).191 * later (which the user would see as a visible reflow).
185 */192 */
186 viewport: Schema.optional(PreviewViewportSetting),193 viewport: Schema.optional(PreviewViewportSetting),
194 /** Omit to open under the client's configured default profile. */
195 profileId: Schema.optional(BrowserProfileId),
187});196});
188export type PreviewOpenInput = typeof PreviewOpenInput.Type;197export type PreviewOpenInput = typeof PreviewOpenInput.Type;
189198
packages/contracts/src/settings.ts+15
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 ProviderOptionSelections,10 ProviderOptionSelections,
11} from "./model.ts";11} from "./model.ts";
12import { ModelSelection } from "./orchestration.ts";12import { ModelSelection } from "./orchestration.ts";
13import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";
13import {14import {
14 DEFAULT_PREVIEW_APPEARANCE,15 DEFAULT_PREVIEW_APPEARANCE,
15 DEFAULT_PREVIEW_ZOOM_FACTOR,16 DEFAULT_PREVIEW_ZOOM_FACTOR,
...@@ -199,6 +200,18 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -199,6 +200,18 @@ export const ClientSettingsSchema = Schema.Struct({
199 browserAutoShowFloatingPreview: Schema.Boolean.pipe(200 browserAutoShowFloatingPreview: Schema.Boolean.pipe(
200 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)),201 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)),
201 ),202 ),
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 ),
202 // Desktop-only. Boolean values from older settings files decode to their215 // Desktop-only. Boolean values from older settings files decode to their
203 // equivalent mode and encode back as the canonical string value.216 // equivalent mode and encode back as the canonical string value.
204 confirmQuit: QuitConfirmationModeSetting.pipe(217 confirmQuit: QuitConfirmationModeSetting.pipe(
...@@ -958,6 +971,8 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -958,6 +971,8 @@ export const ClientSettingsPatch = Schema.Struct({
958 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),971 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),
959 browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate),972 browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate),
960 browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean),973 browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean),
974 browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)),
975 browserDefaultProfileId: Schema.optionalKey(BrowserProfileId),
961 confirmQuit: Schema.optionalKey(QuitConfirmationMode),976 confirmQuit: Schema.optionalKey(QuitConfirmationMode),
962 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),977 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
963 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),978 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),