| author | |
| committer | |
| log | 134d51096ea0d00a53a499e8f0c87e31fafb0006 |
| tree | 64e14e1a4c031ac06770dc989fe27c85797fc787 |
| parent | 28ddaf75917140e5e4355d4386bc5d14d9dad7b6 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>40 files changed, 1535 insertions(+), 119 deletions(-)
apps/desktop/src/ipc/methods/preview.test.ts+43-1| ... | @@ -1,5 +1,9 @@ | ... | @@ -1,5 +1,9 @@ |
| 1 | import { it as effectIt } from "@effect/vitest"; | 1 | import { it as effectIt } from "@effect/vitest"; |
| 2 | import { PreviewAutomationStatus } from "@t3tools/contracts"; | 2 | import { |
| 3 | DEFAULT_BROWSER_PROFILE_ID, | ||
| 4 | INCOGNITO_BROWSER_PROFILE_ID, | ||
| 5 | PreviewAutomationStatus, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 3 | import * as Cause from "effect/Cause"; | 7 | import * as Cause from "effect/Cause"; |
| 4 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | 9 | import * as Exit from "effect/Exit"; |
| ... | @@ -38,6 +42,44 @@ describe("preview IPC methods", () => { | ... | @@ -38,6 +42,44 @@ describe("preview IPC methods", () => { |
| 38 | expect(fromPartition).not.toHaveBeenCalled(); | 42 | expect(fromPartition).not.toHaveBeenCalled(); |
| 39 | }); | 43 | }); |
| 40 | 44 | ||
| 45 | it("derives distinct partition scopes when identifiers contain the delimiter", () => { | ||
| 46 | const first = PreviewIpc.resolvePartitionScope("a", "b::c"); | ||
| 47 | const second = PreviewIpc.resolvePartitionScope("a::b", "c"); | ||
| 48 | |||
| 49 | expect(first).toEqual({ scope: '["a","b::c"]', persistent: true, namespace: "profile" }); | ||
| 50 | expect(second).toEqual({ scope: '["a::b","c"]', persistent: true, namespace: "profile" }); | ||
| 51 | expect(first.scope).not.toBe(second.scope); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("preserves lone surrogates without collapsing them to replacement characters", () => { | ||
| 55 | const highSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\ud800"); | ||
| 56 | const lowSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\udc00"); | ||
| 57 | const replacement = PreviewIpc.resolvePartitionScope("environment", "profile-�"); | ||
| 58 | |||
| 59 | expect(highSurrogate.scope).toBe('["environment","profile-\\ud800"]'); | ||
| 60 | expect(lowSurrogate.scope).toBe('["environment","profile-\\udc00"]'); | ||
| 61 | expect(highSurrogate.scope).not.toBe(lowSurrogate.scope); | ||
| 62 | expect(highSurrogate.scope).not.toBe(replacement.scope); | ||
| 63 | expect(lowSurrogate.scope).not.toBe(replacement.scope); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("keeps the legacy default partition scope and incognito persistence", () => { | ||
| 67 | expect(PreviewIpc.resolvePartitionScope("environment::legacy", undefined)).toEqual({ | ||
| 68 | scope: "environment::legacy", | ||
| 69 | persistent: true, | ||
| 70 | }); | ||
| 71 | expect( | ||
| 72 | PreviewIpc.resolvePartitionScope("environment::legacy", DEFAULT_BROWSER_PROFILE_ID), | ||
| 73 | ).toEqual({ scope: "environment::legacy", persistent: true }); | ||
| 74 | expect( | ||
| 75 | PreviewIpc.resolvePartitionScope("environment::legacy", INCOGNITO_BROWSER_PROFILE_ID), | ||
| 76 | ).toEqual({ | ||
| 77 | scope: '["environment::legacy","incognito"]', | ||
| 78 | persistent: false, | ||
| 79 | namespace: "profile", | ||
| 80 | }); | ||
| 81 | }); | ||
| 82 | |||
| 41 | effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => | 83 | effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => |
| 42 | Effect.map( | 84 | Effect.map( |
| 43 | PreviewIpc.registerWebview | 85 | PreviewIpc.registerWebview |
apps/desktop/src/ipc/methods/preview.ts+64-9| ... | @@ -16,11 +16,14 @@ import { | ... | @@ -16,11 +16,14 @@ import { |
| 16 | DesktopPreviewScreenshotArtifactSchema, | 16 | DesktopPreviewScreenshotArtifactSchema, |
| 17 | DesktopPreviewSetAudioMutedInputSchema, | 17 | DesktopPreviewSetAudioMutedInputSchema, |
| 18 | DesktopPreviewSetColorSchemeInputSchema, | 18 | DesktopPreviewSetColorSchemeInputSchema, |
| 19 | DesktopPreviewClearDataInputSchema, | ||
| 19 | DesktopPreviewCreateTabInputSchema, | 20 | DesktopPreviewCreateTabInputSchema, |
| 20 | DesktopPreviewTabInputSchema, | 21 | DesktopPreviewTabInputSchema, |
| 21 | DesktopPreviewWebviewConfigSchema, | 22 | DesktopPreviewWebviewConfigSchema, |
| 22 | PreviewAnnotationSubmissionResultSchema, | 23 | PreviewAnnotationSubmissionResultSchema, |
| 23 | PreviewAutomationSnapshot, | 24 | PreviewAutomationSnapshot, |
| 25 | DEFAULT_BROWSER_PROFILE_ID, | ||
| 26 | INCOGNITO_BROWSER_PROFILE_ID, | ||
| 24 | } from "@t3tools/contracts"; | 27 | } from "@t3tools/contracts"; |
| 25 | import * as Effect from "effect/Effect"; | 28 | import * as Effect from "effect/Effect"; |
| 26 | import * as Schema from "effect/Schema"; | 29 | import * as Schema from "effect/Schema"; |
| ... | @@ -196,33 +199,85 @@ export const closePictureInPicture = tabMethod( | ... | @@ -196,33 +199,85 @@ export const closePictureInPicture = tabMethod( |
| 196 | 199 | ||
| 197 | export const clearCookies = DesktopIpc.makeIpcMethod({ | 200 | export const clearCookies = DesktopIpc.makeIpcMethod({ |
| 198 | channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, | 201 | channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, |
| 199 | payload: Schema.Void, | 202 | payload: DesktopPreviewClearDataInputSchema, |
| 200 | result: Schema.Void, | 203 | result: Schema.Void, |
| 201 | handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () { | 204 | handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* ({ environmentId, profileId }) { |
| 202 | const manager = yield* PreviewManager.PreviewManager; | 205 | const manager = yield* PreviewManager.PreviewManager; |
| 203 | yield* manager.clearCookies(); | 206 | yield* manager.clearCookies(yield* resolveClearPartitions(manager, environmentId, profileId)); |
| 204 | }), | 207 | }), |
| 205 | }); | 208 | }); |
| 206 | 209 | ||
| 207 | export const clearCache = DesktopIpc.makeIpcMethod({ | 210 | export const clearCache = DesktopIpc.makeIpcMethod({ |
| 208 | channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, | 211 | channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, |
| 209 | payload: Schema.Void, | 212 | payload: DesktopPreviewClearDataInputSchema, |
| 210 | result: Schema.Void, | 213 | result: Schema.Void, |
| 211 | handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () { | 214 | handler: Effect.fn("desktop.ipc.preview.clearCache")(function* ({ environmentId, profileId }) { |
| 212 | const manager = yield* PreviewManager.PreviewManager; | 215 | const manager = yield* PreviewManager.PreviewManager; |
| 213 | yield* manager.clearCache(); | 216 | yield* manager.clearCache(yield* resolveClearPartitions(manager, environmentId, profileId)); |
| 214 | }), | 217 | }), |
| 215 | }); | 218 | }); |
| 216 | 219 | ||
| 220 | /** | ||
| 221 | * Partition scope for an (environment, profile) pair. | ||
| 222 | * | ||
| 223 | * The default profile keeps the bare environment id it used before profiles | ||
| 224 | * existed, so upgrading does not strand anyone's existing logins in an | ||
| 225 | * orphaned partition. Incognito derives a non-persistent partition. | ||
| 226 | */ | ||
| 227 | export function resolvePartitionScope( | ||
| 228 | environmentId: string, | ||
| 229 | profileId: string | undefined, | ||
| 230 | ): { | ||
| 231 | readonly scope: string; | ||
| 232 | readonly persistent: boolean; | ||
| 233 | readonly namespace?: "profile"; | ||
| 234 | } { | ||
| 235 | if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) { | ||
| 236 | return { scope: environmentId, persistent: true }; | ||
| 237 | } | ||
| 238 | // JSON's tuple framing is injective for strings, including lone UTF-16 | ||
| 239 | // surrogates (which it escapes). URI encoding throws on those supported ids, | ||
| 240 | // while replacing them with U+FFFD would collapse distinct identities. | ||
| 241 | return { | ||
| 242 | scope: JSON.stringify([environmentId, profileId]), | ||
| 243 | persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID, | ||
| 244 | namespace: "profile" as const, | ||
| 245 | }; | ||
| 246 | } | ||
| 247 | |||
| 248 | /** | ||
| 249 | * Clearing without a profile keeps the historical "everything" behaviour for | ||
| 250 | * an explicit all-profiles action; naming a profile confines it to that | ||
| 251 | * profile's partition so one profile's sign-out cannot reach the others. | ||
| 252 | */ | ||
| 253 | const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartitions")(function* ( | ||
| 254 | manager: PreviewManager.PreviewManager["Service"], | ||
| 255 | environmentId: string, | ||
| 256 | profileId: string | undefined, | ||
| 257 | ) { | ||
| 258 | if (profileId === undefined) return undefined; | ||
| 259 | const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); | ||
| 260 | // Loading the session is what puts the partition in the map the clear walks. | ||
| 261 | // Deriving the partition string alone leaves nothing to match, so clearing a | ||
| 262 | // profile with no tab open this run — after a restart, or when deleting a | ||
| 263 | // profile — would report success and delete nothing. | ||
| 264 | yield* manager.getBrowserSession(scope, persistent, namespace); | ||
| 265 | return [yield* manager.getBrowserPartition(scope, persistent, namespace)]; | ||
| 266 | }); | ||
| 267 | |||
| 217 | export const getPreviewConfig = DesktopIpc.makeIpcMethod({ | 268 | export const getPreviewConfig = DesktopIpc.makeIpcMethod({ |
| 218 | channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, | 269 | channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, |
| 219 | payload: DesktopPreviewConfigInputSchema, | 270 | payload: DesktopPreviewConfigInputSchema, |
| 220 | result: DesktopPreviewWebviewConfigSchema, | 271 | result: DesktopPreviewWebviewConfigSchema, |
| 221 | handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId }) { | 272 | handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId, profileId }) { |
| 222 | const manager = yield* PreviewManager.PreviewManager; | 273 | const manager = yield* PreviewManager.PreviewManager; |
| 223 | yield* manager.getBrowserSession(environmentId); | 274 | const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); |
| 275 | // Creating the session first is what installs the UA rewrite and permission | ||
| 276 | // handlers; a guest that attached to an untouched partition would run with | ||
| 277 | // Electron's default UA and Chromium's default permission behaviour. | ||
| 278 | yield* manager.getBrowserSession(scope, persistent, namespace); | ||
| 224 | return { | 279 | return { |
| 225 | partition: yield* manager.getBrowserPartition(environmentId), | 280 | partition: yield* manager.getBrowserPartition(scope, persistent, namespace), |
| 226 | webPreferences: PREVIEW_WEBVIEW_PREFERENCES, | 281 | webPreferences: PREVIEW_WEBVIEW_PREFERENCES, |
| 227 | preloadUrl: NodeURL.pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, | 282 | preloadUrl: NodeURL.pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href, |
| 228 | }; | 283 | }; |
apps/desktop/src/preload.ts+6-4| ... | @@ -222,10 +222,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -222,10 +222,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 222 | ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), | 222 | ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), |
| 223 | openDevTools: (tabId) => | 223 | openDevTools: (tabId) => |
| 224 | ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), | 224 | ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), |
| 225 | clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), | 225 | clearCookies: (environmentId, profileId) => |
| 226 | clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), | 226 | ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }), |
| 227 | getPreviewConfig: (environmentId) => | 227 | clearCache: (environmentId, profileId) => |
| 228 | ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }), | 228 | ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, { environmentId, profileId }), |
| 229 | getPreviewConfig: (environmentId, profileId) => | ||
| 230 | ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId, profileId }), | ||
| 229 | setAnnotationTheme: (theme) => | 231 | setAnnotationTheme: (theme) => |
| 230 | ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }), | 232 | ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }), |
| 231 | pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), | 233 | pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), |
apps/desktop/src/preview/BrowserSession.test.ts+61| ... | @@ -63,6 +63,45 @@ describe("BrowserSession", () => { | ... | @@ -63,6 +63,45 @@ describe("BrowserSession", () => { |
| 63 | }).pipe(Effect.provide(layer)), | 63 | }).pipe(Effect.provide(layer)), |
| 64 | ); | 64 | ); |
| 65 | 65 | ||
| 66 | it.effect("keeps scopes that differ only by a lone surrogate in separate partitions", () => | ||
| 67 | Effect.gen(function* () { | ||
| 68 | const browserSessions = yield* BrowserSession.BrowserSession; | ||
| 69 | |||
| 70 | // TextEncoder folds a lone surrogate to U+FFFD, so without escaping these | ||
| 71 | // two supported ids would hash to one partition and share every cookie. | ||
| 72 | const loneSurrogate = yield* browserSessions.getPartition("p\ud800"); | ||
| 73 | const replacementChar = yield* browserSessions.getPartition("p\ufffd"); | ||
| 74 | assert.notStrictEqual(loneSurrogate, replacementChar); | ||
| 75 | |||
| 76 | // The escape can't be forged with a literal backslash either. | ||
| 77 | const literal = yield* browserSessions.getPartition("p\\ud800"); | ||
| 78 | assert.notStrictEqual(literal, loneSurrogate); | ||
| 79 | |||
| 80 | // And a well-formed scope still lands on its historical partition. | ||
| 81 | assert.strictEqual( | ||
| 82 | yield* browserSessions.getPartition("scope-a"), | ||
| 83 | "persist:t3code-preview-f051bb2c68cb7b2fe969", | ||
| 84 | ); | ||
| 85 | }).pipe(Effect.provide(layer)), | ||
| 86 | ); | ||
| 87 | |||
| 88 | it.effect("keeps legacy defaults disjoint from nondefault profile partitions", () => | ||
| 89 | Effect.gen(function* () { | ||
| 90 | const browserSessions = yield* BrowserSession.BrowserSession; | ||
| 91 | |||
| 92 | // These share the same scope string: default environment `a::b`, and | ||
| 93 | // environment `a` with nondefault profile `b`. | ||
| 94 | const legacyDefault = yield* browserSessions.getPartition("a::b"); | ||
| 95 | const nondefaultProfile = yield* browserSessions.getPartition("a::b", true, "profile"); | ||
| 96 | |||
| 97 | assert.strictEqual(legacyDefault, "persist:t3code-preview-78f0be89237d77f7a70e"); | ||
| 98 | assert.strictEqual(nondefaultProfile, "persist:t3code-preview-profile-78f0be89237d77f7a70e"); | ||
| 99 | assert.notStrictEqual(nondefaultProfile, legacyDefault); | ||
| 100 | assert.isTrue(browserSessions.isPartition(legacyDefault)); | ||
| 101 | assert.isTrue(browserSessions.isPartition(nondefaultProfile)); | ||
| 102 | }).pipe(Effect.provide(layer)), | ||
| 103 | ); | ||
| 104 | |||
| 66 | it.effect("grants clipboard-sanitized-write through both the request and check handlers", () => | 105 | it.effect("grants clipboard-sanitized-write through both the request and check handlers", () => |
| 67 | Effect.gen(function* () { | 106 | Effect.gen(function* () { |
| 68 | const browserSessions = yield* BrowserSession.BrowserSession; | 107 | const browserSessions = yield* BrowserSession.BrowserSession; |
| ... | @@ -192,6 +231,28 @@ describe("BrowserSession", () => { | ... | @@ -192,6 +231,28 @@ describe("BrowserSession", () => { |
| 192 | }).pipe(Effect.provide(layer)), | 231 | }).pipe(Effect.provide(layer)), |
| 193 | ); | 232 | ); |
| 194 | 233 | ||
| 234 | it.effect("clears a partition whose session has not been opened yet", () => | ||
| 235 | Effect.gen(function* () { | ||
| 236 | const browserSessions = yield* BrowserSession.BrowserSession; | ||
| 237 | const partition = yield* browserSessions.getPartition("scope-untouched"); | ||
| 238 | |||
| 239 | // Deriving the partition string does not create the session, and the | ||
| 240 | // clear only walks sessions it already holds. Without loading it first | ||
| 241 | // this reports success and deletes nothing — which is what a user | ||
| 242 | // clearing a profile after a restart would get. | ||
| 243 | assert.isUndefined(sessions.get(partition)); | ||
| 244 | yield* browserSessions.clearCookies([partition]); | ||
| 245 | assert.isUndefined(sessions.get(partition)); | ||
| 246 | |||
| 247 | yield* browserSessions.getSession("scope-untouched"); | ||
| 248 | yield* browserSessions.clearCookies([partition]); | ||
| 249 | |||
| 250 | const created = sessions.get(partition); | ||
| 251 | assert.isDefined(created); | ||
| 252 | assert.strictEqual(created.clearStorageData.mock.calls.length, 1); | ||
| 253 | }).pipe(Effect.provide(layer)), | ||
| 254 | ); | ||
| 255 | |||
| 195 | it.effect("correlates clear failures while still attempting every session", () => | 256 | it.effect("correlates clear failures while still attempting every session", () => |
| 196 | Effect.gen(function* () { | 257 | Effect.gen(function* () { |
| 197 | const browserSessions = yield* BrowserSession.BrowserSession; | 258 | const browserSessions = yield* BrowserSession.BrowserSession; |
apps/desktop/src/preview/BrowserSession.ts+81-13| ... | @@ -10,6 +10,16 @@ import * as Schema from "effect/Schema"; | ... | @@ -10,6 +10,16 @@ import * as Schema from "effect/Schema"; |
| 10 | import * as SynchronizedRef from "effect/SynchronizedRef"; | 10 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 11 | 11 | ||
| 12 | const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; | 12 | const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; |
| 13 | /** | ||
| 14 | * Incognito partitions deliberately omit the `persist:` prefix, which is what | ||
| 15 | * makes Chromium keep them in memory and discard them with the process. They | ||
| 16 | * still carry the product prefix so `isPartition` can admit them — the | ||
| 17 | * `will-attach-webview` gate rejects anything it does not recognise. | ||
| 18 | */ | ||
| 19 | const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-"; | ||
| 20 | const PROFILE_PARTITION_MARKER = "profile-"; | ||
| 21 | |||
| 22 | export type BrowserSessionPartitionNamespace = "profile"; | ||
| 13 | 23 | ||
| 14 | // Permissions granted to preview web content. `clipboard-sanitized-write` is the | 24 | // Permissions granted to preview web content. `clipboard-sanitized-write` is the |
| 15 | // Electron permission behind `navigator.clipboard.writeText()` — note it is NOT | 25 | // Electron permission behind `navigator.clipboard.writeText()` — note it is NOT |
| ... | @@ -99,20 +109,68 @@ export class BrowserSession extends Context.Service< | ... | @@ -99,20 +109,68 @@ export class BrowserSession extends Context.Service< |
| 99 | { | 109 | { |
| 100 | readonly getPartition: ( | 110 | readonly getPartition: ( |
| 101 | scope?: string, | 111 | scope?: string, |
| 112 | persistent?: boolean, | ||
| 113 | namespace?: BrowserSessionPartitionNamespace, | ||
| 102 | ) => Effect.Effect<string, BrowserSessionPartitionDerivationError>; | 114 | ) => Effect.Effect<string, BrowserSessionPartitionDerivationError>; |
| 103 | readonly isPartition: (partition: string) => boolean; | 115 | readonly isPartition: (partition: string) => boolean; |
| 104 | readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionGetSessionError>; | 116 | readonly getSession: ( |
| 105 | readonly clearCookies: () => Effect.Effect<void, BrowserSessionStorageClearError>; | 117 | scope?: string, |
| 106 | readonly clearCache: () => Effect.Effect<void, BrowserSessionCacheClearError>; | 118 | persistent?: boolean, |
| 119 | namespace?: BrowserSessionPartitionNamespace, | ||
| 120 | ) => Effect.Effect<Session, BrowserSessionGetSessionError>; | ||
| 121 | /** Omit `partitions` to clear every known partition. */ | ||
| 122 | readonly clearCookies: ( | ||
| 123 | partitions?: ReadonlyArray<string>, | ||
| 124 | ) => Effect.Effect<void, BrowserSessionStorageClearError>; | ||
| 125 | readonly clearCache: ( | ||
| 126 | partitions?: ReadonlyArray<string>, | ||
| 127 | ) => Effect.Effect<void, BrowserSessionCacheClearError>; | ||
| 107 | } | 128 | } |
| 108 | >()("@t3tools/desktop/preview/BrowserSession") {} | 129 | >()("@t3tools/desktop/preview/BrowserSession") {} |
| 109 | 130 | ||
| 131 | /** | ||
| 132 | * Restricts a clear to the given partitions. Omitting them keeps the historical | ||
| 133 | * "every partition" behaviour, which callers now only use for an explicit | ||
| 134 | * "all profiles" action — a per-profile clear must never reach across profiles. | ||
| 135 | */ | ||
| 136 | const selectSessions = ( | ||
| 137 | sessions: ReadonlyMap<string, Session>, | ||
| 138 | partitions: ReadonlyArray<string> | undefined, | ||
| 139 | ): ReadonlyArray<readonly [string, Session]> => | ||
| 140 | [...sessions.entries()].filter( | ||
| 141 | ([partition]) => partitions === undefined || partitions.includes(partition), | ||
| 142 | ); | ||
| 143 | |||
| 144 | /** | ||
| 145 | * Scope bytes for the partition digest. | ||
| 146 | * | ||
| 147 | * `TextEncoder` replaces a lone UTF-16 surrogate with U+FFFD, so `"p\ud800"` | ||
| 148 | * and `"p\ufffd"` would hash to the same partition and share cookies. Those | ||
| 149 | * are distinct, supported ids, so lone surrogates are escaped to `\uXXXX` | ||
| 150 | * first — and a literal backslash is doubled so the escape cannot be forged. | ||
| 151 | * Every well-formed scope passes through byte-for-byte unchanged, which keeps | ||
| 152 | * existing partitions (and the logins in them) where they are. | ||
| 153 | */ | ||
| 154 | const encodeScopeForDigest = (scope: string): Uint8Array => | ||
| 155 | new TextEncoder().encode( | ||
| 156 | scope | ||
| 157 | .replace(/\\/g, "\\\\") | ||
| 158 | .replace( | ||
| 159 | /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, | ||
| 160 | (unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`, | ||
| 161 | ), | ||
| 162 | ); | ||
| 163 | |||
| 110 | export const make = Effect.gen(function* BrowserSessionMake() { | 164 | export const make = Effect.gen(function* BrowserSessionMake() { |
| 111 | const crypto = yield* Crypto.Crypto; | 165 | const crypto = yield* Crypto.Crypto; |
| 112 | const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map()); | 166 | const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map()); |
| 113 | 167 | ||
| 114 | const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") { | 168 | const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( |
| 115 | const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe( | 169 | scope = "shared", |
| 170 | persistent = true, | ||
| 171 | namespace?: BrowserSessionPartitionNamespace, | ||
| 172 | ) { | ||
| 173 | const digest = yield* crypto.digest("SHA-256", encodeScopeForDigest(scope)).pipe( | ||
| 116 | Effect.mapError( | 174 | Effect.mapError( |
| 117 | (cause) => | 175 | (cause) => |
| 118 | new BrowserSessionPartitionDerivationError({ | 176 | new BrowserSessionPartitionDerivationError({ |
| ... | @@ -121,11 +179,19 @@ export const make = Effect.gen(function* BrowserSessionMake() { | ... | @@ -121,11 +179,19 @@ export const make = Effect.gen(function* BrowserSessionMake() { |
| 121 | }), | 179 | }), |
| 122 | ), | 180 | ), |
| 123 | ); | 181 | ); |
| 124 | return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`; | 182 | const prefix = persistent ? PREVIEW_PARTITION_PREFIX : PREVIEW_EPHEMERAL_PARTITION_PREFIX; |
| 183 | // Legacy/default partitions are prefix + hex digest. The non-hex profile | ||
| 184 | // marker creates a disjoint namespace while leaving every legacy default | ||
| 185 | // partition byte-for-byte unchanged. | ||
| 186 | return `${prefix}${namespace === "profile" ? PROFILE_PARTITION_MARKER : ""}${Encoding.encodeHex(digest).slice(0, 20)}`; | ||
| 125 | }); | 187 | }); |
| 126 | 188 | ||
| 127 | const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") { | 189 | const getSession = Effect.fn("BrowserSession.getSession")(function* ( |
| 128 | const partition = yield* getPartition(scope); | 190 | scope = "shared", |
| 191 | persistent = true, | ||
| 192 | namespace?: BrowserSessionPartitionNamespace, | ||
| 193 | ) { | ||
| 194 | const partition = yield* getPartition(scope, persistent, namespace); | ||
| 129 | return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => { | 195 | return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => { |
| 130 | const existing = sessions.get(partition); | 196 | const existing = sessions.get(partition); |
| 131 | if (existing) return Effect.succeed([existing, sessions] as const); | 197 | if (existing) return Effect.succeed([existing, sessions] as const); |
| ... | @@ -159,12 +225,14 @@ export const make = Effect.gen(function* BrowserSessionMake() { | ... | @@ -159,12 +225,14 @@ export const make = Effect.gen(function* BrowserSessionMake() { |
| 159 | 225 | ||
| 160 | return BrowserSession.of({ | 226 | return BrowserSession.of({ |
| 161 | getPartition, | 227 | getPartition, |
| 162 | isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX), | 228 | isPartition: (partition) => |
| 229 | partition.startsWith(PREVIEW_PARTITION_PREFIX) || | ||
| 230 | partition.startsWith(PREVIEW_EPHEMERAL_PARTITION_PREFIX), | ||
| 163 | getSession, | 231 | getSession, |
| 164 | clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { | 232 | clearCookies: Effect.fn("BrowserSession.clearCookies")(function* (partitions?) { |
| 165 | const sessions = yield* SynchronizedRef.get(sessionsRef); | 233 | const sessions = yield* SynchronizedRef.get(sessionsRef); |
| 166 | yield* Effect.all( | 234 | yield* Effect.all( |
| 167 | [...sessions.entries()].map(([partition, browserSession]) => | 235 | selectSessions(sessions, partitions).map(([partition, browserSession]) => |
| 168 | Effect.tryPromise({ | 236 | Effect.tryPromise({ |
| 169 | try: () => | 237 | try: () => |
| 170 | browserSession.clearStorageData({ | 238 | browserSession.clearStorageData({ |
| ... | @@ -180,10 +248,10 @@ export const make = Effect.gen(function* BrowserSessionMake() { | ... | @@ -180,10 +248,10 @@ export const make = Effect.gen(function* BrowserSessionMake() { |
| 180 | { concurrency: "unbounded", discard: true }, | 248 | { concurrency: "unbounded", discard: true }, |
| 181 | ); | 249 | ); |
| 182 | }), | 250 | }), |
| 183 | clearCache: Effect.fn("BrowserSession.clearCache")(function* () { | 251 | clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) { |
| 184 | const sessions = yield* SynchronizedRef.get(sessionsRef); | 252 | const sessions = yield* SynchronizedRef.get(sessionsRef); |
| 185 | yield* Effect.all( | 253 | yield* Effect.all( |
| 186 | [...sessions.entries()].map(([partition, browserSession]) => | 254 | selectSessions(sessions, partitions).map(([partition, browserSession]) => |
| 187 | Effect.tryPromise({ | 255 | Effect.tryPromise({ |
| 188 | try: () => browserSession.clearCache(), | 256 | try: () => browserSession.clearCache(), |
| 189 | catch: (cause) => | 257 | catch: (cause) => |
apps/desktop/src/preview/Manager.ts+42-26| ... | @@ -4409,7 +4409,11 @@ export class PreviewManager extends Context.Service< | ... | @@ -4409,7 +4409,11 @@ export class PreviewManager extends Context.Service< |
| 4409 | PreviewManager, | 4409 | PreviewManager, |
| 4410 | { | 4410 | { |
| 4411 | readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>; | 4411 | readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>; |
| 4412 | readonly getBrowserSession: (scope?: string) => Effect.Effect<Session, PreviewManagerError>; | 4412 | readonly getBrowserSession: ( |
| 4413 | scope?: string, | ||
| 4414 | persistent?: boolean, | ||
| 4415 | namespace?: BrowserSession.BrowserSessionPartitionNamespace, | ||
| 4416 | ) => Effect.Effect<Session, PreviewManagerError>; | ||
| 4413 | readonly isBrowserPartition: (partition: string) => boolean; | 4417 | readonly isBrowserPartition: (partition: string) => boolean; |
| 4414 | readonly createTab: ( | 4418 | readonly createTab: ( |
| 4415 | tabId: string, | 4419 | tabId: string, |
| ... | @@ -4440,9 +4444,17 @@ export class PreviewManager extends Context.Service< | ... | @@ -4440,9 +4444,17 @@ export class PreviewManager extends Context.Service< |
| 4440 | audioMuted: boolean, | 4444 | audioMuted: boolean, |
| 4441 | ) => Effect.Effect<void, PreviewManagerError>; | 4445 | ) => Effect.Effect<void, PreviewManagerError>; |
| 4442 | readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>; | 4446 | readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 4443 | readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>; | 4447 | readonly clearCookies: ( |
| 4444 | readonly clearCache: () => Effect.Effect<void, PreviewManagerError>; | 4448 | partitions?: ReadonlyArray<string>, |
| 4445 | readonly getBrowserPartition: (scope?: string) => Effect.Effect<string, PreviewManagerError>; | 4449 | ) => Effect.Effect<void, PreviewManagerError>; |
| 4450 | readonly clearCache: ( | ||
| 4451 | partitions?: ReadonlyArray<string>, | ||
| 4452 | ) => Effect.Effect<void, PreviewManagerError>; | ||
| 4453 | readonly getBrowserPartition: ( | ||
| 4454 | scope?: string, | ||
| 4455 | persistent?: boolean, | ||
| 4456 | namespace?: BrowserSession.BrowserSessionPartitionNamespace, | ||
| 4457 | ) => Effect.Effect<string, PreviewManagerError>; | ||
| 4446 | readonly setAnnotationTheme: ( | 4458 | readonly setAnnotationTheme: ( |
| 4447 | theme: DesktopPreviewAnnotationTheme, | 4459 | theme: DesktopPreviewAnnotationTheme, |
| 4448 | ) => Effect.Effect<void, PreviewManagerError>; | 4460 | ) => Effect.Effect<void, PreviewManagerError>; |
| ... | @@ -4514,15 +4526,17 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -4514,15 +4526,17 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 4514 | 4526 | ||
| 4515 | return PreviewManager.of({ | 4527 | return PreviewManager.of({ |
| 4516 | setMainWindow: operations.setMainWindow, | 4528 | setMainWindow: operations.setMainWindow, |
| 4517 | getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) { | 4529 | getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")( |
| 4518 | return yield* browserSession | 4530 | function* (scope, persistent, namespace) { |
| 4519 | .getSession(scope) | 4531 | return yield* browserSession |
| 4520 | .pipe( | 4532 | .getSession(scope, persistent, namespace) |
| 4521 | Effect.mapError( | 4533 | .pipe( |
| 4522 | (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), | 4534 | Effect.mapError( |
| 4523 | ), | 4535 | (cause) => new PreviewOperationError({ operation: "getBrowserSession", cause }), |
| 4524 | ); | 4536 | ), |
| 4525 | }), | 4537 | ); |
| 4538 | }, | ||
| 4539 | ), | ||
| 4526 | isBrowserPartition: browserSession.isPartition, | 4540 | isBrowserPartition: browserSession.isPartition, |
| 4527 | createTab: operations.createTab, | 4541 | createTab: operations.createTab, |
| 4528 | closeTab: operations.closeTab, | 4542 | closeTab: operations.closeTab, |
| ... | @@ -4539,31 +4553,33 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -4539,31 +4553,33 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 4539 | setColorScheme: operations.setColorScheme, | 4553 | setColorScheme: operations.setColorScheme, |
| 4540 | setAudioMuted: operations.setAudioMuted, | 4554 | setAudioMuted: operations.setAudioMuted, |
| 4541 | openDevTools: operations.openDevTools, | 4555 | openDevTools: operations.openDevTools, |
| 4542 | clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { | 4556 | clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) { |
| 4543 | yield* browserSession | 4557 | yield* browserSession |
| 4544 | .clearCookies() | 4558 | .clearCookies(partitions) |
| 4545 | .pipe( | 4559 | .pipe( |
| 4546 | Effect.mapError( | 4560 | Effect.mapError( |
| 4547 | (cause) => new PreviewOperationError({ operation: "clearCookies", cause }), | 4561 | (cause) => new PreviewOperationError({ operation: "clearCookies", cause }), |
| 4548 | ), | 4562 | ), |
| 4549 | ); | 4563 | ); |
| 4550 | }), | 4564 | }), |
| 4551 | clearCache: Effect.fn("PreviewManager.clearCache")(function* () { | 4565 | clearCache: Effect.fn("PreviewManager.clearCache")(function* (partitions) { |
| 4552 | yield* browserSession | 4566 | yield* browserSession |
| 4553 | .clearCache() | 4567 | .clearCache(partitions) |
| 4554 | .pipe( | 4568 | .pipe( |
| 4555 | Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })), | 4569 | Effect.mapError((cause) => new PreviewOperationError({ operation: "clearCache", cause })), |
| 4556 | ); | 4570 | ); |
| 4557 | }), | 4571 | }), |
| 4558 | getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")(function* (scope) { | 4572 | getBrowserPartition: Effect.fn("PreviewManager.getBrowserPartition")( |
| 4559 | return yield* browserSession | 4573 | function* (scope, persistent, namespace) { |
| 4560 | .getPartition(scope) | 4574 | return yield* browserSession |
| 4561 | .pipe( | 4575 | .getPartition(scope, persistent, namespace) |
| 4562 | Effect.mapError( | 4576 | .pipe( |
| 4563 | (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }), | 4577 | Effect.mapError( |
| 4564 | ), | 4578 | (cause) => new PreviewOperationError({ operation: "getBrowserPartition", cause }), |
| 4565 | ); | 4579 | ), |
| 4566 | }), | 4580 | ); |
| 4581 | }, | ||
| 4582 | ), | ||
| 4567 | setAnnotationTheme: operations.setAnnotationTheme, | 4583 | setAnnotationTheme: operations.setAnnotationTheme, |
| 4568 | pickElement: operations.pickElement, | 4584 | pickElement: operations.pickElement, |
| 4569 | cancelPickElement: operations.cancelPickElement, | 4585 | cancelPickElement: operations.cancelPickElement, |
apps/desktop/src/settings/DesktopClientSettings.test.ts+2| ... | @@ -19,6 +19,8 @@ const clientSettings: ClientSettings = { | ... | @@ -19,6 +19,8 @@ const clientSettings: ClientSettings = { |
| 19 | browserDefaultAppearance: "dark", | 19 | browserDefaultAppearance: "dark", |
| 20 | browserRecordingFrameRate: 60, | 20 | browserRecordingFrameRate: 60, |
| 21 | browserAutoShowFloatingPreview: false, | 21 | browserAutoShowFloatingPreview: false, |
| 22 | browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], | ||
| 23 | browserDefaultProfileId: "work", | ||
| 22 | confirmQuit: "double-click", | 24 | confirmQuit: "double-click", |
| 23 | confirmThreadArchive: true, | 25 | confirmThreadArchive: true, |
| 24 | confirmThreadDelete: false, | 26 | confirmThreadDelete: false, |
apps/server/src/preview/Manager.test.ts+31| ... | @@ -58,6 +58,37 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { | ... | @@ -58,6 +58,37 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { |
| 58 | }), | 58 | }), |
| 59 | ); | 59 | ); |
| 60 | 60 | ||
| 61 | it.effect("keeps the tab's profile across navigation and status reports", () => | ||
| 62 | Effect.gen(function* () { | ||
| 63 | const threadId = freshThreadId(); | ||
| 64 | const manager = yield* PreviewManager.PreviewManager; | ||
| 65 | |||
| 66 | const opened = yield* manager.open({ threadId, profileId: "work" }); | ||
| 67 | expect(opened.profileId).toBe("work"); | ||
| 68 | |||
| 69 | // `navigate` and `reportStatus` rebuild the snapshot field by field | ||
| 70 | // rather than spreading it, so a new field is dropped unless carried | ||
| 71 | // explicitly — which would silently move the tab to another profile's | ||
| 72 | // partition on its first navigation. | ||
| 73 | const navigated = yield* manager.navigate({ | ||
| 74 | threadId, | ||
| 75 | tabId: opened.tabId, | ||
| 76 | url: "localhost:5173", | ||
| 77 | }); | ||
| 78 | expect(navigated.profileId).toBe("work"); | ||
| 79 | |||
| 80 | yield* manager.reportStatus({ | ||
| 81 | threadId, | ||
| 82 | tabId: opened.tabId, | ||
| 83 | navStatus: { _tag: "Success", url: "http://localhost:5173/", title: "Dev" }, | ||
| 84 | canGoBack: true, | ||
| 85 | canGoForward: false, | ||
| 86 | }); | ||
| 87 | const listed = yield* manager.list({ threadId }); | ||
| 88 | expect(listed.sessions.find((s) => s.tabId === opened.tabId)?.profileId).toBe("work"); | ||
| 89 | }), | ||
| 90 | ); | ||
| 91 | |||
| 61 | it.effect("opens an Idle tab when no URL is supplied", () => | 92 | it.effect("opens an Idle tab when no URL is supplied", () => |
| 62 | Effect.gen(function* () { | 93 | Effect.gen(function* () { |
| 63 | const threadId = freshThreadId(); | 94 | const threadId = freshThreadId(); |
apps/server/src/preview/Manager.ts+18-1| ... | @@ -123,6 +123,7 @@ const buildLoadingSnapshot = (input: { | ... | @@ -123,6 +123,7 @@ const buildLoadingSnapshot = (input: { |
| 123 | readonly url: string; | 123 | readonly url: string; |
| 124 | readonly title: string; | 124 | readonly title: string; |
| 125 | readonly viewport: PreviewViewportSetting; | 125 | readonly viewport: PreviewViewportSetting; |
| 126 | readonly profileId?: string | undefined; | ||
| 126 | readonly updatedAt: string; | 127 | readonly updatedAt: string; |
| 127 | }): PreviewSessionSnapshot => ({ | 128 | }): PreviewSessionSnapshot => ({ |
| 128 | threadId: input.threadId, | 129 | threadId: input.threadId, |
| ... | @@ -131,6 +132,7 @@ const buildLoadingSnapshot = (input: { | ... | @@ -131,6 +132,7 @@ const buildLoadingSnapshot = (input: { |
| 131 | canGoBack: false, | 132 | canGoBack: false, |
| 132 | canGoForward: false, | 133 | canGoForward: false, |
| 133 | viewport: input.viewport, | 134 | viewport: input.viewport, |
| 135 | ...(input.profileId === undefined ? {} : { profileId: input.profileId }), | ||
| 134 | updatedAt: input.updatedAt, | 136 | updatedAt: input.updatedAt, |
| 135 | }); | 137 | }); |
| 136 | 138 | ||
| ... | @@ -138,6 +140,7 @@ const buildIdleSnapshot = (input: { | ... | @@ -138,6 +140,7 @@ const buildIdleSnapshot = (input: { |
| 138 | readonly threadId: string; | 140 | readonly threadId: string; |
| 139 | readonly tabId: string; | 141 | readonly tabId: string; |
| 140 | readonly viewport: PreviewViewportSetting; | 142 | readonly viewport: PreviewViewportSetting; |
| 143 | readonly profileId?: string | undefined; | ||
| 141 | readonly updatedAt: string; | 144 | readonly updatedAt: string; |
| 142 | }): PreviewSessionSnapshot => ({ | 145 | }): PreviewSessionSnapshot => ({ |
| 143 | threadId: input.threadId, | 146 | threadId: input.threadId, |
| ... | @@ -146,6 +149,7 @@ const buildIdleSnapshot = (input: { | ... | @@ -146,6 +149,7 @@ const buildIdleSnapshot = (input: { |
| 146 | canGoBack: false, | 149 | canGoBack: false, |
| 147 | canGoForward: false, | 150 | canGoForward: false, |
| 148 | viewport: input.viewport, | 151 | viewport: input.viewport, |
| 152 | ...(input.profileId === undefined ? {} : { profileId: input.profileId }), | ||
| 149 | updatedAt: input.updatedAt, | 153 | updatedAt: input.updatedAt, |
| 150 | }); | 154 | }); |
| 151 | 155 | ||
| ... | @@ -229,9 +233,16 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -229,9 +233,16 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 229 | url: yield* normalizeUrl(input.url), | 233 | url: yield* normalizeUrl(input.url), |
| 230 | title: "", | 234 | title: "", |
| 231 | viewport, | 235 | viewport, |
| 236 | profileId: input.profileId, | ||
| 232 | updatedAt, | 237 | updatedAt, |
| 233 | }) | 238 | }) |
| 234 | : buildIdleSnapshot({ threadId: input.threadId, tabId, viewport, updatedAt }); | 239 | : buildIdleSnapshot({ |
| 240 | threadId: input.threadId, | ||
| 241 | tabId, | ||
| 242 | viewport, | ||
| 243 | profileId: input.profileId, | ||
| 244 | updatedAt, | ||
| 245 | }); | ||
| 235 | yield* SynchronizedRef.modifyEffect(stateRef, (state) => | 246 | yield* SynchronizedRef.modifyEffect(stateRef, (state) => |
| 236 | Effect.gen(function* () { | 247 | Effect.gen(function* () { |
| 237 | const revision = state.revision + 1; | 248 | const revision = state.revision + 1; |
| ... | @@ -275,6 +286,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -275,6 +286,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 275 | canGoBack: session.snapshot.canGoBack, | 286 | canGoBack: session.snapshot.canGoBack, |
| 276 | canGoForward: session.snapshot.canGoForward, | 287 | canGoForward: session.snapshot.canGoForward, |
| 277 | viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, | 288 | viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, |
| 289 | ...(session.snapshot.profileId === undefined | ||
| 290 | ? {} | ||
| 291 | : { profileId: session.snapshot.profileId }), | ||
| 278 | updatedAt, | 292 | updatedAt, |
| 279 | }; | 293 | }; |
| 280 | return { | 294 | return { |
| ... | @@ -308,6 +322,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { | ... | @@ -308,6 +322,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 308 | canGoBack: input.canGoBack, | 322 | canGoBack: input.canGoBack, |
| 309 | canGoForward: input.canGoForward, | 323 | canGoForward: input.canGoForward, |
| 310 | viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, | 324 | viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, |
| 325 | ...(session.snapshot.profileId === undefined | ||
| 326 | ? {} | ||
| 327 | : { profileId: session.snapshot.profileId }), | ||
| 311 | updatedAt, | 328 | updatedAt, |
| 312 | }; | 329 | }; |
| 313 | const emit: PreviewEventDraft = | 330 | const emit: PreviewEventDraft = |
apps/web/src/browser/ElectronBrowserHost.tsx+1| ... | @@ -93,6 +93,7 @@ export function ElectronBrowserHost() { | ... | @@ -93,6 +93,7 @@ export function ElectronBrowserHost() { |
| 93 | initialUrl={url} | 93 | initialUrl={url} |
| 94 | viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT} | 94 | viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT} |
| 95 | pictureInPicture={pictureInPicture} | 95 | pictureInPicture={pictureInPicture} |
| 96 | profileId={snapshot.profileId} | ||
| 96 | zoomFactor={zoomFactor} | 97 | zoomFactor={zoomFactor} |
| 97 | /> | 98 | /> |
| 98 | ); | 99 | ); |
apps/web/src/browser/HostedBrowserWebview.tsx+16-3| ... | @@ -49,11 +49,24 @@ export function HostedBrowserWebview(props: { | ... | @@ -49,11 +49,24 @@ export function HostedBrowserWebview(props: { |
| 49 | readonly initialUrl: string | null; | 49 | readonly initialUrl: string | null; |
| 50 | readonly viewport: PreviewViewportSetting; | 50 | readonly viewport: PreviewViewportSetting; |
| 51 | readonly pictureInPicture: boolean; | 51 | readonly pictureInPicture: boolean; |
| 52 | /** | ||
| 53 | * Fixed for the tab's lifetime: Electron only honours `partition` before the | ||
| 54 | * guest attaches, so a live change here would not move the tab anyway. | ||
| 55 | */ | ||
| 56 | readonly profileId: string | undefined; | ||
| 52 | readonly zoomFactor: number; | 57 | readonly zoomFactor: number; |
| 53 | }) { | 58 | }) { |
| 54 | const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor } = | 59 | const { |
| 55 | props; | 60 | threadRef, |
| 56 | const config = usePreviewWebviewConfig(threadRef.environmentId); | 61 | tabId, |
| 62 | runtimeTabId, | ||
| 63 | initialUrl, | ||
| 64 | viewport, | ||
| 65 | pictureInPicture, | ||
| 66 | zoomFactor, | ||
| 67 | profileId, | ||
| 68 | } = props; | ||
| 69 | const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); | ||
| 57 | const [initialSrc] = useState(() => initialUrl ?? "about:blank"); | 70 | const [initialSrc] = useState(() => initialUrl ?? "about:blank"); |
| 58 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); | 71 | const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null); |
| 59 | const wrapperRef = useRef<HTMLDivElement | null>(null); | 72 | const wrapperRef = useRef<HTMLDivElement | null>(null); |
apps/web/src/browser/browserDefaults.test.ts created+43| ... | @@ -0,0 +1,43 @@ | ||
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | const settings = vi.hoisted(() => ({ current: {} as Record<string, unknown> })); | ||
| 5 | |||
| 6 | vi.mock("~/hooks/useSettings", () => ({ | ||
| 7 | getClientSettings: () => settings.current, | ||
| 8 | useClientSettings: () => undefined, | ||
| 9 | ensureClientSettingsHydrated: () => Promise.resolve(), | ||
| 10 | })); | ||
| 11 | |||
| 12 | const { getBrowserDefaults } = await import("./browserDefaults"); | ||
| 13 | |||
| 14 | const withDefaultProfile = (browserDefaultProfileId: string) => { | ||
| 15 | settings.current = { | ||
| 16 | browserDefaultViewport: { _tag: "fill" }, | ||
| 17 | browserDefaultZoomFactor: 1, | ||
| 18 | browserDefaultAppearance: "system", | ||
| 19 | browserAutoShowFloatingPreview: true, | ||
| 20 | browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], | ||
| 21 | browserDefaultProfileId, | ||
| 22 | }; | ||
| 23 | return getBrowserDefaults(); | ||
| 24 | }; | ||
| 25 | |||
| 26 | describe("getBrowserDefaults profile resolution", () => { | ||
| 27 | it("keeps a configured persistent profile", () => { | ||
| 28 | expect(withDefaultProfile("work").profileId).toBe("work"); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("falls back for an unknown profile", () => { | ||
| 32 | expect(withDefaultProfile("deleted").profileId).toBe(DEFAULT_BROWSER_PROFILE_ID); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("refuses incognito as the default", () => { | ||
| 36 | // A stored incognito default would open every new tab into storage that is | ||
| 37 | // discarded on close, and the settings list no longer offers it — so the | ||
| 38 | // row badged "Default" must be the one tabs actually open under. | ||
| 39 | expect(withDefaultProfile(INCOGNITO_BROWSER_PROFILE_ID).profileId).toBe( | ||
| 40 | DEFAULT_BROWSER_PROFILE_ID, | ||
| 41 | ); | ||
| 42 | }); | ||
| 43 | }); | ||
apps/web/src/browser/browserDefaults.ts+39-10| ... | @@ -13,10 +13,13 @@ | ... | @@ -13,10 +13,13 @@ |
| 13 | * | 13 | * |
| 14 | * @module browserDefaults | 14 | * @module browserDefaults |
| 15 | */ | 15 | */ |
| 16 | import type { | 16 | import { |
| 17 | DesktopPreviewTabDefaults, | 17 | DEFAULT_BROWSER_PROFILE_ID, |
| 18 | PreviewAppearancePreference, | 18 | resolveBrowserProfiles, |
| 19 | PreviewViewportSetting, | 19 | type BrowserProfile, |
| 20 | type DesktopPreviewTabDefaults, | ||
| 21 | type PreviewAppearancePreference, | ||
| 22 | type PreviewViewportSetting, | ||
| 20 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| 21 | 24 | ||
| 22 | import { | 25 | import { |
| ... | @@ -32,6 +35,8 @@ export interface BrowserDefaults { | ... | @@ -32,6 +35,8 @@ export interface BrowserDefaults { |
| 32 | readonly zoomFactor: number; | 35 | readonly zoomFactor: number; |
| 33 | readonly appearance: PreviewAppearancePreference; | 36 | readonly appearance: PreviewAppearancePreference; |
| 34 | readonly autoShowFloatingPreview: boolean; | 37 | readonly autoShowFloatingPreview: boolean; |
| 38 | readonly profiles: ReadonlyArray<BrowserProfile>; | ||
| 39 | readonly profileId: string; | ||
| 35 | } | 40 | } |
| 36 | 41 | ||
| 37 | const toBrowserDefaults = (settings: { | 42 | const toBrowserDefaults = (settings: { |
| ... | @@ -39,12 +44,29 @@ const toBrowserDefaults = (settings: { | ... | @@ -39,12 +44,29 @@ const toBrowserDefaults = (settings: { |
| 39 | readonly browserDefaultZoomFactor: number; | 44 | readonly browserDefaultZoomFactor: number; |
| 40 | readonly browserDefaultAppearance: PreviewAppearancePreference; | 45 | readonly browserDefaultAppearance: PreviewAppearancePreference; |
| 41 | readonly browserAutoShowFloatingPreview: boolean; | 46 | readonly browserAutoShowFloatingPreview: boolean; |
| 42 | }): BrowserDefaults => ({ | 47 | readonly browserProfiles: ReadonlyArray<BrowserProfile>; |
| 43 | viewport: settings.browserDefaultViewport, | 48 | readonly browserDefaultProfileId: string; |
| 44 | zoomFactor: settings.browserDefaultZoomFactor, | 49 | }): BrowserDefaults => { |
| 45 | appearance: settings.browserDefaultAppearance, | 50 | const profiles = resolveBrowserProfiles(settings.browserProfiles); |
| 46 | autoShowFloatingPreview: settings.browserAutoShowFloatingPreview, | 51 | return { |
| 47 | }); | 52 | viewport: settings.browserDefaultViewport, |
| 53 | zoomFactor: settings.browserDefaultZoomFactor, | ||
| 54 | appearance: settings.browserDefaultAppearance, | ||
| 55 | autoShowFloatingPreview: settings.browserAutoShowFloatingPreview, | ||
| 56 | profiles, | ||
| 57 | // A default pointing at a deleted profile falls back rather than opening | ||
| 58 | // tabs into a partition with no profile behind it. | ||
| 59 | // Incognito is a per-tab choice, not a default: a profile that discards | ||
| 60 | // everything on close would leave every new tab signed out. Excluding it | ||
| 61 | // here keeps the resolved default equal to what the settings list offers, | ||
| 62 | // so the row badged "Default" is the one tabs actually open under. | ||
| 63 | profileId: | ||
| 64 | profiles.find( | ||
| 65 | (profile) => | ||
| 66 | profile.id === settings.browserDefaultProfileId && profile.kind !== "incognito", | ||
| 67 | )?.id ?? DEFAULT_BROWSER_PROFILE_ID, | ||
| 68 | }; | ||
| 69 | }; | ||
| 48 | 70 | ||
| 49 | /** Non-hook accessor for imperative open paths (menu actions, automation hosts). */ | 71 | /** Non-hook accessor for imperative open paths (menu actions, automation hosts). */ |
| 50 | export function getBrowserDefaults(): BrowserDefaults { | 72 | export function getBrowserDefaults(): BrowserDefaults { |
| ... | @@ -89,6 +111,13 @@ export function browserDefaultOpenViewport( | ... | @@ -89,6 +111,13 @@ export function browserDefaultOpenViewport( |
| 89 | return defaults.viewport; | 111 | return defaults.viewport; |
| 90 | } | 112 | } |
| 91 | 113 | ||
| 114 | /** Profile a tab opens under when the caller doesn't name one. */ | ||
| 115 | export function browserDefaultOpenProfileId( | ||
| 116 | defaults: BrowserDefaults = getBrowserDefaults(), | ||
| 117 | ): string { | ||
| 118 | return defaults.profileId; | ||
| 119 | } | ||
| 120 | |||
| 92 | /** | 121 | /** |
| 93 | * The viewport to switch to when the user turns the device toolbar on for a tab | 122 | * The viewport to switch to when the user turns the device toolbar on for a tab |
| 94 | * currently in fill mode. | 123 | * currently in fill mode. |
apps/web/src/browser/openFileInPreview.ts+16-1| ... | @@ -23,6 +23,12 @@ import { | ... | @@ -23,6 +23,12 @@ import { |
| 23 | } from "~/previewStateStore"; | 23 | } from "~/previewStateStore"; |
| 24 | import { useRightPanelStore } from "~/rightPanelStore"; | 24 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 25 | 25 | ||
| 26 | import { | ||
| 27 | browserDefaultOpenProfileId, | ||
| 28 | browserDefaultOpenViewport, | ||
| 29 | resolveBrowserDefaults, | ||
| 30 | } from "./browserDefaults"; | ||
| 31 | |||
| 26 | export const isBrowserPreviewFile = (path: string): boolean => | 32 | export const isBrowserPreviewFile = (path: string): boolean => |
| 27 | /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); | 33 | /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? ""); |
| 28 | 34 | ||
| ... | @@ -42,9 +48,18 @@ export async function openUrlInPreview<E>(input: { | ... | @@ -42,9 +48,18 @@ export async function openUrlInPreview<E>(input: { |
| 42 | readonly url: string; | 48 | readonly url: string; |
| 43 | readonly openPreview: OpenPreviewMutation<E>; | 49 | readonly openPreview: OpenPreviewMutation<E>; |
| 44 | }): Promise<AtomCommandResult<void, E>> { | 50 | }): Promise<AtomCommandResult<void, E>> { |
| 51 | const defaults = await resolveBrowserDefaults(); | ||
| 45 | const result = await input.openPreview({ | 52 | const result = await input.openPreview({ |
| 46 | environmentId: input.threadRef.environmentId, | 53 | environmentId: input.threadRef.environmentId, |
| 47 | input: { threadId: input.threadRef.threadId, url: input.url }, | 54 | input: { |
| 55 | threadId: input.threadRef.threadId, | ||
| 56 | url: input.url, | ||
| 57 | // Built here rather than via `openPreviewSession` because this path | ||
| 58 | // maps the result differently, so the configured defaults have to be | ||
| 59 | // applied explicitly or file/link opens would ignore them. | ||
| 60 | viewport: browserDefaultOpenViewport(defaults), | ||
| 61 | profileId: browserDefaultOpenProfileId(defaults), | ||
| 62 | }, | ||
| 48 | }); | 63 | }); |
| 49 | return mapAtomCommandResult(result, (snapshot) => { | 64 | return mapAtomCommandResult(result, (snapshot) => { |
| 50 | applyPreviewServerSnapshot(input.threadRef, snapshot); | 65 | applyPreviewServerSnapshot(input.threadRef, snapshot); |
apps/web/src/browser/previewWebviewConfigState.test.ts+11-8| ... | @@ -13,7 +13,9 @@ const environmentId = EnvironmentId.make("environment-1"); | ... | @@ -13,7 +13,9 @@ const environmentId = EnvironmentId.make("environment-1"); |
| 13 | describe("loadPreviewWebviewConfig", () => { | 13 | describe("loadPreviewWebviewConfig", () => { |
| 14 | it.effect("reports a structurally distinct missing-bridge failure", () => | 14 | it.effect("reports a structurally distinct missing-bridge failure", () => |
| 15 | Effect.gen(function* () { | 15 | Effect.gen(function* () { |
| 16 | const error = yield* loadPreviewWebviewConfig(environmentId, null).pipe(Effect.flip); | 16 | const error = yield* loadPreviewWebviewConfig(environmentId, undefined, null).pipe( |
| 17 | Effect.flip, | ||
| 18 | ); | ||
| 17 | 19 | ||
| 18 | expect(error).toBeInstanceOf(PreviewWebviewBridgeUnavailableError); | 20 | expect(error).toBeInstanceOf(PreviewWebviewBridgeUnavailableError); |
| 19 | expect(error.environmentId).toBe(environmentId); | 21 | expect(error.environmentId).toBe(environmentId); |
| ... | @@ -25,7 +27,7 @@ describe("loadPreviewWebviewConfig", () => { | ... | @@ -25,7 +27,7 @@ describe("loadPreviewWebviewConfig", () => { |
| 25 | it.effect("preserves the bridge rejection as the load failure cause", () => | 27 | it.effect("preserves the bridge rejection as the load failure cause", () => |
| 26 | Effect.gen(function* () { | 28 | Effect.gen(function* () { |
| 27 | const cause = new Error("ipc unavailable"); | 29 | const cause = new Error("ipc unavailable"); |
| 28 | const error = yield* loadPreviewWebviewConfig(environmentId, { | 30 | const error = yield* loadPreviewWebviewConfig(environmentId, undefined, { |
| 29 | getPreviewConfig: () => Promise.reject(cause), | 31 | getPreviewConfig: () => Promise.reject(cause), |
| 30 | }).pipe(Effect.flip); | 32 | }).pipe(Effect.flip); |
| 31 | 33 | ||
| ... | @@ -36,22 +38,23 @@ describe("loadPreviewWebviewConfig", () => { | ... | @@ -36,22 +38,23 @@ describe("loadPreviewWebviewConfig", () => { |
| 36 | }), | 38 | }), |
| 37 | ); | 39 | ); |
| 38 | 40 | ||
| 39 | it.effect("forwards the environment id to the bridge", () => | 41 | it.effect("forwards the environment id and profile to the bridge", () => |
| 40 | Effect.gen(function* () { | 42 | Effect.gen(function* () { |
| 41 | let requestedEnvironmentId: EnvironmentId | null = null; | 43 | let requested: { environmentId: EnvironmentId; profileId: string | undefined } | null = null; |
| 42 | const config = { | 44 | const config = { |
| 43 | partition: "persist:test-preview", | 45 | partition: "persist:test-preview", |
| 44 | webPreferences: "sandbox=yes", | 46 | webPreferences: "sandbox=yes", |
| 45 | preloadUrl: null, | 47 | preloadUrl: null, |
| 46 | }; | 48 | }; |
| 47 | const result = yield* loadPreviewWebviewConfig(environmentId, { | 49 | const result = yield* loadPreviewWebviewConfig(environmentId, "work", { |
| 48 | getPreviewConfig: (input) => { | 50 | getPreviewConfig: (requestedEnvironmentId, profileId) => { |
| 49 | requestedEnvironmentId = input; | 51 | requested = { environmentId: requestedEnvironmentId, profileId }; |
| 50 | return Promise.resolve(config); | 52 | return Promise.resolve(config); |
| 51 | }, | 53 | }, |
| 52 | }); | 54 | }); |
| 53 | 55 | ||
| 54 | expect(requestedEnvironmentId).toBe(environmentId); | 56 | // The partition is derived in main from both, so both have to arrive. |
| 57 | expect(requested).toEqual({ environmentId, profileId: "work" }); | ||
| 55 | expect(result).toEqual(config); | 58 | expect(result).toEqual(config); |
| 56 | }), | 59 | }), |
| 57 | ); | 60 | ); |
apps/web/src/browser/previewWebviewConfigState.ts+35-7| ... | @@ -45,6 +45,7 @@ type PreviewConfigBridge = Pick<DesktopPreviewBridge, "getPreviewConfig">; | ... | @@ -45,6 +45,7 @@ type PreviewConfigBridge = Pick<DesktopPreviewBridge, "getPreviewConfig">; |
| 45 | 45 | ||
| 46 | export const loadPreviewWebviewConfig = ( | 46 | export const loadPreviewWebviewConfig = ( |
| 47 | environmentId: EnvironmentId, | 47 | environmentId: EnvironmentId, |
| 48 | profileId?: string, | ||
| 48 | bridge: PreviewConfigBridge | null = previewBridge, | 49 | bridge: PreviewConfigBridge | null = previewBridge, |
| 49 | ): Effect.Effect<DesktopPreviewWebviewConfig, PreviewWebviewConfigError> => { | 50 | ): Effect.Effect<DesktopPreviewWebviewConfig, PreviewWebviewConfigError> => { |
| 50 | if (bridge === null) { | 51 | if (bridge === null) { |
| ... | @@ -52,25 +53,52 @@ export const loadPreviewWebviewConfig = ( | ... | @@ -52,25 +53,52 @@ export const loadPreviewWebviewConfig = ( |
| 52 | } | 53 | } |
| 53 | 54 | ||
| 54 | return Effect.tryPromise({ | 55 | return Effect.tryPromise({ |
| 55 | try: () => bridge.getPreviewConfig(environmentId), | 56 | try: () => bridge.getPreviewConfig(environmentId, profileId), |
| 56 | catch: (cause) => new PreviewWebviewConfigLoadError({ environmentId, cause }), | 57 | catch: (cause) => new PreviewWebviewConfigLoadError({ environmentId, cause }), |
| 57 | }); | 58 | }); |
| 58 | }; | 59 | }; |
| 59 | 60 | ||
| 60 | const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) => | 61 | /** |
| 61 | Atom.make(loadPreviewWebviewConfig(environmentId)).pipe( | 62 | * `Atom.family` keys on its argument, so the environment and profile are |
| 63 | * folded into one string: passing an object would allocate a fresh entry on | ||
| 64 | * every render. | ||
| 65 | * | ||
| 66 | * The profile is the tail rather than a second field, so an id containing the | ||
| 67 | * delimiter round-trips whole instead of being truncated into a different | ||
| 68 | * profile's key. `BrowserProfileId` rejects control characters, which is what | ||
| 69 | * makes the environment side of the split unambiguous. | ||
| 70 | */ | ||
| 71 | const CONFIG_KEY_DELIMITER = "\u0000"; | ||
| 72 | |||
| 73 | const configKey = (environmentId: EnvironmentId, profileId: string | undefined): string => | ||
| 74 | `${environmentId}${CONFIG_KEY_DELIMITER}${profileId ?? ""}`; | ||
| 75 | |||
| 76 | const parseConfigKey = (key: string): { environmentId: EnvironmentId; profileId?: string } => { | ||
| 77 | const delimiter = key.indexOf(CONFIG_KEY_DELIMITER); | ||
| 78 | const environmentId = (delimiter === -1 ? key : key.slice(0, delimiter)) as EnvironmentId; | ||
| 79 | const profileId = delimiter === -1 ? "" : key.slice(delimiter + CONFIG_KEY_DELIMITER.length); | ||
| 80 | return { | ||
| 81 | environmentId, | ||
| 82 | ...(profileId === "" ? {} : { profileId }), | ||
| 83 | }; | ||
| 84 | }; | ||
| 85 | |||
| 86 | const previewWebviewConfigAtom = Atom.family((key: string) => { | ||
| 87 | const { environmentId, profileId } = parseConfigKey(key); | ||
| 88 | return Atom.make(loadPreviewWebviewConfig(environmentId, profileId)).pipe( | ||
| 62 | Atom.swr({ | 89 | Atom.swr({ |
| 63 | staleTime: PREVIEW_CONFIG_STALE_TIME_MS, | 90 | staleTime: PREVIEW_CONFIG_STALE_TIME_MS, |
| 64 | revalidateOnMount: true, | 91 | revalidateOnMount: true, |
| 65 | }), | 92 | }), |
| 66 | Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS), | 93 | Atom.setIdleTTL(PREVIEW_CONFIG_IDLE_TTL_MS), |
| 67 | Atom.withLabel(`preview:webview-config:${environmentId}`), | 94 | Atom.withLabel(`preview:webview-config:${key}`), |
| 68 | ), | 95 | ); |
| 69 | ); | 96 | }); |
| 70 | 97 | ||
| 71 | export function usePreviewWebviewConfig( | 98 | export function usePreviewWebviewConfig( |
| 72 | environmentId: EnvironmentId, | 99 | environmentId: EnvironmentId, |
| 100 | profileId?: string, | ||
| 73 | ): DesktopPreviewWebviewConfig | null { | 101 | ): DesktopPreviewWebviewConfig | null { |
| 74 | const result = useAtomValue(previewWebviewConfigAtom(environmentId)); | 102 | const result = useAtomValue(previewWebviewConfigAtom(configKey(environmentId, profileId))); |
| 75 | return Option.getOrNull(AsyncResult.value(result)); | 103 | return Option.getOrNull(AsyncResult.value(result)); |
| 76 | } | 104 | } |
apps/web/src/components/ChatView.tsx+15-6| ... | @@ -3608,10 +3608,17 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3608,10 +3608,17 @@ function ChatViewContent(props: ChatViewProps) { |
| 3608 | const toggleInteractionMode = useCallback(() => { | 3608 | const toggleInteractionMode = useCallback(() => { |
| 3609 | handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); | 3609 | handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); |
| 3610 | }, [handleInteractionModeChange, interactionMode]); | 3610 | }, [handleInteractionModeChange, interactionMode]); |
| 3611 | const createBrowserSurface = useCallback(() => { | 3611 | const createBrowserSurface = useCallback( |
| 3612 | if (!activeThreadRef) return; | 3612 | (profileId?: string) => { |
| 3613 | void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); | 3613 | if (!activeThreadRef) return; |
| 3614 | }, [activeThreadRef, openPreview]); | 3614 | void addBrowserSurface({ |
| 3615 | threadRef: activeThreadRef, | ||
| 3616 | openPreview, | ||
| 3617 | ...(profileId === undefined ? {} : { profileId }), | ||
| 3618 | }); | ||
| 3619 | }, | ||
| 3620 | [activeThreadRef, openPreview], | ||
| 3621 | ); | ||
| 3615 | const addDiffSurface = useCallback(() => { | 3622 | const addDiffSurface = useCallback(() => { |
| 3616 | if (!activeThreadRef || !isServerThread || !isGitRepo) return; | 3623 | if (!activeThreadRef || !isServerThread || !isGitRepo) return; |
| 3617 | useRightPanelStore.getState().open(activeThreadRef, "diff"); | 3624 | useRightPanelStore.getState().open(activeThreadRef, "diff"); |
| ... | @@ -7664,7 +7671,8 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -7664,7 +7671,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 7664 | onCloseSurfacesToRight={closeRightPanelSurfacesToRight} | 7671 | onCloseSurfacesToRight={closeRightPanelSurfacesToRight} |
| 7665 | onCloseAllSurfaces={closeAllRightPanelSurfaces} | 7672 | onCloseAllSurfaces={closeAllRightPanelSurfaces} |
| 7666 | onCopyFilePath={copyRightPanelFilePath} | 7673 | onCopyFilePath={copyRightPanelFilePath} |
| 7667 | onAddBrowser={createBrowserSurface} | 7674 | onAddBrowser={() => createBrowserSurface()} |
| 7675 | onAddBrowserInProfile={createBrowserSurface} | ||
| 7668 | onAddTerminal={addTerminalSurface} | 7676 | onAddTerminal={addTerminalSurface} |
| 7669 | onAddDiff={addDiffSurface} | 7677 | onAddDiff={addDiffSurface} |
| 7670 | onAddFiles={addFilesSurface} | 7678 | onAddFiles={addFilesSurface} |
| ... | @@ -7704,7 +7712,8 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -7704,7 +7712,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 7704 | onCloseSurfacesToRight={closeRightPanelSurfacesToRight} | 7712 | onCloseSurfacesToRight={closeRightPanelSurfacesToRight} |
| 7705 | onCloseAllSurfaces={closeAllRightPanelSurfaces} | 7713 | onCloseAllSurfaces={closeAllRightPanelSurfaces} |
| 7706 | onCopyFilePath={copyRightPanelFilePath} | 7714 | onCopyFilePath={copyRightPanelFilePath} |
| 7707 | onAddBrowser={createBrowserSurface} | 7715 | onAddBrowser={() => createBrowserSurface()} |
| 7716 | onAddBrowserInProfile={createBrowserSurface} | ||
| 7708 | onAddTerminal={addTerminalSurface} | 7717 | onAddTerminal={addTerminalSurface} |
| 7709 | onAddDiff={addDiffSurface} | 7718 | onAddDiff={addDiffSurface} |
| 7710 | onAddFiles={addFilesSurface} | 7719 | onAddFiles={addFilesSurface} |
apps/web/src/components/RightPanelTabs.test.tsx+10| ... | @@ -4,11 +4,20 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -4,11 +4,20 @@ import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | RightPanelTabs, | 6 | RightPanelTabs, |
| 7 | shouldOpenDefaultBrowserProfileFromMenuClick, | ||
| 7 | surfaceShortcutActionForKey, | 8 | surfaceShortcutActionForKey, |
| 8 | surfaceShortcutTargetsTypingContext, | 9 | surfaceShortcutTargetsTypingContext, |
| 9 | tabMuteMenuItem, | 10 | tabMuteMenuItem, |
| 10 | } from "./RightPanelTabs"; | 11 | } from "./RightPanelTabs"; |
| 11 | 12 | ||
| 13 | describe("browser profile submenu", () => { | ||
| 14 | it("reserves touch clicks for opening the choices while mouse clicks use the default", () => { | ||
| 15 | expect(shouldOpenDefaultBrowserProfileFromMenuClick("touch")).toBe(false); | ||
| 16 | expect(shouldOpenDefaultBrowserProfileFromMenuClick("mouse")).toBe(true); | ||
| 17 | expect(shouldOpenDefaultBrowserProfileFromMenuClick(undefined)).toBe(true); | ||
| 18 | }); | ||
| 19 | }); | ||
| 20 | |||
| 12 | function shortcutEvent( | 21 | function shortcutEvent( |
| 13 | key: string, | 22 | key: string, |
| 14 | overrides: Partial<Parameters<typeof surfaceShortcutActionForKey>[1]> = {}, | 23 | overrides: Partial<Parameters<typeof surfaceShortcutActionForKey>[1]> = {}, |
| ... | @@ -104,6 +113,7 @@ function renderTabs( | ... | @@ -104,6 +113,7 @@ function renderTabs( |
| 104 | onCloseAllSurfaces={() => undefined} | 113 | onCloseAllSurfaces={() => undefined} |
| 105 | onCopyFilePath={() => undefined} | 114 | onCopyFilePath={() => undefined} |
| 106 | onAddBrowser={() => undefined} | 115 | onAddBrowser={() => undefined} |
| 116 | onAddBrowserInProfile={() => undefined} | ||
| 107 | onAddTerminal={() => undefined} | 117 | onAddTerminal={() => undefined} |
| 108 | onAddPullRequest={() => undefined} | 118 | onAddPullRequest={() => undefined} |
| 109 | onAddDiff={() => undefined} | 119 | onAddDiff={() => undefined} |
apps/web/src/components/RightPanelTabs.tsx+73-1| ... | @@ -30,7 +30,17 @@ import { readLocalApi } from "~/localApi"; | ... | @@ -30,7 +30,17 @@ import { readLocalApi } from "~/localApi"; |
| 30 | import { Button } from "~/components/ui/button"; | 30 | import { Button } from "~/components/ui/button"; |
| 31 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | 31 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 32 | import { Kbd } from "~/components/ui/kbd"; | 32 | import { Kbd } from "~/components/ui/kbd"; |
| 33 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "~/components/ui/menu"; | 33 | import { |
| 34 | Menu, | ||
| 35 | MenuItem, | ||
| 36 | MenuPopup, | ||
| 37 | MenuShortcut, | ||
| 38 | MenuSub, | ||
| 39 | MenuSubPopup, | ||
| 40 | MenuSubTrigger, | ||
| 41 | MenuTrigger, | ||
| 42 | } from "~/components/ui/menu"; | ||
| 43 | import { useBrowserDefaults } from "~/browser/browserDefaults"; | ||
| 34 | import { ScrollArea } from "~/components/ui/scroll-area"; | 44 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 35 | import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; | 45 | import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; |
| 36 | import { faviconUrlForOrigin } from "~/lib/favicon"; | 46 | import { faviconUrlForOrigin } from "~/lib/favicon"; |
| ... | @@ -69,6 +79,12 @@ interface RightPanelTabsProps { | ... | @@ -69,6 +79,12 @@ interface RightPanelTabsProps { |
| 69 | onCloseAllSurfaces: () => void; | 79 | onCloseAllSurfaces: () => void; |
| 70 | onCopyFilePath: (relativePath: string) => void; | 80 | onCopyFilePath: (relativePath: string) => void; |
| 71 | onAddBrowser: () => void; | 81 | onAddBrowser: () => void; |
| 82 | /** | ||
| 83 | * Separate from `onAddBrowser` on purpose: that one is passed directly as a | ||
| 84 | * DOM click handler, and a `(profileId?: string)` signature would silently | ||
| 85 | * accept the MouseEvent as a profile id. | ||
| 86 | */ | ||
| 87 | onAddBrowserInProfile: (profileId: string) => void; | ||
| 72 | onAddTerminal: () => void; | 88 | onAddTerminal: () => void; |
| 73 | onAddDiff: () => void; | 89 | onAddDiff: () => void; |
| 74 | onAddFiles: () => void; | 90 | onAddFiles: () => void; |
| ... | @@ -94,6 +110,12 @@ export interface PullRequestTabStatus { | ... | @@ -94,6 +110,12 @@ export interface PullRequestTabStatus { |
| 94 | isDraft: boolean; | 110 | isDraft: boolean; |
| 95 | } | 111 | } |
| 96 | 112 | ||
| 113 | export function shouldOpenDefaultBrowserProfileFromMenuClick( | ||
| 114 | pointerType: string | undefined, | ||
| 115 | ): boolean { | ||
| 116 | return pointerType !== "touch"; | ||
| 117 | } | ||
| 118 | |||
| 97 | const SURFACE_DISABLED_REASONS = { | 119 | const SURFACE_DISABLED_REASONS = { |
| 98 | browser: "Browser previews are only available in the T3 Code desktop app.", | 120 | browser: "Browser previews are only available in the T3 Code desktop app.", |
| 99 | terminal: "Terminal surfaces are only available from a project thread.", | 121 | terminal: "Terminal surfaces are only available from a project thread.", |
| ... | @@ -600,6 +622,7 @@ function SurfaceIcon({ | ... | @@ -600,6 +622,7 @@ function SurfaceIcon({ |
| 600 | 622 | ||
| 601 | export function RightPanelTabs(props: RightPanelTabsProps) { | 623 | export function RightPanelTabs(props: RightPanelTabsProps) { |
| 602 | const ownsDesktopTitleBar = isElectron && props.mode === "inline"; | 624 | const ownsDesktopTitleBar = isElectron && props.mode === "inline"; |
| 625 | const browserProfiles = useBrowserDefaults().profiles; | ||
| 603 | const { resolvedTheme } = useTheme(); | 626 | const { resolvedTheme } = useTheme(); |
| 604 | const tabListRef = useRef<HTMLDivElement>(null); | 627 | const tabListRef = useRef<HTMLDivElement>(null); |
| 605 | const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); | 628 | const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); |
| ... | @@ -911,6 +934,55 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -911,6 +934,55 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 911 | > | 934 | > |
| 912 | {addSurfaceActions.map((action) => { | 935 | {addSurfaceActions.map((action) => { |
| 913 | const Icon = action.icon; | 936 | const Icon = action.icon; |
| 937 | // Browser collapses into one row: clicking the trigger opens | ||
| 938 | // the default profile (the common case stays one click), | ||
| 939 | // while hover or arrow reveals the profiles. The choice | ||
| 940 | // lives at open time because a tab's profile is fixed then — | ||
| 941 | // Electron only honours a partition before attach. | ||
| 942 | if (action.label === "Browser" && action.available) { | ||
| 943 | return ( | ||
| 944 | <MenuSub key={action.label}> | ||
| 945 | <MenuSubTrigger | ||
| 946 | className="[&>svg:last-child]:ms-0" | ||
| 947 | aria-keyshortcuts={action.shortcut} | ||
| 948 | onClick={(event) => { | ||
| 949 | const pointerType = | ||
| 950 | "pointerType" in event.nativeEvent && | ||
| 951 | typeof event.nativeEvent.pointerType === "string" | ||
| 952 | ? event.nativeEvent.pointerType | ||
| 953 | : undefined; | ||
| 954 | // Touch has no hover path to the profile choices: | ||
| 955 | // its first tap opens the submenu, then a profile | ||
| 956 | // is selected there. Mouse click keeps the common | ||
| 957 | // default-profile action at one click. | ||
| 958 | if (!shouldOpenDefaultBrowserProfileFromMenuClick(pointerType)) | ||
| 959 | return; | ||
| 960 | setAddSurfaceMenuOpen(false); | ||
| 961 | action.onClick(); | ||
| 962 | }} | ||
| 963 | > | ||
| 964 | <Icon /> | ||
| 965 | {action.label} | ||
| 966 | <MenuShortcut>{action.shortcut}</MenuShortcut> | ||
| 967 | </MenuSubTrigger> | ||
| 968 | {/* | ||
| 969 | Capped and truncated: profile names are user-supplied | ||
| 970 | and run to 48 characters, which would otherwise widen | ||
| 971 | the popup to fit-content and wrap. | ||
| 972 | */} | ||
| 973 | <MenuSubPopup className="min-w-40 max-w-56"> | ||
| 974 | {browserProfiles.map((profile) => ( | ||
| 975 | <MenuItem | ||
| 976 | key={profile.id} | ||
| 977 | onClick={() => props.onAddBrowserInProfile(profile.id)} | ||
| 978 | > | ||
| 979 | <span className="min-w-0 truncate">{profile.name}</span> | ||
| 980 | </MenuItem> | ||
| 981 | ))} | ||
| 982 | </MenuSubPopup> | ||
| 983 | </MenuSub> | ||
| 984 | ); | ||
| 985 | } | ||
| 914 | return ( | 986 | return ( |
| 915 | <SurfaceMenuItem | 987 | <SurfaceMenuItem |
| 916 | key={action.label} | 988 | key={action.label} |
apps/web/src/components/preview/PreviewChromeRow.tsx+8| ... | @@ -57,6 +57,11 @@ interface Props { | ... | @@ -57,6 +57,11 @@ interface Props { |
| 57 | * to mount the three-dot menu (hard reload, devtools, zoom, clear data). | 57 | * to mount the three-dot menu (hard reload, devtools, zoom, clear data). |
| 58 | */ | 58 | */ |
| 59 | trailingActions?: ReactNode; | 59 | trailingActions?: ReactNode; |
| 60 | /** | ||
| 61 | * Slot between the nav buttons and the URL input. The preview view uses it | ||
| 62 | * to name the tab's browser profile, which is otherwise invisible. | ||
| 63 | */ | ||
| 64 | leadingActions?: ReactNode; | ||
| 60 | } | 65 | } |
| 61 | 66 | ||
| 62 | const NOOP = () => {}; | 67 | const NOOP = () => {}; |
| ... | @@ -85,6 +90,7 @@ export function PreviewChromeRow({ | ... | @@ -85,6 +90,7 @@ export function PreviewChromeRow({ |
| 85 | pickDisabled, | 90 | pickDisabled, |
| 86 | pickDisabledReason, | 91 | pickDisabledReason, |
| 87 | trailingActions, | 92 | trailingActions, |
| 93 | leadingActions, | ||
| 88 | }: Props) { | 94 | }: Props) { |
| 89 | const inputRef = useRef<HTMLInputElement | null>(null); | 95 | const inputRef = useRef<HTMLInputElement | null>(null); |
| 90 | const [draft, setDraft] = useState(url); | 96 | const [draft, setDraft] = useState(url); |
| ... | @@ -166,6 +172,8 @@ export function PreviewChromeRow({ | ... | @@ -166,6 +172,8 @@ export function PreviewChromeRow({ |
| 166 | </Tooltip> | 172 | </Tooltip> |
| 167 | </div> | 173 | </div> |
| 168 | 174 | ||
| 175 | {leadingActions} | ||
| 176 | |||
| 169 | <InputGroup variant="ghost" className="group/address h-7 flex-1"> | 177 | <InputGroup variant="ghost" className="group/address h-7 flex-1"> |
| 170 | <Tooltip> | 178 | <Tooltip> |
| 171 | <TooltipTrigger | 179 | <TooltipTrigger |
apps/web/src/components/preview/PreviewMoreMenu.tsx+48-7| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import type { DesktopPreviewColorScheme } from "@t3tools/contracts"; | 3 | import type { DesktopPreviewColorScheme, EnvironmentId } from "@t3tools/contracts"; |
| 4 | import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; | 4 | import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; |
| 5 | 5 | ||
| 6 | import { Button } from "~/components/ui/button"; | 6 | import { Button } from "~/components/ui/button"; |
| ... | @@ -10,6 +10,8 @@ import { | ... | @@ -10,6 +10,8 @@ import { |
| 10 | MenuPopup, | 10 | MenuPopup, |
| 11 | MenuRadioGroup, | 11 | MenuRadioGroup, |
| 12 | MenuRadioItem, | 12 | MenuRadioItem, |
| 13 | MenuGroup, | ||
| 14 | MenuGroupLabel, | ||
| 13 | MenuSeparator, | 15 | MenuSeparator, |
| 14 | MenuSub, | 16 | MenuSub, |
| 15 | MenuSubPopup, | 17 | MenuSubPopup, |
| ... | @@ -50,6 +52,17 @@ interface Props { | ... | @@ -50,6 +52,17 @@ interface Props { |
| 50 | nativePictureInPicture: boolean; | 52 | nativePictureInPicture: boolean; |
| 51 | /** Toggles the optional native always-on-top preview window. */ | 53 | /** Toggles the optional native always-on-top preview window. */ |
| 52 | onNativePictureInPicture: () => void; | 54 | onNativePictureInPicture: () => void; |
| 55 | /** Environment the tab belongs to; scopes storage clearing to its partitions. */ | ||
| 56 | environmentId: EnvironmentId; | ||
| 57 | /** Profile the tab was opened under, if the server recorded one. */ | ||
| 58 | /** | ||
| 59 | * Required: the IPC layer reads an absent profile as "every profile", so a | ||
| 60 | * tab whose own profile is unknown must resolve the default before it gets | ||
| 61 | * here rather than passing the gap along. | ||
| 62 | */ | ||
| 63 | profileId: string; | ||
| 64 | /** Profile display name, shown so the menu says which data is being cleared. */ | ||
| 65 | profileName: string | undefined; | ||
| 53 | } | 66 | } |
| 54 | 67 | ||
| 55 | /** | 68 | /** |
| ... | @@ -66,6 +79,9 @@ export function PreviewMoreMenu({ | ... | @@ -66,6 +79,9 @@ export function PreviewMoreMenu({ |
| 66 | onToggleDeviceToolbar, | 79 | onToggleDeviceToolbar, |
| 67 | nativePictureInPicture, | 80 | nativePictureInPicture, |
| 68 | onNativePictureInPicture, | 81 | onNativePictureInPicture, |
| 82 | environmentId, | ||
| 83 | profileId, | ||
| 84 | profileName, | ||
| 69 | }: Props) { | 85 | }: Props) { |
| 70 | if (!previewBridge) return null; | 86 | if (!previewBridge) return null; |
| 71 | const bridge = previewBridge; | 87 | const bridge = previewBridge; |
| ... | @@ -177,12 +193,37 @@ export function PreviewMoreMenu({ | ... | @@ -177,12 +193,37 @@ export function PreviewMoreMenu({ |
| 177 | </span> | 193 | </span> |
| 178 | </MenuItem> | 194 | </MenuItem> |
| 179 | <MenuSeparator /> | 195 | <MenuSeparator /> |
| 180 | <MenuItem onClick={() => void bridge.clearCookies().catch(() => undefined)}> | 196 | {/* |
| 181 | Clear cookies | 197 | Grouped so the heading has a `MenuGroup` ancestor — `MenuGroupLabel` |
| 182 | </MenuItem> | 198 | reads its context and throws without one. The heading also answers |
| 183 | <MenuItem onClick={() => void bridge.clearCache().catch(() => undefined)}> | 199 | which profile the tab is in, which is otherwise invisible: it is fixed |
| 184 | Clear cache | 200 | at open and nothing else in the chrome shows it. |
| 185 | </MenuItem> | 201 | */} |
| 202 | <MenuGroup> | ||
| 203 | {/* | ||
| 204 | The heading carries the profile so the actions below can keep | ||
| 205 | fixed-length labels: repeating a name of up to 48 characters in | ||
| 206 | each one drove the popup far past its width. | ||
| 207 | */} | ||
| 208 | {profileName ? ( | ||
| 209 | // Truncation sits on the label itself: it renders a block box, so | ||
| 210 | // `text-overflow` on an inline child inside it never applies and a | ||
| 211 | // long name would push the popup past its width instead. | ||
| 212 | <MenuGroupLabel className="max-w-64 truncate">Profile: {profileName}</MenuGroupLabel> | ||
| 213 | ) : null} | ||
| 214 | <MenuItem | ||
| 215 | onClick={() => | ||
| 216 | void bridge.clearCookies(environmentId, profileId).catch(() => undefined) | ||
| 217 | } | ||
| 218 | > | ||
| 219 | Clear cookies | ||
| 220 | </MenuItem> | ||
| 221 | <MenuItem | ||
| 222 | onClick={() => void bridge.clearCache(environmentId, profileId).catch(() => undefined)} | ||
| 223 | > | ||
| 224 | Clear cache | ||
| 225 | </MenuItem> | ||
| 226 | </MenuGroup> | ||
| 186 | </MenuPopup> | 227 | </MenuPopup> |
| 187 | </Menu> | 228 | </Menu> |
| 188 | ); | 229 | ); |
apps/web/src/components/preview/PreviewView.test.tsx+21-13| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | BUILT_IN_BROWSER_PROFILES, | ||
| 3 | DEFAULT_BROWSER_PROFILE_ID, | ||
| 2 | DEFAULT_PREVIEW_APPEARANCE, | 4 | DEFAULT_PREVIEW_APPEARANCE, |
| 3 | DEFAULT_PREVIEW_ZOOM_FACTOR, | 5 | DEFAULT_PREVIEW_ZOOM_FACTOR, |
| 4 | EnvironmentId, | 6 | EnvironmentId, |
| ... | @@ -37,6 +39,15 @@ const mocks = vi.hoisted(() => ({ | ... | @@ -37,6 +39,15 @@ const mocks = vi.hoisted(() => ({ |
| 37 | 39 | ||
| 38 | const EMPTY_HISTORY: never[] = []; | 40 | const EMPTY_HISTORY: never[] = []; |
| 39 | 41 | ||
| 42 | const STUB_BROWSER_DEFAULTS = { | ||
| 43 | viewport: FILL_PREVIEW_VIEWPORT, | ||
| 44 | zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, | ||
| 45 | appearance: DEFAULT_PREVIEW_APPEARANCE, | ||
| 46 | autoShowFloatingPreview: true, | ||
| 47 | profiles: BUILT_IN_BROWSER_PROFILES, | ||
| 48 | profileId: DEFAULT_BROWSER_PROFILE_ID, | ||
| 49 | }; | ||
| 50 | |||
| 40 | vi.mock("~/browserHistoryStore", () => ({ | 51 | vi.mock("~/browserHistoryStore", () => ({ |
| 41 | recordVisitForThread: mocks.recordVisitForThread, | 52 | recordVisitForThread: mocks.recordVisitForThread, |
| 42 | setTitleForThreadUrl: vi.fn(), | 53 | setTitleForThreadUrl: vi.fn(), |
| ... | @@ -53,19 +64,10 @@ vi.mock("~/state/session", () => ({ | ... | @@ -53,19 +64,10 @@ vi.mock("~/state/session", () => ({ |
| 53 | // `useSettings` -> `state/server`, which would drag the whole settings and | 64 | // `useSettings` -> `state/server`, which would drag the whole settings and |
| 54 | // connection graph into a test that only cares about the browser chrome. | 65 | // connection graph into a test that only cares about the browser chrome. |
| 55 | vi.mock("~/browser/browserDefaults", () => ({ | 66 | vi.mock("~/browser/browserDefaults", () => ({ |
| 56 | useBrowserDefaults: () => ({ | 67 | useBrowserDefaults: () => STUB_BROWSER_DEFAULTS, |
| 57 | viewport: FILL_PREVIEW_VIEWPORT, | 68 | getBrowserDefaults: () => STUB_BROWSER_DEFAULTS, |
| 58 | zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, | ||
| 59 | appearance: DEFAULT_PREVIEW_APPEARANCE, | ||
| 60 | autoShowFloatingPreview: true, | ||
| 61 | }), | ||
| 62 | getBrowserDefaults: () => ({ | ||
| 63 | viewport: FILL_PREVIEW_VIEWPORT, | ||
| 64 | zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, | ||
| 65 | appearance: DEFAULT_PREVIEW_APPEARANCE, | ||
| 66 | autoShowFloatingPreview: true, | ||
| 67 | }), | ||
| 68 | browserDefaultOpenViewport: () => FILL_PREVIEW_VIEWPORT, | 69 | browserDefaultOpenViewport: () => FILL_PREVIEW_VIEWPORT, |
| 70 | browserDefaultOpenProfileId: () => DEFAULT_BROWSER_PROFILE_ID, | ||
| 69 | browserDefaultTabState: () => ({ | 71 | browserDefaultTabState: () => ({ |
| 70 | zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, | 72 | zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, |
| 71 | colorScheme: DEFAULT_PREVIEW_APPEARANCE, | 73 | colorScheme: DEFAULT_PREVIEW_APPEARANCE, |
| ... | @@ -249,7 +251,7 @@ vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); | ... | @@ -249,7 +251,7 @@ vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); |
| 249 | vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); | 251 | vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); |
| 250 | vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); | 252 | vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); |
| 251 | 253 | ||
| 252 | import { PreviewView } from "./PreviewView"; | 254 | import { PreviewView, previewProfileName } from "./PreviewView"; |
| 253 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; | 255 | import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; |
| 254 | 256 | ||
| 255 | const TEST_THREAD_REF = { | 257 | const TEST_THREAD_REF = { |
| ... | @@ -347,6 +349,12 @@ describe("PreviewView navigation", () => { | ... | @@ -347,6 +349,12 @@ describe("PreviewView navigation", () => { |
| 347 | mocks.recordVisitForThread.mockClear(); | 349 | mocks.recordVisitForThread.mockClear(); |
| 348 | }); | 350 | }); |
| 349 | 351 | ||
| 352 | it("labels a tab whose saved profile was removed", () => { | ||
| 353 | expect(previewProfileName(BUILT_IN_BROWSER_PROFILES, "profile-removed")).toBe( | ||
| 354 | "Removed profile", | ||
| 355 | ); | ||
| 356 | }); | ||
| 357 | |||
| 350 | it("does not rerender while loading time passes", async () => { | 358 | it("does not rerender while loading time passes", async () => { |
| 351 | vi.useFakeTimers(); | 359 | vi.useFakeTimers(); |
| 352 | mocks.loading = true; | 360 | mocks.loading = true; |
apps/web/src/components/preview/PreviewView.tsx+41| ... | @@ -3,6 +3,7 @@ | ... | @@ -3,6 +3,7 @@ |
| 3 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | 3 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 5 | import { | 5 | import { |
| 6 | DEFAULT_BROWSER_PROFILE_ID, | ||
| 6 | FILL_PREVIEW_VIEWPORT, | 7 | FILL_PREVIEW_VIEWPORT, |
| 7 | type PreviewAnnotationPayload, | 8 | type PreviewAnnotationPayload, |
| 8 | type PreviewViewportSetting, | 9 | type PreviewViewportSetting, |
| ... | @@ -48,6 +49,7 @@ import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; | ... | @@ -48,6 +49,7 @@ import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; |
| 48 | import { PreviewUnreachable } from "./PreviewUnreachable"; | 49 | import { PreviewUnreachable } from "./PreviewUnreachable"; |
| 49 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; | 50 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; |
| 50 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; | 51 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; |
| 52 | import { Badge } from "~/components/ui/badge"; | ||
| 51 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; | 53 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; |
| 52 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; | 54 | import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; |
| 53 | import { usePreviewSession } from "./usePreviewSession"; | 55 | import { usePreviewSession } from "./usePreviewSession"; |
| ... | @@ -61,6 +63,7 @@ import { | ... | @@ -61,6 +63,7 @@ import { |
| 61 | useActiveBrowserRecordingTabIds, | 63 | useActiveBrowserRecordingTabIds, |
| 62 | } from "~/browser/browserRecording"; | 64 | } from "~/browser/browserRecording"; |
| 63 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | 65 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| 66 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | ||
| 64 | 67 | ||
| 65 | interface Props { | 68 | interface Props { |
| 66 | threadRef: ScopedThreadRef; | 69 | threadRef: ScopedThreadRef; |
| ... | @@ -73,6 +76,13 @@ interface Props { | ... | @@ -73,6 +76,13 @@ interface Props { |
| 73 | ) => void; | 76 | ) => void; |
| 74 | } | 77 | } |
| 75 | 78 | ||
| 79 | export function previewProfileName( | ||
| 80 | profiles: ReadonlyArray<{ readonly id: string; readonly name: string }>, | ||
| 81 | profileId: string, | ||
| 82 | ): string { | ||
| 83 | return profiles.find((profile) => profile.id === profileId)?.name ?? "Removed profile"; | ||
| 84 | } | ||
| 85 | |||
| 76 | const localApi = typeof window === "undefined" ? null : ensureLocalApi(); | 86 | const localApi = typeof window === "undefined" ? null : ensureLocalApi(); |
| 77 | 87 | ||
| 78 | /** | 88 | /** |
| ... | @@ -144,6 +154,14 @@ export function PreviewView({ | ... | @@ -144,6 +154,14 @@ export function PreviewView({ |
| 144 | const controller = desktopOverlay?.controller ?? "none"; | 154 | const controller = desktopOverlay?.controller ?? "none"; |
| 145 | const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; | 155 | const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; |
| 146 | const browserDefaults = useBrowserDefaults(); | 156 | const browserDefaults = useBrowserDefaults(); |
| 157 | // A tab created before profiles existed carries no profile of its own. It | ||
| 158 | // runs in the built-in `default` partition — the scope the browser used | ||
| 159 | // before profiles — not in whatever profile is configured as the default | ||
| 160 | // now, so that is what its label names and its clear actions target. | ||
| 161 | // Passing the snapshot's raw `undefined` through would reach the IPC layer | ||
| 162 | // as "every profile". | ||
| 163 | const activeProfileId = snapshot?.profileId ?? DEFAULT_BROWSER_PROFILE_ID; | ||
| 164 | const activeProfileName = previewProfileName(browserDefaults.profiles, activeProfileId); | ||
| 147 | const panelRect = useBrowserSurfaceStore((state) => | 165 | const panelRect = useBrowserSurfaceStore((state) => |
| 148 | runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, | 166 | runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, |
| 149 | ); | 167 | ); |
| ... | @@ -686,9 +704,32 @@ export function PreviewView({ | ... | @@ -686,9 +704,32 @@ export function PreviewView({ |
| 686 | pickDisabledReason={ | 704 | pickDisabledReason={ |
| 687 | isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined | 705 | isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined |
| 688 | } | 706 | } |
| 707 | leadingActions={ | ||
| 708 | // Only when it differs from the default: labelling every tab | ||
| 709 | // "Default" would be noise on the common case, while a tab in | ||
| 710 | // another profile is exactly what needs calling out. | ||
| 711 | activeProfileId !== browserDefaults.profileId ? ( | ||
| 712 | // Capped: profile names run to 48 characters, and an unbounded | ||
| 713 | // badge in this row takes its width from the URL input, the only | ||
| 714 | // flexible element in the compact chrome. The cap sits on the | ||
| 715 | // badge and the truncation on an inner span, because `Badge` is an | ||
| 716 | // `inline-flex` with `whitespace-nowrap` — `text-overflow` never | ||
| 717 | // reaches a bare text node inside it, so the name would be cut off | ||
| 718 | // at both ends with no ellipsis. | ||
| 719 | <Tooltip> | ||
| 720 | <TooltipTrigger render={<Badge variant="outline" className="max-w-28 shrink-0" />}> | ||
| 721 | <span className="truncate">{activeProfileName}</span> | ||
| 722 | </TooltipTrigger> | ||
| 723 | <TooltipPopup side="top">{activeProfileName}</TooltipPopup> | ||
| 724 | </Tooltip> | ||
| 725 | ) : null | ||
| 726 | } | ||
| 689 | trailingActions={ | 727 | trailingActions={ |
| 690 | previewBridge ? ( | 728 | previewBridge ? ( |
| 691 | <PreviewMoreMenu | 729 | <PreviewMoreMenu |
| 730 | environmentId={threadRef.environmentId} | ||
| 731 | profileId={activeProfileId} | ||
| 732 | profileName={activeProfileName} | ||
| 692 | tabId={runtimeTabId} | 733 | tabId={runtimeTabId} |
| 693 | hasWebContents={desktopOverlay?.hasWebContents ?? false} | 734 | hasWebContents={desktopOverlay?.hasWebContents ?? false} |
| 694 | zoomFactor={desktopOverlay?.zoomFactor ?? 1} | 735 | zoomFactor={desktopOverlay?.zoomFactor ?? 1} |
apps/web/src/components/preview/addBrowserSurface.test.ts+20| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_BROWSER_PROFILE_ID, | ||
| 2 | FILL_PREVIEW_VIEWPORT, | 3 | FILL_PREVIEW_VIEWPORT, |
| 3 | type PreviewOpenInput, | 4 | type PreviewOpenInput, |
| 4 | type PreviewSessionSnapshot, | 5 | type PreviewSessionSnapshot, |
| ... | @@ -36,6 +37,24 @@ beforeEach(() => { | ... | @@ -36,6 +37,24 @@ beforeEach(() => { |
| 36 | }); | 37 | }); |
| 37 | 38 | ||
| 38 | describe("addBrowserSurface", () => { | 39 | describe("addBrowserSurface", () => { |
| 40 | it("opens under the requested profile", async () => { | ||
| 41 | const openPreview = vi.fn(async (_input: PreviewOpenInput) => | ||
| 42 | AsyncResult.success(snapshot("tab-1")), | ||
| 43 | ); | ||
| 44 | |||
| 45 | await addBrowserSurface({ | ||
| 46 | threadRef, | ||
| 47 | openPreview: ({ input }) => openPreview(input), | ||
| 48 | profileId: "profile-work", | ||
| 49 | }); | ||
| 50 | |||
| 51 | expect(openPreview).toHaveBeenCalledWith({ | ||
| 52 | threadId: "thread-1", | ||
| 53 | viewport: FILL_PREVIEW_VIEWPORT, | ||
| 54 | profileId: "profile-work", | ||
| 55 | }); | ||
| 56 | }); | ||
| 57 | |||
| 39 | it("creates another preview session when a browser tab is already active", async () => { | 58 | it("creates another preview session when a browser tab is already active", async () => { |
| 40 | const first = snapshot("tab-1"); | 59 | const first = snapshot("tab-1"); |
| 41 | const second = snapshot("tab-2"); | 60 | const second = snapshot("tab-2"); |
| ... | @@ -48,6 +67,7 @@ describe("addBrowserSurface", () => { | ... | @@ -48,6 +67,7 @@ describe("addBrowserSurface", () => { |
| 48 | expect(openPreview).toHaveBeenCalledWith({ | 67 | expect(openPreview).toHaveBeenCalledWith({ |
| 49 | threadId: "thread-1", | 68 | threadId: "thread-1", |
| 50 | viewport: FILL_PREVIEW_VIEWPORT, | 69 | viewport: FILL_PREVIEW_VIEWPORT, |
| 70 | profileId: DEFAULT_BROWSER_PROFILE_ID, | ||
| 51 | }); | 71 | }); |
| 52 | expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]); | 72 | expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]); |
| 53 | expect( | 73 | expect( |
apps/web/src/components/preview/addBrowserSurface.ts+3| ... | @@ -13,10 +13,13 @@ import { openPreviewSession } from "./openPreviewSession"; | ... | @@ -13,10 +13,13 @@ import { openPreviewSession } from "./openPreviewSession"; |
| 13 | export async function addBrowserSurface<E>(input: { | 13 | export async function addBrowserSurface<E>(input: { |
| 14 | readonly threadRef: ScopedThreadRef; | 14 | readonly threadRef: ScopedThreadRef; |
| 15 | readonly openPreview: OpenPreviewMutation<E>; | 15 | readonly openPreview: OpenPreviewMutation<E>; |
| 16 | /** Omit to use the configured default profile. */ | ||
| 17 | readonly profileId?: string | undefined; | ||
| 16 | }): Promise<AtomCommandResult<void, E>> { | 18 | }): Promise<AtomCommandResult<void, E>> { |
| 17 | const result = await openPreviewSession({ | 19 | const result = await openPreviewSession({ |
| 18 | openPreview: input.openPreview, | 20 | openPreview: input.openPreview, |
| 19 | threadRef: input.threadRef, | 21 | threadRef: input.threadRef, |
| 22 | ...(input.profileId === undefined ? {} : { profileId: input.profileId }), | ||
| 20 | }); | 23 | }); |
| 21 | return mapAtomCommandResult(result, (snapshot) => { | 24 | return mapAtomCommandResult(result, (snapshot) => { |
| 22 | useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); | 25 | useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); |
apps/web/src/components/preview/openPreviewSession.test.ts+3| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_BROWSER_PROFILE_ID, | ||
| 2 | FILL_PREVIEW_VIEWPORT, | 3 | FILL_PREVIEW_VIEWPORT, |
| 3 | type PreviewOpenInput, | 4 | type PreviewOpenInput, |
| 4 | type PreviewSessionSnapshot, | 5 | type PreviewSessionSnapshot, |
| ... | @@ -49,6 +50,7 @@ describe("openPreviewSession", () => { | ... | @@ -49,6 +50,7 @@ describe("openPreviewSession", () => { |
| 49 | expect(open).toHaveBeenCalledWith({ | 50 | expect(open).toHaveBeenCalledWith({ |
| 50 | threadId: "thread-1", | 51 | threadId: "thread-1", |
| 51 | viewport: FILL_PREVIEW_VIEWPORT, | 52 | viewport: FILL_PREVIEW_VIEWPORT, |
| 53 | profileId: DEFAULT_BROWSER_PROFILE_ID, | ||
| 52 | }); | 54 | }); |
| 53 | expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot); | 55 | expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot); |
| 54 | expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); | 56 | expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); |
| ... | @@ -67,6 +69,7 @@ describe("openPreviewSession", () => { | ... | @@ -67,6 +69,7 @@ describe("openPreviewSession", () => { |
| 67 | threadId: "thread-1", | 69 | threadId: "thread-1", |
| 68 | url: "t3.chat", | 70 | url: "t3.chat", |
| 69 | viewport: FILL_PREVIEW_VIEWPORT, | 71 | viewport: FILL_PREVIEW_VIEWPORT, |
| 72 | profileId: DEFAULT_BROWSER_PROFILE_ID, | ||
| 70 | }); | 73 | }); |
| 71 | expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); | 74 | expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); |
| 72 | expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]); | 75 | expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]); |
apps/web/src/components/preview/openPreviewSession.ts+12-2| ... | @@ -7,7 +7,11 @@ import type { | ... | @@ -7,7 +7,11 @@ import type { |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | 8 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; |
| 9 | 9 | ||
| 10 | import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; | 10 | import { |
| 11 | browserDefaultOpenProfileId, | ||
| 12 | browserDefaultOpenViewport, | ||
| 13 | resolveBrowserDefaults, | ||
| 14 | } from "~/browser/browserDefaults"; | ||
| 11 | import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; | 15 | import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; |
| 12 | 16 | ||
| 13 | interface OpenPreviewSessionInput<E> { | 17 | interface OpenPreviewSessionInput<E> { |
| ... | @@ -19,17 +23,23 @@ interface OpenPreviewSessionInput<E> { | ... | @@ -19,17 +23,23 @@ interface OpenPreviewSessionInput<E> { |
| 19 | url?: string; | 23 | url?: string; |
| 20 | /** Overrides the configured default; automation passes an explicit size. */ | 24 | /** Overrides the configured default; automation passes an explicit size. */ |
| 21 | viewport?: PreviewViewportSetting; | 25 | viewport?: PreviewViewportSetting; |
| 26 | /** Overrides the configured default profile. */ | ||
| 27 | profileId?: string; | ||
| 22 | } | 28 | } |
| 23 | 29 | ||
| 24 | export async function openPreviewSession<E>( | 30 | export async function openPreviewSession<E>( |
| 25 | input: OpenPreviewSessionInput<E>, | 31 | input: OpenPreviewSessionInput<E>, |
| 26 | ): Promise<AtomCommandResult<PreviewSessionSnapshot, E>> { | 32 | ): Promise<AtomCommandResult<PreviewSessionSnapshot, E>> { |
| 33 | // Resolved once: a tab opened before client settings hydrate would otherwise | ||
| 34 | // be born at the schema defaults and never corrected. | ||
| 35 | const defaults = await resolveBrowserDefaults(); | ||
| 27 | const result = await input.openPreview({ | 36 | const result = await input.openPreview({ |
| 28 | environmentId: input.threadRef.environmentId, | 37 | environmentId: input.threadRef.environmentId, |
| 29 | input: { | 38 | input: { |
| 30 | threadId: input.threadRef.threadId, | 39 | threadId: input.threadRef.threadId, |
| 31 | ...(input.url === undefined ? {} : { url: input.url }), | 40 | ...(input.url === undefined ? {} : { url: input.url }), |
| 32 | viewport: input.viewport ?? browserDefaultOpenViewport(await resolveBrowserDefaults()), | 41 | viewport: input.viewport ?? browserDefaultOpenViewport(defaults), |
| 42 | profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), | ||
| 33 | }, | 43 | }, |
| 34 | }); | 44 | }); |
| 35 | if (result._tag === "Failure") { | 45 | if (result._tag === "Failure") { |
apps/web/src/components/preview/openTerminalLinkInPreview.test.ts+59-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; | 1 | import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import * as Cause from "effect/Cause"; | 2 | import * as Cause from "effect/Cause"; |
| 3 | import { AsyncResult } from "effect/unstable/reactivity"; | 3 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 4 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 5 | 5 | ||
| 6 | import { | 6 | import { |
| 7 | openTerminalLinkInPreview, | 7 | openTerminalLinkInPreview, |
| ... | @@ -20,6 +20,21 @@ vi.mock("~/rightPanelStore", () => ({ | ... | @@ -20,6 +20,21 @@ vi.mock("~/rightPanelStore", () => ({ |
| 20 | }, | 20 | }, |
| 21 | })); | 21 | })); |
| 22 | 22 | ||
| 23 | const browserDefaultsMocks = vi.hoisted(() => ({ | ||
| 24 | resolve: vi.fn(), | ||
| 25 | })); | ||
| 26 | |||
| 27 | vi.mock("~/browser/browserDefaults", () => ({ | ||
| 28 | resolveBrowserDefaults: browserDefaultsMocks.resolve, | ||
| 29 | browserDefaultOpenViewport: (defaults: { viewport: unknown }) => defaults.viewport, | ||
| 30 | browserDefaultOpenProfileId: (defaults: { profileId: string }) => defaults.profileId, | ||
| 31 | })); | ||
| 32 | |||
| 33 | const hydratedDefaults = { | ||
| 34 | viewport: { _tag: "fixed", width: 1280, height: 720 } as const, | ||
| 35 | profileId: "work", | ||
| 36 | }; | ||
| 37 | |||
| 23 | const threadRef = { | 38 | const threadRef = { |
| 24 | environmentId: "local" as ScopedThreadRef["environmentId"], | 39 | environmentId: "local" as ScopedThreadRef["environmentId"], |
| 25 | threadId: "thread-1" as ScopedThreadRef["threadId"], | 40 | threadId: "thread-1" as ScopedThreadRef["threadId"], |
| ... | @@ -34,11 +49,54 @@ const snapshot: PreviewSessionSnapshot = { | ... | @@ -34,11 +49,54 @@ const snapshot: PreviewSessionSnapshot = { |
| 34 | updatedAt: "2026-06-20T00:00:00.000Z", | 49 | updatedAt: "2026-06-20T00:00:00.000Z", |
| 35 | }; | 50 | }; |
| 36 | 51 | ||
| 52 | beforeEach(() => { | ||
| 53 | browserDefaultsMocks.resolve.mockResolvedValue(hydratedDefaults); | ||
| 54 | }); | ||
| 55 | |||
| 37 | afterEach(() => { | 56 | afterEach(() => { |
| 38 | vi.restoreAllMocks(); | 57 | vi.restoreAllMocks(); |
| 39 | }); | 58 | }); |
| 40 | 59 | ||
| 41 | describe("openTerminalLinkInPreview", () => { | 60 | describe("openTerminalLinkInPreview", () => { |
| 61 | it("waits for hydrated viewport and profile defaults before opening", async () => { | ||
| 62 | let hydrate: ((defaults: typeof hydratedDefaults) => void) | undefined; | ||
| 63 | browserDefaultsMocks.resolve.mockImplementationOnce( | ||
| 64 | () => | ||
| 65 | new Promise<typeof hydratedDefaults>((resolve) => { | ||
| 66 | hydrate = resolve; | ||
| 67 | }), | ||
| 68 | ); | ||
| 69 | const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); | ||
| 70 | |||
| 71 | const opening = openTerminalLinkInPreview({ | ||
| 72 | url: "http://localhost:3000/", | ||
| 73 | position: { x: 12, y: 34 }, | ||
| 74 | threadRef, | ||
| 75 | openPreview, | ||
| 76 | localApi: { | ||
| 77 | contextMenu: { | ||
| 78 | show: vi.fn(async () => "open-in-preview"), | ||
| 79 | }, | ||
| 80 | } as unknown as LocalApi, | ||
| 81 | fallbackToBrowser: vi.fn(), | ||
| 82 | }); | ||
| 83 | |||
| 84 | await vi.waitFor(() => expect(browserDefaultsMocks.resolve).toHaveBeenCalledOnce()); | ||
| 85 | expect(openPreview).not.toHaveBeenCalled(); | ||
| 86 | hydrate?.(hydratedDefaults); | ||
| 87 | await opening; | ||
| 88 | |||
| 89 | expect(openPreview).toHaveBeenCalledWith({ | ||
| 90 | environmentId: "local", | ||
| 91 | input: { | ||
| 92 | threadId: "thread-1", | ||
| 93 | url: "http://localhost:3000/", | ||
| 94 | viewport: hydratedDefaults.viewport, | ||
| 95 | profileId: hydratedDefaults.profileId, | ||
| 96 | }, | ||
| 97 | }); | ||
| 98 | }); | ||
| 99 | |||
| 42 | it("preserves context-menu failures with terminal link context before falling back", async () => { | 100 | it("preserves context-menu failures with terminal link context before falling back", async () => { |
| 43 | const cause = new Error("menu unavailable"); | 101 | const cause = new Error("menu unavailable"); |
| 44 | const fallbackToBrowser = vi.fn(); | 102 | const fallbackToBrowser = vi.fn(); |
apps/web/src/components/preview/openTerminalLinkInPreview.ts+14-1| ... | @@ -3,6 +3,11 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime" | ... | @@ -3,6 +3,11 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime" |
| 3 | import { isPreviewableUrl } from "@t3tools/shared/preview"; | 3 | import { isPreviewableUrl } from "@t3tools/shared/preview"; |
| 4 | import * as Schema from "effect/Schema"; | 4 | import * as Schema from "effect/Schema"; |
| 5 | 5 | ||
| 6 | import { | ||
| 7 | browserDefaultOpenProfileId, | ||
| 8 | browserDefaultOpenViewport, | ||
| 9 | resolveBrowserDefaults, | ||
| 10 | } from "~/browser/browserDefaults"; | ||
| 6 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; | 11 | import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; |
| 7 | import { recordVisitForThread } from "~/browserHistoryStore"; | 12 | import { recordVisitForThread } from "~/browserHistoryStore"; |
| 8 | import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; | 13 | import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; |
| ... | @@ -82,9 +87,17 @@ export async function openTerminalLinkInPreview<E>( | ... | @@ -82,9 +87,17 @@ export async function openTerminalLinkInPreview<E>( |
| 82 | } | 87 | } |
| 83 | 88 | ||
| 84 | if (choice === "open-in-preview") { | 89 | if (choice === "open-in-preview") { |
| 90 | const defaults = await resolveBrowserDefaults(); | ||
| 85 | const result = await input.openPreview({ | 91 | const result = await input.openPreview({ |
| 86 | environmentId: input.threadRef.environmentId, | 92 | environmentId: input.threadRef.environmentId, |
| 87 | input: { threadId: input.threadRef.threadId, url: input.url }, | 93 | input: { |
| 94 | threadId: input.threadRef.threadId, | ||
| 95 | url: input.url, | ||
| 96 | // Same reason as `openUrlInPreview`: this path handles its own result | ||
| 97 | // mapping, so the configured defaults are applied explicitly. | ||
| 98 | viewport: browserDefaultOpenViewport(defaults), | ||
| 99 | profileId: browserDefaultOpenProfileId(defaults), | ||
| 100 | }, | ||
| 88 | }); | 101 | }); |
| 89 | if (result._tag === "Failure") { | 102 | if (result._tag === "Failure") { |
| 90 | if (isAtomCommandInterrupted(result)) { | 103 | if (isAtomCommandInterrupted(result)) { |
apps/web/src/components/settings/IntegrationsSettings.logic.test.ts created+74| ... | @@ -0,0 +1,74 @@ | ||
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { browserProfileRemovalAvailable, clearBrowserProfileData } from "./IntegrationsSettings"; | ||
| 5 | |||
| 6 | const environmentId = "environment-a" as EnvironmentId; | ||
| 7 | const secondEnvironmentId = "environment-b" as EnvironmentId; | ||
| 8 | |||
| 9 | describe("clearBrowserProfileData", () => { | ||
| 10 | it("waits for cookie and cache cleanup", async () => { | ||
| 11 | const clearCookies = vi.fn().mockResolvedValue(undefined); | ||
| 12 | const clearCache = vi.fn().mockResolvedValue(undefined); | ||
| 13 | |||
| 14 | await clearBrowserProfileData({ clearCookies, clearCache }, [environmentId], "profile-a"); | ||
| 15 | |||
| 16 | expect(clearCookies).toHaveBeenCalledWith(environmentId, "profile-a"); | ||
| 17 | expect(clearCache).toHaveBeenCalledWith(environmentId, "profile-a"); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("clears every known environment before succeeding", async () => { | ||
| 21 | const clearCookies = vi.fn().mockResolvedValue(undefined); | ||
| 22 | const clearCache = vi.fn().mockResolvedValue(undefined); | ||
| 23 | |||
| 24 | await clearBrowserProfileData( | ||
| 25 | { clearCookies, clearCache }, | ||
| 26 | [environmentId, secondEnvironmentId], | ||
| 27 | "profile-a", | ||
| 28 | ); | ||
| 29 | |||
| 30 | expect(clearCookies.mock.calls).toEqual([ | ||
| 31 | [environmentId, "profile-a"], | ||
| 32 | [secondEnvironmentId, "profile-a"], | ||
| 33 | ]); | ||
| 34 | expect(clearCache.mock.calls).toEqual([ | ||
| 35 | [environmentId, "profile-a"], | ||
| 36 | [secondEnvironmentId, "profile-a"], | ||
| 37 | ]); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("propagates cleanup failures", async () => { | ||
| 41 | const failure = new Error("clear failed"); | ||
| 42 | await expect( | ||
| 43 | clearBrowserProfileData( | ||
| 44 | { | ||
| 45 | clearCookies: vi.fn().mockRejectedValue(failure), | ||
| 46 | clearCache: vi.fn().mockResolvedValue(undefined), | ||
| 47 | }, | ||
| 48 | [environmentId], | ||
| 49 | "profile-a", | ||
| 50 | ), | ||
| 51 | ).rejects.toBe(failure); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("does not report success without an environment or bridge", async () => { | ||
| 55 | const bridge = { | ||
| 56 | clearCookies: vi.fn().mockResolvedValue(undefined), | ||
| 57 | clearCache: vi.fn().mockResolvedValue(undefined), | ||
| 58 | }; | ||
| 59 | |||
| 60 | await expect(clearBrowserProfileData(bridge, [], "profile-a")).rejects.toThrow(); | ||
| 61 | await expect(clearBrowserProfileData(null, [environmentId], "profile-a")).rejects.toThrow(); | ||
| 62 | expect(bridge.clearCookies).not.toHaveBeenCalled(); | ||
| 63 | expect(bridge.clearCache).not.toHaveBeenCalled(); | ||
| 64 | }); | ||
| 65 | }); | ||
| 66 | |||
| 67 | describe("browserProfileRemovalAvailable", () => { | ||
| 68 | it("requires a ready non-empty catalog and desktop bridge", () => { | ||
| 69 | expect(browserProfileRemovalAvailable(true, true, 1)).toBe(true); | ||
| 70 | expect(browserProfileRemovalAvailable(true, true, 0)).toBe(false); | ||
| 71 | expect(browserProfileRemovalAvailable(true, false, 1)).toBe(false); | ||
| 72 | expect(browserProfileRemovalAvailable(false, true, 1)).toBe(false); | ||
| 73 | }); | ||
| 74 | }); | ||
apps/web/src/components/settings/IntegrationsSettings.tsx+351-1| ... | @@ -7,8 +7,13 @@ | ... | @@ -7,8 +7,13 @@ |
| 7 | * @module IntegrationsSettings | 7 | * @module IntegrationsSettings |
| 8 | */ | 8 | */ |
| 9 | import { | 9 | import { |
| 10 | BROWSER_PROFILE_MAX_COUNT, | ||
| 11 | type BrowserProfile, | ||
| 12 | type EnvironmentId, | ||
| 13 | BROWSER_PROFILE_NAME_MAX_LENGTH, | ||
| 10 | BROWSER_RECORDING_FRAME_RATES, | 14 | BROWSER_RECORDING_FRAME_RATES, |
| 11 | DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, | 15 | DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, |
| 16 | DEFAULT_BROWSER_PROFILE_ID, | ||
| 12 | DEFAULT_BROWSER_RECORDING_FRAME_RATE, | 17 | DEFAULT_BROWSER_RECORDING_FRAME_RATE, |
| 13 | DEFAULT_BROWSER_VIEWPORT, | 18 | DEFAULT_BROWSER_VIEWPORT, |
| 14 | DEFAULT_PREVIEW_APPEARANCE, | 19 | DEFAULT_PREVIEW_APPEARANCE, |
| ... | @@ -19,17 +24,35 @@ import { | ... | @@ -19,17 +24,35 @@ import { |
| 19 | PREVIEW_VIEWPORT_MAX_DIMENSION, | 24 | PREVIEW_VIEWPORT_MAX_DIMENSION, |
| 20 | PREVIEW_VIEWPORT_MIN_DIMENSION, | 25 | PREVIEW_VIEWPORT_MIN_DIMENSION, |
| 21 | PREVIEW_ZOOM_LEVELS, | 26 | PREVIEW_ZOOM_LEVELS, |
| 27 | findBrowserProfile, | ||
| 28 | isBuiltInBrowserProfileId, | ||
| 29 | resolveBrowserProfiles, | ||
| 22 | type PreviewAppearancePreference, | 30 | type PreviewAppearancePreference, |
| 23 | type PreviewViewportSetting, | 31 | type PreviewViewportSetting, |
| 24 | } from "@t3tools/contracts"; | 32 | } from "@t3tools/contracts"; |
| 25 | import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; | 33 | import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; |
| 26 | import { InfoIcon } from "lucide-react"; | 34 | import { InfoIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react"; |
| 35 | import { useState } from "react"; | ||
| 27 | import type { ReactNode } from "react"; | 36 | import type { ReactNode } from "react"; |
| 28 | 37 | ||
| 29 | import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; | 38 | import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; |
| 39 | import { previewBridge } from "~/components/preview/previewBridge"; | ||
| 40 | import { cn, randomUUID } from "~/lib/utils"; | ||
| 41 | import { useEnvironments } from "~/state/environments"; | ||
| 30 | import { isElectron } from "../../env"; | 42 | import { isElectron } from "../../env"; |
| 31 | 43 | ||
| 44 | import { Badge } from "../ui/badge"; | ||
| 45 | import { | ||
| 46 | AlertDialog, | ||
| 47 | AlertDialogClose, | ||
| 48 | AlertDialogDescription, | ||
| 49 | AlertDialogFooter, | ||
| 50 | AlertDialogHeader, | ||
| 51 | AlertDialogPopup, | ||
| 52 | AlertDialogTitle, | ||
| 53 | } from "../ui/alert-dialog"; | ||
| 32 | import { Button } from "../ui/button"; | 54 | import { Button } from "../ui/button"; |
| 55 | import { DraftInput } from "../ui/draft-input"; | ||
| 33 | import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field"; | 56 | import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field"; |
| 34 | import { | 57 | import { |
| 35 | Select, | 58 | Select, |
| ... | @@ -43,7 +66,9 @@ import { | ... | @@ -43,7 +66,9 @@ import { |
| 43 | import { Switch } from "../ui/switch"; | 66 | import { Switch } from "../ui/switch"; |
| 44 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 67 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 45 | import { | 68 | import { |
| 69 | getClientSettings, | ||
| 46 | useClientSettings, | 70 | useClientSettings, |
| 71 | useClientSettingsHydrated, | ||
| 47 | usePrimarySettings, | 72 | usePrimarySettings, |
| 48 | useUpdatePrimarySettings, | 73 | useUpdatePrimarySettings, |
| 49 | } from "~/hooks/useSettings"; | 74 | } from "~/hooks/useSettings"; |
| ... | @@ -54,11 +79,41 @@ import { | ... | @@ -54,11 +79,41 @@ import { |
| 54 | SettingsRow, | 79 | SettingsRow, |
| 55 | SettingsSection, | 80 | SettingsSection, |
| 56 | } from "./settingsLayout"; | 81 | } from "./settingsLayout"; |
| 82 | import { ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; | ||
| 57 | import { searchableSetting } from "./settingsSearch"; | 83 | import { searchableSetting } from "./settingsSearch"; |
| 58 | 84 | ||
| 59 | const FILL_VALUE = "fill"; | 85 | const FILL_VALUE = "fill"; |
| 60 | const RESPONSIVE_VALUE = "responsive"; | 86 | const RESPONSIVE_VALUE = "responsive"; |
| 61 | 87 | ||
| 88 | type BrowserProfileDataBridge = Pick< | ||
| 89 | NonNullable<typeof previewBridge>, | ||
| 90 | "clearCookies" | "clearCache" | ||
| 91 | >; | ||
| 92 | |||
| 93 | export async function clearBrowserProfileData( | ||
| 94 | bridge: BrowserProfileDataBridge | null, | ||
| 95 | environmentIds: ReadonlyArray<EnvironmentId>, | ||
| 96 | profileId: string, | ||
| 97 | ): Promise<void> { | ||
| 98 | if (bridge === null || environmentIds.length === 0) { | ||
| 99 | throw new Error("Browser profile data is not available to clear."); | ||
| 100 | } | ||
| 101 | await Promise.all( | ||
| 102 | environmentIds.flatMap((environmentId) => [ | ||
| 103 | bridge.clearCookies(environmentId, profileId), | ||
| 104 | bridge.clearCache(environmentId, profileId), | ||
| 105 | ]), | ||
| 106 | ); | ||
| 107 | } | ||
| 108 | |||
| 109 | export function browserProfileRemovalAvailable( | ||
| 110 | bridgeAvailable: boolean, | ||
| 111 | environmentsReady: boolean, | ||
| 112 | environmentCount: number, | ||
| 113 | ): boolean { | ||
| 114 | return bridgeAvailable && environmentsReady && environmentCount > 0; | ||
| 115 | } | ||
| 116 | |||
| 62 | /** | 117 | /** |
| 63 | * The size a "Responsive" default falls back to when the user switches away | 118 | * The size a "Responsive" default falls back to when the user switches away |
| 64 | * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry | 119 | * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry |
| ... | @@ -501,11 +556,306 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode | ... | @@ -501,11 +556,306 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode |
| 501 | ); | 556 | ); |
| 502 | } | 557 | } |
| 503 | 558 | ||
| 559 | /** | ||
| 560 | * Create, rename, and remove browser profiles. | ||
| 561 | * | ||
| 562 | * Built-ins render without controls: they are synthesized rather than stored, | ||
| 563 | * so there is nothing to rename and removing them would strand every tab that | ||
| 564 | * opened under them. | ||
| 565 | */ | ||
| 566 | function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { | ||
| 567 | const userProfiles = useClientSettings((settings) => settings.browserProfiles); | ||
| 568 | const settingsHydrated = useClientSettingsHydrated(); | ||
| 569 | const updateSettings = useUpdatePrimarySettings(); | ||
| 570 | const { environments, isReady: environmentsReady } = useEnvironments(); | ||
| 571 | const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null); | ||
| 572 | const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null); | ||
| 573 | const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false); | ||
| 574 | const removalAvailable = browserProfileRemovalAvailable( | ||
| 575 | previewBridge !== null, | ||
| 576 | environmentsReady, | ||
| 577 | environments.length, | ||
| 578 | ); | ||
| 579 | const profileWritesDisabled = disabled || !settingsHydrated; | ||
| 580 | |||
| 581 | const addProfile = () => { | ||
| 582 | if (!settingsHydrated) return; | ||
| 583 | const currentProfiles = getClientSettings().browserProfiles; | ||
| 584 | if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return; | ||
| 585 | const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name)); | ||
| 586 | let name = "New profile"; | ||
| 587 | for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`; | ||
| 588 | updateSettings({ | ||
| 589 | browserProfiles: [ | ||
| 590 | ...currentProfiles, | ||
| 591 | { id: `profile-${randomUUID()}`, name, kind: "persistent" as const }, | ||
| 592 | ], | ||
| 593 | }); | ||
| 594 | }; | ||
| 595 | |||
| 596 | const renameProfile = (id: string, next: string) => { | ||
| 597 | if (!settingsHydrated) return; | ||
| 598 | const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH); | ||
| 599 | if (name === "") return; | ||
| 600 | const currentProfiles = getClientSettings().browserProfiles; | ||
| 601 | updateSettings({ | ||
| 602 | browserProfiles: currentProfiles.map((profile) => | ||
| 603 | profile.id === id ? { ...profile, name } : profile, | ||
| 604 | ), | ||
| 605 | }); | ||
| 606 | }; | ||
| 607 | |||
| 608 | const removeProfile = async (id: string) => { | ||
| 609 | if (!settingsHydrated) return; | ||
| 610 | if (!removalAvailable) { | ||
| 611 | setProfileRemovalError("Connect to an environment before removing this profile."); | ||
| 612 | return; | ||
| 613 | } | ||
| 614 | setProfileRemovalError(null); | ||
| 615 | setProfileRemovalInFlight(true); | ||
| 616 | // Drop the partition's data too, otherwise a removed profile's cookies | ||
| 617 | // stay on disk with nothing in the UI pointing at them. | ||
| 618 | try { | ||
| 619 | await clearBrowserProfileData( | ||
| 620 | previewBridge, | ||
| 621 | environmentsReady ? environments.map((environment) => environment.environmentId) : [], | ||
| 622 | id, | ||
| 623 | ); | ||
| 624 | } catch { | ||
| 625 | setProfileRemovalError("Profile data could not be deleted. Try again."); | ||
| 626 | setProfileRemovalInFlight(false); | ||
| 627 | return; | ||
| 628 | } | ||
| 629 | const currentSettings = getClientSettings(); | ||
| 630 | updateSettings({ | ||
| 631 | browserProfiles: currentSettings.browserProfiles.filter((profile) => profile.id !== id), | ||
| 632 | // Reassign the default rather than leaving it pointing at nothing. | ||
| 633 | ...(currentSettings.browserDefaultProfileId === id | ||
| 634 | ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } | ||
| 635 | : {}), | ||
| 636 | }); | ||
| 637 | setProfileRemovalInFlight(false); | ||
| 638 | setProfilePendingRemoval(null); | ||
| 639 | }; | ||
| 640 | |||
| 641 | return ( | ||
| 642 | <SettingsRow | ||
| 643 | {...searchableSetting("browser-profiles")} | ||
| 644 | description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes." | ||
| 645 | control={ | ||
| 646 | <Button | ||
| 647 | size="sm" | ||
| 648 | variant="outline" | ||
| 649 | disabled={profileWritesDisabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT} | ||
| 650 | onClick={addProfile} | ||
| 651 | > | ||
| 652 | <PlusIcon /> | ||
| 653 | Add profile | ||
| 654 | </Button> | ||
| 655 | } | ||
| 656 | > | ||
| 657 | {/* | ||
| 658 | Each profile is its own bounded row, and the list carries the bottom | ||
| 659 | spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows | ||
| 660 | stack on narrow viewports with a larger gap inside a row than between | ||
| 661 | rows, which reads as the remove button belonging to the profile below. | ||
| 662 | */} | ||
| 663 | <div className="mt-2 space-y-1 pb-2"> | ||
| 664 | {resolveBrowserProfiles(userProfiles).map((profile) => { | ||
| 665 | const builtIn = isBuiltInBrowserProfileId(profile.id); | ||
| 666 | return ( | ||
| 667 | <div | ||
| 668 | key={profile.id} | ||
| 669 | className={cn( | ||
| 670 | ITEM_ROW_INNER_CLASSNAME, | ||
| 671 | "rounded-lg border border-border/60 px-3 py-2", | ||
| 672 | )} | ||
| 673 | > | ||
| 674 | {builtIn ? ( | ||
| 675 | // Dimmed here rather than on the list, which is the only | ||
| 676 | // content in the row without a disabled treatment of its own: | ||
| 677 | // a wrapper-level dim would stack with the rename field's and | ||
| 678 | // the remove button's, landing them near 0.41 while every | ||
| 679 | // other disabled control in the block sits at 0.64. | ||
| 680 | <span | ||
| 681 | className={cn( | ||
| 682 | "flex min-w-0 items-center gap-2 text-sm text-foreground", | ||
| 683 | profileWritesDisabled && "opacity-64", | ||
| 684 | )} | ||
| 685 | > | ||
| 686 | {profile.name} | ||
| 687 | <Badge variant="outline"> | ||
| 688 | {profile.kind === "incognito" ? "Ephemeral" : "Built-in"} | ||
| 689 | </Badge> | ||
| 690 | </span> | ||
| 691 | ) : ( | ||
| 692 | <DraftInput | ||
| 693 | nativeInput | ||
| 694 | size="sm" | ||
| 695 | className="w-full sm:w-64" | ||
| 696 | aria-label={`Rename ${profile.name}`} | ||
| 697 | disabled={profileWritesDisabled} | ||
| 698 | maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH} | ||
| 699 | value={profile.name} | ||
| 700 | onCommit={(next) => renameProfile(profile.id, next)} | ||
| 701 | /> | ||
| 702 | )} | ||
| 703 | {builtIn ? null : ( | ||
| 704 | <Tooltip> | ||
| 705 | <TooltipTrigger | ||
| 706 | render={ | ||
| 707 | <span className="inline-flex" {...(!removalAvailable ? { tabIndex: 0 } : {})}> | ||
| 708 | <Button | ||
| 709 | size="icon-sm" | ||
| 710 | variant="ghost-muted" | ||
| 711 | disabled={profileWritesDisabled || !removalAvailable} | ||
| 712 | aria-label={`Remove ${profile.name}`} | ||
| 713 | onClick={() => setProfilePendingRemoval(profile)} | ||
| 714 | > | ||
| 715 | <Trash2Icon /> | ||
| 716 | </Button> | ||
| 717 | </span> | ||
| 718 | } | ||
| 719 | /> | ||
| 720 | <TooltipPopup side="top"> | ||
| 721 | {removalAvailable | ||
| 722 | ? "Remove profile and its data" | ||
| 723 | : "Connect to an environment to remove this profile"} | ||
| 724 | </TooltipPopup> | ||
| 725 | </Tooltip> | ||
| 726 | )} | ||
| 727 | </div> | ||
| 728 | ); | ||
| 729 | })} | ||
| 730 | </div> | ||
| 731 | <AlertDialog | ||
| 732 | open={profilePendingRemoval !== null} | ||
| 733 | onOpenChange={(open) => { | ||
| 734 | if (!open && !profileRemovalInFlight) { | ||
| 735 | setProfilePendingRemoval(null); | ||
| 736 | setProfileRemovalError(null); | ||
| 737 | } | ||
| 738 | }} | ||
| 739 | > | ||
| 740 | <AlertDialogPopup> | ||
| 741 | <AlertDialogHeader> | ||
| 742 | <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle> | ||
| 743 | <AlertDialogDescription> | ||
| 744 | Its cookies, logins, and cache are deleted with it. Tabs already open in this profile | ||
| 745 | stay open until you close them. | ||
| 746 | </AlertDialogDescription> | ||
| 747 | {profileRemovalError ? ( | ||
| 748 | <p aria-live="polite" className="text-sm text-destructive"> | ||
| 749 | {profileRemovalError} | ||
| 750 | </p> | ||
| 751 | ) : null} | ||
| 752 | {!removalAvailable ? ( | ||
| 753 | <p className="text-sm text-muted-foreground"> | ||
| 754 | Connect to an environment to remove this profile and its data. | ||
| 755 | </p> | ||
| 756 | ) : null} | ||
| 757 | </AlertDialogHeader> | ||
| 758 | <AlertDialogFooter> | ||
| 759 | <AlertDialogClose | ||
| 760 | disabled={profileRemovalInFlight} | ||
| 761 | render={<Button variant="outline" disabled={profileRemovalInFlight} />} | ||
| 762 | > | ||
| 763 | Cancel | ||
| 764 | </AlertDialogClose> | ||
| 765 | <Button | ||
| 766 | variant="destructive" | ||
| 767 | disabled={profileRemovalInFlight || !settingsHydrated || !removalAvailable} | ||
| 768 | onClick={() => { | ||
| 769 | if (profilePendingRemoval && settingsHydrated && removalAvailable) { | ||
| 770 | void removeProfile(profilePendingRemoval.id); | ||
| 771 | } | ||
| 772 | }} | ||
| 773 | > | ||
| 774 | {profileRemovalInFlight ? "Removing…" : "Remove profile"} | ||
| 775 | </Button> | ||
| 776 | </AlertDialogFooter> | ||
| 777 | </AlertDialogPopup> | ||
| 778 | </AlertDialog> | ||
| 779 | </SettingsRow> | ||
| 780 | ); | ||
| 781 | } | ||
| 782 | |||
| 783 | function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) { | ||
| 784 | const userProfiles = useClientSettings((settings) => settings.browserProfiles); | ||
| 785 | const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); | ||
| 786 | const settingsHydrated = useClientSettingsHydrated(); | ||
| 787 | const updateSettings = useUpdatePrimarySettings(); | ||
| 788 | const profileWritesDisabled = disabled || !settingsHydrated; | ||
| 789 | // Incognito is deliberately absent: as a default it would open every tab | ||
| 790 | // into storage that is discarded on close. | ||
| 791 | const profiles = resolveBrowserProfiles(userProfiles).filter( | ||
| 792 | (profile) => profile.kind !== "incognito", | ||
| 793 | ); | ||
| 794 | const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0]; | ||
| 795 | |||
| 796 | return ( | ||
| 797 | <SettingsRow | ||
| 798 | {...searchableSetting("browser-default-profile")} | ||
| 799 | description="Profile new browser tabs open under, including tabs an agent opens." | ||
| 800 | resetAction={ | ||
| 801 | !profileWritesDisabled && defaultProfileId !== DEFAULT_BROWSER_PROFILE_ID ? ( | ||
| 802 | <SettingResetButton | ||
| 803 | label="default browser profile" | ||
| 804 | onClick={() => { | ||
| 805 | if (settingsHydrated) { | ||
| 806 | updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID }); | ||
| 807 | } | ||
| 808 | }} | ||
| 809 | /> | ||
| 810 | ) : null | ||
| 811 | } | ||
| 812 | control={ | ||
| 813 | <Select | ||
| 814 | disabled={profileWritesDisabled} | ||
| 815 | value={selected?.id ?? DEFAULT_BROWSER_PROFILE_ID} | ||
| 816 | onValueChange={(value) => { | ||
| 817 | if (settingsHydrated && value !== null) { | ||
| 818 | updateSettings({ browserDefaultProfileId: value }); | ||
| 819 | } | ||
| 820 | }} | ||
| 821 | > | ||
| 822 | <SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Default browser profile"> | ||
| 823 | <SelectValue>{selected?.name ?? "Default"}</SelectValue> | ||
| 824 | </SelectTrigger> | ||
| 825 | {/* | ||
| 826 | Capped and truncated like the tab menu's profile list: names are | ||
| 827 | user-supplied and run to 48 characters, which would otherwise | ||
| 828 | widen the popup to fit the longest one. The cap goes on the glass | ||
| 829 | shell (`popupClassName`), and the list fills that shell so it is | ||
| 830 | never narrower than the trigger it opens from — the same floor | ||
| 831 | every other settings select keeps. `ItemText` renders a block, so | ||
| 832 | the label must be a block too for `truncate` to apply. | ||
| 833 | */} | ||
| 834 | <SelectPopup | ||
| 835 | align="end" | ||
| 836 | alignItemWithTrigger={false} | ||
| 837 | popupClassName="max-w-64" | ||
| 838 | className="w-full" | ||
| 839 | > | ||
| 840 | {profiles.map((profile) => ( | ||
| 841 | <SelectItem hideIndicator key={profile.id} value={profile.id}> | ||
| 842 | <span className="block min-w-0 truncate">{profile.name}</span> | ||
| 843 | </SelectItem> | ||
| 844 | ))} | ||
| 845 | </SelectPopup> | ||
| 846 | </Select> | ||
| 847 | } | ||
| 848 | /> | ||
| 849 | ); | ||
| 850 | } | ||
| 851 | |||
| 504 | export function IntegrationsSettingsPanel() { | 852 | export function IntegrationsSettingsPanel() { |
| 505 | // Client-local preview defaults are editable only where the preview exists. | 853 | // Client-local preview defaults are editable only where the preview exists. |
| 506 | const previewDefaultsDisabled = !isElectron; | 854 | const previewDefaultsDisabled = !isElectron; |
| 507 | const previewDefaults = ( | 855 | const previewDefaults = ( |
| 508 | <> | 856 | <> |
| 857 | <BrowserProfilesSetting disabled={previewDefaultsDisabled} /> | ||
| 858 | <BrowserDefaultProfileSetting disabled={previewDefaultsDisabled} /> | ||
| 509 | <BrowserViewportSetting disabled={previewDefaultsDisabled} /> | 859 | <BrowserViewportSetting disabled={previewDefaultsDisabled} /> |
| 510 | <BrowserZoomSetting disabled={previewDefaultsDisabled} /> | 860 | <BrowserZoomSetting disabled={previewDefaultsDisabled} /> |
| 511 | <BrowserAppearanceSetting disabled={previewDefaultsDisabled} /> | 861 | <BrowserAppearanceSetting disabled={previewDefaultsDisabled} /> |
apps/web/src/components/settings/settingsSearch.ts+12| ... | @@ -312,6 +312,18 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -312,6 +312,18 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 312 | to: "/settings/integrations", | 312 | to: "/settings/integrations", |
| 313 | searchTerms: ["allow open drive preview tools sessions"], | 313 | searchTerms: ["allow open drive preview tools sessions"], |
| 314 | }, | 314 | }, |
| 315 | { | ||
| 316 | id: "browser-profiles", | ||
| 317 | title: "Browser profiles", | ||
| 318 | to: "/settings/integrations", | ||
| 319 | targetId: "browser", | ||
| 320 | }, | ||
| 321 | { | ||
| 322 | id: "browser-default-profile", | ||
| 323 | title: "Default browser profile", | ||
| 324 | to: "/settings/integrations", | ||
| 325 | targetId: "browser", | ||
| 326 | }, | ||
| 315 | { | 327 | { |
| 316 | id: "browser-default-viewport", | 328 | id: "browser-default-viewport", |
| 317 | title: "Default browser viewport", | 329 | title: "Default browser viewport", |
apps/web/src/components/ui/menu.tsx+6-1| ... | @@ -235,7 +235,12 @@ function MenuSubTrigger({ | ... | @@ -235,7 +235,12 @@ function MenuSubTrigger({ |
| 235 | return ( | 235 | return ( |
| 236 | <MenuPrimitive.SubmenuTrigger | 236 | <MenuPrimitive.SubmenuTrigger |
| 237 | className={cn( | 237 | className={cn( |
| 238 | "flex min-h-8 items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none", | 238 | // Leading-icon treatment matches `MenuItem`: a sub-trigger sits in the |
| 239 | // same column as the items around it, so its icon has to align and dim | ||
| 240 | // with theirs. Scoped away from the last child because the chevron is | ||
| 241 | // also a direct svg — on a sub-trigger with no leading icon it is the | ||
| 242 | // only one, and these rules would take away its `ms-auto` alignment. | ||
| 243 | "[&>svg:not(:last-child)]:-mx-0.5 flex min-h-8 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:cursor-not-allowed data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&>svg:not(:last-child):not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&>svg]:shrink-0", | ||
| 239 | className, | 244 | className, |
| 240 | )} | 245 | )} |
| 241 | data-inset={inset} | 246 | data-inset={inset} |
apps/web/src/routes/_chat.pull-requests.tsx+1| ... | @@ -1867,6 +1867,7 @@ function PullRequestsRouteView() { | ... | @@ -1867,6 +1867,7 @@ function PullRequestsRouteView() { |
| 1867 | onCloseAllSurfaces={closeAllSurfaces} | 1867 | onCloseAllSurfaces={closeAllSurfaces} |
| 1868 | onCopyFilePath={() => undefined} | 1868 | onCopyFilePath={() => undefined} |
| 1869 | onAddBrowser={() => undefined} | 1869 | onAddBrowser={() => undefined} |
| 1870 | onAddBrowserInProfile={() => undefined} | ||
| 1870 | onAddTerminal={() => undefined} | 1871 | onAddTerminal={() => undefined} |
| 1871 | onAddDiff={() => undefined} | 1872 | onAddDiff={() => undefined} |
| 1872 | onAddFiles={() => undefined} | 1873 | onAddFiles={() => undefined} |
packages/contracts/src/browserProfile.test.ts created+111| ... | @@ -0,0 +1,111 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | BrowserProfileId, | ||
| 7 | BUILT_IN_BROWSER_PROFILES, | ||
| 8 | DEFAULT_BROWSER_PROFILE_ID, | ||
| 9 | INCOGNITO_BROWSER_PROFILE_ID, | ||
| 10 | findBrowserProfile, | ||
| 11 | isBuiltInBrowserProfileId, | ||
| 12 | resolveBrowserProfiles, | ||
| 13 | type BrowserProfile, | ||
| 14 | } from "./browserProfile.ts"; | ||
| 15 | |||
| 16 | const work: BrowserProfile = { id: "profile-work", name: "Work", kind: "persistent" }; | ||
| 17 | |||
| 18 | describe("resolveBrowserProfiles", () => { | ||
| 19 | it("lists built-ins ahead of the user's own profiles", () => { | ||
| 20 | const resolved = resolveBrowserProfiles([work]); | ||
| 21 | |||
| 22 | expect(resolved.map((profile) => profile.id)).toEqual([ | ||
| 23 | DEFAULT_BROWSER_PROFILE_ID, | ||
| 24 | INCOGNITO_BROWSER_PROFILE_ID, | ||
| 25 | work.id, | ||
| 26 | ]); | ||
| 27 | }); | ||
| 28 | |||
| 29 | it("drops stored entries that collide with a built-in id", () => { | ||
| 30 | // Built-ins are synthesized rather than stored, so a hand-edited settings | ||
| 31 | // file must not be able to shadow Default with a persistent partition of | ||
| 32 | // its own — every tab already opened under Default would follow it. | ||
| 33 | const resolved = resolveBrowserProfiles([ | ||
| 34 | { id: DEFAULT_BROWSER_PROFILE_ID, name: "Hijacked", kind: "persistent" }, | ||
| 35 | { id: INCOGNITO_BROWSER_PROFILE_ID, name: "Not incognito", kind: "persistent" }, | ||
| 36 | work, | ||
| 37 | ]); | ||
| 38 | |||
| 39 | expect(resolved).toEqual([...BUILT_IN_BROWSER_PROFILES, work]); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("keeps incognito ephemeral", () => { | ||
| 43 | const incognito = findBrowserProfile(resolveBrowserProfiles([]), INCOGNITO_BROWSER_PROFILE_ID); | ||
| 44 | |||
| 45 | expect(incognito?.kind).toBe("incognito"); | ||
| 46 | }); | ||
| 47 | }); | ||
| 48 | |||
| 49 | describe("findBrowserProfile", () => { | ||
| 50 | it("returns nothing for an id that no longer exists", () => { | ||
| 51 | // The settings UI relies on this to fall back rather than opening tabs | ||
| 52 | // into a partition with no profile behind it. | ||
| 53 | expect(findBrowserProfile(resolveBrowserProfiles([]), work.id)).toBeUndefined(); | ||
| 54 | expect(findBrowserProfile(resolveBrowserProfiles([work]), undefined)).toBeUndefined(); | ||
| 55 | }); | ||
| 56 | }); | ||
| 57 | |||
| 58 | describe("isBuiltInBrowserProfileId", () => { | ||
| 59 | it("separates built-ins from user profiles", () => { | ||
| 60 | expect(isBuiltInBrowserProfileId(DEFAULT_BROWSER_PROFILE_ID)).toBe(true); | ||
| 61 | expect(isBuiltInBrowserProfileId(INCOGNITO_BROWSER_PROFILE_ID)).toBe(true); | ||
| 62 | expect(isBuiltInBrowserProfileId(work.id)).toBe(false); | ||
| 63 | }); | ||
| 64 | }); | ||
| 65 | |||
| 66 | describe("resolveBrowserProfiles normalization", () => { | ||
| 67 | it("keeps only the first entry for a repeated id", () => { | ||
| 68 | // Both map to the same Electron partition, so presenting two would offer | ||
| 69 | // isolated identities that in fact share every cookie. | ||
| 70 | const resolved = resolveBrowserProfiles([ | ||
| 71 | { id: "work", name: "Work", kind: "persistent" }, | ||
| 72 | { id: "work", name: "Work (old)", kind: "persistent" }, | ||
| 73 | ]); | ||
| 74 | |||
| 75 | expect(resolved.filter((profile) => profile.id === "work")).toEqual([ | ||
| 76 | { id: "work", name: "Work", kind: "persistent" }, | ||
| 77 | ]); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("reports a custom incognito profile as persistent", () => { | ||
| 81 | // Partition persistence is keyed off the built-in incognito id alone, so | ||
| 82 | // a custom profile claiming that kind keeps its cookies across restarts. | ||
| 83 | // Labelling it ephemeral would be a promise the partition layer breaks. | ||
| 84 | const resolved = resolveBrowserProfiles([ | ||
| 85 | { id: "throwaway", name: "Throwaway", kind: "incognito" }, | ||
| 86 | ]); | ||
| 87 | |||
| 88 | expect(resolved.find((profile) => profile.id === "throwaway")).toEqual({ | ||
| 89 | id: "throwaway", | ||
| 90 | name: "Throwaway", | ||
| 91 | kind: "persistent", | ||
| 92 | }); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("still lets the built-in incognito profile stay ephemeral", () => { | ||
| 96 | const incognito = resolveBrowserProfiles([]).find( | ||
| 97 | (profile) => profile.id === INCOGNITO_BROWSER_PROFILE_ID, | ||
| 98 | ); | ||
| 99 | |||
| 100 | expect(incognito?.kind).toBe("incognito"); | ||
| 101 | }); | ||
| 102 | }); | ||
| 103 | |||
| 104 | describe("BrowserProfileId", () => { | ||
| 105 | it("rejects control characters", () => { | ||
| 106 | // Ids are folded into delimiter-joined cache keys on the client, so one | ||
| 107 | // carrying the delimiter would resolve to another profile's partition. | ||
| 108 | expect(Schema.is(BrowserProfileId)("profile-a\u0000b")).toBe(false); | ||
| 109 | expect(Schema.is(BrowserProfileId)("profile-a")).toBe(true); | ||
| 110 | }); | ||
| 111 | }); | ||
packages/contracts/src/browserProfile.ts created+99| ... | @@ -0,0 +1,99 @@ | ||
| 1 | /** | ||
| 2 | * Browser profiles - named identities for the in-app preview browser. | ||
| 3 | * | ||
| 4 | * Each profile maps to its own Electron session partition, so cookies and | ||
| 5 | * storage are isolated between them: a tab opened under "Work" cannot see | ||
| 6 | * "Personal"'s logins. Profiles are client-local, like the other browser | ||
| 7 | * defaults, because the Chromium guest they configure is desktop-local. | ||
| 8 | * | ||
| 9 | * Two profiles are built in and cannot be edited or removed: | ||
| 10 | * - `default` keeps the partition scope the browser used before profiles | ||
| 11 | * existed, so upgrading does not sign anyone out. | ||
| 12 | * - `incognito` maps to a non-persistent partition for throwaway sessions. | ||
| 13 | * | ||
| 14 | * @module BrowserProfile | ||
| 15 | */ | ||
| 16 | import * as Schema from "effect/Schema"; | ||
| 17 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 18 | |||
| 19 | export const BROWSER_PROFILE_NAME_MAX_LENGTH = 48; | ||
| 20 | export const BROWSER_PROFILE_MAX_COUNT = 24; | ||
| 21 | |||
| 22 | /** | ||
| 23 | * Control characters are rejected because ids are folded into delimiter-joined | ||
| 24 | * cache keys on the client; one carrying the delimiter would resolve to a | ||
| 25 | * different profile's partition. | ||
| 26 | */ | ||
| 27 | export const BrowserProfileId = TrimmedNonEmptyString.check( | ||
| 28 | Schema.isMaxLength(64), | ||
| 29 | Schema.isPattern(/^[^\p{Cc}]+$/u), | ||
| 30 | ); | ||
| 31 | export type BrowserProfileId = typeof BrowserProfileId.Type; | ||
| 32 | |||
| 33 | export const BrowserProfileName = TrimmedNonEmptyString.check( | ||
| 34 | Schema.isMaxLength(BROWSER_PROFILE_NAME_MAX_LENGTH), | ||
| 35 | ); | ||
| 36 | |||
| 37 | /** | ||
| 38 | * `persistent` profiles keep cookies on disk across restarts; `incognito` | ||
| 39 | * uses an in-memory partition that Chromium discards with the process. | ||
| 40 | */ | ||
| 41 | export const BrowserProfileKind = Schema.Literals(["persistent", "incognito"]); | ||
| 42 | export type BrowserProfileKind = typeof BrowserProfileKind.Type; | ||
| 43 | |||
| 44 | export const BrowserProfile = Schema.Struct({ | ||
| 45 | id: BrowserProfileId, | ||
| 46 | name: BrowserProfileName, | ||
| 47 | kind: BrowserProfileKind, | ||
| 48 | }); | ||
| 49 | export type BrowserProfile = typeof BrowserProfile.Type; | ||
| 50 | |||
| 51 | export const DEFAULT_BROWSER_PROFILE_ID: BrowserProfileId = "default"; | ||
| 52 | export const INCOGNITO_BROWSER_PROFILE_ID: BrowserProfileId = "incognito"; | ||
| 53 | |||
| 54 | /** | ||
| 55 | * Built-ins are synthesized rather than stored, so they cannot be renamed out | ||
| 56 | * of existence or deleted by editing the settings file by hand. | ||
| 57 | */ | ||
| 58 | export const BUILT_IN_BROWSER_PROFILES: ReadonlyArray<BrowserProfile> = [ | ||
| 59 | { id: DEFAULT_BROWSER_PROFILE_ID, name: "Default", kind: "persistent" }, | ||
| 60 | { id: INCOGNITO_BROWSER_PROFILE_ID, name: "Incognito", kind: "incognito" }, | ||
| 61 | ]; | ||
| 62 | |||
| 63 | export function isBuiltInBrowserProfileId(id: string): boolean { | ||
| 64 | return BUILT_IN_BROWSER_PROFILES.some((profile) => profile.id === id); | ||
| 65 | } | ||
| 66 | |||
| 67 | /** | ||
| 68 | * The full picker list: built-ins first, then the user's own profiles. | ||
| 69 | * | ||
| 70 | * Three things are normalized away, because each would present a profile the | ||
| 71 | * partition layer does not actually deliver: | ||
| 72 | * | ||
| 73 | * - Entries colliding with a built-in id, so a hand-edited settings file | ||
| 74 | * cannot shadow "Default" or "Incognito". | ||
| 75 | * - Repeated ids, which map to one partition and would otherwise appear as | ||
| 76 | * two isolated identities sharing every cookie. First entry wins. | ||
| 77 | * - `kind: "incognito"` on anything but the built-in, since persistence is | ||
| 78 | * keyed off that one id; such a profile is labelled ephemeral while its | ||
| 79 | * cookies survive restarts. | ||
| 80 | */ | ||
| 81 | export function resolveBrowserProfiles( | ||
| 82 | userProfiles: ReadonlyArray<BrowserProfile>, | ||
| 83 | ): ReadonlyArray<BrowserProfile> { | ||
| 84 | const seen = new Set(BUILT_IN_BROWSER_PROFILES.map((profile) => profile.id)); | ||
| 85 | const resolved = [...BUILT_IN_BROWSER_PROFILES]; | ||
| 86 | for (const profile of userProfiles) { | ||
| 87 | if (seen.has(profile.id)) continue; | ||
| 88 | seen.add(profile.id); | ||
| 89 | resolved.push(profile.kind === "persistent" ? profile : { ...profile, kind: "persistent" }); | ||
| 90 | } | ||
| 91 | return resolved; | ||
| 92 | } | ||
| 93 | |||
| 94 | export function findBrowserProfile( | ||
| 95 | profiles: ReadonlyArray<BrowserProfile>, | ||
| 96 | id: string | undefined, | ||
| 97 | ): BrowserProfile | undefined { | ||
| 98 | return id === undefined ? undefined : profiles.find((profile) => profile.id === id); | ||
| 99 | } | ||
packages/contracts/src/index.ts+1| ... | @@ -28,6 +28,7 @@ export * from "./project.ts"; | ... | @@ -28,6 +28,7 @@ export * from "./project.ts"; |
| 28 | export * from "./filesystem.ts"; | 28 | export * from "./filesystem.ts"; |
| 29 | export * from "./assets.ts"; | 29 | export * from "./assets.ts"; |
| 30 | export * from "./review.ts"; | 30 | export * from "./review.ts"; |
| 31 | export * from "./browserProfile.ts"; | ||
| 31 | export * from "./preview.ts"; | 32 | export * from "./preview.ts"; |
| 32 | export * from "./previewAutomation.ts"; | 33 | export * from "./previewAutomation.ts"; |
| 33 | export * from "./resourceTelemetry.ts"; | 34 | export * from "./resourceTelemetry.ts"; |
packages/contracts/src/ipc.ts+20-3| ... | @@ -88,6 +88,7 @@ import type { | ... | @@ -88,6 +88,7 @@ import type { |
| 88 | OrchestrationThreadStreamItem, | 88 | OrchestrationThreadStreamItem, |
| 89 | } from "./orchestration.ts"; | 89 | } from "./orchestration.ts"; |
| 90 | import { EnvironmentId } from "./baseSchemas.ts"; | 90 | import { EnvironmentId } from "./baseSchemas.ts"; |
| 91 | import { BrowserProfileId } from "./browserProfile.ts"; | ||
| 91 | import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; | 92 | import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; |
| 92 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; | 93 | import { AdvertisedEndpoint } from "./remoteAccess.ts"; |
| 93 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | 94 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| ... | @@ -974,6 +975,19 @@ export const DesktopPreviewNavigateInputSchema = Schema.Struct({ | ... | @@ -974,6 +975,19 @@ export const DesktopPreviewNavigateInputSchema = Schema.Struct({ |
| 974 | 975 | ||
| 975 | export const DesktopPreviewConfigInputSchema = Schema.Struct({ | 976 | export const DesktopPreviewConfigInputSchema = Schema.Struct({ |
| 976 | environmentId: EnvironmentId, | 977 | environmentId: EnvironmentId, |
| 978 | /** | ||
| 979 | * Browser profile the partition is derived from. Derivation stays in main: | ||
| 980 | * `will-attach-webview` only prefix-checks the partition string, so a | ||
| 981 | * renderer-supplied partition could attach to a session that never had the | ||
| 982 | * UA rewrite or permission handlers installed. | ||
| 983 | */ | ||
| 984 | profileId: Schema.optional(BrowserProfileId), | ||
| 985 | }); | ||
| 986 | |||
| 987 | export const DesktopPreviewClearDataInputSchema = Schema.Struct({ | ||
| 988 | environmentId: EnvironmentId, | ||
| 989 | /** Omit to clear every profile; otherwise only this profile's partition. */ | ||
| 990 | profileId: Schema.optional(BrowserProfileId), | ||
| 977 | }); | 991 | }); |
| 978 | 992 | ||
| 979 | export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({ | 993 | export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({ |
| ... | @@ -1158,16 +1172,19 @@ export interface DesktopPreviewBridge { | ... | @@ -1158,16 +1172,19 @@ export interface DesktopPreviewBridge { |
| 1158 | /** Open the guest webview's DevTools (detached). */ | 1172 | /** Open the guest webview's DevTools (detached). */ |
| 1159 | openDevTools: (tabId: string) => Promise<void>; | 1173 | openDevTools: (tabId: string) => Promise<void>; |
| 1160 | /** Drop cookies + storage data for the preview partition (all tabs). */ | 1174 | /** Drop cookies + storage data for the preview partition (all tabs). */ |
| 1161 | clearCookies: () => Promise<void>; | 1175 | clearCookies: (environmentId: EnvironmentId, profileId?: string) => Promise<void>; |
| 1162 | /** Drop the HTTP cache for the preview partition (all tabs). */ | 1176 | /** Drop the HTTP cache for the preview partition (all tabs). */ |
| 1163 | clearCache: () => Promise<void>; | 1177 | clearCache: (environmentId: EnvironmentId, profileId?: string) => Promise<void>; |
| 1164 | /** | 1178 | /** |
| 1165 | * One-shot config for mounting a preview `<webview>`. Replaces three | 1179 | * One-shot config for mounting a preview `<webview>`. Replaces three |
| 1166 | * earlier round-trip calls (`getBrowserPartition`, `getWebviewPreferences`, | 1180 | * earlier round-trip calls (`getBrowserPartition`, `getWebviewPreferences`, |
| 1167 | * `getPickPreloadPath`) so adding a new field here only requires touching | 1181 | * `getPickPreloadPath`) so adding a new field here only requires touching |
| 1168 | * the contract + main, not the renderer's mount logic. | 1182 | * the contract + main, not the renderer's mount logic. |
| 1169 | */ | 1183 | */ |
| 1170 | getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>; | 1184 | getPreviewConfig: ( |
| 1185 | environmentId: EnvironmentId, | ||
| 1186 | profileId?: string, | ||
| 1187 | ) => Promise<DesktopPreviewWebviewConfig>; | ||
| 1171 | setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>; | 1188 | setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>; |
| 1172 | /** | 1189 | /** |
| 1173 | * Activate the in-page element picker for the given tab. Resolves with | 1190 | * Activate the in-page element picker for the given tab. Resolves with |
packages/contracts/src/preview.ts+9| ... | @@ -10,6 +10,7 @@ | ... | @@ -10,6 +10,7 @@ |
| 10 | */ | 10 | */ |
| 11 | import { Schema } from "effect"; | 11 | import { Schema } from "effect"; |
| 12 | import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 12 | import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 13 | import { BrowserProfileId } from "./browserProfile.ts"; | ||
| 13 | 14 | ||
| 14 | export const PREVIEW_URL_MAX_LENGTH = 2_048; | 15 | export const PREVIEW_URL_MAX_LENGTH = 2_048; |
| 15 | export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32; | 16 | export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32; |
| ... | @@ -169,6 +170,12 @@ export const PreviewSessionSnapshot = Schema.Struct({ | ... | @@ -169,6 +170,12 @@ export const PreviewSessionSnapshot = Schema.Struct({ |
| 169 | canGoForward: Schema.Boolean, | 170 | canGoForward: Schema.Boolean, |
| 170 | /** Missing snapshots from older servers are treated as fill-panel mode. */ | 171 | /** Missing snapshots from older servers are treated as fill-panel mode. */ |
| 171 | viewport: Schema.optional(PreviewViewportSetting), | 172 | viewport: Schema.optional(PreviewViewportSetting), |
| 173 | /** | ||
| 174 | * Browser profile the tab's Chromium partition is derived from. Fixed at | ||
| 175 | * open: Electron only honours a `<webview>`'s partition before attach, so | ||
| 176 | * switching would require tearing the guest down and losing page state. | ||
| 177 | */ | ||
| 178 | profileId: Schema.optional(BrowserProfileId), | ||
| 172 | updatedAt: Schema.String, | 179 | updatedAt: Schema.String, |
| 173 | }); | 180 | }); |
| 174 | export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; | 181 | export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; |
| ... | @@ -184,6 +191,8 @@ export const PreviewOpenInput = Schema.Struct({ | ... | @@ -184,6 +191,8 @@ export const PreviewOpenInput = Schema.Struct({ |
| 184 | * later (which the user would see as a visible reflow). | 191 | * later (which the user would see as a visible reflow). |
| 185 | */ | 192 | */ |
| 186 | viewport: Schema.optional(PreviewViewportSetting), | 193 | viewport: Schema.optional(PreviewViewportSetting), |
| 194 | /** Omit to open under the client's configured default profile. */ | ||
| 195 | profileId: Schema.optional(BrowserProfileId), | ||
| 187 | }); | 196 | }); |
| 188 | export type PreviewOpenInput = typeof PreviewOpenInput.Type; | 197 | export type PreviewOpenInput = typeof PreviewOpenInput.Type; |
| 189 | 198 |
packages/contracts/src/settings.ts+15| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | ProviderOptionSelections, | 10 | ProviderOptionSelections, |
| 11 | } from "./model.ts"; | 11 | } from "./model.ts"; |
| 12 | import { ModelSelection } from "./orchestration.ts"; | 12 | import { ModelSelection } from "./orchestration.ts"; |
| 13 | import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; | ||
| 13 | import { | 14 | import { |
| 14 | DEFAULT_PREVIEW_APPEARANCE, | 15 | DEFAULT_PREVIEW_APPEARANCE, |
| 15 | DEFAULT_PREVIEW_ZOOM_FACTOR, | 16 | DEFAULT_PREVIEW_ZOOM_FACTOR, |
| ... | @@ -199,6 +200,18 @@ export const ClientSettingsSchema = Schema.Struct({ | ... | @@ -199,6 +200,18 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 199 | browserAutoShowFloatingPreview: Schema.Boolean.pipe( | 200 | browserAutoShowFloatingPreview: Schema.Boolean.pipe( |
| 200 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)), | 201 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)), |
| 201 | ), | 202 | ), |
| 203 | /** | ||
| 204 | * User-created browser profiles. The built-in Default and Incognito profiles | ||
| 205 | * are synthesized by `resolveBrowserProfiles`, not stored here, so they | ||
| 206 | * cannot be renamed away or deleted. | ||
| 207 | */ | ||
| 208 | browserProfiles: Schema.Array(BrowserProfile).pipe( | ||
| 209 | Schema.withDecodingDefault(Effect.succeed([])), | ||
| 210 | ), | ||
| 211 | /** Profile new tabs open under. Falls back to Default if it no longer exists. */ | ||
| 212 | browserDefaultProfileId: BrowserProfileId.pipe( | ||
| 213 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_PROFILE_ID)), | ||
| 214 | ), | ||
| 202 | // Desktop-only. Boolean values from older settings files decode to their | 215 | // Desktop-only. Boolean values from older settings files decode to their |
| 203 | // equivalent mode and encode back as the canonical string value. | 216 | // equivalent mode and encode back as the canonical string value. |
| 204 | confirmQuit: QuitConfirmationModeSetting.pipe( | 217 | confirmQuit: QuitConfirmationModeSetting.pipe( |
| ... | @@ -958,6 +971,8 @@ export const ClientSettingsPatch = Schema.Struct({ | ... | @@ -958,6 +971,8 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 958 | browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), | 971 | browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), |
| 959 | browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate), | 972 | browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate), |
| 960 | browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), | 973 | browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), |
| 974 | browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)), | ||
| 975 | browserDefaultProfileId: Schema.optionalKey(BrowserProfileId), | ||
| 961 | confirmQuit: Schema.optionalKey(QuitConfirmationMode), | 976 | confirmQuit: Schema.optionalKey(QuitConfirmationMode), |
| 962 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), | 977 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), |
| 963 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), | 978 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), |