feat(dashboard): focus same-page Start Session input from NextStep CTA and checklist
The "Start a session" CTAs on the NextStepCard and SetupChecklist used to Link-navigate, which left the user on the same page (the Start Session input lives on the dashboard) without any visible response. Replace those CTAs with a custom window-event dispatch (FOCUS_START_SESSION_EVENT) that the StartSessionInput listens for: scroll the input into view, focus the textarea, and pulse a ring for 900ms so the click feels intentional. The NextStepCard also locally hides itself after firing so the user isn't double-prompted while typing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import type { OnboardingStatus } from '@/api/onboarding'
|
|||||||
import { useTrialBanner } from '@/hooks/useTrialBanner'
|
import { useTrialBanner } from '@/hooks/useTrialBanner'
|
||||||
import type { TrialBannerStage } from '@/hooks/useTrialBanner'
|
import type { TrialBannerStage } from '@/hooks/useTrialBanner'
|
||||||
import { useOnboardingStatus } from '@/hooks/useOnboardingStatus'
|
import { useOnboardingStatus } from '@/hooks/useOnboardingStatus'
|
||||||
|
import { FOCUS_START_SESSION_EVENT } from '@/components/dashboard/StartSessionInput'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next-step card — surfaces the single highest-priority incomplete onboarding
|
* Next-step card — surfaces the single highest-priority incomplete onboarding
|
||||||
@@ -114,9 +115,10 @@ export function pickNextStep(
|
|||||||
export function NextStepCard() {
|
export function NextStepCard() {
|
||||||
const status = useOnboardingStatus()
|
const status = useOnboardingStatus()
|
||||||
const [locallyDismissed, setLocallyDismissed] = useState(false)
|
const [locallyDismissed, setLocallyDismissed] = useState(false)
|
||||||
|
const [locallyHidden, setLocallyHidden] = useState(false)
|
||||||
const { stage } = useTrialBanner()
|
const { stage } = useTrialBanner()
|
||||||
|
|
||||||
if (!status || status.dismissed || locallyDismissed) return null
|
if (!status || status.dismissed || locallyDismissed || locallyHidden) return null
|
||||||
|
|
||||||
const next = pickNextStep(status, stage)
|
const next = pickNextStep(status, stage)
|
||||||
if (!next) return null
|
if (!next) return null
|
||||||
@@ -154,14 +156,29 @@ export function NextStepCard() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<Link
|
{next.key === 'ran_session' ? (
|
||||||
to={next.ctaPath}
|
<button
|
||||||
data-testid="next-step-cta"
|
type="button"
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
onClick={() => {
|
||||||
>
|
window.dispatchEvent(new Event(FOCUS_START_SESSION_EVENT))
|
||||||
{next.ctaLabel}
|
setLocallyHidden(true)
|
||||||
<ArrowRight size={14} />
|
}}
|
||||||
</Link>
|
data-testid="next-step-cta"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
{next.ctaLabel}
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={next.ctaPath}
|
||||||
|
data-testid="next-step-cta"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
{next.ctaLabel}
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { OnboardingStatus } from '@/api/onboarding'
|
|||||||
import { useTrialBanner } from '@/hooks/useTrialBanner'
|
import { useTrialBanner } from '@/hooks/useTrialBanner'
|
||||||
import type { TrialBannerStage } from '@/hooks/useTrialBanner'
|
import type { TrialBannerStage } from '@/hooks/useTrialBanner'
|
||||||
import { useOnboardingStatus } from '@/hooks/useOnboardingStatus'
|
import { useOnboardingStatus } from '@/hooks/useOnboardingStatus'
|
||||||
|
import { FOCUS_START_SESSION_EVENT } from '@/components/dashboard/StartSessionInput'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified setup checklist — single list (no SOLO/TEAM bifurcation).
|
* Unified setup checklist — single list (no SOLO/TEAM bifurcation).
|
||||||
@@ -112,6 +113,21 @@ export function SetupChecklist() {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : item.key === 'ran_session' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.dispatchEvent(new Event(FOCUS_START_SESSION_EVENT))}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors text-left',
|
||||||
|
'hover:bg-[rgba(255,255,255,0.04)]',
|
||||||
|
)}
|
||||||
|
data-testid={`checklist-item-${item.key}`}
|
||||||
|
data-done="false"
|
||||||
|
>
|
||||||
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-border" />
|
||||||
|
<span className="flex-1 text-foreground">{item.label}</span>
|
||||||
|
<ChevronRight size={14} className="text-muted-foreground shrink-0" />
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
to={item.path}
|
to={item.path}
|
||||||
|
|||||||
@@ -18,19 +18,38 @@ const SUGGESTIONS: { icon: LucideIcon; label: string }[] = [
|
|||||||
|
|
||||||
const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx'
|
const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx'
|
||||||
|
|
||||||
|
export const FOCUS_START_SESSION_EVENT = 'rf:focus-start-session'
|
||||||
|
|
||||||
export function StartSessionInput() {
|
export function StartSessionInput() {
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const [showLogs, setShowLogs] = useState(false)
|
const [showLogs, setShowLogs] = useState(false)
|
||||||
const [logContent, setLogContent] = useState('')
|
const [logContent, setLogContent] = useState('')
|
||||||
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
||||||
const [isDragOver, setIsDragOver] = useState(false)
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
const [nudge, setNudge] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const dragCounterRef = useRef(0)
|
const dragCounterRef = useRef(0)
|
||||||
|
|
||||||
useEffect(() => { textareaRef.current?.focus() }, [])
|
useEffect(() => { textareaRef.current?.focus() }, [])
|
||||||
|
|
||||||
|
// External "focus me" trigger (e.g. NextStepCard "Start a session" CTA on
|
||||||
|
// the same page). Scrolls into view, focuses the textarea, and pulses a
|
||||||
|
// ring so the click feels intentional even when the input was already
|
||||||
|
// partially visible.
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
wrapperRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
textareaRef.current?.focus({ preventScroll: true })
|
||||||
|
setNudge(true)
|
||||||
|
window.setTimeout(() => setNudge(false), 900)
|
||||||
|
}
|
||||||
|
window.addEventListener(FOCUS_START_SESSION_EVENT, handler)
|
||||||
|
return () => window.removeEventListener(FOCUS_START_SESSION_EVENT, handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Auto-grow textarea
|
// Auto-grow textarea
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
@@ -190,7 +209,8 @@ export function StartSessionInput() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="w-full"
|
ref={wrapperRef}
|
||||||
|
className="w-full scroll-mt-6"
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
@@ -198,9 +218,11 @@ export function StartSessionInput() {
|
|||||||
>
|
>
|
||||||
{/* Main input area */}
|
{/* Main input area */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'relative rounded-2xl border bg-card transition-all',
|
'relative rounded-2xl border bg-card transition-all duration-300',
|
||||||
isDragOver ? 'border-primary/50 bg-primary/5' : 'border-border',
|
isDragOver ? 'border-primary/50 bg-primary/5' : 'border-border',
|
||||||
'focus-within:border-[rgba(96,165,250,0.25)] focus-within:ring-1 focus-within:ring-[rgba(96,165,250,0.1)]'
|
nudge
|
||||||
|
? 'border-[rgba(96,165,250,0.6)] ring-2 ring-[rgba(96,165,250,0.35)] shadow-[0_0_0_6px_rgba(96,165,250,0.12)]'
|
||||||
|
: 'focus-within:border-[rgba(96,165,250,0.25)] focus-within:ring-1 focus-within:ring-[rgba(96,165,250,0.1)]'
|
||||||
)}>
|
)}>
|
||||||
{/* Drag overlay */}
|
{/* Drag overlay */}
|
||||||
{isDragOver && (
|
{isDragOver && (
|
||||||
|
|||||||
Reference in New Issue
Block a user