From 939702147ef985f07bc17d20cd33ea59c1130203 Mon Sep 17 00:00:00 2001 From: ibraheemshaikh5 Date: Sat, 7 Feb 2026 18:31:45 -0500 Subject: [PATCH 1/5] feat: add workspace instruction modal for onboarding --- .../onboarding/WorkspaceInstructionModal.tsx | 148 ++++++++++ .../use-workspace-instruction-modal.ts | 267 ++++++++++++++++++ src/lib/workspace/instruction-modal.ts | 17 ++ 3 files changed, 432 insertions(+) create mode 100644 src/components/onboarding/WorkspaceInstructionModal.tsx create mode 100644 src/hooks/workspace/use-workspace-instruction-modal.ts create mode 100644 src/lib/workspace/instruction-modal.ts diff --git a/src/components/onboarding/WorkspaceInstructionModal.tsx b/src/components/onboarding/WorkspaceInstructionModal.tsx new file mode 100644 index 00000000..6bbad302 --- /dev/null +++ b/src/components/onboarding/WorkspaceInstructionModal.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { X, Move, SquarePen, FileSearch, Youtube, Share2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { WorkspaceInstructionMode } from "@/hooks/workspace/use-workspace-instruction-modal"; + +export interface WorkspaceInstructionModalProps { + mode: WorkspaceInstructionMode; + open: boolean; + canClose: boolean; + showFallback: boolean; + onRequestClose?: () => void; + onFallbackContinue?: () => void; + mediaSrc?: string; + useStaticFallback?: boolean; +} + +const STEP_COPY = [ + { icon: Move, label: "Drag cards" }, + { icon: SquarePen, label: "Create a note" }, + { icon: FileSearch, label: "PDF text/image to chat" }, + { icon: Youtube, label: "Add YouTube from chat" }, + { icon: Share2, label: "Share workspace" }, +]; + +function InstructionVisual({ mediaSrc, useStaticFallback = true }: { mediaSrc?: string; useStaticFallback?: boolean }) { + const [activeIndex, setActiveIndex] = useState(0); + + useEffect(() => { + if (mediaSrc && !useStaticFallback) return; + const id = window.setInterval(() => { + setActiveIndex((prev) => (prev + 1) % STEP_COPY.length); + }, 1700); + return () => window.clearInterval(id); + }, [mediaSrc, useStaticFallback]); + + if (mediaSrc && !useStaticFallback) { + const isVideo = /\.(mp4|webm|ogg)$/i.test(mediaSrc); + return ( +
+ {isVideo ? ( +
+ ); + } + + return ( +
+
+
+ +
+ {STEP_COPY.map((step, index) => { + const Icon = step.icon; + const active = index === activeIndex; + return ( +
+
+ {index + 1} +
+ + {step.label} +
+ ); + })} +
+
+ ); +} + +export function WorkspaceInstructionModal({ + mode, + open, + canClose, + showFallback, + onRequestClose, + onFallbackContinue, + mediaSrc, + useStaticFallback = true, +}: WorkspaceInstructionModalProps) { + const copy = useMemo(() => { + return mode === "autogen" ? "Your workspace is being generated" : "Welcome to Thinkex"; + }, [mode]); + + if (!open) return null; + + return ( +
+
+
+
+ +
+ +
+

{copy}

+ + {mode === "first-open" && canClose && ( + + )} + + {mode === "autogen" && showFallback && ( + + )} +
+
+
+
+ ); +} diff --git a/src/hooks/workspace/use-workspace-instruction-modal.ts b/src/hooks/workspace/use-workspace-instruction-modal.ts new file mode 100644 index 00000000..5ef74a95 --- /dev/null +++ b/src/hooks/workspace/use-workspace-instruction-modal.ts @@ -0,0 +1,267 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { getNewWorkspaceInstructionKeys } from "@/lib/workspace/instruction-modal"; + +export type WorkspaceInstructionMode = "first-open" | "autogen"; + +type CloseReason = "manual" | "autogen_complete" | "fallback_continue"; + +interface AnalyticsClient { + capture: (event: string, properties?: Record) => void; +} + +interface UseWorkspaceInstructionModalParams { + workspaceId: string | null; + userId: string | null; + assistantIsRunning: boolean | null; + analytics?: AnalyticsClient | null; +} + +interface UseWorkspaceInstructionModalResult { + open: boolean; + mode: WorkspaceInstructionMode | null; + canClose: boolean; + showFallback: boolean; + close: () => void; + continueFromFallback: () => void; +} + +const FIRST_OPEN_UNLOCK_MS = 7000; +const AUTOGEN_FALLBACK_MS = 45000; + +export function useWorkspaceInstructionModal({ + workspaceId, + userId, + assistantIsRunning, + analytics, +}: UseWorkspaceInstructionModalParams): UseWorkspaceInstructionModalResult { + const searchParams = useSearchParams(); + const createFrom = searchParams.get("createFrom"); + const action = searchParams.get("action"); + const isAutogenRoute = Boolean(createFrom) || action === "generate_study_materials"; + + const [open, setOpen] = useState(false); + const [mode, setMode] = useState(null); + const [canClose, setCanClose] = useState(false); + const [showFallback, setShowFallback] = useState(false); + + const startTimeRef = useRef(null); + const shownSignatureRef = useRef(null); + const seenRunningInCurrentAutogenRef = useRef(false); + const dismissedAutogenSignaturesRef = useRef>(new Set()); + + const safeUserId = userId ?? "anonymous"; + const firstOpenStorageKey = `workspace-instruction-first-open:${safeUserId}`; + const newWorkspaceStorageKeys = useMemo(() => { + if (!workspaceId) return [] as string[]; + return getNewWorkspaceInstructionKeys(userId, workspaceId); + }, [userId, workspaceId]); + + const autogenSignature = useMemo(() => { + return `${workspaceId ?? "none"}|${createFrom ?? ""}|${action ?? ""}`; + }, [workspaceId, createFrom, action]); + + const track = useCallback( + (event: string, properties: Record) => { + analytics?.capture(event, properties); + }, + [analytics] + ); + + const closeInternal = useCallback( + (reason: CloseReason) => { + if (!open || !mode) return; + + const timeToCloseMs = startTimeRef.current ? Date.now() - startTimeRef.current : undefined; + const common = { + mode, + workspace_id: workspaceId, + close_reason: reason, + time_to_close_ms: timeToCloseMs, + }; + + if (reason === "autogen_complete") { + track("workspace-instruction-modal-autoclosed", common); + } + if (reason === "fallback_continue") { + track("workspace-instruction-modal-fallback-continued", common); + } + + track("workspace-instruction-modal-closed", common); + + if (mode === "autogen") { + dismissedAutogenSignaturesRef.current.add(autogenSignature); + } + + setOpen(false); + setMode(null); + setCanClose(false); + setShowFallback(false); + seenRunningInCurrentAutogenRef.current = false; + startTimeRef.current = null; + shownSignatureRef.current = null; + }, + [autogenSignature, mode, open, track, workspaceId] + ); + + const close = useCallback(() => { + if (!open || mode !== "first-open" || !canClose) return; + closeInternal("manual"); + }, [canClose, closeInternal, mode, open]); + + const continueFromFallback = useCallback(() => { + if (!open || mode !== "autogen" || !showFallback) return; + closeInternal("fallback_continue"); + }, [closeInternal, mode, open, showFallback]); + + useEffect(() => { + if (!workspaceId) { + setOpen(false); + setMode(null); + setCanClose(false); + setShowFallback(false); + seenRunningInCurrentAutogenRef.current = false; + shownSignatureRef.current = null; + startTimeRef.current = null; + return; + } + + if (isAutogenRoute) { + try { + window.localStorage.setItem(firstOpenStorageKey, "true"); + } catch { + // no-op: storage unavailable + } + + if (!dismissedAutogenSignaturesRef.current.has(autogenSignature)) { + setOpen(true); + setMode("autogen"); + setCanClose(false); + setShowFallback(false); + seenRunningInCurrentAutogenRef.current = false; + } + return; + } + + if (open && mode === "autogen") { + return; + } + + let isNewWorkspaceTrigger = false; + try { + isNewWorkspaceTrigger = newWorkspaceStorageKeys.some( + (storageKey) => window.localStorage.getItem(storageKey) === "true" + ); + } catch { + isNewWorkspaceTrigger = false; + } + + if (isNewWorkspaceTrigger) { + try { + newWorkspaceStorageKeys.forEach((storageKey) => window.localStorage.removeItem(storageKey)); + } catch { + // no-op: storage unavailable + } + setOpen(true); + setMode("first-open"); + setCanClose(false); + setShowFallback(false); + return; + } + + let hasSeenFirstOpen = false; + try { + hasSeenFirstOpen = window.localStorage.getItem(firstOpenStorageKey) === "true"; + } catch { + hasSeenFirstOpen = false; + } + + if (!hasSeenFirstOpen) { + try { + window.localStorage.setItem(firstOpenStorageKey, "true"); + } catch { + // no-op: storage unavailable + } + setOpen(true); + setMode("first-open"); + setCanClose(false); + setShowFallback(false); + return; + } + + if (open && mode === "first-open") { + setOpen(false); + setMode(null); + setCanClose(false); + setShowFallback(false); + startTimeRef.current = null; + shownSignatureRef.current = null; + } + }, [autogenSignature, firstOpenStorageKey, isAutogenRoute, mode, newWorkspaceStorageKeys, open, workspaceId]); + + useEffect(() => { + if (!open || !mode) return; + + const signature = `${mode}|${workspaceId}|${autogenSignature}`; + if (shownSignatureRef.current === signature) return; + + shownSignatureRef.current = signature; + startTimeRef.current = Date.now(); + + track("workspace-instruction-modal-shown", { + mode, + workspace_id: workspaceId, + create_from_present: Boolean(createFrom), + action, + }); + }, [action, autogenSignature, createFrom, mode, open, track, workspaceId]); + + useEffect(() => { + if (!open || mode !== "first-open") return; + + const timeoutId = window.setTimeout(() => { + setCanClose(true); + }, FIRST_OPEN_UNLOCK_MS); + + return () => window.clearTimeout(timeoutId); + }, [mode, open]); + + useEffect(() => { + if (!open || mode !== "autogen") return; + + const timeoutId = window.setTimeout(() => { + setShowFallback(true); + track("workspace-instruction-modal-fallback-shown", { + mode, + workspace_id: workspaceId, + timeout_ms: AUTOGEN_FALLBACK_MS, + }); + }, AUTOGEN_FALLBACK_MS); + + return () => window.clearTimeout(timeoutId); + }, [mode, open, track, workspaceId]); + + useEffect(() => { + if (!open || mode !== "autogen") return; + + if (assistantIsRunning === true) { + seenRunningInCurrentAutogenRef.current = true; + return; + } + + if (assistantIsRunning === false && seenRunningInCurrentAutogenRef.current) { + closeInternal("autogen_complete"); + } + }, [assistantIsRunning, closeInternal, mode, open]); + + return { + open, + mode, + canClose, + showFallback, + close, + continueFromFallback, + }; +} diff --git a/src/lib/workspace/instruction-modal.ts b/src/lib/workspace/instruction-modal.ts new file mode 100644 index 00000000..b61fbb5c --- /dev/null +++ b/src/lib/workspace/instruction-modal.ts @@ -0,0 +1,17 @@ +export function getNewWorkspaceInstructionKeys(userId: string | null | undefined, workspaceId: string): string[] { + const safeUserId = userId ?? "anonymous"; + return [ + `workspace-instruction-new-workspace:${safeUserId}:${workspaceId}`, + `workspace-instruction-new-workspace:${workspaceId}`, + ]; +} + +export function markNewWorkspaceInstruction(userId: string | null | undefined, workspaceId: string): void { + if (!workspaceId) return; + const keys = getNewWorkspaceInstructionKeys(userId, workspaceId); + try { + keys.forEach((key) => window.localStorage.setItem(key, "true")); + } catch { + // no-op: storage unavailable + } +} From 3023f1605b70121a078fc1d87f2054fcf6a77564 Mon Sep 17 00:00:00 2001 From: ibraheemshaikh5 Date: Sat, 7 Feb 2026 18:31:50 -0500 Subject: [PATCH 2/5] feat: integrate workspace instruction modal into dashboard and workspace creation --- src/app/dashboard/page.tsx | 22 ++++++++++++++++ .../assistant-ui/AssistantPanel.tsx | 26 ++++++++++++++++--- src/components/home/HomeContent.tsx | 4 +++ src/components/layout/DashboardLayout.tsx | 5 +++- .../workspace/CreateWorkspaceModal.tsx | 7 ++++- 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 9a34f18e..f6b0fc3c 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -35,8 +35,10 @@ import { AnonymousSessionHandler, SidebarCoordinator } from "@/components/layout import { PdfEngineWrapper } from "@/components/pdf/PdfEngineWrapper"; import WorkspaceSettingsModal from "@/components/workspace/WorkspaceSettingsModal"; import ShareWorkspaceDialog from "@/components/workspace/ShareWorkspaceDialog"; +import { WorkspaceInstructionModal } from "@/components/onboarding/WorkspaceInstructionModal"; import { RealtimeProvider } from "@/contexts/RealtimeContext"; import { toast } from "sonner"; +import { useWorkspaceInstructionModal } from "@/hooks/workspace/use-workspace-instruction-modal"; import { InviteGuard } from "@/components/workspace/InviteGuard"; @@ -104,6 +106,14 @@ function DashboardContent({ // Workspace settings/share modals (lifted so header can open them) const [showWorkspaceSettings, setShowWorkspaceSettings] = useState(false); const [showWorkspaceShare, setShowWorkspaceShare] = useState(false); + const [assistantThreadRunning, setAssistantThreadRunning] = useState(null); + + const instructionModal = useWorkspaceInstructionModal({ + workspaceId: currentWorkspaceId, + userId: session?.user?.id ?? null, + assistantIsRunning: assistantThreadRunning, + analytics: posthog ?? null, + }); // Show sign-in prompt after 13 events for anonymous users useEffect(() => { @@ -427,6 +437,7 @@ function DashboardContent({ onWorkspaceSizeChange={setWorkspacePanelSize} onSingleSelect={handleCreateInstantNote} onMultiSelect={handleCreateCardFromSelections} + onAssistantThreadRunningChange={setAssistantThreadRunning} panels={panels} workspaceSection={ + {instructionModal.open && instructionModal.mode && ( + + )} void | Promise; onMultiSelect?: (selections: Array<{ text: string; id: string; range?: Range }>) => void | Promise; onReady?: () => void; + onThreadRunningChange?: (isRunning: boolean) => void; } export function AssistantPanel({ @@ -31,7 +32,8 @@ export function AssistantPanel({ setIsChatMaximized, onSingleSelect, onMultiSelect, - onReady + onReady, + onThreadRunningChange, }: AssistantPanelProps) { // Don't render if no workspaceId if (!workspaceId) { @@ -49,6 +51,7 @@ export function AssistantPanel({ onSingleSelect={onSingleSelect} onMultiSelect={onMultiSelect} onReady={onReady} + onThreadRunningChange={onThreadRunningChange} /> ); @@ -61,7 +64,8 @@ function WorkspaceContextWrapper({ setIsChatMaximized, onSingleSelect, onMultiSelect, - onReady + onReady, + onThreadRunningChange, }: { workspaceId?: string | null; setIsChatExpanded?: (expanded: boolean) => void; @@ -70,6 +74,7 @@ function WorkspaceContextWrapper({ onSingleSelect?: (text: string, range?: Range) => void | Promise; onMultiSelect?: (selections: Array<{ text: string; id: string; range?: Range }>) => void | Promise; onReady?: () => void; + onThreadRunningChange?: (isRunning: boolean) => void; }) { // Fetch current workspace state (includes loading state) const { state, isLoading } = useWorkspaceState(workspaceId || null); @@ -96,6 +101,7 @@ function WorkspaceContextWrapper({ onReady={onReady} state={state} isLoading={isLoading} + onThreadRunningChange={onThreadRunningChange} /> ); @@ -249,6 +255,7 @@ function WorkspaceContextWrapperContent({ onReady, state, isLoading, + onThreadRunningChange, }: { workspaceId?: string | null; setIsChatExpanded?: (expanded: boolean) => void; @@ -259,6 +266,7 @@ function WorkspaceContextWrapperContent({ onReady?: () => void; state: ReturnType["state"]; isLoading: boolean; + onThreadRunningChange?: (isRunning: boolean) => void; }) { // Notify parent when content is ready useEffect(() => { @@ -308,6 +316,8 @@ function WorkspaceContextWrapperContent({ )} data-tour="chat-panel" > + + {/* Chat Header */} setIsChatExpanded?.(false)} @@ -329,3 +339,13 @@ function WorkspaceContextWrapperContent({
); } + +function ThreadRunningObserver({ onRunningChange }: { onRunningChange?: (isRunning: boolean) => void }) { + const isRunning = useAuiState(({ thread }) => (thread as any)?.isRunning ?? false); + + useEffect(() => { + onRunningChange?.(isRunning); + }, [isRunning, onRunningChange]); + + return null; +} diff --git a/src/components/home/HomeContent.tsx b/src/components/home/HomeContent.tsx index e93cc264..ffb008e9 100644 --- a/src/components/home/HomeContent.tsx +++ b/src/components/home/HomeContent.tsx @@ -13,6 +13,8 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { FolderPlus, Github } from "lucide-react"; import { useCreateWorkspace } from "@/hooks/workspace/use-create-workspace"; +import { useSession } from "@/lib/auth-client"; +import { markNewWorkspaceInstruction } from "@/lib/workspace/instruction-modal"; import { HoverCard, HoverCardContent, @@ -29,6 +31,7 @@ export const useSectionVisibility = () => useContext(SectionVisibilityContext); export function HomeContent() { const router = useRouter(); + const { data: session } = useSession(); const [scrollY, setScrollY] = useState(0); const [searchQuery, setSearchQuery] = useState(""); const [heroVisible, setHeroVisible] = useState(true); @@ -92,6 +95,7 @@ export function HomeContent() { }, { onSuccess: ({ workspace }) => { + markNewWorkspaceInstruction(session?.user?.id, workspace.id); router.push(`/workspace/${workspace.slug}`); }, onError: (err) => { diff --git a/src/components/layout/DashboardLayout.tsx b/src/components/layout/DashboardLayout.tsx index 1d6c9786..7dc966bb 100644 --- a/src/components/layout/DashboardLayout.tsx +++ b/src/components/layout/DashboardLayout.tsx @@ -32,6 +32,7 @@ interface DashboardLayoutProps { // Text selection handlers onSingleSelect?: (text: string) => void | Promise; onMultiSelect?: (selections: Array<{ text: string; id: string }>) => void | Promise; + onAssistantThreadRunningChange?: (isRunning: boolean) => void; // Component slots workspaceSection: React.ReactNode; @@ -60,6 +61,7 @@ export function DashboardLayout({ onWorkspaceSizeChange, onSingleSelect, onMultiSelect, + onAssistantThreadRunningChange, workspaceSection, panels, modalManager, @@ -107,6 +109,7 @@ export function DashboardLayout({ setIsChatMaximized={setIsChatMaximized} onSingleSelect={onSingleSelect} onMultiSelect={onMultiSelect} + onThreadRunningChange={onAssistantThreadRunningChange} />
@@ -188,6 +191,7 @@ export function DashboardLayout({ setIsChatMaximized={setIsChatMaximized} onSingleSelect={onSingleSelect} onMultiSelect={onMultiSelect} + onThreadRunningChange={onAssistantThreadRunningChange} /> @@ -208,4 +212,3 @@ export function DashboardLayout({ return content; } - diff --git a/src/components/workspace/CreateWorkspaceModal.tsx b/src/components/workspace/CreateWorkspaceModal.tsx index e03f00b9..a0ebd8e3 100644 --- a/src/components/workspace/CreateWorkspaceModal.tsx +++ b/src/components/workspace/CreateWorkspaceModal.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { usePostHog } from 'posthog-js/react'; import { useCreateWorkspace } from "@/hooks/workspace/use-create-workspace"; +import { useSession } from "@/lib/auth-client"; import { Dialog, DialogContent, @@ -24,6 +25,7 @@ import { IconRenderer } from "@/hooks/use-icon-picker"; import { SwatchesPicker, ColorResult } from "react-color"; import { SWATCHES_COLOR_GROUPS, type CardColor } from "@/lib/workspace-state/colors"; import { validateImportedJSON, generateImportPreview, type ValidationResult } from "@/lib/workspace/import-validation"; +import { markNewWorkspaceInstruction } from "@/lib/workspace/instruction-modal"; import { Textarea } from "@/components/ui/textarea"; import { Upload, FileText } from "lucide-react"; import type { AgentState } from "@/lib/workspace-state/types"; @@ -59,6 +61,7 @@ export default function CreateWorkspaceModal({ }: CreateWorkspaceModalProps) { const router = useRouter(); const posthog = usePostHog(); + const { data: session } = useSession(); const createWorkspace = useCreateWorkspace(); const [name, setName] = useState(initialData?.name || ""); const [selectedIcon, setSelectedIcon] = useState(initialData?.icon || null); @@ -160,6 +163,9 @@ export default function CreateWorkspaceModal({ }, { onSuccess: async ({ workspace }) => { + // Mark this newly created workspace so workspace route can show the instruction modal. + markNewWorkspaceInstruction(session?.user?.id, workspace.id); + posthog.capture('workspace-created', { workspace_id: workspace.id, workspace_slug: workspace.slug, @@ -465,4 +471,3 @@ export default function CreateWorkspaceModal({ ); } - From f7db6afe7461e9c885bdd8c0471dd0fe5d63aecb Mon Sep 17 00:00:00 2001 From: ibraheemshaikh5 Date: Sat, 7 Feb 2026 20:16:52 -0500 Subject: [PATCH 3/5] feat: improve workspace instruction modal copy --- .../onboarding/WorkspaceInstructionModal.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/onboarding/WorkspaceInstructionModal.tsx b/src/components/onboarding/WorkspaceInstructionModal.tsx index 6bbad302..b0d7be81 100644 --- a/src/components/onboarding/WorkspaceInstructionModal.tsx +++ b/src/components/onboarding/WorkspaceInstructionModal.tsx @@ -18,11 +18,11 @@ export interface WorkspaceInstructionModalProps { } const STEP_COPY = [ - { icon: Move, label: "Drag cards" }, - { icon: SquarePen, label: "Create a note" }, - { icon: FileSearch, label: "PDF text/image to chat" }, - { icon: Youtube, label: "Add YouTube from chat" }, - { icon: Share2, label: "Share workspace" }, + { icon: Move, label: "Arrange your materials" }, + { icon: SquarePen, label: "Take notes as you go" }, + { icon: FileSearch, label: "Ask AI about your documents" }, + { icon: Youtube, label: "Drop in lecture videos" }, + { icon: Share2, label: "Collaborate with others" }, ]; function InstructionVisual({ mediaSrc, useStaticFallback = true }: { mediaSrc?: string; useStaticFallback?: boolean }) { @@ -97,7 +97,7 @@ export function WorkspaceInstructionModal({ useStaticFallback = true, }: WorkspaceInstructionModalProps) { const copy = useMemo(() => { - return mode === "autogen" ? "Your workspace is being generated" : "Welcome to Thinkex"; + return mode === "autogen" ? "Your workspace is being generated" : "Your workspace is ready \u2014 here\u2019s how to use it"; }, [mode]); if (!open) return null; From 5671895553035e52498ab2f5e8af1a4c2789571d Mon Sep 17 00:00:00 2001 From: ibraheemshaikh5 Date: Sun, 8 Feb 2026 02:06:23 -0500 Subject: [PATCH 4/5] fix: workspace instruction modal bugs and ux improvements --- src/app/dashboard/page.tsx | 22 +- .../onboarding/WorkspaceInstructionModal.tsx | 445 ++++++++++++++---- .../use-workspace-instruction-modal.ts | 38 +- 3 files changed, 392 insertions(+), 113 deletions(-) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index af4242dd..b6a82eb8 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -466,17 +466,17 @@ function DashboardContent({ modalManager={modalManagerElement} maximizedItemId={maximizedItemId} /> - {instructionModal.open && instructionModal.mode && ( - - )} + void; onFallbackContinue?: () => void; + onUserInteracted?: () => void; + isGenerating?: boolean; mediaSrc?: string; useStaticFallback?: boolean; } -const STEP_COPY = [ - { icon: Move, label: "Arrange your materials" }, - { icon: SquarePen, label: "Take notes as you go" }, - { icon: FileSearch, label: "Ask AI about your documents" }, - { icon: Youtube, label: "Drop in lecture videos" }, - { icon: Share2, label: "Collaborate with others" }, +interface Step { + icon: typeof Move; + label: string; + description: string; + /** When set, shows "Method X of Y" badge on the slide */ + variant?: { current: number; total: number }; + video?: { dark: string; light: string }; +} + +const VIDEO_BASE = "https://uxcoymwbfcbvkgwbhttq.supabase.co/storage/v1/object/public/video"; + +const STEPS: Step[] = [ + { + icon: Move, + label: "Arrange your materials", + description: "Drag, resize, and organize cards on your workspace grid to build your layout.", + video: { dark: `${VIDEO_BASE}/step-1-arrange-dark.mp4`, light: `${VIDEO_BASE}/step-1-arrange-light.mp4` }, + }, + { + icon: SquarePen, + label: "Generate notes as you go", + description: "Type a prompt and let the AI create a complete set of study materials for you.", + variant: { current: 1, total: 3 }, + video: { dark: `${VIDEO_BASE}/step-2-generate-card-dark-1.mp4`, light: `${VIDEO_BASE}/step-2-generate-card-light-1.mp4` }, + }, + { + icon: SquarePen, + label: "Generate notes as you go", + description: "Select existing cards and ask the AI to generate notes based on their content.", + variant: { current: 2, total: 3 }, + video: { dark: `${VIDEO_BASE}/step-2-generate-card-dark-2.mp4`, light: `${VIDEO_BASE}/step-2-generate-card-light-2.mp4` }, + }, + { + icon: SquarePen, + label: "Generate notes as you go", + description: "Upload a PDF and have the AI automatically create summaries and study guides.", + variant: { current: 3, total: 3 }, + video: { dark: `${VIDEO_BASE}/step-2-generate-card-dark-3.mp4`, light: `${VIDEO_BASE}/step-2-generate-card-light-3.mp4` }, + }, + { + icon: FileSearch, + label: "Ask AI about your documents", + description: "Select cards and chat with the AI to get answers grounded in your materials.", + video: { dark: `${VIDEO_BASE}/step-3-pdf-ss-dark.mp4`, light: `${VIDEO_BASE}/step-3-pdf-ss-light.mp4` }, + }, + { + icon: Youtube, + label: "Drop in lecture videos", + description: "Paste a YouTube link or drag it in to add lecture videos alongside your notes.", + video: { dark: `${VIDEO_BASE}/step-4-youtube-dark.mp4`, light: `${VIDEO_BASE}/step-4-youtube-light.mp4` }, + }, + { + icon: Share2, + label: "Collaborate with others", + description: "Share your workspace with classmates or teammates to work together in real time.", + video: { dark: `${VIDEO_BASE}/step-5-collab-dark.mp4`, light: `${VIDEO_BASE}/step-5-collab-light.mp4` }, + }, ]; -function InstructionVisual({ mediaSrc, useStaticFallback = true }: { mediaSrc?: string; useStaticFallback?: boolean }) { +const ICON_SLIDE_MS = 4000; +const FADE_MS = 250; + +function useCarousel(open: boolean) { const [activeIndex, setActiveIndex] = useState(0); + const [visibleIndex, setVisibleIndex] = useState(0); + const [fading, setFading] = useState(false); + const [videoLoaded, setVideoLoaded] = useState(false); + const fallbackTimerRef = useRef | null>(null); + const fadeTimeoutRef = useRef | null>(null); + const pausedRef = useRef(false); + const { resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + const isDark = mounted && resolvedTheme === "dark"; + // Reset carousel state when modal closes so re-opening starts from slide 0 useEffect(() => { - if (mediaSrc && !useStaticFallback) return; - const id = window.setInterval(() => { - setActiveIndex((prev) => (prev + 1) % STEP_COPY.length); - }, 1700); - return () => window.clearInterval(id); - }, [mediaSrc, useStaticFallback]); - - if (mediaSrc && !useStaticFallback) { - const isVideo = /\.(mp4|webm|ogg)$/i.test(mediaSrc); - return ( -
- {isVideo ? ( -
- ); - } + if (!open) { + if (fadeTimeoutRef.current) { + clearTimeout(fadeTimeoutRef.current); + fadeTimeoutRef.current = null; + } + setActiveIndex(0); + setVisibleIndex(0); + setFading(false); + setVideoLoaded(false); + pausedRef.current = false; + } + }, [open]); - return ( -
-
-
- -
- {STEP_COPY.map((step, index) => { - const Icon = step.icon; - const active = index === activeIndex; - return ( -
-
- {index + 1} -
- - {step.label} -
- ); - })} -
-
- ); + // Transition: fade out → swap → fade in + const transitionTo = useCallback((nextIndex: number) => { + if (nextIndex === visibleIndex) return; + if (fadeTimeoutRef.current) { + clearTimeout(fadeTimeoutRef.current); + fadeTimeoutRef.current = null; + } + setFading(true); + setVideoLoaded(false); + fadeTimeoutRef.current = setTimeout(() => { + setActiveIndex(nextIndex); + setVisibleIndex(nextIndex); + setFading(false); + fadeTimeoutRef.current = null; + }, FADE_MS); + }, [visibleIndex]); + + const advance = useCallback(() => { + if (pausedRef.current) return; + transitionTo((visibleIndex + 1) % STEPS.length); + }, [transitionTo, visibleIndex]); + + const pause = useCallback(() => { + pausedRef.current = true; + if (fallbackTimerRef.current) { + clearTimeout(fallbackTimerRef.current); + fallbackTimerRef.current = null; + } + }, []); + + const goTo = useCallback((index: number) => { + transitionTo(index); + }, [transitionTo]); + + const goPrev = useCallback(() => { + transitionTo((visibleIndex - 1 + STEPS.length) % STEPS.length); + }, [transitionTo, visibleIndex]); + + const goNext = useCallback(() => { + transitionTo((visibleIndex + 1) % STEPS.length); + }, [transitionTo, visibleIndex]); + + const handleVideoEnded = useCallback(() => { + // Always auto-advance on video end, even if user has interacted (paused) + transitionTo((visibleIndex + 1) % STEPS.length); + }, [transitionTo, visibleIndex]); + + const handleVideoCanPlay = useCallback(() => { + setVideoLoaded(true); + }, []); + + // Start fallback timer for icon-only slides (video slides use key + autoPlay) + useEffect(() => { + if (!open) return; + const step = STEPS[activeIndex]; + if (!step.video) { + fallbackTimerRef.current = setTimeout(advance, ICON_SLIDE_MS); + } + + return () => { + if (fallbackTimerRef.current) { + clearTimeout(fallbackTimerRef.current); + fallbackTimerRef.current = null; + } + }; + }, [open, activeIndex, advance]); + + const step = STEPS[activeIndex]; + const videoSrc = step.video ? (isDark ? step.video.dark : step.video.light) : null; + + // Compute next slide's video src for preloading + const nextStep = STEPS[(activeIndex + 1) % STEPS.length]; + const nextVideoSrc = nextStep.video ? (isDark ? nextStep.video.dark : nextStep.video.light) : null; + + // Preload the next video so transitions are instant + useEffect(() => { + if (!open || !nextVideoSrc) return; + const preloadVideo = document.createElement("video"); + preloadVideo.preload = "auto"; + preloadVideo.src = nextVideoSrc; + preloadVideo.load(); + return () => { + preloadVideo.src = ""; + preloadVideo.load(); + }; + }, [open, nextVideoSrc]); + + return { activeIndex, step, videoSrc, nextVideoSrc, fading, videoLoaded, goTo, goPrev, goNext, handleVideoEnded, handleVideoCanPlay, pause }; } export function WorkspaceInstructionModal({ @@ -93,50 +207,201 @@ export function WorkspaceInstructionModal({ showFallback, onRequestClose, onFallbackContinue, - mediaSrc, - useStaticFallback = true, + onUserInteracted, + isGenerating, }: WorkspaceInstructionModalProps) { - const copy = useMemo(() => { - return mode === "autogen" ? "Your workspace is being generated" : "Your workspace is ready \u2014 here\u2019s how to use it"; - }, [mode]); + const carousel = useCarousel(open); + const { activeIndex, step, videoSrc, fading, videoLoaded, goTo, goPrev, goNext, handleVideoEnded, handleVideoCanPlay, pause } = carousel; + const Icon = step.icon; + + const [isVisible, setIsVisible] = useState(false); + const [isClosing, setIsClosing] = useState(false); - if (!open) return null; + useEffect(() => { + if (open) { + setIsVisible(true); + setIsClosing(false); + } else if (isVisible) { + setIsClosing(true); + const timer = setTimeout(() => { + setIsVisible(false); + setIsClosing(false); + }, 300); + return () => clearTimeout(timer); + } + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!open) return; + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + if (mode === "first-open" && canClose) { + onRequestClose?.(); + } else if (mode === "autogen" && (!isGenerating || showFallback)) { + onFallbackContinue?.(); + } + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, mode, canClose, isGenerating, showFallback, onRequestClose, onFallbackContinue]); + + if (!isVisible) return null; return (
-
-
-
- + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
{ pause(); onUserInteracted?.(); }} + className={cn( + "w-full max-w-[1100px] rounded-[28px] border border-sidebar-border/70 bg-sidebar p-2 shadow-[0_28px_100px_rgba(0,0,0,0.35)] transition-all duration-300 ease-out", + isClosing ? "opacity-0 scale-[0.97]" : "opacity-100 scale-100" + )} + > +
+ {/* Generation status banner (autogen only) */} + {mode === "autogen" && ( +
+
+ + {!isGenerating ? "Your workspace is ready" : "Generating your workspace..."} + +
+ )} + + {/* Upper panel — video fills the space */} +
+
+
+ + {/* Left chevron */} + + + {/* Right chevron */} + + + {/* Video or icon fallback */} +
+ {/* Icon placeholder — shown until video is loaded */} +
+
+ +
+
+ + {/* Video — fades in over the icon once ready */} + {videoSrc && ( +
-
-

{copy}

+ {/* Lower panel — text, dots, action buttons */} +
+ {/* Text block — fades with the video */} +
+ {/* Variant badge */} + {step.variant && ( + + Method {step.variant.current} of {step.variant.total} + + )} + + {/* Label */} +

+ {step.label} +

+ {/* Description */} +

+ {step.description} +

+
+ + {/* Dot navigation */} +
+ {STEPS.map((s, index) => ( +
+ + {/* CTA — vertically centered in bottom bar */} {mode === "first-open" && canClose && ( - )} - - {mode === "autogen" && showFallback && ( - )} diff --git a/src/hooks/workspace/use-workspace-instruction-modal.ts b/src/hooks/workspace/use-workspace-instruction-modal.ts index 5ef74a95..f5ed5d49 100644 --- a/src/hooks/workspace/use-workspace-instruction-modal.ts +++ b/src/hooks/workspace/use-workspace-instruction-modal.ts @@ -24,8 +24,10 @@ interface UseWorkspaceInstructionModalResult { mode: WorkspaceInstructionMode | null; canClose: boolean; showFallback: boolean; + isGenerating: boolean; close: () => void; continueFromFallback: () => void; + markInteracted: () => void; } const FIRST_OPEN_UNLOCK_MS = 7000; @@ -46,10 +48,12 @@ export function useWorkspaceInstructionModal({ const [mode, setMode] = useState(null); const [canClose, setCanClose] = useState(false); const [showFallback, setShowFallback] = useState(false); + const [generationComplete, setGenerationComplete] = useState(false); const startTimeRef = useRef(null); const shownSignatureRef = useRef(null); const seenRunningInCurrentAutogenRef = useRef(false); + const userInteractedRef = useRef(false); const dismissedAutogenSignaturesRef = useRef>(new Set()); const safeUserId = userId ?? "anonymous"; @@ -99,7 +103,9 @@ export function useWorkspaceInstructionModal({ setMode(null); setCanClose(false); setShowFallback(false); + setGenerationComplete(false); seenRunningInCurrentAutogenRef.current = false; + userInteractedRef.current = false; startTimeRef.current = null; shownSignatureRef.current = null; }, @@ -112,9 +118,13 @@ export function useWorkspaceInstructionModal({ }, [canClose, closeInternal, mode, open]); const continueFromFallback = useCallback(() => { - if (!open || mode !== "autogen" || !showFallback) return; + if (!open || mode !== "autogen") return; closeInternal("fallback_continue"); - }, [closeInternal, mode, open, showFallback]); + }, [closeInternal, mode, open]); + + const markInteracted = useCallback(() => { + userInteractedRef.current = true; + }, []); useEffect(() => { if (!workspaceId) { @@ -191,15 +201,11 @@ export function useWorkspaceInstructionModal({ return; } - if (open && mode === "first-open") { - setOpen(false); - setMode(null); - setCanClose(false); - setShowFallback(false); - startTimeRef.current = null; - shownSignatureRef.current = null; - } - }, [autogenSignature, firstOpenStorageKey, isAutogenRoute, mode, newWorkspaceStorageKeys, open, workspaceId]); + // "first-open" modal only closes via explicit user action (Close button). + // No catch-all auto-close needed — workspaceId change is handled by the + // guard at the top of this effect, and autogen has its own close logic. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autogenSignature, firstOpenStorageKey, isAutogenRoute, newWorkspaceStorageKeys, workspaceId]); useEffect(() => { if (!open || !mode) return; @@ -252,16 +258,24 @@ export function useWorkspaceInstructionModal({ } if (assistantIsRunning === false && seenRunningInCurrentAutogenRef.current) { - closeInternal("autogen_complete"); + if (userInteractedRef.current) { + setGenerationComplete(true); + } else { + closeInternal("autogen_complete"); + } } }, [assistantIsRunning, closeInternal, mode, open]); + const isGenerating = open && mode === "autogen" && !generationComplete; + return { open, mode, canClose, showFallback, + isGenerating, close, continueFromFallback, + markInteracted, }; } From f79d45cf33cfbaa4a5940cb9279e888a4585796b Mon Sep 17 00:00:00 2001 From: ibraheemshaikh5 Date: Sun, 8 Feb 2026 02:26:11 -0500 Subject: [PATCH 5/5] feat: modal glassmorphism --- src/app/globals.css | 15 ++++++ .../onboarding/WorkspaceInstructionModal.tsx | 50 ++++++++++++------- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 916ae570..2050294d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1449,4 +1449,19 @@ body:has(.card-detail-modal) .workspace-grid-container { to { opacity: 1; } +} + +/* Liquid glass shimmer sweep */ +@keyframes glass-shimmer { + 0% { + transform: translateX(-100%); + } + + 50% { + transform: translateX(100%); + } + + 100% { + transform: translateX(100%); + } } \ No newline at end of file diff --git a/src/components/onboarding/WorkspaceInstructionModal.tsx b/src/components/onboarding/WorkspaceInstructionModal.tsx index 4668eb2a..274a7ef1 100644 --- a/src/components/onboarding/WorkspaceInstructionModal.tsx +++ b/src/components/onboarding/WorkspaceInstructionModal.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Move, SquarePen, FileSearch, Youtube, Share2, ChevronLeft, ChevronRight, X } from "lucide-react"; import { useTheme } from "next-themes"; -import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { WorkspaceInstructionMode } from "@/hooks/workspace/use-workspace-instruction-modal"; @@ -253,7 +252,7 @@ export function WorkspaceInstructionModal({ return (
{ pause(); onUserInteracted?.(); }} className={cn( - "w-full max-w-[1100px] rounded-[28px] border border-sidebar-border/70 bg-sidebar p-2 shadow-[0_28px_100px_rgba(0,0,0,0.35)] transition-all duration-300 ease-out", + "relative w-full max-w-[1100px] rounded-[28px] border border-white/[0.15] dark:border-white/[0.1] bg-white/60 dark:bg-white/[0.06] backdrop-blur-[24px] backdrop-saturate-[180%] p-2 shadow-[0_28px_80px_rgba(0,0,0,0.12),0_8px_24px_rgba(0,0,0,0.08),inset_0_1px_0_rgba(255,255,255,0.4)] dark:shadow-[0_28px_80px_rgba(0,0,0,0.5),0_8px_24px_rgba(0,0,0,0.3),inset_0_1px_0_rgba(255,255,255,0.08)] transition-all duration-300 ease-out", isClosing ? "opacity-0 scale-[0.97]" : "opacity-100 scale-100" )} > -
+ {/* Shimmer sweep — slow light traveling across the glass */} +
+
+
+ +
{/* Generation status banner (autogen only) */} {mode === "autogen" && ( -
+
{!isGenerating ? "Your workspace is ready" : "Generating your workspace..."} @@ -280,15 +284,15 @@ export function WorkspaceInstructionModal({ )} {/* Upper panel — video fills the space */} -
-
-
+
+
+
{/* Left chevron */}
{/* Lower panel — text, dots, action buttons */} -
+
{/* Text block — fades with the video */}
{/* Variant badge */} {step.variant && ( - + Method {step.variant.current} of {step.variant.total} )} @@ -385,7 +389,7 @@ export function WorkspaceInstructionModal({ className={cn( "h-2 rounded-full transition-all duration-300", index === activeIndex - ? "w-6 bg-primary" + ? "w-6 bg-primary shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "w-2 bg-sidebar-foreground/25 hover:bg-sidebar-foreground/40" )} aria-label={`Go to slide ${index + 1}${s.variant ? ` — ${s.label} method ${s.variant.current}` : ` — ${s.label}`}`} @@ -395,15 +399,23 @@ export function WorkspaceInstructionModal({ {/* CTA — vertically centered in bottom bar */} {mode === "first-open" && canClose && ( - + )} {mode === "autogen" && (!isGenerating || showFallback) && ( - + )}