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