From 4a1ca62f954d4da1f945bfdd520c1c22c8aacc43 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 10 Sep 2026 21:58:22 -0400 Subject: [PATCH] fix: prompt unexpectedly signed-out Cloud users once per version --- .../profile-cloud-auth-status.test.ts | 106 ++++++++ .../runtime/rpc/methods/client-ui-schemas.ts | 1 + .../runtime/rpc/methods/client-ui.test.ts | 2 + .../src/app-shell/AppRootSurfaces.tsx | 10 + .../src/components/UnexpectedSignoutCard.tsx | 244 ++++++++++++++++++ .../unexpected-signout-card.test.tsx | 152 +++++++++++ .../unexpected-signout-visibility.test.ts | 150 +++++++++++ .../unexpected-signout-visibility.ts | 22 ++ src/renderer/src/i18n/locales/en.json | 15 ++ .../store/slices/ui-notice-dismissals.test.ts | 78 ++++++ .../ui/ui-slice-contract-preferences.ts | 4 + .../slices/ui/ui-slice-hydration-actions.ts | 2 + .../ui/ui-slice-hydration-sanitizers.ts | 13 + .../slices/ui/ui-slice-update-actions.ts | 12 + src/shared/constants.ts | 1 + src/shared/persisted-ui-state-types.ts | 2 + 16 files changed, 814 insertions(+) create mode 100644 src/main/orca-profiles/profile-cloud-auth-status.test.ts create mode 100644 src/renderer/src/components/UnexpectedSignoutCard.tsx create mode 100644 src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx create mode 100644 src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts create mode 100644 src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts diff --git a/src/main/orca-profiles/profile-cloud-auth-status.test.ts b/src/main/orca-profiles/profile-cloud-auth-status.test.ts new file mode 100644 index 00000000000..7a28783e9a9 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-auth-status.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ActiveOrcaProfileState } from './profile-index-store' +import type { OrcaCloudSessionReadResult } from './profile-cloud-session-store' +import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status' + +const { readSession, configuration } = vi.hoisted(() => ({ + readSession: vi.fn<() => OrcaCloudSessionReadResult>(), + configuration: { configured: true } +})) + +vi.mock('./profile-cloud-session-store', () => ({ readOrcaCloudSession: readSession })) +vi.mock('./profile-cloud-auth-config', () => ({ + getOrcaCloudAuthConfig: () => configuration, + isOrcaCloudDevAuthEnabled: () => false +})) + +function activeProfile(linked: boolean): ActiveOrcaProfileState { + const profile: ActiveOrcaProfileState['profile'] = { + id: 'profile-1', + name: 'Personal', + avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, + kind: linked ? 'cloud-linked' : 'local', + createdAt: 0, + updatedAt: 0, + lastOpenedAt: 0, + ...(linked + ? { + cloud: { + cloudProfileId: 'cloud-1', + userId: 'user-1', + email: 'a@example.com', + linkedAt: 0 + } + } + : {}) + } + return { + profile, + index: { schemaVersion: 1, activeProfileId: profile.id, profiles: [profile] }, + dataFile: '', + profileDirectory: '' + } +} + +const absentSessions: OrcaCloudSessionReadResult[] = [ + { status: 'missing', persistence: 'none' }, + { status: 'decrypt-failed', persistence: 'none', error: 'Cannot decrypt' }, + { status: 'unreadable', persistence: 'none', error: 'Permission denied' } +] + +describe('unexpected sign-out auth evidence', () => { + beforeEach(() => { + readSession.mockReset() + configuration.configured = true + }) + + it.each(absentSessions)('requires a preserved cloud link for $status credentials', (session) => { + readSession.mockReturnValue(session) + const linked = activeProfile(true) + expect(getOrcaProfileAuthStatusFromProfile(linked, '')).toMatchObject({ + state: 'reconnect-required', + cloud: linked.profile.cloud, + persistence: 'none', + credentialError: 'error' in session ? session.error : undefined + }) + readSession.mockClear() + const signedOut = getOrcaProfileAuthStatusFromProfile(activeProfile(false), '') + expect(signedOut.state).toBe('local') + expect(signedOut.cloud).toBeUndefined() + expect(readSession).not.toHaveBeenCalled() + }) + + it.each(absentSessions)( + 'keeps unconfigured linked profiles out of reconnect for $status', + (session) => { + configuration.configured = false + readSession.mockReturnValue(session) + expect(getOrcaProfileAuthStatusFromProfile(activeProfile(true), '').state).toBe( + 'unconfigured' + ) + expect(getOrcaProfileAuthStatusFromProfile(activeProfile(false), '').state).toBe( + 'unconfigured' + ) + } + ) + + it('treats a live memory-only session as connected, then reconnects after its loss', () => { + readSession.mockReturnValue({ + status: 'found', + persistence: 'memory-only', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + capabilities: { flags: {}, refreshedAt: 0 } + } + }) + const linked = activeProfile(true) + expect(getOrcaProfileAuthStatusFromProfile(linked, '')).toMatchObject({ + state: 'connected', + persistence: 'memory-only' + }) + readSession.mockReturnValue({ status: 'missing', persistence: 'none' }) + expect(getOrcaProfileAuthStatusFromProfile(linked, '').state).toBe('reconnect-required') + }) +}) diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 943d0081fdf..32e62e105d7 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -171,6 +171,7 @@ const UiUpdateFields = z usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), statusBarUsageMode: z.enum(['verbose', 'compact']).optional(), dismissedUpdateVersion: NullableString.optional(), + dismissedUnexpectedSignoutVersion: NullableString.optional(), lastUpdateCheckAt: z.number().finite().nullable().optional(), pendingUpdateNudgeId: NullableString.optional(), dismissedUpdateNudgeId: NullableString.optional(), diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 39048611155..3a26b1c615d 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -607,6 +607,8 @@ describe('client UI RPC methods', () => { ], ['taskResumeState.jiraPreset', { taskResumeState: { jiraPreset: 'assigned' } }], ['taskResumeState.jiraQuery', { taskResumeState: { jiraQuery: 'ENG' } }], + ['dismissedUnexpectedSignoutVersion', { dismissedUnexpectedSignoutVersion: '1.2.3' }], + ['dismissedUnexpectedSignoutVersion null', { dismissedUnexpectedSignoutVersion: null }], ['activeView', { activeView: 'tasks' }], ['showDotfilesByWorktree', { showDotfilesByWorktree: { 'repo::/worktree': true } }], ['setupGuideSidebarDismissed', { setupGuideSidebarDismissed: true }], diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 202c99df4d2..67bae4e3e43 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -58,6 +58,11 @@ const SshPassphraseDialog = lazy(() => const UpdateCard = lazy(() => import('../components/UpdateCard').then((module) => ({ default: module.UpdateCard })) ) +const UnexpectedSignoutCard = lazy(() => + import('../components/UnexpectedSignoutCard').then((module) => ({ + default: module.UnexpectedSignoutCard + })) +) const RemoteServerUpdateDialog = lazy( () => import('../components/settings/RemoteServerUpdateDialog') ) @@ -280,6 +285,11 @@ export function AppRootSurfaces(props: { ) : null} + + + + + diff --git a/src/renderer/src/components/UnexpectedSignoutCard.tsx b/src/renderer/src/components/UnexpectedSignoutCard.tsx new file mode 100644 index 00000000000..f2790cc682b --- /dev/null +++ b/src/renderer/src/components/UnexpectedSignoutCard.tsx @@ -0,0 +1,244 @@ +import { useEffect, useRef, useState } from 'react' +import { BookOpen, ChevronDown, CircleUserRound, Files, Smartphone, X } from 'lucide-react' +import { useAppStore } from '../store' +import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { Button } from './ui/button' +import { Card } from './ui/card' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible' +import { shouldShowUnexpectedSignoutCard } from './unexpected-signout/unexpected-signout-visibility' + +function readPreviewFlag(): boolean { + if (!import.meta.env.DEV) { + return false + } + try { + if (new URLSearchParams(window.location.search).get('showSignoutCard') === '1') { + return true + } + return window.localStorage.getItem('orca-debug-show-signout-card') === '1' + } catch { + return false + } +} + +function FeatureRow({ + icon: Icon, + title, + description +}: { + icon: typeof Files + title: string + description: string +}): React.JSX.Element { + return ( +
+ +
+

{title}

+

{description}

+
+
+ ) +} + +export function UnexpectedSignoutCard(): React.JSX.Element | null { + const authStatus = useAppStore((s) => s.orcaProfileAuthStatus) + const persistedUIReady = useAppStore((s) => s.persistedUIReady) + const persistedDismissedVersion = useAppStore((s) => s.dismissedUnexpectedSignoutVersion) + const dismissedVersions = useAppStore((s) => s.unexpectedSignoutDismissedVersions) + const dismissForVersion = useAppStore((s) => s.dismissUnexpectedSignoutCard) + const connecting = useAppStore((s) => s.orcaProfileConnecting) + const connect = useAppStore((s) => s.connectCurrentOrcaProfile) + const updateStatus = useAppStore((s) => s.updateStatus) + const [appVersion, setAppVersion] = useState(null) + const [expanded, setExpanded] = useState(false) + const [preview] = useState(readPreviewFlag) + const [previewDismissed, setPreviewDismissed] = useState(false) + const reconnectingProfile = useRef(null) + + useOrcaProfileAuthStatusRefresh() + + useEffect(() => { + let cancelled = false + void window.api.updater + .getVersion() + .then((version) => { + if (!cancelled) { + setAppVersion(version) + } + }) + .catch(() => { + if (!cancelled) { + setAppVersion(null) + } + }) + return () => { + cancelled = true + } + }, []) + + const dismissedVersion = + appVersion && dismissedVersions.includes(appVersion) ? appVersion : persistedDismissedVersion + const eligible = shouldShowUnexpectedSignoutCard({ + authStatus, + persistedUIReady, + appVersion, + dismissedVersion + }) + + const visible = preview ? persistedUIReady && !previewDismissed : eligible + + // Observe recovery independently of visibility and asynchronous version/hydration reads. + useEffect(() => { + if (preview) { + return + } + if (authStatus?.state === 'reconnect-required' && authStatus.configured && authStatus.cloud) { + reconnectingProfile.current = authStatus.activeProfileId + } else if (authStatus?.state === 'connected') { + if ( + reconnectingProfile.current === authStatus.activeProfileId && + persistedUIReady && + appVersion + ) { + reconnectingProfile.current = null + if (dismissedVersion !== appVersion) { + dismissForVersion(appVersion) + } + } + } else { + reconnectingProfile.current = null + } + }, [preview, authStatus, persistedUIReady, appVersion, dismissedVersion, dismissForVersion]) + + if (!visible) { + return null + } + + const email = authStatus?.cloud?.email?.trim() || null + const canConnect = authStatus?.configured === true + const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available' + + const handleDismiss = (): void => { + if (preview) { + setPreviewDismissed(true) + } else if (appVersion) { + dismissForVersion(appVersion) + } + } + + return ( +
+ +
+
+
+ +

+ {translate( + 'auto.components.UnexpectedSignoutCard.9f2c1a4b7d', + "You've been signed out" + )} +

+
+ +
+ +

+ {email + ? translate( + 'auto.components.UnexpectedSignoutCard.7b4d9e1f2a', + 'Sign in again as {{value0}} to restore Artifact sharing, Orca Relay, and skill sharing.', + { value0: email } + ) + : translate( + 'auto.components.UnexpectedSignoutCard.5a1c8d3e6f', + 'Sign in again to restore Artifact sharing, Orca Relay, and skill sharing.' + )} +

+ + + + + + + + + + + + +
+ +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx b/src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx new file mode 100644 index 00000000000..406f4ad9aea --- /dev/null +++ b/src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '../../store' +import { getDefaultUIState } from '../../../../shared/constants' +import { UnexpectedSignoutCard } from '../UnexpectedSignoutCard' +import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' + +const status: OrcaProfileAuthStatus = { + activeProfileId: 'profile-1', + configured: true, + state: 'reconnect-required', + persistence: 'none', + cloud: { + cloudProfileId: 'cloud-1', + userId: 'user-1', + email: 'user@example.com', + displayName: 'User', + linkedAt: 0 + } +} +const persist = vi.fn().mockResolvedValue(undefined) + +beforeEach(() => { + vi.stubEnv('DEV', false) + persist.mockClear() + window.localStorage.clear() + window.history.replaceState({}, '', '/') + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ui: { set: persist }, + updater: { getVersion: vi.fn().mockResolvedValue('1.4.197') } + } + }) + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + orcaProfileAuthStatus: status, + persistedUIReady: true, + fetchOrcaProfileAuthStatus: vi.fn().mockResolvedValue(undefined) + }) +}) +afterEach(() => { + cleanup() + vi.unstubAllEnvs() +}) + +async function showCard(): Promise { + render() + await screen.findByRole('complementary') +} + +describe('unexpected signout lifecycle', () => { + it('stamps successful sign-in and never re-arms in the same version', async () => { + await showCard() + act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } })) + await waitFor(() => + expect(persist).toHaveBeenCalledWith({ dismissedUnexpectedSignoutVersion: '1.4.197' }) + ) + act(() => useAppStore.setState({ orcaProfileAuthStatus: status })) + expect(screen.queryByRole('complementary')).toBeNull() + cleanup() + render() + await act(async () => {}) + expect(screen.queryByRole('complementary')).toBeNull() + expect(persist).toHaveBeenCalledTimes(1) + }) + + it('does not treat a cached connected status as a successful re-sign-in', async () => { + useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } }) + render() + await act(async () => {}) + act(() => useAppStore.setState({ orcaProfileAuthStatus: status })) + expect(screen.queryByRole('complementary')).not.toBeNull() + expect(persist).not.toHaveBeenCalled() + }) + + it('waits for hydration before stamping an already recovered session', async () => { + useAppStore.setState({ persistedUIReady: false }) + render() + await act(async () => {}) + act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } })) + expect(persist).not.toHaveBeenCalled() + act(() => useAppStore.setState({ persistedUIReady: true })) + await waitFor(() => expect(persist).toHaveBeenCalledTimes(1)) + act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } })) + expect(persist).toHaveBeenCalledTimes(1) + }) + + it('keeps a failed sign-in available without stamping dismissal', async () => { + useAppStore.setState({ connectCurrentOrcaProfile: vi.fn().mockResolvedValue(undefined) }) + await showCard() + fireEvent.click(screen.getByRole('button', { name: 'Sign in to Orca' })) + await act(async () => {}) + expect(screen.queryByRole('complementary')).not.toBeNull() + expect(persist).not.toHaveBeenCalled() + }) + + it('retains a reopened dismissal through stale UI sync and a renderer remount', async () => { + useAppStore.getState().hydratePersistedUI( + { + ...getDefaultUIState(), + dismissedUnexpectedSignoutVersion: '1.4.197' + }, + 'startup' + ) + render() + await act(async () => {}) + act(() => useAppStore.getState().hydratePersistedUI(getDefaultUIState())) + expect(screen.queryByRole('complementary')).toBeNull() + cleanup() + render() + await act(async () => {}) + expect(screen.queryByRole('complementary')).toBeNull() + expect(persist).not.toHaveBeenCalledWith( + expect.objectContaining({ dismissedUnexpectedSignoutVersion: expect.anything() }) + ) + }) + + it('persists X dismissal and stays hidden after remount', async () => { + await showCard() + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(persist).toHaveBeenCalledWith({ dismissedUnexpectedSignoutVersion: '1.4.197' }) + cleanup() + render() + await act(async () => {}) + expect(screen.queryByRole('complementary')).toBeNull() + }) + + it.each(['storage', 'query'])('ignores the %s preview flag in production', async (source) => { + if (source === 'storage') { + window.localStorage.setItem('orca-debug-show-signout-card', '1') + } else { + window.history.replaceState({}, '', '/?showSignoutCard=1') + } + useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'local', cloud: undefined } }) + render() + await act(async () => {}) + expect(screen.queryByRole('complementary')).toBeNull() + expect(persist).not.toHaveBeenCalled() + }) + + it('dismisses a development preview without writing real dismissal state', async () => { + vi.stubEnv('DEV', true) + window.localStorage.setItem('orca-debug-show-signout-card', '1') + useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'local', cloud: undefined } }) + await showCard() + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(screen.queryByRole('complementary')).toBeNull() + expect(persist).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts new file mode 100644 index 00000000000..c1b8459204f --- /dev/null +++ b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' +import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' +import { shouldShowUnexpectedSignoutCard } from './unexpected-signout-visibility' + +function reconnectRequired(): OrcaProfileAuthStatus { + return { + activeProfileId: 'profile-1', + configured: true, + state: 'reconnect-required', + persistence: 'none', + cloud: { + cloudProfileId: 'cloud-1', + userId: 'user-1', + email: 'user@example.com', + displayName: 'User', + linkedAt: 0 + } + } +} + +describe('shouldShowUnexpectedSignoutCard', () => { + it.each([ + { name: 'unknown auth', auth: null, visible: false }, + { + name: 'missing cloud link', + auth: { ...reconnectRequired(), cloud: undefined }, + visible: false + }, + { + name: 'unconfigured linked profile', + auth: { ...reconnectRequired(), configured: false, state: 'unconfigured' }, + visible: false + }, + { + name: 'unconfigured reconnect status', + auth: { ...reconnectRequired(), configured: false }, + visible: false + }, + { + name: 'local with stale cloud metadata', + auth: { ...reconnectRequired(), state: 'local' }, + visible: false + }, + { + name: 'live memory-only session', + auth: { ...reconnectRequired(), state: 'connected', persistence: 'memory-only' }, + visible: false + }, + { + name: 'decrypt failure with retained link', + auth: { ...reconnectRequired(), credentialError: 'Cannot decrypt' }, + visible: true + }, + { + name: 'unreadable session with retained link', + auth: { ...reconnectRequired(), credentialError: 'Permission denied' }, + visible: true + } + ] satisfies { name: string; auth: OrcaProfileAuthStatus | null; visible: boolean }[])( + '$name', + ({ auth, visible }) => { + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: auth, + persistedUIReady: true, + appVersion: '1.4.197', + dismissedVersion: null + }) + ).toBe(visible) + } + ) + + it('shows when linked but the session is gone', () => { + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: reconnectRequired(), + persistedUIReady: true, + appVersion: '1.4.197', + dismissedVersion: null + }) + ).toBe(true) + }) + + it('hides after an explicit sign-out (link removed)', () => { + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: { + activeProfileId: 'profile-1', + configured: true, + state: 'local', + persistence: 'none' + }, + persistedUIReady: true, + appVersion: '1.4.197', + dismissedVersion: null + }) + ).toBe(false) + }) + + it('hides when connected', () => { + const status = reconnectRequired() + status.state = 'connected' + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: status, + persistedUIReady: true, + appVersion: '1.4.197', + dismissedVersion: null + }) + ).toBe(false) + }) + + it('shows only once per app version', () => { + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: reconnectRequired(), + persistedUIReady: true, + appVersion: '1.4.197', + dismissedVersion: '1.4.197' + }) + ).toBe(false) + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: reconnectRequired(), + persistedUIReady: true, + appVersion: '1.4.198', + dismissedVersion: '1.4.197' + }) + ).toBe(true) + }) + + it('waits for hydration and version', () => { + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: reconnectRequired(), + persistedUIReady: false, + appVersion: '1.4.197', + dismissedVersion: null + }) + ).toBe(false) + expect( + shouldShowUnexpectedSignoutCard({ + authStatus: reconnectRequired(), + persistedUIReady: true, + appVersion: null, + dismissedVersion: null + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts new file mode 100644 index 00000000000..a9a47b2d98b --- /dev/null +++ b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts @@ -0,0 +1,22 @@ +import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' + +export type UnexpectedSignoutGate = { + authStatus: OrcaProfileAuthStatus | null + persistedUIReady: boolean + appVersion: string | null + dismissedVersion: string | null +} + +export function shouldShowUnexpectedSignoutCard(gate: UnexpectedSignoutGate): boolean { + if (!gate.persistedUIReady || gate.appVersion === null) { + return false + } + if (gate.dismissedVersion === gate.appVersion) { + return false + } + return ( + gate.authStatus?.configured === true && + gate.authStatus.state === 'reconnect-required' && + gate.authStatus.cloud != null + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6bf96010195..3fe33eb2197 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16885,6 +16885,21 @@ "HostedReviewUnlinkMenuItem": { "label": "Unlink {{value0}} from workspace", "description": "Orca will hide {{value0}} {{value1}} details for this workspace. The {{value0}} and branch on {{value2}} won’t be changed." + }, + "UnexpectedSignoutCard": { + "9f2c1a4b7d": "You've been signed out", + "3e8f5c2a91": "Dismiss", + "7b4d9e1f2a": "Your Orca Cloud session for {{value0}} ended. Sign in again to restore cloud features.", + "5a1c8d3e6f": "Your Orca Cloud session ended. Sign in again to restore cloud features.", + "1f6b2c9d4e": "What you get back", + "8d2e4f7a1b": "Artifact sharing", + "2c9a5b6e8d": "Publish HTML and Markdown files and manage every shared link from Orca.", + "6e3f1a9c5b": "Orca Relay", + "4b7d2e8f1a": "Connect Orca Mobile to this desktop across cellular or any Wi-Fi.", + "9a4c6b2d7e": "Skill sharing", + "3d8e5f1b9c": "Share skills behind an unlisted link and install them on any machine you use.", + "7e1a9c4d2f": "Signing in…", + "c5b3e8a17d": "Sign in to Orca" } }, "i18n": { diff --git a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts index f17c77c78e3..180f96e427a 100644 --- a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts +++ b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts @@ -280,3 +280,81 @@ describe('createUISlice clearOsc52ClipboardDefaultOnNotice', () => { expect(setUI).toHaveBeenCalledWith({ osc52ClipboardDefaultOnNoticePending: false }) }) }) + +describe('unexpected sign-out dismissal persistence', () => { + it('persists a dismissal once even when effects repeat', () => { + const setUI = vi.fn(() => Promise.resolve()) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + store.getState().dismissUnexpectedSignoutCard('1.2.3') + store.getState().dismissUnexpectedSignoutCard('1.2.3') + + expect(store.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3') + expect(setUI).toHaveBeenCalledExactlyOnceWith({ dismissedUnexpectedSignoutVersion: '1.2.3' }) + }) + + it.each([undefined, null, '1.2.2'])( + 'does not re-arm a local dismissal from stale hydration (%s)', + (dismissedUnexpectedSignoutVersion) => { + vi.stubGlobal('window', { api: { ui: { set: vi.fn(() => Promise.resolve()) } } }) + const store = createUIStore() + store.getState().dismissUnexpectedSignoutCard('1.2.3') + + store.getState().hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion })) + + expect(store.getState().unexpectedSignoutDismissedVersions).toContain('1.2.3') + } + ) + + it('defaults legacy profiles and restores dismissal on reopen', () => { + const store = createUIStore() + expect(store.getState().dismissedUnexpectedSignoutVersion).toBeNull() + store + .getState() + .hydratePersistedUI( + makePersistedUI({ dismissedUnexpectedSignoutVersion: undefined }), + 'startup' + ) + expect(store.getState().dismissedUnexpectedSignoutVersion).toBeNull() + const reopened = createUIStore() + reopened + .getState() + .hydratePersistedUI( + makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }), + 'startup' + ) + expect(reopened.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3') + }) + + it('accepts another window dismissal after hydrating an older version', () => { + const store = createUIStore() + store + .getState() + .hydratePersistedUI( + makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.2' }), + 'startup' + ) + store + .getState() + .hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' })) + expect(store.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3') + }) +}) + +describe('unexpected sign-out hydrated dismissal history', () => { + it.each([undefined, null, '1.2.2', '1.2.4'])( + 'retains an observed dismissal after a different version sync (%s)', + (dismissedUnexpectedSignoutVersion) => { + const store = createUIStore() + store + .getState() + .hydratePersistedUI( + makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }), + 'startup' + ) + store.getState().hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion })) + expect(store.getState().unexpectedSignoutDismissedVersions).toContain('1.2.3') + } + ) +}) diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts index dff5f50c7e3..d913f998d2c 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -175,6 +175,10 @@ export type UISlicePersistence = { dismissedUpdateVersion: string | null dismissUpdate: (versionOverride?: string) => void clearDismissedUpdateVersion: () => void + /** App version that dismissed the unexpected-sign-out card; null = never dismissed. */ + dismissedUnexpectedSignoutVersion: string | null + unexpectedSignoutDismissedVersions: string[] + dismissUnexpectedSignoutCard: (version: string) => void /** Dev-only channel override; null follows the running build's own channel. */ releaseChannelOverride: ReleaseChannel | null setReleaseChannelOverride: (channel: ReleaseChannel | null) => void diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index a5daf8a500d..6ca8d702bd3 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -52,6 +52,7 @@ import { } from '../persisted-ui-write-baseline' import { hydrateTrustedOrcaHooks, + hydrateUnexpectedSignoutDismissal, normalizeHydratedVisibleWorkspaceHostIds, preserveStringArrayIdentity, sanitizeHydratedActiveView, @@ -226,6 +227,7 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par return DEFAULT_PET_ID })(), dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null, + ...hydrateUnexpectedSignoutDismissal(s, ui.dismissedUnexpectedSignoutVersion), // Why: a persisted value from a build that knew a different channel set // would otherwise survive as-is; activeChannel only falls back on null, // so an unknown string reaches listBuilds and the segmented control. diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts index bfed25a75cc..9737a575f06 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts @@ -238,3 +238,16 @@ export function migrateStatusBarItems(items: readonly string[] | undefined): Sta } return out as StatusBarItem[] } + +export function hydrateUnexpectedSignoutDismissal( + state: Pick, + version: string | null | undefined +): Pick { + const observed = state.unexpectedSignoutDismissedVersions + return { + dismissedUnexpectedSignoutVersion: version ?? null, + // A later sync must never undo any dismissal observed in this session. + unexpectedSignoutDismissedVersions: + typeof version === 'string' && !observed.includes(version) ? [...observed, version] : observed + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts index e4beadf4c6c..942ff9610a5 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts @@ -43,6 +43,18 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial updateChangelog: null, updateUserInitiatedCycle: false, dismissedUpdateVersion: null, + dismissedUnexpectedSignoutVersion: null, + unexpectedSignoutDismissedVersions: [], + dismissUnexpectedSignoutCard: (version) => { + if (get().unexpectedSignoutDismissedVersions.includes(version)) { + return + } + set({ + dismissedUnexpectedSignoutVersion: version, + unexpectedSignoutDismissedVersions: [...get().unexpectedSignoutDismissedVersions, version] + }) + void window.api.ui.set({ dismissedUnexpectedSignoutVersion: version }).catch(console.error) + }, clearDismissedUpdateVersion: () => { set({ dismissedUpdateVersion: null }) }, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7a06e11dba3..6840d92adc9 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -296,6 +296,7 @@ export function getDefaultUIState(): PersistedUIState { usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE, dismissedUpdateVersion: null, + dismissedUnexpectedSignoutVersion: null, lastUpdateCheckAt: null, trustedOrcaHooks: {}, setupScriptPromptDismissedRepoIds: [], diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 943813d7255..2a3eb411a0e 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -125,6 +125,8 @@ export type PersistedUIState = { /** Client-side footer presentation; verbose preserves the pre-roster all-window default. */ statusBarUsageMode?: StatusBarUsageMode dismissedUpdateVersion: string | null + /** App version that last dismissed the unexpected-sign-out card; null = never. Re-arms on each new version while still signed out. */ + dismissedUnexpectedSignoutVersion?: string | null lastUpdateCheckAt: number | null /** Dev-only update channel override; absent means the build's own channel. */ releaseChannelOverride?: ReleaseChannel | null