diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index e7770dc629dd..68ff5dbfef9b 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -1,5 +1,9 @@ import { it as effectIt } from "@effect/vitest"; -import { PreviewAutomationStatus } from "@t3tools/contracts"; +import { + DEFAULT_BROWSER_PROFILE_ID, + INCOGNITO_BROWSER_PROFILE_ID, + PreviewAutomationStatus, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -38,6 +42,44 @@ describe("preview IPC methods", () => { expect(fromPartition).not.toHaveBeenCalled(); }); + it("derives distinct partition scopes when identifiers contain the delimiter", () => { + const first = PreviewIpc.resolvePartitionScope("a", "b::c"); + const second = PreviewIpc.resolvePartitionScope("a::b", "c"); + + expect(first).toEqual({ scope: '["a","b::c"]', persistent: true, namespace: "profile" }); + expect(second).toEqual({ scope: '["a::b","c"]', persistent: true, namespace: "profile" }); + expect(first.scope).not.toBe(second.scope); + }); + + it("preserves lone surrogates without collapsing them to replacement characters", () => { + const highSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\ud800"); + const lowSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\udc00"); + const replacement = PreviewIpc.resolvePartitionScope("environment", "profile-�"); + + expect(highSurrogate.scope).toBe('["environment","profile-\\ud800"]'); + expect(lowSurrogate.scope).toBe('["environment","profile-\\udc00"]'); + expect(highSurrogate.scope).not.toBe(lowSurrogate.scope); + expect(highSurrogate.scope).not.toBe(replacement.scope); + expect(lowSurrogate.scope).not.toBe(replacement.scope); + }); + + it("keeps the legacy default partition scope and incognito persistence", () => { + expect(PreviewIpc.resolvePartitionScope("environment::legacy", undefined)).toEqual({ + scope: "environment::legacy", + persistent: true, + }); + expect( + PreviewIpc.resolvePartitionScope("environment::legacy", DEFAULT_BROWSER_PROFILE_ID), + ).toEqual({ scope: "environment::legacy", persistent: true }); + expect( + PreviewIpc.resolvePartitionScope("environment::legacy", INCOGNITO_BROWSER_PROFILE_ID), + ).toEqual({ + scope: '["environment::legacy","incognito"]', + persistent: false, + namespace: "profile", + }); + }); + effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => Effect.map( PreviewIpc.registerWebview diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 5229d36c31f1..8a77770deb1e 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -16,11 +16,14 @@ import { DesktopPreviewScreenshotArtifactSchema, DesktopPreviewSetAudioMutedInputSchema, DesktopPreviewSetColorSchemeInputSchema, + DesktopPreviewClearDataInputSchema, DesktopPreviewCreateTabInputSchema, DesktopPreviewTabInputSchema, DesktopPreviewWebviewConfigSchema, PreviewAnnotationSubmissionResultSchema, PreviewAutomationSnapshot, + DEFAULT_BROWSER_PROFILE_ID, + INCOGNITO_BROWSER_PROFILE_ID, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -196,33 +199,85 @@ export const closePictureInPicture = tabMethod( export const clearCookies = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, - payload: Schema.Void, + payload: DesktopPreviewClearDataInputSchema, result: Schema.Void, - handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () { + handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* ({ environmentId, profileId }) { const manager = yield* PreviewManager.PreviewManager; - yield* manager.clearCookies(); + yield* manager.clearCookies(yield* resolveClearPartitions(manager, environmentId, profileId)); }), }); export const clearCache = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, - payload: Schema.Void, + payload: DesktopPreviewClearDataInputSchema, result: Schema.Void, - handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () { + handler: Effect.fn("desktop.ipc.preview.clearCache")(function* ({ environmentId, profileId }) { const manager = yield* PreviewManager.PreviewManager; - yield* manager.clearCache(); + yield* manager.clearCache(yield* resolveClearPartitions(manager, environmentId, profileId)); }), }); +/** + * Partition scope for an (environment, profile) pair. + * + * The default profile keeps the bare environment id it used before profiles + * existed, so upgrading does not strand anyone's existing logins in an + * orphaned partition. Incognito derives a non-persistent partition. + */ +export function resolvePartitionScope( + environmentId: string, + profileId: string | undefined, +): { + readonly scope: string; + readonly persistent: boolean; + readonly namespace?: "profile"; +} { + if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) { + return { scope: environmentId, persistent: true }; + } + // JSON's tuple framing is injective for strings, including lone UTF-16 + // surrogates (which it escapes). URI encoding throws on those supported ids, + // while replacing them with U+FFFD would collapse distinct identities. + return { + scope: JSON.stringify([environmentId, profileId]), + persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID, + namespace: "profile" as const, + }; +} + +/** + * Clearing without a profile keeps the historical "everything" behaviour for + * an explicit all-profiles action; naming a profile confines it to that + * profile's partition so one profile's sign-out cannot reach the others. + */ +const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartitions")(function* ( + manager: PreviewManager.PreviewManager["Service"], + environmentId: string, + profileId: string | undefined, +) { + if (profileId === undefined) return undefined; + const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); + // Loading the session is what puts the partition in the map the clear walks. + // Deriving the partition string alone leaves nothing to match, so clearing a + // profile with no tab open this run — after a restart, or when deleting a + // profile — would report success and delete nothing. + yield* manager.getBrowserSession(scope, persistent, namespace); + return [yield* manager.getBrowserPartition(scope, persistent, namespace)]; +}); + export const getPreviewConfig = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, payload: DesktopPreviewConfigInputSchema, result: DesktopPreviewWebviewConfigSchema, - handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId }) { + handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId, profileId }) { const manager = yield* PreviewManager.PreviewManager; - yield* manager.getBrowserSession(environmentId); + const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); + // Creating the session first is what installs the UA rewrite and permission + // handlers; a guest that attached to an untouched partition would run with + // Electron's default UA and Chromium's default permission behaviour. + yield* manager.getBrowserSession(scope, persistent, namespace); return { - partition: yield* manager.getBrowserPartition(environmentId), + partition: yield* manager.getBrowserPartition(scope, persistent, namespace), webPreferences: PREVIEW_WEBVIEW_PREFERENCES, preloadUrl: NodeURL.pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, }; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3e181e2ca698..b91aa5624dc2 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -222,10 +222,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), - clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), - clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), - getPreviewConfig: (environmentId) => - ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }), + clearCookies: (environmentId, profileId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }), + clearCache: (environmentId, profileId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, { environmentId, profileId }), + getPreviewConfig: (environmentId, profileId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId, profileId }), setAnnotationTheme: (theme) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }), pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 50798de916e0..ff22f3dd2272 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -63,6 +63,45 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("keeps scopes that differ only by a lone surrogate in separate partitions", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + + // TextEncoder folds a lone surrogate to U+FFFD, so without escaping these + // two supported ids would hash to one partition and share every cookie. + const loneSurrogate = yield* browserSessions.getPartition("p\ud800"); + const replacementChar = yield* browserSessions.getPartition("p\ufffd"); + assert.notStrictEqual(loneSurrogate, replacementChar); + + // The escape can't be forged with a literal backslash either. + const literal = yield* browserSessions.getPartition("p\\ud800"); + assert.notStrictEqual(literal, loneSurrogate); + + // And a well-formed scope still lands on its historical partition. + assert.strictEqual( + yield* browserSessions.getPartition("scope-a"), + "persist:t3code-preview-f051bb2c68cb7b2fe969", + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("keeps legacy defaults disjoint from nondefault profile partitions", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + + // These share the same scope string: default environment `a::b`, and + // environment `a` with nondefault profile `b`. + const legacyDefault = yield* browserSessions.getPartition("a::b"); + const nondefaultProfile = yield* browserSessions.getPartition("a::b", true, "profile"); + + assert.strictEqual(legacyDefault, "persist:t3code-preview-78f0be89237d77f7a70e"); + assert.strictEqual(nondefaultProfile, "persist:t3code-preview-profile-78f0be89237d77f7a70e"); + assert.notStrictEqual(nondefaultProfile, legacyDefault); + assert.isTrue(browserSessions.isPartition(legacyDefault)); + assert.isTrue(browserSessions.isPartition(nondefaultProfile)); + }).pipe(Effect.provide(layer)), + ); + it.effect("grants clipboard-sanitized-write through both the request and check handlers", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; @@ -192,6 +231,28 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("clears a partition whose session has not been opened yet", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-untouched"); + + // Deriving the partition string does not create the session, and the + // clear only walks sessions it already holds. Without loading it first + // this reports success and deletes nothing — which is what a user + // clearing a profile after a restart would get. + assert.isUndefined(sessions.get(partition)); + yield* browserSessions.clearCookies([partition]); + assert.isUndefined(sessions.get(partition)); + + yield* browserSessions.getSession("scope-untouched"); + yield* browserSessions.clearCookies([partition]); + + const created = sessions.get(partition); + assert.isDefined(created); + assert.strictEqual(created.clearStorageData.mock.calls.length, 1); + }).pipe(Effect.provide(layer)), + ); + it.effect("correlates clear failures while still attempting every session", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 784afe019edf..7f3c9ec5d7ac 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -10,6 +10,16 @@ import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; +/** + * Incognito partitions deliberately omit the `persist:` prefix, which is what + * makes Chromium keep them in memory and discard them with the process. They + * still carry the product prefix so `isPartition` can admit them — the + * `will-attach-webview` gate rejects anything it does not recognise. + */ +const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-"; +const PROFILE_PARTITION_MARKER = "profile-"; + +export type BrowserSessionPartitionNamespace = "profile"; // Permissions granted to preview web content. `clipboard-sanitized-write` is the // Electron permission behind `navigator.clipboard.writeText()` — note it is NOT @@ -99,20 +109,68 @@ export class BrowserSession extends Context.Service< { readonly getPartition: ( scope?: string, + persistent?: boolean, + namespace?: BrowserSessionPartitionNamespace, ) => Effect.Effect; readonly isPartition: (partition: string) => boolean; - readonly getSession: (scope?: string) => Effect.Effect; - readonly clearCookies: () => Effect.Effect; - readonly clearCache: () => Effect.Effect; + readonly getSession: ( + scope?: string, + persistent?: boolean, + namespace?: BrowserSessionPartitionNamespace, + ) => Effect.Effect; + /** Omit `partitions` to clear every known partition. */ + readonly clearCookies: ( + partitions?: ReadonlyArray, + ) => Effect.Effect; + readonly clearCache: ( + partitions?: ReadonlyArray, + ) => Effect.Effect; } >()("@t3tools/desktop/preview/BrowserSession") {} +/** + * Restricts a clear to the given partitions. Omitting them keeps the historical + * "every partition" behaviour, which callers now only use for an explicit + * "all profiles" action — a per-profile clear must never reach across profiles. + */ +const selectSessions = ( + sessions: ReadonlyMap, + partitions: ReadonlyArray | undefined, +): ReadonlyArray => + [...sessions.entries()].filter( + ([partition]) => partitions === undefined || partitions.includes(partition), + ); + +/** + * Scope bytes for the partition digest. + * + * `TextEncoder` replaces a lone UTF-16 surrogate with U+FFFD, so `"p\ud800"` + * and `"p\ufffd"` would hash to the same partition and share cookies. Those + * are distinct, supported ids, so lone surrogates are escaped to `\uXXXX` + * first — and a literal backslash is doubled so the escape cannot be forged. + * Every well-formed scope passes through byte-for-byte unchanged, which keeps + * existing partitions (and the logins in them) where they are. + */ +const encodeScopeForDigest = (scope: string): Uint8Array => + new TextEncoder().encode( + scope + .replace(/\\/g, "\\\\") + .replace( + /[\ud800-\udbff](?![\udc00-\udfff])|(? `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`, + ), + ); + export const make = Effect.gen(function* BrowserSessionMake() { const crypto = yield* Crypto.Crypto; const sessionsRef = yield* SynchronizedRef.make>(new Map()); - const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") { - const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe( + const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( + scope = "shared", + persistent = true, + namespace?: BrowserSessionPartitionNamespace, + ) { + const digest = yield* crypto.digest("SHA-256", encodeScopeForDigest(scope)).pipe( Effect.mapError( (cause) => new BrowserSessionPartitionDerivationError({ @@ -121,11 +179,19 @@ export const make = Effect.gen(function* BrowserSessionMake() { }), ), ); - return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`; + const prefix = persistent ? PREVIEW_PARTITION_PREFIX : PREVIEW_EPHEMERAL_PARTITION_PREFIX; + // Legacy/default partitions are prefix + hex digest. The non-hex profile + // marker creates a disjoint namespace while leaving every legacy default + // partition byte-for-byte unchanged. + return `${prefix}${namespace === "profile" ? PROFILE_PARTITION_MARKER : ""}${Encoding.encodeHex(digest).slice(0, 20)}`; }); - const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") { - const partition = yield* getPartition(scope); + const getSession = Effect.fn("BrowserSession.getSession")(function* ( + scope = "shared", + persistent = true, + namespace?: BrowserSessionPartitionNamespace, + ) { + const partition = yield* getPartition(scope, persistent, namespace); return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => { const existing = sessions.get(partition); if (existing) return Effect.succeed([existing, sessions] as const); @@ -159,12 +225,14 @@ export const make = Effect.gen(function* BrowserSessionMake() { return BrowserSession.of({ getPartition, - isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX), + isPartition: (partition) => + partition.startsWith(PREVIEW_PARTITION_PREFIX) || + partition.startsWith(PREVIEW_EPHEMERAL_PARTITION_PREFIX), getSession, - clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { + clearCookies: Effect.fn("BrowserSession.clearCookies")(function* (partitions?) { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( - [...sessions.entries()].map(([partition, browserSession]) => + selectSessions(sessions, partitions).map(([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearStorageData({ @@ -180,10 +248,10 @@ export const make = Effect.gen(function* BrowserSessionMake() { { concurrency: "unbounded", discard: true }, ); }), - clearCache: Effect.fn("BrowserSession.clearCache")(function* () { + clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( - [...sessions.entries()].map(([partition, browserSession]) => + selectSessions(sessions, partitions).map(([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearCache(), catch: (cause) => diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 8ee312110d86..01398721dd58 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -4409,7 +4409,11 @@ export class PreviewManager extends Context.Service< PreviewManager, { readonly setMainWindow: (window: BrowserWindow) => Effect.Effect; - readonly getBrowserSession: (scope?: string) => Effect.Effect; + readonly getBrowserSession: ( + scope?: string, + persistent?: boolean, + namespace?: BrowserSession.BrowserSessionPartitionNamespace, + ) => Effect.Effect; readonly isBrowserPartition: (partition: string) => boolean; readonly createTab: ( tabId: string, @@ -4440,9 +4444,17 @@ export class PreviewManager extends Context.Service< audioMuted: boolean, ) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; - readonly clearCookies: () => Effect.Effect; - readonly clearCache: () => Effect.Effect; - readonly getBrowserPartition: (scope?: string) => Effect.Effect; + readonly clearCookies: ( + partitions?: ReadonlyArray, + ) => Effect.Effect; + readonly clearCache: ( + partitions?: ReadonlyArray, + ) => Effect.Effect; + readonly getBrowserPartition: ( + scope?: string, + persistent?: boolean, + namespace?: BrowserSession.BrowserSessionPartitionNamespace, + ) => Effect.Effect; readonly setAnnotationTheme: ( theme: DesktopPreviewAnnotationTheme, ) => Effect.Effect; @@ -4514,15 +4526,17 @@ export const make = Effect.gen(function* PreviewManagerMake() { return PreviewManager.of({ setMainWindow: operations.setMainWindow, - getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { - return yield* browserSession - .getSession(scope) - .pipe( - Effect.mapError( - (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), - ), - ); - }), + getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")( + function* (scope, persistent, namespace) { + return yield* browserSession + .getSession(scope, persistent, namespace) + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), + ), + ); + }, + ), isBrowserPartition: browserSession.isPartition, createTab: operations.createTab, closeTab: operations.closeTab, @@ -4539,31 +4553,33 @@ export const make = Effect.gen(function* PreviewManagerMake() { setColorScheme: operations.setColorScheme, setAudioMuted: operations.setAudioMuted, openDevTools: operations.openDevTools, - clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { + clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) { yield* browserSession - .clearCookies() + .clearCookies(partitions) .pipe( Effect.mapError( (cause) => new PreviewOperationError({ operation: "clearCookies", cause }), ), ); }), - clearCache: Effect.fn("PreviewManager.clearCache")(function* () { + clearCache: Effect.fn("PreviewManager.clearCache")(function* (partitions) { yield* browserSession - .clearCache() + .clearCache(partitions) .pipe( Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })), ); }), - getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) { - return yield* browserSession - .getPartition(scope) - .pipe( - Effect.mapError( - (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }), - ), - ); - }), + getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")( + function* (scope, persistent, namespace) { + return yield* browserSession + .getPartition(scope, persistent, namespace) + .pipe( + Effect.mapError( + (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }), + ), + ); + }, + ), setAnnotationTheme: operations.setAnnotationTheme, pickElement: operations.pickElement, cancelPickElement: operations.cancelPickElement, diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 95c5cc022c6d..4766b7a3439c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,6 +19,8 @@ const clientSettings: ClientSettings = { browserDefaultAppearance: "dark", browserRecordingFrameRate: 60, browserAutoShowFloatingPreview: false, + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + browserDefaultProfileId: "work", confirmQuit: "double-click", confirmThreadArchive: true, confirmThreadDelete: false, diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 8b3dabfa3386..d1fc142502db 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -58,6 +58,37 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { }), ); + it.effect("keeps the tab's profile across navigation and status reports", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + + const opened = yield* manager.open({ threadId, profileId: "work" }); + expect(opened.profileId).toBe("work"); + + // `navigate` and `reportStatus` rebuild the snapshot field by field + // rather than spreading it, so a new field is dropped unless carried + // explicitly — which would silently move the tab to another profile's + // partition on its first navigation. + const navigated = yield* manager.navigate({ + threadId, + tabId: opened.tabId, + url: "localhost:5173", + }); + expect(navigated.profileId).toBe("work"); + + yield* manager.reportStatus({ + threadId, + tabId: opened.tabId, + navStatus: { _tag: "Success", url: "http://localhost:5173/", title: "Dev" }, + canGoBack: true, + canGoForward: false, + }); + const listed = yield* manager.list({ threadId }); + expect(listed.sessions.find((s) => s.tabId === opened.tabId)?.profileId).toBe("work"); + }), + ); + it.effect("opens an Idle tab when no URL is supplied", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 09bbe0a41c76..a5b1f4da8db0 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -123,6 +123,7 @@ const buildLoadingSnapshot = (input: { readonly url: string; readonly title: string; readonly viewport: PreviewViewportSetting; + readonly profileId?: string | undefined; readonly updatedAt: string; }): PreviewSessionSnapshot => ({ threadId: input.threadId, @@ -131,6 +132,7 @@ const buildLoadingSnapshot = (input: { canGoBack: false, canGoForward: false, viewport: input.viewport, + ...(input.profileId === undefined ? {} : { profileId: input.profileId }), updatedAt: input.updatedAt, }); @@ -138,6 +140,7 @@ const buildIdleSnapshot = (input: { readonly threadId: string; readonly tabId: string; readonly viewport: PreviewViewportSetting; + readonly profileId?: string | undefined; readonly updatedAt: string; }): PreviewSessionSnapshot => ({ threadId: input.threadId, @@ -146,6 +149,7 @@ const buildIdleSnapshot = (input: { canGoBack: false, canGoForward: false, viewport: input.viewport, + ...(input.profileId === undefined ? {} : { profileId: input.profileId }), updatedAt: input.updatedAt, }); @@ -229,9 +233,16 @@ export const make = Effect.gen(function* PreviewManagerMake() { url: yield* normalizeUrl(input.url), title: "", viewport, + profileId: input.profileId, updatedAt, }) - : buildIdleSnapshot({ threadId: input.threadId, tabId, viewport, updatedAt }); + : buildIdleSnapshot({ + threadId: input.threadId, + tabId, + viewport, + profileId: input.profileId, + updatedAt, + }); yield* SynchronizedRef.modifyEffect(stateRef, (state) => Effect.gen(function* () { const revision = state.revision + 1; @@ -275,6 +286,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { canGoBack: session.snapshot.canGoBack, canGoForward: session.snapshot.canGoForward, viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, + ...(session.snapshot.profileId === undefined + ? {} + : { profileId: session.snapshot.profileId }), updatedAt, }; return { @@ -308,6 +322,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { canGoBack: input.canGoBack, canGoForward: input.canGoForward, viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, + ...(session.snapshot.profileId === undefined + ? {} + : { profileId: session.snapshot.profileId }), updatedAt, }; const emit: PreviewEventDraft = diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 5425bca0b4bc..de7e23603298 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -93,6 +93,7 @@ export function ElectronBrowserHost() { initialUrl={url} viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT} pictureInPicture={pictureInPicture} + profileId={snapshot.profileId} zoomFactor={zoomFactor} /> ); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 77c65264aa94..564a2453b2be 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -49,11 +49,24 @@ export function HostedBrowserWebview(props: { readonly initialUrl: string | null; readonly viewport: PreviewViewportSetting; readonly pictureInPicture: boolean; + /** + * Fixed for the tab's lifetime: Electron only honours `partition` before the + * guest attaches, so a live change here would not move the tab anyway. + */ + readonly profileId: string | undefined; readonly zoomFactor: number; }) { - const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor } = - props; - const config = usePreviewWebviewConfig(threadRef.environmentId); + const { + threadRef, + tabId, + runtimeTabId, + initialUrl, + viewport, + pictureInPicture, + zoomFactor, + profileId, + } = props; + const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); const wrapperRef = useRef(null); diff --git a/apps/web/src/browser/browserDefaults.test.ts b/apps/web/src/browser/browserDefaults.test.ts new file mode 100644 index 000000000000..bac9600c182b --- /dev/null +++ b/apps/web/src/browser/browserDefaults.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; + +const settings = vi.hoisted(() => ({ current: {} as Record })); + +vi.mock("~/hooks/useSettings", () => ({ + getClientSettings: () => settings.current, + useClientSettings: () => undefined, + ensureClientSettingsHydrated: () => Promise.resolve(), +})); + +const { getBrowserDefaults } = await import("./browserDefaults"); + +const withDefaultProfile = (browserDefaultProfileId: string) => { + settings.current = { + browserDefaultViewport: { _tag: "fill" }, + browserDefaultZoomFactor: 1, + browserDefaultAppearance: "system", + browserAutoShowFloatingPreview: true, + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + browserDefaultProfileId, + }; + return getBrowserDefaults(); +}; + +describe("getBrowserDefaults profile resolution", () => { + it("keeps a configured persistent profile", () => { + expect(withDefaultProfile("work").profileId).toBe("work"); + }); + + it("falls back for an unknown profile", () => { + expect(withDefaultProfile("deleted").profileId).toBe(DEFAULT_BROWSER_PROFILE_ID); + }); + + it("refuses incognito as the default", () => { + // A stored incognito default would open every new tab into storage that is + // discarded on close, and the settings list no longer offers it — so the + // row badged "Default" must be the one tabs actually open under. + expect(withDefaultProfile(INCOGNITO_BROWSER_PROFILE_ID).profileId).toBe( + DEFAULT_BROWSER_PROFILE_ID, + ); + }); +}); diff --git a/apps/web/src/browser/browserDefaults.ts b/apps/web/src/browser/browserDefaults.ts index da8bf6a65826..eaae409568a2 100644 --- a/apps/web/src/browser/browserDefaults.ts +++ b/apps/web/src/browser/browserDefaults.ts @@ -13,10 +13,13 @@ * * @module browserDefaults */ -import type { - DesktopPreviewTabDefaults, - PreviewAppearancePreference, - PreviewViewportSetting, +import { + DEFAULT_BROWSER_PROFILE_ID, + resolveBrowserProfiles, + type BrowserProfile, + type DesktopPreviewTabDefaults, + type PreviewAppearancePreference, + type PreviewViewportSetting, } from "@t3tools/contracts"; import { @@ -32,6 +35,8 @@ export interface BrowserDefaults { readonly zoomFactor: number; readonly appearance: PreviewAppearancePreference; readonly autoShowFloatingPreview: boolean; + readonly profiles: ReadonlyArray; + readonly profileId: string; } const toBrowserDefaults = (settings: { @@ -39,12 +44,29 @@ const toBrowserDefaults = (settings: { readonly browserDefaultZoomFactor: number; readonly browserDefaultAppearance: PreviewAppearancePreference; readonly browserAutoShowFloatingPreview: boolean; -}): BrowserDefaults => ({ - viewport: settings.browserDefaultViewport, - zoomFactor: settings.browserDefaultZoomFactor, - appearance: settings.browserDefaultAppearance, - autoShowFloatingPreview: settings.browserAutoShowFloatingPreview, -}); + readonly browserProfiles: ReadonlyArray; + readonly browserDefaultProfileId: string; +}): BrowserDefaults => { + const profiles = resolveBrowserProfiles(settings.browserProfiles); + return { + viewport: settings.browserDefaultViewport, + zoomFactor: settings.browserDefaultZoomFactor, + appearance: settings.browserDefaultAppearance, + autoShowFloatingPreview: settings.browserAutoShowFloatingPreview, + profiles, + // A default pointing at a deleted profile falls back rather than opening + // tabs into a partition with no profile behind it. + // Incognito is a per-tab choice, not a default: a profile that discards + // everything on close would leave every new tab signed out. Excluding it + // here keeps the resolved default equal to what the settings list offers, + // so the row badged "Default" is the one tabs actually open under. + profileId: + profiles.find( + (profile) => + profile.id === settings.browserDefaultProfileId && profile.kind !== "incognito", + )?.id ?? DEFAULT_BROWSER_PROFILE_ID, + }; +}; /** Non-hook accessor for imperative open paths (menu actions, automation hosts). */ export function getBrowserDefaults(): BrowserDefaults { @@ -89,6 +111,13 @@ export function browserDefaultOpenViewport( return defaults.viewport; } +/** Profile a tab opens under when the caller doesn't name one. */ +export function browserDefaultOpenProfileId( + defaults: BrowserDefaults = getBrowserDefaults(), +): string { + return defaults.profileId; +} + /** * The viewport to switch to when the user turns the device toolbar on for a tab * currently in fill mode. diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 4e540a9a0963..f506e42e73e5 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -23,6 +23,12 @@ import { } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "./browserDefaults"; + export const isBrowserPreviewFile = (path: string): boolean => /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); @@ -42,9 +48,18 @@ export async function openUrlInPreview(input: { readonly url: string; readonly openPreview: OpenPreviewMutation; }): Promise> { + const defaults = await resolveBrowserDefaults(); const result = await input.openPreview({ environmentId: input.threadRef.environmentId, - input: { threadId: input.threadRef.threadId, url: input.url }, + input: { + threadId: input.threadRef.threadId, + url: input.url, + // Built here rather than via `openPreviewSession` because this path + // maps the result differently, so the configured defaults have to be + // applied explicitly or file/link opens would ignore them. + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), + }, }); return mapAtomCommandResult(result, (snapshot) => { applyPreviewServerSnapshot(input.threadRef, snapshot); diff --git a/apps/web/src/browser/previewWebviewConfigState.test.ts b/apps/web/src/browser/previewWebviewConfigState.test.ts index 35eb665eb7e3..9ce113dce981 100644 --- a/apps/web/src/browser/previewWebviewConfigState.test.ts +++ b/apps/web/src/browser/previewWebviewConfigState.test.ts @@ -13,7 +13,9 @@ const environmentId = EnvironmentId.make("environment-1"); describe("loadPreviewWebviewConfig", () => { it.effect("reports a structurally distinct missing-bridge failure", () => Effect.gen(function* () { - const error = yield* loadPreviewWebviewConfig(environmentId, null).pipe(Effect.flip); + const error = yield* loadPreviewWebviewConfig(environmentId, undefined, null).pipe( + Effect.flip, + ); expect(error).toBeInstanceOf(PreviewWebviewBridgeUnavailableError); expect(error.environmentId).toBe(environmentId); @@ -25,7 +27,7 @@ describe("loadPreviewWebviewConfig", () => { it.effect("preserves the bridge rejection as the load failure cause", () => Effect.gen(function* () { const cause = new Error("ipc unavailable"); - const error = yield* loadPreviewWebviewConfig(environmentId, { + const error = yield* loadPreviewWebviewConfig(environmentId, undefined, { getPreviewConfig: () => Promise.reject(cause), }).pipe(Effect.flip); @@ -36,22 +38,23 @@ describe("loadPreviewWebviewConfig", () => { }), ); - it.effect("forwards the environment id to the bridge", () => + it.effect("forwards the environment id and profile to the bridge", () => Effect.gen(function* () { - let requestedEnvironmentId: EnvironmentId | null = null; + let requested: { environmentId: EnvironmentId; profileId: string | undefined } | null = null; const config = { partition: "persist:test-preview", webPreferences: "sandbox=yes", preloadUrl: null, }; - const result = yield* loadPreviewWebviewConfig(environmentId, { - getPreviewConfig: (input) => { - requestedEnvironmentId = input; + const result = yield* loadPreviewWebviewConfig(environmentId, "work", { + getPreviewConfig: (requestedEnvironmentId, profileId) => { + requested = { environmentId: requestedEnvironmentId, profileId }; return Promise.resolve(config); }, }); - expect(requestedEnvironmentId).toBe(environmentId); + // The partition is derived in main from both, so both have to arrive. + expect(requested).toEqual({ environmentId, profileId: "work" }); expect(result).toEqual(config); }), ); diff --git a/apps/web/src/browser/previewWebviewConfigState.ts b/apps/web/src/browser/previewWebviewConfigState.ts index 6f1cf058e38c..6decff578248 100644 --- a/apps/web/src/browser/previewWebviewConfigState.ts +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -45,6 +45,7 @@ type PreviewConfigBridge = Pick; export const loadPreviewWebviewConfig = ( environmentId: EnvironmentId, + profileId?: string, bridge: PreviewConfigBridge | null = previewBridge, ): Effect.Effect => { if (bridge === null) { @@ -52,25 +53,52 @@ export const loadPreviewWebviewConfig = ( } return Effect.tryPromise({ - try: () => bridge.getPreviewConfig(environmentId), + try: () => bridge.getPreviewConfig(environmentId, profileId), catch: (cause) => new PreviewWebviewConfigLoadError({ environmentId, cause }), }); }; -const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make(loadPreviewWebviewConfig(environmentId)).pipe( +/** + * `Atom.family` keys on its argument, so the environment and profile are + * folded into one string: passing an object would allocate a fresh entry on + * every render. + * + * The profile is the tail rather than a second field, so an id containing the + * delimiter round-trips whole instead of being truncated into a different + * profile's key. `BrowserProfileId` rejects control characters, which is what + * makes the environment side of the split unambiguous. + */ +const CONFIG_KEY_DELIMITER = "\u0000"; + +const configKey = (environmentId: EnvironmentId, profileId: string | undefined): string => + `${environmentId}${CONFIG_KEY_DELIMITER}${profileId ?? ""}`; + +const parseConfigKey = (key: string): { environmentId: EnvironmentId; profileId?: string } => { + const delimiter = key.indexOf(CONFIG_KEY_DELIMITER); + const environmentId = (delimiter === -1 ? key : key.slice(0, delimiter)) as EnvironmentId; + const profileId = delimiter === -1 ? "" : key.slice(delimiter + CONFIG_KEY_DELIMITER.length); + return { + environmentId, + ...(profileId === "" ? {} : { profileId }), + }; +}; + +const previewWebviewConfigAtom = Atom.family((key: string) => { + const { environmentId, profileId } = parseConfigKey(key); + return Atom.make(loadPreviewWebviewConfig(environmentId, profileId)).pipe( Atom.swr({ staleTime: PREVIEW_CONFIG_STALE_TIME_MS, revalidateOnMount: true, }), Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS), - Atom.withLabel(`preview:webview-config:${environmentId}`), - ), -); + Atom.withLabel(`preview:webview-config:${key}`), + ); +}); export function usePreviewWebviewConfig( environmentId: EnvironmentId, + profileId?: string, ): DesktopPreviewWebviewConfig | null { - const result = useAtomValue(previewWebviewConfigAtom(environmentId)); + const result = useAtomValue(previewWebviewConfigAtom(configKey(environmentId, profileId))); return Option.getOrNull(AsyncResult.value(result)); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 305773955d0e..bee12b522944 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3608,10 +3608,17 @@ function ChatViewContent(props: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); - const createBrowserSurface = useCallback(() => { - if (!activeThreadRef) return; - void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); - }, [activeThreadRef, openPreview]); + const createBrowserSurface = useCallback( + (profileId?: string) => { + if (!activeThreadRef) return; + void addBrowserSurface({ + threadRef: activeThreadRef, + openPreview, + ...(profileId === undefined ? {} : { profileId }), + }); + }, + [activeThreadRef, openPreview], + ); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; useRightPanelStore.getState().open(activeThreadRef, "diff"); @@ -7664,7 +7671,8 @@ function ChatViewContent(props: ChatViewProps) { onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={createBrowserSurface} + onAddBrowser={() => createBrowserSurface()} + onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} @@ -7704,7 +7712,8 @@ function ChatViewContent(props: ChatViewProps) { onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={createBrowserSurface} + onAddBrowser={() => createBrowserSurface()} + onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 7b0ae9b4c201..ebfda100b533 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -4,11 +4,20 @@ import { describe, expect, it } from "vite-plus/test"; import { RightPanelTabs, + shouldOpenDefaultBrowserProfileFromMenuClick, surfaceShortcutActionForKey, surfaceShortcutTargetsTypingContext, tabMuteMenuItem, } from "./RightPanelTabs"; +describe("browser profile submenu", () => { + it("reserves touch clicks for opening the choices while mouse clicks use the default", () => { + expect(shouldOpenDefaultBrowserProfileFromMenuClick("touch")).toBe(false); + expect(shouldOpenDefaultBrowserProfileFromMenuClick("mouse")).toBe(true); + expect(shouldOpenDefaultBrowserProfileFromMenuClick(undefined)).toBe(true); + }); +}); + function shortcutEvent( key: string, overrides: Partial[1]> = {}, @@ -104,6 +113,7 @@ function renderTabs( onCloseAllSurfaces={() => undefined} onCopyFilePath={() => undefined} onAddBrowser={() => undefined} + onAddBrowserInProfile={() => undefined} onAddTerminal={() => undefined} onAddPullRequest={() => undefined} onAddDiff={() => undefined} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 40db79e80f56..c48dceb048a6 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -30,7 +30,17 @@ import { readLocalApi } from "~/localApi"; import { Button } from "~/components/ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Kbd } from "~/components/ui/kbd"; -import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "~/components/ui/menu"; +import { + Menu, + MenuItem, + MenuPopup, + MenuShortcut, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuTrigger, +} from "~/components/ui/menu"; +import { useBrowserDefaults } from "~/browser/browserDefaults"; import { ScrollArea } from "~/components/ui/scroll-area"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; import { faviconUrlForOrigin } from "~/lib/favicon"; @@ -69,6 +79,12 @@ interface RightPanelTabsProps { onCloseAllSurfaces: () => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; + /** + * Separate from `onAddBrowser` on purpose: that one is passed directly as a + * DOM click handler, and a `(profileId?: string)` signature would silently + * accept the MouseEvent as a profile id. + */ + onAddBrowserInProfile: (profileId: string) => void; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; @@ -94,6 +110,12 @@ export interface PullRequestTabStatus { isDraft: boolean; } +export function shouldOpenDefaultBrowserProfileFromMenuClick( + pointerType: string | undefined, +): boolean { + return pointerType !== "touch"; +} + const SURFACE_DISABLED_REASONS = { browser: "Browser previews are only available in the T3 Code desktop app.", terminal: "Terminal surfaces are only available from a project thread.", @@ -600,6 +622,7 @@ function SurfaceIcon({ export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; + const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); @@ -911,6 +934,55 @@ export function RightPanelTabs(props: RightPanelTabsProps) { > {addSurfaceActions.map((action) => { const Icon = action.icon; + // Browser collapses into one row: clicking the trigger opens + // the default profile (the common case stays one click), + // while hover or arrow reveals the profiles. The choice + // lives at open time because a tab's profile is fixed then — + // Electron only honours a partition before attach. + if (action.label === "Browser" && action.available) { + return ( + + { + const pointerType = + "pointerType" in event.nativeEvent && + typeof event.nativeEvent.pointerType === "string" + ? event.nativeEvent.pointerType + : undefined; + // Touch has no hover path to the profile choices: + // its first tap opens the submenu, then a profile + // is selected there. Mouse click keeps the common + // default-profile action at one click. + if (!shouldOpenDefaultBrowserProfileFromMenuClick(pointerType)) + return; + setAddSurfaceMenuOpen(false); + action.onClick(); + }} + > + + {action.label} + {action.shortcut} + + {/* + Capped and truncated: profile names are user-supplied + and run to 48 characters, which would otherwise widen + the popup to fit-content and wrap. + */} + + {browserProfiles.map((profile) => ( + props.onAddBrowserInProfile(profile.id)} + > + {profile.name} + + ))} + + + ); + } return ( {}; @@ -85,6 +90,7 @@ export function PreviewChromeRow({ pickDisabled, pickDisabledReason, trailingActions, + leadingActions, }: Props) { const inputRef = useRef(null); const [draft, setDraft] = useState(url); @@ -166,6 +172,8 @@ export function PreviewChromeRow({ + {leadingActions} + void; + /** Environment the tab belongs to; scopes storage clearing to its partitions. */ + environmentId: EnvironmentId; + /** Profile the tab was opened under, if the server recorded one. */ + /** + * Required: the IPC layer reads an absent profile as "every profile", so a + * tab whose own profile is unknown must resolve the default before it gets + * here rather than passing the gap along. + */ + profileId: string; + /** Profile display name, shown so the menu says which data is being cleared. */ + profileName: string | undefined; } /** @@ -66,6 +79,9 @@ export function PreviewMoreMenu({ onToggleDeviceToolbar, nativePictureInPicture, onNativePictureInPicture, + environmentId, + profileId, + profileName, }: Props) { if (!previewBridge) return null; const bridge = previewBridge; @@ -177,12 +193,37 @@ export function PreviewMoreMenu({ - void bridge.clearCookies().catch(() => undefined)}> - Clear cookies - - void bridge.clearCache().catch(() => undefined)}> - Clear cache - + {/* + Grouped so the heading has a `MenuGroup` ancestor — `MenuGroupLabel` + reads its context and throws without one. The heading also answers + which profile the tab is in, which is otherwise invisible: it is fixed + at open and nothing else in the chrome shows it. + */} + + {/* + The heading carries the profile so the actions below can keep + fixed-length labels: repeating a name of up to 48 characters in + each one drove the popup far past its width. + */} + {profileName ? ( + // Truncation sits on the label itself: it renders a block box, so + // `text-overflow` on an inline child inside it never applies and a + // long name would push the popup past its width instead. + Profile: {profileName} + ) : null} + + void bridge.clearCookies(environmentId, profileId).catch(() => undefined) + } + > + Clear cookies + + void bridge.clearCache(environmentId, profileId).catch(() => undefined)} + > + Clear cache + + ); diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index fd6ac25ceced..808842044e92 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -1,4 +1,6 @@ import { + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, EnvironmentId, @@ -37,6 +39,15 @@ const mocks = vi.hoisted(() => ({ const EMPTY_HISTORY: never[] = []; +const STUB_BROWSER_DEFAULTS = { + viewport: FILL_PREVIEW_VIEWPORT, + zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, + appearance: DEFAULT_PREVIEW_APPEARANCE, + autoShowFloatingPreview: true, + profiles: BUILT_IN_BROWSER_PROFILES, + profileId: DEFAULT_BROWSER_PROFILE_ID, +}; + vi.mock("~/browserHistoryStore", () => ({ recordVisitForThread: mocks.recordVisitForThread, setTitleForThreadUrl: vi.fn(), @@ -53,19 +64,10 @@ vi.mock("~/state/session", () => ({ // `useSettings` -> `state/server`, which would drag the whole settings and // connection graph into a test that only cares about the browser chrome. vi.mock("~/browser/browserDefaults", () => ({ - useBrowserDefaults: () => ({ - viewport: FILL_PREVIEW_VIEWPORT, - zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, - appearance: DEFAULT_PREVIEW_APPEARANCE, - autoShowFloatingPreview: true, - }), - getBrowserDefaults: () => ({ - viewport: FILL_PREVIEW_VIEWPORT, - zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, - appearance: DEFAULT_PREVIEW_APPEARANCE, - autoShowFloatingPreview: true, - }), + useBrowserDefaults: () => STUB_BROWSER_DEFAULTS, + getBrowserDefaults: () => STUB_BROWSER_DEFAULTS, browserDefaultOpenViewport: () => FILL_PREVIEW_VIEWPORT, + browserDefaultOpenProfileId: () => DEFAULT_BROWSER_PROFILE_ID, browserDefaultTabState: () => ({ zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, colorScheme: DEFAULT_PREVIEW_APPEARANCE, @@ -249,7 +251,7 @@ vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); -import { PreviewView } from "./PreviewView"; +import { PreviewView, previewProfileName } from "./PreviewView"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; const TEST_THREAD_REF = { @@ -347,6 +349,12 @@ describe("PreviewView navigation", () => { mocks.recordVisitForThread.mockClear(); }); + it("labels a tab whose saved profile was removed", () => { + expect(previewProfileName(BUILT_IN_BROWSER_PROFILES, "profile-removed")).toBe( + "Removed profile", + ); + }); + it("does not rerender while loading time passes", async () => { vi.useFakeTimers(); mocks.loading = true; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 063314863fec..6d431a48e3db 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -3,6 +3,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { + DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, type PreviewAnnotationPayload, type PreviewViewportSetting, @@ -48,6 +49,7 @@ import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; +import { Badge } from "~/components/ui/badge"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import { usePreviewSession } from "./usePreviewSession"; @@ -61,6 +63,7 @@ import { useActiveBrowserRecordingTabIds, } from "~/browser/browserRecording"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; interface Props { threadRef: ScopedThreadRef; @@ -73,6 +76,13 @@ interface Props { ) => void; } +export function previewProfileName( + profiles: ReadonlyArray<{ readonly id: string; readonly name: string }>, + profileId: string, +): string { + return profiles.find((profile) => profile.id === profileId)?.name ?? "Removed profile"; +} + const localApi = typeof window === "undefined" ? null : ensureLocalApi(); /** @@ -144,6 +154,14 @@ export function PreviewView({ const controller = desktopOverlay?.controller ?? "none"; const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; const browserDefaults = useBrowserDefaults(); + // A tab created before profiles existed carries no profile of its own. It + // runs in the built-in `default` partition — the scope the browser used + // before profiles — not in whatever profile is configured as the default + // now, so that is what its label names and its clear actions target. + // Passing the snapshot's raw `undefined` through would reach the IPC layer + // as "every profile". + const activeProfileId = snapshot?.profileId ?? DEFAULT_BROWSER_PROFILE_ID; + const activeProfileName = previewProfileName(browserDefaults.profiles, activeProfileId); const panelRect = useBrowserSurfaceStore((state) => runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, ); @@ -686,9 +704,32 @@ export function PreviewView({ pickDisabledReason={ isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined } + leadingActions={ + // Only when it differs from the default: labelling every tab + // "Default" would be noise on the common case, while a tab in + // another profile is exactly what needs calling out. + activeProfileId !== browserDefaults.profileId ? ( + // Capped: profile names run to 48 characters, and an unbounded + // badge in this row takes its width from the URL input, the only + // flexible element in the compact chrome. The cap sits on the + // badge and the truncation on an inner span, because `Badge` is an + // `inline-flex` with `whitespace-nowrap` — `text-overflow` never + // reaches a bare text node inside it, so the name would be cut off + // at both ends with no ellipsis. + + }> + {activeProfileName} + + {activeProfileName} + + ) : null + } trailingActions={ previewBridge ? ( { }); describe("addBrowserSurface", () => { + it("opens under the requested profile", async () => { + const openPreview = vi.fn(async (_input: PreviewOpenInput) => + AsyncResult.success(snapshot("tab-1")), + ); + + await addBrowserSurface({ + threadRef, + openPreview: ({ input }) => openPreview(input), + profileId: "profile-work", + }); + + expect(openPreview).toHaveBeenCalledWith({ + threadId: "thread-1", + viewport: FILL_PREVIEW_VIEWPORT, + profileId: "profile-work", + }); + }); + it("creates another preview session when a browser tab is already active", async () => { const first = snapshot("tab-1"); const second = snapshot("tab-2"); @@ -48,6 +67,7 @@ describe("addBrowserSurface", () => { expect(openPreview).toHaveBeenCalledWith({ threadId: "thread-1", viewport: FILL_PREVIEW_VIEWPORT, + profileId: DEFAULT_BROWSER_PROFILE_ID, }); expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]); expect( diff --git a/apps/web/src/components/preview/addBrowserSurface.ts b/apps/web/src/components/preview/addBrowserSurface.ts index 4eecac695cea..622cdbec2f1c 100644 --- a/apps/web/src/components/preview/addBrowserSurface.ts +++ b/apps/web/src/components/preview/addBrowserSurface.ts @@ -13,10 +13,13 @@ import { openPreviewSession } from "./openPreviewSession"; export async function addBrowserSurface(input: { readonly threadRef: ScopedThreadRef; readonly openPreview: OpenPreviewMutation; + /** Omit to use the configured default profile. */ + readonly profileId?: string | undefined; }): Promise> { const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, + ...(input.profileId === undefined ? {} : { profileId: input.profileId }), }); return mapAtomCommandResult(result, (snapshot) => { useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index 138c3dd368cb..ef3d51a9e7fa 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -1,4 +1,5 @@ import { + DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -49,6 +50,7 @@ describe("openPreviewSession", () => { expect(open).toHaveBeenCalledWith({ threadId: "thread-1", viewport: FILL_PREVIEW_VIEWPORT, + profileId: DEFAULT_BROWSER_PROFILE_ID, }); expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); @@ -67,6 +69,7 @@ describe("openPreviewSession", () => { threadId: "thread-1", url: "t3.chat", viewport: FILL_PREVIEW_VIEWPORT, + profileId: DEFAULT_BROWSER_PROFILE_ID, }); expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]); diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index 1a3ceabad3ad..deb5465ebc28 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -7,7 +7,11 @@ import type { } from "@t3tools/contracts"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; -import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "~/browser/browserDefaults"; import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; interface OpenPreviewSessionInput { @@ -19,17 +23,23 @@ interface OpenPreviewSessionInput { url?: string; /** Overrides the configured default; automation passes an explicit size. */ viewport?: PreviewViewportSetting; + /** Overrides the configured default profile. */ + profileId?: string; } export async function openPreviewSession( input: OpenPreviewSessionInput, ): Promise> { + // Resolved once: a tab opened before client settings hydrate would otherwise + // be born at the schema defaults and never corrected. + const defaults = await resolveBrowserDefaults(); const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { threadId: input.threadRef.threadId, ...(input.url === undefined ? {} : { url: input.url }), - viewport: input.viewport ?? browserDefaultOpenViewport(await resolveBrowserDefaults()), + viewport: input.viewport ?? browserDefaultOpenViewport(defaults), + profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 47f03761f6bb..9a5656d76a1c 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -1,7 +1,7 @@ import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { openTerminalLinkInPreview, @@ -20,6 +20,21 @@ vi.mock("~/rightPanelStore", () => ({ }, })); +const browserDefaultsMocks = vi.hoisted(() => ({ + resolve: vi.fn(), +})); + +vi.mock("~/browser/browserDefaults", () => ({ + resolveBrowserDefaults: browserDefaultsMocks.resolve, + browserDefaultOpenViewport: (defaults: { viewport: unknown }) => defaults.viewport, + browserDefaultOpenProfileId: (defaults: { profileId: string }) => defaults.profileId, +})); + +const hydratedDefaults = { + viewport: { _tag: "fixed", width: 1280, height: 720 } as const, + profileId: "work", +}; + const threadRef = { environmentId: "local" as ScopedThreadRef["environmentId"], threadId: "thread-1" as ScopedThreadRef["threadId"], @@ -34,11 +49,54 @@ const snapshot: PreviewSessionSnapshot = { updatedAt: "2026-06-20T00:00:00.000Z", }; +beforeEach(() => { + browserDefaultsMocks.resolve.mockResolvedValue(hydratedDefaults); +}); + afterEach(() => { vi.restoreAllMocks(); }); describe("openTerminalLinkInPreview", () => { + it("waits for hydrated viewport and profile defaults before opening", async () => { + let hydrate: ((defaults: typeof hydratedDefaults) => void) | undefined; + browserDefaultsMocks.resolve.mockImplementationOnce( + () => + new Promise((resolve) => { + hydrate = resolve; + }), + ); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + const opening = openTerminalLinkInPreview({ + url: "http://localhost:3000/", + position: { x: 12, y: 34 }, + threadRef, + openPreview, + localApi: { + contextMenu: { + show: vi.fn(async () => "open-in-preview"), + }, + } as unknown as LocalApi, + fallbackToBrowser: vi.fn(), + }); + + await vi.waitFor(() => expect(browserDefaultsMocks.resolve).toHaveBeenCalledOnce()); + expect(openPreview).not.toHaveBeenCalled(); + hydrate?.(hydratedDefaults); + await opening; + + expect(openPreview).toHaveBeenCalledWith({ + environmentId: "local", + input: { + threadId: "thread-1", + url: "http://localhost:3000/", + viewport: hydratedDefaults.viewport, + profileId: hydratedDefaults.profileId, + }, + }); + }); + it("preserves context-menu failures with terminal link context before falling back", async () => { const cause = new Error("menu unavailable"); const fallbackToBrowser = vi.fn(); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index f4e0373a73c3..f5725fc2acfa 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -3,6 +3,11 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime" import { isPreviewableUrl } from "@t3tools/shared/preview"; import * as Schema from "effect/Schema"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "~/browser/browserDefaults"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; import { recordVisitForThread } from "~/browserHistoryStore"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -82,9 +87,17 @@ export async function openTerminalLinkInPreview( } if (choice === "open-in-preview") { + const defaults = await resolveBrowserDefaults(); const result = await input.openPreview({ environmentId: input.threadRef.environmentId, - input: { threadId: input.threadRef.threadId, url: input.url }, + input: { + threadId: input.threadRef.threadId, + url: input.url, + // Same reason as `openUrlInPreview`: this path handles its own result + // mapping, so the configured defaults are applied explicitly. + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), + }, }); if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) { diff --git a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts new file mode 100644 index 000000000000..26eef9536b6f --- /dev/null +++ b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { browserProfileRemovalAvailable, clearBrowserProfileData } from "./IntegrationsSettings"; + +const environmentId = "environment-a" as EnvironmentId; +const secondEnvironmentId = "environment-b" as EnvironmentId; + +describe("clearBrowserProfileData", () => { + it("waits for cookie and cache cleanup", async () => { + const clearCookies = vi.fn().mockResolvedValue(undefined); + const clearCache = vi.fn().mockResolvedValue(undefined); + + await clearBrowserProfileData({ clearCookies, clearCache }, [environmentId], "profile-a"); + + expect(clearCookies).toHaveBeenCalledWith(environmentId, "profile-a"); + expect(clearCache).toHaveBeenCalledWith(environmentId, "profile-a"); + }); + + it("clears every known environment before succeeding", async () => { + const clearCookies = vi.fn().mockResolvedValue(undefined); + const clearCache = vi.fn().mockResolvedValue(undefined); + + await clearBrowserProfileData( + { clearCookies, clearCache }, + [environmentId, secondEnvironmentId], + "profile-a", + ); + + expect(clearCookies.mock.calls).toEqual([ + [environmentId, "profile-a"], + [secondEnvironmentId, "profile-a"], + ]); + expect(clearCache.mock.calls).toEqual([ + [environmentId, "profile-a"], + [secondEnvironmentId, "profile-a"], + ]); + }); + + it("propagates cleanup failures", async () => { + const failure = new Error("clear failed"); + await expect( + clearBrowserProfileData( + { + clearCookies: vi.fn().mockRejectedValue(failure), + clearCache: vi.fn().mockResolvedValue(undefined), + }, + [environmentId], + "profile-a", + ), + ).rejects.toBe(failure); + }); + + it("does not report success without an environment or bridge", async () => { + const bridge = { + clearCookies: vi.fn().mockResolvedValue(undefined), + clearCache: vi.fn().mockResolvedValue(undefined), + }; + + await expect(clearBrowserProfileData(bridge, [], "profile-a")).rejects.toThrow(); + await expect(clearBrowserProfileData(null, [environmentId], "profile-a")).rejects.toThrow(); + expect(bridge.clearCookies).not.toHaveBeenCalled(); + expect(bridge.clearCache).not.toHaveBeenCalled(); + }); +}); + +describe("browserProfileRemovalAvailable", () => { + it("requires a ready non-empty catalog and desktop bridge", () => { + expect(browserProfileRemovalAvailable(true, true, 1)).toBe(true); + expect(browserProfileRemovalAvailable(true, true, 0)).toBe(false); + expect(browserProfileRemovalAvailable(true, false, 1)).toBe(false); + expect(browserProfileRemovalAvailable(false, true, 1)).toBe(false); + }); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 2757866ecbbd..af08091a520a 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -7,8 +7,13 @@ * @module IntegrationsSettings */ import { + BROWSER_PROFILE_MAX_COUNT, + type BrowserProfile, + type EnvironmentId, + BROWSER_PROFILE_NAME_MAX_LENGTH, BROWSER_RECORDING_FRAME_RATES, DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, + DEFAULT_BROWSER_PROFILE_ID, DEFAULT_BROWSER_RECORDING_FRAME_RATE, DEFAULT_BROWSER_VIEWPORT, DEFAULT_PREVIEW_APPEARANCE, @@ -19,17 +24,35 @@ import { PREVIEW_VIEWPORT_MAX_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_ZOOM_LEVELS, + findBrowserProfile, + isBuiltInBrowserProfileId, + resolveBrowserProfiles, type PreviewAppearancePreference, type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; -import { InfoIcon } from "lucide-react"; +import { InfoIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react"; +import { useState } from "react"; import type { ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; +import { previewBridge } from "~/components/preview/previewBridge"; +import { cn, randomUUID } from "~/lib/utils"; +import { useEnvironments } from "~/state/environments"; import { isElectron } from "../../env"; +import { Badge } from "../ui/badge"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { DraftInput } from "../ui/draft-input"; import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field"; import { Select, @@ -43,7 +66,9 @@ import { import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { + getClientSettings, useClientSettings, + useClientSettingsHydrated, usePrimarySettings, useUpdatePrimarySettings, } from "~/hooks/useSettings"; @@ -54,11 +79,41 @@ import { SettingsRow, SettingsSection, } from "./settingsLayout"; +import { ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; import { searchableSetting } from "./settingsSearch"; const FILL_VALUE = "fill"; const RESPONSIVE_VALUE = "responsive"; +type BrowserProfileDataBridge = Pick< + NonNullable, + "clearCookies" | "clearCache" +>; + +export async function clearBrowserProfileData( + bridge: BrowserProfileDataBridge | null, + environmentIds: ReadonlyArray, + profileId: string, +): Promise { + if (bridge === null || environmentIds.length === 0) { + throw new Error("Browser profile data is not available to clear."); + } + await Promise.all( + environmentIds.flatMap((environmentId) => [ + bridge.clearCookies(environmentId, profileId), + bridge.clearCache(environmentId, profileId), + ]), + ); +} + +export function browserProfileRemovalAvailable( + bridgeAvailable: boolean, + environmentsReady: boolean, + environmentCount: number, +): boolean { + return bridgeAvailable && environmentsReady && environmentCount > 0; +} + /** * The size a "Responsive" default falls back to when the user switches away * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry @@ -501,11 +556,306 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode ); } +/** + * Create, rename, and remove browser profiles. + * + * Built-ins render without controls: they are synthesized rather than stored, + * so there is nothing to rename and removing them would strand every tab that + * opened under them. + */ +function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { + const userProfiles = useClientSettings((settings) => settings.browserProfiles); + const settingsHydrated = useClientSettingsHydrated(); + const updateSettings = useUpdatePrimarySettings(); + const { environments, isReady: environmentsReady } = useEnvironments(); + const [profilePendingRemoval, setProfilePendingRemoval] = useState(null); + const [profileRemovalError, setProfileRemovalError] = useState(null); + const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false); + const removalAvailable = browserProfileRemovalAvailable( + previewBridge !== null, + environmentsReady, + environments.length, + ); + const profileWritesDisabled = disabled || !settingsHydrated; + + const addProfile = () => { + if (!settingsHydrated) return; + const currentProfiles = getClientSettings().browserProfiles; + if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return; + const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name)); + let name = "New profile"; + for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`; + updateSettings({ + browserProfiles: [ + ...currentProfiles, + { id: `profile-${randomUUID()}`, name, kind: "persistent" as const }, + ], + }); + }; + + const renameProfile = (id: string, next: string) => { + if (!settingsHydrated) return; + const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH); + if (name === "") return; + const currentProfiles = getClientSettings().browserProfiles; + updateSettings({ + browserProfiles: currentProfiles.map((profile) => + profile.id === id ? { ...profile, name } : profile, + ), + }); + }; + + const removeProfile = async (id: string) => { + if (!settingsHydrated) return; + if (!removalAvailable) { + setProfileRemovalError("Connect to an environment before removing this profile."); + return; + } + setProfileRemovalError(null); + setProfileRemovalInFlight(true); + // Drop the partition's data too, otherwise a removed profile's cookies + // stay on disk with nothing in the UI pointing at them. + try { + await clearBrowserProfileData( + previewBridge, + environmentsReady ? environments.map((environment) => environment.environmentId) : [], + id, + ); + } catch { + setProfileRemovalError("Profile data could not be deleted. Try again."); + setProfileRemovalInFlight(false); + return; + } + const currentSettings = getClientSettings(); + updateSettings({ + browserProfiles: currentSettings.browserProfiles.filter((profile) => profile.id !== id), + // Reassign the default rather than leaving it pointing at nothing. + ...(currentSettings.browserDefaultProfileId === id + ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } + : {}), + }); + setProfileRemovalInFlight(false); + setProfilePendingRemoval(null); + }; + + return ( + = BROWSER_PROFILE_MAX_COUNT} + onClick={addProfile} + > + + Add profile + + } + > + {/* + Each profile is its own bounded row, and the list carries the bottom + spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows + stack on narrow viewports with a larger gap inside a row than between + rows, which reads as the remove button belonging to the profile below. + */} +
+ {resolveBrowserProfiles(userProfiles).map((profile) => { + const builtIn = isBuiltInBrowserProfileId(profile.id); + return ( +
+ {builtIn ? ( + // Dimmed here rather than on the list, which is the only + // content in the row without a disabled treatment of its own: + // a wrapper-level dim would stack with the rename field's and + // the remove button's, landing them near 0.41 while every + // other disabled control in the block sits at 0.64. + + {profile.name} + + {profile.kind === "incognito" ? "Ephemeral" : "Built-in"} + + + ) : ( + renameProfile(profile.id, next)} + /> + )} + {builtIn ? null : ( + + + + + } + /> + + {removalAvailable + ? "Remove profile and its data" + : "Connect to an environment to remove this profile"} + + + )} +
+ ); + })} +
+ { + if (!open && !profileRemovalInFlight) { + setProfilePendingRemoval(null); + setProfileRemovalError(null); + } + }} + > + + + Remove “{profilePendingRemoval?.name}”? + + Its cookies, logins, and cache are deleted with it. Tabs already open in this profile + stay open until you close them. + + {profileRemovalError ? ( +

+ {profileRemovalError} +

+ ) : null} + {!removalAvailable ? ( +

+ Connect to an environment to remove this profile and its data. +

+ ) : null} +
+ + } + > + Cancel + + + +
+
+
+ ); +} + +function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) { + const userProfiles = useClientSettings((settings) => settings.browserProfiles); + const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); + const settingsHydrated = useClientSettingsHydrated(); + const updateSettings = useUpdatePrimarySettings(); + const profileWritesDisabled = disabled || !settingsHydrated; + // Incognito is deliberately absent: as a default it would open every tab + // into storage that is discarded on close. + const profiles = resolveBrowserProfiles(userProfiles).filter( + (profile) => profile.kind !== "incognito", + ); + const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0]; + + return ( + { + if (settingsHydrated) { + updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }); + } + }} + /> + ) : null + } + control={ + + } + /> + ); +} + export function IntegrationsSettingsPanel() { // Client-local preview defaults are editable only where the preview exists. const previewDefaultsDisabled = !isElectron; const previewDefaults = ( <> + + diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 44859e5cb040..20aea7d3f77e 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -312,6 +312,18 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/integrations", searchTerms: ["allow open drive preview tools sessions"], }, + { + id: "browser-profiles", + title: "Browser profiles", + to: "/settings/integrations", + targetId: "browser", + }, + { + id: "browser-default-profile", + title: "Default browser profile", + to: "/settings/integrations", + targetId: "browser", + }, { id: "browser-default-viewport", title: "Default browser viewport", diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index b66782ebe2d1..d7892cb228ab 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -235,7 +235,12 @@ function MenuSubTrigger({ return ( 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", className, )} data-inset={inset} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 4c0bf1ad310e..2864eb4ced95 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1867,6 +1867,7 @@ function PullRequestsRouteView() { onCloseAllSurfaces={closeAllSurfaces} onCopyFilePath={() => undefined} onAddBrowser={() => undefined} + onAddBrowserInProfile={() => undefined} onAddTerminal={() => undefined} onAddDiff={() => undefined} onAddFiles={() => undefined} diff --git a/packages/contracts/src/browserProfile.test.ts b/packages/contracts/src/browserProfile.test.ts new file mode 100644 index 000000000000..d53423bd6eef --- /dev/null +++ b/packages/contracts/src/browserProfile.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "@effect/vitest"; + +import * as Schema from "effect/Schema"; + +import { + BrowserProfileId, + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, + INCOGNITO_BROWSER_PROFILE_ID, + findBrowserProfile, + isBuiltInBrowserProfileId, + resolveBrowserProfiles, + type BrowserProfile, +} from "./browserProfile.ts"; + +const work: BrowserProfile = { id: "profile-work", name: "Work", kind: "persistent" }; + +describe("resolveBrowserProfiles", () => { + it("lists built-ins ahead of the user's own profiles", () => { + const resolved = resolveBrowserProfiles([work]); + + expect(resolved.map((profile) => profile.id)).toEqual([ + DEFAULT_BROWSER_PROFILE_ID, + INCOGNITO_BROWSER_PROFILE_ID, + work.id, + ]); + }); + + it("drops stored entries that collide with a built-in id", () => { + // Built-ins are synthesized rather than stored, so a hand-edited settings + // file must not be able to shadow Default with a persistent partition of + // its own — every tab already opened under Default would follow it. + const resolved = resolveBrowserProfiles([ + { id: DEFAULT_BROWSER_PROFILE_ID, name: "Hijacked", kind: "persistent" }, + { id: INCOGNITO_BROWSER_PROFILE_ID, name: "Not incognito", kind: "persistent" }, + work, + ]); + + expect(resolved).toEqual([...BUILT_IN_BROWSER_PROFILES, work]); + }); + + it("keeps incognito ephemeral", () => { + const incognito = findBrowserProfile(resolveBrowserProfiles([]), INCOGNITO_BROWSER_PROFILE_ID); + + expect(incognito?.kind).toBe("incognito"); + }); +}); + +describe("findBrowserProfile", () => { + it("returns nothing for an id that no longer exists", () => { + // The settings UI relies on this to fall back rather than opening tabs + // into a partition with no profile behind it. + expect(findBrowserProfile(resolveBrowserProfiles([]), work.id)).toBeUndefined(); + expect(findBrowserProfile(resolveBrowserProfiles([work]), undefined)).toBeUndefined(); + }); +}); + +describe("isBuiltInBrowserProfileId", () => { + it("separates built-ins from user profiles", () => { + expect(isBuiltInBrowserProfileId(DEFAULT_BROWSER_PROFILE_ID)).toBe(true); + expect(isBuiltInBrowserProfileId(INCOGNITO_BROWSER_PROFILE_ID)).toBe(true); + expect(isBuiltInBrowserProfileId(work.id)).toBe(false); + }); +}); + +describe("resolveBrowserProfiles normalization", () => { + it("keeps only the first entry for a repeated id", () => { + // Both map to the same Electron partition, so presenting two would offer + // isolated identities that in fact share every cookie. + const resolved = resolveBrowserProfiles([ + { id: "work", name: "Work", kind: "persistent" }, + { id: "work", name: "Work (old)", kind: "persistent" }, + ]); + + expect(resolved.filter((profile) => profile.id === "work")).toEqual([ + { id: "work", name: "Work", kind: "persistent" }, + ]); + }); + + it("reports a custom incognito profile as persistent", () => { + // Partition persistence is keyed off the built-in incognito id alone, so + // a custom profile claiming that kind keeps its cookies across restarts. + // Labelling it ephemeral would be a promise the partition layer breaks. + const resolved = resolveBrowserProfiles([ + { id: "throwaway", name: "Throwaway", kind: "incognito" }, + ]); + + expect(resolved.find((profile) => profile.id === "throwaway")).toEqual({ + id: "throwaway", + name: "Throwaway", + kind: "persistent", + }); + }); + + it("still lets the built-in incognito profile stay ephemeral", () => { + const incognito = resolveBrowserProfiles([]).find( + (profile) => profile.id === INCOGNITO_BROWSER_PROFILE_ID, + ); + + expect(incognito?.kind).toBe("incognito"); + }); +}); + +describe("BrowserProfileId", () => { + it("rejects control characters", () => { + // Ids are folded into delimiter-joined cache keys on the client, so one + // carrying the delimiter would resolve to another profile's partition. + expect(Schema.is(BrowserProfileId)("profile-a\u0000b")).toBe(false); + expect(Schema.is(BrowserProfileId)("profile-a")).toBe(true); + }); +}); diff --git a/packages/contracts/src/browserProfile.ts b/packages/contracts/src/browserProfile.ts new file mode 100644 index 000000000000..39dd58dfb336 --- /dev/null +++ b/packages/contracts/src/browserProfile.ts @@ -0,0 +1,99 @@ +/** + * Browser profiles - named identities for the in-app preview browser. + * + * Each profile maps to its own Electron session partition, so cookies and + * storage are isolated between them: a tab opened under "Work" cannot see + * "Personal"'s logins. Profiles are client-local, like the other browser + * defaults, because the Chromium guest they configure is desktop-local. + * + * Two profiles are built in and cannot be edited or removed: + * - `default` keeps the partition scope the browser used before profiles + * existed, so upgrading does not sign anyone out. + * - `incognito` maps to a non-persistent partition for throwaway sessions. + * + * @module BrowserProfile + */ +import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const BROWSER_PROFILE_NAME_MAX_LENGTH = 48; +export const BROWSER_PROFILE_MAX_COUNT = 24; + +/** + * Control characters are rejected because ids are folded into delimiter-joined + * cache keys on the client; one carrying the delimiter would resolve to a + * different profile's partition. + */ +export const BrowserProfileId = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[^\p{Cc}]+$/u), +); +export type BrowserProfileId = typeof BrowserProfileId.Type; + +export const BrowserProfileName = TrimmedNonEmptyString.check( + Schema.isMaxLength(BROWSER_PROFILE_NAME_MAX_LENGTH), +); + +/** + * `persistent` profiles keep cookies on disk across restarts; `incognito` + * uses an in-memory partition that Chromium discards with the process. + */ +export const BrowserProfileKind = Schema.Literals(["persistent", "incognito"]); +export type BrowserProfileKind = typeof BrowserProfileKind.Type; + +export const BrowserProfile = Schema.Struct({ + id: BrowserProfileId, + name: BrowserProfileName, + kind: BrowserProfileKind, +}); +export type BrowserProfile = typeof BrowserProfile.Type; + +export const DEFAULT_BROWSER_PROFILE_ID: BrowserProfileId = "default"; +export const INCOGNITO_BROWSER_PROFILE_ID: BrowserProfileId = "incognito"; + +/** + * Built-ins are synthesized rather than stored, so they cannot be renamed out + * of existence or deleted by editing the settings file by hand. + */ +export const BUILT_IN_BROWSER_PROFILES: ReadonlyArray = [ + { id: DEFAULT_BROWSER_PROFILE_ID, name: "Default", kind: "persistent" }, + { id: INCOGNITO_BROWSER_PROFILE_ID, name: "Incognito", kind: "incognito" }, +]; + +export function isBuiltInBrowserProfileId(id: string): boolean { + return BUILT_IN_BROWSER_PROFILES.some((profile) => profile.id === id); +} + +/** + * The full picker list: built-ins first, then the user's own profiles. + * + * Three things are normalized away, because each would present a profile the + * partition layer does not actually deliver: + * + * - Entries colliding with a built-in id, so a hand-edited settings file + * cannot shadow "Default" or "Incognito". + * - Repeated ids, which map to one partition and would otherwise appear as + * two isolated identities sharing every cookie. First entry wins. + * - `kind: "incognito"` on anything but the built-in, since persistence is + * keyed off that one id; such a profile is labelled ephemeral while its + * cookies survive restarts. + */ +export function resolveBrowserProfiles( + userProfiles: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(BUILT_IN_BROWSER_PROFILES.map((profile) => profile.id)); + const resolved = [...BUILT_IN_BROWSER_PROFILES]; + for (const profile of userProfiles) { + if (seen.has(profile.id)) continue; + seen.add(profile.id); + resolved.push(profile.kind === "persistent" ? profile : { ...profile, kind: "persistent" }); + } + return resolved; +} + +export function findBrowserProfile( + profiles: ReadonlyArray, + id: string | undefined, +): BrowserProfile | undefined { + return id === undefined ? undefined : profiles.find((profile) => profile.id === id); +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index e8e8de2758e5..85bfb6034e67 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -28,6 +28,7 @@ export * from "./project.ts"; export * from "./filesystem.ts"; export * from "./assets.ts"; export * from "./review.ts"; +export * from "./browserProfile.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 609df9159247..25b06e866fdd 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -88,6 +88,7 @@ import type { OrchestrationThreadStreamItem, } from "./orchestration.ts"; import { EnvironmentId } from "./baseSchemas.ts"; +import { BrowserProfileId } from "./browserProfile.ts"; import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; @@ -974,6 +975,19 @@ export const DesktopPreviewNavigateInputSchema = Schema.Struct({ export const DesktopPreviewConfigInputSchema = Schema.Struct({ environmentId: EnvironmentId, + /** + * Browser profile the partition is derived from. Derivation stays in main: + * `will-attach-webview` only prefix-checks the partition string, so a + * renderer-supplied partition could attach to a session that never had the + * UA rewrite or permission handlers installed. + */ + profileId: Schema.optional(BrowserProfileId), +}); + +export const DesktopPreviewClearDataInputSchema = Schema.Struct({ + environmentId: EnvironmentId, + /** Omit to clear every profile; otherwise only this profile's partition. */ + profileId: Schema.optional(BrowserProfileId), }); export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({ @@ -1158,16 +1172,19 @@ export interface DesktopPreviewBridge { /** Open the guest webview's DevTools (detached). */ openDevTools: (tabId: string) => Promise; /** Drop cookies + storage data for the preview partition (all tabs). */ - clearCookies: () => Promise; + clearCookies: (environmentId: EnvironmentId, profileId?: string) => Promise; /** Drop the HTTP cache for the preview partition (all tabs). */ - clearCache: () => Promise; + clearCache: (environmentId: EnvironmentId, profileId?: string) => Promise; /** * One-shot config for mounting a preview ``. Replaces three * earlier round-trip calls (`getBrowserPartition`, `getWebviewPreferences`, * `getPickPreloadPath`) so adding a new field here only requires touching * the contract + main, not the renderer's mount logic. */ - getPreviewConfig: (environmentId: EnvironmentId) => Promise; + getPreviewConfig: ( + environmentId: EnvironmentId, + profileId?: string, + ) => Promise; setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise; /** * Activate the in-page element picker for the given tab. Resolves with diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index a1b743afc673..2df5c6401915 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -10,6 +10,7 @@ */ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { BrowserProfileId } from "./browserProfile.ts"; export const PREVIEW_URL_MAX_LENGTH = 2_048; export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32; @@ -169,6 +170,12 @@ export const PreviewSessionSnapshot = Schema.Struct({ canGoForward: Schema.Boolean, /** Missing snapshots from older servers are treated as fill-panel mode. */ viewport: Schema.optional(PreviewViewportSetting), + /** + * Browser profile the tab's Chromium partition is derived from. Fixed at + * open: Electron only honours a ``'s partition before attach, so + * switching would require tearing the guest down and losing page state. + */ + profileId: Schema.optional(BrowserProfileId), updatedAt: Schema.String, }); export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; @@ -184,6 +191,8 @@ export const PreviewOpenInput = Schema.Struct({ * later (which the user would see as a visible reflow). */ viewport: Schema.optional(PreviewViewportSetting), + /** Omit to open under the client's configured default profile. */ + profileId: Schema.optional(BrowserProfileId), }); export type PreviewOpenInput = typeof PreviewOpenInput.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1b6e8949e32b..7c867c212d44 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -10,6 +10,7 @@ import { ProviderOptionSelections, } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; +import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; import { DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, @@ -199,6 +200,18 @@ export const ClientSettingsSchema = Schema.Struct({ browserAutoShowFloatingPreview: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)), ), + /** + * User-created browser profiles. The built-in Default and Incognito profiles + * are synthesized by `resolveBrowserProfiles`, not stored here, so they + * cannot be renamed away or deleted. + */ + browserProfiles: Schema.Array(BrowserProfile).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + /** Profile new tabs open under. Falls back to Default if it no longer exists. */ + browserDefaultProfileId: BrowserProfileId.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_PROFILE_ID)), + ), // Desktop-only. Boolean values from older settings files decode to their // equivalent mode and encode back as the canonical string value. confirmQuit: QuitConfirmationModeSetting.pipe( @@ -958,6 +971,8 @@ export const ClientSettingsPatch = Schema.Struct({ browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate), browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), + browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)), + browserDefaultProfileId: Schema.optionalKey(BrowserProfileId), confirmQuit: Schema.optionalKey(QuitConfirmationMode), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean),