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 type { TrialBannerStage } from '@/hooks/useTrialBanner'
|
||||
import { useOnboardingStatus } from '@/hooks/useOnboardingStatus'
|
||||
import { FOCUS_START_SESSION_EVENT } from '@/components/dashboard/StartSessionInput'
|
||||
|
||||
/**
|
||||
* Next-step card — surfaces the single highest-priority incomplete onboarding
|
||||
@@ -114,9 +115,10 @@ export function pickNextStep(
|
||||
export function NextStepCard() {
|
||||
const status = useOnboardingStatus()
|
||||
const [locallyDismissed, setLocallyDismissed] = useState(false)
|
||||
const [locallyHidden, setLocallyHidden] = useState(false)
|
||||
const { stage } = useTrialBanner()
|
||||
|
||||
if (!status || status.dismissed || locallyDismissed) return null
|
||||
if (!status || status.dismissed || locallyDismissed || locallyHidden) return null
|
||||
|
||||
const next = pickNextStep(status, stage)
|
||||
if (!next) return null
|
||||
@@ -154,14 +156,29 @@ export function NextStepCard() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<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>
|
||||
{next.key === 'ran_session' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new Event(FOCUS_START_SESSION_EVENT))
|
||||
setLocallyHidden(true)
|
||||
}}
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { OnboardingStatus } from '@/api/onboarding'
|
||||
import { useTrialBanner } from '@/hooks/useTrialBanner'
|
||||
import type { TrialBannerStage } from '@/hooks/useTrialBanner'
|
||||
import { useOnboardingStatus } from '@/hooks/useOnboardingStatus'
|
||||
import { FOCUS_START_SESSION_EVENT } from '@/components/dashboard/StartSessionInput'
|
||||
|
||||
/**
|
||||
* Unified setup checklist — single list (no SOLO/TEAM bifurcation).
|
||||
@@ -112,6 +113,21 @@ export function SetupChecklist() {
|
||||
{item.label}
|
||||
</span>
|
||||
</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
|
||||
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'
|
||||
|
||||
export const FOCUS_START_SESSION_EVENT = 'rf:focus-start-session'
|
||||
|
||||
export function StartSessionInput() {
|
||||
const [value, setValue] = useState('')
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const [logContent, setLogContent] = useState('')
|
||||
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [nudge, setNudge] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const dragCounterRef = useRef(0)
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current
|
||||
@@ -190,7 +209,8 @@ export function StartSessionInput() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full"
|
||||
ref={wrapperRef}
|
||||
className="w-full scroll-mt-6"
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -198,9 +218,11 @@ export function StartSessionInput() {
|
||||
>
|
||||
{/* Main input area */}
|
||||
<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',
|
||||
'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 */}
|
||||
{isDragOver && (
|
||||
|
||||
Reference in New Issue
Block a user