From 2497282716288952983c81ddd1e97b4799122cb0 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Tue, 5 May 2026 01:11:08 +0530 Subject: [PATCH] feat(onboarding): replace welcome-agent bot with react-joyride walkthrough (#1180) --- app/package.json | 1 + app/src/App.tsx | 98 ++- app/src/components/BottomTabBar.tsx | 19 +- .../__tests__/BottomTabBar.test.tsx | 106 +++ .../components/walkthrough/AppWalkthrough.tsx | 117 +++ .../walkthrough/WalkthroughTooltip.tsx | 100 +++ .../__tests__/AppWalkthrough.test.tsx | 416 ++++++++++ .../walkthrough/walkthroughSteps.ts | 54 ++ app/src/constants/onboardingChat.ts | 18 +- app/src/lib/coreState/__tests__/store.test.ts | 14 +- app/src/lib/coreState/store.ts | 32 +- app/src/pages/Accounts.tsx | 92 ++- app/src/pages/Conversations.tsx | 559 ++++++------- app/src/pages/Home.tsx | 9 +- .../__tests__/Conversations.render.test.tsx | 556 +++++++++++++ .../Conversations.welcomeLock.test.tsx | 757 ++---------------- app/src/pages/onboarding/OnboardingLayout.tsx | 129 +-- .../__tests__/OnboardingLayout.test.tsx | 83 +- app/src/store/__tests__/threadSlice.test.ts | 9 + app/src/store/threadSlice.ts | 24 +- pnpm-lock.yaml | 125 ++- src/openhuman/config/ops.rs | 46 +- 22 files changed, 2171 insertions(+), 1193 deletions(-) create mode 100644 app/src/components/__tests__/BottomTabBar.test.tsx create mode 100644 app/src/components/walkthrough/AppWalkthrough.tsx create mode 100644 app/src/components/walkthrough/WalkthroughTooltip.tsx create mode 100644 app/src/components/walkthrough/__tests__/AppWalkthrough.test.tsx create mode 100644 app/src/components/walkthrough/walkthroughSteps.ts create mode 100644 app/src/pages/__tests__/Conversations.render.test.tsx diff --git a/app/package.json b/app/package.json index c02009f56..75afac031 100644 --- a/app/package.json +++ b/app/package.json @@ -78,6 +78,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-icons": "^5.6.0", + "react-joyride": "^3.1.0", "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.0", diff --git a/app/src/App.tsx b/app/src/App.tsx index 37aa62923..86d7300d9 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -16,7 +16,9 @@ import MeshGradient from './components/MeshGradient'; import OpenhumanLinkModal from './components/OpenhumanLinkModal'; import PersistRehydrationScreen from './components/PersistRehydrationScreen'; import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner'; -import { isWelcomeLocked } from './lib/coreState/store'; +import AppWalkthrough from './components/walkthrough/AppWalkthrough'; +// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough +// import { isWelcomeLocked } from './lib/coreState/store'; import { startNativeNotificationsService } from './lib/nativeNotifications'; import { startWebviewNotificationsService } from './lib/webviewNotifications'; import ChatRuntimeProvider from './providers/ChatRuntimeProvider'; @@ -24,8 +26,10 @@ import CoreStateProvider, { useCoreState } from './providers/CoreStateProvider'; import SocketProvider from './providers/SocketProvider'; import { startWebviewAccountService } from './services/webviewAccountService'; import { persistor, store } from './store'; -import { useAppDispatch, useAppSelector } from './store/hooks'; -import { clearSelectedThread, deleteThread, setWelcomeThreadId } from './store/threadSlice'; +// [#1123] useAppDispatch commented out — welcome-agent onboarding replaced by Joyride walkthrough +import { useAppSelector } from './store/hooks'; +// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough +// import { clearSelectedThread, deleteThread, setWelcomeThreadId } from './store/threadSlice'; import { isAccountsFullscreen } from './utils/accountsFullscreen'; import { DEV_FORCE_ONBOARDING } from './utils/config'; @@ -77,7 +81,8 @@ function AppShell() { // bottom padding. Any other selected "app" (e.g. WhatsApp) takes the // full viewport so the embedded webview goes edge-to-edge. const fullscreen = isAccountsFullscreen(location.pathname, activeAccountId); - const welcomeLocked = isWelcomeLocked(snapshot); + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough + // const welcomeLocked = isWelcomeLocked(snapshot); const onOnboardingRoute = location.pathname.startsWith('/onboarding'); const onboardingPending = !!snapshot.sessionToken && (DEV_FORCE_ONBOARDING || !snapshot.onboardingCompleted); @@ -107,51 +112,53 @@ function AppShell() { navigate, ]); + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // After the welcome agent calls `complete_onboarding` and // `chat_onboarding_completed` flips false→true, discard the transient // welcome thread we created in `OnboardingLayout`. The next user // message will route to the orchestrator and create its own thread. - const dispatch = useAppDispatch(); - const welcomeThreadId = useAppSelector(state => state.thread.welcomeThreadId); - const chatOnboardingCompleted = snapshot.chatOnboardingCompleted; - useEffect(() => { - if (!chatOnboardingCompleted || !welcomeThreadId) return; - let cancelled = false; - console.debug( - `[welcome-cleanup] chat_onboarding_completed=true — deleting welcome thread ${welcomeThreadId}` - ); - // Await the delete before dropping the local id so a backend failure - // leaves `welcomeThreadId` set for retry on the next render. Without - // the await, a 500 from `threads.delete` would leave a stale row in - // the user's thread list while the renderer thinks it's gone. - (async () => { - try { - await dispatch(deleteThread(welcomeThreadId)).unwrap(); - if (cancelled) return; - dispatch(clearSelectedThread()); - dispatch(setWelcomeThreadId(null)); - } catch (err) { - console.warn('[welcome-cleanup] deleteThread failed; will retry on next render', err); - } - })(); - return () => { - cancelled = true; - }; - }, [chatOnboardingCompleted, welcomeThreadId, dispatch]); - + // const dispatch = useAppDispatch(); + // const welcomeThreadId = useAppSelector(state => state.thread.welcomeThreadId); + // const chatOnboardingCompleted = snapshot.chatOnboardingCompleted; + // useEffect(() => { + // if (!chatOnboardingCompleted || !welcomeThreadId) return; + // let cancelled = false; + // console.debug( + // `[welcome-cleanup] chat_onboarding_completed=true — deleting welcome thread ${welcomeThreadId}` + // ); + // // Await the delete before dropping the local id so a backend failure + // // leaves `welcomeThreadId` set for retry on the next render. Without + // // the await, a 500 from `threads.delete` would leave a stale row in + // // the user's thread list while the renderer thinks it's gone. + // (async () => { + // try { + // await dispatch(deleteThread(welcomeThreadId)).unwrap(); + // if (cancelled) return; + // dispatch(clearSelectedThread()); + // dispatch(setWelcomeThreadId(null)); + // } catch (err) { + // console.warn('[welcome-cleanup] deleteThread failed; will retry on next render', err); + // } + // })(); + // return () => { + // cancelled = true; + // }; + // }, [chatOnboardingCompleted, welcomeThreadId, dispatch]); + // + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // Welcome lockdown (#883) — force any route other than `/chat` back to // `/chat` while the welcome-agent conversation is still in progress. // Skipped while onboarding is still pending (the onboarding gate above // owns the route during that phase). - useEffect(() => { - if (!welcomeLocked || isBootstrapping) return; - if (onboardingPending) return; - if (location.pathname === '/chat') return; - console.debug( - `[welcome-lock] redirecting ${location.pathname} -> /chat (chat onboarding incomplete)` - ); - navigate('/chat', { replace: true }); - }, [welcomeLocked, isBootstrapping, onboardingPending, location.pathname, navigate]); + // useEffect(() => { + // if (!welcomeLocked || isBootstrapping) return; + // if (onboardingPending) return; + // if (location.pathname === '/chat') return; + // console.debug( + // `[welcome-lock] redirecting ${location.pathname} -> /chat (chat onboarding incomplete)` + // ); + // navigate('/chat', { replace: true }); + // }, [welcomeLocked, isBootstrapping, onboardingPending, location.pathname, navigate]); return (
@@ -159,7 +166,8 @@ function AppShell() {
@@ -167,6 +175,12 @@ function AppShell() { {!onOnboardingRoute && }
+ {/* Post-onboarding Joyride walkthrough — mounted here (outside routes) so + it persists across tab navigations. Joyride targets span Home + BottomTabBar + tabs so it must stay mounted while the user moves between routes. */} + {!isBootstrapping && !onOnboardingRoute && ( + + )}
); } diff --git a/app/src/components/BottomTabBar.tsx b/app/src/components/BottomTabBar.tsx index b92c9b759..16aee9bb4 100644 --- a/app/src/components/BottomTabBar.tsx +++ b/app/src/components/BottomTabBar.tsx @@ -1,7 +1,8 @@ import { useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { isWelcomeLocked } from '../lib/coreState/store'; +// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough +// import { isWelcomeLocked } from '../lib/coreState/store'; import { useCoreState } from '../providers/CoreStateProvider'; import { useAppSelector } from '../store/hooks'; import { selectUnreadCount } from '../store/notificationSlice'; @@ -159,12 +160,13 @@ const BottomTabBar = () => { return null; } + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // Welcome lockdown (#883) — hide the bottom nav entirely while the // chat-based welcome-agent flow is still in progress so the user // cannot navigate away from the welcome conversation. - if (isWelcomeLocked(snapshot)) { - return null; - } + // if (isWelcomeLocked(snapshot)) { + // return null; + // } // On /accounts we want as much real estate as possible for the embedded // webview — but *only* when a real account (WhatsApp, …) is selected. @@ -215,9 +217,18 @@ const BottomTabBar = () => { .map(tab => { const active = isActive(tab.path); const showBadge = tab.id === 'notifications' && unreadCount > 0; + // data-walkthrough attributes for the Joyride walkthrough steps. + // Maps tab ids to their walkthrough target names (steps 3–6). + const walkthroughAttr: Record = { + chat: 'tab-chat', + skills: 'tab-skills', + notifications: 'tab-automation', + settings: 'tab-settings', + }; return ( + )} + +
+ + {/* Back */} + {index > 0 && ( + + )} + + {/* Next / Let's go! */} + {continuous && ( + + )} +
+
+ + + ); +}; + +export default WalkthroughTooltip; diff --git a/app/src/components/walkthrough/__tests__/AppWalkthrough.test.tsx b/app/src/components/walkthrough/__tests__/AppWalkthrough.test.tsx new file mode 100644 index 000000000..0e2f13705 --- /dev/null +++ b/app/src/components/walkthrough/__tests__/AppWalkthrough.test.tsx @@ -0,0 +1,416 @@ +/** + * Tests for the Joyride walkthrough components introduced in #1123. + * + * Verifies: + * - isWalkthroughPending / setWalkthroughPending / markWalkthroughComplete helpers + * - AppWalkthrough renders only when pending + * - AppWalkthrough does not render when already completed + * - Completing/skipping the tour calls markWalkthroughComplete (localStorage set) + * - Step count matches WALKTHROUGH_STEPS + * - WalkthroughTooltip renders step title, content, and navigation buttons + */ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + isWalkthroughPending, + markWalkthroughComplete, + setWalkthroughPending, +} from '../AppWalkthrough'; +import { WALKTHROUGH_STEPS } from '../walkthroughSteps'; +// ── WalkthroughTooltip rendering tests ─────────────────────────────────── + +import WalkthroughTooltip from '../WalkthroughTooltip'; + +// ── Mock react-joyride so tests don't need a real DOM with +// positioned elements for each step target. ───────────────────────────── +// The mock captures the `onEvent` callback so individual tests can +// simulate tour events (TOUR_END with FINISHED / SKIPPED status). + +type JoyrideMockProps = { + run: boolean; + onEvent?: (data: { type: string; status: string; index: number }) => void; +}; + +let capturedOnEvent: JoyrideMockProps['onEvent'] | undefined; + +vi.mock('react-joyride', () => ({ + Joyride: ({ run, onEvent }: JoyrideMockProps) => { + capturedOnEvent = onEvent; + return
; + }, + EVENTS: { TOUR_END: 'tour:end' }, + STATUS: { FINISHED: 'finished', SKIPPED: 'skipped' }, +})); + +// ── localStorage helpers ─────────────────────────────────────────────────── + +const WALKTHROUGH_KEY = 'openhuman:walkthrough_completed'; +const WALKTHROUGH_PENDING_KEY = 'openhuman:walkthrough_pending'; + +beforeEach(() => { + localStorage.clear(); + capturedOnEvent = undefined; +}); + +afterEach(() => { + localStorage.clear(); + vi.resetModules(); +}); + +// ── Helper state tests ──────────────────────────────────────────────────── + +describe('isWalkthroughPending', () => { + it('returns false when nothing is set', () => { + expect(isWalkthroughPending()).toBe(false); + }); + + it('returns true when pending flag is set and completed flag is not', () => { + localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true'); + expect(isWalkthroughPending()).toBe(true); + }); + + it('returns false when both pending and completed are set', () => { + localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true'); + localStorage.setItem(WALKTHROUGH_KEY, 'true'); + expect(isWalkthroughPending()).toBe(false); + }); + + it('returns false when only completed flag is set', () => { + localStorage.setItem(WALKTHROUGH_KEY, 'true'); + expect(isWalkthroughPending()).toBe(false); + }); +}); + +describe('setWalkthroughPending', () => { + it('sets the pending flag in localStorage', () => { + setWalkthroughPending(); + expect(localStorage.getItem(WALKTHROUGH_PENDING_KEY)).toBe('true'); + }); + + it('swallows error when localStorage.setItem throws (SecurityError / quota)', () => { + // Temporarily replace localStorage with a broken implementation to trigger + // the catch block at line 44 in setWalkthroughPending. + const realStorage = globalThis.localStorage; + Object.defineProperty(globalThis, 'localStorage', { + value: { + ...realStorage, + setItem() { + throw new DOMException('QuotaExceededError', 'QuotaExceededError'); + }, + }, + configurable: true, + writable: true, + }); + + try { + // Should not throw — the error is swallowed inside setWalkthroughPending + expect(() => setWalkthroughPending()).not.toThrow(); + } finally { + Object.defineProperty(globalThis, 'localStorage', { + value: realStorage, + configurable: true, + writable: true, + }); + } + }); +}); + +describe('markWalkthroughComplete', () => { + it('sets the completed flag and removes the pending flag', () => { + localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true'); + markWalkthroughComplete(); + expect(localStorage.getItem(WALKTHROUGH_KEY)).toBe('true'); + expect(localStorage.getItem(WALKTHROUGH_PENDING_KEY)).toBeNull(); + }); + + it('swallows error when localStorage.setItem throws (SecurityError / quota)', () => { + // Temporarily replace localStorage with a broken implementation to trigger + // the catch block at line 61 in markWalkthroughComplete. + const realStorage = globalThis.localStorage; + Object.defineProperty(globalThis, 'localStorage', { + value: { + ...realStorage, + setItem() { + throw new DOMException('QuotaExceededError', 'QuotaExceededError'); + }, + }, + configurable: true, + writable: true, + }); + + try { + // Should not throw — the error is swallowed inside markWalkthroughComplete + expect(() => markWalkthroughComplete()).not.toThrow(); + } finally { + Object.defineProperty(globalThis, 'localStorage', { + value: realStorage, + configurable: true, + writable: true, + }); + } + }); +}); + +describe('isWalkthroughPending — localStorage unavailable', () => { + it('returns false and swallows error when localStorage.getItem throws', () => { + // Temporarily replace localStorage with a broken implementation to trigger + // the catch block at lines 26-27 in isWalkthroughPending. + const realStorage = globalThis.localStorage; + Object.defineProperty(globalThis, 'localStorage', { + value: { + ...realStorage, + getItem() { + throw new DOMException('SecurityError', 'SecurityError'); + }, + }, + configurable: true, + writable: true, + }); + + try { + // Should return false (the catch branch) and not throw + expect(isWalkthroughPending()).toBe(false); + } finally { + Object.defineProperty(globalThis, 'localStorage', { + value: realStorage, + configurable: true, + writable: true, + }); + } + }); +}); + +// ── AppWalkthrough component tests ──────────────────────────────────────── + +describe('AppWalkthrough component', () => { + it('renders Joyride when walkthrough is pending', async () => { + setWalkthroughPending(); + + const { default: AppWalkthrough } = await import('../AppWalkthrough'); + render(); + + expect(screen.getByTestId('joyride-mock')).toBeInTheDocument(); + expect(screen.getByTestId('joyride-mock').getAttribute('data-run')).toBe('true'); + }); + + it('renders nothing when walkthrough is not pending', async () => { + // No pending flag set + + const { default: AppWalkthrough } = await import('../AppWalkthrough'); + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when walkthrough is already completed', async () => { + // Set pending but also completed — should not render + localStorage.setItem(WALKTHROUGH_PENDING_KEY, 'true'); + localStorage.setItem(WALKTHROUGH_KEY, 'true'); + + const { default: AppWalkthrough } = await import('../AppWalkthrough'); + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('calls markWalkthroughComplete and stops running when tour finishes (FINISHED)', async () => { + setWalkthroughPending(); + + const { default: AppWalkthrough } = await import('../AppWalkthrough'); + render(); + + // Joyride should be running initially + expect(screen.getByTestId('joyride-mock').getAttribute('data-run')).toBe('true'); + + // Simulate TOUR_END with FINISHED status + await act(async () => { + capturedOnEvent?.({ type: 'tour:end', status: 'finished', index: 5 }); + }); + + // Walkthrough should be marked complete in localStorage + expect(localStorage.getItem(WALKTHROUGH_KEY)).toBe('true'); + expect(localStorage.getItem(WALKTHROUGH_PENDING_KEY)).toBeNull(); + }); + + it('calls markWalkthroughComplete and stops running when tour is skipped (SKIPPED)', async () => { + setWalkthroughPending(); + + const { default: AppWalkthrough } = await import('../AppWalkthrough'); + render(); + + expect(screen.getByTestId('joyride-mock').getAttribute('data-run')).toBe('true'); + + // Simulate TOUR_END with SKIPPED status + await act(async () => { + capturedOnEvent?.({ type: 'tour:end', status: 'skipped', index: 1 }); + }); + + expect(localStorage.getItem(WALKTHROUGH_KEY)).toBe('true'); + expect(localStorage.getItem(WALKTHROUGH_PENDING_KEY)).toBeNull(); + }); + + it('does not call markWalkthroughComplete for non-TOUR_END events', async () => { + setWalkthroughPending(); + + const { default: AppWalkthrough } = await import('../AppWalkthrough'); + render(); + + // Simulate a step:after event (not tour:end) + await act(async () => { + capturedOnEvent?.({ type: 'step:after', status: 'running', index: 0 }); + }); + + // Should NOT have marked complete + expect(localStorage.getItem(WALKTHROUGH_KEY)).toBeNull(); + // Still running + expect(screen.getByTestId('joyride-mock')).toBeInTheDocument(); + }); +}); + +/** Build the minimal props required by WalkthroughTooltip without fighting the full TooltipRenderProps type. */ +function makeTooltipProps( + overrides: { + index?: number; + size?: number; + isLastStep?: boolean; + continuous?: boolean; + title?: string; + content?: string; + } = {} +) { + const { + index = 0, + size = 3, + isLastStep = false, + continuous = true, + title = 'Step title', + content = 'Step content', + } = overrides; + // Cast to unknown then to the component's expected props to avoid fighting + // the exhaustive TooltipRenderProps type in test code. + return { + continuous, + index, + size, + isLastStep, + step: { title, content, target: 'body' }, + backProps: { + 'aria-label': 'Back', + onClick: vi.fn(), + role: 'button', + title: 'Back', + 'data-action': 'back', + }, + primaryProps: { + 'aria-label': 'Next', + onClick: vi.fn(), + role: 'button', + title: 'Next', + 'data-action': 'primary', + }, + skipProps: { + 'aria-label': 'Skip', + onClick: vi.fn(), + role: 'button', + title: 'Skip', + 'data-action': 'skip', + }, + tooltipProps: { role: 'tooltip' }, + closeProps: { + 'aria-label': 'Close', + onClick: vi.fn(), + role: 'button', + title: 'Close', + 'data-action': 'close', + }, + } as unknown as Parameters[0]; +} + +describe('WalkthroughTooltip', () => { + it('renders step title and content', () => { + render(); + + expect(screen.getByText('Step title')).toBeInTheDocument(); + expect(screen.getByText('Step content')).toBeInTheDocument(); + }); + + it('renders step counter showing current step of total', () => { + render(); + + expect(screen.getByText('2 of 6')).toBeInTheDocument(); + }); + + it('shows Skip button when not on last step', () => { + render(); + + expect(screen.getByText('Skip tour')).toBeInTheDocument(); + }); + + it('hides Skip button on the last step', () => { + render(); + + expect(screen.queryByText('Skip tour')).toBeNull(); + }); + + it('shows Finish on the last step', () => { + render(); + + expect(screen.getByText("Let's go!")).toBeInTheDocument(); + }); + + it('shows Next on non-last steps', () => { + render(); + + expect(screen.getByText('Next →')).toBeInTheDocument(); + }); + + it('hides Back button on the first step (index 0)', () => { + render(); + + expect(screen.queryByText('Back')).toBeNull(); + }); + + it('shows Back button after the first step', () => { + render(); + + expect(screen.getByText('Back')).toBeInTheDocument(); + }); + + it('renders progress bar', () => { + const { container } = render( + + ); + + // Gradient progress bar fills based on step progress + const bar = container.querySelector('div.bg-gradient-to-r'); + expect(bar).not.toBeNull(); + expect(bar?.getAttribute('style')).toContain('width: 50%'); + }); +}); + +// ── walkthroughSteps tests ──────────────────────────────────────────────── + +describe('WALKTHROUGH_STEPS', () => { + it('has 6 steps', () => { + expect(WALKTHROUGH_STEPS).toHaveLength(6); + }); + + it('first step targets home-card and disables beacon', () => { + const first = WALKTHROUGH_STEPS[0]; + expect(first.target).toBe('[data-walkthrough="home-card"]'); + expect(first.skipBeacon).toBe(true); + }); + + it('last step targets tab-settings', () => { + const last = WALKTHROUGH_STEPS[WALKTHROUGH_STEPS.length - 1]; + expect(last.target).toBe('[data-walkthrough="tab-settings"]'); + }); + + it('all steps have a title and content', () => { + for (const step of WALKTHROUGH_STEPS) { + expect(step.title).toBeTruthy(); + expect(step.content).toBeTruthy(); + } + }); +}); diff --git a/app/src/components/walkthrough/walkthroughSteps.ts b/app/src/components/walkthrough/walkthroughSteps.ts new file mode 100644 index 000000000..6032e3d0f --- /dev/null +++ b/app/src/components/walkthrough/walkthroughSteps.ts @@ -0,0 +1,54 @@ +import type { Step } from 'react-joyride'; + +/** + * Step definitions for the post-onboarding product walkthrough. + * Targets must match `data-walkthrough="..."` attributes in the DOM. + * + * Copy is conversational and warm — matching OpenHuman's "calm sophistication" + * design language. Each step has an emoji accent for visual interest. + */ +export const WALKTHROUGH_STEPS: Step[] = [ + { + target: '[data-walkthrough="home-card"]', + title: 'Your command center', + content: + "Everything starts here — your connections, your conversations, your AI. Think of this as mission control. Let's take a quick look around.", + placement: 'bottom', + skipBeacon: true, + }, + { + target: '[data-walkthrough="home-cta"]', + title: 'Say hello', + content: + "This is the fastest way to talk to your AI assistant. Try asking it to summarize your emails, draft a message, or just say hi — it's surprisingly good at small talk.", + placement: 'bottom', + }, + { + target: '[data-walkthrough="tab-chat"]', + title: 'Conversations that remember', + content: + 'Every chat is saved and searchable. Your assistant remembers context across conversations, so you can pick up right where you left off.', + placement: 'top', + }, + { + target: '[data-walkthrough="tab-skills"]', + title: 'Supercharge your assistant', + content: + 'Connect Gmail, Slack, WhatsApp, and more. The more you connect, the more your assistant can actually do — not just talk about doing.', + placement: 'top', + }, + { + target: '[data-walkthrough="tab-automation"]', + title: 'Set it and forget it', + content: + 'Morning briefings, scheduled check-ins, proactive alerts. Your assistant can work for you even when you are not looking.', + placement: 'top', + }, + { + target: '[data-walkthrough="tab-settings"]', + title: "You're in control", + content: + "That's the quick tour! You can always find settings, billing, and preferences here. Now go explore — your assistant is ready when you are.", + placement: 'top', + }, +]; diff --git a/app/src/constants/onboardingChat.ts b/app/src/constants/onboardingChat.ts index e75a61ae8..2640091a0 100644 --- a/app/src/constants/onboardingChat.ts +++ b/app/src/constants/onboardingChat.ts @@ -1,8 +1,12 @@ -/** - * Label applied to the welcome thread created when the user finishes the - * desktop onboarding wizard. The thread is deleted once the welcome agent - * calls `complete_onboarding(action: "complete")`. While it exists, the label - * lets the UI hide all other threads during welcome lockdown and show a stable - * "Onboarding" title. - */ +// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough +// /** +// * Label applied to the welcome thread created when the user finishes the +// * desktop onboarding wizard. The thread is deleted once the welcome agent +// * calls `complete_onboarding(action: "complete")`. While it exists, the label +// * lets the UI hide all other threads during welcome lockdown and show a stable +// * "Onboarding" title. +// */ +// export const ONBOARDING_WELCOME_THREAD_LABEL = 'onboarding'; + +/** @deprecated [#1123] — kept for any remaining imports; use empty string as placeholder */ export const ONBOARDING_WELCOME_THREAD_LABEL = 'onboarding'; diff --git a/app/src/lib/coreState/__tests__/store.test.ts b/app/src/lib/coreState/__tests__/store.test.ts index b53645cac..bbd0fe0d8 100644 --- a/app/src/lib/coreState/__tests__/store.test.ts +++ b/app/src/lib/coreState/__tests__/store.test.ts @@ -16,20 +16,24 @@ function makeSnapshot(overrides: Partial = {}): CoreAppSnapshot }; } +// [#1123] isWelcomeLocked now always returns false — welcome-agent onboarding +// replaced by Joyride walkthrough. Tests updated to reflect the new behavior. describe('isWelcomeLocked', () => { - it('locks when authenticated user finished the wizard but chat onboarding is still false', () => { - expect(isWelcomeLocked(makeSnapshot())).toBe(true); + it('[#1123] always returns false — welcome lockdown replaced by Joyride walkthrough', () => { + // Previously returned true when onboardingCompleted=true and chatOnboardingCompleted=false. + // Now always returns false since the welcome-lock UI was removed. + expect(isWelcomeLocked(makeSnapshot())).toBe(false); }); - it('unlocks once chat onboarding completes', () => { + it('returns false once chat onboarding completes', () => { expect(isWelcomeLocked(makeSnapshot({ chatOnboardingCompleted: true }))).toBe(false); }); - it('stays unlocked while the wizard is still up — the /onboarding route owns that gate', () => { + it('returns false while the wizard is still up', () => { expect(isWelcomeLocked(makeSnapshot({ onboardingCompleted: false }))).toBe(false); }); - it('stays unlocked when signed out so the signed-out first paint does not flicker', () => { + it('returns false when signed out', () => { expect( isWelcomeLocked( makeSnapshot({ diff --git a/app/src/lib/coreState/store.ts b/app/src/lib/coreState/store.ts index 1c8f06ade..6d0024205 100644 --- a/app/src/lib/coreState/store.ts +++ b/app/src/lib/coreState/store.ts @@ -90,24 +90,26 @@ export function setCoreStateSnapshot(next: CoreState): void { /** * Is the UI currently locked to the welcome-agent conversation? (#883) * + * [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough. + * Function body always returns `false` so existing callers compile without + * changes. The welcome-lock UI affordances are also commented out at each + * call site but the function signature is preserved to avoid import errors. + * + * Original implementation: * Returns `true` when the authenticated user has completed the React * wizard (`onboardingCompleted`) but the chat-based welcome flow has - * not yet finalized (`chatOnboardingCompleted === false`). Consumers - * (BottomTabBar, Accounts left rail, Conversations thread sidebar, - * AppShell redirect) hide their navigation affordances while this is - * `true` so the user cannot escape the welcome conversation until the - * welcome agent calls `complete_onboarding(action: "complete")`. - * - * The auth guard prevents a lock flicker during signed-out first paint - * (snapshot briefly reports `onboardingCompleted=false` before the - * async refresh completes; the overlay handles that path). + * not yet finalized (`chatOnboardingCompleted === false`). */ -export function isWelcomeLocked(snapshot: CoreAppSnapshot): boolean { - return ( - snapshot.auth.isAuthenticated && - snapshot.onboardingCompleted && - !snapshot.chatOnboardingCompleted - ); +// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough +export function isWelcomeLocked(_snapshot: CoreAppSnapshot): boolean { + // [#1123] Always return false — welcome-lock replaced by Joyride walkthrough + return false; + // Original implementation: + // return ( + // snapshot.auth.isAuthenticated && + // snapshot.onboardingCompleted && + // !snapshot.chatOnboardingCompleted + // ); } export function patchCoreStateSnapshot(patch: { diff --git a/app/src/pages/Accounts.tsx b/app/src/pages/Accounts.tsx index e7579d1fe..e5037d72d 100644 --- a/app/src/pages/Accounts.tsx +++ b/app/src/pages/Accounts.tsx @@ -4,8 +4,10 @@ import AddAccountModal from '../components/accounts/AddAccountModal'; import { AgentIcon, ProviderIcon } from '../components/accounts/providerIcons'; // import RespondQueuePanel from '../components/accounts/RespondQueuePanel'; import WebviewHost from '../components/accounts/WebviewHost'; -import { isWelcomeLocked } from '../lib/coreState/store'; -import { useCoreState } from '../providers/CoreStateProvider'; +// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough +// import { isWelcomeLocked } from '../lib/coreState/store'; +// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough +// import { useCoreState } from '../providers/CoreStateProvider'; import { hideWebviewAccount, purgeWebviewAccount, @@ -79,8 +81,9 @@ const Accounts = () => { const order = useAppSelector(state => state.accounts.order); const activeAccountId = useAppSelector(state => state.accounts.activeAccountId); const unreadByAccount = useAppSelector(state => state.accounts.unread); - const { snapshot } = useCoreState(); - const welcomeLocked = isWelcomeLocked(snapshot); + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough + // const { snapshot } = useCoreState(); + // const welcomeLocked = isWelcomeLocked(snapshot); // Respond-queue selectors disabled while RespondQueuePanel is hidden. // const respondQueue = useAppSelector(state => state.providerSurfaces.queue); // const respondQueueCount = useAppSelector(state => state.providerSurfaces.count); @@ -94,15 +97,16 @@ const Accounts = () => { startWebviewAccountService(); }, []); + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // Welcome lockdown (#883) — force the Agent pane while the welcome // conversation is in progress so the user cannot jump to a connected // account webview. The rail is hidden below, so this is belt-and- // suspenders in case an external caller toggles `activeAccountId`. - useEffect(() => { - if (welcomeLocked && activeAccountId !== AGENT_ID) { - dispatch(setActiveAccount(AGENT_ID)); - } - }, [welcomeLocked, activeAccountId, dispatch]); + // useEffect(() => { + // if (welcomeLocked && activeAccountId !== AGENT_ID) { + // dispatch(setActiveAccount(AGENT_ID)); + // } + // }, [welcomeLocked, activeAccountId, dispatch]); useEffect(() => { void dispatch(fetchRespondQueue()); @@ -122,11 +126,13 @@ const Accounts = () => { [accounts] ); + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // While welcome-locked, derive the effective selection directly from // `welcomeLocked` so the first paint after a lock flip never renders the // stale `activeAccountId`. The post-paint `useEffect` above still // syncs Redux so other consumers observe the forced selection. - const selectedId = welcomeLocked ? AGENT_ID : (activeAccountId ?? AGENT_ID); + // const selectedId = welcomeLocked ? AGENT_ID : (activeAccountId ?? AGENT_ID); + const selectedId = activeAccountId ?? AGENT_ID; const active = selectedId === AGENT_ID ? null : (accountsById[selectedId] ?? null); const isAgentSelected = selectedId === AGENT_ID; @@ -196,45 +202,37 @@ const Accounts = () => { return (
- {/* Narrow icon rail — always rendered as a floating card alongside - the main content pane. Hidden during welcome lockdown (#883) so - the user cannot navigate to a connected account or add a new one. */} - {!welcomeLocked && ( - {/* Main pane */}
diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 74b50ba03..ca672a2b4 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -10,11 +10,14 @@ import PillTabBar from '../components/PillTabBar'; import UpsellBanner from '../components/upsell/UpsellBanner'; import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState'; import UsageLimitModal from '../components/upsell/UsageLimitModal'; -import { ONBOARDING_WELCOME_THREAD_LABEL } from '../constants/onboardingChat'; +// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough +// import { ONBOARDING_WELCOME_THREAD_LABEL } from '../constants/onboardingChat'; import { useStickToBottom } from '../hooks/useStickToBottom'; import { useUsageState } from '../hooks/useUsageState'; -import { getCoreStateSnapshot, isWelcomeLocked } from '../lib/coreState/store'; -import { useCoreState } from '../providers/CoreStateProvider'; +// [#1123] getCoreStateSnapshot and isWelcomeLocked commented out — welcome-agent onboarding replaced by Joyride walkthrough +// import { getCoreStateSnapshot, isWelcomeLocked } from '../lib/coreState/store'; +// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough +// import { useCoreState } from '../providers/CoreStateProvider'; import { chatCancel, chatSend, useRustChat } from '../services/chatService'; import { store } from '../store'; import { @@ -92,30 +95,31 @@ export function isComposerInteractionBlocked(args: { return !args.rustChat || Boolean(args.activeThreadId) || args.welcomePending; } -function WelcomeThinkingTypewriter() { - const text = 'Your agent is thinking...'; - const [visibleChars, setVisibleChars] = useState(0); - - useEffect(() => { - const isComplete = visibleChars >= text.length; - const delayMs = isComplete ? 950 : 42; - const timeoutId = window.setTimeout(() => { - setVisibleChars(current => (current >= text.length ? 0 : current + 1)); - }, delayMs); - - return () => window.clearTimeout(timeoutId); - }, [text.length, visibleChars]); - - return ( -

- {text.slice(0, visibleChars)} -

- ); -} +// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough +// function WelcomeThinkingTypewriter() { +// const text = 'Your agent is thinking...'; +// const [visibleChars, setVisibleChars] = useState(0); +// +// useEffect(() => { +// const isComplete = visibleChars >= text.length; +// const delayMs = isComplete ? 950 : 42; +// const timeoutId = window.setTimeout(() => { +// setVisibleChars(current => (current >= text.length ? 0 : current + 1)); +// }, delayMs); +// +// return () => window.clearTimeout(timeoutId); +// }, [text.length, visibleChars]); +// +// return ( +//

