From 731ffe2215067be5146d549f12724d9bbb2381d7 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:11:52 +0530 Subject: [PATCH] tour part 1 --- apps/x/apps/renderer/src/App.tsx | 47 +- .../components/chat-input-with-mentions.tsx | 2 +- .../renderer/src/components/product-tour.tsx | 401 ++++++++++++++++++ .../src/components/sidebar-content.tsx | 23 +- 4 files changed, 468 insertions(+), 5 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/product-tour.tsx diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 977c28fa..9714f621 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -120,6 +120,7 @@ import { toast } from "sonner" import { useVoiceMode } from '@/hooks/useVoiceMode' import { useVoiceTTS } from '@/hooks/useVoiceTTS' import { TalkingHeadOverlay } from '@/components/talking-head' +import { ProductTour, type TourNavTarget } from '@/components/product-tour' import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' import * as analytics from '@/lib/analytics' @@ -973,6 +974,7 @@ function App() { const [ttsMode, setTtsMode] = useState<'summary' | 'full'>('summary') const ttsModeRef = useRef<'summary' | 'full'>('summary') const [ttsAvatarEnabled, setTtsAvatarEnabled] = useState(false) + const [tourActive, setTourActive] = useState(false) const [isRecording, setIsRecording] = useState(false) const voiceTextBufferRef = useRef('') const spokenIndexRef = useRef(0) @@ -4941,6 +4943,33 @@ function App() { }, }), [tree, selectedPath, isGraphOpen, selectedBackgroundTask, workspaceRoot, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab, removeEditorCacheForPath]) + // Drives the mascot product tour through the app's main sections + const handleTourNavigate = useCallback((target: TourNavTarget) => { + switch (target) { + case 'home': + void navigateToView({ type: 'home' }) + break + case 'email': + openEmailView() + break + case 'meetings': + openMeetingsView() + break + case 'code': + openCodeView() + break + case 'knowledge': + knowledgeActions.openKnowledgeView() + break + case 'agents': + openBgTasksView() + break + case 'workspaces': + knowledgeActions.openWorkspaceAt() + break + } + }, [navigateToView, openEmailView, openMeetingsView, openCodeView, knowledgeActions, openBgTasksView]) + // Handler for when a voice note is created/updated const handleVoiceNoteCreated = useCallback(async (notePath: string) => { // Refresh the tree to show the new file/folder @@ -5600,6 +5629,7 @@ function App() { onNewChat={handleNewChatTab} onToggleBrowser={handleToggleBrowser} onVoiceNoteCreated={handleVoiceNoteCreated} + onStartTour={() => setTourActive(true)} meetingRecordingState={meetingTranscription.state} recordingMeetingSource={recordingMeetingSource} onToggleMeetingRecording={() => { void handleToggleMeeting() }} @@ -6423,14 +6453,27 @@ function App() { onComposioConnected={handleComposioConnected} /> )} - {/* Talking head hovers over the active view while avatar voice mode is on */} - {ttsAvatarEnabled && ( + {/* Talking head hovers over the active view while avatar voice mode is + on (hidden during the tour, which shows its own mascot) */} + {ttsAvatarEnabled && !tourActive && ( )} + {/* Mascot-guided product tour */} + {tourActive && ( + setTourActive(false)} + onNavigate={handleTourNavigate} + ttsAvailable={ttsAvailable} + ttsState={tts.state} + speak={tts.speak} + cancelSpeech={tts.cancel} + getLevel={tts.getLevel} + /> + )} {/* Rendered last so its no-drag region paints over the sidebar drag region */} + {attachments.length > 0 && ( {attachments.map((attachment) => { diff --git a/apps/x/apps/renderer/src/components/product-tour.tsx b/apps/x/apps/renderer/src/components/product-tour.tsx new file mode 100644 index 00000000..b2c8dea2 --- /dev/null +++ b/apps/x/apps/renderer/src/components/product-tour.tsx @@ -0,0 +1,401 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { TalkingHead } from '@/components/talking-head' +import type { TTSState } from '@/hooks/useVoiceTTS' +import { cn } from '@/lib/utils' + +export type TourNavTarget = + | 'home' + | 'email' + | 'meetings' + | 'code' + | 'knowledge' + | 'agents' + | 'workspaces' + +type TourStep = { + id: string + /** Matches a [data-tour-id] element. Steps whose target is absent are skipped. */ + targetId?: string + /** View to open when the step starts, via App's navigation handlers. */ + navigate?: TourNavTarget + title: string + text: string +} + +const TOUR_STEPS: TourStep[] = [ + { + id: 'welcome', + title: 'Ahoy! 👋', + text: "I'm the Rowboat mascot. Let me row you around the app — it only takes a minute. Use the Next button or your arrow keys.", + }, + { + id: 'home', + targetId: 'nav-home', + navigate: 'home', + title: 'Home', + text: 'Home is your landing spot — a quick overview of what needs your attention to get you back into the flow.', + }, + { + id: 'email', + targetId: 'nav-email', + navigate: 'email', + title: 'Email', + text: 'Read and triage your inbox right here. Rowboat can summarize threads, label messages, and help you draft replies.', + }, + { + id: 'meetings', + targetId: 'nav-meetings', + navigate: 'meetings', + title: 'Meetings', + text: 'Record or join meetings, and get transcripts and notes automatically — prep briefs show up before your calls, too.', + }, + { + id: 'code', + targetId: 'nav-code', + navigate: 'code', + title: 'Code', + text: 'The Code section runs coding agents on your projects — point one at a folder and drive it from a chat.', + }, + { + id: 'knowledge', + targetId: 'nav-knowledge', + navigate: 'knowledge', + title: 'Brain', + text: "Brain is your knowledge base — notes, files, and everything Rowboat learns for you, all connected and searchable.", + }, + { + id: 'agents', + targetId: 'nav-agents', + navigate: 'agents', + title: 'Background agents', + text: 'Background agents work on schedules — they keep your Brain fresh and take care of recurring tasks while you row elsewhere.', + }, + { + id: 'workspaces', + targetId: 'nav-workspaces', + navigate: 'workspaces', + title: 'Workspaces', + text: 'Workspaces hold your project folders and files, so related work stays docked together.', + }, + { + id: 'chats', + targetId: 'nav-chats', + title: 'Chats', + text: 'Your recent conversations live here — pick any of them back up right where you left off.', + }, + { + id: 'composer', + targetId: 'chat-composer', + title: 'Talk to Rowboat', + text: 'And this is where we talk! Type, dictate with the mic, or turn on voice output — tap my face button and I’ll read replies out loud myself.', + }, + { + id: 'done', + title: "That's the lay of the water! 🚣", + text: 'You can take this tour again anytime from the bottom of the sidebar. Happy rowing!', + }, +] + +const MASCOT_SIZE = 120 +const VIEWPORT_MARGIN = 16 +const BUBBLE_WIDTH = 288 +const TARGET_RESOLVE_TIMEOUT_MS = 1500 + +type TourLayout = { + mascot: { x: number; y: number } + ring: { left: number; top: number; width: number; height: number } | null + bubbleSide: 'left' | 'right' +} + +function clamp(v: number, min: number, max: number): number { + return Math.max(min, Math.min(max, v)) +} + +// A data-tour-id can legitimately appear on several elements (e.g. the chat +// composer renders in both the full-screen chat and the side pane) — pick the +// one that is actually laid out. +function findTourTarget(targetId: string): DOMRect | null { + const nodes = document.querySelectorAll(`[data-tour-id="${targetId}"]`) + for (const el of nodes) { + const rect = el.getBoundingClientRect() + if (rect.width > 0 && rect.height > 0) return rect + } + return null +} + +function layoutForCenter(): TourLayout { + return { + mascot: { + x: window.innerWidth / 2 - MASCOT_SIZE / 2 - BUBBLE_WIDTH / 2, + y: window.innerHeight / 2 - MASCOT_SIZE / 2, + }, + ring: null, + bubbleSide: 'right', + } +} + +function layoutForTarget(rect: DOMRect): TourLayout { + let x: number + let y: number + const fitsRight = rect.right + MASCOT_SIZE + BUBBLE_WIDTH + VIEWPORT_MARGIN * 3 < window.innerWidth + if (fitsRight) { + x = rect.right + 20 + y = rect.top + rect.height / 2 - MASCOT_SIZE / 2 + } else if (rect.top > MASCOT_SIZE + VIEWPORT_MARGIN * 2) { + x = rect.left + rect.width / 2 - MASCOT_SIZE / 2 + y = rect.top - MASCOT_SIZE - 20 + } else { + x = rect.left - MASCOT_SIZE - 20 + y = rect.top + rect.height / 2 - MASCOT_SIZE / 2 + } + x = clamp(x, VIEWPORT_MARGIN, window.innerWidth - MASCOT_SIZE - VIEWPORT_MARGIN) + y = clamp(y, VIEWPORT_MARGIN, window.innerHeight - MASCOT_SIZE - VIEWPORT_MARGIN) + return { + mascot: { x, y }, + ring: { + left: rect.left - 6, + top: rect.top - 6, + width: rect.width + 12, + height: rect.height + 12, + }, + bubbleSide: x + MASCOT_SIZE / 2 < window.innerWidth / 2 ? 'right' : 'left', + } +} + +type ProductTourProps = { + onClose: () => void + onNavigate: (target: TourNavTarget) => void + ttsAvailable: boolean + ttsState: TTSState + speak: (text: string) => void + cancelSpeech: () => void + getLevel: () => number +} + +/** + * Mascot-guided walkthrough. The talking head glides between the app's + * [data-tour-id] anchors, opening each section as it goes, with a speech + * bubble (and spoken narration when TTS is configured) describing each stop. + */ +export function ProductTour({ + onClose, + onNavigate, + ttsAvailable, + ttsState, + speak, + cancelSpeech, + getLevel, +}: ProductTourProps) { + const [stepIndex, setStepIndex] = useState(0) + // Start below the bottom-right corner so the first layout glides the mascot in + const [layout, setLayout] = useState(() => ({ + mascot: { x: window.innerWidth - MASCOT_SIZE - 32, y: window.innerHeight + 40 }, + ring: null, + bubbleSide: 'left', + })) + const [resizeNonce, setResizeNonce] = useState(0) + const [flipped, setFlipped] = useState(false) + + // Direction of travel through the steps, used when a step's target is + // missing (e.g. Code mode disabled) and the tour has to skip over it. + const directionRef = useRef(1) + const enteredStepRef = useRef(-1) + const lastXRef = useRef(null) + const stepIndexRef = useRef(stepIndex) + + const onCloseRef = useRef(onClose) + const onNavigateRef = useRef(onNavigate) + const speakRef = useRef(speak) + const cancelSpeechRef = useRef(cancelSpeech) + const ttsAvailableRef = useRef(ttsAvailable) + + // Keep latest callbacks/state in refs so the step effect and key handlers + // stay stable. Runs before the step effect below (effect order = call order). + useEffect(() => { + stepIndexRef.current = stepIndex + onCloseRef.current = onClose + onNavigateRef.current = onNavigate + speakRef.current = speak + cancelSpeechRef.current = cancelSpeech + ttsAvailableRef.current = ttsAvailable + }) + + const finish = useCallback(() => { + cancelSpeechRef.current() + onCloseRef.current() + }, []) + + const goTo = useCallback((index: number, direction: 1 | -1) => { + directionRef.current = direction + if (index < 0) return + if (index >= TOUR_STEPS.length) { + finish() + return + } + setStepIndex(index) + }, [finish]) + + // Stop any in-flight narration when the tour unmounts + useEffect(() => () => cancelSpeechRef.current(), []) + + useEffect(() => { + const handleResize = () => setResizeNonce((n) => n + 1) + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + + // Enter the current step: navigate, wait for its anchor to exist, position + // the mascot, and narrate. Re-runs on resize purely to recompute positions. + useEffect(() => { + const step = TOUR_STEPS[stepIndex] + const entering = enteredStepRef.current !== stepIndex + let cancelled = false + + if (entering && step.navigate) { + onNavigateRef.current(step.navigate) + } + + const applyLayout = (next: TourLayout) => { + setLayout(next) + setFlipped(lastXRef.current != null && next.mascot.x < lastXRef.current) + lastXRef.current = next.mascot.x + if (entering) { + enteredStepRef.current = stepIndex + cancelSpeechRef.current() + if (ttsAvailableRef.current) { + speakRef.current(step.text) + } + } + } + + if (!step.targetId) { + applyLayout(layoutForCenter()) + return + } + + const startedAt = performance.now() + const attempt = () => { + if (cancelled) return + const rect = findTourTarget(step.targetId!) + if (rect) { + applyLayout(layoutForTarget(rect)) + return + } + if (performance.now() - startedAt < TARGET_RESOLVE_TIMEOUT_MS) { + requestAnimationFrame(attempt) + } else if (entering) { + // Anchor never appeared (feature disabled / pane closed) — skip past it + goTo(stepIndex + directionRef.current, directionRef.current as 1 | -1) + } + } + attempt() + return () => { + cancelled = true + } + }, [stepIndex, resizeNonce, goTo]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + finish() + } else if (e.key === 'ArrowRight' || e.key === 'Enter') { + e.preventDefault() + goTo(stepIndexRef.current + 1, 1) + } else if (e.key === 'ArrowLeft') { + e.preventDefault() + goTo(stepIndexRef.current - 1, -1) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [finish, goTo]) + + const step = TOUR_STEPS[stepIndex] + const isFirst = stepIndex === 0 + const isLast = stepIndex === TOUR_STEPS.length - 1 + + return ( + <> + + {/* highlight ring around the current step's anchor */} + {layout.ring && ( + + )} + {/* mascot + speech bubble */} + + + + + + + + + {step.title} + {step.text} + + + {stepIndex + 1} / {TOUR_STEPS.length} + + + {!isFirst && ( + goTo(stepIndex - 1, -1)}> + Back + + )} + (isLast ? finish() : goTo(stepIndex + 1, 1))} + > + {isLast ? 'Done' : 'Next'} + + + + + + > + ) +} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index bd3d7888..f2c26145 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -59,6 +59,7 @@ import { } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" import { SettingsDialog } from "@/components/settings-dialog" +import { MascotFaceIcon } from "@/components/talking-head" import { extractConferenceLink } from "@/lib/calendar-event" import { useBilling } from "@/hooks/useBilling" import { toast } from "@/lib/toast" @@ -170,6 +171,8 @@ type SidebarContentPanelProps = { onNewChat?: () => void onToggleBrowser?: () => void onVoiceNoteCreated?: (path: string) => void + /** Starts the mascot-guided product tour. */ + onStartTour?: () => void /** Which primary destination is currently active, for nav highlighting. */ activeNav?: 'home' | 'email' | 'meetings' | 'code' | 'knowledge' | 'agents' | 'workspaces' | null /** Live meeting recording state, so the recording row can show its indicator/stop. */ @@ -418,6 +421,7 @@ export function SidebarContentPanel({ onNewChat, onToggleBrowser, onVoiceNoteCreated, + onStartTour, activeNav, meetingRecordingState = 'idle', recordingMeetingSource = null, @@ -695,13 +699,14 @@ export function SidebarContentPanel({ - + Home onOpenEmail?.()} className={previewEmail ? 'h-auto py-1.5' : undefined} @@ -724,6 +729,7 @@ export function SidebarContentPanel({ {codeModeEnabled && ( - + Code @@ -810,6 +816,7 @@ export function SidebarContentPanel({ )} knowledgeActions.openKnowledgeView()} className={knowledgeUpdatedLabel ? 'h-auto py-1.5' : undefined} @@ -830,6 +837,7 @@ export function SidebarContentPanel({ knowledgeActions.openWorkspaceAt()} className="h-auto py-1.5" @@ -874,6 +883,7 @@ export function SidebarContentPanel({ setChatsExpanded((v) => !v)} className="flex w-full items-center gap-1.5 px-3 py-1 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground" > @@ -1015,6 +1025,15 @@ export function SidebarContentPanel({ )} + {onStartTour && ( + + + Take a tour + + )}
{step.title}
{step.text}