diff --git a/src/renderer/src/components/BrowserCookieImportDisclosure.tsx b/src/renderer/src/components/BrowserCookieImportDisclosure.tsx new file mode 100644 index 00000000000..9e7c203675d --- /dev/null +++ b/src/renderer/src/components/BrowserCookieImportDisclosure.tsx @@ -0,0 +1,28 @@ +import { Info } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' + +export function BrowserCookieImportDisclosure(): React.JSX.Element { + return ( + <> + + + + + + {translate( + 'auto.components.BrowserCookieImportDisclosure.title', + "Google logins aren't imported" + )} + + + {translate( + 'auto.components.BrowserCookieImportDisclosure.description', + 'Sign in to Google directly in Orca.' + )} + + + + + ) +} diff --git a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx index 6ef0481edab..0d17baed21b 100644 --- a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx +++ b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx @@ -1,24 +1,29 @@ /** * @vitest-environment happy-dom * - * STA-3811: imports never touch the Google cookie family, so both import menus must say so at - * the moment of decision. Covers the browser toolbar menu and the Settings profile row. + * STA-3811: imports never touch the Google cookie family, so every import menu must disclose it + * at the moment of decision. */ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import en from '@/i18n/locales/en.json' -const DISCLOSURE = 'Google requires signing in directly - imports skip it.' +const DISCLOSURE_TITLE = "Google logins aren't imported" +const DISCLOSURE_DESCRIPTION = 'Sign in to Google directly in Orca.' vi.mock('@/components/ui/dropdown-menu', () => dropdownMenuStubs()) vi.mock('../ui/dropdown-menu', () => dropdownMenuStubs()) +vi.mock('@/components/ui/popover', () => popoverStubs()) vi.mock('@/store', () => ({ useAppStore: appStoreStub() })) vi.mock('../../store', () => ({ useAppStore: appStoreStub() })) vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +import { BrowserCookieImportDisclosure } from './BrowserCookieImportDisclosure' +import { BrowserImportHintButton } from './browser-pane/BrowserImportHintButton' import { BrowserToolbarMenuDropdown } from './browser-pane/browser-toolbar-menu-dropdown' import { BrowserProfileRow } from './settings/BrowserProfileRow' +import { BrowserUseCookieImportStep } from './settings/BrowserUseCookieImportStep' const DETECTED_BROWSERS = [ { @@ -29,7 +34,7 @@ const DETECTED_BROWSERS = [ } ] -describe('cookie-import Google disclosure caption', () => { +describe('cookie-import Google disclosure footer', () => { let container: HTMLDivElement let root: Root @@ -44,9 +49,10 @@ describe('cookie-import Google disclosure caption', () => { container.remove() }) - it('is shown in the browser toolbar import menu', () => { - act(() => { - root.render( + it.each([ + [ + 'browser toolbar overflow', + () => ( { onApplyViewportPreset={vi.fn()} /> ) - }) - - expect(container.textContent).toContain(DISCLOSURE) - }) - - it('is shown in the Settings browser-profile import menu', () => { - act(() => { - root.render( + ], + ['browser toolbar hint', () => ], + [ + 'Settings browser-use setup', + () => ( + + ) + ], + [ + 'Settings browser-profile row', + () => ( { onSelect={vi.fn()} /> ) - }) + ] + ] satisfies [string, () => ReactNode][])('is shown in the %s menu', (_name, renderSurface) => { + act(() => root.render(renderSurface())) - expect(container.textContent).toContain(DISCLOSURE) + expect(container.textContent).toContain(DISCLOSURE_TITLE) + expect(container.textContent).toContain(DISCLOSURE_DESCRIPTION) }) - // Why: the rendered text comes from the catalog, not the translate() fallback, so a copy - // drift in en.json alone would otherwise slip through both render assertions. - it('reads the same copy from the catalog on both surfaces', () => { - expect(catalogEntry('auto.components.browser.pane.BrowserToolbarMenu.c186b4d890')).toBe( - DISCLOSURE + it('renders the icon and separator as non-interactive footer chrome', () => { + act(() => root.render()) + + const label = container.querySelector('[data-testid="dropdown-menu-label"]') + expect(label?.querySelector('svg')).not.toBeNull() + expect(label?.previousElementSibling?.tagName).toBe('HR') + }) + + it('reads the footer copy from the catalog', () => { + expect(catalogEntry('auto.components.BrowserCookieImportDisclosure.title')).toBe( + DISCLOSURE_TITLE + ) + expect(catalogEntry('auto.components.BrowserCookieImportDisclosure.description')).toBe( + DISCLOSURE_DESCRIPTION ) - expect(catalogEntry('auto.components.settings.BrowserProfileRow.654a0c2073')).toBe(DISCLOSURE) }) }) @@ -113,11 +138,13 @@ function dropdownMenuStubs(): Record { DropdownMenu: passthrough, DropdownMenuContent: block, DropdownMenuItem: block, - DropdownMenuLabel: block, + DropdownMenuLabel: ({ children }: { children?: ReactNode }): ReactNode => ( +
{children}
+ ), DropdownMenuPortal: passthrough, DropdownMenuRadioGroup: passthrough, DropdownMenuRadioItem: block, - DropdownMenuSeparator: () => null, + DropdownMenuSeparator: () =>
, DropdownMenuSub: passthrough, DropdownMenuSubContent: block, DropdownMenuSubTrigger: block, @@ -125,11 +152,33 @@ function dropdownMenuStubs(): Record { } } +function popoverStubs(): Record { + const passthrough = ({ children }: { children?: ReactNode }): ReactNode => children + const block = ({ children }: { children?: ReactNode }): ReactNode =>
{children}
+ return { Popover: passthrough, PopoverContent: block, PopoverTrigger: passthrough } +} + function appStoreStub(): unknown { const state = { + browserImportHintHidden: false, + browserSessionImportState: null, + detectedBrowsers: [ + { + family: 'chrome', + label: 'Google Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } + ], + detectedBrowsersLoaded: true, fetchDetectedBrowsers: vi.fn(), + importCookiesFromBrowser: vi.fn(), + importCookiesToProfile: vi.fn(), openSettingsTarget: vi.fn(), - openSettingsPage: vi.fn() + openSettingsPage: vi.fn(), + persistedUIReady: true, + setBrowserImportHintHidden: vi.fn(), + settingsSearchQuery: '' } const useAppStore = (selector?: (s: typeof state) => unknown): unknown => selector ? selector(state) : state diff --git a/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx b/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx index 32af12eeb10..18f5cf28e1c 100644 --- a/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx +++ b/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx @@ -3,6 +3,7 @@ import { Import } from 'lucide-react' import { toast } from 'sonner' import { emitBrowserCookieImportToast } from '@/lib/browser-cookie-import-toast' import { Button } from '@/components/ui/button' +import { BrowserCookieImportDisclosure } from '@/components/BrowserCookieImportDisclosure' import { DropdownMenu, DropdownMenuContent, @@ -100,7 +101,8 @@ export function BrowserImportHintButton({ value1: browser?.label ?? browserFamily, value2: browserProfile ? ` (${browserProfile})` : '' } - ) + ), + result.profileId ) return } @@ -120,7 +122,8 @@ export function BrowserImportHintButton({ 'auto.components.browser.pane.BrowserImportHintButton.d40d584769', 'Imported {{value0}} cookies from file.', { value0: result.summary.importedCookies } - ) + ), + result.profileId ) return } @@ -257,6 +260,7 @@ export function BrowserImportHintButton({ 'From File…' )} + diff --git a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx index 90afa5aec59..9d250d20221 100644 --- a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx +++ b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx @@ -205,7 +205,8 @@ export function BrowserToolbarMenu({ value0: result.summary.importedCookies, value1: browser?.label ?? browserFamily } - ) + ), + result.profileId ) } else { toast.error(result.reason) @@ -221,7 +222,8 @@ export function BrowserToolbarMenu({ 'auto.components.browser.pane.BrowserToolbarMenu.53bbe3dab4', 'Imported {{value0}} cookies from file.', { value0: result.summary.importedCookies } - ) + ), + result.profileId ) } else if (result.reason !== 'canceled') { toast.error(result.reason) diff --git a/src/renderer/src/components/browser-pane/browser-toolbar-menu-dropdown.tsx b/src/renderer/src/components/browser-pane/browser-toolbar-menu-dropdown.tsx index 0491f55db05..508eb059ca2 100644 --- a/src/renderer/src/components/browser-pane/browser-toolbar-menu-dropdown.tsx +++ b/src/renderer/src/components/browser-pane/browser-toolbar-menu-dropdown.tsx @@ -4,7 +4,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, @@ -14,6 +13,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { BrowserCookieImportDisclosure } from '@/components/BrowserCookieImportDisclosure' import { useAppStore } from '@/store' import { BROWSER_FAMILY_LABELS } from '../../../../shared/constants' import type { BrowserSessionProfile, BrowserViewportPresetId } from '../../../../shared/types' @@ -128,12 +128,6 @@ export function BrowserToolbarMenuDropdown({ - - {translate( - 'auto.components.browser.pane.BrowserToolbarMenu.c186b4d890', - 'Google requires signing in directly - imports skip it.' - )} - {detectedBrowsers.map((browser) => browser.profiles.length > 1 ? ( @@ -177,6 +171,7 @@ export function BrowserToolbarMenuDropdown({ 'From File…' )} + diff --git a/src/renderer/src/components/settings/BrowserProfileRow.tsx b/src/renderer/src/components/settings/BrowserProfileRow.tsx index 7ab502698ee..1240a37ed0f 100644 --- a/src/renderer/src/components/settings/BrowserProfileRow.tsx +++ b/src/renderer/src/components/settings/BrowserProfileRow.tsx @@ -7,7 +7,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuSub, @@ -15,6 +14,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger } from '../ui/dropdown-menu' +import { BrowserCookieImportDisclosure } from '../BrowserCookieImportDisclosure' import { useAppStore } from '../../store' import { BROWSER_FAMILY_LABELS } from '../../../../shared/constants' import { translate } from '@/i18n/i18n' @@ -81,7 +81,8 @@ export function BrowserProfileRow({ value1: browser?.label ?? browserFamily, value2: profile.label } - ) + ), + result.profileId ) } else { toast.error(result.reason) @@ -97,7 +98,8 @@ export function BrowserProfileRow({ 'auto.components.settings.BrowserProfileRow.b4c167764d', 'Imported {{value0}} cookies from file into {{value1}}.', { value0: result.summary.importedCookies, value1: profile.label } - ) + ), + result.profileId ) } else if (result.reason !== 'canceled') { toast.error(result.reason) @@ -172,12 +174,6 @@ export function BrowserProfileRow({ - - {translate( - 'auto.components.settings.BrowserProfileRow.654a0c2073', - 'Google requires signing in directly - imports skip it.' - )} - {detectedBrowsers.map((browser) => browser.profiles.length > 1 ? ( @@ -220,6 +216,7 @@ export function BrowserProfileRow({ void handleImportFromFile()}> {translate('auto.components.settings.BrowserProfileRow.ebb78dfd6f', 'From File…')} + {isDefault ? ( diff --git a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx index 1332883e4a1..0fcb3eb4e81 100644 --- a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { emitBrowserCookieImportToast } from '@/lib/browser-cookie-import-toast' import { cn } from '@/lib/utils' import { Button } from '../ui/button' +import { BrowserCookieImportDisclosure } from '../BrowserCookieImportDisclosure' import { DropdownMenu, DropdownMenuContent, @@ -58,7 +59,8 @@ export function BrowserUseCookieImportStep({ value1: browser?.label ?? browserFamily, value2: browserProfile ? ` (${browserProfile})` : '' } - ) + ), + result.profileId ) } else { toast.error(result.reason) @@ -74,7 +76,8 @@ export function BrowserUseCookieImportStep({ 'auto.components.settings.BrowserUsePane.8f2675c2f3', 'Imported {{value0}} cookies from file.', { value0: result.summary.importedCookies } - ) + ), + result.profileId ) } else if (result.reason !== 'canceled') { toast.error(result.reason) @@ -204,6 +207,7 @@ export function BrowserUseCookieImportStep({ void handleImportFromFile()}> {translate('auto.components.settings.BrowserUsePane.be6df68384', 'From File…')} + diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 473157aacf7..b984ac7aad8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -757,7 +757,9 @@ "toast": { "restartFallbackUnavailableNone": "None of the {{value0}} cookies could be loaded, and the restart fallback was unavailable. The previous cookies for this profile were replaced. Try the import again.", "restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.", - "googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google." + "googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.", + "googleDirectSignInAction": "Sign in to Google", + "googleDirectSignInUnavailable": "Could not open the browser profile. Open it and sign in at accounts.google.com." } } } @@ -926,6 +928,10 @@ } }, "components": { + "BrowserCookieImportDisclosure": { + "title": "Google logins aren't imported", + "description": "Sign in to Google directly in Orca." + }, "CodexRestartChip": { "a4c8e1b2f7": "Codex is still signed in as {{value0}}", "c72a5fb234": "Restart", @@ -5858,8 +5864,7 @@ "a3f8c2d1e0b4": "Imported {{value0}} cookies from {{value1}} ({{value2}}) into {{value3}}.", "b4e9d3f2a1c5": "Imported {{value0}} cookies from {{value1}} into {{value2}}.", "c5a273a809": "From {{value0}}", - "b5c0479e21": "Unmodified user agent", - "654a0c2073": "Google requires signing in directly - imports skip it." + "b5c0479e21": "Unmodified user agent" }, "BrowserUseComputerUseNotice": { "15b5e680ba": "Open Computer Use", @@ -14214,8 +14219,7 @@ "bf648471c5": "Creating…", "53bbe3dab4": "Imported {{value0}} cookies from file.", "c5f0e4d3b2a1": "Imported {{value0}} cookies from {{value1}} ({{value2}}).", - "d6a1f5e4c3b2": "Imported {{value0}} cookies from {{value1}}.", - "c186b4d890": "Google requires signing in directly - imports skip it." + "d6a1f5e4c3b2": "Imported {{value0}} cookies from {{value1}}." }, "GrabConfirmationSheet": { "314a0aaa5b": "Attach to AI", diff --git a/src/renderer/src/lib/browser-cookie-import-toast.test.ts b/src/renderer/src/lib/browser-cookie-import-toast.test.ts index 6a23e0f4a43..3fbd97d6d4b 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.test.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.test.ts @@ -1,12 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { successToastMock, warningToastMock } = vi.hoisted(() => ({ +const { successToastMock, warningToastMock, errorToastMock, getStateMock } = vi.hoisted(() => ({ successToastMock: vi.fn(), - warningToastMock: vi.fn() + warningToastMock: vi.fn(), + errorToastMock: vi.fn(), + getStateMock: vi.fn() })) vi.mock('sonner', () => ({ - toast: { success: successToastMock, warning: warningToastMock } + toast: { success: successToastMock, warning: warningToastMock, error: errorToastMock } +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } })) import type { BrowserCookieImportSummary } from '../../../shared/types' @@ -23,6 +29,13 @@ describe('emitBrowserCookieImportToast', () => { beforeEach(() => { successToastMock.mockReset() warningToastMock.mockReset() + errorToastMock.mockReset() + getStateMock.mockReset() + getStateMock.mockReturnValue({ + activeWorktreeId: 'worktree-1', + closeSettingsPage: vi.fn(), + openBrowserProfileTabInActiveWorkspace: vi.fn().mockResolvedValue(true) + }) }) it('shows the localized total-failure warning', () => { @@ -35,7 +48,8 @@ describe('emitBrowserCookieImportToast', () => { failedCookies: 3 } }, - 'Imported 3 cookies.' + 'Imported 3 cookies.', + 'profile-1' ) expect(warningToastMock).toHaveBeenCalledWith( @@ -54,7 +68,8 @@ describe('emitBrowserCookieImportToast', () => { failedCookies: 1 } }, - 'Imported 3 cookies.' + 'Imported 3 cookies.', + 'profile-1' ) expect(warningToastMock).toHaveBeenCalledWith( @@ -64,7 +79,7 @@ describe('emitBrowserCookieImportToast', () => { }) it('shows success when the import has no warning', () => { - emitBrowserCookieImportToast(summary, 'Imported 3 cookies.') + emitBrowserCookieImportToast(summary, 'Imported 3 cookies.', 'profile-1') expect(successToastMock).toHaveBeenCalledWith('Imported 3 cookies.') expect(warningToastMock).not.toHaveBeenCalled() @@ -73,13 +88,15 @@ describe('emitBrowserCookieImportToast', () => { it('shows a separate Google sign-in warning after the concise success toast', () => { emitBrowserCookieImportToast( { ...summary, importedCookies: 2, skippedCookies: 1, googleCookiesSkipped: 1 }, - 'Imported 2 cookies.' + 'Imported 2 cookies.', + 'profile-1' ) expect(successToastMock).toHaveBeenCalledWith('Imported 2 cookies.') - expect(warningToastMock).toHaveBeenCalledWith( + expect(warningToastMock.mock.calls[0][0]).toBe( 'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.' ) + expect(warningToastMock.mock.calls[0][1].action.label).toBe('Sign in to Google') expect(successToastMock.mock.invocationCallOrder[0]).toBeLessThan( warningToastMock.mock.invocationCallOrder[0] ) @@ -88,7 +105,8 @@ describe('emitBrowserCookieImportToast', () => { it('does not infer a Google warning from generic skipped cookies', () => { emitBrowserCookieImportToast( { ...summary, importedCookies: 2, skippedCookies: 1 }, - 'Imported 2 cookies.' + 'Imported 2 cookies.', + 'profile-1' ) expect(successToastMock).toHaveBeenCalledWith('Imported 2 cookies.') @@ -108,7 +126,8 @@ describe('emitBrowserCookieImportToast', () => { failedCookies: 1 } }, - 'Imported 1 cookie.' + 'Imported 1 cookie.', + 'profile-1' ) expect(successToastMock).not.toHaveBeenCalled() @@ -117,4 +136,81 @@ describe('emitBrowserCookieImportToast', () => { 'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.' ]) }) + + it('opens Google with the imported profile from the warning action', async () => { + const closeSettingsPage = vi.fn() + const openBrowserProfileTabInActiveWorkspace = vi.fn().mockResolvedValue(true) + getStateMock.mockReturnValue({ + activeWorktreeId: 'worktree-1', + closeSettingsPage, + openBrowserProfileTabInActiveWorkspace + }) + + emitBrowserCookieImportToast( + { ...summary, googleCookiesSkipped: 1 }, + 'Imported 2 cookies.', + 'profile-1' + ) + warningToastMock.mock.calls[0][1].action.onClick() + + await vi.waitFor(() => expect(closeSettingsPage).toHaveBeenCalledTimes(1)) + expect(openBrowserProfileTabInActiveWorkspace).toHaveBeenCalledWith( + 'https://accounts.google.com/', + 'profile-1' + ) + }) + + it('keeps guidance but omits the action without an active worktree', () => { + getStateMock.mockReturnValue({ activeWorktreeId: null }) + + emitBrowserCookieImportToast( + { ...summary, googleCookiesSkipped: 1 }, + 'Imported 2 cookies.', + 'profile-1' + ) + + expect(warningToastMock).toHaveBeenLastCalledWith( + 'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.' + ) + }) + + it('reports when the profile tab cannot be opened', async () => { + const openBrowserProfileTabInActiveWorkspace = vi.fn().mockResolvedValue(false) + getStateMock.mockReturnValue({ + activeWorktreeId: 'worktree-1', + closeSettingsPage: vi.fn(), + openBrowserProfileTabInActiveWorkspace + }) + + emitBrowserCookieImportToast( + { ...summary, googleCookiesSkipped: 1 }, + 'Imported 2 cookies.', + 'profile-1' + ) + warningToastMock.mock.calls[0][1].action.onClick() + + await vi.waitFor(() => expect(errorToastMock).toHaveBeenCalledTimes(1)) + expect(errorToastMock).toHaveBeenCalledWith( + 'Could not open the browser profile. Open it and sign in at accounts.google.com.' + ) + }) + + it('reports when opening the profile tab rejects', async () => { + getStateMock.mockReturnValue({ + activeWorktreeId: 'worktree-1', + closeSettingsPage: vi.fn(), + openBrowserProfileTabInActiveWorkspace: vi + .fn() + .mockRejectedValue(new Error('runtime unavailable')) + }) + + emitBrowserCookieImportToast( + { ...summary, googleCookiesSkipped: 1 }, + 'Imported 2 cookies.', + 'profile-1' + ) + warningToastMock.mock.calls[0][1].action.onClick() + + await vi.waitFor(() => expect(errorToastMock).toHaveBeenCalledTimes(1)) + }) }) diff --git a/src/renderer/src/lib/browser-cookie-import-toast.ts b/src/renderer/src/lib/browser-cookie-import-toast.ts index 6ded5ef9969..2bd4538007a 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.ts @@ -1,6 +1,7 @@ import { toast } from 'sonner' import type { BrowserCookieImportSummary } from '../../../shared/types' import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' type CookieImportWarning = NonNullable @@ -24,23 +25,63 @@ function formatCookieImportWarning(warning: CookieImportWarning): string { } } -function emitGoogleCookieImportWarning(summary: BrowserCookieImportSummary): void { +const GOOGLE_SIGN_IN_URL = 'https://accounts.google.com/' + +function googleSignInErrorMessage(): string { + return translate( + 'auto.lib.browser.cookie.import.toast.googleDirectSignInUnavailable', + 'Could not open the browser profile. Open it and sign in at accounts.google.com.' + ) +} + +function emitGoogleCookieImportWarning( + summary: BrowserCookieImportSummary, + profileId: string +): void { if (!summary.googleCookiesSkipped) { return } - toast.warning( - translate( - 'auto.lib.browser.cookie.import.toast.googleCookiesSkipped', - 'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.' - ) + const message = translate( + 'auto.lib.browser.cookie.import.toast.googleCookiesSkipped', + 'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.' ) + if (!useAppStore.getState().activeWorktreeId) { + toast.warning(message) + return + } + toast.warning(message, { + duration: 12000, + action: { + label: translate( + 'auto.lib.browser.cookie.import.toast.googleDirectSignInAction', + 'Sign in to Google' + ), + onClick: () => { + void useAppStore + .getState() + .openBrowserProfileTabInActiveWorkspace(GOOGLE_SIGN_IN_URL, profileId) + .then((opened) => { + if (!opened) { + toast.error(googleSignInErrorMessage()) + return + } + // Why: Settings would cover the newly opened browser tab. + useAppStore.getState().closeSettingsPage() + }) + .catch(() => { + toast.error(googleSignInErrorMessage()) + }) + } + } + }) } // Why: a degraded import returns ok:true with a warning, so every call site must route it to a // warning toast instead of reporting an unqualified success (#9355). export function emitBrowserCookieImportToast( summary: BrowserCookieImportSummary, - successMessage: string + successMessage: string, + profileId: string ): void { const warning = summary.warning if (warning) { @@ -48,5 +89,5 @@ export function emitBrowserCookieImportToast( } else { toast.success(successMessage) } - emitGoogleCookieImportWarning(summary) + emitGoogleCookieImportWarning(summary, profileId) } diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index c760b37a655..420813a4a36 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -164,6 +164,21 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().recordFeatureInteraction).toHaveBeenCalledWith('browser-tab-created') }) + it('opens a local sign-in tab with the imported browser profile', async () => { + const store = createTestStore() + + await expect( + store + .getState() + .openBrowserProfileTabInActiveWorkspace('https://accounts.google.com/', 'profile-1') + ).resolves.toBe(true) + + expect(store.getState().browserTabsByWorktree['wt-1']?.[0]).toMatchObject({ + url: 'https://accounts.google.com/', + sessionProfileId: 'profile-1' + }) + }) + it('clears page annotations when the browser page URL changes', () => { const store = createTestStore() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') @@ -1028,6 +1043,38 @@ describe('createBrowserSlice runtime guard', () => { ) }) + it('opens a remote sign-in tab with the imported browser profile', async () => { + const store = createTestStore() + store.setState({ + activeWorktreeId: 'wt-remote', + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-remote', + repoId: 'repo-1', + hostId: 'local', + runtimeOwnerEnvironmentId: 'env-1' + } as never + ] + } + }) + + await expect( + store + .getState() + .openBrowserProfileTabInActiveWorkspace('https://accounts.google.com/', 'profile-1') + ).resolves.toBe(true) + + expect(createWebRuntimeSessionBrowserTabMock).toHaveBeenCalledWith({ + worktreeId: 'wt-remote', + environmentId: 'env-1', + url: 'https://accounts.google.com/', + profileId: 'profile-1' + }) + expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() + }) + it('does not create a local fallback tab when remote browser creation throws', async () => { const store = createTestStore() createWebRuntimeSessionBrowserTabMock.mockRejectedValueOnce(new Error('remote down')) diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 3911830c072..cc6e399651d 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -135,6 +135,7 @@ export type BrowserSlice = { options?: CreateBrowserTabOptions ) => BrowserWorkspace openNewBrowserTabInActiveWorkspace: (groupId: string) => Promise + openBrowserProfileTabInActiveWorkspace: (url: string, profileId: string) => Promise closeBrowserTab: (tabId: string) => void shutdownWorktreeBrowsers: (worktreeId: string) => Promise reopenClosedBrowserTab: (worktreeId: string) => BrowserWorkspace | null @@ -706,6 +707,34 @@ export const createBrowserSlice: StateCreator = }) get().recordFeatureInteraction('browser-tab-created') }, + + openBrowserProfileTabInActiveWorkspace: async (url, profileId) => { + const state = get() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + return false + } + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (runtimeEnvironmentId) { + const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session') + try { + return await createWebRuntimeSessionBrowserTab({ + worktreeId, + environmentId: runtimeEnvironmentId, + url, + profileId + }) + } catch (error) { + console.warn( + '[browser] remote profile tab creation failed:', + error instanceof Error ? error.message : String(error) + ) + return false + } + } + get().createBrowserTab(worktreeId, url, { activate: true, sessionProfileId: profileId }) + return true + }, closeBrowserTab: (tabId) => { let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] set((s) => {