+// {text.slice(0, visibleChars)} +//

+// ); +// } const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { const dispatch = useAppDispatch(); @@ -127,24 +131,28 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { isLoadingMessages, messagesError, activeThreadId, - welcomeThreadId, + // [#1123] welcomeThreadId commented out — welcome-agent onboarding replaced by Joyride walkthrough + // welcomeThreadId, } = useAppSelector(state => state.thread); - const { snapshot } = useCoreState(); - const welcomeLocked = isWelcomeLocked(snapshot); + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough + // const { snapshot } = useCoreState(); + // const welcomeLocked = isWelcomeLocked(snapshot); + + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // While the proactive welcome agent is running and hasn't published its // first message yet, hide the composer (and a few other non-message // chrome bits) so the user just sees the "Your agent is thinking..." // loader. Flips off the moment the first agent message arrives. - const welcomePending = - !!welcomeThreadId && selectedThreadId === welcomeThreadId && messages.length === 0; - const chatOnboardingCompleted = snapshot.chatOnboardingCompleted; - const previousChatOnboardingCompletedRef = useRef(null); + // const welcomePending = + // !!welcomeThreadId && selectedThreadId === welcomeThreadId && messages.length === 0; + // const chatOnboardingCompleted = snapshot.chatOnboardingCompleted; + // const previousChatOnboardingCompletedRef = useRef(null); // Guard against the mount-time `loadThreads()` promise resolving AFTER // the welcome-lock unlock transition creates a fresh thread. Without // this, the stale `.then(...)` would re-select the old welcome thread // and clobber the auto-created one (#883 CodeRabbit feedback). - const skipInitialThreadSelectionRef = useRef(false); + // const skipInitialThreadSelectionRef = useRef(false); const [showSidebar, setShowSidebar] = useState(true); const [inputValue, setInputValue] = useState(''); @@ -234,18 +242,22 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { void dispatch(loadThreads()) .unwrap() .then(data => { - if (cancelled || skipInitialThreadSelectionRef.current) return; + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough + // if (cancelled || skipInitialThreadSelectionRef.current) return; + if (cancelled) return; + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // Always prefer the welcome thread during lockdown regardless of // whether the server list is empty or not. Without this guard the // stale `.then` could select a pre-existing thread from a prior // session and pull the user out of the welcome conversation. - const snapForSelect = getCoreStateSnapshot().snapshot; + // const snapForSelect = getCoreStateSnapshot().snapshot; + // const threadStateForSelect = store.getState().thread; + // if (isWelcomeLocked(snapForSelect) && threadStateForSelect.welcomeThreadId) { + // dispatch(setSelectedThread(threadStateForSelect.welcomeThreadId)); + // void dispatch(loadThreadMessages(threadStateForSelect.welcomeThreadId)); + // return; + // } const threadStateForSelect = store.getState().thread; - if (isWelcomeLocked(snapForSelect) && threadStateForSelect.welcomeThreadId) { - dispatch(setSelectedThread(threadStateForSelect.welcomeThreadId)); - void dispatch(loadThreadMessages(threadStateForSelect.welcomeThreadId)); - return; - } if (data.threads.length > 0) { // Prefer the thread the user was last viewing (persisted across // reloads via redux-persist on the `thread` slice). Only fall @@ -275,27 +287,28 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { } }, [selectedThreadId, dispatch]); + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // Welcome lockdown unlock (#883) — when `chatOnboardingCompleted` // transitions from `false` → `true` (the welcome agent just called // `complete_onboarding(action: "complete")`), open a fresh thread so // the user starts their first "real" conversation with the orchestrator // instead of continuing the welcome thread. Ref-tracked one-shot so // the 2s snapshot poll cannot re-fire this. - useEffect(() => { - const prev = previousChatOnboardingCompletedRef.current; - previousChatOnboardingCompletedRef.current = chatOnboardingCompleted; - if (prev === false && chatOnboardingCompleted === true) { - // Signal the mount-time `loadThreads()` promise to bail if it is - // still pending — otherwise its stale resolution would overwrite - // our freshly created thread selection. - skipInitialThreadSelectionRef.current = true; - console.debug('[welcome-lock] chat onboarding completed — opening new thread'); - void handleCreateNewThread(); - } - // handleCreateNewThread is stable for the component lifetime (only - // uses `dispatch`); the ref guards against duplicate fires. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chatOnboardingCompleted]); + // useEffect(() => { + // const prev = previousChatOnboardingCompletedRef.current; + // previousChatOnboardingCompletedRef.current = chatOnboardingCompleted; + // if (prev === false && chatOnboardingCompleted === true) { + // // Signal the mount-time `loadThreads()` promise to bail if it is + // // still pending — otherwise its stale resolution would overwrite + // // our freshly created thread selection. + // skipInitialThreadSelectionRef.current = true; + // console.debug('[welcome-lock] chat onboarding completed — opening new thread'); + // void handleCreateNewThread(); + // } + // // handleCreateNewThread is stable for the component lifetime (only + // // uses `dispatch`); the ref guards against duplicate fires. + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [chatOnboardingCompleted]); const location = useLocation(); const { containerRef: messagesContainerRef, endRef: messagesEndRef } = useStickToBottom( @@ -467,13 +480,14 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { const handleSlashCommand = (command: string): boolean => { const cmd = command.toLowerCase(); if (cmd === '/new' || cmd === '/clear') { + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // Welcome lockdown (#883) — consume the command so it is not sent // to the agent, but skip thread creation/reset so the user cannot // escape the welcome conversation via `/new` or `/clear`. - if (welcomeLocked) { - setInputValue(''); - return true; - } + // if (welcomeLocked) { + // setInputValue(''); + // return true; + // } setInputValue(''); void handleCreateNewThread(); return true; @@ -824,9 +838,10 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { // Blocks all composer interaction while a turn is in-flight, the // proactive welcome opener is pending, or Rust chat is unavailable. // isSending: the *selected* thread is in-flight (drives selected-thread UI only). + // [#1123] welcomePending removed — welcome-agent onboarding replaced by Joyride walkthrough const composerInteractionBlocked = isComposerInteractionBlocked({ activeThreadId, - welcomePending, + welcomePending: false, rustChat, }); const isSending = Boolean( @@ -842,17 +857,19 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { if (selectedLabel === 'all') return true; return t.labels?.includes(selectedLabel); }); - if (!welcomeLocked) return base; - // During welcome lockdown only the onboarding welcome thread should - // appear — not stray blank threads from races or proactive:* handling. - if (welcomeThreadId) { - return base.filter(t => t.id === welcomeThreadId); - } - // Fallback: welcomeThreadId not yet set but the server already returned the - // thread (e.g. hot-reload). Keep only onboarding-labelled threads so the - // welcome thread is visible rather than hidden behind the empty-state message. - return base.filter(t => (t.labels ?? []).includes(ONBOARDING_WELCOME_THREAD_LABEL)); - }, [threads, selectedLabel, welcomeLocked, welcomeThreadId]); + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough + // if (!welcomeLocked) return base; + // // During welcome lockdown only the onboarding welcome thread should + // // appear — not stray blank threads from races or proactive:* handling. + // if (welcomeThreadId) { + // return base.filter(t => t.id === welcomeThreadId); + // } + // // Fallback: welcomeThreadId not yet set but the server already returned the + // // thread (e.g. hot-reload). Keep only onboarding-labelled threads so the + // // welcome thread is visible rather than hidden behind the empty-state message. + // return base.filter(t => (t.labels ?? []).includes(ONBOARDING_WELCOME_THREAD_LABEL)); + return base; + }, [threads, selectedLabel]); const sortedThreads = useMemo(() => { return [...filteredThreads].sort( @@ -881,24 +898,26 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { }, [allLabels, selectedLabel]); const isSidebar = variant === 'sidebar'; + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough // During welcome lockdown keep the sidebar forced open so the user always // sees the single onboarding thread entry and cannot accidentally close the // panel via the toggle (leaving themselves with no thread list). - const effectiveShowSidebar = welcomeLocked ? true : showSidebar; + // const effectiveShowSidebar = welcomeLocked ? true : showSidebar; + const effectiveShowSidebar = showSidebar; // Stable title resolver used by both the sidebar thread list and the header. - // Returns "Onboarding" for the welcome thread while welcome-locked; falls back - // to the thread's server-side title or a placeholder. + // [#1123] welcome-lock title override removed — Joyride walkthrough replaced welcome-agent const resolveThreadDisplayTitle = (threadId: string | null): string => { if (!threadId) return 'Select a thread'; const t = threads.find(thr => thr.id === threadId); - if ( - welcomeLocked && - t?.id === welcomeThreadId && - (t?.labels ?? []).includes(ONBOARDING_WELCOME_THREAD_LABEL) - ) { - return 'Onboarding'; - } + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough + // if ( + // welcomeLocked && + // t?.id === welcomeThreadId && + // (t?.labels ?? []).includes(ONBOARDING_WELCOME_THREAD_LABEL) + // ) { + // return 'Onboarding'; + // } return t?.title ?? 'Select a thread'; }; @@ -917,32 +936,30 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {

