diff --git a/app/ui_layer/browser/frontend/package-lock.json b/app/ui_layer/browser/frontend/package-lock.json index b341e4dd..7ad29ffb 100644 --- a/app/ui_layer/browser/frontend/package-lock.json +++ b/app/ui_layer/browser/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@reduxjs/toolkit": "^2.12.0", "@tanstack/react-virtual": "^3.13.23", + "driver.js": "^1.8.0", "lucide-react": "^0.344.0", "prism-react-renderer": "^2.4.1", "react": "^18.2.0", @@ -2190,6 +2191,12 @@ "node": ">=6.0.0" } }, + "node_modules/driver.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.8.0.tgz", + "integrity": "sha512-+8/IO7h1v14IzWh2GP60N7T3PFZweXwdn5e5POuxRSBoCYUojsBxzqawPeXh3YZIibRy7EehYNEyxe7slwwtdg==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.321", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", diff --git a/app/ui_layer/browser/frontend/package.json b/app/ui_layer/browser/frontend/package.json index 08cf5c29..5a441431 100644 --- a/app/ui_layer/browser/frontend/package.json +++ b/app/ui_layer/browser/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@reduxjs/toolkit": "^2.12.0", "@tanstack/react-virtual": "^3.13.23", + "driver.js": "^1.8.0", "lucide-react": "^0.344.0", "prism-react-renderer": "^2.4.1", "react": "^18.2.0", diff --git a/app/ui_layer/browser/frontend/src/App.tsx b/app/ui_layer/browser/frontend/src/App.tsx index e09ee5aa..02ca4621 100644 --- a/app/ui_layer/browser/frontend/src/App.tsx +++ b/app/ui_layer/browser/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { SettingsPage } from './pages/Settings' import { OnboardingPage } from './pages/Onboarding' import { LivingUIPage } from './pages/LivingUI' import { useWebSocket } from './contexts/WebSocketContext' +import { TourProvider } from './tour' import { LoadingMascot } from '@mascot' // Forces LivingUIPage to remount per-project so useState initializers @@ -74,19 +75,24 @@ function App() { return } + // TourProvider wraps the ready app (past hard onboarding), so the first-run + // walkthrough can never collide with the onboarding wizard. It sits inside + // the router, so the tour can navigate between pages. return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) } diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx index 1eee0307..4e729b72 100644 --- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx +++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx @@ -30,6 +30,7 @@ import { import { selectSessionActivity } from '../../store/selectors/activity' import { selectSessionBusy, selectSessionRunState } from '../../store/selectors/agent' import type { ActionItem, ChatMessage } from '../../types' +import { tourAnchorProps } from '../../tour' import styles from './Chat.module.css' // Pending attachment type @@ -1359,6 +1360,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) { onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} + {...tourAnchorProps('chat-composer')} > {replyTarget && (
@@ -1443,6 +1445,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) { title="Attach and tools" aria-label="Attach and tools" aria-expanded={plusOpen} + {...tourAnchorProps('chat-plus')} > {enhancing ? diff --git a/app/ui_layer/browser/frontend/src/components/layout/Layout.tsx b/app/ui_layer/browser/frontend/src/components/layout/Layout.tsx index 6d69fb28..20f57fa1 100644 --- a/app/ui_layer/browser/frontend/src/components/layout/Layout.tsx +++ b/app/ui_layer/browser/frontend/src/components/layout/Layout.tsx @@ -3,8 +3,13 @@ import { useLocation } from 'react-router-dom' import { Menu, X } from 'lucide-react' import { NavBar } from './NavBar' import { useFullscreen } from '../../contexts/FullscreenContext' +import { useTourEnvAction } from '../../tour' import styles from './Layout.module.css' +// Matches the mobile breakpoint in Layout.module.css, where the sidebar +// becomes an off-canvas drawer. +const MOBILE_QUERY = '(max-width: 768px)' + interface LayoutProps { children: ReactNode } @@ -54,6 +59,16 @@ export function Layout({ children }: LayoutProps) { }) } + // Let the guided tour reveal the sidebar before highlighting a nav item. + // Expanding it in memory only (not persisting COLLAPSED_KEY) keeps the user's + // saved preference intact for their next session. + useTourEnvAction('ensureSidebarVisible', () => { + setCollapsed(false) + if (window.matchMedia(MOBILE_QUERY).matches) { + setMobileOpen(true) + } + }) + return (
{!isFullscreen && ( diff --git a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx index ac335022..29f04c30 100644 --- a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx +++ b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx @@ -23,6 +23,7 @@ import { } from 'lucide-react' import { useWebSocket } from '../../contexts/WebSocketContext' import { useTheme } from '../../contexts/ThemeContext' +import { tourAnchorProps, useTourEnvAction, type TourAnchorId } from '../../tour' import { useSkillCreator } from '../../hooks' import { CreateLivingUIModal } from '../ui/CreateLivingUIModal' import { SkillCreatorModal } from '../ui/SkillCreatorModal' @@ -39,6 +40,7 @@ interface NavItem { label: string icon: React.ReactNode path: string + tourAnchor?: TourAnchorId } // Sidebar title with a typewriter reveal: when the auto-title replaces the @@ -84,8 +86,8 @@ function AnimatedSessionTitle({ title }: { title: string }) { } const utilityNavItems: NavItem[] = [ - { id: 'dashboard', label: 'Dashboard', icon: , path: '/dashboard' }, - { id: 'workspace', label: 'Workspace', icon: , path: '/workspace' }, + { id: 'dashboard', label: 'Dashboard', icon: , path: '/dashboard', tourAnchor: 'nav-dashboard' }, + { id: 'workspace', label: 'Workspace', icon: , path: '/workspace', tourAnchor: 'nav-workspace' }, ] const settingsItem: NavItem = { id: 'settings', label: 'Settings', icon: , path: '/settings' } @@ -322,6 +324,19 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) { navigate('/session/new') } + // Let the guided tour open a fresh New Chat via the exact same action as the + // button, so the chat is demonstrated on a clean draft, not the Main session. + useTourEnvAction('openNewChat', startNewChat) + + // Let the tour expand the Chats group so the pinned Main row is on screen + // before it highlights it. + useTourEnvAction('ensureChatsExpanded', () => setChatsExpanded(true)) + + // Let the tour open and close the "Add Living UI" modal while it walks the + // creation methods. + useTourEnvAction('openLivingUIModal', () => setShowCreateModal(true)) + useTourEnvAction('closeLivingUIModal', () => setShowCreateModal(false)) + // Close any open context menu when clicking anywhere else. useEffect(() => { if (!menu) return @@ -455,6 +470,7 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) { key={session.id} className={`${styles.sessionRow} ${active ? styles.sessionRowActive : ''} ${opts.isMain ? styles.sessionRowMain : ''}`} title={opts.isMain ? 'Main' : session.title} + {...(opts.isMain ? tourAnchorProps('nav-main-session') : {})} > {renaming ? ( New Chat @@ -594,6 +611,7 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) { className={`${styles.navItem} ${isActive(item.path) ? styles.active : ''}`} onClick={() => navigate(item.path)} title={item.label} + {...(item.tourAnchor ? tourAnchorProps(item.tourAnchor) : {})} > {item.icon} {item.label} @@ -631,7 +649,7 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) { ) : ( <> {/* Living UI group */} -
+
+ + {/* Replay the first-run walkthrough. Hidden at the smallest widget size + so it never crowds the mascot + Learn More stack. */} + {isEnlarged && ( + + )}
) } diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx index ed8ff167..23db3842 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx @@ -13,6 +13,7 @@ import { Trash2, Package, PackageOpen, + Compass, } from 'lucide-react' import { Button, @@ -26,6 +27,7 @@ import { } from '../../components/ui' import { useTheme } from '../../contexts/ThemeContext' import { useWebSocket } from '../../contexts/WebSocketContext' +import { useTour } from '../../tour' import { useConfirmModal } from '../../hooks' import styles from './SettingsPage.module.css' import { useSettingsWebSocket } from './useSettingsWebSocket' @@ -73,6 +75,7 @@ function getInitialAgentName(): string { export function GeneralSettings() { const { send, onMessage, isConnected } = useSettingsWebSocket() const { agentProfilePictureUrl, agentProfilePictureHasCustom } = useWebSocket() + const { startTour } = useTour() const version = useAppSelector(selectVersion) const dispatch = useAppDispatch() const { theme: globalTheme, setTheme: setGlobalTheme } = useTheme() @@ -713,6 +716,22 @@ export function GeneralSettings() {
+ +
+ +
+ +
+ + Replay the guided walkthrough of the CraftBot interface. + +
diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx index 2d0aebb5..7da5bd3e 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx @@ -1,6 +1,14 @@ import { useState } from 'react' import styles from './SettingsPage.module.css' +import { tourAnchorProps, useTourEnvAction, type TourAnchorId } from '../../tour' import { SettingsCategory, categories } from './types' + +// Settings tabs the guided tour highlights individually. +const TAB_TOUR_ANCHORS: Partial> = { + proactive: 'settings-proactive', + skills: 'settings-skills', + integrations: 'settings-integrations', +} import { GeneralSettings } from './GeneralSettings' import { ProactiveSettings } from './ProactiveSettings' import { MemorySettings } from './MemorySettings' @@ -13,6 +21,14 @@ import { LivingUISettings } from './LivingUISettings' export function SettingsPage() { const [activeCategory, setActiveCategory] = useState('general') + // Let the guided tour open a specific tab so its panel is shown, not just its + // rail button highlighted. + useTourEnvAction('openSettingsTab', (arg) => { + if (arg && categories.some(c => c.id === arg)) { + setActiveCategory(arg as SettingsCategory) + } + }) + const renderSettingsContent = () => { switch (activeCategory) { case 'general': @@ -41,17 +57,21 @@ export function SettingsPage() { {/* Category rail — sits flush against the content, no separate background/border. Compact icon + label, no description/chevron. */} diff --git a/app/ui_layer/browser/frontend/src/tour/TourProvider.tsx b/app/ui_layer/browser/frontend/src/tour/TourProvider.tsx new file mode 100644 index 00000000..8df69dee --- /dev/null +++ b/app/ui_layer/browser/frontend/src/tour/TourProvider.tsx @@ -0,0 +1,138 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { TourController, type TourEnvironment } from './controller' +import type { TourEnvActionId, TourId } from './types' +import { TOURS } from './tours' +import { hasCompletedTour, resetTourCompletion } from './storage' +import 'driver.js/dist/driver.css' +import './tour.css' + +interface TourContextValue { + /** Start a tour now. `restart: true` clears its completed flag first. */ + startTour: (id: TourId, opts?: { restart?: boolean }) => void + /** + * Register a component capability the tour can invoke by name (e.g. a layout + * expanding its sidebar). Returns an unregister function. Prefer the + * `useTourEnvAction` hook, which wires cleanup automatically. + */ + registerEnvAction: (id: TourEnvActionId, fn: (arg?: string) => void) => () => void + isActive: boolean +} + +const TourContext = createContext(null) + +// Delay before a first-run tour auto-starts, letting the initial layout, +// fonts, and websocket-driven content settle so anchors are in place. +const AUTOSTART_DELAY_MS = 800 + +interface TourProviderProps { + children: ReactNode + /** + * Gate for the one-time auto-start. The provider only auto-starts the core + * tour when true — pass it once the app is past hard onboarding and ready. + */ + autoStartEnabled?: boolean +} + +export function TourProvider({ children, autoStartEnabled = false }: TourProviderProps) { + const navigate = useNavigate() + const location = useLocation() + + // Latest pathname, readable synchronously from controller callbacks. + const pathnameRef = useRef(location.pathname) + pathnameRef.current = location.pathname + + // Component capabilities the tour can invoke (see registerEnvAction). + const envActionsRef = useRef void>>(new Map()) + + const controllerRef = useRef(null) + const [isActive, setIsActive] = useState(false) + const autoStartedRef = useRef(false) + + const registerEnvAction = useCallback((id: TourEnvActionId, fn: (arg?: string) => void) => { + envActionsRef.current.set(id, fn) + return () => { + // Only remove if still the same fn, so a newer registration isn't clobbered. + if (envActionsRef.current.get(id) === fn) { + envActionsRef.current.delete(id) + } + } + }, []) + + const environment = useMemo(() => ({ + navigate: (path: string) => navigate(path), + getPathname: () => pathnameRef.current, + runEnvAction: (id: TourEnvActionId, arg?: string) => { + envActionsRef.current.get(id)?.(arg) + }, + }), [navigate]) + + const startTour = useCallback((id: TourId, opts?: { restart?: boolean }) => { + const def = TOURS[id] + if (!def) return + if (controllerRef.current?.isActive()) return // never run two tours at once + if (opts?.restart) resetTourCompletion(id) + const controller = new TourController(def, environment, () => { + controllerRef.current = null + setIsActive(false) + }) + controllerRef.current = controller + setIsActive(true) + void controller.start() + }, [environment]) + + // One-time auto-start of the core tour for first-time users. autoStartedRef + // is set only when the timer actually fires, so StrictMode's mount/cleanup/ + // remount in dev reschedules cleanly instead of cancelling itself. + useEffect(() => { + if (!autoStartEnabled || autoStartedRef.current) return + const def = TOURS.core + if (!def.autoStart || hasCompletedTour('core')) return + const timer = window.setTimeout(() => { + autoStartedRef.current = true + startTour('core') + }, AUTOSTART_DELAY_MS) + return () => window.clearTimeout(timer) + }, [autoStartEnabled, startTour]) + + // Tear down an in-flight tour if the provider unmounts. + useEffect(() => () => { + controllerRef.current?.destroy() + controllerRef.current = null + }, []) + + const value = useMemo(() => ({ + startTour, + registerEnvAction, + isActive, + }), [startTour, registerEnvAction, isActive]) + + return {children} +} + +export function useTour(): TourContextValue { + const ctx = useContext(TourContext) + if (!ctx) throw new Error('useTour must be used within a TourProvider') + return ctx +} + +/** + * Register a component capability the tour can invoke by name (e.g. a layout + * expanding its sidebar so a nav item is visible). The latest `fn` is always + * used, and it unregisters automatically on unmount. + */ +export function useTourEnvAction(id: TourEnvActionId, fn: (arg?: string) => void): void { + const { registerEnvAction } = useTour() + const fnRef = useRef(fn) + fnRef.current = fn + useEffect(() => registerEnvAction(id, (arg) => fnRef.current(arg)), [id, registerEnvAction]) +} diff --git a/app/ui_layer/browser/frontend/src/tour/anchors.ts b/app/ui_layer/browser/frontend/src/tour/anchors.ts new file mode 100644 index 00000000..8fc80b63 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/tour/anchors.ts @@ -0,0 +1,43 @@ +// Stable DOM anchors for the guided product tour. +// +// Component CSS is authored with CSS Modules, whose class names are hashed at +// build time and therefore useless as tour targets. Instead, tour targets are +// explicit `data-tour=""` attributes. The same typed id is referenced by +// the component (via `tourAnchorProps`) and by the step definition (via +// `tourSelector`), so renaming an anchor is a compile error rather than a +// silently broken step. + +export type TourAnchorId = + | 'chat-composer' + | 'chat-plus' + | 'nav-new-chat' + | 'nav-chats' + | 'nav-main-session' + | 'nav-living-ui' + // Tabs inside the "Add Living UI" modal. + | 'livingui-tab-marketplace' + | 'livingui-tab-custom' + | 'livingui-tab-import' + | 'nav-dashboard' + | 'nav-workspace' + // On-page anchors for the Settings page: the whole category rail, plus the + // individual tabs the tour calls out. + | 'settings-categories' + | 'settings-proactive' + | 'settings-skills' + | 'settings-integrations' + +const ATTR = 'data-tour' as const + +/** + * Props to spread onto the JSX element a tour step should highlight: + *