diff --git a/apps/desktop/electron/chat-onboarding-window.ts b/apps/desktop/electron/chat-onboarding-window.ts index f9d33774cb..25757d2a07 100644 --- a/apps/desktop/electron/chat-onboarding-window.ts +++ b/apps/desktop/electron/chat-onboarding-window.ts @@ -1,6 +1,6 @@ import { type BrowserWindow, ipcMain, screen } from 'electron' -import { type GrowRequest, growWindowBounds } from './window-growth' +import { centeredBounds, type GrowRequest, growWindowBounds } from './window-growth' interface ChatOnboardingWindowOptions { enabled: boolean @@ -40,14 +40,6 @@ export function registerChatOnboardingWindow({ enabled, mainWindow }: ChatOnboar const width = Math.min(600, area.width) const height = Math.min(640, area.height) - win.setBounds( - { - height, - width, - x: Math.round(area.x + (area.width - width) / 2), - y: Math.round(area.y + (area.height - height) / 2) - }, - true - ) + win.setBounds(centeredBounds(area, width, height), true) }) } diff --git a/apps/desktop/electron/window-growth.ts b/apps/desktop/electron/window-growth.ts index 816adab1c5..40850274d3 100644 --- a/apps/desktop/electron/window-growth.ts +++ b/apps/desktop/electron/window-growth.ts @@ -6,6 +6,8 @@ * asserted rather than eyeballed on a first run. */ +import type { Rectangle } from 'electron' + export interface GrowRequest { bottom?: number left?: number @@ -65,6 +67,10 @@ export function growWindowBounds( Math.round(workArea.height * MAX_WORK_AREA) ) + return centeredBounds(workArea, width, height) +} + +export function centeredBounds(workArea: Rectangle, width: number, height: number): Rectangle { return { height, width, diff --git a/apps/desktop/src/app/contrib/handoff-receipt.test.ts b/apps/desktop/src/app/contrib/handoff-receipt.test.ts new file mode 100644 index 0000000000..ac01c18900 --- /dev/null +++ b/apps/desktop/src/app/contrib/handoff-receipt.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest' + +import { readKey, writeKey } from '@/lib/storage' + +import { handoffReceiptKey, quarantineHandoffReceipt, readHandoffReceipt } from './handoff-receipt' + +it('preserves an unreadable receipt and removes it from the retry lookup', () => { + const key = handoffReceiptKey('receipt-recovery', 'guide') + const corrupt = '{unreadable receipt' + writeKey(key, corrupt) + + expect(() => readHandoffReceipt(key)).toThrow() + + quarantineHandoffReceipt(key) + + expect(readKey(`${key}.unreadable`)).toBe(corrupt) + expect(readHandoffReceipt(key)).toBeNull() +}) diff --git a/apps/desktop/src/app/contrib/handoff-receipt.ts b/apps/desktop/src/app/contrib/handoff-receipt.ts index 3ef068f020..876e7ae041 100644 --- a/apps/desktop/src/app/contrib/handoff-receipt.ts +++ b/apps/desktop/src/app/contrib/handoff-receipt.ts @@ -1,4 +1,4 @@ -import { readKey, writeJson } from '@/lib/storage' +import { readKey, writeJson, writeKey } from '@/lib/storage' import type { HandoffReceipt } from './handoff-leg' @@ -59,6 +59,11 @@ export function readHandoffReceipt(key: string): HandoffReceipt | null { return value } +export function quarantineHandoffReceipt(key: string): void { + writeKey(`${key}.unreadable`, readKey(key)) + writeKey(key, null) +} + export function saveHandoffReceipt(key: string, receipt: HandoffReceipt): void { unsavedReceipts.set(key, receipt) writeJson(key, receipt) diff --git a/apps/desktop/src/app/contrib/onboarding-handoff.ts b/apps/desktop/src/app/contrib/onboarding-handoff.ts index 7a1a2b1454..d78f9e1fda 100644 --- a/apps/desktop/src/app/contrib/onboarding-handoff.ts +++ b/apps/desktop/src/app/contrib/onboarding-handoff.ts @@ -14,6 +14,7 @@ import { buildHandoffCompleteNote, firstTaskTitle, guideSourceConnectionId, + readGuideHandoffReceipt, retrySetupHandoff, SETUP_PROFILE } from '@/components/onboarding-chat/setup-profile' @@ -40,7 +41,7 @@ import { import { patchSessionTile } from '@/store/session-states' import { BUILD_PROFILE, type HandoffDeps, type HandoffReceipt, paintHandoffBrief, startHandoff } from './handoff-leg' -import { handoffReceiptKey, readHandoffReceipt, saveHandoffReceipt } from './handoff-receipt' +import { saveHandoffReceipt } from './handoff-receipt' import type { AmbientGatewayRequest } from './session-rpc-dispatcher' export interface OnboardingHandoffOptions extends Pick< @@ -82,7 +83,7 @@ export function useOnboardingHandoff({ const connectionId = guideSourceConnectionId(selectedStoredId) try { - const saved = readHandoffReceipt(handoffReceiptKey(connectionId, selectedStoredId)) + const { receipt: saved } = readGuideHandoffReceipt(selectedStoredId) if (!saved) { return @@ -146,8 +147,8 @@ export function useOnboardingHandoff({ $setupSession.set(setupSession) // Resume only has the guide's stored id, so its source must also key the saved receipt. - const receiptKey = handoffReceiptKey(guideSourceConnectionId(setupSession.storedId), setupSession.storedId) - receipt = readHandoffReceipt(receiptKey) + const { key: receiptKey, receipt: saved } = readGuideHandoffReceipt(setupSession.storedId) + receipt = saved const owner: HandoffReceipt['owner'] = receipt?.owner ?? { connectionId, profile: BUILD_PROFILE } // Save facts before session.create freezes the new agent's memory. // A retry never re-creates the session or copies the guide's memory. diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index c45a377621..7b3f7f3b7d 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -463,14 +463,17 @@ export function useStatusbarItems({ // The model id is the quiet part; the sign-in is the action, so it is // solid and set off by a gap instead of touching the label. detail: ( - <> + {freeTier?.model ?? FREE_TIER_MODEL} - + {/* The class merger drops Badge's own leading-none behind the size's + font-size class, so the badge grows to the inherited 1.5 leading and + overhangs an 11px label. Restating it here keeps it 11.6px tall. */} + {freeTierCopy.signIn} - + ), // Shown while a free-tier identity exists and the tier is on: it names the // identity that carries the connectors (and inference when nothing else diff --git a/apps/desktop/src/components/onboarding-chat/cards/build.tsx b/apps/desktop/src/components/onboarding-chat/cards/build.tsx index d01df113af..1eb20232ad 100644 --- a/apps/desktop/src/components/onboarding-chat/cards/build.tsx +++ b/apps/desktop/src/components/onboarding-chat/cards/build.tsx @@ -11,7 +11,7 @@ import { useEffect, useMemo, useState } from 'react' import { requestComposerSubmit } from '@/app/chat/composer/focus' import { useSessionView } from '@/app/chat/session-view' -import { handoffReceiptKey, readHandoffReceipt } from '@/app/contrib/handoff-receipt' +import { quarantineHandoffReceipt } from '@/app/contrib/handoff-receipt' import { resolveSessionOwner } from '@/app/session/hooks/use-session-actions/utils' import type { CardProps } from '@/components/onboarding-chat/cards/frame' import { Chip } from '@/components/onboarding-chat/chip' @@ -19,8 +19,9 @@ import { $handoffError, $setupHandoff, firstTaskTitle, - guideSourceConnectionId, + guideHandoffReceiptKey, parseHandoffPlan, + readGuideHandoffReceipt, requestSetupHandoff, retrySetupHandoff, SETUP_PROFILE @@ -111,16 +112,19 @@ export function HandoffCard({ attrs, locked }: CardProps) { const brief = (attrs.brief ?? '').trim().slice(0, 240) const plan = parseHandoffPlan(attrs.plan) const state = useStore($setupHandoff) - let error = useStore($handoffError) - let completed = false + const receipt = useMemo(() => { + try { + return { + completed: !!storedId && readGuideHandoffReceipt(storedId).receipt?.status === 'accepted', + error: null + } + } catch (error) { + return { completed: false, error: String(error) } + } + }, [storedId, state?.phase]) - try { - completed = - !!storedId && - readHandoffReceipt(handoffReceiptKey(guideSourceConnectionId(storedId), storedId))?.status === 'accepted' - } catch (receiptError) { - error = String(receiptError) - } + const error = useStore($handoffError) ?? receipt.error + const completed = receipt.completed useEffect(() => { if (!task || !brief || locked || !storedId || !runtimeId || $setupHandoff.get() || completed) { @@ -161,6 +165,36 @@ export function HandoffCard({ attrs, locked }: CardProps) { const failed = state?.phase === 'error' || error !== null const title = state?.sessionTitle ?? firstTaskTitle(task) + const retry = async () => { + if (state?.phase !== 'error') { + return + } + + try { + if (receipt.error && storedId) { + quarantineHandoffReceipt(guideHandoffReceiptKey(storedId)) + } + + if (!state.guide && storedId && runtimeId) { + const owner = await resolveSessionOwner(storedId) + assertSessionOwnerResolved(owner, { method: 'onboarding.handoff', sessionId: storedId }) + $setupHandoff.set({ + ...state, + guide: { + storedId, + runtimeId, + connectionId: isSessionOwnerRoute(owner) ? owner.connectionId : null, + profile: isSessionOwnerRoute(owner) ? owner.profile : owner || SETUP_PROFILE + } + }) + } + + retrySetupHandoff() + } catch (error) { + $handoffError.set(String(error)) + } + } + return (
@@ -171,8 +205,8 @@ export function HandoffCard({ attrs, locked }: CardProps) { ? `${title} was started — find it in your sessions` : `Opening ${title}\u2026`} - {failed && ( - )} @@ -187,29 +221,27 @@ export function ProgressCard({ attrs, locked }: CardProps) { const messageId = useAuiState(state => state.message.id) const title = (attrs.title ?? '').trim() || 'Working on it' - const steps = useMemo(() => { - const index = messages.findIndex(message => message.id === messageId) - const previous = index < 0 ? [] : messages.slice(0, index) + const index = messages.findIndex(message => message.id === messageId) + const previous = index < 0 ? [] : messages.slice(0, index) - return previous.flatMap(message => { - const directives = message.parts.flatMap(part => - part.type === 'text' ? (segmentTranscriptDirectives(part.text) ?? []) : [] + const steps = previous.flatMap(message => { + const directives = message.parts.flatMap(part => + part.type === 'text' ? (segmentTranscriptDirectives(part.text) ?? []) : [] + ) + + const progress = directives + .filter( + segment => + segment.kind === 'directive' && + segment.directive.name === 'onboarding' && + segment.directive.attrs.step === 'progress' ) + .at(-1) - const progress = directives - .filter( - segment => - segment.kind === 'directive' && - segment.directive.name === 'onboarding' && - segment.directive.attrs.step === 'progress' - ) - .at(-1) - - return progress?.kind === 'directive' - ? [{ id: message.id, title: progress.directive.attrs.title?.trim() || 'Working on it' }] - : [] - }) - }, [messages, messageId]) + return progress?.kind === 'directive' + ? [{ id: message.id, title: progress.directive.attrs.title?.trim() || 'Working on it' }] + : [] + }) return (
diff --git a/apps/desktop/src/components/onboarding-chat/setup-profile.ts b/apps/desktop/src/components/onboarding-chat/setup-profile.ts index 570d62862e..bcd28aa816 100644 --- a/apps/desktop/src/components/onboarding-chat/setup-profile.ts +++ b/apps/desktop/src/components/onboarding-chat/setup-profile.ts @@ -24,6 +24,7 @@ import { atom } from 'nanostores' +import type { HandoffReceipt } from '@/app/contrib/handoff-leg' import { handoffReceiptKey, readHandoffReceipt } from '@/app/contrib/handoff-receipt' import type { GatewayRequest } from '@/app/session/hooks/use-prompt-actions/utils' import { activeGatewayConnectionId } from '@/store/gateway' @@ -111,13 +112,21 @@ export function guideSourceConnectionId(guideStoredId: null | string | undefined return (guideStoredId && getSessionOwnerHint(guideStoredId)?.connectionId) || activeGatewayConnectionId() || null } +export function guideHandoffReceiptKey(guideStoredId: string): string { + return handoffReceiptKey(guideSourceConnectionId(guideStoredId), guideStoredId) +} + +export function readGuideHandoffReceipt(guideStoredId: string): { key: string; receipt: HandoffReceipt | null } { + const key = guideHandoffReceiptKey(guideStoredId) + + return { key, receipt: readHandoffReceipt(key) } +} + /** The request atom suppresses remounts; only an accepted receipt suppresses relaunches. */ export function requestSetupHandoff(task: string, brief: string, plan: HandoffPlan, guide: SetupSession): boolean { if ( $setupHandoff.get() !== null || - (guide.storedId && - readHandoffReceipt(handoffReceiptKey(guideSourceConnectionId(guide.storedId), guide.storedId))?.status === - 'accepted') + (guide.storedId && readGuideHandoffReceipt(guide.storedId).receipt?.status === 'accepted') ) { return false } diff --git a/apps/desktop/src/store/onboarding-gate.test.ts b/apps/desktop/src/store/onboarding-gate.test.ts new file mode 100644 index 0000000000..e928cdd788 --- /dev/null +++ b/apps/desktop/src/store/onboarding-gate.test.ts @@ -0,0 +1,37 @@ +import { expect, it, vi } from 'vitest' + +import type * as storageModule from '@/lib/storage' + +const storage = vi.hoisted(() => new Map()) + +vi.mock('@/lib/onboarding-enabled', () => ({ isOnboardingEnabled: () => true })) +vi.mock('@/lib/storage', async importOriginal => ({ + ...(await importOriginal()), + readKey: (key: string) => storage.get(key) ?? null, + writeKey: (key: string, value: string | null) => { + if (value === null) { + storage.delete(key) + } else { + storage.set(key, value) + } + } +})) + +it('restores every persisted onboarding phase and rejects unknown phases', async () => { + const { ONBOARDING_PHASES } = await import('./onboarding-gate') + + for (const phase of ONBOARDING_PHASES.filter(phase => phase !== 'idle')) { + storage.clear() + storage.set('hermes-onboarding-phase-v1', phase) + vi.resetModules() + const { $onboardingGate } = await import('./onboarding-gate') + + expect($onboardingGate.get().phase).toBe(phase) + } + + storage.set('hermes-onboarding-phase-v1', 'unknown-phase') + vi.resetModules() + const { $onboardingGate } = await import('./onboarding-gate') + + expect($onboardingGate.get().phase).toBe('idle') +}) diff --git a/apps/desktop/src/store/onboarding-gate.ts b/apps/desktop/src/store/onboarding-gate.ts index 2b067579ed..d17edbd89f 100644 --- a/apps/desktop/src/store/onboarding-gate.ts +++ b/apps/desktop/src/store/onboarding-gate.ts @@ -8,7 +8,13 @@ import { DEFAULT_ANSWERS, setOnboardingAnswers } from './onboarding-answers' const PHASE_KEY = 'hermes-onboarding-phase-v1' -export type OnboardingPhase = 'idle' | 'cinematic' | 'guided' | 'skipped' | 'handoff' | 'done' +export const ONBOARDING_PHASES = ['idle', 'cinematic', 'guided', 'skipped', 'handoff', 'done'] as const + +export type OnboardingPhase = (typeof ONBOARDING_PHASES)[number] + +function isOnboardingPhase(value: string | null): value is OnboardingPhase { + return ONBOARDING_PHASES.some(phase => phase === value) +} export interface OnboardingGateState { phase: OnboardingPhase @@ -20,11 +26,7 @@ type GuideKickoff = { status: 'idle' } | { status: 'starting'; promise: Promise< function loadGate(): OnboardingGateState { const saved = readKey(PHASE_KEY) - const phase = - isOnboardingEnabled() && - (saved === 'cinematic' || saved === 'guided' || saved === 'skipped' || saved === 'handoff' || saved === 'done') - ? saved - : 'idle' + const phase = isOnboardingEnabled() && isOnboardingPhase(saved) ? saved : 'idle' return { phase, guideQueued: phase === 'cinematic' && hasSeenIntroReveal() } }