From 0591da2ba65f1e8bc61e5b5f2ae1dab579e73451 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:16:48 +0530 Subject: [PATCH] Guided first launch: review fixes from #107985 and the free-tier chip badge (NS-848, NS-855) (#108211) * fix(desktop): centralize guide handoff receipt reads Resolve the guide receipt key and value together in setup-profile. Use the helper at all four read sites so connection scoping follows one implementation. * fix(desktop): recover from unreadable handoff receipts Memoize receipt reads and show Retry only for the error phase. Quarantine corrupt data before retrying, and resolve the guide identity when the failed request did not retain it so a fresh build can start. Cover preservation of corrupt data and removal from the active receipt key with an invariant test. * fix(desktop): validate persisted onboarding phases from one list Derive OnboardingPhase and persisted-value validation from the same phase list so future phases survive relaunch. Verify every persisted phase reloads and an unknown value falls back to idle. * fix(desktop): share window centering arithmetic Extract centeredBounds and use it for onboarding boot and window growth. Keep the existing work-area clamps and coordinate rounding unchanged. * fix(desktop): compute progress steps inline Remove the ineffective ProgressCard memo because streaming flushes replace the messages array. Keep the same transcript scan and rendered steps. * fix(desktop): center the free-tier status chip detail Wrap the model label and sign-in badge in an inline flex span with a shared gap. This centers the badge beside the model text without changing other status-bar details. * fix(desktop): derive the guide receipt key in one place The Retry path spelled the key derivation out again because the read helper throws on a corrupt receipt before it can return the key. A separate guideHandoffReceiptKey serves both the reader and the quarantine, so the derivation has one home again. * fix(desktop): keep the free-tier badge at its intended leading Badge declares leading-none, but the class merger drops it behind the size variant's font-size class, so the badge inherits a 1.5 leading and renders 16px tall next to an 11px label. That height, not the inline alignment, is what read as a detached badge. Restating leading-none on the chip's badge brings it to 11.6px, inside the label's cap height. The Badge component itself is left alone; every other badge in the app has the same dropped leading and that is a separate decision. --- .../electron/chat-onboarding-window.ts | 12 +-- apps/desktop/electron/window-growth.ts | 6 ++ .../src/app/contrib/handoff-receipt.test.ts | 18 ++++ .../src/app/contrib/handoff-receipt.ts | 7 +- .../src/app/contrib/onboarding-handoff.ts | 9 +- .../app/shell/hooks/use-statusbar-items.tsx | 9 +- .../onboarding-chat/cards/build.tsx | 98 ++++++++++++------- .../onboarding-chat/setup-profile.ts | 15 ++- .../desktop/src/store/onboarding-gate.test.ts | 37 +++++++ apps/desktop/src/store/onboarding-gate.ts | 14 +-- 10 files changed, 165 insertions(+), 60 deletions(-) create mode 100644 apps/desktop/src/app/contrib/handoff-receipt.test.ts create mode 100644 apps/desktop/src/store/onboarding-gate.test.ts 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() } }