Threads

- {!welcomeLocked ? ( - - ) : null} + {/* [#1123] welcomeLocked guard removed — always show new thread button */} + +
+ {/* [#1123] welcomeLocked guard removed — always show label filter */} +
+
- {!welcomeLocked ? ( -
- -
- ) : null}
{sortedThreads.length === 0 ? (

@@ -980,39 +997,38 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { }`}> {resolveThreadDisplayTitle(thread.id)}

- {!(welcomeLocked && thread.id === welcomeThreadId) ? ( - - ) : null} + {/* [#1123] welcomeLocked guard removed — always show delete button */} +
{/*
@@ -1060,19 +1076,16 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {

{resolveThreadDisplayTitle(selectedThreadId)}

- {!welcomeLocked ? ( - <> - - - - ) : ( + {/* [#1123] welcomeLocked guard removed — always show token usage + new thread button */} + <> - )} + +
)}
@@ -1381,20 +1394,21 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => { )}
- ) : welcomeThreadId && selectedThreadId === welcomeThreadId ? ( - // Welcome thread, no messages yet — the proactive welcome agent - // is running in the background. Show a friendly loader until - // the first agent message lands (which flips us into the - // `hasVisibleMessages` branch above). -
-
- - - -
- -
) : ( + // [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough + // ) : welcomeThreadId && selectedThreadId === welcomeThreadId ? ( + // // Welcome thread, no messages yet — the proactive welcome agent + // // is running in the background. Show a friendly loader until + // // the first agent message lands (which flips us into the + // // `hasVisibleMessages` branch above). + //
+ //
+ // + // + // + //
+ // + //

No messages yet

@@ -1402,131 +1416,130 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
- {!welcomeLocked && !welcomePending && ( - <> - {isNearLimit && - !isAtLimit && - isFreeTier && - shouldShowBanner('conversations-warning', 24 * 60 * 60 * 1000) && ( -
- { - void openUrl(BILLING_DASHBOARD_URL); - }} - dismissible - onDismiss={() => dismissBanner('conversations-warning')} - /> -
- )} - {teamUsage && (shouldShowBudgetCompletedMessage || isRateLimited) && ( -
-
- - - -

- {shouldShowBudgetCompletedMessage - ? teamUsage.cycleBudgetUsd > 0 - ? `You've hit your weekly limit.${teamUsage.cycleEndsAt ? ` Resets ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} Top up to continue.` - : 'Your included budget is complete. Add credits or upgrade to continue.' - : `10-hour rate limit reached.${teamUsage.fiveHourResetsAt ? ` Resets ${formatResetTime(teamUsage.fiveHourResetsAt)}.` : ''}`} -

-
- {shouldShowBudgetCompletedMessage && ( - - )} + {/* [#1123] welcomeLocked and welcomePending guards removed — Joyride walkthrough replaced welcome-agent */} + <> + {isNearLimit && + !isAtLimit && + isFreeTier && + shouldShowBanner('conversations-warning', 24 * 60 * 60 * 1000) && ( +
+ { + void openUrl(BILLING_DASHBOARD_URL); + }} + dismissible + onDismiss={() => dismissBanner('conversations-warning')} + />
)} + {teamUsage && (shouldShowBudgetCompletedMessage || isRateLimited) && ( +
+
+ + + +

+ {shouldShowBudgetCompletedMessage + ? teamUsage.cycleBudgetUsd > 0 + ? `You've hit your weekly limit.${teamUsage.cycleEndsAt ? ` Resets ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} Top up to continue.` + : 'Your included budget is complete. Add credits or upgrade to continue.' + : `10-hour rate limit reached.${teamUsage.fiveHourResetsAt ? ` Resets ${formatResetTime(teamUsage.fiveHourResetsAt)}.` : ''}`} +

+
+ {shouldShowBudgetCompletedMessage && ( + + )} +
+ )} - {/* Quota / usage pills — hidden during welcome lockdown so the + {/* Quota / usage pills — hidden during welcome lockdown so the onboarding chat doesn't surface billing affordances. */} -
- {(isLoadingBudget || teamUsage) && ( -
- {teamUsage ? ( -
- {!teamUsage.bypassCycleLimit && ( - 0 - ? Math.min(1, teamUsage.cycleLimit5hr / teamUsage.fiveHourCapUsd) - : 0 - } - /> - )} +
+ {(isLoadingBudget || teamUsage) && ( +
+ {teamUsage ? ( +
+ {!teamUsage.bypassCycleLimit && ( 0 - ? Math.min( - 1, - (teamUsage.cycleBudgetUsd - teamUsage.remainingUsd) / - teamUsage.cycleBudgetUsd - ) + teamUsage.fiveHourCapUsd > 0 + ? Math.min(1, teamUsage.cycleLimit5hr / teamUsage.fiveHourCapUsd) : 0 } /> -
- ) : ( - loading… - )} - {teamUsage && ( -
-
- {!teamUsage.bypassCycleLimit && ( -
- 5-hour limit - - ${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $ - {(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)} - {teamUsage.fiveHourResetsAt && ( - - — resets {formatResetTime(teamUsage.fiveHourResetsAt)} - - )} - -
- )} + )} + 0 + ? Math.min( + 1, + (teamUsage.cycleBudgetUsd - teamUsage.remainingUsd) / + teamUsage.cycleBudgetUsd + ) + : 0 + } + /> +
+ ) : ( + loading… + )} + {teamUsage && ( +
+
+ {!teamUsage.bypassCycleLimit && (
- Weekly limit + 5-hour limit - ${(teamUsage.remainingUsd ?? 0).toFixed(2)} / $ - {(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} left - {teamUsage.cycleEndsAt && ( + ${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $ + {(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)} + {teamUsage.fiveHourResetsAt && ( - — resets {formatResetTime(teamUsage.cycleEndsAt)} + — resets {formatResetTime(teamUsage.fiveHourResetsAt)} )}
+ )} +
+ Weekly limit + + ${(teamUsage.remainingUsd ?? 0).toFixed(2)} / $ + {(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} left + {teamUsage.cycleEndsAt && ( + + — resets {formatResetTime(teamUsage.cycleEndsAt)} + + )} +
- )} -
- )} -
- - )} +
+ )} +
+ )} +
+ {sendAdvisory && (
diff --git a/app/src/pages/Home.tsx b/app/src/pages/Home.tsx index 5a21b95a6..c8cfcde60 100644 --- a/app/src/pages/Home.tsx +++ b/app/src/pages/Home.tsx @@ -145,8 +145,10 @@ const Home = () => { {showPromoBanner && } - {/* Main card */} -
+ {/* Main card — data-walkthrough target for step 1 */} +
{/* Header row: logo + version + settings */}
v{APP_VERSION} @@ -170,8 +172,9 @@ const Home = () => { "Connecting" / "Disconnected". */}

{statusCopy}

- {/* CTA button */} + {/* CTA button — data-walkthrough target for step 2 */}