From 3503088d88c1d61193e7b683e69be88671c5a65f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 16 Aug 2026 04:45:42 +0200 Subject: [PATCH 01/35] feat(desktop): partition preview browsers by profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for browser profiles: each profile maps to its own Electron session partition, so a tab opened under one profile cannot see another's cookies. Two profiles are built in and synthesized rather than stored, so they cannot be renamed away or deleted by hand-editing settings. Default keeps the bare environment id the browser already used as its partition scope, so upgrading does not strand existing logins in an orphaned partition. Incognito derives a non-persistent partition, which Chromium discards with the process. Incognito partitions omit the `persist:` prefix, so `will-attach-webview` — which only prefix-checks — now recognises both shapes; without that, incognito tabs would simply fail to attach. Clearing cookies or cache takes an optional profile. It previously reached every partition unconditionally, which under profiles would mean signing out of every profile from any one tab. Partition derivation stays in main. The attach gate only checks the partition string's prefix, so a renderer-supplied partition could attach to a session that never had the UA rewrite or permission handlers installed. `navigate` and `reportStatus` rebuild the snapshot field by field rather than spreading, so both carry the profile explicitly — otherwise a tab would move to another partition on its first navigation. The regression test was checked against a reverted fix to confirm it fails for that reason. Co-Authored-By: Claude Opus 5 (1M context) feat(web): open preview tabs under a browser profile Threads the tab's profile from the server snapshot down to the Chromium guest, so a tab opened under one profile mounts against that profile's partition. The webview-config atom is now keyed by environment *and* profile. `Atom.family` keys on its argument, so the two are folded into one string rather than passed as an object, which would allocate a fresh entry on every render. Storage clearing in the three-dot menu names the tab's profile and scopes the call to it. Previously it cleared every partition, which under profiles would have signed the user out of every profile from any one tab. A configured default pointing at a deleted profile falls back to Default rather than opening tabs into a partition with nothing behind it. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop/src/ipc/methods/preview.ts | 60 +++++++++++--- apps/desktop/src/preload.ts | 10 ++- apps/desktop/src/preview/BrowserSession.ts | 62 ++++++++++++--- apps/desktop/src/preview/Manager.ts | 50 +++++++----- .../settings/DesktopClientSettings.test.ts | 2 + apps/server/src/preview/Manager.test.ts | 31 ++++++++ apps/server/src/preview/Manager.ts | 19 ++++- apps/web/src/browser/ElectronBrowserHost.tsx | 1 + apps/web/src/browser/HostedBrowserWebview.tsx | 9 ++- apps/web/src/browser/browserDefaults.ts | 44 ++++++++--- .../browser/previewWebviewConfigState.test.ts | 19 +++-- .../src/browser/previewWebviewConfigState.ts | 33 ++++++-- .../components/preview/PreviewMoreMenu.tsx | 23 ++++-- .../components/preview/PreviewView.test.tsx | 26 ++++--- .../src/components/preview/PreviewView.tsx | 7 ++ .../preview/addBrowserSurface.test.ts | 2 + .../preview/openPreviewSession.test.ts | 3 + .../components/preview/openPreviewSession.ts | 14 +++- packages/contracts/src/browserProfile.ts | 78 +++++++++++++++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 23 +++++- packages/contracts/src/preview.ts | 9 +++ packages/contracts/src/settings.ts | 15 ++++ 23 files changed, 447 insertions(+), 94 deletions(-) create mode 100644 packages/contracts/src/browserProfile.ts diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 5229d36c31f1..a107ab4c936f 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,72 @@ export const closePictureInPicture = tabMethod( export const clearCookies = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, - payload: Schema.Void, + payload: DesktopPreviewClearDataInputSchema, result: Schema.Void, - handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () { + handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* ({ environmentId, profileId }) { const manager = yield* PreviewManager.PreviewManager; - yield* manager.clearCookies(); + yield* manager.clearCookies(yield* resolveClearPartitions(manager, environmentId, profileId)); }), }); export const clearCache = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, - payload: Schema.Void, + payload: DesktopPreviewClearDataInputSchema, result: Schema.Void, - handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () { + handler: Effect.fn("desktop.ipc.preview.clearCache")(function* ({ environmentId, profileId }) { const manager = yield* PreviewManager.PreviewManager; - yield* manager.clearCache(); + yield* manager.clearCache(yield* resolveClearPartitions(manager, environmentId, profileId)); }), }); +/** + * Partition scope for an (environment, profile) pair. + * + * The default profile keeps the bare environment id it used before profiles + * existed, so upgrading does not strand anyone's existing logins in an + * orphaned partition. Incognito derives a non-persistent partition. + */ +function resolvePartitionScope( + environmentId: string, + profileId: string | undefined, +): { readonly scope: string; readonly persistent: boolean } { + if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) { + return { scope: environmentId, persistent: true }; + } + return { + scope: `${environmentId}::${profileId}`, + persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID, + }; +} + +/** + * 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 } = resolvePartitionScope(environmentId, profileId); + return [yield* manager.getBrowserPartition(scope, persistent)]; +}); + 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 } = 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); return { - partition: yield* manager.getBrowserPartition(environmentId), + partition: yield* manager.getBrowserPartition(scope, persistent), 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.ts b/apps/desktop/src/preview/BrowserSession.ts index 784afe019edf..3715e2523ad5 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -10,6 +10,13 @@ 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-"; // Permissions granted to preview web content. `clipboard-sanitized-write` is the // Electron permission behind `navigator.clipboard.writeText()` — note it is NOT @@ -99,19 +106,44 @@ export class BrowserSession extends Context.Service< { readonly getPartition: ( scope?: string, + persistent?: boolean, ) => 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, + ) => 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), + ); + 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 getPartition = Effect.fn("BrowserSession.getPartition")(function* ( + scope = "shared", + persistent = true, + ) { const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe( Effect.mapError( (cause) => @@ -121,11 +153,15 @@ 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; + return `${prefix}${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, + ) { + const partition = yield* getPartition(scope, persistent); return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => { const existing = sessions.get(partition); if (existing) return Effect.succeed([existing, sessions] as const); @@ -159,12 +195,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 +218,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..ce2a90e05b97 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -4409,7 +4409,10 @@ 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, + ) => Effect.Effect; readonly isBrowserPartition: (partition: string) => boolean; readonly createTab: ( tabId: string, @@ -4440,9 +4443,16 @@ 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, + ) => Effect.Effect; readonly setAnnotationTheme: ( theme: DesktopPreviewAnnotationTheme, ) => Effect.Effect; @@ -4514,9 +4524,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { return PreviewManager.of({ setMainWindow: operations.setMainWindow, - getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { + getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope, persistent) { return yield* browserSession - .getSession(scope) + .getSession(scope, persistent) .pipe( Effect.mapError( (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), @@ -4539,31 +4549,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) { + return yield* browserSession + .getPartition(scope, persistent) + .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..49e4a659718b 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -49,11 +49,16 @@ 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 } = + const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor, profileId } = props; - const config = usePreviewWebviewConfig(threadRef.environmentId); + 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.ts b/apps/web/src/browser/browserDefaults.ts index da8bf6a65826..d7db50c00962 100644 --- a/apps/web/src/browser/browserDefaults.ts +++ b/apps/web/src/browser/browserDefaults.ts @@ -13,10 +13,14 @@ * * @module browserDefaults */ -import type { - DesktopPreviewTabDefaults, - PreviewAppearancePreference, - PreviewViewportSetting, +import { + DEFAULT_BROWSER_PROFILE_ID, + findBrowserProfile, + resolveBrowserProfiles, + type BrowserProfile, + type DesktopPreviewTabDefaults, + type PreviewAppearancePreference, + type PreviewViewportSetting, } from "@t3tools/contracts"; import { @@ -32,6 +36,8 @@ export interface BrowserDefaults { readonly zoomFactor: number; readonly appearance: PreviewAppearancePreference; readonly autoShowFloatingPreview: boolean; + readonly profiles: ReadonlyArray; + readonly profileId: string; } const toBrowserDefaults = (settings: { @@ -39,12 +45,23 @@ 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. + profileId: + findBrowserProfile(profiles, settings.browserDefaultProfileId)?.id ?? + DEFAULT_BROWSER_PROFILE_ID, + }; +}; /** Non-hook accessor for imperative open paths (menu actions, automation hosts). */ export function getBrowserDefaults(): BrowserDefaults { @@ -89,6 +106,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/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..2da18eede183 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,43 @@ 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. + */ +const configKey = (environmentId: EnvironmentId, profileId: string | undefined): string => + `${environmentId}\u0000${profileId ?? ""}`; + +const parseConfigKey = (key: string): { environmentId: EnvironmentId; profileId?: string } => { + const [environmentId = "", profileId = ""] = key.split("\u0000"); + return { + environmentId: environmentId as EnvironmentId, + ...(profileId === "" ? {} : { profileId }), + }; +}; + +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/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 8b7c75cb1d95..a93fd133a8be 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -1,6 +1,6 @@ "use client"; -import type { DesktopPreviewColorScheme } from "@t3tools/contracts"; +import type { DesktopPreviewColorScheme, EnvironmentId } from "@t3tools/contracts"; import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; import { Button } from "~/components/ui/button"; @@ -50,6 +50,12 @@ interface Props { nativePictureInPicture: boolean; /** Toggles the optional native always-on-top preview window. */ onNativePictureInPicture: () => 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. */ + profileId: string | undefined; + /** Profile display name, shown so the menu says which data is being cleared. */ + profileName: string | undefined; } /** @@ -66,6 +72,9 @@ export function PreviewMoreMenu({ onToggleDeviceToolbar, nativePictureInPicture, onNativePictureInPicture, + environmentId, + profileId, + profileName, }: Props) { if (!previewBridge) return null; const bridge = previewBridge; @@ -177,11 +186,15 @@ export function PreviewMoreMenu({ - void bridge.clearCookies().catch(() => undefined)}> - Clear cookies + void bridge.clearCookies(environmentId, profileId).catch(() => undefined)} + > + {profileName ? `Clear cookies (${profileName})` : "Clear cookies"} - void bridge.clearCache().catch(() => undefined)}> - Clear cache + void bridge.clearCache(environmentId, profileId).catch(() => undefined)} + > + {profileName ? `Clear cache (${profileName})` : "Clear cache"} diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index fd6ac25ceced..1eb364c23851 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, diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 063314863fec..b1bf3404fa57 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -689,6 +689,13 @@ export function PreviewView({ trailingActions={ previewBridge ? ( profile.id === (snapshot?.profileId ?? browserDefaults.profileId), + )?.name + } tabId={runtimeTabId} hasWebContents={desktopOverlay?.hasWebContents ?? false} zoomFactor={desktopOverlay?.zoomFactor ?? 1} diff --git a/apps/web/src/components/preview/addBrowserSurface.test.ts b/apps/web/src/components/preview/addBrowserSurface.test.ts index 4299374b321c..e26d472b8674 100644 --- a/apps/web/src/components/preview/addBrowserSurface.test.ts +++ b/apps/web/src/components/preview/addBrowserSurface.test.ts @@ -1,4 +1,5 @@ import { + DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -48,6 +49,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/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/packages/contracts/src/browserProfile.ts b/packages/contracts/src/browserProfile.ts new file mode 100644 index 000000000000..79742898e26e --- /dev/null +++ b/packages/contracts/src/browserProfile.ts @@ -0,0 +1,78 @@ +/** + * Browser profiles - named identities for the in-app preview browser. + * + * Each profile maps to its own Electron session partition, so cookies and + * storage are isolated between them: a tab opened under "Work" cannot see + * "Personal"'s logins. Profiles are client-local, like the other browser + * defaults, because the Chromium guest they configure is desktop-local. + * + * Two profiles are built in and cannot be edited or removed: + * - `default` keeps the partition scope the browser used before profiles + * existed, so upgrading does not sign anyone out. + * - `incognito` maps to a non-persistent partition for throwaway sessions. + * + * @module BrowserProfile + */ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const BROWSER_PROFILE_NAME_MAX_LENGTH = 48; +export const BROWSER_PROFILE_MAX_COUNT = 24; + +export const BrowserProfileId = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); +export type BrowserProfileId = typeof BrowserProfileId.Type; + +export const BrowserProfileName = TrimmedNonEmptyString.check( + Schema.isMaxLength(BROWSER_PROFILE_NAME_MAX_LENGTH), +); + +/** + * `persistent` profiles keep cookies on disk across restarts; `incognito` + * uses an in-memory partition that Chromium discards with the process. + */ +export const BrowserProfileKind = Schema.Literals(["persistent", "incognito"]); +export type BrowserProfileKind = typeof BrowserProfileKind.Type; + +export const BrowserProfile = Schema.Struct({ + id: BrowserProfileId, + name: BrowserProfileName, + kind: BrowserProfileKind, +}); +export type BrowserProfile = typeof BrowserProfile.Type; + +export const DEFAULT_BROWSER_PROFILE_ID: BrowserProfileId = "default"; +export const INCOGNITO_BROWSER_PROFILE_ID: BrowserProfileId = "incognito"; + +/** + * Built-ins are synthesized rather than stored, so they cannot be renamed out + * of existence or deleted by editing the settings file by hand. + */ +export const BUILT_IN_BROWSER_PROFILES: ReadonlyArray = [ + { id: DEFAULT_BROWSER_PROFILE_ID, name: "Default", kind: "persistent" }, + { id: INCOGNITO_BROWSER_PROFILE_ID, name: "Incognito", kind: "incognito" }, +]; + +export function isBuiltInBrowserProfileId(id: string): boolean { + return BUILT_IN_BROWSER_PROFILES.some((profile) => profile.id === id); +} + +/** + * The full picker list: built-ins first, then the user's own profiles with any + * entry that collides with a built-in id dropped, so a hand-edited settings + * file cannot shadow "Default" or "Incognito". + */ +export function resolveBrowserProfiles( + userProfiles: ReadonlyArray, +): ReadonlyArray { + return [ + ...BUILT_IN_BROWSER_PROFILES, + ...userProfiles.filter((profile) => !isBuiltInBrowserProfileId(profile.id)), + ]; +} + +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), From bd3f91068041d9c389f1ca4a2d10df8040b7fa72 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 16 Aug 2026 16:38:19 +0200 Subject: [PATCH 02/35] =?UTF-8?q?feat(web):=20manage=20browser=20profiles?= =?UTF-8?q?=20from=20Settings=20=E2=86=92=20Integrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the create/rename/remove list and a picker for which profile new tabs open under, including tabs an agent opens. Built-ins render without controls: they are synthesized rather than stored, so there is nothing to rename, and removing them would strand every tab already opened under them. A test covers the matching invariant — a hand-edited settings file cannot shadow Default or Incognito with a stored entry. Removing a profile clears its partition's cookies and cache, otherwise its logins would sit on disk with nothing in the UI pointing at them, and reassigns the default if it pointed at the removed profile. Names commit on blur via DraftInput rather than per keystroke, matching the dimension fields. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/IntegrationsSettings.tsx | 175 +++++++++++++++++- .../src/components/settings/settingsSearch.ts | 12 ++ packages/contracts/src/browserProfile.test.ts | 61 ++++++ 3 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 packages/contracts/src/browserProfile.test.ts diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 2757866ecbbd..f0e16736b153 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -7,8 +7,11 @@ * @module IntegrationsSettings */ import { + BROWSER_PROFILE_MAX_COUNT, + 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 +22,25 @@ 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 type { ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; +import { previewBridge } from "~/components/preview/previewBridge"; +import { randomUUID } from "~/lib/utils"; +import { usePrimaryEnvironment } from "~/state/environments"; import { isElectron } from "../../env"; +import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; +import { DraftInput } from "../ui/draft-input"; import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field"; import { Select, @@ -54,6 +65,7 @@ import { SettingsRow, SettingsSection, } from "./settingsLayout"; +import { ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; import { searchableSetting } from "./settingsSearch"; const FILL_VALUE = "fill"; @@ -501,11 +513,172 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode ); } +/** + * Create, rename, and remove browser profiles. + * + * Built-ins render without controls: they are synthesized rather than stored, + * so there is nothing to rename and removing them would strand every tab that + * opened under them. + */ +function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { + const userProfiles = useClientSettings((settings) => settings.browserProfiles); + const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); + const updateSettings = useUpdatePrimarySettings(); + const environmentId = usePrimaryEnvironment()?.environmentId; + + const addProfile = () => { + if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return; + const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name)); + let name = "New profile"; + for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`; + updateSettings({ + browserProfiles: [ + ...userProfiles, + { id: `profile-${randomUUID()}`, name, kind: "persistent" as const }, + ], + }); + }; + + const renameProfile = (id: string, next: string) => { + const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH); + if (name === "") return; + updateSettings({ + browserProfiles: userProfiles.map((profile) => + profile.id === id ? { ...profile, name } : profile, + ), + }); + }; + + const removeProfile = (id: string) => { + // Drop the partition's data too, otherwise a removed profile's cookies + // stay on disk with nothing in the UI pointing at them. + if (environmentId) { + void previewBridge?.clearCookies(environmentId, id).catch(() => undefined); + void previewBridge?.clearCache(environmentId, id).catch(() => undefined); + } + updateSettings({ + browserProfiles: userProfiles.filter((profile) => profile.id !== id), + // Reassign the default rather than leaving it pointing at nothing. + ...(defaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}), + }); + }; + + return ( + = BROWSER_PROFILE_MAX_COUNT} + onClick={addProfile} + > + + Add profile + + } + > +
+ {resolveBrowserProfiles(userProfiles).map((profile) => { + const builtIn = isBuiltInBrowserProfileId(profile.id); + return ( +
+ {builtIn ? ( + + {profile.name} + + {profile.kind === "incognito" ? "Ephemeral" : "Built-in"} + + + ) : ( + renameProfile(profile.id, next)} + /> + )} + {builtIn ? null : ( + + removeProfile(profile.id)} + > + + + } + /> + Remove profile and its data + + )} +
+ ); + })} +
+
+ ); +} + +function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) { + const userProfiles = useClientSettings((settings) => settings.browserProfiles); + const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); + const updateSettings = useUpdatePrimarySettings(); + const profiles = resolveBrowserProfiles(userProfiles); + const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0]; + + return ( + 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/packages/contracts/src/browserProfile.test.ts b/packages/contracts/src/browserProfile.test.ts new file mode 100644 index 000000000000..5ee606c1de5b --- /dev/null +++ b/packages/contracts/src/browserProfile.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + 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); + }); +}); From b5435d27c91f3c7b9a156a6031b450f5e2543c93 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 16 Aug 2026 16:48:58 +0200 Subject: [PATCH 03/35] feat(web): open browser tabs in a chosen profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to reach a non-default profile: tabs always opened under the configured default, and nothing in the chrome said which profile you were in. The "+" surface menu gains a "Browser in profile" submenu. The choice lives at open time because a tab's profile is fixed then — Electron only honours a partition before the guest attaches — so offering it on an already-open tab would promise a switch that cannot happen. The three-dot menu now names the tab's profile, which was otherwise invisible. Also routes the two paths that bypassed `openPreviewSession` — opening a file or an external link in the preview, and the terminal's "open in preview" — through the configured defaults. Both built their open input by hand, so they silently ignored the default viewport as well as the profile. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/browser/openFileInPreview.ts | 12 +++- apps/web/src/components/ChatView.tsx | 15 +++-- apps/web/src/components/RightPanelTabs.tsx | 60 +++++++++++++++---- .../components/preview/PreviewMoreMenu.tsx | 6 ++ .../components/preview/addBrowserSurface.ts | 3 + .../preview/openTerminalLinkInPreview.ts | 10 +++- 6 files changed, 88 insertions(+), 18 deletions(-) diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 4e540a9a0963..b24a0ceabe14 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -23,6 +23,8 @@ import { } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; +import { browserDefaultOpenProfileId, browserDefaultOpenViewport } from "./browserDefaults"; + export const isBrowserPreviewFile = (path: string): boolean => /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); @@ -44,7 +46,15 @@ export async function openUrlInPreview(input: { }): Promise> { const result = await input.openPreview({ environmentId: input.threadRef.environmentId, - input: { threadId: input.threadRef.threadId, url: input.url }, + input: { + threadId: input.threadRef.threadId, + url: input.url, + // Built here rather than via `openPreviewSession` because this path + // maps the result differently, so the configured defaults have to be + // applied explicitly or file/link opens would ignore them. + viewport: browserDefaultOpenViewport(), + profileId: browserDefaultOpenProfileId(), + }, }); return mapAtomCommandResult(result, (snapshot) => { applyPreviewServerSnapshot(input.threadRef, snapshot); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 305773955d0e..dfa7b6adca36 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"); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 40db79e80f56..445259baa9de 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -12,6 +12,7 @@ import { VolumeOff, } from "lucide-react"; import { + Fragment, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -30,7 +31,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"; @@ -68,7 +79,7 @@ interface RightPanelTabsProps { onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; onCopyFilePath: (relativePath: string) => void; - onAddBrowser: () => void; + onAddBrowser: (profileId?: string) => void; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; @@ -600,6 +611,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); @@ -912,16 +924,40 @@ export function RightPanelTabs(props: RightPanelTabsProps) { {addSurfaceActions.map((action) => { const Icon = action.icon; return ( - - - {action.label} - + + + + {action.label} + + {action.label === "Browser" && action.available ? ( + + {/* + A tab's profile is fixed at open — Electron only honours + a partition before the guest attaches — so the choice + belongs here rather than on an already-open tab. + */} + + + Browser in profile + + + {browserProfiles.map((profile) => ( + props.onAddBrowser(profile.id)} + > + {profile.name} + + ))} + + + ) : null} + ); })} diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index a93fd133a8be..9cb55365fc52 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -10,6 +10,7 @@ import { MenuPopup, MenuRadioGroup, MenuRadioItem, + MenuGroupLabel, MenuSeparator, MenuSub, MenuSubPopup, @@ -186,6 +187,11 @@ export function PreviewMoreMenu({
+ {profileName ? ( + // Otherwise the tab's profile is invisible: it is fixed at open, and + // nothing else in the chrome says which one you are browsing in. + Profile: {profileName} + ) : null} void bridge.clearCookies(environmentId, profileId).catch(() => undefined)} > 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/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index f4e0373a73c3..b0b4829670b6 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -3,6 +3,7 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime" import { isPreviewableUrl } from "@t3tools/shared/preview"; import * as Schema from "effect/Schema"; +import { browserDefaultOpenProfileId, browserDefaultOpenViewport } from "~/browser/browserDefaults"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; import { recordVisitForThread } from "~/browserHistoryStore"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -84,7 +85,14 @@ export async function openTerminalLinkInPreview( if (choice === "open-in-preview") { 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(), + profileId: browserDefaultOpenProfileId(), + }, }); if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) { From bd58fabbaba23e9a97fa40eb0f05e3bed26c4212 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 16 Aug 2026 21:56:19 +0200 Subject: [PATCH 04/35] fix(web): repair the Browser surface card and collapse the profile menu row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widening `onAddBrowser` to `(profileId?: string)` broke the empty-state Browser card: it is passed straight to a DOM click handler, so React handed the MouseEvent in as the profile id and the open silently failed schema validation. Fixed structurally rather than at the call site — `onAddBrowser` goes back to taking no arguments, and choosing a profile is a separate `onAddBrowserInProfile(profileId)`. The type now rejects wiring the profile variant to a DOM handler, so this cannot recur; adding it surfaced all four call sites immediately. The "+" menu is one row again. The submenu trigger is itself clickable and opens the default profile, with hover or arrow revealing the rest, so the common case stays a single click. The menu is controlled so that action can dismiss it, which a submenu trigger does not do on its own. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/ChatView.tsx | 6 +- .../src/components/RightPanelTabs.test.tsx | 1 + apps/web/src/components/RightPanelTabs.tsx | 86 +++++++++++-------- .../preview/addBrowserSurface.test.ts | 18 ++++ apps/web/src/routes/_chat.pull-requests.tsx | 1 + 5 files changed, 74 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dfa7b6adca36..bee12b522944 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7671,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} @@ -7711,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..9390bfb591e4 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -104,6 +104,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 445259baa9de..44c0d979286f 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -12,7 +12,6 @@ import { VolumeOff, } from "lucide-react"; import { - Fragment, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -79,7 +78,13 @@ interface RightPanelTabsProps { onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; onCopyFilePath: (relativePath: string) => void; - onAddBrowser: (profileId?: 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; @@ -612,6 +617,9 @@ function SurfaceIcon({ export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; + // Controlled so the submenu trigger's own action can dismiss the menu; a + // submenu trigger does not close it the way a plain item does. + const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); @@ -923,41 +931,47 @@ 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 ( + + { + setAddSurfaceMenuOpen(false); + action.onClick(); + }} + > + + {action.label} + + + {browserProfiles.map((profile) => ( + props.onAddBrowserInProfile(profile.id)} + > + {profile.name} + + ))} + + + ); + } return ( - - - - {action.label} - - {action.label === "Browser" && action.available ? ( - - {/* - A tab's profile is fixed at open — Electron only honours - a partition before the guest attaches — so the choice - belongs here rather than on an already-open tab. - */} - - - Browser in profile - - - {browserProfiles.map((profile) => ( - props.onAddBrowser(profile.id)} - > - {profile.name} - - ))} - - - ) : null} - + + + {action.label} + ); })} diff --git a/apps/web/src/components/preview/addBrowserSurface.test.ts b/apps/web/src/components/preview/addBrowserSurface.test.ts index e26d472b8674..f26cb0fff9e1 100644 --- a/apps/web/src/components/preview/addBrowserSurface.test.ts +++ b/apps/web/src/components/preview/addBrowserSurface.test.ts @@ -37,6 +37,24 @@ beforeEach(() => { }); 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"); 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} From 2fbfa0e2554702e765f72baff8bbf5a646fbc934 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 16 Aug 2026 21:56:19 +0200 Subject: [PATCH 05/35] fix(web): give menu submenu triggers the same cursor as menu items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MenuSubTrigger` was the only interactive menu primitive without a cursor: `MenuItem`, `MenuCheckboxItem`, and `MenuRadioItem` all set `cursor-pointer` plus `data-disabled:cursor-not-allowed`, so it kept the default arrow and read as inert. It always was clickable — clicking opens the submenu — and it is now also an action in the add-surface menu, where the arrow cursor was actively misleading. Fixed in the primitive rather than at that one call site, since the gap applies to the other two submenu triggers (Diff scope, preview Appearance) as well. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/ui/menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index b66782ebe2d1..05243a2ea1e7 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -235,7 +235,7 @@ function MenuSubTrigger({ return ( Date: Sun, 16 Aug 2026 22:38:31 +0200 Subject: [PATCH 06/35] feat(web): show which profile a browser tab is running in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tab's profile was only discoverable by opening the three-dot menu, which is a poor place for something that changes what you are logged into. The chrome row gains a leading slot before the URL bar, and the preview names the tab's profile there. Only when it differs from the default: labelling every tab "Default" would be noise on the common case, while a tab running in another profile is exactly what needs calling out. Also gives the three-dot menu's profile heading a `MenuGroup` ancestor — `MenuGroupLabel` reads Base UI's group context and throws without one, which took the app to its error boundary as soon as the menu opened. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/preview/PreviewChromeRow.tsx | 8 ++++ .../components/preview/PreviewMoreMenu.tsx | 37 +++++++++++-------- .../src/components/preview/PreviewView.tsx | 20 +++++++--- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index d6a64084218a..8dbf9f0904f0 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -57,6 +57,11 @@ interface Props { * to mount the three-dot menu (hard reload, devtools, zoom, clear data). */ trailingActions?: ReactNode; + /** + * Slot between the nav buttons and the URL input. The preview view uses it + * to name the tab's browser profile, which is otherwise invisible. + */ + leadingActions?: ReactNode; } const NOOP = () => {}; @@ -85,6 +90,7 @@ 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} + - {profileName ? ( - // Otherwise the tab's profile is invisible: it is fixed at open, and - // nothing else in the chrome says which one you are browsing in. - Profile: {profileName} - ) : null} - void bridge.clearCookies(environmentId, profileId).catch(() => undefined)} - > - {profileName ? `Clear cookies (${profileName})` : "Clear cookies"} - - void bridge.clearCache(environmentId, profileId).catch(() => undefined)} - > - {profileName ? `Clear cache (${profileName})` : "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. + */} + + {profileName ? Profile: {profileName} : null} + + void bridge.clearCookies(environmentId, profileId).catch(() => undefined) + } + > + {profileName ? `Clear cookies (${profileName})` : "Clear cookies"} + + void bridge.clearCache(environmentId, profileId).catch(() => undefined)} + > + {profileName ? `Clear cache (${profileName})` : "Clear cache"} + + ); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index b1bf3404fa57..3f0027fda03d 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -48,6 +48,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"; @@ -144,6 +145,9 @@ export function PreviewView({ const controller = desktopOverlay?.controller ?? "none"; const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; const browserDefaults = useBrowserDefaults(); + const activeProfile = browserDefaults.profiles.find( + (profile) => profile.id === (snapshot?.profileId ?? browserDefaults.profileId), + ); const panelRect = useBrowserSurfaceStore((state) => runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, ); @@ -686,16 +690,22 @@ export function PreviewView({ pickDisabledReason={ isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined } + leadingActions={ + // Only when it differs from the default: labelling every tab + // "Default" would be noise on the common case, while a tab in + // another profile is exactly what needs calling out. + activeProfile && activeProfile.id !== browserDefaults.profileId ? ( + + {activeProfile.name} + + ) : null + } trailingActions={ previewBridge ? ( profile.id === (snapshot?.profileId ?? browserDefaults.profileId), - )?.name - } + profileName={activeProfile?.name} tabId={runtimeTabId} hasWebContents={desktopOverlay?.hasWebContents ?? false} zoomFactor={desktopOverlay?.zoomFactor ?? 1} From fa3e6b5cd543f9a49131f1da5c7164e4ca622e04 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 00:29:09 +0200 Subject: [PATCH 07/35] fix(web): clear the profile the menu names, and only that profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The More menu passed the tab's raw `profileId` to the clear actions. A tab created before profiles existed has none, and the IPC layer reads an absent profile as "every profile" — so a menu labelled "Clear cookies (Default)" wiped every partition. The view now resolves the default the same way it resolves the name it displays, and the prop is required so the gap cannot come back. Clearing also only touched sessions already in the in-memory map. Deriving the partition string does not create the session, so clearing a profile with no tab open this run reported success and deleted nothing; the handler now loads the session first. `resolveBrowserProfiles` additionally drops repeated ids, which map to one partition and would otherwise show as two isolated identities sharing every cookie, and reports a custom `incognito` profile as persistent, since persistence is keyed off the built-in id alone. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop/src/ipc/methods/preview.ts | 5 ++ .../src/preview/BrowserSession.test.ts | 22 ++++++++ .../src/browser/previewWebviewConfigState.ts | 15 ++++-- .../components/preview/PreviewMoreMenu.tsx | 7 ++- .../src/components/preview/PreviewView.tsx | 10 ++-- apps/web/src/components/ui/menu.tsx | 6 ++- packages/contracts/src/browserProfile.test.ts | 50 +++++++++++++++++++ packages/contracts/src/browserProfile.ts | 37 +++++++++++--- 8 files changed, 135 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index a107ab4c936f..2e39c8d33241 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -249,6 +249,11 @@ const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartit ) { if (profileId === undefined) return undefined; const { scope, persistent } = 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); return [yield* manager.getBrowserPartition(scope, persistent)]; }); diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 50798de916e0..9f2fb0226809 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -192,6 +192,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/web/src/browser/previewWebviewConfigState.ts b/apps/web/src/browser/previewWebviewConfigState.ts index 2da18eede183..6decff578248 100644 --- a/apps/web/src/browser/previewWebviewConfigState.ts +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -62,14 +62,23 @@ export const loadPreviewWebviewConfig = ( * `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}\u0000${profileId ?? ""}`; + `${environmentId}${CONFIG_KEY_DELIMITER}${profileId ?? ""}`; const parseConfigKey = (key: string): { environmentId: EnvironmentId; profileId?: string } => { - const [environmentId = "", profileId = ""] = key.split("\u0000"); + 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: environmentId as EnvironmentId, + environmentId, ...(profileId === "" ? {} : { profileId }), }; }; diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 81d482dbc0fd..a252b85fb41c 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -55,7 +55,12 @@ interface Props { /** Environment the tab belongs to; scopes storage clearing to its partitions. */ environmentId: EnvironmentId; /** Profile the tab was opened under, if the server recorded one. */ - profileId: string | undefined; + /** + * 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; } diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 3f0027fda03d..1096a82a3a17 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -145,9 +145,11 @@ export function PreviewView({ const controller = desktopOverlay?.controller ?? "none"; const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; const browserDefaults = useBrowserDefaults(); - const activeProfile = browserDefaults.profiles.find( - (profile) => profile.id === (snapshot?.profileId ?? browserDefaults.profileId), - ); + // A tab created before profiles existed carries no profile of its own, so it + // runs in — and must clear — the configured default. Passing the snapshot's + // raw `undefined` through would reach the IPC layer as "every profile". + const activeProfileId = snapshot?.profileId ?? browserDefaults.profileId; + const activeProfile = browserDefaults.profiles.find((profile) => profile.id === activeProfileId); const panelRect = useBrowserSurfaceStore((state) => runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, ); @@ -704,7 +706,7 @@ export function PreviewView({ previewBridge ? ( svg:first-of-type]:-mx-0.5 flex min-h-8 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:cursor-not-allowed data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&>svg:first-of-type:not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&>svg]:shrink-0", className, )} data-inset={inset} diff --git a/packages/contracts/src/browserProfile.test.ts b/packages/contracts/src/browserProfile.test.ts index 5ee606c1de5b..8686498e0362 100644 --- a/packages/contracts/src/browserProfile.test.ts +++ b/packages/contracts/src/browserProfile.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; +import { Schema } from "effect"; + import { + BrowserProfileId, BUILT_IN_BROWSER_PROFILES, DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID, @@ -59,3 +62,50 @@ describe("isBuiltInBrowserProfileId", () => { 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 index 79742898e26e..2a2afa71f134 100644 --- a/packages/contracts/src/browserProfile.ts +++ b/packages/contracts/src/browserProfile.ts @@ -19,7 +19,15 @@ import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const BROWSER_PROFILE_NAME_MAX_LENGTH = 48; export const BROWSER_PROFILE_MAX_COUNT = 24; -export const BrowserProfileId = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); +/** + * 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( @@ -57,17 +65,30 @@ export function isBuiltInBrowserProfileId(id: string): boolean { } /** - * The full picker list: built-ins first, then the user's own profiles with any - * entry that collides with a built-in id dropped, so a hand-edited settings - * file cannot shadow "Default" or "Incognito". + * 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 { - return [ - ...BUILT_IN_BROWSER_PROFILES, - ...userProfiles.filter((profile) => !isBuiltInBrowserProfileId(profile.id)), - ]; + 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( From 040e929071538540e94623fe45aca92a10b9287d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 01:02:53 +0200 Subject: [PATCH 08/35] fix(web): keep the profile chrome from crowding its neighbours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leading-icon rules added for the Browser sub-trigger were scoped with `:first-of-type`, which on a sub-trigger with no leading icon matches the trailing chevron instead — the compound selector outranks its `ms-auto` and took away the right alignment on the existing Appearance and Turn triggers. Scoping away from the last child leaves the chevron alone. The profile badge in the chrome row was unbounded while profile names run to 48 characters, so it took width from the URL input, the only flexible element there. It is capped and truncated. Removing a profile now confirms first, like every other destructive action in Settings, and Incognito is no longer offered as — or resolved to — the default profile: as a default it would open every new tab into storage discarded on close, and the settings list and the resolved default now agree on that. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/browser/browserDefaults.test.ts | 43 +++++++++++++++++ apps/web/src/browser/browserDefaults.ts | 11 +++-- .../src/components/preview/PreviewView.tsx | 5 +- .../settings/IntegrationsSettings.tsx | 48 ++++++++++++++++++- apps/web/src/components/ui/menu.tsx | 7 +-- 5 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/browser/browserDefaults.test.ts 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 d7db50c00962..eaae409568a2 100644 --- a/apps/web/src/browser/browserDefaults.ts +++ b/apps/web/src/browser/browserDefaults.ts @@ -15,7 +15,6 @@ */ import { DEFAULT_BROWSER_PROFILE_ID, - findBrowserProfile, resolveBrowserProfiles, type BrowserProfile, type DesktopPreviewTabDefaults, @@ -57,9 +56,15 @@ const toBrowserDefaults = (settings: { 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: - findBrowserProfile(profiles, settings.browserDefaultProfileId)?.id ?? - DEFAULT_BROWSER_PROFILE_ID, + profiles.find( + (profile) => + profile.id === settings.browserDefaultProfileId && profile.kind !== "incognito", + )?.id ?? DEFAULT_BROWSER_PROFILE_ID, }; }; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 1096a82a3a17..8f5173bb837a 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -697,7 +697,10 @@ export function PreviewView({ // "Default" would be noise on the common case, while a tab in // another profile is exactly what needs calling out. activeProfile && activeProfile.id !== browserDefaults.profileId ? ( - + // Capped and truncated: 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. + {activeProfile.name} ) : null diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index f0e16736b153..4a117f95d6ec 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -8,6 +8,7 @@ */ import { BROWSER_PROFILE_MAX_COUNT, + type BrowserProfile, BROWSER_PROFILE_NAME_MAX_LENGTH, BROWSER_RECORDING_FRAME_RATES, DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, @@ -30,6 +31,7 @@ import { } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; 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"; @@ -39,6 +41,15 @@ import { usePrimaryEnvironment } 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"; @@ -525,6 +536,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); const updateSettings = useUpdatePrimarySettings(); const environmentId = usePrimaryEnvironment()?.environmentId; + const [profilePendingRemoval, setProfilePendingRemoval] = useState(null); const addProfile = () => { if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return; @@ -550,6 +562,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { }; const removeProfile = (id: string) => { + setProfilePendingRemoval(null); // Drop the partition's data too, otherwise a removed profile's cookies // stay on disk with nothing in the UI pointing at them. if (environmentId) { @@ -612,7 +625,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { variant="ghost-muted" disabled={disabled} aria-label={`Remove ${profile.name}`} - onClick={() => removeProfile(profile.id)} + onClick={() => setProfilePendingRemoval(profile)} > @@ -625,6 +638,33 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { ); })} + { + if (!open) setProfilePendingRemoval(null); + }} + > + + + Remove “{profilePendingRemoval?.name}”? + + Its cookies, logins, and cache are deleted with it. Tabs open in this profile move to + the default one. + + + + }>Cancel + + + + ); } @@ -633,7 +673,11 @@ function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean const userProfiles = useClientSettings((settings) => settings.browserProfiles); const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); const updateSettings = useUpdatePrimarySettings(); - const profiles = resolveBrowserProfiles(userProfiles); + // 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 ( diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index ca6d60f74e31..d7892cb228ab 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -237,9 +237,10 @@ function MenuSubTrigger({ className={cn( // Leading-icon treatment matches `MenuItem`: a sub-trigger sits in the // same column as the items around it, so its icon has to align and dim - // with theirs. Scoped to the first svg because the chevron below is - // also a direct child, and `-mx-0.5` would override its `-me-0.5`. - "[&>svg:first-of-type]:-mx-0.5 flex min-h-8 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:cursor-not-allowed data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&>svg:first-of-type:not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&>svg]:shrink-0", + // with theirs. Scoped away from the last child because the chevron is + // also a direct svg — on a sub-trigger with no leading icon it is the + // only one, and these rules would take away its `ms-auto` alignment. + "[&>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} From a1568c0331b23f813b56690756619573449536c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 02:54:14 +0200 Subject: [PATCH 09/35] fix(web): give the profile badge a real ellipsis `Badge` is an `inline-flex` with `whitespace-nowrap`, so `truncate` on the badge never reached the name inside it: a long profile name was hard-clipped at both ends with no ellipsis. The cap stays on the badge, the truncation moves to an inner span, and the full name is available as a title. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/preview/PreviewView.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 8f5173bb837a..1b16a049066b 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -697,11 +697,15 @@ export function PreviewView({ // "Default" would be noise on the common case, while a tab in // another profile is exactly what needs calling out. activeProfile && activeProfile.id !== browserDefaults.profileId ? ( - // Capped and truncated: 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. - - {activeProfile.name} + // 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. + + {activeProfile.name} ) : null } From 5cd1e0c6b4f5893f41987df2a4fe2f031e542347 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 03:15:49 +0200 Subject: [PATCH 10/35] fix(web): dim the profile list with the rest of the desktop-only block Built-in rows are a plain span and a badge rather than `h3`/`p` or disabled controls, so the block's own dimming never reached them: on web they were the only full-contrast content inside "only available in the desktop app". Also switches `browserProfile` to the subpath namespace import the rest of `packages/contracts` uses. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/settings/IntegrationsSettings.tsx | 10 ++++++++-- packages/contracts/src/browserProfile.test.ts | 2 +- packages/contracts/src/browserProfile.ts | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 4a117f95d6ec..ffd67e093dc7 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -36,7 +36,7 @@ import type { ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { previewBridge } from "~/components/preview/previewBridge"; -import { randomUUID } from "~/lib/utils"; +import { cn, randomUUID } from "~/lib/utils"; import { usePrimaryEnvironment } from "~/state/environments"; import { isElectron } from "../../env"; @@ -592,7 +592,13 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { } > -
+ {/* + Dimmed as a whole when the section is unavailable. The built-in rows + are a plain span and a badge rather than `h3`/`p` or disabled controls, + so the block's own dimming does not reach them and they would be the + only full-contrast content inside "only available in the desktop app". + */} +
{resolveBrowserProfiles(userProfiles).map((profile) => { const builtIn = isBuiltInBrowserProfileId(profile.id); return ( diff --git a/packages/contracts/src/browserProfile.test.ts b/packages/contracts/src/browserProfile.test.ts index 8686498e0362..d53423bd6eef 100644 --- a/packages/contracts/src/browserProfile.test.ts +++ b/packages/contracts/src/browserProfile.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { BrowserProfileId, diff --git a/packages/contracts/src/browserProfile.ts b/packages/contracts/src/browserProfile.ts index 2a2afa71f134..39dd58dfb336 100644 --- a/packages/contracts/src/browserProfile.ts +++ b/packages/contracts/src/browserProfile.ts @@ -13,7 +13,7 @@ * * @module BrowserProfile */ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const BROWSER_PROFILE_NAME_MAX_LENGTH = 48; From 56b048244170930517c302e56efd32bb783675eb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 03:36:03 +0200 Subject: [PATCH 11/35] fix(web): dim only the row content that has no disabled state of its own The wrapper-level dim stacked with each control's own: the rename field and remove button composited to roughly 0.41 alpha while every other disabled control in the desktop-only block sits at 0.64. Only the built-in row's name and badge lack a disabled treatment, so the dim belongs there. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/IntegrationsSettings.tsx | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index ffd67e093dc7..c0860de0dce2 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -592,19 +592,23 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { } > - {/* - Dimmed as a whole when the section is unavailable. The built-in rows - are a plain span and a badge rather than `h3`/`p` or disabled controls, - so the block's own dimming does not reach them and they would be the - only full-contrast content inside "only available in the desktop app". - */} -
+
{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"} From 904f7bd642db7b5097f838a5d9b332e57ced1043 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 03:54:15 +0200 Subject: [PATCH 12/35] fix(web): keep profile names from stretching the menus Profile names are user-supplied and run to 48 characters. The Browser sub-menu rendered them bare inside an unbounded popup, so a long one widened it to fit-content and wrapped; it is now capped and truncated like the other name-bearing menus. The clear actions repeated the name their own group heading already shows, which drove the popup far past its width for no added information. The heading keeps the profile and the actions keep fixed-length labels. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/RightPanelTabs.tsx | 9 +++++++-- .../src/components/preview/PreviewMoreMenu.tsx | 15 ++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 44c0d979286f..b53c9a205484 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -948,13 +948,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { {action.label} - + {/* + 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} + {profile.name} ))} diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index a252b85fb41c..45c5fbf98b8c 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -200,18 +200,27 @@ export function PreviewMoreMenu({ at open and nothing else in the chrome shows it. */} - {profileName ? Profile: {profileName} : null} + {/* + 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 ? ( + + Profile: {profileName} + + ) : null} void bridge.clearCookies(environmentId, profileId).catch(() => undefined) } > - {profileName ? `Clear cookies (${profileName})` : "Clear cookies"} + Clear cookies void bridge.clearCache(environmentId, profileId).catch(() => undefined)} > - {profileName ? `Clear cache (${profileName})` : "Clear cache"} + Clear cache From 1baec7ea27f5e4e4d8c475d27d8b3e94271c00c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 04:18:56 +0200 Subject: [PATCH 13/35] fix(web): make each profile its own row, and truncate the menu heading Bare rows stack on narrow viewports with a larger gap inside a row than between rows, so the remove button read as belonging to the profile below. Each profile is now a bounded row, and the list carries the bottom spacing `SettingsRow` leaves to its children. `MenuGroupLabel` renders a block box, so `text-overflow` on an inline span inside it never applied and a long profile name pushed the popup past its width. The truncation sits on the label itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/preview/PreviewMoreMenu.tsx | 7 ++++--- .../components/settings/IntegrationsSettings.tsx | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 45c5fbf98b8c..7080fb5238f4 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -206,9 +206,10 @@ export function PreviewMoreMenu({ each one drove the popup far past its width. */} {profileName ? ( - - Profile: {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} diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index c0860de0dce2..1594c895e637 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -592,11 +592,23 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { } > -
+ {/* + 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: From bccf188b33612a00877320e4a040a45635db2306 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 04:54:23 +0200 Subject: [PATCH 14/35] fix(web): clear the partition a legacy tab actually runs in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tab created before profiles existed carries no profile of its own and runs in the built-in `default` partition — the scope the browser used before profiles. It was labelled with, and cleared against, whatever profile is configured as the default now, so on a machine with a custom default the active tab's data was left untouched while another profile's was wiped. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/RightPanelTabs.tsx | 3 --- apps/web/src/components/preview/PreviewView.tsx | 12 ++++++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b53c9a205484..a3fbf6ecd574 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -617,9 +617,6 @@ function SurfaceIcon({ export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; - // Controlled so the submenu trigger's own action can dismiss the menu; a - // submenu trigger does not close it the way a plain item does. - const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 1b16a049066b..15ff07c8b12f 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, @@ -145,10 +146,13 @@ 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, so it - // runs in — and must clear — the configured default. Passing the snapshot's - // raw `undefined` through would reach the IPC layer as "every profile". - const activeProfileId = snapshot?.profileId ?? browserDefaults.profileId; + // 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 activeProfile = browserDefaults.profiles.find((profile) => profile.id === activeProfileId); const panelRect = useBrowserSurfaceStore((state) => runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, From e190ae5ae4794cf3af05ec815d23a6662590f712 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 28 Aug 2026 23:48:06 -0700 Subject: [PATCH 15/35] fix(desktop): avoid browser partition scope collisions --- apps/desktop/src/ipc/methods/preview.test.ts | 28 +++++++++++++++++++- apps/desktop/src/ipc/methods/preview.ts | 6 +++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index e7770dc629dd..1d491e0e6818 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,28 @@ 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%3A%3Ac", persistent: true }); + expect(second).toEqual({ scope: "a%3A%3Ab::c", persistent: true }); + expect(first.scope).not.toBe(second.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%3A%3Alegacy::incognito", persistent: false }); + }); + 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 2e39c8d33241..46cab298513f 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -224,15 +224,17 @@ export const clearCache = DesktopIpc.makeIpcMethod({ * existed, so upgrading does not strand anyone's existing logins in an * orphaned partition. Incognito derives a non-persistent partition. */ -function resolvePartitionScope( +export function resolvePartitionScope( environmentId: string, profileId: string | undefined, ): { readonly scope: string; readonly persistent: boolean } { if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) { return { scope: environmentId, persistent: true }; } + // Encode each component independently so delimiters inside either id cannot + // make two distinct pairs hash to the same Electron partition. return { - scope: `${environmentId}::${profileId}`, + scope: `${encodeURIComponent(environmentId)}::${encodeURIComponent(profileId)}`, persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID, }; } From 52548a2ad7c432228fac47dbb2d6c886ce9603e5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 28 Aug 2026 23:55:46 -0700 Subject: [PATCH 16/35] fix(web): theme profile badge tooltip --- apps/web/src/components/preview/PreviewView.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 15ff07c8b12f..69f73c5ca219 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -63,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; @@ -708,9 +709,12 @@ export function PreviewView({ // `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. - - {activeProfile.name} - + + }> + {activeProfile.name} + + {activeProfile.name} + ) : null } trailingActions={ From 59cc0ff8c0440d25cc9d5f2ba31ab2b1fb177ecd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 28 Aug 2026 23:55:55 -0700 Subject: [PATCH 17/35] style(web): format hosted browser webview --- apps/web/src/browser/HostedBrowserWebview.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 49e4a659718b..564a2453b2be 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -56,8 +56,16 @@ export function HostedBrowserWebview(props: { readonly profileId: string | undefined; readonly zoomFactor: number; }) { - const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor, profileId } = - props; + const { + threadRef, + tabId, + runtimeTabId, + initialUrl, + viewport, + pictureInPicture, + zoomFactor, + profileId, + } = props; const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); From ccff475200bb21c09ddd9558bf6363f0cd66c040 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 28 Aug 2026 23:56:57 -0700 Subject: [PATCH 18/35] fix(web): hydrate preview open defaults --- apps/web/src/browser/openFileInPreview.ts | 11 +++- .../preview/openTerminalLinkInPreview.test.ts | 60 ++++++++++++++++++- .../preview/openTerminalLinkInPreview.ts | 11 +++- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index b24a0ceabe14..f506e42e73e5 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -23,7 +23,11 @@ import { } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; -import { browserDefaultOpenProfileId, browserDefaultOpenViewport } from "./browserDefaults"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "./browserDefaults"; export const isBrowserPreviewFile = (path: string): boolean => /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); @@ -44,6 +48,7 @@ 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: { @@ -52,8 +57,8 @@ export async function openUrlInPreview(input: { // Built here rather than via `openPreviewSession` because this path // maps the result differently, so the configured defaults have to be // applied explicitly or file/link opens would ignore them. - viewport: browserDefaultOpenViewport(), - profileId: browserDefaultOpenProfileId(), + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), }, }); return mapAtomCommandResult(result, (snapshot) => { 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 b0b4829670b6..f5725fc2acfa 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -3,7 +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 } from "~/browser/browserDefaults"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "~/browser/browserDefaults"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; import { recordVisitForThread } from "~/browserHistoryStore"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -83,6 +87,7 @@ export async function openTerminalLinkInPreview( } if (choice === "open-in-preview") { + const defaults = await resolveBrowserDefaults(); const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { @@ -90,8 +95,8 @@ export async function openTerminalLinkInPreview( url: input.url, // Same reason as `openUrlInPreview`: this path handles its own result // mapping, so the configured defaults are applied explicitly. - viewport: browserDefaultOpenViewport(), - profileId: browserDefaultOpenProfileId(), + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), }, }); if (result._tag === "Failure") { From ac3c431e2ff93963a5f13502dca4754b6d7e2174 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 28 Aug 2026 23:57:27 -0700 Subject: [PATCH 19/35] fix(web): show browser panel shortcut --- apps/web/src/components/RightPanelTabs.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index a3fbf6ecd574..9db0e35597b8 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -937,6 +937,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { return ( { setAddSurfaceMenuOpen(false); action.onClick(); @@ -944,6 +945,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { > {action.label} + {action.shortcut} {/* Capped and truncated: profile names are user-supplied From 944d63d2fc558f52b07d8b031a9ef202f259644b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 28 Aug 2026 23:58:29 -0700 Subject: [PATCH 20/35] fix(web): open browser profiles on touch --- .../src/components/RightPanelTabs.test.tsx | 9 +++++++++ apps/web/src/components/RightPanelTabs.tsx | 19 ++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 9390bfb591e4..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]> = {}, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 9db0e35597b8..cb654f761034 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -110,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.", @@ -938,7 +944,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { { + onClick={(event) => { + const pointerType = + "pointerType" in event.nativeEvent && + typeof event.nativeEvent.pointerType === "string" + ? event.nativeEvent.pointerType + : undefined; + // Touch has no hover path to the profile choices: + // its first tap opens the submenu, then a profile + // is selected there. Mouse click keeps the common + // default-profile action at one click. + if (!shouldOpenDefaultBrowserProfileFromMenuClick(pointerType)) + return; setAddSurfaceMenuOpen(false); action.onClick(); }} From efd952247f905551576117aa4b56cdb0010ebd1d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 28 Aug 2026 23:59:58 -0700 Subject: [PATCH 21/35] fix(web): keep profiles when cleanup fails --- .../IntegrationsSettings.logic.test.ts | 44 +++++++++++++++ .../settings/IntegrationsSettings.tsx | 55 ++++++++++++++++--- 2 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/settings/IntegrationsSettings.logic.test.ts 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..9cc9a7e6a573 --- /dev/null +++ b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { clearBrowserProfileData } from "./IntegrationsSettings"; + +const environmentId = "environment-a" 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("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(); + }); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 1594c895e637..d78f869521cf 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -9,6 +9,7 @@ import { BROWSER_PROFILE_MAX_COUNT, type BrowserProfile, + type EnvironmentId, BROWSER_PROFILE_NAME_MAX_LENGTH, BROWSER_RECORDING_FRAME_RATES, DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, @@ -82,6 +83,27 @@ 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), + ]), + ); +} + /** * 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 @@ -537,6 +559,8 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { const updateSettings = useUpdatePrimarySettings(); const environmentId = usePrimaryEnvironment()?.environmentId; const [profilePendingRemoval, setProfilePendingRemoval] = useState(null); + const [profileRemovalError, setProfileRemovalError] = useState(null); + const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false); const addProfile = () => { if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return; @@ -561,19 +585,25 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { }); }; - const removeProfile = (id: string) => { - setProfilePendingRemoval(null); + const removeProfile = async (id: string) => { + setProfileRemovalError(null); + setProfileRemovalInFlight(true); // Drop the partition's data too, otherwise a removed profile's cookies // stay on disk with nothing in the UI pointing at them. - if (environmentId) { - void previewBridge?.clearCookies(environmentId, id).catch(() => undefined); - void previewBridge?.clearCache(environmentId, id).catch(() => undefined); + try { + await clearBrowserProfileData(previewBridge, environmentId ? [environmentId] : [], id); + } catch { + setProfileRemovalError("Profile data could not be deleted. Try again."); + setProfileRemovalInFlight(false); + return; } updateSettings({ browserProfiles: userProfiles.filter((profile) => profile.id !== id), // Reassign the default rather than leaving it pointing at nothing. ...(defaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}), }); + setProfileRemovalInFlight(false); + setProfilePendingRemoval(null); }; return ( @@ -663,7 +693,10 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { { - if (!open) setProfilePendingRemoval(null); + if (!open && !profileRemovalInFlight) { + setProfilePendingRemoval(null); + setProfileRemovalError(null); + } }} > @@ -673,16 +706,22 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { Its cookies, logins, and cache are deleted with it. Tabs open in this profile move to the default one. + {profileRemovalError ? ( +

+ {profileRemovalError} +

+ ) : null} }>Cancel
From 2a74df7243eb5352cccc77c7a904c7e4cc07d075 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 00:00:28 -0700 Subject: [PATCH 22/35] fix(web): clear profiles from every environment --- .../IntegrationsSettings.logic.test.ts | 21 +++++++++++++++++++ .../settings/IntegrationsSettings.tsx | 12 +++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts index 9cc9a7e6a573..2e55363b613c 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts +++ b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts @@ -4,6 +4,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { 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 () => { @@ -16,6 +17,26 @@ describe("clearBrowserProfileData", () => { 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( diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index d78f869521cf..7a0e19916949 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -38,7 +38,7 @@ import type { ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { previewBridge } from "~/components/preview/previewBridge"; import { cn, randomUUID } from "~/lib/utils"; -import { usePrimaryEnvironment } from "~/state/environments"; +import { useEnvironments } from "~/state/environments"; import { isElectron } from "../../env"; import { Badge } from "../ui/badge"; @@ -557,7 +557,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { const userProfiles = useClientSettings((settings) => settings.browserProfiles); const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); const updateSettings = useUpdatePrimarySettings(); - const environmentId = usePrimaryEnvironment()?.environmentId; + const { environments, isReady: environmentsReady } = useEnvironments(); const [profilePendingRemoval, setProfilePendingRemoval] = useState(null); const [profileRemovalError, setProfileRemovalError] = useState(null); const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false); @@ -591,7 +591,11 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { // 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, environmentId ? [environmentId] : [], id); + await clearBrowserProfileData( + previewBridge, + environmentsReady ? environments.map((environment) => environment.environmentId) : [], + id, + ); } catch { setProfileRemovalError("Profile data could not be deleted. Try again."); setProfileRemovalInFlight(false); @@ -675,7 +679,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { } /> - Remove profile and its data + + {removalAvailable + ? "Remove profile and its data" + : "Connect to an environment to remove this profile"} + )}
@@ -720,6 +741,11 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { {profileRemovalError}

) : null} + {!removalAvailable ? ( +

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

+ ) : null} + + + } /> From b1c6b4ade5744f1da1ada38e1fe4a488d1c0945c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 00:46:00 -0700 Subject: [PATCH 31/35] fix(web): wait for settings before profile writes --- .../settings/IntegrationsSettings.tsx | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index c1efb706f92b..46c77e9679e9 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -68,6 +68,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { getClientSettings, useClientSettings, + useClientSettingsHydrated, usePrimarySettings, useUpdatePrimarySettings, } from "~/hooks/useSettings"; @@ -564,6 +565,7 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode */ 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); @@ -574,8 +576,10 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { 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)); @@ -590,6 +594,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { }; 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; @@ -601,6 +606,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { }; const removeProfile = async (id: string) => { + if (!settingsHydrated) return; if (!removalAvailable) { setProfileRemovalError("Connect to an environment before removing this profile."); return; @@ -640,7 +646,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {