From f3af926f1ea24d92bce366ff24cc20589878f1f2 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:38:23 -0500 Subject: [PATCH 1/5] feat: add youtube creation tool --- src/app/api/chat/route.ts | 8 +- .../assistant-ui/YouTubeSearchToolUI.tsx | 303 ++++++++++++++++++ src/components/assistant-ui/thread.tsx | 7 +- src/lib/ai/tools/index.ts | 5 + src/lib/ai/tools/youtube-tools.ts | 71 ++++ src/lib/ai/workers/workspace-worker.ts | 97 +++--- src/lib/stores/ui-store.ts | 2 +- .../workspace-state/grid-layout-helpers.ts | 2 +- src/lib/youtube.ts | 94 ++++++ 9 files changed, 538 insertions(+), 51 deletions(-) create mode 100644 src/components/assistant-ui/YouTubeSearchToolUI.tsx create mode 100644 src/lib/ai/tools/youtube-tools.ts create mode 100644 src/lib/youtube.ts diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 2fcb0278..6b9e5041 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -219,7 +219,7 @@ export async function POST(req: Request) { const finalSystemPrompt = systemPromptParts.join(''); // Get model - const modelId = body.modelId || "gemini-3-flash-preview"; + const modelId = body.modelId || "gemini-2.5-flash-lite"; const model = google(modelId); // Create tools using the modular factory @@ -256,14 +256,14 @@ export async function POST(req: Request) { } : undefined, finishReason, }; - + logger.info("📊 [CHAT-API] Final Token Usage:", usageInfo); }, onStepFinish: (result) => { // stepType exists in runtime but may not be in type definitions const stepResult = result as typeof result & { stepType?: "initial" | "continue" | "tool-result" }; const { stepType, usage, finishReason } = stepResult; - + if (usage) { const stepUsageInfo = { stepType: stepType || 'unknown', @@ -280,7 +280,7 @@ export async function POST(req: Request) { noCacheTokens: (usage as any).inputTokenDetails?.noCacheTokens, } : undefined, }; - + logger.debug(`📊 [CHAT-API] Step Usage (${stepType || 'unknown'}):`, stepUsageInfo); } }, diff --git a/src/components/assistant-ui/YouTubeSearchToolUI.tsx b/src/components/assistant-ui/YouTubeSearchToolUI.tsx new file mode 100644 index 00000000..9a2258fc --- /dev/null +++ b/src/components/assistant-ui/YouTubeSearchToolUI.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { makeAssistantToolUI, useAui, useScrollLock } from "@assistant-ui/react"; +import { Loader2, Plus, Youtube, Check, ChevronDownIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useState, useRef, useCallback, type FC, type PropsWithChildren } from "react"; +import { toast } from "sonner"; +import { useWorkspaceStore } from "@/lib/stores/workspace-store"; +import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; +import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; +import { initialState } from "@/lib/workspace-state/state"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import ShinyText from "@/components/ShinyText"; + +const ANIMATION_DURATION = 200; +const SHIMMER_DURATION = 1000; + +/** + * Root collapsible container that manages open/closed state and scroll lock. + */ +const ToolRoot: FC< + PropsWithChildren<{ + className?: string; + }> +> = ({ className, children }) => { + const collapsibleRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + lockScroll(); + } + setIsOpen(open); + }, + [lockScroll], + ); + + return ( + + {children} + + ); +}; + +ToolRoot.displayName = "ToolRoot"; + +/** + * Gradient overlay that softens the bottom edge during expand/collapse animations. + */ +const GradientFade: FC<{ className?: string }> = ({ className }) => ( +
+); + +/** + * Trigger button for the tool collapsible. + */ +const ToolTrigger: FC<{ + active: boolean; + label: string; + icon: React.ReactNode; + className?: string; +}> = ({ + active, + label, + icon, + className, +}) => ( + + {icon} + + {active ? ( + + ) : ( + {label} + )} + + + + ); + +/** + * Collapsible content wrapper that handles height expand/collapse animation. + */ +const ToolContent: FC< + PropsWithChildren<{ + className?: string; + "aria-busy"?: boolean; + }> +> = ({ className, children, "aria-busy": ariaBusy }) => ( + + {children} + + +); + +ToolContent.displayName = "ToolContent"; + +interface VideoResult { + id: string; + title: string; + description: string; + channelTitle: string; + thumbnailUrl: string; + publishedAt: string; + url: string; +} + +interface SearchYoutubeArgs { + query: string; +} + +interface SearchYoutubeResult { + success: boolean; + videos?: VideoResult[]; + message?: string; +} + +export const YouTubeSearchToolUI = makeAssistantToolUI({ + toolName: "searchYoutube", + render: function YouTubeSearchToolUI({ args, status, result }) { + const aui = useAui(); + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const { state: workspaceState } = useWorkspaceState(workspaceId); + const operations = useWorkspaceOperations(workspaceId, workspaceState || initialState); + + const [addedVideos, setAddedVideos] = useState>(new Set()); + const [addingVideos, setAddingVideos] = useState>(new Set()); + + const isRunning = status.type === "running"; + + const handleAddVideo = async (video: VideoResult) => { + if (addedVideos.has(video.id) || addingVideos.has(video.id)) return; + + try { + setAddingVideos(prev => new Set(prev).add(video.id)); + + // Direct creation using workspace operations + operations.createItem("youtube", video.title, { + url: `https://www.youtube.com/watch?v=${video.id}` + }); + + setAddedVideos(prev => new Set(prev).add(video.id)); + toast.success("Video added to workspace"); + } catch (error) { + console.error("Failed to add video:", error); + toast.error("Failed to add video"); + } finally { + setAddingVideos(prev => { + const next = new Set(prev); + next.delete(video.id); + return next; + }); + } + }; + + return ( + + + : + } + /> + + +
+ {/* Query Info */} +
+ Query: +

{args.query}

+
+ + {/* Results */} + {status.type === "complete" && result && ( +
+ {!result.success || !result.videos || result.videos.length === 0 ? ( +
+ No videos found. + {result.message &&

{result.message}

} +
+ ) : ( +
+
+ {result.videos.length} videos found +
+ {result.videos.map((video) => ( +
+ {/* Thumbnail */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {video.title} +
+ + {/* Content */} +
+

+ {video.title} +

+

+ {video.channelTitle} • {new Date(video.publishedAt).toLocaleDateString()} +

+
+ + {/* Action */} +
+ +
+
+ ))} +
+ )} +
+ )} +
+
+
+ ); + }, +}); diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index fd0d521b..56ce6a37 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -62,6 +62,7 @@ import { CreateNoteToolUI } from "@/components/assistant-ui/CreateNoteToolUI"; import { CreateFlashcardToolUI } from "@/components/assistant-ui/CreateFlashcardToolUI"; import { UpdateFlashcardToolUI } from "@/components/assistant-ui/UpdateFlashcardToolUI"; import { SearchWebToolUI } from "@/components/assistant-ui/SearchWebToolUI"; +import { YouTubeSearchToolUI } from "@/components/assistant-ui/YouTubeSearchToolUI"; import { ExecuteCodeToolUI } from "@/components/assistant-ui/ExecuteCodeToolUI"; import { FileProcessingToolUI } from "@/components/assistant-ui/FileProcessingToolUI"; import { URLContextToolUI } from "@/components/assistant-ui/URLContextToolUI"; @@ -107,6 +108,7 @@ import { SpeechToTextButton } from "@/components/assistant-ui/SpeechToTextButton const AI_MODELS = [ { id: "gemini-3-pro-preview", name: "Gemini 3 Pro", description: "Latest preview model" }, { id: "gemini-3-flash-preview", name: "Gemini 3 Flash", description: "Latest fast preview model" }, + { id: "gemini-2.5-flash-lite", name: "Gemini 3.0 Fast", description: "Fastest & cost-efficient" }, { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", description: "Powerful & reliable" }, { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "Fast & efficient" }, ]; @@ -132,6 +134,7 @@ export const Thread: FC = ({ items = [] }) => { + @@ -555,7 +558,7 @@ const Composer: FC = ({ items }) => { // Get the current composer state const composerState = aui?.composer()?.getState(); if (!composerState) return; - + const currentText = composerState.text; const attachments = composerState.attachments || []; @@ -1789,7 +1792,7 @@ const EditComposer: FC = () => { // Get the current composer state const composerState = aui?.composer()?.getState(); if (!composerState) return; - + const currentText = composerState.text; // Re-add URL markers from parsed URLs (stored in state) diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts index a16dd0a3..92d812d5 100644 --- a/src/lib/ai/tools/index.ts +++ b/src/lib/ai/tools/index.ts @@ -18,6 +18,7 @@ import { import { createFlashcardsTool, createUpdateFlashcardsTool } from "./flashcard-tools"; import { createQuizTool, createUpdateQuizTool } from "./quiz-tools"; import { createDeepResearchTool } from "./deep-research"; +import { createSearchYoutubeTool, createAddYoutubeVideoTool } from "./youtube-tools"; import { logger } from "@/lib/utils/logger"; export interface ChatToolsConfig { @@ -72,6 +73,10 @@ export function createChatTools(config: ChatToolsConfig): Record { // Deep research deepResearch: createDeepResearchTool(ctx), + // YouTube + searchYoutube: createSearchYoutubeTool(), + addYoutubeVideo: createAddYoutubeVideoTool(ctx), + // Client tools from frontend ...frontendClientTools, }; diff --git a/src/lib/ai/tools/youtube-tools.ts b/src/lib/ai/tools/youtube-tools.ts new file mode 100644 index 00000000..9a729879 --- /dev/null +++ b/src/lib/ai/tools/youtube-tools.ts @@ -0,0 +1,71 @@ +import { tool, zodSchema } from "ai"; +import { z } from "zod"; +import { logger } from "@/lib/utils/logger"; +import { searchVideos } from "@/lib/youtube"; +import { workspaceWorker } from "@/lib/ai/workers"; +import type { WorkspaceToolContext } from "./workspace-tools"; + +/** + * Create the searchYoutube tool + */ +export function createSearchYoutubeTool() { + return tool({ + description: "Search for videos on YouTube. Returns a list of videos with titles, descriptions, and IDs.", + inputSchema: zodSchema( + z.object({ + query: z.string().describe("The search query for YouTube videos"), + }) + ), + execute: async ({ query }) => { + logger.debug("📹 [YOUTUBE] Searching for:", query); + try { + const videos = await searchVideos(query); + return { + success: true, + videos, + }; + } catch (error) { + logger.error("❌ [YOUTUBE] Search tool failed:", error); + return { + success: false, + message: "Failed to search YouTube videos. Please try again later.", + }; + } + }, + }); +} + +/** + * Create the addYoutubeVideo tool + */ +export function createAddYoutubeVideoTool(ctx: WorkspaceToolContext) { + return tool({ + description: "Add a specific YouTube video to the workspace as a card. Use this when the user wants to save a video.", + inputSchema: zodSchema( + z.object({ + videoId: z.string().describe("The YouTube Video ID (not the full URL)"), + title: z.string().describe("The title of the video"), + }) + ), + execute: async ({ videoId, title }) => { + logger.debug("📹 [YOUTUBE] Adding video:", { videoId, title }); + + if (!ctx.workspaceId) { + return { + success: false, + message: "No workspace context available", + }; + } + + const url = `https://www.youtube.com/watch?v=${videoId}`; + + return await workspaceWorker("create", { + workspaceId: ctx.workspaceId, + title, + itemType: "youtube", + youtubeData: { url }, + folderId: ctx.activeFolderId, + }); + }, + }); +} diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index 549f1844..0db67c49 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -18,46 +18,46 @@ import type { WorkspaceEvent } from "@/lib/workspace/events"; * Defensively handles various formats and falls back to safe defaults on parse failure. */ function parseAppendResult(rawResult: string | any): { version: number; conflict: boolean } { - // If it's already an object, try to extract version and conflict - if (typeof rawResult === 'object' && rawResult !== null) { - // Coerce version to number, handling string-typed fields - const versionNum = typeof rawResult.version === 'number' - ? rawResult.version - : Number(rawResult.version); - const version = isNaN(versionNum) ? 0 : versionNum; - - // Normalize conflict from boolean or string ('t'/'f'/'true'/'false') - let conflict = false; - if (typeof rawResult.conflict === 'boolean') { - conflict = rawResult.conflict; - } else if (typeof rawResult.conflict === 'string') { - const conflictStr = rawResult.conflict.toLowerCase().trim(); - conflict = conflictStr === 't' || conflictStr === 'true'; + // If it's already an object, try to extract version and conflict + if (typeof rawResult === 'object' && rawResult !== null) { + // Coerce version to number, handling string-typed fields + const versionNum = typeof rawResult.version === 'number' + ? rawResult.version + : Number(rawResult.version); + const version = isNaN(versionNum) ? 0 : versionNum; + + // Normalize conflict from boolean or string ('t'/'f'/'true'/'false') + let conflict = false; + if (typeof rawResult.conflict === 'boolean') { + conflict = rawResult.conflict; + } else if (typeof rawResult.conflict === 'string') { + const conflictStr = rawResult.conflict.toLowerCase().trim(); + conflict = conflictStr === 't' || conflictStr === 'true'; + } + + return { version, conflict }; } - return { version, conflict }; - } - - // PostgreSQL returns result as string like "(6,t)" - need to parse it - // Make regex more lenient: allow whitespace, case-insensitive, accept 'true'/'false' - const resultString = typeof rawResult === 'string' ? rawResult : String(rawResult); - // Match: (number, t|f|true|false) with optional whitespace - const match = resultString.match(/\(\s*(\d+)\s*,\s*(t|f|true|false)\s*\)/i); - - if (!match) { - logger.error(`[WORKSPACE-WORKER] Failed to parse PostgreSQL result:`, rawResult); - // Fall back to safe defaults instead of throwing - return { version: 0, conflict: false }; - } - - const versionNum = parseInt(match[1], 10); - const conflictStr = match[2].toLowerCase(); - const conflict = conflictStr === 't' || conflictStr === 'true'; - - return { - version: isNaN(versionNum) ? 0 : versionNum, - conflict, - }; + // PostgreSQL returns result as string like "(6,t)" - need to parse it + // Make regex more lenient: allow whitespace, case-insensitive, accept 'true'/'false' + const resultString = typeof rawResult === 'string' ? rawResult : String(rawResult); + // Match: (number, t|f|true|false) with optional whitespace + const match = resultString.match(/\(\s*(\d+)\s*,\s*(t|f|true|false)\s*\)/i); + + if (!match) { + logger.error(`[WORKSPACE-WORKER] Failed to parse PostgreSQL result:`, rawResult); + // Fall back to safe defaults instead of throwing + return { version: 0, conflict: false }; + } + + const versionNum = parseInt(match[1], 10); + const conflictStr = match[2].toLowerCase(); + const conflict = conflictStr === 't' || conflictStr === 'true'; + + return { + version: isNaN(versionNum) ? 0 : versionNum, + conflict, + }; } /** @@ -73,13 +73,16 @@ export async function workspaceWorker( content?: string; // For notes itemId?: string; - itemType?: "note" | "flashcard" | "quiz"; // Defaults to "note" if undefined + itemType?: "note" | "flashcard" | "quiz" | "youtube"; // Defaults to "note" if undefined flashcardData?: { cards?: { front: string; back: string }[]; // For creating flashcards cardsToAdd?: { front: string; back: string }[]; // For updating flashcards (appending) }; quizData?: QuizData; // For creating quizzes questionsToAdd?: QuizQuestion[]; // For updating quizzes (appending questions) + youtubeData?: { + url: string; // For creating youtube cards + }; // Optional: deep research metadata to attach to a note deepResearchData?: { prompt: string; @@ -91,7 +94,7 @@ export async function workspaceWorker( // For "create" operations, allow parallel execution (bypass queue) // For "update" and "delete" operations, serialize via queue const allowParallel = action === "create"; - + return executeWorkspaceOperation(params.workspaceId, async () => { try { logger.debug("📝 [WORKSPACE-WORKER] Action:", action, params); @@ -169,6 +172,14 @@ export async function workspaceWorker( itemData = { cards: cardsWithIds }; + } else if (itemType === "youtube") { + // YouTube type + if (!params.youtubeData || !params.youtubeData.url) { + throw new Error("YouTube data required for youtube card creation"); + } + itemData = { + url: params.youtubeData.url + }; } else if (itemType === "quiz") { // Quiz type if (!params.quizData) { @@ -208,7 +219,7 @@ export async function workspaceWorker( const item: Item = { id: itemId, type: itemType, - name: params.title || (itemType === "quiz" ? "New Quiz" : itemType === "flashcard" ? "New Flashcard Deck" : "New Note"), + name: params.title || (itemType === "youtube" ? "YouTube Video" : itemType === "quiz" ? "New Quiz" : itemType === "flashcard" ? "New Flashcard Deck" : "New Note"), subtitle: "", data: itemData, color: getRandomCardColor(), @@ -256,7 +267,7 @@ export async function workspaceWorker( } appendResult = parseAppendResult(eventResult[0].result); - + // If no conflict, we're done if (!appendResult.conflict) { break; @@ -266,7 +277,7 @@ export async function workspaceWorker( // This is more efficient than re-reading get_workspace_version baseVersion = appendResult.version; retryCount++; - + if (retryCount <= maxRetries) { logger.debug(`🔄 [WORKSPACE-WORKER] Version conflict on create, retrying (attempt ${retryCount + 1}/${maxRetries + 1}):`, { expectedVersion: baseVersion - 1, diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts index 09b381df..74ccad05 100644 --- a/src/lib/stores/ui-store.ts +++ b/src/lib/stores/ui-store.ts @@ -154,7 +154,7 @@ const initialState = { activeFolderId: null, selectedActions: [], - selectedModelId: 'gemini-3-flash-preview', + selectedModelId: 'gemini-2.5-flash-lite', folderHistoryBack: [], folderHistoryForward: null, diff --git a/src/lib/workspace-state/grid-layout-helpers.ts b/src/lib/workspace-state/grid-layout-helpers.ts index 2d8f74dd..c0edd0ee 100644 --- a/src/lib/workspace-state/grid-layout-helpers.ts +++ b/src/lib/workspace-state/grid-layout-helpers.ts @@ -9,7 +9,7 @@ export const DEFAULT_CARD_DIMENSIONS: Record pdf: { w: 1, h: 4 }, flashcard: { w: 2, h: 5 }, folder: { w: 1, h: 4 }, - youtube: { w: 2, h: 5 }, + youtube: { w: 4, h: 10 }, quiz: { w: 2, h: 13 }, }; diff --git a/src/lib/youtube.ts b/src/lib/youtube.ts new file mode 100644 index 00000000..941770a8 --- /dev/null +++ b/src/lib/youtube.ts @@ -0,0 +1,94 @@ +import { logger } from "@/lib/utils/logger"; + +interface YouTubeSearchResult { + id: { + videoId: string; + kind: string; + }; + snippet: { + title: string; + description: string; + channelTitle: string; + publishedAt: string; + thumbnails: { + default: { url: string }; + medium: { url: string }; + high: { url: string }; + }; + }; +} + +interface YouTubeSearchResponse { + items: YouTubeSearchResult[]; + pageInfo: { + totalResults: number; + resultsPerPage: number; + }; +} + +export interface VideoResult { + id: string; + title: string; + description: string; + channelTitle: string; + thumbnailUrl: string; + publishedAt: string; + url: string; +} + +/** + * Search for videos using the YouTube Data API + */ +export async function searchVideos(query: string, maxResults = 5): Promise { + const apiKey = process.env.YOUTUBE_API_KEY; + + if (!apiKey) { + logger.error("❌ [YOUTUBE] API key not found"); + throw new Error("YouTube API key is not configured"); + } + + try { + const url = new URL("https://www.googleapis.com/youtube/v3/search"); + url.searchParams.append("part", "snippet"); + url.searchParams.append("maxResults", maxResults.toString()); + url.searchParams.append("q", query); + url.searchParams.append("type", "video"); + url.searchParams.append("safeSearch", "moderate"); + url.searchParams.append("key", apiKey); + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Accept": "application/json", + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error(`❌ [YOUTUBE] API Error: ${response.status} ${response.statusText}`, errorText); + throw new Error(`YouTube API request failed: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as YouTubeSearchResponse; + + if (!data.items) { + return []; + } + + return data.items + .filter(item => item.id.kind === "youtube#video") + .map(item => ({ + id: item.id.videoId, + title: item.snippet.title, + description: item.snippet.description, + channelTitle: item.snippet.channelTitle, + thumbnailUrl: item.snippet.thumbnails.medium.url, + publishedAt: item.snippet.publishedAt, + url: `https://www.youtube.com/watch?v=${item.id.videoId}`, + })); + + } catch (error) { + logger.error("❌ [YOUTUBE] Search failed:", error); + throw error; + } +} From 85fddd497b58aee1444291cb73dd371ad17dc797 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:54:55 -0500 Subject: [PATCH 2/5] fix: autoscroll when add video --- src/app/api/chat/route.ts | 3 + .../assistant-ui/YouTubeSearchToolUI.tsx | 254 ++++++++++-------- src/components/workspace/WorkspaceItem.tsx | 1 + 3 files changed, 146 insertions(+), 112 deletions(-) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 6b9e5041..cb323469 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -133,6 +133,9 @@ When a query requires both current data AND conceptual explanation, do both: 2. Use internal knowledge for the conceptual/explanatory component 3. Synthesize into a cohesive answer +YOUTUBE SEARCH GUIDANCE: +If the user asks to "add a youtube video" or "search for a video" but does not provide a specific topic (e.g., "add a video for this workspace"), you MUST inference a relevant search query based on the current workspace context, selected cards, or recent conversation history. Do NOT ask the user for a topic if meaningful context is available. Use the 'searchYoutube' tool directly with your inferred query. + CONFIDENCE THRESHOLD: If you are uncertain about a fact's accuracy or currency, prefer to search rather than risk providing outdated information.`); diff --git a/src/components/assistant-ui/YouTubeSearchToolUI.tsx b/src/components/assistant-ui/YouTubeSearchToolUI.tsx index 9a2258fc..8729ebaa 100644 --- a/src/components/assistant-ui/YouTubeSearchToolUI.tsx +++ b/src/components/assistant-ui/YouTubeSearchToolUI.tsx @@ -3,12 +3,13 @@ import { makeAssistantToolUI, useAui, useScrollLock } from "@assistant-ui/react"; import { Loader2, Plus, Youtube, Check, ChevronDownIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { useState, useRef, useCallback, type FC, type PropsWithChildren } from "react"; +import { useState, useRef, useCallback, useEffect, type FC, type PropsWithChildren } from "react"; import { toast } from "sonner"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; import { initialState } from "@/lib/workspace-state/state"; +import { useNavigateToItem } from "@/hooks/ui/use-navigate-to-item"; import { Collapsible, CollapsibleContent, @@ -16,6 +17,7 @@ import { } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; import ShinyText from "@/components/ShinyText"; +import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; const ANIMATION_DURATION = 200; const SHIMMER_DURATION = 1000; @@ -26,10 +28,11 @@ const SHIMMER_DURATION = 1000; const ToolRoot: FC< PropsWithChildren<{ className?: string; + defaultOpen?: boolean; }> -> = ({ className, children }) => { +> = ({ className, children, defaultOpen = false }) => { const collapsibleRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(defaultOpen); const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); const handleOpenChange = useCallback( @@ -176,128 +179,155 @@ interface SearchYoutubeResult { message?: string; } -export const YouTubeSearchToolUI = makeAssistantToolUI({ - toolName: "searchYoutube", - render: function YouTubeSearchToolUI({ args, status, result }) { - const aui = useAui(); - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); - const operations = useWorkspaceOperations(workspaceId, workspaceState || initialState); +const YouTubeSearchContent: FC<{ + args: SearchYoutubeArgs; + status: { type: string }; + result: SearchYoutubeResult | null; +}> = ({ args, status, result }) => { + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const { state: workspaceState } = useWorkspaceState(workspaceId); + const operations = useWorkspaceOperations(workspaceId, workspaceState || initialState); - const [addedVideos, setAddedVideos] = useState>(new Set()); - const [addingVideos, setAddingVideos] = useState>(new Set()); + const [addedVideos, setAddedVideos] = useState>(new Set()); + const [addingVideos, setAddingVideos] = useState>(new Set()); - const isRunning = status.type === "running"; + const isRunning = status.type === "running"; - const handleAddVideo = async (video: VideoResult) => { - if (addedVideos.has(video.id) || addingVideos.has(video.id)) return; + const navigateToItem = useNavigateToItem(); + const [scrollToId, setScrollToId] = useState(null); - try { - setAddingVideos(prev => new Set(prev).add(video.id)); + // Effect to handle scrolling to new items once they exist in state + useEffect(() => { + if (scrollToId && workspaceState?.items) { + const item = workspaceState.items.find(i => i.id === scrollToId); + if (item) { + navigateToItem(scrollToId); + setScrollToId(null); + } + } + }, [scrollToId, workspaceState?.items, navigateToItem]); - // Direct creation using workspace operations - operations.createItem("youtube", video.title, { - url: `https://www.youtube.com/watch?v=${video.id}` - }); + const handleAddVideo = async (video: VideoResult) => { + if (addedVideos.has(video.id) || addingVideos.has(video.id)) return; - setAddedVideos(prev => new Set(prev).add(video.id)); - toast.success("Video added to workspace"); - } catch (error) { - console.error("Failed to add video:", error); - toast.error("Failed to add video"); - } finally { - setAddingVideos(prev => { - const next = new Set(prev); - next.delete(video.id); - return next; - }); - } - }; + try { + setAddingVideos(prev => new Set(prev).add(video.id)); - return ( - - - : - } - /> + const id = operations.createItem("youtube", video.title, { + url: `https://www.youtube.com/watch?v=${video.id}` + }); - -
- {/* Query Info */} -
- Query: -

{args.query}

-
+ setAddedVideos(prev => new Set(prev).add(video.id)); + toast.success("Video added to workspace"); - {/* Results */} - {status.type === "complete" && result && ( -
- {!result.success || !result.videos || result.videos.length === 0 ? ( -
- No videos found. - {result.message &&

{result.message}

} + // Queue scroll to position once item exists in state + setScrollToId(id); + } catch (error) { + console.error("Failed to add video:", error); + toast.error("Failed to add video"); + } finally { + setAddingVideos(prev => { + const next = new Set(prev); + next.delete(video.id); + return next; + }); + } + }; + + return ( + + + : + } + /> + + +
+ {/* Query Info */} +
+ Query: +

{args.query}

+
+ + {/* Results */} + {status.type === "complete" && result && ( +
+ {!result.success || !result.videos || result.videos.length === 0 ? ( +
+ No videos found. + {result.message &&

{result.message}

} +
+ ) : ( +
+
+ {result.videos.length} videos found
- ) : ( -
-
- {result.videos.length} videos found -
- {result.videos.map((video) => ( -
- {/* Thumbnail */} -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {video.title} -
+ {result.videos.map((video) => ( +
+ {/* Thumbnail */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {video.title} +
- {/* Content */} -
-

- {video.title} -

-

- {video.channelTitle} • {new Date(video.publishedAt).toLocaleDateString()} -

-
+ {/* Content */} +
+

+ {video.title} +

+

+ {video.channelTitle} • {new Date(video.publishedAt).toLocaleDateString()} +

+
- {/* Action */} -
- -
+ {/* Action */} +
+
- ))} -
- )} -
- )} -
- - +
+ ))} +
+ )} +
+ )} +
+ + + ); +}; + +export const YouTubeSearchToolUI = makeAssistantToolUI({ + toolName: "searchYoutube", + render: function YouTubeSearchToolUI({ args, status, result }) { + return ( + + + ); }, }); diff --git a/src/components/workspace/WorkspaceItem.tsx b/src/components/workspace/WorkspaceItem.tsx index 34256dbd..17754f87 100644 --- a/src/components/workspace/WorkspaceItem.tsx +++ b/src/components/workspace/WorkspaceItem.tsx @@ -175,6 +175,7 @@ function WorkspaceItem({ >
Date: Wed, 28 Jan 2026 20:00:38 -0500 Subject: [PATCH 3/5] fix: quizui loading state --- .../assistant-ui/CreateQuizToolUI.tsx | 128 ++++++++++-------- 1 file changed, 68 insertions(+), 60 deletions(-) diff --git a/src/components/assistant-ui/CreateQuizToolUI.tsx b/src/components/assistant-ui/CreateQuizToolUI.tsx index 1da36edf..13bb28b3 100644 --- a/src/components/assistant-ui/CreateQuizToolUI.tsx +++ b/src/components/assistant-ui/CreateQuizToolUI.tsx @@ -22,11 +22,11 @@ import type { QuizResult } from "@/lib/ai/tool-result-schemas"; import { parseQuizResult } from "@/lib/ai/tool-result-schemas"; type CreateQuizArgs = { - topic?: string; - difficulty?: "easy" | "medium" | "hard"; - contextContent?: string; - sourceCardIds?: string[]; - sourceCardNames?: string[]; + topic?: string; + difficulty?: "easy" | "medium" | "hard"; + contextContent?: string; + sourceCardIds?: string[]; + sourceCardNames?: string[]; }; interface CreateQuizReceiptProps { @@ -188,60 +188,68 @@ const CreateQuizReceipt = ({ args, result, status, moveItemToFolder, allItems = }; export const CreateQuizToolUI = makeAssistantToolUI({ - toolName: "createQuiz", - render: function CreateQuizUI({ args, result, status }) { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); - const operations = useWorkspaceOperations(workspaceId, workspaceState || initialState); - const workspaceContext = useWorkspaceContext(); - const currentWorkspace = workspaceContext.workspaces.find((w) => w.id === workspaceId); - - useEffect(() => { - logger.debug("🎯 [CreateQuizTool] Render:", { args, result, status: status?.type }); - }, [args, result, status]); - - useOptimisticToolUpdate(status, result, workspaceId); - - const parsed = result != null ? parseQuizResult(result) : null; - - let content: ReactNode = null; - - if (parsed?.success) { - content = ( - - ); - } else if (status.type === "running") { - content = ; - } else if ( - (status.type === "incomplete" && status.reason === "error") || - (status.type === "complete" && parsed && !parsed.success) - ) { - content = ( -
-
- -

Failed to create quiz

-
- {parsed && !parsed.success && parsed.message && ( -

{parsed.message}

- )} -
- ); - } + toolName: "createQuiz", + render: function CreateQuizUI({ args, result, status }) { + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const { state: workspaceState } = useWorkspaceState(workspaceId); + const operations = useWorkspaceOperations(workspaceId, workspaceState || initialState); + const workspaceContext = useWorkspaceContext(); + const currentWorkspace = workspaceContext.workspaces.find((w) => w.id === workspaceId); + + useEffect(() => { + logger.debug("🎯 [CreateQuizTool] Render:", { args, result, status: status?.type }); + }, [args, result, status]); + + useOptimisticToolUpdate(status, result, workspaceId); + + let parsed: QuizResult | null = null; + try { + parsed = result != null ? parseQuizResult(result) : null; + } catch (err) { + // If we're still running, ignore parsing errors (likely partial data) + if (status.type !== "running") { + throw err; + } + } - return ( - - {content} - - ); - }, + let content: ReactNode = null; + + if (parsed?.success) { + content = ( + + ); + } else if (status.type === "running") { + content = ; + } else if ( + (status.type === "incomplete" && status.reason === "error") || + (status.type === "complete" && parsed && !parsed.success) + ) { + content = ( +
+
+ +

Failed to create quiz

+
+ {parsed && !parsed.success && parsed.message && ( +

{parsed.message}

+ )} +
+ ); + } + + return ( + + {content} + + ); + }, }); From e980a058e56c98ece3be66ad4e23fb79e4dd383d Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:10:51 -0500 Subject: [PATCH 4/5] Update AssistantPanel.tsx --- src/components/assistant-ui/AssistantPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/assistant-ui/AssistantPanel.tsx b/src/components/assistant-ui/AssistantPanel.tsx index fa97d279..6d9058d5 100644 --- a/src/components/assistant-ui/AssistantPanel.tsx +++ b/src/components/assistant-ui/AssistantPanel.tsx @@ -119,7 +119,7 @@ function CreateFromPromptHandler({ setIsChatExpanded?.(true); - const wrapped = `Create a workspace about: ${createFrom}. Please create notes, flashcards, and a quiz on this topic.`; + const wrapped = `Create a workspace about: ${createFrom}. Please create notes, flashcards, a quiz, and search for YouTube videos on this topic if relevant.`; let attempts = 0; const maxAttempts = 12; From d4438d1e666b7911a67dfd92b49e2ad9f36a3159 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:18:52 -0500 Subject: [PATCH 5/5] fix: remove team from footer --- src/components/landing/Footer.tsx | 49 ++++--------------------------- 1 file changed, 5 insertions(+), 44 deletions(-) diff --git a/src/components/landing/Footer.tsx b/src/components/landing/Footer.tsx index 4a9e7785..26da87c4 100644 --- a/src/components/landing/Footer.tsx +++ b/src/components/landing/Footer.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import Link from "next/link"; import Image from "next/image"; -import { Linkedin } from "lucide-react"; + export function Footer() { const currentYear = new Date().getFullYear(); @@ -32,7 +32,7 @@ export function Footer() {
{/* Brand Column */} -
+
{/* Product Column */} -
+

Product

  • Home
  • @@ -82,7 +82,7 @@ export function Footer() {
{/* Community Column */} -
+

Community

  • @@ -112,46 +112,7 @@ export function Footer() {
- {/* Team Column - Adjusted col-span */} -
-

Team

-
    -
  • -
    - - - -
    - Ishaan Chakraborty - CEO | Prev. MLOps @ Children's National Hospital -
    -
    -
  • -
  • -
    - - - -
    - Urjit Chakraborty - CTO | Prev. Backend @ Exiger -
    -
    -
  • -
-
+ {/* Mobile Only: Legal/Copyright at Bottom */}