fix: prompt unexpectedly signed-out Cloud users once per version

This commit is contained in:
Jinwoo-H
2026-09-10 21:58:22 -04:00
parent 027acb4efa
commit 4a1ca62f95
16 changed files with 814 additions and 0 deletions
@@ -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')
})
})
@@ -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(),
@@ -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 }],
@@ -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: {
</OverlayBoundary>
</Suspense>
) : null}
<Suspense fallback={null}>
<OverlayBoundary boundaryId="overlay.unexpected-signout" resetKey={activeView}>
<UnexpectedSignoutCard />
</OverlayBoundary>
</Suspense>
<OverlayBoundary boundaryId="overlay.star-nag" resetKey={activeView}>
<StarNagCard />
</OverlayBoundary>
@@ -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 (
<div className="flex items-start gap-2.5">
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="space-y-0.5">
<p className="text-xs font-medium">{title}</p>
<p className="text-xs leading-5 text-muted-foreground">{description}</p>
</div>
</div>
)
}
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<string | null>(null)
const [expanded, setExpanded] = useState(false)
const [preview] = useState(readPreviewFlag)
const [previewDismissed, setPreviewDismissed] = useState(false)
const reconnectingProfile = useRef<string | null>(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 (
<div
className={`fixed right-4 z-40 w-[360px] max-w-[calc(100vw-32px)] max-[480px]:left-4 max-[480px]:right-4 max-[480px]:w-auto ${
updateCardVisible ? 'bottom-[220px]' : 'bottom-10'
}`}
>
<Card
className="py-0 gap-0 shadow-floating"
role="complementary"
aria-live="polite"
aria-labelledby="unexpected-signout-heading"
>
<div className="flex flex-col gap-2.5 p-3.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<CircleUserRound className="size-4 text-muted-foreground" />
<h3 id="unexpected-signout-heading" className="text-sm font-semibold">
{translate(
'auto.components.UnexpectedSignoutCard.9f2c1a4b7d',
"You've been signed out"
)}
</h3>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={handleDismiss}
aria-label={translate('auto.components.UnexpectedSignoutCard.3e8f5c2a91', 'Dismiss')}
>
<X className="size-3.5" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{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.'
)}
</p>
<Collapsible open={expanded} onOpenChange={setExpanded}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-fit gap-1 px-1 text-xs text-muted-foreground"
aria-expanded={expanded}
>
{translate('auto.components.UnexpectedSignoutCard.1f6b2c9d4e', 'What you get back')}
<ChevronDown
className={cn('size-3.5 transition-transform', expanded && 'rotate-180')}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2.5">
<FeatureRow
icon={Files}
title={translate(
'auto.components.UnexpectedSignoutCard.8d2e4f7a1b',
'Artifact sharing'
)}
description={translate(
'auto.components.UnexpectedSignoutCard.2c9a5b6e8d',
'Publish HTML and Markdown files and manage every shared link from Orca.'
)}
/>
<FeatureRow
icon={Smartphone}
title={translate('auto.components.UnexpectedSignoutCard.6e3f1a9c5b', 'Orca Relay')}
description={translate(
'auto.components.UnexpectedSignoutCard.4b7d2e8f1a',
'Connect Orca Mobile to this desktop across cellular or any Wi-Fi.'
)}
/>
<FeatureRow
icon={BookOpen}
title={translate(
'auto.components.UnexpectedSignoutCard.9a4c6b2d7e',
'Skill sharing'
)}
description={translate(
'auto.components.UnexpectedSignoutCard.3d8e5f1b9c',
'Share skills behind an unlisted link and install them on any machine you use.'
)}
/>
</CollapsibleContent>
</Collapsible>
<div className="mt-0.5 flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1"
disabled={!canConnect || connecting}
onClick={() => void connect()}
>
{connecting
? translate('auto.components.UnexpectedSignoutCard.7e1a9c4d2f', 'Signing in…')
: translate('auto.components.UnexpectedSignoutCard.c5b3e8a17d', 'Sign in to Orca')}
</Button>
</div>
</div>
</Card>
</div>
)
}
@@ -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<void> {
render(<UnexpectedSignoutCard />)
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(<UnexpectedSignoutCard />)
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(<UnexpectedSignoutCard />)
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(<UnexpectedSignoutCard />)
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(<UnexpectedSignoutCard />)
await act(async () => {})
act(() => useAppStore.getState().hydratePersistedUI(getDefaultUIState()))
expect(screen.queryByRole('complementary')).toBeNull()
cleanup()
render(<UnexpectedSignoutCard />)
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(<UnexpectedSignoutCard />)
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(<UnexpectedSignoutCard />)
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()
})
})
@@ -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)
})
})
@@ -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
)
}
+15
View File
@@ -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": {
@@ -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')
}
)
})
@@ -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
@@ -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.
@@ -238,3 +238,16 @@ export function migrateStatusBarItems(items: readonly string[] | undefined): Sta
}
return out as StatusBarItem[]
}
export function hydrateUnexpectedSignoutDismissal(
state: Pick<UISlice, 'unexpectedSignoutDismissedVersions'>,
version: string | null | undefined
): Pick<UISlice, 'dismissedUnexpectedSignoutVersion' | 'unexpectedSignoutDismissedVersions'> {
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
}
}
@@ -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 })
},
+1
View File
@@ -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: [],
+2
View File
@@ -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