diff --git a/package.json b/package.json index a9b13026..40e4339b 100644 --- a/package.json +++ b/package.json @@ -30,34 +30,34 @@ "packageManager": "pnpm@10.14.0", "dependencies": { "@ai-sdk/devtools": "^0.0.15", - "@ai-sdk/google": "^3.0.55", - "@assistant-ui/react": "^0.12.21", - "@assistant-ui/react-ai-sdk": "^1.3.16", - "@assistant-ui/react-devtools": "^1.0.2", - "@assistant-ui/react-markdown": "^0.12.7", - "@embedpdf/core": "^2.10.1", - "@embedpdf/engines": "^2.10.1", - "@embedpdf/models": "^2.10.1", - "@embedpdf/plugin-annotation": "^2.10.1", - "@embedpdf/plugin-capture": "^2.10.1", - "@embedpdf/plugin-document-manager": "^2.10.1", - "@embedpdf/plugin-export": "^2.10.1", - "@embedpdf/plugin-fullscreen": "^2.10.1", - "@embedpdf/plugin-history": "^2.10.1", - "@embedpdf/plugin-interaction-manager": "^2.10.1", - "@embedpdf/plugin-pan": "^2.10.1", - "@embedpdf/plugin-render": "^2.10.1", - "@embedpdf/plugin-rotate": "^2.10.1", - "@embedpdf/plugin-scroll": "^2.10.1", - "@embedpdf/plugin-search": "^2.10.1", - "@embedpdf/plugin-selection": "^2.10.1", - "@embedpdf/plugin-thumbnail": "^2.10.1", - "@embedpdf/plugin-tiling": "^2.10.1", - "@embedpdf/plugin-viewport": "^2.10.1", - "@embedpdf/plugin-zoom": "^2.10.1", + "@ai-sdk/google": "^3.0.58", + "@assistant-ui/react": "^0.12.23", + "@assistant-ui/react-ai-sdk": "^1.3.17", + "@assistant-ui/react-devtools": "^1.0.4", + "@assistant-ui/react-markdown": "^0.12.8", + "@embedpdf/core": "^2.14.0", + "@embedpdf/engines": "^2.14.0", + "@embedpdf/models": "^2.14.0", + "@embedpdf/plugin-annotation": "^2.14.0", + "@embedpdf/plugin-capture": "^2.14.0", + "@embedpdf/plugin-document-manager": "^2.14.0", + "@embedpdf/plugin-export": "^2.14.0", + "@embedpdf/plugin-fullscreen": "^2.14.0", + "@embedpdf/plugin-history": "^2.14.0", + "@embedpdf/plugin-interaction-manager": "^2.14.0", + "@embedpdf/plugin-pan": "^2.14.0", + "@embedpdf/plugin-render": "^2.14.0", + "@embedpdf/plugin-rotate": "^2.14.0", + "@embedpdf/plugin-scroll": "^2.14.0", + "@embedpdf/plugin-search": "^2.14.0", + "@embedpdf/plugin-selection": "^2.14.0", + "@embedpdf/plugin-thumbnail": "^2.14.0", + "@embedpdf/plugin-tiling": "^2.14.0", + "@embedpdf/plugin-viewport": "^2.14.0", + "@embedpdf/plugin-zoom": "^2.14.0", "@floating-ui/react": "^0.27.19", - "@google/genai": "^1.42.0", - "@lottiefiles/dotlottie-react": "^0.18.7", + "@google/genai": "^1.48.0", + "@lottiefiles/dotlottie-react": "^0.18.9", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", @@ -86,26 +86,26 @@ "@supabase/supabase-js": "^2.101.1", "@tanstack/react-query": "^5.96.1", "@tanstack/react-query-devtools": "^5.96.1", - "@tiptap/core": "3.22.1", - "@tiptap/extension-code-block": "3.22.1", - "@tiptap/extension-highlight": "3.22.1", - "@tiptap/extension-horizontal-rule": "3.22.1", - "@tiptap/extension-image": "3.22.1", - "@tiptap/extension-list": "3.22.1", - "@tiptap/extension-mathematics": "3.22.1", - "@tiptap/extension-subscript": "3.22.1", - "@tiptap/extension-superscript": "3.22.1", - "@tiptap/extension-table": "3.22.1", - "@tiptap/extension-text-align": "3.22.1", - "@tiptap/extension-typography": "3.22.1", - "@tiptap/extensions": "3.22.1", - "@tiptap/markdown": "3.22.1", - "@tiptap/pm": "3.22.1", - "@tiptap/react": "3.22.1", - "@tiptap/starter-kit": "3.22.1", + "@tiptap/core": "3.22.2", + "@tiptap/extension-code-block": "3.22.2", + "@tiptap/extension-highlight": "3.22.2", + "@tiptap/extension-horizontal-rule": "3.22.2", + "@tiptap/extension-image": "3.22.2", + "@tiptap/extension-list": "3.22.2", + "@tiptap/extension-mathematics": "3.22.2", + "@tiptap/extension-subscript": "3.22.2", + "@tiptap/extension-superscript": "3.22.2", + "@tiptap/extension-table": "3.22.2", + "@tiptap/extension-text-align": "3.22.2", + "@tiptap/extension-typography": "3.22.2", + "@tiptap/extensions": "3.22.2", + "@tiptap/markdown": "3.22.2", + "@tiptap/pm": "3.22.2", + "@tiptap/react": "3.22.2", + "@tiptap/starter-kit": "3.22.2", "@vercel/otel": "^2.1.1", - "ai": "^6.0.143", - "assistant-stream": "^0.3.8", + "ai": "^6.0.146", + "assistant-stream": "^0.3.10", "better-auth": "^1.5.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -116,15 +116,15 @@ "gsap": "^3.14.2", "heic2any": "^0.0.4", "jsonrepair": "^3.13.2", - "katex": "^0.16.44", + "katex": "^0.16.45", "lodash.throttle": "^4.1.1", "lucide-react": "^1.7.0", "next": "16.2.2", "next-themes": "^0.4.6", "parse-diff": "^0.11.1", "postcss": "^8.5.8", - "postgres": "^3.4.7", - "posthog-js": "^1.364.6", + "postgres": "^3.4.9", + "posthog-js": "^1.364.7", "posthog-node": "^5.28.11", "prosemirror-highlight": "^0.15.1", "radix-ui": "^1.4.3", @@ -137,14 +137,14 @@ "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-quizlet-flashcard": "^4.0.22", - "react-resizable-panels": "^4.7.5", + "react-resizable-panels": "^4.9.0", "react-shiki": "^0.9.1", "react-speech-recognition": "^4.0.1", "regenerator-runtime": "^0.14.1", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "resend": "^6.9.2", + "resend": "^6.10.0", "server-only": "^0.0.1", "shiki": "^4.0.2", "sonner": "^2.0.7", @@ -152,7 +152,7 @@ "tailwind-merge": "^3.5.0", "tw-shimmer": "^0.4.9", "unicodeit": "^0.7.5", - "workflow": "4.2.0-beta.75", + "workflow": "4.2.0-beta.76", "zod": "^4.3.6", "zustand": "^5.0.11" }, diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 05bec851..b23fbcd8 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -36,40 +36,6 @@ function extractWorkspaceId(body: any): string | null { return null; } -/** - * Process messages in a single pass: clean URL context markers - * File attachments are handled natively as file parts via the SupabaseAttachmentAdapter. - */ -function processMessages(messages: any[]): { - cleanedMessages: any[]; -} { - const cleanedMessages = messages.map((message) => { - if (message.content && Array.isArray(message.content)) { - const updatedContent = message.content.map((part: any) => { - if (part.type === "text" && typeof part.text === "string") { - const text = part.text; - - const updatedText = text.replace( - /\[URL_CONTEXT:(.+?)\]/g, - (_match: string, url: string) => { - return url; - }, - ); - - return { ...part, text: updatedText }; - } - return part; - }); - return { ...message, content: updatedContent } as typeof message; - } - return message; - }); - - return { - cleanedMessages, - }; -} - /** * Selected cards context is now formatted on the client side and sent directly. * This eliminates the need for server-side database fetch. @@ -167,10 +133,11 @@ async function handlePOST(req: Request) { activeFolderId, threadId, clientTools: body.tools, - enableMagicFetch: true, // experiment: log AI data requests to PostHog }); - const compatibleMessages = normalizeLegacyToolMessages(messages); + const compatibleMessages = normalizeLegacyToolMessages(messages, { + availableToolNames: Object.keys(tools), + }); const validation = await safeValidateUIMessages({ messages: compatibleMessages, @@ -206,9 +173,6 @@ async function handlePOST(req: Request) { emptyMessages: "remove", }); - // Process messages in single pass: clean URL context markers - const { cleanedMessages } = processMessages(convertedMessages); - // Get pre-formatted selected cards context from client (no DB fetch needed) const selectedCardsContext = getSelectedCardsContext(body); @@ -228,7 +192,7 @@ async function handlePOST(req: Request) { // Inject selected cards + reply selections into the last user message injectSelectionContext( - cleanedMessages, + convertedMessages, body.metadata?.custom, selectedCardsContext, ); @@ -253,15 +217,15 @@ async function handlePOST(req: Request) { }); // Stream the response - logger.debug("πŸ” [CHAT-API] Final cleanedMessages before streamText:", { - count: cleanedMessages.length, + logger.debug("πŸ” [CHAT-API] Final messages before streamText:", { + count: convertedMessages.length, modelId, }); // Configure Google Thinking capabilities const googleConfig: any = { grounding: { - // googleSearchRetrieval removed to force usage of explicit webSearch tool + // googleSearchRetrieval removed to force usage of explicit web_search tool }, thinkingConfig: { includeThoughts: true, @@ -297,7 +261,7 @@ async function handlePOST(req: Request) { model: model, temperature: 1.0, system, - messages: cleanedMessages, + messages: convertedMessages, stopWhen: stepCountIs(25), tools, providerOptions, diff --git a/src/app/api/workspaces/autogen/route.ts b/src/app/api/workspaces/autogen/route.ts index ed0bb205..dd7b82f2 100644 --- a/src/app/api/workspaces/autogen/route.ts +++ b/src/app/api/workspaces/autogen/route.ts @@ -148,9 +148,9 @@ If needsSearch=true, provide a searchQuery (2-6 words) to look up.`, } const query = String(output.searchQuery).trim(); - send({ type: "toolCall", data: { toolName: "webSearch", query, status: "searching" } }); + send({ type: "toolCall", data: { toolName: "web_search", query, status: "searching" } }); const { text, sources } = await executeWebSearch(query); - send({ type: "toolResult", data: { toolName: "webSearch", status: "done" } }); + send({ type: "toolResult", data: { toolName: "web_search", status: "done" } }); const searchContext = `\n\nCONTEXT FROM WEB SEARCH (use this to inform your response):\n${text}`; return { searchContext, sources }; @@ -172,7 +172,7 @@ async function runDistillationAgent( const nonYtLinks = links?.filter((l) => !isYouTubeUrl(l)) ?? []; let linkContext = ""; if (nonYtLinks.length > 0) { - send({ type: "toolCall", data: { toolName: "urlFetch", status: "fetching" } }); + send({ type: "toolCall", data: { toolName: "web_fetch", status: "fetching" } }); const client = new FirecrawlClient(); const results = await client.scrapeUrls(nonYtLinks); const successful = results.filter((r) => r.success && r.content); @@ -186,7 +186,7 @@ async function runDistillationAgent( }) .join("\n\n---\n\n"); } - send({ type: "toolResult", data: { toolName: "urlFetch", status: "done" } }); + send({ type: "toolResult", data: { toolName: "web_fetch", status: "done" } }); } const userMessage = buildUserMessage(prompt, fileUrls, links); diff --git a/src/app/generate/page.tsx b/src/app/generate/page.tsx index 38e92fb5..7ca882f9 100644 --- a/src/app/generate/page.tsx +++ b/src/app/generate/page.tsx @@ -12,6 +12,7 @@ import { WorkspaceProvider } from "@/contexts/WorkspaceContext"; import { MobileWarning } from "@/components/ui/MobileWarning"; import { FloatingWorkspaceCards } from "@/components/background/FloatingWorkspaceCards"; import { ATTACHMENTS_SESSION_KEY } from "@/contexts/HomeAttachmentsContext"; +import { matchesWebSearchStreamToolName } from "@/lib/ai/chat-tool-names"; const PROGRESS_LABELS: Record = { understanding: "Understanding content...", @@ -127,12 +128,12 @@ function GenerateContent() { if (ev.type === "phase" && ev.data?.stage === "understanding") { applyIfNotAborted(() => setProgressText("Understanding content...")); - } else if (ev.type === "toolCall" && ev.data?.toolName === "webSearch" && ev.data?.query) { + } else if (ev.type === "toolCall" && matchesWebSearchStreamToolName(ev.data?.toolName) && ev.data?.query) { const q = ev.data.query; applyIfNotAborted(() => setProgressText(`Searching the web for: "${q.length > 50 ? q.slice(0, 50) + "..." : q}"`) ); - } else if (ev.type === "toolResult" && ev.data?.toolName === "webSearch") { + } else if (ev.type === "toolResult" && matchesWebSearchStreamToolName(ev.data?.toolName)) { applyIfNotAborted(() => setProgressText("Processing search results...")); } else if (ev.type === "partial" && ev.data?.partial) { const p = ev.data.partial as Record; diff --git a/src/components/assistant-ui/AddYoutubeVideoToolUI.tsx b/src/components/assistant-ui/AddYoutubeVideoToolUI.tsx index 35d4c4b3..77702116 100644 --- a/src/components/assistant-ui/AddYoutubeVideoToolUI.tsx +++ b/src/components/assistant-ui/AddYoutubeVideoToolUI.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { AssistantToolUIProps } from "@assistant-ui/react"; import { X, Eye, Play } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -14,6 +14,21 @@ import { parseWorkspaceResult } from "@/lib/ai/tool-result-schemas"; type AddYoutubeVideoArgs = { videoId: string; title: string }; +function getToolStatusErrorMessage(status: unknown): string | undefined { + if (!status || typeof status !== "object") return undefined; + const value = status as Record; + if (typeof value.error === "string") return value.error; + if (typeof value.message === "string") return value.message; + if ( + value.payload && + typeof value.payload === "object" && + typeof (value.payload as Record).message === "string" + ) { + return (value.payload as Record).message as string; + } + return undefined; +} + interface AddYoutubeVideoReceiptProps { args: AddYoutubeVideoArgs; result: WorkspaceResult; @@ -92,52 +107,49 @@ const AddYoutubeVideoReceipt = ({ ); }; -export const AddYoutubeVideoToolUI = makeAssistantToolUI< +export const renderAddYoutubeVideoToolUI: AssistantToolUIProps< AddYoutubeVideoArgs, WorkspaceResult ->({ - toolName: "addYoutubeVideo", - render: function AddYoutubeVideoToolUI({ args, result, status }) { - // Don't try to parse while still running - wait for completion - let parsed: WorkspaceResult | null = null; - if (status.type === "complete" && result != null) { - try { - parsed = parseWorkspaceResult(result); - } catch (err) { - // Log the error but don't throw - we'll show error state below - console.error("πŸŽ₯ [AddYoutubeVideoTool] Failed to parse result:", err); - parsed = null; - } +>["render"] = ({ args, result, status }) => { + let parsed: WorkspaceResult | null = null; + if (status.type === "complete" && result != null) { + try { + parsed = parseWorkspaceResult(result); + } catch (err) { + console.error("πŸŽ₯ [AddYoutubeVideoTool] Failed to parse result:", err); + parsed = null; } + } - let content: ReactNode = null; - - if (parsed?.success) { - content = ( - - ); - } else if (status.type === "running") { - content = ; - } else if (status.type === "complete" && parsed && !parsed.success) { - content = ( - - ); - } else if (status.type === "incomplete" && status.reason === "error") { - content = ( - - ); - } + let content: ReactNode = null; + const statusErrorMessage = + status.type === "incomplete" && status.reason === "error" + ? getToolStatusErrorMessage(status) + : undefined; - return ( - - {content} - + if (parsed?.success) { + content = ; + } else if (status.type === "running") { + content = ; + } else if (status.type === "complete" && parsed && !parsed.success) { + content = ( + ); - }, -}); + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( + + ); + } + + return ( + + {content} + + ); +}; diff --git a/src/components/assistant-ui/CreateDocumentToolUI.tsx b/src/components/assistant-ui/CreateDocumentToolUI.tsx index ec3d3c63..196bb319 100644 --- a/src/components/assistant-ui/CreateDocumentToolUI.tsx +++ b/src/components/assistant-ui/CreateDocumentToolUI.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from "react"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { AssistantToolUIProps } from "@assistant-ui/react"; import { X, Eye, FolderInput, FileText } from "lucide-react"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { Button } from "@/components/ui/button"; @@ -25,6 +25,11 @@ import { logger } from "@/lib/utils/logger"; import type { Item } from "@/lib/workspace-state/types"; type CreateDocumentArgs = { title: string; content: string }; +type CreateDocumentToolRendererProps = { + args: CreateDocumentArgs; + result?: WorkspaceResult; + status: { type: string; reason?: string }; +}; interface CreateDocumentReceiptProps { args: CreateDocumentArgs; @@ -174,72 +179,84 @@ const CreateDocumentReceipt = ({ ); }; -export const CreateDocumentToolUI = makeAssistantToolUI< +export const renderCreateDocumentToolUI: AssistantToolUIProps< CreateDocumentArgs, WorkspaceResult ->({ - toolName: "createDocument", - render: function CreateDocumentToolUIRender({ 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, - ); +>["render"] = (props) => { + return ; +}; - let parsed: WorkspaceResult | null = null; - if (status.type === "complete" && result != null) { - try { - parsed = parseWorkspaceResult(result); - } catch (err) { - logger.error("🎨 [CreateDocumentTool] Failed to parse result:", err); - parsed = null; - } - } +function CreateDocumentToolRenderer({ + args, + result, + status, +}: CreateDocumentToolRendererProps) { + 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, + ); - let content: ReactNode = null; - - if (parsed?.success) { - content = ( - - ); - } else if (status.type === "complete" && parsed && !parsed.success) { - content = ( - - ); - } else if (status.type === "running") { - content = ; - } else if (status.type === "incomplete" && status.reason === "error") { - content = ( - - ); + let parsed: WorkspaceResult | null = null; + if (status.type === "complete" && result != null) { + try { + parsed = parseWorkspaceResult(result); + } catch (err) { + logger.error("🎨 [CreateDocumentTool] Failed to parse result:", err); + parsed = null; } + } - return ( - - {content} - + let content: ReactNode = null; + + if (parsed?.success) { + content = ( + + ); + } else if (status.type === "complete" && parsed && !parsed.success) { + content = ( + ); - }, -}); + } else if (status.type === "complete" && !parsed) { + content = ( + + ); + } else if (status.type === "running") { + content = ; + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( + + ); + } + + return ( + + {content} + + ); +} diff --git a/src/components/assistant-ui/CreateFlashcardToolUI.tsx b/src/components/assistant-ui/CreateFlashcardToolUI.tsx index 150b1839..fefc6c71 100644 --- a/src/components/assistant-ui/CreateFlashcardToolUI.tsx +++ b/src/components/assistant-ui/CreateFlashcardToolUI.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useMemo } from "react"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { AssistantToolUIProps } from "@assistant-ui/react"; import { X, Eye, FolderInput } from "lucide-react"; import { PiCardsThreeBold } from "react-icons/pi"; import { logger } from "@/lib/utils/logger"; @@ -33,6 +33,11 @@ type CreateFlashcardArgs = title?: string; cards?: Array<{ front: string; back: string }>; }; +type CreateFlashcardToolRendererProps = { + args: CreateFlashcardArgs; + result?: FlashcardResult; + status: { type: string; reason?: string; error?: unknown }; +}; function isCreateFlashcardArgsObject( args: CreateFlashcardArgs, @@ -214,93 +219,106 @@ const CreateFlashcardReceipt = ({ ); }; -export const CreateFlashcardToolUI = makeAssistantToolUI< +export const renderCreateFlashcardToolUI: AssistantToolUIProps< CreateFlashcardArgs, FlashcardResult ->({ - toolName: "createFlashcards", - render: function CreateFlashcardUI({ args, result, status }) { - const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - const { state: workspaceState } = useWorkspaceState(workspaceId); - const operations = useWorkspaceOperations( - workspaceId, - workspaceState || initialState, - ); +>["render"] = (props) => { + return ; +}; - const workspaceContext = useWorkspaceContext(); - const currentWorkspace = workspaceContext.workspaces.find( - (w) => w.id === workspaceId, - ); +function CreateFlashcardToolRenderer({ + args, + result, + status, +}: CreateFlashcardToolRendererProps) { + const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); + const { state: workspaceState } = useWorkspaceState(workspaceId); + const operations = useWorkspaceOperations( + workspaceId, + workspaceState || initialState, + ); - // Don't try to parse while still running - wait for completion - let parsed: FlashcardResult | null = null; - if (status.type === "complete" && result != null) { - try { - parsed = parseFlashcardResult(result); - } catch (err) { - // Log the error but don't throw - we'll show error state below - logger.error("🎨 [CreateFlashcardTool] Failed to parse result:", err); - parsed = null; - } - } + const workspaceContext = useWorkspaceContext(); + const currentWorkspace = workspaceContext.workspaces.find( + (w) => w.id === workspaceId, + ); - useEffect(() => { - logger.group(`🎨 [CreateFlashcardTool] RENDER CALLED`, true); - logger.debug("Args:", args ? JSON.stringify({ args }, null, 2) : "null"); - logger.debug( - "Result:", - result ? JSON.stringify(result, null, 2) : "null", - ); - logger.debug( - "Status:", - status ? JSON.stringify(status, null, 2) : "null", - ); - logger.debug("Status type:", status?.type); - logger.debug("Workspace ID:", workspaceId); - logger.debug("Result itemId:", parsed?.itemId); - logger.groupEnd(); - }, [args, result, status, workspaceId, parsed?.itemId]); + const parsed = useMemo(() => { + if (status.type !== "complete" || result == null) { + return null; + } + try { + return parseFlashcardResult(result); + } catch (err) { + logger.error("🎨 [CreateFlashcardTool] Failed to parse result:", err); + return null; + } + }, [result, status.type]); - let content: ReactNode = null; + useEffect(() => { + logger.group(`🎨 [CreateFlashcardTool] RENDER CALLED`, true); + logger.debug("Args:", args ? JSON.stringify({ args }, null, 2) : "null"); + logger.debug( + "Result:", + result ? JSON.stringify(result, null, 2) : "null", + ); + logger.debug( + "Status:", + status ? JSON.stringify(status, null, 2) : "null", + ); + logger.debug("Status type:", status?.type); + logger.debug("Workspace ID:", workspaceId); + logger.debug("Result itemId:", parsed?.itemId); + logger.groupEnd(); + }, [args, result, status, workspaceId, parsed?.itemId]); - if (parsed?.success) { - logger.debug("βœ… [CreateFlashcardTool] Rendering receipt with result"); - content = ( - - ); - } else if (status.type === "running") { - logger.debug( - "⏳ [CreateFlashcardTool] Rendering loading state - status is running", - ); - content = ; - } else if (status.type === "incomplete" && status.reason === "error") { - content = ( - - ); - } else { - logger.debug( - "❓ [CreateFlashcardTool] Rendering null - no result and status is not running", - ); - } + let content: ReactNode = null; - return ( - - {content} - + if (parsed?.success) { + logger.debug("βœ… [CreateFlashcardTool] Rendering receipt with result"); + content = ( + ); - }, -}); + } else if (status.type === "running") { + logger.debug( + "⏳ [CreateFlashcardTool] Rendering loading state - status is running", + ); + content = ; + } else if (status.type === "complete" && !parsed) { + logger.error("🎨 [CreateFlashcardTool] Complete status had no parseable result"); + content = ( + + ); + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( + + ); + } else { + logger.debug( + "❓ [CreateFlashcardTool] Rendering null - no result and status is not running", + ); + } + + return ( + + {content} + + ); +} diff --git a/src/components/assistant-ui/CreateQuizToolUI.tsx b/src/components/assistant-ui/CreateQuizToolUI.tsx index b593188a..064d2c88 100644 --- a/src/components/assistant-ui/CreateQuizToolUI.tsx +++ b/src/components/assistant-ui/CreateQuizToolUI.tsx @@ -3,7 +3,7 @@ import type { MouseEvent, ReactNode } from "react"; import { useEffect, useState, useMemo } from "react"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { AssistantToolUIProps } from "@assistant-ui/react"; import { X, Eye, FolderInput, Brain } from "lucide-react"; import { logger } from "@/lib/utils/logger"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; @@ -28,6 +28,12 @@ type ToolStatus = { reason?: string; }; +type CreateQuizToolRendererProps = { + args: CreateQuizInput; + result?: QuizResult; + status: { type: string; reason?: string }; +}; + interface CreateQuizReceiptProps { result: QuizResult; status: ToolStatus; @@ -192,79 +198,87 @@ const CreateQuizReceipt = ({ ); }; -export const CreateQuizToolUI = makeAssistantToolUI< +export const renderCreateQuizToolUI: AssistantToolUIProps< CreateQuizInput, QuizResult ->({ - 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, - ); +>["render"] = (props) => { + return ; +}; - useEffect(() => { - logger.debug("🎯 [CreateQuizTool] Render:", { - args, - result, - status: status?.type, - }); - }, [args, result, status]); +function CreateQuizToolRenderer({ + args, + result, + status, +}: CreateQuizToolRendererProps) { + 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, + ); - // Don't try to parse while still running - wait for completion - let parsed: QuizResult | null = null; - if (status.type === "complete" && result != null) { - try { - // With the new Output.object() approach, the quiz worker returns clean structured data - // so we can parse normally without special streaming handling - parsed = parseQuizResult(result); - } catch (err) { - // Log the error but don't throw - we'll show error state below - logger.error("🎯 [CreateQuizTool] Failed to parse result:", err); - parsed = null; - } + useEffect(() => { + if (process.env.NODE_ENV === "production") { + return; } + logger.debug("🎯 [CreateQuizTool] Render:", { + args, + result, + status: status?.type, + }); + }, [args, result, status]); - 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 = ( - - ); + let parsed: QuizResult | null = null; + if (status.type === "complete" && result != null) { + try { + parsed = parseQuizResult(result); + } catch (err) { + logger.error("🎯 [CreateQuizTool] Failed to parse result:", err); + parsed = null; } + } - return ( - - {content} - + let content: ReactNode = null; + + if (status.type === "running") { + content = ; + } else if (status.type === "complete" && parsed?.success) { + content = ( + + ); + } else if (status.type === "complete" && parsed && !parsed.success) { + content = ( + ); - }, -}); + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( + + ); + } + + return ( + + {content} + + ); +} diff --git a/src/components/assistant-ui/EditItemToolUI.tsx b/src/components/assistant-ui/EditItemToolUI.tsx index 2297b845..94574f78 100644 --- a/src/components/assistant-ui/EditItemToolUI.tsx +++ b/src/components/assistant-ui/EditItemToolUI.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; import { useMemo } from "react"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { AssistantToolUIProps } from "@assistant-ui/react"; import { X, Eye } from "lucide-react"; import { Pencil } from "lucide-react"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; @@ -27,6 +27,14 @@ type EditItemArgs = { newName?: string; }; +function getToolStatusErrorMessage(status: unknown): string | undefined { + if (!status || typeof status !== "object") return undefined; + const value = status as Record; + if (typeof value.error === "string") return value.error; + if (typeof value.message === "string") return value.message; + return undefined; +} + interface EditItemResult extends WorkspaceResult { diff?: string; filediff?: { additions: number; deletions: number }; @@ -146,53 +154,52 @@ const EditItemReceipt = ({ args, result, status }: EditItemReceiptProps) => { ); }; -export const EditItemToolUI = makeAssistantToolUI< +export const renderEditItemToolUI: AssistantToolUIProps< EditItemArgs, WorkspaceResult ->({ - toolName: "editItem", - render: function EditItemUI({ args, result, status }) { - let parsed: WorkspaceResult | null = null; - if (status.type === "complete" && result != null) { - try { - parsed = parseWorkspaceResult(result); - } catch (err) { - console.error("[EditItemTool] Failed to parse result:", err); - parsed = null; - } - } - - let content: ReactNode = null; - - if (parsed?.success) { - content = ( - - ); - } else if (status.type === "running") { - const itemName = args?.itemName; - content = ( - - ); - } else if (status.type === "complete" && parsed && !parsed.success) { - content = ( - - ); - } else if (status.type === "incomplete" && status.reason === "error") { - content = ( - - ); +>["render"] = ({ args, result, status }) => { + let parsed: WorkspaceResult | null = null; + if (status.type === "complete" && result != null) { + try { + parsed = parseWorkspaceResult(result); + } catch (err) { + console.error("[EditItemTool] Failed to parse result:", err); + parsed = null; } - - return ( - - {content} - + } + + let content: ReactNode = null; + const statusErrorMessage = + getToolStatusErrorMessage(status) ?? "An error occurred while editing"; + + if (parsed?.success) { + content = ( + ); - }, -}); + } else if (status.type === "running") { + const itemName = args?.itemName; + content = ( + + ); + } else if (status.type === "complete" && parsed && !parsed.success) { + content = ( + + ); + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( + + ); + } + + return ( + + {content} + + ); +}; diff --git a/src/components/assistant-ui/ExecuteCodeToolUI.tsx b/src/components/assistant-ui/ExecuteCodeToolUI.tsx deleted file mode 100644 index 163c11bf..00000000 --- a/src/components/assistant-ui/ExecuteCodeToolUI.tsx +++ /dev/null @@ -1,271 +0,0 @@ -"use client"; - -import { CodeIcon, ChevronDownIcon } from "lucide-react"; -import { - useCallback, - useRef, - useState, - type FC, - type PropsWithChildren, -} from "react"; - -import { - useScrollLock, - makeAssistantToolUI, -} from "@assistant-ui/react"; - -import { StandaloneMarkdown } from "@/components/assistant-ui/standalone-markdown"; -import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; -import { parseStringResult } from "@/lib/ai/tool-result-schemas"; -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"; - -/** - * Text content wrapper that animates the tool text visibility. - */ -const ToolText: FC< - PropsWithChildren<{ - className?: string; - }> -> = ({ className, children }) => ( -
- {children} -
-); - -ToolText.displayName = "ToolText"; - -type ExecuteCodeResult = - | string - | { - text?: string; - result?: string; - value?: string; - }; - -/** - * Inner component that handles result parsing inside the error boundary. - */ -const ExecuteCodeContent: FC<{ - args: { task: string }; - status: { type: string }; - result: ExecuteCodeResult | null; -}> = ({ args, status, result }) => { - const isRunning = status.type === "running"; - // Parse inside the boundary so errors are caught - const parsed = result != null ? parseStringResult(result) : null; - - return ( - - } - /> - - - -
-
- Task: -

{args.task}

-
- - {isRunning && ( -
Executing code...
- )} - - {parsed != null && ( -
- Result: -
- {parsed} -
-
- )} -
-
-
-
- ); -}; - -ExecuteCodeContent.displayName = "ExecuteCodeContent"; - -/** - * Tool UI component for executeCode tool. - * Displays code execution task and results in a collapsible format similar to Reasoning. - */ -export const ExecuteCodeToolUI = makeAssistantToolUI<{ - task: string; -}, ExecuteCodeResult>({ - toolName: "executeCode", - render: function ExecuteCodeToolUI({ args, status, result }) { - return ( - - - - ); - }, -}); - - diff --git a/src/components/assistant-ui/MagicFetchToolUI.tsx b/src/components/assistant-ui/MagicFetchToolUI.tsx deleted file mode 100644 index 62847757..00000000 --- a/src/components/assistant-ui/MagicFetchToolUI.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import { makeAssistantToolUI } from "@assistant-ui/react"; - -type MagicFetchArgs = { description: string }; -type MagicFetchResult = string; - -/** - * Invisible Tool UI for magicFetch. Registers the tool with assistant-ui - * so it doesn't fall back to the generic ToolFallback, but renders nothing. - */ -export const MagicFetchToolUI = makeAssistantToolUI< - MagicFetchArgs, - MagicFetchResult ->({ - toolName: "magicFetch", - render: () => null, -}); diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx index ce7998f9..7a30f969 100644 --- a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx +++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx @@ -1,7 +1,7 @@ "use client"; import { Eye } from "lucide-react"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { AssistantToolUIProps } from "@assistant-ui/react"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; @@ -22,74 +22,71 @@ function stripExtension(s: string): string { return s.replace(/\.[^.]+$/, ""); } -export const ReadWorkspaceToolUI = makeAssistantToolUI({ - toolName: "readWorkspace", - render: ({ args, status, result }) => { - let content: React.ReactNode = null; +export const renderReadWorkspaceToolUI: AssistantToolUIProps< + ReadArgs, + ReadResult +>["render"] = ({ args, status, result }) => { + let content: React.ReactNode = null; - if (status.type === "running") { - const pageInfo = - args?.pageStart != null && args?.pageEnd != null - ? ` (pages ${args.pageStart}-${args.pageEnd})` - : args?.pageStart != null - ? ` (from page ${args.pageStart})` - : args?.pageEnd != null - ? ` (to page ${args.pageEnd})` - : ""; - const label = args?.path - ? `Reading ${stripExtension(args.path)}${pageInfo}` - : args?.itemName - ? `Reading "${args.itemName}"${pageInfo}` - : "Reading workspace item..."; - content = ; - } else if (status.type === "complete" && result) { - if (!result.success && result.message) { - content = ( - - ); - } else if (result.success) { - const pageLabel = - result.pageRange?.start != null && result.pageRange?.end != null - ? ` pages ${result.pageRange.start}-${result.pageRange.end}` - : result.totalPages != null - ? ` (${result.totalPages} pages)` - : ""; - content = ( -
-
- - - Read -{" "} - {result.path - ? stripExtension(result.path) - : result.itemName} - {pageLabel} - {result.type && ( - - {result.type} - - )} - -
-
- ); - } - } else if (status.type === "incomplete" && status.reason === "error") { - content = ( - - ); - } + if (status.type === "running") { + const pageInfo = + args?.pageStart != null && args?.pageEnd != null + ? ` (pages ${args.pageStart}-${args.pageEnd})` + : args?.pageStart != null + ? ` (from page ${args.pageStart})` + : args?.pageEnd != null + ? ` (to page ${args.pageEnd})` + : ""; + const label = args?.path + ? `Reading ${stripExtension(args.path)}${pageInfo}` + : args?.itemName + ? `Reading "${args.itemName}"${pageInfo}` + : "Reading workspace item..."; + content = ; + } else if (status.type === "complete" && result) { + if (!result.success && result.message) { + content = ( + + ); + } else if (result.success) { + const pageLabel = + result.pageRange?.start != null && result.pageRange?.end != null + ? ` pages ${result.pageRange.start}-${result.pageRange.end}` + : result.totalPages != null + ? ` (${result.totalPages} pages)` + : ""; + content = ( +
+
+ + + Read - {result.path ? stripExtension(result.path) : result.itemName} + {pageLabel} + {result.type && ( + + {result.type} + + )} + +
+
+ ); + } + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( + + ); + } - return ( - - {content} - - ); - }, -}); + return ( + + {content} + + ); +}; diff --git a/src/components/assistant-ui/SearchWorkspaceToolUI.tsx b/src/components/assistant-ui/SearchWorkspaceToolUI.tsx index 564d4de9..e7f67383 100644 --- a/src/components/assistant-ui/SearchWorkspaceToolUI.tsx +++ b/src/components/assistant-ui/SearchWorkspaceToolUI.tsx @@ -1,7 +1,7 @@ "use client"; import { Search } from "lucide-react"; -import { makeAssistantToolUI } from "@assistant-ui/react"; +import type { AssistantToolUIProps } from "@assistant-ui/react"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { ToolUILoadingShell } from "@/components/assistant-ui/tool-ui-loading-shell"; import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell"; @@ -9,47 +9,47 @@ import { ToolUIErrorShell } from "@/components/assistant-ui/tool-ui-error-shell" type GrepArgs = { pattern: string; include?: string; path?: string }; type GrepResult = { success: boolean; matches?: number; output?: string; message?: string }; -export const SearchWorkspaceToolUI = makeAssistantToolUI({ - toolName: "searchWorkspace", - render: ({ status, result }) => { - let content: React.ReactNode = null; +export const renderSearchWorkspaceToolUI: AssistantToolUIProps< + GrepArgs, + GrepResult +>["render"] = ({ status, result }) => { + let content: React.ReactNode = null; - if (status.type === "running") { - content = ; - } else if (status.type === "complete" && result) { - if (!result.success && result.message) { - content = ( - - ); - } else if (result.success) { - content = ( -
- - - Search -{" "} - {result.matches != null - ? `${result.matches} match${result.matches !== 1 ? "es" : ""}` - : "completed"} - -
- ); - } - } else if (status.type === "incomplete" && status.reason === "error") { - content = ( - - ); - } + if (status.type === "running") { + content = ; + } else if (status.type === "complete" && result) { + if (!result.success && result.message) { + content = ( + + ); + } else if (result.success) { + content = ( +
+ + + Search -{" "} + {result.matches != null + ? `${result.matches} match${result.matches !== 1 ? "es" : ""}` + : "completed"} + +
+ ); + } + } else if (status.type === "incomplete" && status.reason === "error") { + content = ( + + ); + } - return ( - - {content} - - ); - }, -}); + return ( + + {content} + + ); +}; diff --git a/src/components/assistant-ui/URLContextToolUI.tsx b/src/components/assistant-ui/URLContextToolUI.tsx index 988ebcb2..3a32a790 100644 --- a/src/components/assistant-ui/URLContextToolUI.tsx +++ b/src/components/assistant-ui/URLContextToolUI.tsx @@ -9,10 +9,7 @@ import { type PropsWithChildren, } from "react"; -import { - useScrollLock, - makeAssistantToolUI, -} from "@assistant-ui/react"; +import { useScrollLock, type AssistantToolUIProps } from "@assistant-ui/react"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { parseURLContextResult } from "@/lib/ai/tool-result-schemas"; @@ -196,7 +193,7 @@ const ToolText: FC< ToolText.displayName = "ToolText"; /** - * Tool UI component for processUrls tool. + * Tool UI component for web_fetch tool. * Displays URLs being processed and their retrieval status. */ type URLMetadata = { @@ -213,55 +210,56 @@ type ProcessUrlsResult = }; }; -export const URLContextToolUI = makeAssistantToolUI<{ +export const renderURLContextToolUI: AssistantToolUIProps<{ urls?: string[]; jsonInput?: string; -}, ProcessUrlsResult>({ - toolName: "processUrls", - render: function URLContextToolUI({ args, status, result }) { - const isRunning = status.type === "running"; - const isComplete = status.type === "complete"; +}, ProcessUrlsResult>["render"] = ({ args, status, result }) => { + const isRunning = status.type === "running"; + const isComplete = status.type === "complete"; - const parsedResult = result != null ? parseURLContextResult(result) : null; - type Meta = { - urlMetadata?: URLMetadata[]; - }; - const metadata = (typeof parsedResult === "object" && parsedResult !== null && "metadata" in parsedResult ? (parsedResult as { metadata?: Meta }).metadata : null) as Meta | null; - const urlMetadata = metadata?.urlMetadata ?? null; + const parsedResult = result != null ? parseURLContextResult(result) : null; + type Meta = { + urlMetadata?: URLMetadata[]; + }; + const metadata = (typeof parsedResult === "object" && parsedResult !== null && "metadata" in parsedResult ? (parsedResult as { metadata?: Meta }).metadata : null) as Meta | null; + const urlMetadata = metadata?.urlMetadata ?? null; - const normalizedArgs = normalizeProcessUrlsArgs(args); - const urls = normalizedArgs?.urls ?? []; + const normalizedArgs = normalizeProcessUrlsArgs(args); + const urls = normalizedArgs?.urls ?? []; - const urlCount = urls.length; - const successfulCount = - urlMetadata?.filter((m) => m.urlRetrievalStatus === "URL_RETRIEVAL_STATUS_SUCCESS").length ?? 0; - const failedCount = (urlMetadata?.length ?? 0) - successfulCount; + const urlCount = urls.length; + const successfulCount = + urlMetadata?.filter((m) => m.urlRetrievalStatus === "URL_RETRIEVAL_STATUS_SUCCESS").length ?? 0; + const failedCount = (urlMetadata?.length ?? 0) - successfulCount; - // Helper to get status badge color - const getStatusColor = (status: string) => { - if (status === "URL_RETRIEVAL_STATUS_SUCCESS") return "bg-green-500/10 text-green-600 border-green-500/20"; - if (status === "URL_RETRIEVAL_STATUS_FAILED") return "bg-red-500/10 text-red-600 border-red-500/20"; - if (status?.includes("ERROR")) return "bg-red-500/10 text-red-600 border-red-500/20"; - return "bg-yellow-500/10 text-yellow-600 border-yellow-500/20"; - }; + // Helper to get status badge color + const getStatusColor = (status: string) => { + if (status === "URL_RETRIEVAL_STATUS_SUCCESS") + return "bg-green-500/10 text-green-600 border-green-500/20"; + if (status === "URL_RETRIEVAL_STATUS_FAILED") + return "bg-red-500/10 text-red-600 border-red-500/20"; + if (status?.includes("ERROR")) + return "bg-red-500/10 text-red-600 border-red-500/20"; + return "bg-yellow-500/10 text-yellow-600 border-yellow-500/20"; + }; - // Helper to format status text - const formatStatus = (status: string) => { - return status - .replace("URL_RETRIEVAL_STATUS_", "") - .replace(/_/g, " ") - .toLowerCase() - .replace(/\b\w/g, (l) => l.toUpperCase()); - }; + // Helper to format status text + const formatStatus = (status: string) => { + return status + .replace("URL_RETRIEVAL_STATUS_", "") + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (l) => l.toUpperCase()); + }; - return ( - - - } - /> + return ( + + + } + /> @@ -333,8 +331,7 @@ export const URLContextToolUI = makeAssistantToolUI<{
- - - ); - }, -}); + + + ); +}; diff --git a/src/components/assistant-ui/WebSearchToolUI.tsx b/src/components/assistant-ui/WebSearchToolUI.tsx index c75f7d87..cc73358a 100644 --- a/src/components/assistant-ui/WebSearchToolUI.tsx +++ b/src/components/assistant-ui/WebSearchToolUI.tsx @@ -9,10 +9,7 @@ import { type PropsWithChildren, } from "react"; -import { - useScrollLock, - makeAssistantToolUI, -} from "@assistant-ui/react"; +import { useScrollLock, type AssistantToolUIProps } from "@assistant-ui/react"; import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; import { parseWebSearchResult } from "@/lib/ai/tool-result-schemas"; @@ -288,18 +285,19 @@ const WebSearchContent: FC<{ WebSearchContent.displayName = "WebSearchContent"; /** - * Tool UI component for webSearch tool. + * Tool UI component for web_search tool. * Displays search query and results in a collapsible format similar to Reasoning. */ -export const WebSearchToolUI = makeAssistantToolUI<{ - query: string; -}, WebSearchResult>({ - toolName: "webSearch", - render: function WebSearchToolUI({ status, result }) { - return ( - - - - ); - }, -}); +export const renderWebSearchToolUI: AssistantToolUIProps< + { query: string }, + WebSearchResult +>["render"] = ({ + status, + result, +}) => { + return ( + + + + ); +}; diff --git a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx index f2549af7..2d5afb07 100644 --- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx +++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx @@ -2,12 +2,15 @@ import { AssistantRuntimeProvider, - unstable_useRemoteThreadListRuntime as useRemoteThreadListRuntime, + Tools, + useRemoteThreadListRuntime, + useAui, } from "@assistant-ui/react"; import { useChatRuntime, AssistantChatTransport, } from "@assistant-ui/react-ai-sdk"; +import type { UIMessage } from "ai"; import { useMemo, useCallback } from "react"; import { AssistantAvailableProvider } from "@/contexts/AssistantAvailabilityContext"; import { useUIStore } from "@/lib/stores/ui-store"; @@ -16,12 +19,26 @@ import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; import { formatSelectedCardsMetadata } from "@/lib/utils/format-workspace-context"; import { createThreadListAdapter } from "@/lib/chat/custom-thread-list-adapter"; import { toCreateMessageWithContext } from "@/lib/chat/toCreateMessageWithContext"; +import { chatToolToolkit } from "@/components/assistant-ui/chat-toolkit"; interface WorkspaceRuntimeProviderProps { workspaceId: string; children: React.ReactNode; } +function createWorkspaceChatRuntimeHook( + transport: AssistantChatTransport, + onError: (error: Error) => void, +) { + return function useWorkspaceChatRuntimeHook() { + return useChatRuntime({ + transport, + onError, + toCreateMessage: toCreateMessageWithContext, + }); + }; +} + export function WorkspaceRuntimeProvider({ workspaceId, children, @@ -157,18 +174,22 @@ export function WorkspaceRuntimeProvider({ [workspaceId, selectedModelId, activeFolderId, selectedCardsContext], ); + const runtimeHook = useMemo( + () => createWorkspaceChatRuntimeHook(transport, handleChatError), + [transport, handleChatError], + ); + const runtime = useRemoteThreadListRuntime({ - runtimeHook: () => - useChatRuntime({ - transport, - onError: handleChatError, - toCreateMessage: toCreateMessageWithContext, - }), + runtimeHook, adapter: threadListAdapter, }); + const aui = useAui({ + tools: Tools({ toolkit: chatToolToolkit }), + }); + return ( - + {children} ); diff --git a/src/components/assistant-ui/YouTubeSearchToolUI.tsx b/src/components/assistant-ui/YouTubeSearchToolUI.tsx index 7d88f65e..0cf25a0d 100644 --- a/src/components/assistant-ui/YouTubeSearchToolUI.tsx +++ b/src/components/assistant-ui/YouTubeSearchToolUI.tsx @@ -1,6 +1,6 @@ "use client"; -import { makeAssistantToolUI, useAui, useScrollLock } from "@assistant-ui/react"; +import { useAui, useScrollLock, type AssistantToolUIProps } from "@assistant-ui/react"; import { Loader2, Plus, Check, ChevronDownIcon } from "lucide-react"; import { YouTubeMark } from "@/components/icons/YouTubeMark"; import { Button } from "@/components/ui/button"; @@ -380,13 +380,13 @@ const YouTubeSearchContent: FC<{ ); }; -export const YouTubeSearchToolUI = makeAssistantToolUI({ - toolName: "searchYoutube", - render: function YouTubeSearchToolUI({ args, status, result }) { - return ( - - - - ); - }, -}); +export const renderYouTubeSearchToolUI: AssistantToolUIProps< + SearchYoutubeArgs, + SearchYoutubeResult +>["render"] = ({ args, status, result }) => { + return ( + + + + ); +}; diff --git a/src/components/assistant-ui/attachment.tsx b/src/components/assistant-ui/attachment.tsx index 78da36e4..241b5eea 100644 --- a/src/components/assistant-ui/attachment.tsx +++ b/src/components/assistant-ui/attachment.tsx @@ -81,6 +81,33 @@ function getFaviconUrl(url: string): string { } } +type UrlLikeAttachment = { + name?: string; + file?: { name?: string } | File; +}; + +function getHttpAttachmentUrl(att: UrlLikeAttachment | undefined): string | undefined { + const name = att?.name; + if (!name) return undefined; + try { + const url = new URL(name); + if (url.protocol === "http:" || url.protocol === "https:") { + return name; + } + } catch { + // Not a valid URL + } + return undefined; +} + +function isUrlAttachment(att: UrlLikeAttachment | undefined): boolean { + if (!att) return false; + if (att.file?.name?.endsWith(".url")) { + return true; + } + return getHttpAttachmentUrl(att) != null; +} + const useAttachmentSrc = () => { const attachmentState = useAuiState( useShallow( @@ -102,70 +129,14 @@ const useAttachmentSrc = () => { isUrl: false, url: undefined, }; - // Check if this is a URL attachment by checking: - // 1. Content with [URL_CONTEXT:...] marker (after send) - // 2. File name ending with .url (before send) - // 3. Attachment name being a valid URL (before send) - - // Check content first (for sent attachments) - const urlContent = att.content?.find( - (c: { type: string; text?: string }) => { - if (c.type === "text") { - const textContent = c as { type: "text"; text: string }; - return ( - typeof textContent.text === "string" && - textContent.text.startsWith("[URL_CONTEXT:") - ); - } - return false; - }, - ); - if (urlContent && urlContent.type === "text") { - const textContent = urlContent as { type: "text"; text: string }; - const urlMatch = textContent.text.match(/\[URL_CONTEXT:(.+?)\]/); - if (urlMatch) { - const url = urlMatch[1]; - return { isUrl: true, src: getFaviconUrl(url), url }; - } - } - - // Check file name (for pending attachments in composer) - if (att.file?.name.endsWith(".url")) { - // Try to extract URL from attachment name first (it's set to the URL in the adapter) - let url: string | null = null; - if (att.name) { - try { - new URL(att.name); - url = att.name; - } catch { - // Not a valid URL in name, try reading from file - // Note: We can't async read the file here, so we'll use the name if it's a URL - // The adapter sets name to the URL when creating URL files - } - } + if (isUrlAttachment(att)) { + const url = getHttpAttachmentUrl(att); if (url) { return { isUrl: true, src: getFaviconUrl(url), url }; } - // If name is not a URL, still mark as URL but without favicon (will show link icon) return { isUrl: true }; } - // Check if attachment name is a valid URL (for pending attachments) - if (att.name) { - try { - const url = new URL(att.name); - if (url.protocol === "http:" || url.protocol === "https:") { - return { - isUrl: true, - src: getFaviconUrl(att.name), - url: att.name, - }; - } - } catch { - // Not a valid URL - } - } - if (att.type !== "image") return {}; if (att.file) return { file: att.file }; const imageContent = att.content?.find( @@ -306,49 +277,10 @@ const AttachmentUI: FC = () => { } | undefined; if (!att) return "File"; - // Check if this is a URL attachment by checking: - // 1. Content with [URL_CONTEXT:...] marker (after send) - // 2. File name ending with .url (before send) - // 3. Attachment name being a valid URL (before send) - - // Check content first (for sent attachments) - const urlContent = att.content?.find( - (c: { type: string; text?: string }) => { - if (c.type === "text") { - const textContent = c as { type: "text"; text: string }; - return ( - typeof textContent.text === "string" && - textContent.text.startsWith("[URL_CONTEXT:") - ); - } - return false; - }, - ); - if (urlContent && urlContent.type === "text") { - const textContent = urlContent as { type: "text"; text: string }; - const urlMatch = textContent.text.match(/\[URL_CONTEXT:(.+?)\]/); - if (urlMatch) { - return "URL"; - } - } - - // Check file name (for pending attachments in composer) - if (att.file?.name.endsWith(".url")) { + if (isUrlAttachment(att)) { return "URL"; } - // Check if attachment name is a valid URL (for pending attachments) - if (att.name) { - try { - const url = new URL(att.name); - if (url.protocol === "http:" || url.protocol === "https:") { - return "URL"; - } - } catch { - // Not a valid URL - } - } - const type = att.type; switch (type) { case "image": @@ -372,50 +304,7 @@ const AttachmentUI: FC = () => { } | undefined; if (!att) return false; - // Check if this is a URL attachment by checking: - // 1. Content with [URL_CONTEXT:...] marker (after send) - // 2. File name ending with .url (before send) - // 3. Attachment name being a valid URL (before send) - - // Check content first (for sent attachments) - const urlContent = att.content?.find( - (c: { type: string; text?: string }) => { - if (c.type === "text") { - const textContent = c as { type: "text"; text: string }; - return ( - typeof textContent.text === "string" && - textContent.text.startsWith("[URL_CONTEXT:") - ); - } - return false; - }, - ); - if (urlContent && urlContent.type === "text") { - const textContent = urlContent as { type: "text"; text: string }; - const urlMatch = textContent.text.match(/\[URL_CONTEXT:(.+?)\]/); - if (urlMatch) { - return true; - } - } - - // Check file name (for pending attachments in composer) - if (att.file?.name.endsWith(".url")) { - return true; - } - - // Check if attachment name is a valid URL (for pending attachments) - if (att.name) { - try { - const url = new URL(att.name); - if (url.protocol === "http:" || url.protocol === "https:") { - return true; - } - } catch { - // Not a valid URL - } - } - - return false; + return isUrlAttachment(att); }); return ( diff --git a/src/components/assistant-ui/chat-toolkit.tsx b/src/components/assistant-ui/chat-toolkit.tsx new file mode 100644 index 00000000..d5d95b50 --- /dev/null +++ b/src/components/assistant-ui/chat-toolkit.tsx @@ -0,0 +1,89 @@ +"use client"; + +import type { Toolkit } from "@assistant-ui/react"; +import { + CHAT_TOOL, + LEGACY_CHAT_TOOL_NAMES, + type ChatToolName, +} from "@/lib/ai/chat-tool-names"; +import { renderAddYoutubeVideoToolUI } from "@/components/assistant-ui/AddYoutubeVideoToolUI"; +import { renderCreateDocumentToolUI } from "@/components/assistant-ui/CreateDocumentToolUI"; +import { renderCreateFlashcardToolUI } from "@/components/assistant-ui/CreateFlashcardToolUI"; +import { renderCreateQuizToolUI } from "@/components/assistant-ui/CreateQuizToolUI"; +import { renderEditItemToolUI } from "@/components/assistant-ui/EditItemToolUI"; +import { renderReadWorkspaceToolUI } from "@/components/assistant-ui/ReadWorkspaceToolUI"; +import { renderSearchWorkspaceToolUI } from "@/components/assistant-ui/SearchWorkspaceToolUI"; +import { renderURLContextToolUI } from "@/components/assistant-ui/URLContextToolUI"; +import { renderWebSearchToolUI } from "@/components/assistant-ui/WebSearchToolUI"; +import { renderYouTubeSearchToolUI } from "@/components/assistant-ui/YouTubeSearchToolUI"; + +function createBackendTool( + render: NonNullable, +): Toolkit[string] { + return { + type: "backend", + render, + }; +} + +function withLegacyNames( + canonical: ChatToolName, + definition: Toolkit[string], +): Toolkit { + const entries: Toolkit = { + [canonical]: definition, + }; + + for (const [legacyName, mappedCanonical] of Object.entries( + LEGACY_CHAT_TOOL_NAMES, + )) { + if (mappedCanonical === canonical) { + entries[legacyName] = definition; + } + } + + return entries; +} + +export const chatToolToolkit: Toolkit = { + ...withLegacyNames( + CHAT_TOOL.WEB_FETCH, + createBackendTool(renderURLContextToolUI), + ), + ...withLegacyNames( + CHAT_TOOL.WEB_SEARCH, + createBackendTool(renderWebSearchToolUI), + ), + ...withLegacyNames( + CHAT_TOOL.WORKSPACE_SEARCH, + createBackendTool(renderSearchWorkspaceToolUI), + ), + ...withLegacyNames( + CHAT_TOOL.WORKSPACE_READ, + createBackendTool(renderReadWorkspaceToolUI), + ), + ...withLegacyNames( + CHAT_TOOL.DOCUMENT_CREATE, + createBackendTool(renderCreateDocumentToolUI), + ), + ...withLegacyNames( + CHAT_TOOL.ITEM_EDIT, + createBackendTool(renderEditItemToolUI), + ), + ...withLegacyNames( + CHAT_TOOL.FLASHCARDS_CREATE, + createBackendTool(renderCreateFlashcardToolUI), + ), + ...withLegacyNames( + CHAT_TOOL.QUIZ_CREATE, + createBackendTool(renderCreateQuizToolUI), + ), + ...withLegacyNames( + CHAT_TOOL.YOUTUBE_SEARCH, + createBackendTool(renderYouTubeSearchToolUI), + ), + ...withLegacyNames( + CHAT_TOOL.YOUTUBE_ADD, + createBackendTool(renderAddYoutubeVideoToolUI), + ), +}; diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index 76e1de6e..528f012f 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -60,19 +60,6 @@ import { import Link from "next/link"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; -import { CreateQuizToolUI } from "@/components/assistant-ui/CreateQuizToolUI"; -import { CreateDocumentToolUI } from "@/components/assistant-ui/CreateDocumentToolUI"; -import { CreateFlashcardToolUI } from "@/components/assistant-ui/CreateFlashcardToolUI"; -import { EditItemToolUI } from "@/components/assistant-ui/EditItemToolUI"; - -import { YouTubeSearchToolUI } from "@/components/assistant-ui/YouTubeSearchToolUI"; -import { AddYoutubeVideoToolUI } from "@/components/assistant-ui/AddYoutubeVideoToolUI"; -import { ExecuteCodeToolUI } from "@/components/assistant-ui/ExecuteCodeToolUI"; -import { URLContextToolUI } from "@/components/assistant-ui/URLContextToolUI"; -import { WebSearchToolUI } from "@/components/assistant-ui/WebSearchToolUI"; -import { SearchWorkspaceToolUI } from "@/components/assistant-ui/SearchWorkspaceToolUI"; -import { ReadWorkspaceToolUI } from "@/components/assistant-ui/ReadWorkspaceToolUI"; -import { MagicFetchToolUI } from "@/components/assistant-ui/MagicFetchToolUI"; import { AIFeedbackDialog } from "@/components/assistant-ui/AIFeedbackDialog"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; @@ -107,7 +94,6 @@ import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operatio import { useFeatureFlagEnabled } from "posthog-js/react"; import { toast } from "sonner"; import { useCreateCardFromMessage } from "@/hooks/ai/use-create-card-from-message"; -import { extractUrls, createUrlFile } from "@/lib/attachments/url-utils"; import { filterItems } from "@/lib/workspace-state/search"; import { useSession } from "@/lib/auth-client"; import { focusComposerInput } from "@/lib/utils/composer-utils"; @@ -134,22 +120,7 @@ export const Thread: FC = ({ items = [] }) => { const viewportRef = useRef(null); return ( - <> - {/* Register tool UI - this component mounts and registers the UI with the assistant runtime */} - - - - - - - - - - - - - - = ({ items = [] }) => { - ); }; @@ -1270,15 +1240,13 @@ const UserMessageTruncateContext = createContext<{ showExpand: boolean; } | null>(null); -// Custom Text component for UserMessage that handles special structure +// Custom Text component for UserMessage (plain text + truncation) const UserMessageText: FC = () => { const { text: rawText } = useMessagePartText(); const truncateCtx = useContext(UserMessageTruncateContext); - // Strip selected cards markers first (no UI representation) - let text = parseSelectedCardsMarkers(rawText); + let text = rawText; - // Truncate by character count when collapsed and over threshold if ( truncateCtx && !truncateCtx.expanded && @@ -1288,323 +1256,6 @@ const UserMessageText: FC = () => { text = text.slice(0, truncateCtx.maxChars).trim() + "..."; } - // Parse inline attachment markers: [FILE_URL:url|mediaType:type|filename:name] or [FILE_DATA:data|mediaType:type|filename:name] - const fileUrlRegex = - /\[FILE_URL:([^|]+)\|mediaType:([^|]*)\|filename:([^\]]*)\]/g; - const fileDataRegex = - /\[FILE_DATA:([^|]+)\|mediaType:([^|]*)\|filename:([^\]]*)\]/g; - - // Process URL markers: [URL_CONTEXT:url] - const urlContextRegex = /\[URL_CONTEXT:([^\]]+)\]/g; - - // Find all file markers - const fileMarkers: Array<{ - index: number; - length: number; - urlOrData: string; - mediaType: string; - filename: string; - }> = []; - - // Find all URL markers - const urlMarkers: Array<{ - index: number; - length: number; - url: string; - }> = []; - - let match; - while ((match = fileUrlRegex.exec(text)) !== null) { - fileMarkers.push({ - index: match.index, - length: match[0].length, - urlOrData: match[1], - mediaType: match[2], - filename: match[3], - }); - } - - while ((match = fileDataRegex.exec(text)) !== null) { - fileMarkers.push({ - index: match.index, - length: match[0].length, - urlOrData: match[1], - mediaType: match[2], - filename: match[3], - }); - } - - while ((match = urlContextRegex.exec(text)) !== null) { - urlMarkers.push({ - index: match.index, - length: match[0].length, - url: match[1], - }); - } - - // Combine and sort all markers by position - const allMarkers = [ - ...fileMarkers.map((m) => ({ ...m, type: "file" as const })), - ...urlMarkers.map((m) => ({ ...m, type: "url" as const })), - ].sort((a, b) => a.index - b.index); - - // Build content parts (text segments, file chips, and URL chips) - const contentParts: Array<{ - type: "text" | "file" | "url"; - content: string; - fileInfo?: { urlOrData: string; mediaType: string; filename: string }; - urlInfo?: { url: string }; - }> = []; - let lastIndex = 0; - - for (const marker of allMarkers) { - // Add text before marker - if (marker.index > lastIndex) { - const textBefore = text.substring(lastIndex, marker.index); - if (textBefore.trim()) { - contentParts.push({ type: "text", content: textBefore }); - } - } - - // Add file or URL chip - if (marker.type === "file") { - contentParts.push({ - type: "file", - content: "", - fileInfo: { - urlOrData: marker.urlOrData, - mediaType: marker.mediaType, - filename: marker.filename, - }, - }); - } else if (marker.type === "url") { - contentParts.push({ - type: "url", - content: "", - urlInfo: { - url: marker.url, - }, - }); - } - - lastIndex = marker.index + marker.length; - } - - // Add remaining text - if (lastIndex < text.length) { - const textAfter = text.substring(lastIndex); - if (textAfter.trim()) { - contentParts.push({ type: "text", content: textAfter }); - } - } - - // If no markers found, use original text - if (allMarkers.length === 0) { - contentParts.push({ type: "text", content: text }); - } - - // Check if the message contains the reply marker - const { cleanText: regularContent, replies: replyTexts } = - parseReplyMarkers(text); - - if (replyTexts.length > 0) { - // Find the marker index to filter file/URL markers - const replyMarker = "[[REPLY_MARKER]]"; - const markerIndex = text.indexOf(replyMarker); - - // Process regular content for file and URL markers (only markers before reply marker) - const regularFileMarkers = fileMarkers.filter((m) => m.index < markerIndex); - const regularUrlMarkers = urlMarkers.filter((m) => m.index < markerIndex); - const regularAllMarkers = [ - ...regularFileMarkers.map((m) => ({ ...m, type: "file" as const })), - ...regularUrlMarkers.map((m) => ({ ...m, type: "url" as const })), - ].sort((a, b) => a.index - b.index); - - const regularContentParts: Array<{ - type: "text" | "file" | "url"; - content: string; - fileInfo?: { urlOrData: string; mediaType: string; filename: string }; - urlInfo?: { url: string }; - }> = []; - - if (regularAllMarkers.length > 0) { - let regularLastIndex = 0; - - for (const marker of regularAllMarkers) { - // Calculate relative position in regularContent - const relativeIndex = marker.index; - - // Add text before marker - if (relativeIndex > regularLastIndex) { - const textBefore = regularContent.substring( - regularLastIndex, - relativeIndex, - ); - if (textBefore.trim()) { - regularContentParts.push({ type: "text", content: textBefore }); - } - } - - // Add file or URL chip - if (marker.type === "file") { - regularContentParts.push({ - type: "file", - content: "", - fileInfo: { - urlOrData: marker.urlOrData, - mediaType: marker.mediaType, - filename: marker.filename, - }, - }); - } else if (marker.type === "url") { - regularContentParts.push({ - type: "url", - content: "", - urlInfo: { - url: marker.url, - }, - }); - } - - regularLastIndex = relativeIndex + marker.length; - } - - // Add remaining text after last marker - if (regularLastIndex < regularContent.length) { - const textAfter = regularContent.substring(regularLastIndex); - if (textAfter.trim()) { - regularContentParts.push({ type: "text", content: textAfter }); - } - } - } else if (regularContent) { - // No markers, just use the regular content - regularContentParts.push({ type: "text", content: regularContent }); - } - - return ( -
- {replyTexts.length > 0 && ( -
-
-
- Replying to: -
-
- {replyTexts.map((replyText, index) => ( -
- {replyText.trim()} -
- ))} -
-
-
- )} - {regularContentParts.length > 0 && ( -
- {regularContentParts.map((part, index) => { - if (part.type === "file" && part.fileInfo) { - const isImage = part.fileInfo.mediaType.startsWith("image/"); - const isPdf = part.fileInfo.mediaType === "application/pdf"; - const fileIcon = isImage ? "πŸ–ΌοΈ" : isPdf ? "πŸ“„" : "πŸ“Ž"; - - return ( - - {fileIcon} {part.fileInfo.filename} - - ); - } - if (part.type === "url" && part.urlInfo) { - // Extract domain from URL for display - let displayUrl = part.urlInfo.url; - try { - const urlObj = new URL(part.urlInfo.url); - displayUrl = urlObj.hostname.replace("www.", ""); - } catch { - // Keep original URL if parsing fails - } - - return ( - - πŸ”— {displayUrl} - - ); - } - return ( - - {part.content} - - ); - })} -
- )} -
- ); - } - - // No reply marker, but may have file or URL markers - if (allMarkers.length > 0) { - return ( -
- {contentParts.map((part, index) => { - if (part.type === "file" && part.fileInfo) { - const isImage = part.fileInfo.mediaType.startsWith("image/"); - const isPdf = part.fileInfo.mediaType === "application/pdf"; - const fileIcon = isImage ? "πŸ–ΌοΈ" : isPdf ? "πŸ“„" : "πŸ“Ž"; - - return ( - - {fileIcon} {part.fileInfo.filename} - - ); - } - if (part.type === "url" && part.urlInfo) { - // Extract domain from URL for display - let displayUrl = part.urlInfo.url; - try { - const urlObj = new URL(part.urlInfo.url); - displayUrl = urlObj.hostname.replace("www.", ""); - } catch { - // Keep original URL if parsing fails - } - - return ( - - πŸ”— {displayUrl} - - ); - } - return ( - - {part.content} - - ); - })} -
- ); - } - - // No special structure, render normally return
{text}
; }; @@ -1726,400 +1377,15 @@ const UserActionBar: FC = () => { ); }; -// Shared utility function for parsing reply markers from text -const parseReplyMarkers = ( - text: string, -): { cleanText: string; replies: string[] } => { - const replyMarker = "[[REPLY_MARKER]]"; - const markerIndex = text.indexOf(replyMarker); - - if (markerIndex !== -1) { - // Split the message into regular content and reply content - const cleanText = text.substring(0, markerIndex).trim(); - const replyContent = text.substring(markerIndex + replyMarker.length); - const endMarkerIndex = replyContent.indexOf(replyMarker); - const extractedReplies = - endMarkerIndex !== -1 - ? replyContent.substring(0, endMarkerIndex) - : replyContent; - - // Split replies by pipe separator - const replyTexts = extractedReplies - .split("|") - .filter((r) => r.trim().length > 0); - - return { cleanText, replies: replyTexts }; - } - - return { cleanText: text, replies: [] }; -}; - -// Shared utility function for parsing selected cards markers from text (strips them from UI) -const parseSelectedCardsMarkers = (text: string): string => { - const cardsMarker = "[[SELECTED_CARDS_MARKER]]"; - const markerIndex = text.indexOf(cardsMarker); - - if (markerIndex !== -1) { - // Find the end marker and remove everything between (inclusive) - const afterFirstMarker = text.substring(markerIndex + cardsMarker.length); - const endMarkerIndex = afterFirstMarker.indexOf(cardsMarker); - - if (endMarkerIndex !== -1) { - // Remove the entire block including markers - const beforeMarker = text.substring(0, markerIndex).trim(); - const afterBlock = afterFirstMarker - .substring(endMarkerIndex + cardsMarker.length) - .trim(); - return (beforeMarker + " " + afterBlock).trim(); - } else { - // No end marker found, remove from first marker onwards - return text.substring(0, markerIndex).trim(); - } - } - - return text; -}; - -// Shared utility function for parsing selected cards markers from text and extracting the content -const parseSelectedCardsMarkersWithExtraction = ( - text: string, -): { cleanText: string; cardsContext: string } => { - const cardsMarker = "[[SELECTED_CARDS_MARKER]]"; - const markerIndex = text.indexOf(cardsMarker); - - if (markerIndex !== -1) { - // Split the message into regular content and cards content - const cleanText = text.substring(0, markerIndex).trim(); - const cardsContent = text.substring(markerIndex + cardsMarker.length); - const endMarkerIndex = cardsContent.indexOf(cardsMarker); - const extractedCardsContext = - endMarkerIndex !== -1 - ? cardsContent.substring(0, endMarkerIndex) - : cardsContent; - - // Clean up the remaining text after the end marker - const afterBlock = - endMarkerIndex !== -1 - ? cardsContent.substring(endMarkerIndex + cardsMarker.length).trim() - : ""; - - const finalCleanText = afterBlock - ? (cleanText + " " + afterBlock).trim() - : cleanText; - - return { - cleanText: finalCleanText, - cardsContext: extractedCardsContext.trim(), - }; - } - - return { cleanText: text, cardsContext: "" }; -}; - -// Parse URL context markers from text and extract URL information -const parseUrlMarkers = ( - text: string, -): { - cleanText: string; - urls: string[]; -} => { - const urlRegex = /\[URL_CONTEXT:(.+?)\]/g; - const urls: string[] = []; - let cleanText = text; - - let match; - while ((match = urlRegex.exec(text)) !== null) { - urls.push(match[1]); - // Remove the marker from clean text - cleanText = cleanText.replace(match[0], ""); - } - - // Clean up any extra whitespace - cleanText = cleanText.replace(/\s+/g, " ").trim(); - - return { cleanText, urls }; -}; - -// Parse inline attachment markers from text and extract file information -const parseFileMarkers = ( - text: string, -): { - cleanText: string; - files: Array<{ - urlOrData: string; - mediaType: string; - filename: string; - isData: boolean; - }>; -} => { - const fileUrlRegex = - /\[FILE_URL:([^|]+)\|mediaType:([^|]*)\|filename:([^\]]*)\]/g; - const fileDataRegex = - /\[FILE_DATA:([^|]+)\|mediaType:([^|]*)\|filename:([^\]]*)\]/g; - - const files: Array<{ - urlOrData: string; - mediaType: string; - filename: string; - isData: boolean; - }> = []; - let cleanText = text; - - // Find and extract FILE_URL markers - let match; - const urlMatches: Array<{ - match: string; - url: string; - mediaType: string; - filename: string; - }> = []; - while ((match = fileUrlRegex.exec(text)) !== null) { - urlMatches.push({ - match: match[0], - url: match[1], - mediaType: match[2], - filename: match[3], - }); - } - - // Find and extract FILE_DATA markers - const dataMatches: Array<{ - match: string; - data: string; - mediaType: string; - filename: string; - }> = []; - while ((match = fileDataRegex.exec(text)) !== null) { - dataMatches.push({ - match: match[0], - data: match[1], - mediaType: match[2], - filename: match[3], - }); - } - - // Combine and sort by position in text - const allMatches = [ - ...urlMatches.map((m) => ({ - ...m, - index: text.indexOf(m.match), - isData: false, - })), - ...dataMatches.map((m) => ({ - ...m, - index: text.indexOf(m.match), - isData: true, - })), - ].sort((a, b) => b.index - a.index); // Sort in reverse to remove from end to start - - // Remove markers from text (in reverse order to maintain indices) - for (const fileMatch of allMatches) { - cleanText = - cleanText.substring(0, fileMatch.index) + - cleanText.substring(fileMatch.index + fileMatch.match.length); - files.push({ - urlOrData: fileMatch.isData - ? (fileMatch as (typeof dataMatches)[0]).data - : (fileMatch as (typeof urlMatches)[0]).url, - mediaType: fileMatch.mediaType, - filename: fileMatch.filename, - isData: fileMatch.isData, - }); - } - - // Reverse files array to maintain original order - files.reverse(); - - return { cleanText: cleanText.trim(), files }; -}; - -// Convert base64 data URL to File object -const dataUrlToFile = async ( - dataUrl: string, - filename: string, - mediaType: string, -): Promise => { - const response = await fetch(dataUrl); - const blob = await response.blob(); - return new File([blob], filename, { type: mediaType }); -}; - -// Convert URL to File object -const urlToFile = async ( - url: string, - filename: string, - mediaType: string, -): Promise => { - const response = await fetch(url); - const blob = await response.blob(); - return new File([blob], filename, { type: mediaType }); -}; - -// Helper to truncate text for display -const truncateText = (text: string, maxLength: number = 30) => { - if (text.length <= maxLength) return text; - return text.substring(0, maxLength).trim() + "..."; -}; - const EditComposer: FC = () => { const aui = useAui(); const hasUploading = useAttachmentUploadStore((s) => s.uploadingIds.size > 0); - const messageAttachments = useAuiState( - useShallow( - ({ message }) => - (message as { attachments?: unknown[] })?.attachments || [], - ), + const [originalText, setOriginalText] = useState( + () => aui?.composer()?.getState()?.text ?? "", + ); + const [currentText, setCurrentText] = useState( + () => aui?.composer()?.getState()?.text ?? "", ); - const hasParsedRef = useRef(false); - const hasAttachmentsRestoredRef = useRef(false); - const [parsedReplies, setParsedReplies] = useState([]); - const [parsedUrls, setParsedUrls] = useState([]); - const [parsedCardsContext, setParsedCardsContext] = useState(""); - const [originalText, setOriginalText] = useState(""); - const [currentText, setCurrentText] = useState(""); - - // Parse reply markers and restore attachments when edit mode is activated - useEffect(() => { - // Reset flags when component mounts (edit mode activated) - hasParsedRef.current = false; - hasAttachmentsRestoredRef.current = false; - - const composerState = aui?.composer()?.getState(); - - if (!composerState || !composerState.text) return; - - // Parse file markers first (they're in the text) - const { cleanText: textWithoutFiles, files } = parseFileMarkers( - composerState.text, - ); - - // Parse URL markers from the cleaned text - const { cleanText: textWithoutUrls, urls } = - parseUrlMarkers(textWithoutFiles); - - // Parse selected cards markers and extract the context content - const { cleanText: textWithoutCards, cardsContext } = - parseSelectedCardsMarkersWithExtraction(textWithoutUrls); - - // Then parse reply markers from the cleaned text - const { cleanText, replies } = parseReplyMarkers(textWithoutCards); - - // Store parsed replies, URLs, and selected cards context in state (not in store) for display - setParsedReplies(replies); - setParsedUrls(urls); - setParsedCardsContext(cardsContext); - - // Store original text for comparison (to disable Update button if unchanged) - setOriginalText(cleanText); - setCurrentText(cleanText); - - // Update input text to show only clean text (without markers) - if (cleanText !== composerState.text) { - aui?.composer()?.setText(cleanText); - } - - hasParsedRef.current = true; - - // Restore file attachments from markers - if (files.length > 0 && !hasAttachmentsRestoredRef.current) { - const composerAttachments = composerState.attachments || []; - - // Only restore if attachments aren't already in composer - if (composerAttachments.length === 0) { - hasAttachmentsRestoredRef.current = true; // Set flag early to prevent duplicate restorations - - // Restore each file attachment - (async () => { - for (const fileInfo of files) { - try { - let file: File; - - if (fileInfo.isData) { - // Handle base64 data URL - const dataUrl = fileInfo.urlOrData.startsWith("data:") - ? fileInfo.urlOrData - : `data:${fileInfo.mediaType};base64,${fileInfo.urlOrData}`; - file = await dataUrlToFile( - dataUrl, - fileInfo.filename, - fileInfo.mediaType, - ); - } else { - // Handle URL - check if it's a URL_CONTEXT or regular URL - if ( - fileInfo.urlOrData.startsWith("http://") || - fileInfo.urlOrData.startsWith("https://") - ) { - file = await urlToFile( - fileInfo.urlOrData, - fileInfo.filename, - fileInfo.mediaType, - ); - } else { - // Might be a URL_CONTEXT marker, try creating URL file - try { - file = createUrlFile(fileInfo.urlOrData); - } catch { - // If that fails, try as regular URL - file = await urlToFile( - fileInfo.urlOrData, - fileInfo.filename, - fileInfo.mediaType, - ); - } - } - } - - aui?.composer()?.addAttachment(file); - } catch (error) { - console.error("Failed to restore file attachment:", error); - } - } - })(); - } - } - - // Also restore URL attachments from message attachments (for URL_CONTEXT markers) - // These are separate from file markers in text, so check independently - if (messageAttachments.length > 0) { - const composerAttachments = composerState.attachments || []; - - // Only restore if we haven't already restored files from markers - // or if there are no file markers but there are message attachments - if (composerAttachments.length === 0 || files.length === 0) { - ( - messageAttachments as Array<{ - content?: Array<{ type: string; [key: string]: unknown }>; - }> - ).forEach((attachment) => { - if (attachment.content) { - // Check if it's a URL attachment - const urlContent = attachment.content.find( - (c: { type: string; [key: string]: unknown }) => - c.type === "text" && - typeof (c as { type: "text"; text: string }).text === - "string" && - (c as { type: "text"; text: string }).text.startsWith( - "[URL_CONTEXT:", - ), - ); - if (urlContent && urlContent.type === "text") { - const textContent = urlContent as { type: "text"; text: string }; - const urlMatch = textContent.text.match(/\[URL_CONTEXT:(.+?)\]/); - if (urlMatch && urlMatch[1]) { - try { - const urlFile = createUrlFile(urlMatch[1]); - aui?.composer()?.addAttachment(urlFile); - } catch (error) { - console.error("Failed to restore URL attachment:", error); - } - } - } - } - }); - } - } - }, [aui, messageAttachments]); return (
@@ -2133,68 +1399,11 @@ const EditComposer: FC = () => { return; } - // 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) - let modifiedText = currentText; - if (parsedUrls.length > 0) { - // Add URL markers to the text - const urlMarkers = parsedUrls - .map((url) => `[URL_CONTEXT:${url}]`) - .join(" "); - modifiedText = `${urlMarkers} ${currentText}`.trim(); - } - - // Re-add selected cards markers from parsed cards context (stored in state) - if (parsedCardsContext) { - const cardsMarker = "[[SELECTED_CARDS_MARKER]]"; - modifiedText = - modifiedText + - `\n\n${cardsMarker}${parsedCardsContext}${cardsMarker}`; - } - - // Re-add reply markers from parsed replies (stored in state) - if (parsedReplies.length > 0) { - const replyTexts = parsedReplies.join("|"); - const specialMarker = "[[REPLY_MARKER]]"; - modifiedText = - modifiedText + - `\n\n${specialMarker}${replyTexts}${specialMarker}`; - } - - // Set the modified text and send - aui?.composer()?.setText(modifiedText); aui?.composer()?.send(); }} > - {/* Attachment Display - shows restored and new attachments */} - {/* Show parsed replies if any (read-only display, not in store) */} - {parsedReplies.length > 0 && ( -
-
-
- Replying to: -
-
- {parsedReplies.map((replyText: string, index: number) => ( -
- {truncateText(replyText.trim())} -
- ))} -
-
-
- )} - { onChange={(e) => setCurrentText(e.target.value)} /> - {/* Show parsed URLs if any (using same UI as normal messages) */} - {parsedUrls.length > 0 && ( -
-
- {parsedUrls.map((url: string, index: number) => { - // Extract domain from URL for display (same logic as normal messages) - let displayUrl = url; - try { - const urlObj = new URL(url); - displayUrl = urlObj.hostname.replace("www.", ""); - } catch { - // Keep original URL if parsing fails - } - - return ( - - πŸ”— {displayUrl} - - ); - })} -
-
- )} -
diff --git a/src/components/assistant-ui/tool-ui-loading-shell.tsx b/src/components/assistant-ui/tool-ui-loading-shell.tsx index 53c804cf..7b728d49 100644 --- a/src/components/assistant-ui/tool-ui-loading-shell.tsx +++ b/src/components/assistant-ui/tool-ui-loading-shell.tsx @@ -7,18 +7,15 @@ import ShinyText from "@/components/ShinyText"; export interface ToolUILoadingShellProps { /** Main label shown next to the spinner (e.g. "Creating document...") */ label: string; - /** Optional secondary line (e.g. "Adding to context...") */ - subtitle?: string; className?: string; } /** * Shared loading shell for assistant-ui tool UIs. Card-style layout with - * spinner + label (+ optional subtitle). Use when status.type === "running". + * spinner + label. Use when status.type === "running". */ export function ToolUILoadingShell({ label, - subtitle, className, }: ToolUILoadingShellProps) { return ( @@ -32,19 +29,14 @@ export function ToolUILoadingShell({
-
- - - - {subtitle && ( - {subtitle} - )} -
+ + +
); diff --git a/src/components/onboarding/WorkspaceInstructionModal.tsx b/src/components/onboarding/WorkspaceInstructionModal.tsx index 2b5a40e2..01745639 100644 --- a/src/components/onboarding/WorkspaceInstructionModal.tsx +++ b/src/components/onboarding/WorkspaceInstructionModal.tsx @@ -21,6 +21,7 @@ import { YouTubeMark } from "@/components/icons/YouTubeMark"; import { DotLottieReact } from "@lottiefiles/dotlottie-react"; import { useTheme } from "next-themes"; import { cn } from "@/lib/utils"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; export interface WorkspaceInstructionModalProps { open: boolean; @@ -113,6 +114,7 @@ const STEPS: Step[] = [ const ICON_SLIDE_MS = 4000; const FADE_MS = 250; +const EMPTY_COMPLETED_STEPS: string[] = []; function useCarousel(open: boolean) { const [activeIndex, setActiveIndex] = useState(0); @@ -123,24 +125,27 @@ function useCarousel(open: boolean) { 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"; + const isDark = resolvedTheme === "dark"; + + const resetCarousel = useCallback(() => { + if (fadeTimeoutRef.current) { + clearTimeout(fadeTimeoutRef.current); + fadeTimeoutRef.current = null; + } + setActiveIndex(0); + setVisibleIndex(0); + setFading(false); + setVideoLoaded(false); + pausedRef.current = false; + }, []); // Reset carousel state when modal closes so re-opening starts from slide 0 useEffect(() => { if (!open) { - if (fadeTimeoutRef.current) { - clearTimeout(fadeTimeoutRef.current); - fadeTimeoutRef.current = null; - } - setActiveIndex(0); - setVisibleIndex(0); - setFading(false); - setVideoLoaded(false); - pausedRef.current = false; + const resetTimer = window.setTimeout(resetCarousel, 0); + return () => window.clearTimeout(resetTimer); } - }, [open]); + }, [open, resetCarousel]); // Transition: fade out β†’ swap β†’ fade in const transitionTo = useCallback( @@ -224,7 +229,7 @@ function useCarousel(open: boolean) { // Preload only next and previous video (metadata only) for faster step switching useEffect(() => { - if (!open || !mounted) return; + if (!open) return; const prevIndex = (activeIndex - 1 + STEPS.length) % STEPS.length; const nextIndex = (activeIndex + 1) % STEPS.length; const toPreload: string[] = []; @@ -252,7 +257,7 @@ function useCarousel(open: boolean) { v.load(); } }; - }, [open, mounted, activeIndex, isDark]); + }, [open, activeIndex, isDark]); return { activeIndex, @@ -272,13 +277,11 @@ function useCarousel(open: boolean) { export function WorkspaceInstructionModal({ open, canClose, - showFallback, onRequestClose, - onFallbackContinue, onUserInteracted, isGenerating, progressText, - completedSteps = [], + completedSteps = EMPTY_COMPLETED_STEPS, totalSteps = 6, generationComplete, workspaceSlug, @@ -299,76 +302,55 @@ export function WorkspaceInstructionModal({ pause, } = carousel; const { resolvedTheme } = useTheme(); + const allowClose = canClose && !isGenerating; + const overlayClassName = cn( + "z-[90] transition-opacity duration-300 ease-out", + isGenerating || (!!generationComplete && !!workspaceSlug) + ? "bg-black/5 dark:bg-black/15 backdrop-blur-[16px]" + : "bg-black/25 dark:bg-black/40 backdrop-blur-[24px]", + ); - const [isVisible, setIsVisible] = useState(false); - const [isClosing, setIsClosing] = useState(false); - - 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") { - // Do not allow close while generating - if (isGenerating) return; - if (canClose) { + return ( + { + if (!nextOpen && allowClose) { onRequestClose?.(); } - } - } - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [ - open, - canClose, - isGenerating, - showFallback, - onRequestClose, - onFallbackContinue, - ]); - - if (!isVisible) return null; - - return ( -
- {} -
{ + { + event.preventDefault(); + }} + onEscapeKeyDown={(event) => { + if (!allowClose) { + event.preventDefault(); + } + }} + onPointerDownCapture={() => { pause(); onUserInteracted?.(); }} - className={cn( - "relative w-full max-w-[1100px] rounded-[28px] 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", + > + + Workspace instructions + + +
@@ -510,7 +492,7 @@ export function WorkspaceInstructionModal({
{STEPS.map((s, index) => (
{/* CTA β€” vertically centered in bottom bar */} - {canClose && ( + {allowClose && (
-
-
+
+ + ); } diff --git a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx index cd98ce1c..583e64a4 100644 --- a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx +++ b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx @@ -62,6 +62,7 @@ import { } from "@/components/ui/dialog"; import MoveToDialog from "@/components/modals/MoveToDialog"; import RenameDialog from "@/components/modals/RenameDialog"; +import { cn } from "@/lib/utils"; interface FlashcardWorkspaceCardProps { item: Item; allItems?: Item[]; // All items for the move dialog tree @@ -82,6 +83,20 @@ const EMPTY_FLASHCARD_PLACEHOLDER: FlashcardItem = { back: "", }; +/** Center all markdown blocks on the card; keep code blocks full-width and left-aligned. */ +const FLASHCARD_STREAMDOWN_CLASS = cn( + "text-base font-medium max-w-none text-center", + "[&_.streamdown-content]:flex [&_.streamdown-content]:flex-col [&_.streamdown-content]:items-center [&_.streamdown-content]:text-center", + // Lists: drop outside gutter so the block sits in the centered column + "[&_.streamdown-content_ul]:!ml-0 [&_.streamdown-content_ul]:list-inside", + "[&_.streamdown-content_ol]:!ml-0 [&_.streamdown-content_ol]:list-inside", + // Wide / monospace blocks: stretch and align like normal editors + "[&_.streamdown-content_[data-streamdown=code-block]]:w-full [&_.streamdown-content_[data-streamdown=code-block]]:max-w-full [&_.streamdown-content_[data-streamdown=code-block]]:self-stretch [&_.streamdown-content_[data-streamdown=code-block]]:!text-left", + "[&_.streamdown-content_pre]:w-full [&_.streamdown-content_pre]:max-w-full [&_.streamdown-content_pre]:self-stretch [&_.streamdown-content_pre]:!text-left", + // Tables: use full width when present; cells follow markdown alignment when set + "[&_.streamdown-content_table]:w-full [&_.streamdown-content_table]:max-w-full [&_.streamdown-content_table]:self-stretch", +); + /** Read-only markdown per side. */ const FlashcardSideMarkdownView = memo( function FlashcardSideMarkdownView({ @@ -95,29 +110,24 @@ const FlashcardSideMarkdownView = memo( }) { return (
-
+
{!markdown.trim() ? ( -
+
Ask the AI or click the pencil icon to add flashcards
) : ( - + {markdown} )} @@ -444,6 +454,27 @@ export function FlashcardWorkspaceCard({ isSelected && resolvedTheme !== "dark" ? "0 0 3px rgba(0, 0, 0, 0.8), 0 0 8px rgba(0, 0, 0, 0.5)" : undefined; + const neutralControlBg = + resolvedTheme === "dark" ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)"; + const neutralControlHoverBg = "rgba(0, 0, 0, 0.5)"; + const selectedControlBg = isSelected + ? "rgba(239, 68, 68, 0.3)" + : neutralControlBg; + const selectedControlHoverBg = isSelected + ? "rgba(239, 68, 68, 0.5)" + : neutralControlHoverBg; + const getControlStyle = (backgroundColor: string) => ({ + backgroundColor, + backdropFilter: "blur(8px)", + }); + const createControlHoverHandlers = (baseColor: string, hoverColor: string) => ({ + onMouseEnter: (e: React.MouseEvent) => { + e.currentTarget.style.backgroundColor = hoverColor; + }, + onMouseLeave: (e: React.MouseEvent) => { + e.currentTarget.style.backgroundColor = baseColor; + }, + }); return ( @@ -473,23 +504,11 @@ export function FlashcardWorkspaceCard({ : "Click to lock scroll" } className="flashcard-control-button inline-flex h-8 items-center justify-center gap-1.5 pl-2.5 pr-3 rounded-xl text-white/90 hover:text-white hover:scale-105 hover:shadow-lg transition-all duration-200 cursor-pointer" - style={{ - backgroundColor: - resolvedTheme === "dark" - ? "rgba(255, 255, 255, 0.1)" - : "rgba(0, 0, 0, 0.1)", - backdropFilter: "blur(8px)", - }} - onMouseEnter={(e) => - ((e.currentTarget as HTMLButtonElement).style.backgroundColor = - "rgba(0, 0, 0, 0.5)") - } - onMouseLeave={(e) => - ((e.currentTarget as HTMLButtonElement).style.backgroundColor = - resolvedTheme === "dark" - ? "rgba(255, 255, 255, 0.1)" - : "rgba(0, 0, 0, 0.1)") - } + style={getControlStyle(neutralControlBg)} + {...createControlHoverHandlers( + neutralControlBg, + neutralControlHoverBg, + )} onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); @@ -514,23 +533,11 @@ export function FlashcardWorkspaceCard({ aria-label="Edit flashcard" title="Edit flashcard" className="flashcard-control-button inline-flex h-8 w-8 items-center justify-center rounded-xl text-white/90 hover:text-white hover:scale-110 hover:shadow-lg transition-all duration-200 cursor-pointer" - style={{ - backgroundColor: - resolvedTheme === "dark" - ? "rgba(255, 255, 255, 0.1)" - : "rgba(0, 0, 0, 0.1)", - backdropFilter: "blur(8px)", - }} - onMouseEnter={(e) => - ((e.currentTarget as HTMLButtonElement).style.backgroundColor = - "rgba(0, 0, 0, 0.5)") - } - onMouseLeave={(e) => - ((e.currentTarget as HTMLButtonElement).style.backgroundColor = - resolvedTheme === "dark" - ? "rgba(255, 255, 255, 0.1)" - : "rgba(0, 0, 0, 0.1)") - } + style={getControlStyle(neutralControlBg)} + {...createControlHoverHandlers( + neutralControlBg, + neutralControlHoverBg, + )} onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); @@ -545,26 +552,11 @@ export function FlashcardWorkspaceCard({ aria-label={isSelected ? "Deselect card" : "Select card"} title={isSelected ? "Deselect card" : "Select card"} className="flashcard-control-button inline-flex h-8 w-8 items-center justify-center rounded-xl text-white/90 hover:text-white hover:scale-110 hover:shadow-lg transition-all duration-200 cursor-pointer" - style={{ - backgroundColor: isSelected - ? "rgba(239, 68, 68, 0.3)" - : resolvedTheme === "dark" - ? "rgba(255, 255, 255, 0.1)" - : "rgba(0, 0, 0, 0.1)", - backdropFilter: "blur(8px)", - }} - onMouseEnter={(e) => - ((e.currentTarget as HTMLButtonElement).style.backgroundColor = - isSelected ? "rgba(239, 68, 68, 0.5)" : "rgba(0, 0, 0, 0.5)") - } - onMouseLeave={(e) => - ((e.currentTarget as HTMLButtonElement).style.backgroundColor = - isSelected - ? "rgba(239, 68, 68, 0.3)" - : resolvedTheme === "dark" - ? "rgba(255, 255, 255, 0.1)" - : "rgba(0, 0, 0, 0.1)") - } + style={getControlStyle(selectedControlBg)} + {...createControlHoverHandlers( + selectedControlBg, + selectedControlHoverBg, + )} onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); @@ -585,26 +577,11 @@ export function FlashcardWorkspaceCard({ aria-label="Card settings" title="Card settings" className="flashcard-control-button inline-flex h-8 w-8 items-center justify-center rounded-xl text-white/90 hover:text-white hover:scale-110 hover:shadow-lg transition-all duration-200 cursor-pointer" - style={{ - backgroundColor: - resolvedTheme === "dark" - ? "rgba(255, 255, 255, 0.1)" - : "rgba(0, 0, 0, 0.1)", - backdropFilter: "blur(8px)", - }} - onMouseEnter={(e) => - (( - e.currentTarget as HTMLButtonElement - ).style.backgroundColor = "rgba(0, 0, 0, 0.5)") - } - onMouseLeave={(e) => - (( - e.currentTarget as HTMLButtonElement - ).style.backgroundColor = - resolvedTheme === "dark" - ? "rgba(255, 255, 255, 0.1)" - : "rgba(0, 0, 0, 0.1)") - } + style={getControlStyle(neutralControlBg)} + {...createControlHoverHandlers( + neutralControlBg, + neutralControlHoverBg, + )} onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} > diff --git a/src/components/workspace-canvas/WorkspaceCardActions.tsx b/src/components/workspace-canvas/WorkspaceCardActions.tsx index 3f8b2c01..2f30c53c 100644 --- a/src/components/workspace-canvas/WorkspaceCardActions.tsx +++ b/src/components/workspace-canvas/WorkspaceCardActions.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ComponentType, MouseEvent, ReactNode } from "react"; +import type { CSSProperties, ComponentType, MouseEvent, ReactNode } from "react"; import { MoreVertical, Trash2, @@ -50,6 +50,11 @@ function stopCardPropagation(event: MouseEvent) { event.stopPropagation(); } +function preventCardMouseDown(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); +} + function WorkspaceCardMenuItems({ itemType, canMove, @@ -101,56 +106,38 @@ function WorkspaceCardMenuItems({ ); } -interface FloatingControlButtonProps { - ariaLabel: string; - title: string; - backgroundColor: string; - hoverBackgroundColor: string; - onClick?: () => void; - className?: string; - children: ReactNode; +const floatingControlButtonClassName = + "inline-flex h-8 items-center justify-center rounded-xl text-white/90 hover:text-white hover:shadow-lg transition-all duration-200 cursor-pointer"; + +function getFloatingControlStyle(backgroundColor: string): CSSProperties { + return { + backgroundColor, + backdropFilter: "blur(8px)", + }; } -function FloatingControlButton({ - ariaLabel, - title, - backgroundColor, +function getFloatingControlHandlers({ + defaultBackgroundColor, hoverBackgroundColor, onClick, - className, - children, -}: FloatingControlButtonProps) { - return ( - - ); +}: { + defaultBackgroundColor: string; + hoverBackgroundColor: string; + onClick?: () => void; +}) { + return { + onMouseDown: preventCardMouseDown, + onMouseEnter: (event: MouseEvent) => { + event.currentTarget.style.backgroundColor = hoverBackgroundColor; + }, + onMouseLeave: (event: MouseEvent) => { + event.currentTarget.style.backgroundColor = defaultBackgroundColor; + }, + onClick: (event: MouseEvent) => { + event.stopPropagation(); + onClick?.(); + }, + }; } interface WorkspaceCardControlsProps { @@ -208,6 +195,20 @@ export function WorkspaceCardControls({ ? "rgba(239, 68, 68, 0.6)" : "rgba(239, 68, 68, 0.5)" : defaultHoverBackgroundColor; + const scrollLockHandlers = getFloatingControlHandlers({ + defaultBackgroundColor, + hoverBackgroundColor: defaultHoverBackgroundColor, + onClick: onToggleScrollLock, + }); + const selectionHandlers = getFloatingControlHandlers({ + defaultBackgroundColor: selectionBackgroundColor, + hoverBackgroundColor: selectionHoverBackgroundColor, + onClick: onToggleSelection, + }); + const settingsHandlers = getFloatingControlHandlers({ + defaultBackgroundColor, + hoverBackgroundColor: defaultHoverBackgroundColor, + }); return (
{showScrollLockButton && ( - {isScrollLocked ? ( @@ -242,31 +246,32 @@ export function WorkspaceCardControls({ > {isScrollLocked ? "Scroll" : "Lock"} - + )} - {isSelected ? : } - + - - + value === b[index]); +} + +function getResponsiveBreadcrumbs( + entries: BreadcrumbEntry[], + availableWidth: number, + widths: Map, + separatorWidth: number, + ellipsisWidth: number, +) { + if (entries.length <= 1) { + return EMPTY_RESPONSIVE_BREADCRUMBS; + } + + const keys = entries.map((entry) => entry.key); + const rootKey = keys[0]; + const lastKey = keys[keys.length - 1]; + let visibleTailKeys = [lastKey]; + + const getWidth = (key: string) => widths.get(key) ?? 0; + const measureLayout = (candidateTailKeys: string[]) => { + let totalWidth = getWidth(rootKey); + const hiddenCount = Math.max(0, keys.length - 1 - candidateTailKeys.length); + + if (hiddenCount > 0) { + totalWidth += separatorWidth + ellipsisWidth; + } + + for (const key of candidateTailKeys) { + totalWidth += separatorWidth + getWidth(key); + } + + return totalWidth; + }; + + for (let index = keys.length - 2; index >= 1; index -= 1) { + const nextVisibleTailKeys = [keys[index], ...visibleTailKeys]; + + if (measureLayout(nextVisibleTailKeys) <= availableWidth) { + visibleTailKeys = nextVisibleTailKeys; + } else { + break; + } + } + + return { + visibleTailKeys, + hiddenKeys: keys.slice(1, keys.length - visibleTailKeys.length), + }; +} + +function BreadcrumbSeparator({ + measureRef, +}: { + measureRef?: React.Ref; +}) { + return ( + + + + ); +} function GoogleIcon(props: React.SVGProps) { return ( @@ -180,16 +281,24 @@ export function WorkspaceHeader({ // Track drag hover state for breadcrumb elements const [hoveredBreadcrumbTarget, setHoveredBreadcrumbTarget] = useState(null); // 'root' or folderId const isDraggingRef = useRef(false); + const breadcrumbNavRef = useRef(null); + const breadcrumbMeasureRefs = useRef>({}); + const breadcrumbSeparatorMeasureRef = useRef(null); + const breadcrumbEllipsisMeasureRef = useRef(null); const [ellipsisDropdownState, setEllipsisDropdownState] = useState<{ - folderPathKey: string; + breadcrumbLayoutKey: string; open: boolean; }>({ - folderPathKey: "", + breadcrumbLayoutKey: "", open: false, }); + const [responsiveBreadcrumbs, setResponsiveBreadcrumbs] = useState( + EMPTY_RESPONSIVE_BREADCRUMBS, + ); // Consistent breadcrumb item styling - const breadcrumbItemClass = "flex items-center gap-1.5 min-w-0 rounded transition-colors hover:bg-accent cursor-pointer px-2 py-1.5 -mx-2 -my-1.5"; + const breadcrumbItemClass = + "inline-flex h-7 max-w-full min-w-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors"; @@ -202,21 +311,62 @@ export function WorkspaceHeader({ if (!activeFolderId || !items.length) return []; return getFolderPath(activeFolderId, items); }, [activeFolderId, items]); - const folderPathKey = useMemo( - () => folderPath.map((folder) => folder.id).join("/"), - [folderPath], + const workspaceBreadcrumbLabel = workspaceName || "Untitled"; + const primaryActiveItem = activeItems[0] ?? null; + const breadcrumbEntries = useMemo(() => { + const entries: BreadcrumbEntry[] = [ + { + key: "root", + kind: "root", + label: workspaceBreadcrumbLabel, + icon: workspaceIcon, + color: workspaceColor, + }, + ]; + + for (const folder of folderPath) { + entries.push({ + key: `folder:${folder.id}`, + kind: "folder", + id: folder.id, + label: folder.name, + color: folder.color, + }); + } + + if (primaryActiveItem) { + entries.push({ + key: `item:${primaryActiveItem.id}`, + kind: "item", + id: primaryActiveItem.id, + label: primaryActiveItem.name, + itemType: primaryActiveItem.type, + }); + } + + return entries; + }, [ + folderPath, + primaryActiveItem, + workspaceBreadcrumbLabel, + workspaceColor, + workspaceIcon, + ]); + const breadcrumbLayoutKey = useMemo( + () => breadcrumbEntries.map((entry) => entry.key).join("/"), + [breadcrumbEntries], ); const isEllipsisDropdownOpen = - ellipsisDropdownState.folderPathKey === folderPathKey && + ellipsisDropdownState.breadcrumbLayoutKey === breadcrumbLayoutKey && ellipsisDropdownState.open; const handleEllipsisDropdownOpenChange = useCallback( (open: boolean) => { setEllipsisDropdownState({ - folderPathKey, + breadcrumbLayoutKey, open, }); }, - [folderPathKey], + [breadcrumbLayoutKey], ); // Compact mode when space is tight (item panel open + chat expanded) @@ -258,6 +408,375 @@ export function WorkspaceHeader({ setRenamingTarget(null); }, [onRenameFolder, onUpdateActiveItem, renamingTarget, renameValue]); + const openItemRenameDialog = useCallback((itemId: string, itemName: string) => { + setRenamingTarget({ id: itemId, type: "item" }); + setRenameValue(itemName); + setShowRenameDialog(true); + }, []); + + useEffect(() => { + const validKeys = new Set(breadcrumbEntries.map((entry) => entry.key)); + + for (const key of Object.keys(breadcrumbMeasureRefs.current)) { + if (!validKeys.has(key)) { + delete breadcrumbMeasureRefs.current[key]; + } + } + }, [breadcrumbEntries]); + + useLayoutEffect(() => { + const navElement = breadcrumbNavRef.current; + + if (!navElement) { + return; + } + + let animationFrame = 0; + + const recomputeLayout = () => { + animationFrame = 0; + + const availableWidth = navElement.clientWidth; + if (availableWidth <= 0) { + return; + } + + const widths = new Map(); + for (const entry of breadcrumbEntries) { + widths.set( + entry.key, + Math.ceil( + breadcrumbMeasureRefs.current[entry.key]?.getBoundingClientRect().width ?? + 0, + ), + ); + } + + const separatorWidth = Math.ceil( + breadcrumbSeparatorMeasureRef.current?.getBoundingClientRect().width ?? 18, + ); + const ellipsisWidth = Math.ceil( + breadcrumbEllipsisMeasureRef.current?.getBoundingClientRect().width ?? 30, + ); + const nextResponsiveBreadcrumbs = getResponsiveBreadcrumbs( + breadcrumbEntries, + availableWidth, + widths, + separatorWidth, + ellipsisWidth, + ); + + setResponsiveBreadcrumbs((current) => { + if ( + areStringArraysEqual( + current.visibleTailKeys, + nextResponsiveBreadcrumbs.visibleTailKeys, + ) && + areStringArraysEqual(current.hiddenKeys, nextResponsiveBreadcrumbs.hiddenKeys) + ) { + return current; + } + + return nextResponsiveBreadcrumbs; + }); + }; + + const scheduleRecompute = () => { + if (animationFrame) { + cancelAnimationFrame(animationFrame); + } + + animationFrame = window.requestAnimationFrame(recomputeLayout); + }; + + scheduleRecompute(); + + const resizeObserver = new ResizeObserver(scheduleRecompute); + resizeObserver.observe(navElement); + + if (document.fonts?.ready) { + void document.fonts.ready.then(scheduleRecompute); + } + + return () => { + if (animationFrame) { + cancelAnimationFrame(animationFrame); + } + resizeObserver.disconnect(); + }; + }, [breadcrumbEntries]); + + const breadcrumbEntryLookup = useMemo( + () => new Map(breadcrumbEntries.map((entry) => [entry.key, entry])), + [breadcrumbEntries], + ); + const hiddenBreadcrumbEntries = useMemo( + () => + responsiveBreadcrumbs.hiddenKeys + .map((key) => breadcrumbEntryLookup.get(key)) + .filter((entry): entry is BreadcrumbEntry => Boolean(entry)), + [breadcrumbEntryLookup, responsiveBreadcrumbs.hiddenKeys], + ); + const visibleTailBreadcrumbEntries = useMemo( + () => + responsiveBreadcrumbs.visibleTailKeys + .map((key) => breadcrumbEntryLookup.get(key)) + .filter((entry): entry is BreadcrumbEntry => Boolean(entry)), + [breadcrumbEntryLookup, responsiveBreadcrumbs.visibleTailKeys], + ); + + const renderRootBreadcrumbLabel = useCallback( + () => ( + <> + + + {workspaceBreadcrumbLabel} + + + ), + [workspaceBreadcrumbLabel, workspaceColor, workspaceIcon], + ); + + const renderFolderBreadcrumbLabel = useCallback((entry: Extract) => ( + <> + + + {entry.label} + + + ), []); + + const renderItemBreadcrumbLabel = useCallback((entry: Extract) => ( + <> + + + {entry.label} + + + ), []); + + const renderRootBreadcrumb = useCallback(() => { + const rootHighlightClass = + hoveredBreadcrumbTarget === "root" && + BREADCRUMB_DRAG_TARGET_CLASS; + + if (activeFolderId || primaryActiveItem) { + return ( + + ); + } + + if (onOpenSettings || onOpenShare) { + return ( + + + + + + {onOpenSettings && ( + + + Settings + + )} + {onOpenShare && ( + + + Share + + )} + + + ); + } + + return ( +
+ {renderRootBreadcrumbLabel()} +
+ ); + }, [ + activeFolderId, + breadcrumbItemClass, + hoveredBreadcrumbTarget, + onNavigateToRoot, + onOpenSettings, + onOpenShare, + primaryActiveItem, + renderRootBreadcrumbLabel, + ]); + + const renderFolderBreadcrumb = useCallback( + (entry: Extract) => ( + + ), + [ + breadcrumbItemClass, + handleFolderClick, + hoveredBreadcrumbTarget, + renderFolderBreadcrumbLabel, + ], + ); + + const renderItemBreadcrumb = useCallback( + (entry: Extract, measurement = false) => { + if (measurement) { + return ( +
+ {renderItemBreadcrumbLabel(entry)} + +
+ ); + } + + return ( +
openItemRenameDialog(entry.id, entry.label)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + openItemRenameDialog(entry.id, entry.label); + } + }} + className={cn( + breadcrumbItemClass, + BREADCRUMB_INTERACTIVE_CLASS, + "group pr-0.5", + )} + > + {renderItemBreadcrumbLabel(entry)} + + +
+ ); + }, + [ + breadcrumbItemClass, + onCloseActiveItem, + openItemRenameDialog, + renderItemBreadcrumbLabel, + ], + ); + + const renderBreadcrumbEntry = useCallback( + (entry: BreadcrumbEntry) => { + switch (entry.kind) { + case "root": + return renderRootBreadcrumb(); + case "folder": + return renderFolderBreadcrumb(entry); + case "item": + return renderItemBreadcrumb(entry); + default: + return null; + } + }, + [renderFolderBreadcrumb, renderItemBreadcrumb, renderRootBreadcrumb], + ); + + const renderMeasurementBreadcrumbEntry = useCallback( + (entry: BreadcrumbEntry) => { + switch (entry.kind) { + case "root": + return ( +
+ {renderRootBreadcrumbLabel()} +
+ ); + case "folder": + return ( +
+ {renderFolderBreadcrumbLabel(entry)} +
+ ); + case "item": + return renderItemBreadcrumb(entry, true); + default: + return null; + } + }, + [ + breadcrumbItemClass, + renderFolderBreadcrumbLabel, + renderItemBreadcrumb, + renderRootBreadcrumbLabel, + ], + ); + // Auto-focus and select all text when dialog opens useEffect(() => { if (showRenameDialog && renameInputRef.current) { @@ -405,9 +924,9 @@ export function WorkspaceHeader({ {...fileInputProps} /> {/* Main container with flex layout */} -
+
{/* Left Side: Sidebar Toggle + Navigation Arrows + Breadcrumbs */} -
+
{isWorkspaceRoute && ( {/* Breadcrumbs */} -
@@ -729,7 +1016,7 @@ export function WorkspaceHeader({ {activeItemMode === 'maximized' && activeItems.length === 1 ? ( // Maximized Mode: Show Item Controls // Portal divs only for PDF (PdfPanelHeader uses them); skip for documents to avoid double gap -
+
{activeItems[0]?.type === "pdf" && (
)} @@ -828,7 +1115,7 @@ export function WorkspaceHeader({
) : ( // Default Mode: Standard Workspace Controls -
+
{/* Collaborator Avatars - show who's in the workspace */} @@ -956,6 +1243,40 @@ export function WorkspaceHeader({
)}
+ {/* Rename Dialog */} { (onRenameFolder || onUpdateActiveItem) && ( diff --git a/src/components/workspace/SharedWorkspaceModal.tsx b/src/components/workspace/SharedWorkspaceModal.tsx index f106982f..c6bd81e3 100644 --- a/src/components/workspace/SharedWorkspaceModal.tsx +++ b/src/components/workspace/SharedWorkspaceModal.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState, useEffect } from "react"; -import { useRouter, useParams } from "next/navigation"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; @@ -14,7 +15,7 @@ import { SwatchesPicker, ColorResult } from "react-color"; import { SWATCHES_COLOR_GROUPS, type CardColor } from "@/lib/workspace-state/colors"; import { Skeleton } from "@/components/ui/skeleton"; import type { AgentState } from "@/lib/workspace-state/types"; -import { cn } from "@/lib/utils"; +import { useCreateWorkspace } from "@/hooks/workspace/use-create-workspace"; interface SharedWorkspaceData { workspace: { @@ -33,166 +34,113 @@ interface SharedWorkspaceModalProps { workspaceId: string; } -export default function SharedWorkspaceModal({ - open, - onOpenChange, +interface SharedWorkspaceModalContentProps { + workspaceId: string; + onImported: (slug: string) => void; + onDismiss: () => void; +} + +function SharedWorkspaceModalContent({ workspaceId, -}: SharedWorkspaceModalProps) { - const router = useRouter(); - const [workspaceData, setWorkspaceData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const [name, setName] = useState(""); - const [selectedIcon, setSelectedIcon] = useState(null); - const [selectedColor, setSelectedColor] = useState(null); + onImported, + onDismiss, +}: SharedWorkspaceModalContentProps) { + const createWorkspace = useCreateWorkspace(); + const [formError, setFormError] = useState(null); + const [nameOverride, setNameOverride] = useState(undefined); + const [selectedIconOverride, setSelectedIconOverride] = useState< + string | null | undefined + >(undefined); + const [selectedColorOverride, setSelectedColorOverride] = useState< + CardColor | null | undefined + >(undefined); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); - const [isCreating, setIsCreating] = useState(false); - // Fetch workspace data when modal opens - useEffect(() => { - if (open && workspaceId) { - const fetchWorkspaceData = async () => { - try { - setLoading(true); - setError(null); - const response = await fetch(`/api/share/${workspaceId}`); + const sharedWorkspaceQuery = useQuery({ + queryKey: ["shared-workspace", workspaceId], + queryFn: async (): Promise => { + const response = await fetch(`/api/share/${workspaceId}`); - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || "Failed to load workspace"); - } + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || "Failed to load workspace"); + } - const data = await response.json() as SharedWorkspaceData; - setWorkspaceData(data); - - // Pre-fill form with shared workspace data - setName(data.workspace.name || ""); - setSelectedIcon(data.workspace.icon || null); - setSelectedColor(data.workspace.color || null); - } catch (err) { - console.error("Error fetching workspace data:", err); - setError(err instanceof Error ? err.message : "Failed to load workspace"); - } finally { - setLoading(false); - } - }; + return response.json(); + }, + enabled: Boolean(workspaceId), + staleTime: 5 * 60 * 1000, + retry: 1, + }); - fetchWorkspaceData(); - } - }, [open, workspaceId]); + const workspaceData = sharedWorkspaceQuery.data ?? null; + const isLoading = sharedWorkspaceQuery.isLoading; + const isCreating = createWorkspace.isPending; + const name = nameOverride ?? workspaceData?.workspace.name ?? ""; + const selectedIcon = + selectedIconOverride ?? workspaceData?.workspace.icon ?? null; + const selectedColor = + selectedColorOverride ?? workspaceData?.workspace.color ?? null; + const queryError = + sharedWorkspaceQuery.error instanceof Error + ? sharedWorkspaceQuery.error.message + : null; + const error = formError ?? queryError; const handleCreate = async () => { if (!name.trim()) { - setError("Workspace name is required"); + setFormError("Workspace name is required"); return; } if (!workspaceData) { - setError("Workspace data not loaded"); + setFormError("Workspace data not loaded"); return; } - setIsCreating(true); - setError(null); + setFormError(null); try { - const response = await fetch("/api/workspaces", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: name.trim(), - template: "blank", - is_public: false, - icon: selectedIcon, - color: selectedColor, - initialState: workspaceData.workspace.state, - }), + const { workspace } = await createWorkspace.mutateAsync({ + name: name.trim(), + template: "blank", + is_public: false, + icon: selectedIcon, + color: selectedColor, + initialState: workspaceData.workspace.state, }); - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || "Failed to create workspace"); - } - - const { workspace } = await response.json(); - toast.success("Workspace created successfully"); - - // Close modal first - onOpenChange(false); - - // Use full page navigation to ensure workspace context and state are properly loaded - // This ensures a clean reload where the workspace will be found by slug - window.location.href = `/workspace/${workspace.slug}`; + onImported(workspace.slug); } catch (err) { console.error("Error creating workspace:", err); - const errorMessage = err instanceof Error ? err.message : "Failed to create workspace"; - setError(errorMessage); + const errorMessage = + err instanceof Error ? err.message : "Failed to create workspace"; + setFormError(errorMessage); toast.error(errorMessage); - setIsCreating(false); - } - }; - - const handleOpenChange = (newOpen: boolean) => { - if (!isCreating) { - onOpenChange(newOpen); - if (!newOpen) { - router.push("/home"); - } } }; - // Handle escape key - useEffect(() => { - if (!open) return; - - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape" && !isCreating) { - handleOpenChange(false); - } - }; - - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, isCreating]); - - // Prevent body scroll when modal is open - useEffect(() => { - if (open) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = ""; - } - return () => { - document.body.style.overflow = ""; - }; - }, [open]); - - const isLoading = loading; const hasData = workspaceData !== null; const itemCount = workspaceData?.workspace.state?.items?.length || 0; - if (!open) return null; - return ( -
- {/* Overlay - transparent */} -
handleOpenChange(false)} - /> - - {/* Modal Content */} -
e.stopPropagation()} + { + if (isCreating) { + event.preventDefault(); + } + }} + onEscapeKeyDown={(event) => { + if (isCreating) { + event.preventDefault(); + } + }} > {/* Header */} -
+ {isLoading ? ( <> @@ -200,7 +148,7 @@ export default function SharedWorkspaceModal({ ) : ( <> -

Import Shared Workspace

+ Import Shared Workspace

{hasData ? ( `Create your own copy of "${workspaceData.workspace.name}" with ${itemCount} item${itemCount !== 1 ? 's' : ''}.` @@ -210,7 +158,7 @@ export default function SharedWorkspaceModal({

)} -
+
{/* Name */} @@ -223,7 +171,10 @@ export default function SharedWorkspaceModal({ id="name" placeholder="My Workspace" value={name} - onChange={(e) => setName(e.target.value)} + onChange={(e) => { + setFormError(null); + setNameOverride(e.target.value); + }} disabled={isCreating} autoFocus /> @@ -237,7 +188,13 @@ export default function SharedWorkspaceModal({ {isLoading ? ( ) : ( - + { + setFormError(null); + setSelectedIconOverride(icon); + }} + >
{/* Footer */} -
+
+
-
-
+ + ); +} + +export default function SharedWorkspaceModal({ + open, + onOpenChange, + workspaceId, +}: SharedWorkspaceModalProps) { + const router = useRouter(); + + const handleOpenChange = (newOpen: boolean) => { + onOpenChange(newOpen); + if (!newOpen) { + router.push("/home"); + } + }; + const handleDismiss = () => handleOpenChange(false); + + return ( + + {open ? ( + { + window.location.href = `/workspace/${slug}`; + }} + /> + ) : null} + ); } diff --git a/src/hooks/ai/use-create-card-from-message.ts b/src/hooks/ai/use-create-card-from-message.ts index 05ca50d2..3ec0036f 100644 --- a/src/hooks/ai/use-create-card-from-message.ts +++ b/src/hooks/ai/use-create-card-from-message.ts @@ -8,6 +8,7 @@ import { useUIStore } from "@/lib/stores/ui-store"; import { useQueryClient } from "@tanstack/react-query"; import { logger } from "@/lib/utils/logger"; import { normalizeWebSearchResult } from "@/lib/ai/web-search-shared"; +import { CHAT_TOOL, toolPartMatchesCanonical } from "@/lib/ai/chat-tool-names"; interface CreateCardOptions { debounceMs?: number; @@ -45,7 +46,7 @@ export function useCreateCardFromMessage(options: CreateCardOptions = {}) { }; if ( - toolPart.type !== "tool-webSearch" || + !toolPartMatchesCanonical(toolPart.type, CHAT_TOOL.WEB_SEARCH) || toolPart.state !== "output-available" ) { return []; diff --git a/src/hooks/ai/use-workspace-context-provider.ts b/src/hooks/ai/use-workspace-context-provider.ts index 31c3701a..4d78e18c 100644 --- a/src/hooks/ai/use-workspace-context-provider.ts +++ b/src/hooks/ai/use-workspace-context-provider.ts @@ -1,10 +1,10 @@ -import { useAui } from "@assistant-ui/react"; -import { useEffect, useMemo } from "react"; +import { useAssistantContext } from "@assistant-ui/react"; +import { useCallback, useMemo } from "react"; import type { AgentState } from "@/lib/workspace-state/types"; import { formatWorkspaceContext } from "@/lib/utils/format-workspace-context"; /** - * Hook that injects minimal workspace context (metadata and system instructions) into the assistant using modelContext API + * Hook that injects minimal workspace context (metadata and system instructions) into the assistant. * Cards register their own context individually, so this only includes workspace-level metadata * Automatically updates when workspace state changes and cleans up on unmount * @param workspaceNameFallback - Fallback from DB (workspace.name) when state.globalTitle is empty @@ -14,24 +14,15 @@ export function useWorkspaceContextProvider( state: AgentState, workspaceNameFallback?: string ) { - const aui = useAui(); - - // Format workspace context - memoized to avoid recalculation const contextInstructions = useMemo( - () => workspaceId ? formatWorkspaceContext(state, workspaceNameFallback) : "", + () => (workspaceId ? formatWorkspaceContext(state, workspaceNameFallback) : ""), [workspaceId, state, workspaceNameFallback] ); - // Register context provider with proper cleanup - useEffect(() => { - if (!workspaceId) { - return; - } + const getContext = useCallback(() => contextInstructions, [contextInstructions]); - return aui.modelContext().register({ - getModelContext: () => ({ - system: contextInstructions, - }), - }); - }, [aui, contextInstructions, workspaceId]); + useAssistantContext({ + disabled: !workspaceId, + getContext, + }); } diff --git a/src/lib/ai/__tests__/legacy-tool-message-compat.test.ts b/src/lib/ai/__tests__/legacy-tool-message-compat.test.ts index 468471c6..39dfc999 100644 --- a/src/lib/ai/__tests__/legacy-tool-message-compat.test.ts +++ b/src/lib/ai/__tests__/legacy-tool-message-compat.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { UIMessage } from "ai"; +import { safeValidateUIMessages, type UIMessage } from "ai"; import { normalizeLegacyToolMessages } from "../legacy-tool-message-compat"; describe("normalizeLegacyToolMessages", () => { @@ -28,39 +28,13 @@ describe("normalizeLegacyToolMessages", () => { const part = normalized[0]?.parts[0]; expect(part).toMatchObject({ - type: "tool-processUrls", + type: "tool-web_fetch", input: { urls: ["https://example.com"], }, }); }); - it("normalizes legacy executeCode object outputs", () => { - const messages = [ - { - id: "1", - role: "assistant", - parts: [ - { - type: "tool-executeCode", - toolCallId: "call_2", - state: "output-available", - input: { task: "Compute fibonacci." }, - output: { text: "The answer is 6765." }, - }, - ], - }, - ] as UIMessage[]; - - const normalized = normalizeLegacyToolMessages(messages); - const part = normalized[0]?.parts[0]; - - expect(part).toMatchObject({ - type: "tool-executeCode", - output: "The answer is 6765.", - }); - }); - it("normalizes legacy webSearch string outputs", () => { const messages = [ { @@ -90,7 +64,7 @@ describe("normalizeLegacyToolMessages", () => { const part = normalized[0]?.parts[0]; expect(part).toMatchObject({ - type: "tool-webSearch", + type: "tool-web_search", output: { text: "Summary text", sources: [{ title: "Example", url: "https://example.com" }], @@ -126,11 +100,166 @@ describe("normalizeLegacyToolMessages", () => { const part = normalized[0]?.parts[0]; expect(part).toMatchObject({ - type: "tool-webSearch", + type: "tool-web_search", output: { text: "Summary text", sources: [], }, }); }); + + it("leaves canonical snake_case tool part types unchanged", () => { + const messages = [ + { + id: "1", + role: "assistant", + parts: [ + { + type: "tool-workspace_read", + toolCallId: "call_x", + state: "input-available", + input: { path: "a.md" }, + }, + ], + }, + ] as UIMessage[]; + + const normalized = normalizeLegacyToolMessages(messages); + expect(normalized[0]?.parts[0]).toMatchObject({ + type: "tool-workspace_read", + input: { path: "a.md" }, + }); + }); + + it("maps intermediate snake read_workspace to workspace_read", () => { + const messages = [ + { + id: "1", + role: "assistant", + parts: [ + { + type: "tool-read_workspace", + toolCallId: "call_y", + state: "input-available", + input: { path: "b.md" }, + }, + ], + }, + ] as UIMessage[]; + + const normalized = normalizeLegacyToolMessages(messages); + expect(normalized[0]?.parts[0]).toMatchObject({ + type: "tool-workspace_read", + input: { path: "b.md" }, + }); + }); + + it("preserves active non-canonical tools when they are still available", () => { + const messages = [ + { + id: "1", + role: "assistant", + parts: [ + { + type: "tool-custom_client_tool", + toolCallId: "call_custom", + state: "input-available", + input: { query: "hello" }, + }, + ], + }, + ] as UIMessage[]; + + const normalized = normalizeLegacyToolMessages(messages, { + availableToolNames: ["custom_client_tool"], + }); + + expect(normalized[0]?.parts[0]).toMatchObject({ + type: "tool-custom_client_tool", + input: { query: "hello" }, + }); + }); + + it("downgrades removed camelCase tools to text", () => { + const messages = [ + { + id: "1", + role: "assistant", + parts: [ + { + type: "tool-magicFetch", + toolCallId: "call_removed", + state: "output-available", + input: { description: "fetch something" }, + output: "done", + }, + ], + }, + ] as UIMessage[]; + + const normalized = normalizeLegacyToolMessages(messages); + expect(normalized[0]?.parts[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("magicFetch"), + }); + }); + + it("downgrades removed snake_case tools to text", () => { + const messages = [ + { + id: "1", + role: "assistant", + parts: [ + { + type: "tool-magic_fetch", + toolCallId: "call_removed_snake", + state: "input-available", + input: { description: "fetch something else" }, + }, + ], + }, + ] as UIMessage[]; + + const normalized = normalizeLegacyToolMessages(messages); + expect(normalized[0]?.parts[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("magic_fetch"), + }); + }); + + it("allows validation to succeed after downgrading removed tools", async () => { + const messages = [ + { + id: "1", + role: "assistant", + parts: [ + { + type: "tool-magic_fetch", + toolCallId: "call_removed_validation", + state: "output-available", + input: { description: "old removed tool" }, + output: "legacy output", + }, + ], + }, + ] as UIMessage[]; + + const originalValidation = await safeValidateUIMessages({ + messages, + tools: {}, + }); + expect(originalValidation.success).toBe(false); + + const normalized = normalizeLegacyToolMessages(messages); + const normalizedValidation = await safeValidateUIMessages({ + messages: normalized, + tools: {}, + }); + + expect(normalizedValidation.success).toBe(true); + expect(normalized[0]?.parts[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("no longer supported"), + }); + }); }); diff --git a/src/lib/ai/__tests__/tool-result-schemas.test.ts b/src/lib/ai/__tests__/tool-result-schemas.test.ts deleted file mode 100644 index 6fc48b8b..00000000 --- a/src/lib/ai/__tests__/tool-result-schemas.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseStringResult } from "../tool-result-schemas"; - -describe("parseStringResult", () => { - it("returns a raw string unchanged", () => { - expect(parseStringResult("plain markdown")).toBe("plain markdown"); - }); - - it("unwraps string-like object payloads", () => { - expect(parseStringResult({ value: "wrapped markdown" })).toBe("wrapped markdown"); - expect(parseStringResult({ text: "tool output" })).toBe("tool output"); - expect(parseStringResult({ result: "final answer" })).toBe("final answer"); - }); - - it("still rejects objects without a string payload", () => { - expect(() => parseStringResult({ ok: true })).toThrow("Invalid StringResult payload"); - }); -}); diff --git a/src/lib/ai/chat-tool-names.ts b/src/lib/ai/chat-tool-names.ts new file mode 100644 index 00000000..30b78b1e --- /dev/null +++ b/src/lib/ai/chat-tool-names.ts @@ -0,0 +1,92 @@ +/** + * Canonical chat tool names: `{scope}_{action}` (e.g. web_search, web_fetch). + * Legacy camelCase and older snake_case names are accepted in persisted UI + * messages β€” see LEGACY_CHAT_TOOL_NAMES. + */ + +export const CHAT_TOOL = { + WEB_FETCH: "web_fetch", + WEB_SEARCH: "web_search", + WORKSPACE_SEARCH: "workspace_search", + WORKSPACE_READ: "workspace_read", + DOCUMENT_CREATE: "document_create", + ITEM_EDIT: "item_edit", + ITEM_DELETE: "item_delete", + FLASHCARDS_CREATE: "flashcards_create", + QUIZ_CREATE: "quiz_create", + YOUTUBE_SEARCH: "youtube_search", + YOUTUBE_ADD: "youtube_add", +} as const; + +export type ChatToolName = (typeof CHAT_TOOL)[keyof typeof CHAT_TOOL]; +const CANONICAL_CHAT_TOOL_NAMES = new Set(Object.values(CHAT_TOOL)); + +/** Older tool name β†’ canonical */ +export const LEGACY_CHAT_TOOL_NAMES: Record = { + // Original camelCase + processUrls: CHAT_TOOL.WEB_FETCH, + urlFetch: CHAT_TOOL.WEB_FETCH, + webSearch: CHAT_TOOL.WEB_SEARCH, + searchWorkspace: CHAT_TOOL.WORKSPACE_SEARCH, + readWorkspace: CHAT_TOOL.WORKSPACE_READ, + createDocument: CHAT_TOOL.DOCUMENT_CREATE, + editItem: CHAT_TOOL.ITEM_EDIT, + deleteItem: CHAT_TOOL.ITEM_DELETE, + createFlashcards: CHAT_TOOL.FLASHCARDS_CREATE, + createQuiz: CHAT_TOOL.QUIZ_CREATE, + searchYoutube: CHAT_TOOL.YOUTUBE_SEARCH, + addYoutubeVideo: CHAT_TOOL.YOUTUBE_ADD, + // Intermediate snake_case (pre–resource_action rename) + process_urls: CHAT_TOOL.WEB_FETCH, + search_workspace: CHAT_TOOL.WORKSPACE_SEARCH, + read_workspace: CHAT_TOOL.WORKSPACE_READ, + create_document: CHAT_TOOL.DOCUMENT_CREATE, + edit_item: CHAT_TOOL.ITEM_EDIT, + delete_item: CHAT_TOOL.ITEM_DELETE, + create_flashcards: CHAT_TOOL.FLASHCARDS_CREATE, + create_quiz: CHAT_TOOL.QUIZ_CREATE, + search_youtube: CHAT_TOOL.YOUTUBE_SEARCH, + add_youtube_video: CHAT_TOOL.YOUTUBE_ADD, + youtube_video_add: CHAT_TOOL.YOUTUBE_ADD, +}; + +const canonicalToLegacy = (() => { + const m = new Map(); + for (const [legacy, canonical] of Object.entries(LEGACY_CHAT_TOOL_NAMES)) { + const list = m.get(canonical) ?? []; + list.push(legacy); + m.set(canonical, list); + } + return m; +})(); + +export function getLegacyChatToolAliases(canonical: string): string[] { + return canonicalToLegacy.get(canonical) ?? []; +} + +/** Map `tool-{name}` part types to canonical tool-* types. */ +export function canonicalizeToolUIPartType(partType: string): string { + if (!partType.startsWith("tool-")) return partType; + const suffix = partType.slice("tool-".length); + const mapped = LEGACY_CHAT_TOOL_NAMES[suffix]; + return mapped ? `tool-${mapped}` : partType; +} + +export function toolPartMatchesCanonical( + partType: string | undefined, + canonical: ChatToolName, +): boolean { + if (!partType?.startsWith("tool-")) return false; + const suffix = partType.slice("tool-".length); + if (suffix === canonical) return true; + return LEGACY_CHAT_TOOL_NAMES[suffix] === canonical; +} + +export function isCanonicalChatToolName(name: string): name is ChatToolName { + return CANONICAL_CHAT_TOOL_NAMES.has(name); +} + +/** Autogen / SSE events that mirror the web search tool */ +export function matchesWebSearchStreamToolName(name: string | undefined): boolean { + return name === CHAT_TOOL.WEB_SEARCH || name === "webSearch"; +} diff --git a/src/lib/ai/clean-message-content.ts b/src/lib/ai/clean-message-content.ts index 84eebdf2..166d6fb4 100644 --- a/src/lib/ai/clean-message-content.ts +++ b/src/lib/ai/clean-message-content.ts @@ -1,6 +1,6 @@ /** * Processes message content to extract a title and clean up the content - * Similar to how the createDocument tool processes input + * Similar to how the document_create tool processes input */ export interface ProcessedContent { @@ -10,7 +10,7 @@ export interface ProcessedContent { /** * Processes message content to extract a title and clean up the content - * Replicates the behavior of the createDocument tool + * Replicates the behavior of the document_create tool */ export function processMessageContent(rawContent: string): ProcessedContent { // If the content is empty, return defaults diff --git a/src/lib/ai/legacy-tool-message-compat.ts b/src/lib/ai/legacy-tool-message-compat.ts index 1c447942..68c0fa97 100644 --- a/src/lib/ai/legacy-tool-message-compat.ts +++ b/src/lib/ai/legacy-tool-message-compat.ts @@ -1,29 +1,28 @@ import type { UIMessage } from "ai"; +import { + CHAT_TOOL, + canonicalizeToolUIPartType, + isCanonicalChatToolName, +} from "./chat-tool-names"; import { normalizeProcessUrlsArgs } from "./process-urls-shared"; import { normalizeWebSearchResult } from "./web-search-shared"; -function normalizeExecuteCodeOutput(output: unknown): unknown { - if (typeof output === "string") { - return output; - } - - if (output != null && typeof output === "object" && !Array.isArray(output)) { - const record = output as Record; - if (typeof record.text === "string") return record.text; - if (typeof record.result === "string") return record.result; - if (typeof record.value === "string") return record.value; - } - - return output; -} - function normalizeWebSearchOutput(output: unknown): unknown { const normalized = normalizeWebSearchResult(output); return normalized ?? output; } -export function normalizeLegacyToolMessages(messages: UIMessage[]): UIMessage[] { - return messages.map((message) => { +function summarizeRemovedToolPart(toolName: string): string { + return `[Legacy tool omitted: ${toolName}. This tool is no longer supported in chat history replay.]`; +} + +export function normalizeLegacyToolMessages( + messages: UIMessage[], + options?: { availableToolNames?: Iterable }, +): UIMessage[] { + const availableToolNames = new Set(options?.availableToolNames ?? []); + + return messages.map((message): UIMessage => { if (!Array.isArray(message.parts)) { return message; } @@ -33,32 +32,44 @@ export function normalizeLegacyToolMessages(messages: UIMessage[]): UIMessage[] return part; } - if (part.type === "tool-processUrls" && "input" in part) { - const normalizedInput = normalizeProcessUrlsArgs(part.input); - return normalizedInput ? { ...part, input: normalizedInput } : part; - } + const originalToolName = part.type.slice("tool-".length); + const type = canonicalizeToolUIPartType(part.type); + const canonicalToolName = type.slice("tool-".length); + // Downgrade to text only when this part was not transformed (`type === part.type`), + // the original name is unknown to the current runtime (`!availableToolNames.has(originalToolName)`), + // and it is not a canonical tool name (`!isCanonicalChatToolName(originalToolName)`). + // If we transformed a legacy alias, we keep the tool part and preserve behavior. if ( - part.type === "tool-executeCode" && - "state" in part && - part.state === "output-available" && - "output" in part + type === part.type && + !availableToolNames.has(originalToolName) && + !isCanonicalChatToolName(originalToolName) ) { - return { ...part, output: normalizeExecuteCodeOutput(part.output) }; + return { + type: "text", + text: summarizeRemovedToolPart(originalToolName), + }; + } + + let next = type === part.type ? part : { ...part, type }; + + if (type === `tool-${CHAT_TOOL.WEB_FETCH}` && "input" in next) { + const normalizedInput = normalizeProcessUrlsArgs(next.input); + next = normalizedInput ? { ...next, input: normalizedInput } : next; } if ( - part.type === "tool-webSearch" && - "state" in part && - part.state === "output-available" && - "output" in part + type === `tool-${CHAT_TOOL.WEB_SEARCH}` && + "state" in next && + next.state === "output-available" && + "output" in next ) { - return { ...part, output: normalizeWebSearchOutput(part.output) }; + next = { ...next, output: normalizeWebSearchOutput(next.output) }; } - return part; + return next; }); - return { ...message, parts }; + return { ...message, parts } as UIMessage; }); } diff --git a/src/lib/ai/tool-result-schemas.ts b/src/lib/ai/tool-result-schemas.ts index 19f5d050..23137e7b 100644 --- a/src/lib/ai/tool-result-schemas.ts +++ b/src/lib/ai/tool-result-schemas.ts @@ -20,11 +20,11 @@ const baseWorkspace = z }) .passthrough(); -/** createDocument, clearCardContent, editItem */ +/** document_create, item_edit */ export const WorkspaceResultSchema = baseWorkspace; export type WorkspaceResult = z.infer; -/** editItem - extends WorkspaceResult with diff, filediff, cardCount, questionCount */ +/** item_edit - extends WorkspaceResult with diff, filediff, cardCount, questionCount */ export const EditItemResultSchema = baseWorkspace .extend({ diff: z.string().optional(), @@ -35,21 +35,34 @@ export const EditItemResultSchema = baseWorkspace .passthrough(); export type EditItemResult = z.infer; -/** Coerce string or other non-object tool results to a safe WorkspaceResult. */ -function coerceToWorkspaceResult(input: unknown): WorkspaceResult { - if (input == null) { - return { success: false, message: "No result" }; - } - if (typeof input === "string") { - return { success: false, message: input }; - } - if (typeof input !== "object" || Array.isArray(input)) { - return { success: false, message: "Invalid result format" }; - } - return parseWithSchema(WorkspaceResultSchema, input, "WorkspaceResult"); +function createCoerceFunction( + schema: z.ZodType, + schemaName: string, +) { + return (input: unknown): T => { + if (input == null) { + return { success: false, message: "No result" } as T; + } + if (typeof input === "string") { + return { success: false, message: input } as T; + } + if (typeof input !== "object" || Array.isArray(input)) { + return { success: false, message: "Invalid result format" } as T; + } + return parseWithSchema(schema, input, schemaName); + }; } +/** Coerce string or other non-object tool results to a safe WorkspaceResult. */ +const coerceToWorkspaceResult = createCoerceFunction( + WorkspaceResultSchema, + "WorkspaceResult", +); + export function parseWorkspaceResult(input: unknown): WorkspaceResult { + if (input == null) { + return coerceToWorkspaceResult(input); + } if (input != null && typeof input === "object" && !Array.isArray(input)) { return parseWithSchema(WorkspaceResultSchema, input, "WorkspaceResult"); } @@ -69,7 +82,7 @@ export function parseSelectCardsResult(input: unknown): SelectCardsResult { return parseWithSchema(SelectCardsResultSchema, input, "SelectCardsResult"); } -/** createQuiz */ +/** quiz_create */ export const QuizResultSchema = baseWorkspace.extend({ quizId: z.string().optional(), title: z.string().optional(), @@ -81,18 +94,10 @@ export const QuizResultSchema = baseWorkspace.extend({ export type QuizResult = z.infer; /** Coerce string or other non-object tool results to a safe QuizResult. */ -function coerceToQuizResult(input: unknown): QuizResult { - if (input == null) { - return { success: false, message: "No result" }; - } - if (typeof input === "string") { - return { success: false, message: input }; - } - if (typeof input !== "object" || Array.isArray(input)) { - return { success: false, message: "Invalid result format" }; - } - return parseWithSchema(QuizResultSchema, input, "QuizResult"); -} +const coerceToQuizResult = createCoerceFunction( + QuizResultSchema, + "QuizResult", +); export function parseQuizResult(input: unknown): QuizResult { if (input != null && typeof input === "object" && !Array.isArray(input)) { @@ -101,7 +106,7 @@ export function parseQuizResult(input: unknown): QuizResult { return coerceToQuizResult(input); } -/** createFlashcards */ +/** flashcards_create */ export const FlashcardResultSchema = baseWorkspace.extend({ title: z.string().optional(), cardCount: z.number().optional(), @@ -113,18 +118,10 @@ export const FlashcardResultSchema = baseWorkspace.extend({ export type FlashcardResult = z.infer; /** Coerce string or other non-object tool results to a safe FlashcardResult. */ -function coerceToFlashcardResult(input: unknown): FlashcardResult { - if (input == null) { - return { success: false, message: "No result" }; - } - if (typeof input === "string") { - return { success: false, message: input }; - } - if (typeof input !== "object" || Array.isArray(input)) { - return { success: false, message: "Invalid result format" }; - } - return parseWithSchema(FlashcardResultSchema, input, "FlashcardResult"); -} +const coerceToFlashcardResult = createCoerceFunction( + FlashcardResultSchema, + "FlashcardResult", +); export function parseFlashcardResult(input: unknown): FlashcardResult { if (input != null && typeof input === "object" && !Array.isArray(input)) { @@ -133,23 +130,7 @@ export function parseFlashcardResult(input: unknown): FlashcardResult { return coerceToFlashcardResult(input); } -/** executeCode returns markdown text, but SDK/history can rehydrate it as an object wrapper. */ -export function parseStringResult(input: unknown): string { - if (typeof input === "string") { - return input; - } - - if (input != null && typeof input === "object" && !Array.isArray(input)) { - const record = input as Record; - if (typeof record.text === "string") return record.text; - if (typeof record.result === "string") return record.result; - if (typeof record.value === "string") return record.value; - } - - return parseWithSchema(z.string(), input, "StringResult"); -} - -/** processUrls – result is string or { text, metadata } */ +/** web_fetch – result is string or { text, metadata } */ export const URLContextResultSchema = z.union([z.string(), ProcessUrlsOutputSchema]); export type URLContextResult = z.infer; diff --git a/src/lib/ai/tools/edit-item-tool.ts b/src/lib/ai/tools/edit-item-tool.ts index 3f95c785..35bcc352 100644 --- a/src/lib/ai/tools/edit-item-tool.ts +++ b/src/lib/ai/tools/edit-item-tool.ts @@ -9,14 +9,14 @@ import { getVirtualPath } from "@/lib/utils/workspace-fs"; const EDITABLE_TYPES = ["flashcard", "quiz", "pdf", "document"] as const; /** - * Create the editItem tool - unified edit for documents, flashcards, quizzes, and PDFs. - * Uses oldString/newString search-replace on raw content from readWorkspace. + * Create the item_edit tool - unified edit for documents, flashcards, quizzes, and PDFs. + * Uses oldString/newString search-replace on raw content from workspace_read. * PDFs support RENAME ONLY: oldString='', newString='', newName='new name'. */ export function createEditItemTool(ctx: WorkspaceToolContext) { return withSanitizedModelOutput(tool({ description: - "Edit a document, flashcard deck, quiz, or PDF. You must use readWorkspace at least once before editing. DOCUMENTS: content is markdown from readWorkspace. PDFs: RENAME ONLY β€” pass oldString='', newString='', and newName='new name'. PDF content cannot be edited. RENAME ONLY (documents/flashcards/quizzes): same pattern to rename without editing content. QUIZZES: readWorkspace may show '--- Progress (read-only) ---' at the top. That block is READ-ONLY. Never include it in oldString or newString. Only edit the {\"questions\":[...]} JSON. FULL REWRITE: oldString='' and newString=entire new content (quizzes: only the JSON); use when targeted matching fails or the change is large (re-read if needed). TARGETED EDIT: oldString must match exactly. Copy the content from readWorkspace as-is (it has no line prefixes). Match exact whitespace, indentation, newlines. Do NOT minify JSON. Edit FAILS if oldString not found or matches multiple times β€” add more context or use replaceAll.", + "Edit a document, flashcard deck, quiz, or PDF. You must use workspace_read at least once before editing. DOCUMENTS: content is markdown from workspace_read. PDFs: RENAME ONLY β€” pass oldString='', newString='', and newName='new name'. PDF content cannot be edited. RENAME ONLY (documents/flashcards/quizzes): same pattern to rename without editing content. QUIZZES: workspace_read may show '--- Progress (read-only) ---' at the top. That block is READ-ONLY. Never include it in oldString or newString. Only edit the {\"questions\":[...]} JSON. FULL REWRITE: oldString='' and newString=entire new content (quizzes: only the JSON); use when targeted matching fails or the change is large (re-read if needed). TARGETED EDIT: oldString must match exactly. Copy the content from workspace_read as-is (it has no line prefixes). Match exact whitespace, indentation, newlines. Do NOT minify JSON. Edit FAILS if oldString not found or matches multiple times β€” add more context or use replaceAll.", inputSchema: zodSchema( z .object({ @@ -26,7 +26,7 @@ export function createEditItemTool(ctx: WorkspaceToolContext) { oldString: z .string() .describe( - "Text to find; '' = full rewrite. Targeted: copy from readWorkspace as-is, match whitespace, enough context to be unique, or replaceAll." + "Text to find; '' = full rewrite. Targeted: copy from workspace_read as-is, match whitespace, enough context to be unique, or replaceAll." ), newString: z.string().describe("Replacement text (entire content if oldString is empty)"), replaceAll: z diff --git a/src/lib/ai/tools/flashcard-tools.ts b/src/lib/ai/tools/flashcard-tools.ts index 71930aff..e916ec0d 100644 --- a/src/lib/ai/tools/flashcard-tools.ts +++ b/src/lib/ai/tools/flashcard-tools.ts @@ -5,7 +5,7 @@ import { workspaceWorker } from "@/lib/ai/workers"; import type { WorkspaceToolContext } from "./workspace-tools"; import { withSanitizedModelOutput } from "./tool-utils"; /** - * Create the createFlashcards tool + * Create the flashcards_create tool */ export function createFlashcardsTool(ctx: WorkspaceToolContext) { return withSanitizedModelOutput(tool({ @@ -68,4 +68,4 @@ export function createFlashcardsTool(ctx: WorkspaceToolContext) { })); } -// Edit functionality is in edit-item-tool.ts (editItem) +// Edit functionality is in edit-item-tool.ts (item_edit) diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts index 8752535e..b305d061 100644 --- a/src/lib/ai/tools/index.ts +++ b/src/lib/ai/tools/index.ts @@ -5,7 +5,6 @@ import { frontendTools } from "@assistant-ui/react-ai-sdk"; import { createProcessUrlsTool } from "./process-urls"; -import { createExecuteCodeTool } from "./search-code"; import { createDocumentTool, createDeleteItemTool, @@ -21,8 +20,8 @@ import { import { createWebSearchTool } from "./web-search"; import { createSearchWorkspaceTool } from "./search-workspace"; import { createReadWorkspaceTool } from "./read-workspace"; -import { createMagicFetchTool } from "./magic-fetch"; import { logger } from "@/lib/utils/logger"; +import { CHAT_TOOL } from "@/lib/ai/chat-tool-names"; export interface ChatToolsConfig { workspaceId: string | null; @@ -30,8 +29,6 @@ export interface ChatToolsConfig { activeFolderId?: string; threadId?: string | null; clientTools?: Record; - /** Experiment: enable magic_fetch tool (logs AI data requests to PostHog) */ - enableMagicFetch?: boolean; } /** @@ -55,34 +52,28 @@ export function createChatTools(config: ChatToolsConfig): Record { return { // URL processing - processUrls: createProcessUrlsTool(), + [CHAT_TOOL.WEB_FETCH]: createProcessUrlsTool(), - // Search & code execution - webSearch: createWebSearchTool(), - executeCode: createExecuteCodeTool(), - searchWorkspace: createSearchWorkspaceTool(ctx), - readWorkspace: createReadWorkspaceTool(ctx), + // Search + [CHAT_TOOL.WEB_SEARCH]: createWebSearchTool(), + [CHAT_TOOL.WORKSPACE_SEARCH]: createSearchWorkspaceTool(ctx), + [CHAT_TOOL.WORKSPACE_READ]: createReadWorkspaceTool(ctx), // Workspace operations - createDocument: createDocumentTool(ctx), - editItem: createEditItemTool(ctx), + [CHAT_TOOL.DOCUMENT_CREATE]: createDocumentTool(ctx), + [CHAT_TOOL.ITEM_EDIT]: createEditItemTool(ctx), - deleteItem: createDeleteItemTool(ctx), + [CHAT_TOOL.ITEM_DELETE]: createDeleteItemTool(ctx), // Flashcards - createFlashcards: createFlashcardsTool(ctx), + [CHAT_TOOL.FLASHCARDS_CREATE]: createFlashcardsTool(ctx), // Quizzes - createQuiz: createQuizTool(ctx), + [CHAT_TOOL.QUIZ_CREATE]: createQuizTool(ctx), // YouTube - searchYoutube: createSearchYoutubeTool(), - addYoutubeVideo: createAddYoutubeVideoTool(ctx), - - // Experiment: magic_fetch (logs to PostHog via OpenTelemetry) - ...(config.enableMagicFetch - ? { magicFetch: createMagicFetchTool(ctx) } - : {}), + [CHAT_TOOL.YOUTUBE_SEARCH]: createSearchYoutubeTool(), + [CHAT_TOOL.YOUTUBE_ADD]: createAddYoutubeVideoTool(ctx), // Client tools from frontend ...frontendClientTools, diff --git a/src/lib/ai/tools/magic-fetch.ts b/src/lib/ai/tools/magic-fetch.ts deleted file mode 100644 index 81afabf1..00000000 --- a/src/lib/ai/tools/magic-fetch.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Magic Fetch Tool (experiment) - * - * Use when the AI needs data it doesn't have access to. The AI describes what - * it needs and why. Tool calls are captured by PostHog tracing (withTracing). - */ - -import { tool, zodSchema } from "ai"; -import { z } from "zod"; -import type { WorkspaceToolContext } from "./workspace-tools"; - -export function createMagicFetchTool(_ctx: WorkspaceToolContext) { - return tool({ - description: `You can use this tool when you need any data that you don't currently have access to. Describe exactly what you need and why. This tool will retrieve it for you.`, - inputSchema: zodSchema( - z.object({ - description: z - .string() - .min(1) - .describe("What data you need and why you need it"), - }) - ), - strict: true, - execute: async () => { - return "Data retrieved successfully. Continue your reasoning."; - }, - }); -} diff --git a/src/lib/ai/tools/process-urls.ts b/src/lib/ai/tools/process-urls.ts index a3f40c79..216ef21b 100644 --- a/src/lib/ai/tools/process-urls.ts +++ b/src/lib/ai/tools/process-urls.ts @@ -18,7 +18,7 @@ function truncateContent(content: string): string { } /** - * Create the processUrls tool for analyzing web pages + * Create the web_fetch tool for analyzing web pages */ export function createProcessUrlsTool() { return tool({ diff --git a/src/lib/ai/tools/quiz-tools.ts b/src/lib/ai/tools/quiz-tools.ts index 6087c54b..9c214886 100644 --- a/src/lib/ai/tools/quiz-tools.ts +++ b/src/lib/ai/tools/quiz-tools.ts @@ -37,7 +37,7 @@ const CreateQuizInputSchema = z.object({ export type CreateQuizInput = z.infer; /** - * Create the createQuiz tool - AI generates questions directly (like createFlashcards) + * Create the quiz_create tool - AI generates questions directly (like flashcards_create) */ export function createQuizTool(ctx: WorkspaceToolContext) { return withSanitizedModelOutput(tool({ @@ -110,4 +110,4 @@ export function createQuizTool(ctx: WorkspaceToolContext) { })); } -// Edit functionality is in edit-item-tool.ts (editItem) +// Edit functionality is in edit-item-tool.ts (item_edit) diff --git a/src/lib/ai/tools/read-workspace.ts b/src/lib/ai/tools/read-workspace.ts index 93957b5d..ee1d4ff0 100644 --- a/src/lib/ai/tools/read-workspace.ts +++ b/src/lib/ai/tools/read-workspace.ts @@ -42,7 +42,7 @@ const ReadWorkspaceResultSchema = z.discriminatedUnion("success", [ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { return tool({ description: - "Read content of a workspace item by path or name. Works for documents, flashcards, PDFs, quizzes, images, audio, websites, and YouTube cards when readable content or metadata exists. Content is returned as raw lines with no line-number prefixes, so it can be copied directly into editItem oldString. The response includes rangeNote: 'Full content' when returning the entire item, or 'Lines X–Y of Z' (and 'has more' if there is more). Use lineStart (1-indexed) to read later sections. For PDFs: pageStart and pageEnd for page ranges. Use searchWorkspace to find content in large items. Quizzes include progress at the top when started. Any line longer than 2000 characters is truncated. Avoid tiny repeated slices; read a larger window.", + "Read content of a workspace item by path or name. Works for documents, flashcards, PDFs, quizzes, images, audio, websites, and YouTube cards when readable content or metadata exists. Content is returned as raw lines with no line-number prefixes, so it can be copied directly into item_edit oldString. The response includes rangeNote: 'Full content' when returning the entire item, or 'Lines X–Y of Z' (and 'has more' if there is more). Use lineStart (1-indexed) to read later sections. For PDFs: pageStart and pageEnd for page ranges. Use workspace_search to find content in large items. Quizzes include progress at the top when started. Any line longer than 2000 characters is truncated. Avoid tiny repeated slices; read a larger window.", inputSchema: zodSchema( z.object({ path: z @@ -135,7 +135,7 @@ export function createReadWorkspaceTool(ctx: WorkspaceToolContext) { return { success: false, message: `Item not found${itemName ? `: "${itemName}"` : ` at path: ${path}`}. ${ - sample ? `Example paths: ${sample}` : "Workspace may be empty. Use searchWorkspace to search." + sample ? `Example paths: ${sample}` : "Workspace may be empty. Use workspace_search to search." }`, }; } diff --git a/src/lib/ai/tools/search-code.ts b/src/lib/ai/tools/search-code.ts deleted file mode 100644 index 6fb92fdb..00000000 --- a/src/lib/ai/tools/search-code.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { google } from "@ai-sdk/google"; -import { z } from "zod"; -import { generateText, tool, zodSchema } from "ai"; -import { logger } from "@/lib/utils/logger"; - -/** - * Create the googleSearch tool - * Uses standard Google Search tool. - */ -export function createGoogleSearchTool() { - return google.tools.googleSearch({}); -} - -/** - * Create the executeCode tool - */ -export function createExecuteCodeTool() { - return tool({ - description: "Execute Python code for calculations, data processing, algorithms, or mathematical computations.", - inputSchema: zodSchema( - z.object({ - task: z.string().describe("Description of the task to solve with code"), - }) - ), - outputSchema: z.string(), - strict: true, - execute: async ({ task }) => { - logger.debug("🎯 [EXECUTE-CODE] Starting code execution:", task); - - const result = await generateText({ - model: google("gemini-2.5-flash"), - tools: { - code_execution: google.tools.codeExecution({}), - }, - prompt: `${task} - -Use Python code execution to solve this problem. Show your work and explain the result.`, - }); - - logger.debug("🎯 [EXECUTE-CODE] Code execution completed"); - return result.text; - }, - }); -} diff --git a/src/lib/ai/tools/search-workspace.ts b/src/lib/ai/tools/search-workspace.ts index f54c7f00..5d6a0453 100644 --- a/src/lib/ai/tools/search-workspace.ts +++ b/src/lib/ai/tools/search-workspace.ts @@ -40,7 +40,7 @@ function buildRegex(pattern: string): RegExp { export function createSearchWorkspaceTool(ctx: WorkspaceToolContext) { return tool({ description: - "Grep search across workspace. All item types match on path/title, including documents, flashcards, PDFs, quizzes, audio, images, websites, and YouTube cards. Types with readable body content or metadata also search that body. Line numbers for content matches align with readWorkspace(path, lineStart). include: optional item type filter. path: folder prefix or exact item path; for long items use readWorkspace(path, lineStart) on matches. Plain text or regex. Max 100 matches.", + "Grep search across workspace. All item types match on path/title, including documents, flashcards, PDFs, quizzes, audio, images, websites, and YouTube cards. Types with readable body content or metadata also search that body. Line numbers for content matches align with workspace_read(path, lineStart). include: optional item type filter. path: folder prefix or exact item path; for long items use workspace_read(path, lineStart) on matches. Plain text or regex. Max 100 matches.", inputSchema: zodSchema( z.object({ pattern: z.string().describe("Search pattern (plain text or regex)"), diff --git a/src/lib/ai/tools/web-search.ts b/src/lib/ai/tools/web-search.ts index 55ed761a..d2c8a234 100644 --- a/src/lib/ai/tools/web-search.ts +++ b/src/lib/ai/tools/web-search.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { tool, generateText, stepCountIs, zodSchema } from "ai"; +import { tool, generateText, stepCountIs, zodSchema, type ToolSet } from "ai"; import { google } from "@ai-sdk/google"; import { WebSearchResultSchema, @@ -95,14 +95,14 @@ export async function resolveGroundingChunksToSources( } /** - * Execute a web search and return text + sources. Used by both chat webSearch tool and autogen. + * Execute a web search and return text + sources. Used by the chat web_search tool and autogen. */ export async function executeWebSearch(query: string): Promise { const { text, providerMetadata } = await generateText({ model: google('gemini-2.5-flash-lite'), tools: { googleSearch: google.tools.googleSearch({}), - }, + } as ToolSet, prompt: `Search the web for current, accurate information about: ${query} Use the search tool to find relevant sources. Format your response as: diff --git a/src/lib/ai/tools/workspace-search-utils.ts b/src/lib/ai/tools/workspace-search-utils.ts index e354b35a..314a23a4 100644 --- a/src/lib/ai/tools/workspace-search-utils.ts +++ b/src/lib/ai/tools/workspace-search-utils.ts @@ -17,13 +17,13 @@ import { getVirtualPath } from "@/lib/utils/workspace-fs"; export interface SearchableText { /** Path and title lines (1-2 lines). Matches here use matchKind, not lineNum. */ header: string; - /** Body content. Line numbers align with readWorkspace(path, lineStart). */ + /** Body content. Line numbers align with workspace_read(path, lineStart). */ content: string; } /** * Extract plain text from an item for searching (grep). - * Returns header (path, title) and content separately so grep line numbers align with readWorkspace. + * Returns header (path, title) and content separately so grep line numbers align with workspace_read. * Header matches use matchKind (path/title); content matches use 1-based lineNum. */ export function extractSearchableText( diff --git a/src/lib/ai/tools/workspace-tools.ts b/src/lib/ai/tools/workspace-tools.ts index 4debb472..60a8ccf1 100644 --- a/src/lib/ai/tools/workspace-tools.ts +++ b/src/lib/ai/tools/workspace-tools.ts @@ -6,7 +6,7 @@ import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import type { Item } from "@/lib/workspace-state/types"; import { loadStateForTool, resolveItem, getAvailableItemsList, withSanitizedModelOutput } from "./tool-utils"; -// Note: Edit functionality is in edit-item-tool.ts (editItem) +// Note: Edit functionality is in edit-item-tool.ts (item_edit) export interface WorkspaceToolContext { workspaceId: string | null; @@ -16,7 +16,7 @@ export interface WorkspaceToolContext { } /** - * Create the createDocument tool + * Create the document_create tool */ export function createDocumentTool(ctx: WorkspaceToolContext) { return withSanitizedModelOutput(tool({ @@ -75,7 +75,7 @@ export function createDocumentTool(ctx: WorkspaceToolContext) { } /** - * Create the deleteItem tool + * Create the item_delete tool */ export function createDeleteItemTool(ctx: WorkspaceToolContext) { return withSanitizedModelOutput(tool({ diff --git a/src/lib/ai/tools/youtube-tools.ts b/src/lib/ai/tools/youtube-tools.ts index 06324129..c53c9326 100644 --- a/src/lib/ai/tools/youtube-tools.ts +++ b/src/lib/ai/tools/youtube-tools.ts @@ -7,7 +7,7 @@ import type { WorkspaceToolContext } from "./workspace-tools"; import { withSanitizedModelOutput } from "./tool-utils"; /** - * Create the searchYoutube tool + * Create the youtube_search tool */ export function createSearchYoutubeTool() { return tool({ @@ -38,7 +38,7 @@ export function createSearchYoutubeTool() { } /** - * Create the addYoutubeVideo tool + * Create the youtube_add tool */ export function createAddYoutubeVideoTool(ctx: WorkspaceToolContext) { return withSanitizedModelOutput(tool({ diff --git a/src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts b/src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts index a9f96163..7ae92f23 100644 --- a/src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts +++ b/src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts @@ -5,6 +5,7 @@ const mockHeaders = vi.fn(); const mockLoadWorkspaceState = vi.fn(); const mockCreateEvent = vi.fn(); const mockExecute = vi.fn(); +const mockBroadcastWorkspaceEventFromServer = vi.fn(); const mockLimit = vi.fn(); const mockWhere = vi.fn(() => ({ limit: mockLimit })); @@ -50,6 +51,11 @@ vi.mock("@/lib/workspace/events", () => ({ createEvent: (...args: any[]) => mockCreateEvent(...args), })); +vi.mock("@/lib/realtime/server-broadcast", () => ({ + broadcastWorkspaceEventFromServer: (...args: any[]) => + mockBroadcastWorkspaceEventFromServer(...args), +})); + vi.mock("@/lib/workspace/unique-name", () => ({ hasDuplicateName: () => false, })); @@ -135,6 +141,18 @@ describe("workspaceWorker edit end-to-end paths", () => { expect(result.success).toBe(true); expect(result.message).toMatch(/Updated quiz/); expect((result as any).questionCount).toBe(2); + expect(mockBroadcastWorkspaceEventFromServer).toHaveBeenCalledTimes(1); + expect(mockBroadcastWorkspaceEventFromServer).toHaveBeenCalledWith( + "ws-1", + expect.objectContaining({ + id: "evt-1", + type: "ITEM_UPDATED", + version: 2, + payload: expect.objectContaining({ + id: "quiz-1", + }), + }), + ); }); it("repairs malformed appended flashcard JSON and succeeds", async () => { @@ -176,6 +194,18 @@ describe("workspaceWorker edit end-to-end paths", () => { expect(result.success).toBe(true); expect(result.message).toMatch(/Updated flashcard deck/); expect((result as any).cardCount).toBe(2); + expect(mockBroadcastWorkspaceEventFromServer).toHaveBeenCalledTimes(1); + expect(mockBroadcastWorkspaceEventFromServer).toHaveBeenCalledWith( + "ws-1", + expect.objectContaining({ + id: "evt-1", + type: "ITEM_UPDATED", + version: 5, + payload: expect.objectContaining({ + id: "deck-1", + }), + }), + ); }); it("fails quiz edit when repaired JSON violates schema", async () => { @@ -294,6 +324,18 @@ describe("workspaceWorker edit end-to-end paths", () => { expect(result.success).toBe(true); expect(result.message).toMatch(/Updated document successfully/); expect(mockCreateEvent).toHaveBeenCalled(); + expect(mockBroadcastWorkspaceEventFromServer).toHaveBeenCalledTimes(1); + expect(mockBroadcastWorkspaceEventFromServer).toHaveBeenCalledWith( + "ws-1", + expect.objectContaining({ + id: "evt-1", + type: "ITEM_UPDATED", + version: 2, + payload: expect.objectContaining({ + id: "doc-1", + }), + }), + ); const eventPayload = mockCreateEvent.mock.calls[0][1] as { id: string; changes: Record }; expect(eventPayload.id).toBe("doc-1"); expect(eventPayload.changes).toEqual({ name: "Renamed Document" }); diff --git a/src/lib/chat/custom-thread-list-adapter.tsx b/src/lib/chat/custom-thread-list-adapter.tsx index be2e5eef..aff89d14 100644 --- a/src/lib/chat/custom-thread-list-adapter.tsx +++ b/src/lib/chat/custom-thread-list-adapter.tsx @@ -3,7 +3,7 @@ import { type FC, type PropsWithChildren, useMemo } from "react"; import { type ThreadMessage, - type unstable_RemoteThreadListAdapter as RemoteThreadListAdapter, + type RemoteThreadListAdapter, RuntimeAdapterProvider, } from "@assistant-ui/react"; import { createAssistantStream } from "assistant-stream"; @@ -41,7 +41,9 @@ export function createThreadListAdapter( return { async list() { - const res = await fetch(`/api/threads?workspaceId=${workspaceId}`); + const res = await fetch( + `/api/threads?workspaceId=${encodeURIComponent(workspaceId)}`, + ); if (!res.ok) throw new Error(`Failed to list threads: ${res.status}`); const data = await res.json(); return { diff --git a/src/lib/utils/__tests__/edit-replace.test.ts b/src/lib/utils/__tests__/edit-replace.test.ts index ea75f175..12fe898f 100644 --- a/src/lib/utils/__tests__/edit-replace.test.ts +++ b/src/lib/utils/__tests__/edit-replace.test.ts @@ -69,7 +69,7 @@ describe("replace (edit/replace for oldString/newString)", () => { ).toBe("before\n modified\nafter"); }); - it("handles math block format from readWorkspace", () => { + it("handles math block format from workspace_read", () => { const content = "# Note\n\n$$\nx^2\n$$\n\nMore text"; expect(replace(content, "$$\nx^2\n$$\n\n", "$$\n2x\n$$\n\n")).toBe( "# Note\n\n$$\n2x\n$$\n\nMore text" diff --git a/src/lib/utils/__tests__/format-workspace-context.test.ts b/src/lib/utils/__tests__/format-workspace-context.test.ts index 077c3c54..a3bf6416 100644 --- a/src/lib/utils/__tests__/format-workspace-context.test.ts +++ b/src/lib/utils/__tests__/format-workspace-context.test.ts @@ -36,8 +36,8 @@ describe("formatItemContent (document)", () => { }); }); -describe("formatItemContent aligns with readWorkspace document markdown for edit", () => { - it("readWorkspace uses raw markdown only; replace works on that string", () => { +describe("formatItemContent aligns with workspace_read document markdown for edit", () => { + it("workspace_read uses raw markdown only; replace works on that string", () => { const md = "# Title\n\nFirst para\n\nSecond para"; const item = mkDocumentItem(md); const body = (item.data as DocumentData).markdown ?? ""; diff --git a/src/lib/utils/edit-replace.ts b/src/lib/utils/edit-replace.ts index e87c6a36..df73cb74 100644 --- a/src/lib/utils/edit-replace.ts +++ b/src/lib/utils/edit-replace.ts @@ -534,7 +534,7 @@ export function replace(content: string, oldString: string, newString: string, r throw new Error( `Could not find oldString in the file. ${ hints.join(" ") - } Ensure the snippet is copied exactly from readWorkspace and is not truncated.` + } Ensure the snippet is copied exactly from workspace_read and is not truncated.` ); } throw new Error( diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 3a073176..b19da3bb 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -119,7 +119,7 @@ Your knowledge cutoff date is January 2025. Selected cards are the primary context the user wants to work with. All selected cards matter. Items marked (currently viewing) have highest priority in general β€” they are what the user has open right now, so prioritize them for all queries. This applies beyond ambiguous prompts ("this", "here", "that one", "what I'm looking at"); even when the user's intent is clear, favor currently viewing items when relevant. For PDFs with activePage=N, that specific page is the focus. -Selected cards provide paths and metadata only β€” use searchWorkspace or readWorkspace to fetch full content when needed. +Selected cards provide paths and metadata only β€” use workspace_search or workspace_read to fetch full content when needed. If no context is provided, explain how to select: hover + click checkmark, shift-click, or drag-select. Rely only on facts from fetched content. Do not invent or assume information. @@ -127,10 +127,10 @@ Rely only on facts from fetched content. Do not invent or assume information. RESPONSE STYLE (critical): When editing workspace items (documents, quizzes, flashcards, etc.), speak to the user in plain language. Do NOT expose internal mechanics. -- Never mention tool names (editItem, readWorkspace, searchWorkspace, etc.) or parameters (oldString, newString, etc.) in your chat response. +- Never mention tool names (item_edit, workspace_read, workspace_search, etc.) or parameters (oldString, newString, etc.) in your chat response. - Never paste raw JSON, full question lists, or item content into the chat unless the user explicitly asks to see it. -- Do not describe step-by-step reasoning (e.g. "Step 1: I read the quiz... Step 2: I called editItem..."). Just state the outcome. -- Use simple, user-facing language: "I've updated the quiz with harder questions" or "I've added 3 new flashcards" β€” not "I performed an editItem operation with the following payload." +- Do not describe step-by-step reasoning (e.g. "Step 1: I read the quiz... Step 2: I called item_edit..."). Just state the outcome. +- Use simple, user-facing language: "I've updated the quiz with harder questions" or "I've added 3 new flashcards" β€” not "I performed an item_edit operation with the following payload." If something fails, describe the problem in plain terms and what to try next. Do not expose error internals unless they help the user fix the issue. CORE BEHAVIORS: @@ -143,18 +143,18 @@ CORE BEHAVIORS: - Only use emojis if the user explicitly requests them WEB SEARCH GUIDELINES: -Use webSearch when: temporal cues ("today", "latest", "current"), real-time data (scores, stocks, weather), fact verification, niche/recent info. +Use web_search when: temporal cues ("today", "latest", "current"), real-time data (scores, stocks, weather), fact verification, niche/recent info. Use internal knowledge for: creative writing, coding, general concepts, summarizing provided content. -If the information is time-sensitive, niche, or uncertain, prefer webSearch. +If the information is time-sensitive, niche, or uncertain, prefer web_search. -PDF: Always try readWorkspace first for workspace PDFs (pageStart/pageEnd for page ranges). If content is not yet extracted, tell the user it is still being prepared and try again shortly. +PDF: Always try workspace_read first for workspace PDFs (pageStart/pageEnd for page ranges). If content is not yet extracted, tell the user it is still being prepared and try again shortly. PDF VISUALS: PDF OCR in this workspace gives you textual structure from the PDF, including normal text and inline tables, but not visual understanding of charts, figures, diagrams, or screenshots embedded in the PDF. Do not claim you can see those visuals unless the user has separately attached a screenshot/image of that region. If the user needs help with a chart or figure from a PDF, tell them to open the PDF and use the camera button in the top right of the open pdf panelto add a screenshot of that area to chat. When selected card metadata includes (currently viewing) or activePage=N (for PDFs), the user has that item or page open. Prioritize these for ambiguous references ("this", "here", "this page", "what I'm looking at") and tailor responses to that context. YOUTUBE: If user says "add a video" without a topic, infer from workspace context. Don't ask - just search. INLINE CITATIONS (highly recommended for most responses): -Only in your chat response β€” never in item content (documents, flashcards, quizzes, etc.). Use sources param for tools when available, and do not put tags in content passed to createDocument, editItem, createFlashcards, etc. +Only in your chat response β€” never in item content (documents, flashcards, quizzes, etc.). Use sources param for tools when available, and do not put tags in content passed to document_create, item_edit, flashcards_create, etc. Use simple plain text only. Bare minimum for uniqueness. No math, LaTeX, or complex formatting inside citations. Output citation HTML: REF where REF is one of: @@ -192,7 +192,7 @@ MATH FORMATTING: - Use raw LaTeX only inside math. Never use HTML tags or HTML entities in math (for example: , &, <, >,  ) - Currency (CRITICAL): ALWAYS escape dollar signs as \\$ so they are never parsed as math. Examples: \\$5, \\$19.99, \\$1,000, \\$100k, \\$100M - NEVER use \\$ inside math delimiters ($..$ or $$..$$). For dollar signs inside math, use \\\\text{\\$} or omit them entirely (just write the number) -- Apply these rules to ALL tool calls (createDocument, editItem, createFlashcards, etc.) +- Apply these rules to ALL tool calls (document_create, item_edit, flashcards_create, etc.) - Spacing: Use \\, for thin space in integrals: $\\int f(x) \\, dx$ - Use \\\\text{...} for words/units inside math - Common patterns: @@ -419,7 +419,7 @@ No cards selected. ); return ` -SELECTED CARDS (${effectiveItems.length}) β€” paths and metadata. Use searchWorkspace or readWorkspace to fetch content when needed. +SELECTED CARDS (${effectiveItems.length}) β€” paths and metadata. Use workspace_search or workspace_read to fetch content when needed. ${entries.join("\n")} `; @@ -539,7 +539,7 @@ export function formatItemContent( } /** - * Formats OCR pages as markdown matching readWorkspace output. + * Formats OCR pages as markdown matching workspace_read output. * Exported so OCR-derived content can be rendered in the same format everywhere. */ export function formatOcrPagesAsMarkdown( @@ -646,7 +646,7 @@ function formatPdfDetailsFull( } } else if (data.ocrStatus === "processing") { lines.push( - ` - (Content is being extracted. Please wait a moment and try readWorkspace again.)`, + ` - (Content is being extracted. Please wait a moment and try again.)`, ); } else { lines.push( @@ -704,7 +704,7 @@ function formatImageDetailsFull(data: ImageData): string[] { } } else if (data.ocrStatus === "processing") { lines.push( - ` - (Content is being extracted. Please wait a moment and try readWorkspace again.)`, + ` - (Content is being extracted. Please wait a moment and try again.)`, ); } else { if (data.altText) { @@ -719,7 +719,7 @@ function formatImageDetailsFull(data: ImageData): string[] { } /** - * Formats flashcard details as raw JSON (editable by editItem). + * Formats flashcard details as raw JSON (editable by item_edit). * Each side is markdown (`front` / `back`). */ function formatFlashcardDetailsFull(data: FlashcardData): string[] { @@ -734,8 +734,8 @@ function formatFlashcardDetailsFull(data: FlashcardData): string[] { } /** - * Formats quiz details as raw JSON (editable by editItem). - * Session progress is shown at top (read-only); editItem only modifies the questions JSON. + * Formats quiz details as raw JSON (editable by item_edit). + * Session progress is shown at top (read-only); item_edit only modifies the questions JSON. */ function formatQuizDetailsFull(data: QuizData): string[] { const questions = data.questions || []; @@ -763,7 +763,7 @@ function formatQuizDetailsFull(data: QuizData): string[] { } /** - * Formats document details β€” raw markdown content for readWorkspace/editItem. + * Formats document details β€” raw markdown content for workspace_read/item_edit. */ function formatDocumentDetailsFull(data: DocumentData): string[] { const lines: string[] = [];