From c9023dabde3ae43987412a213cacf858f46fe2e0 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:02:51 -0400 Subject: [PATCH 01/19] refactor: remove execute code tool plumbing Drop the unused executeCode backend and UI wiring so chat only exposes supported tools and no longer carries dead compatibility code. Made-with: Cursor --- .../assistant-ui/ExecuteCodeToolUI.tsx | 271 ------------------ src/components/assistant-ui/thread.tsx | 2 - .../legacy-tool-message-compat.test.ts | 26 -- .../ai/__tests__/tool-result-schemas.test.ts | 18 -- src/lib/ai/legacy-tool-message-compat.ts | 24 -- src/lib/ai/tool-result-schemas.ts | 16 -- src/lib/ai/tools/index.ts | 4 +- src/lib/ai/tools/search-code.ts | 44 --- 8 files changed, 1 insertion(+), 404 deletions(-) delete mode 100644 src/components/assistant-ui/ExecuteCodeToolUI.tsx delete mode 100644 src/lib/ai/__tests__/tool-result-schemas.test.ts delete mode 100644 src/lib/ai/tools/search-code.ts 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/thread.tsx b/src/components/assistant-ui/thread.tsx index 76e1de6e..37da99ff 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -67,7 +67,6 @@ 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"; @@ -143,7 +142,6 @@ export const Thread: FC = ({ items = [] }) => { - 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..cfd39abe 100644 --- a/src/lib/ai/__tests__/legacy-tool-message-compat.test.ts +++ b/src/lib/ai/__tests__/legacy-tool-message-compat.test.ts @@ -35,32 +35,6 @@ describe("normalizeLegacyToolMessages", () => { }); }); - 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 = [ { 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/legacy-tool-message-compat.ts b/src/lib/ai/legacy-tool-message-compat.ts index 1c447942..3803b438 100644 --- a/src/lib/ai/legacy-tool-message-compat.ts +++ b/src/lib/ai/legacy-tool-message-compat.ts @@ -2,21 +2,6 @@ import type { UIMessage } from "ai"; 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; @@ -38,15 +23,6 @@ export function normalizeLegacyToolMessages(messages: UIMessage[]): UIMessage[] return normalizedInput ? { ...part, input: normalizedInput } : part; } - if ( - part.type === "tool-executeCode" && - "state" in part && - part.state === "output-available" && - "output" in part - ) { - return { ...part, output: normalizeExecuteCodeOutput(part.output) }; - } - if ( part.type === "tool-webSearch" && "state" in part && diff --git a/src/lib/ai/tool-result-schemas.ts b/src/lib/ai/tool-result-schemas.ts index 19f5d050..2031debf 100644 --- a/src/lib/ai/tool-result-schemas.ts +++ b/src/lib/ai/tool-result-schemas.ts @@ -133,22 +133,6 @@ 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 } */ export const URLContextResultSchema = z.union([z.string(), ProcessUrlsOutputSchema]); diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts index 8752535e..57602bb8 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, @@ -57,9 +56,8 @@ export function createChatTools(config: ChatToolsConfig): Record { // URL processing processUrls: createProcessUrlsTool(), - // Search & code execution + // Search webSearch: createWebSearchTool(), - executeCode: createExecuteCodeTool(), searchWorkspace: createSearchWorkspaceTool(ctx), readWorkspace: createReadWorkspaceTool(ctx), 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; - }, - }); -} From facd1896a3971907d3e45445c40455231080d9e6 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:52:27 -0400 Subject: [PATCH 02/19] refactor: breadcrumb bar --- .../workspace-canvas/WorkspaceHeader.tsx | 921 ++++++++++++------ 1 file changed, 621 insertions(+), 300 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceHeader.tsx b/src/components/workspace-canvas/WorkspaceHeader.tsx index 81d459dd..8fa7dde1 100644 --- a/src/components/workspace-canvas/WorkspaceHeader.tsx +++ b/src/components/workspace-canvas/WorkspaceHeader.tsx @@ -1,10 +1,10 @@ "use client"; import type React from "react"; -import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { Fragment, useState, useRef, useEffect, useCallback, useMemo, useLayoutEffect } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Search, X, ChevronDown, ChevronRight, FolderOpen, Plus, Settings, Share2, Loader2, ExternalLink } from "lucide-react"; +import { Search, X, ChevronRight, FolderOpen, Plus, Settings, Share2, Loader2, ExternalLink } from "lucide-react"; import { LuCalendar } from "react-icons/lu"; import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -26,11 +26,6 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, } from "@/components/ui/dropdown-menu"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; import { Dialog, DialogContent, @@ -57,6 +52,112 @@ import { useAudioRecordingStore } from "@/lib/stores/audio-recording-store"; import { renderWorkspaceMenuItems } from "./workspace-menu-items"; import { PromptBuilderDialog } from "@/components/assistant-ui/PromptBuilderDialog"; const EMPTY_ITEMS: Item[] = []; +const EMPTY_RESPONSIVE_BREADCRUMBS = { + visibleTailKeys: [] as string[], + hiddenKeys: [] as string[], +}; +const BREADCRUMB_ROOT_TEXT_CLASS = + "min-w-0 max-w-[220px] truncate text-sidebar-foreground"; +const BREADCRUMB_FOLDER_TEXT_CLASS = + "min-w-0 max-w-[180px] truncate text-sidebar-foreground"; +const BREADCRUMB_ITEM_TEXT_CLASS = + "min-w-0 max-w-[240px] truncate text-sidebar-foreground"; +const BREADCRUMB_INTERACTIVE_CLASS = + "cursor-pointer text-sidebar-foreground/75 hover:text-sidebar-foreground hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring/60"; +const BREADCRUMB_DRAG_TARGET_CLASS = + "bg-blue-500/10 text-sidebar-foreground ring-1 ring-inset ring-blue-500/50"; +const BREADCRUMB_MENU_ITEM_CLASS = + "flex items-center gap-1.5 rounded-md px-2 py-1.5 cursor-pointer"; + +type BreadcrumbEntry = + | { + key: string; + kind: "root"; + label: string; + icon: string | null | undefined; + color: string | null | undefined; + } + | { + key: string; + kind: "folder"; + id: string; + label: string; + color: string | null | undefined; + } + | { + key: string; + kind: "item"; + id: string; + label: string; + itemType: Item["type"]; + }; + +function areStringArraysEqual(a: readonly string[], b: readonly string[]) { + return a.length === b.length && a.every((value, index) => 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) && ( From e377389245b10c90fb18f3f493e75a9b5452c06d Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:57:08 -0400 Subject: [PATCH 03/19] fix: remove extra breadcrumb offset Drop the manual left margin on the workspace breadcrumb nav so the header spacing stays consistent after the breadcrumb bar refactor. Made-with: Cursor --- src/components/workspace-canvas/WorkspaceHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/workspace-canvas/WorkspaceHeader.tsx b/src/components/workspace-canvas/WorkspaceHeader.tsx index 8fa7dde1..8a31d0b6 100644 --- a/src/components/workspace-canvas/WorkspaceHeader.tsx +++ b/src/components/workspace-canvas/WorkspaceHeader.tsx @@ -950,7 +950,7 @@ export function WorkspaceHeader({ {/* Breadcrumbs */}
- - - ); - }, -}); + + + ); +}; 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..6a267514 100644 --- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx +++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx @@ -2,12 +2,15 @@ import { AssistantRuntimeProvider, + Tools, unstable_useRemoteThreadListRuntime as 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/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 37da99ff..9fc6e83e 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -60,18 +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 { 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"; @@ -133,21 +121,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 = [] }) => {
- ); }; 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/lib/ai/__tests__/legacy-tool-message-compat.test.ts b/src/lib/ai/__tests__/legacy-tool-message-compat.test.ts index cfd39abe..3cf3c536 100644 --- a/src/lib/ai/__tests__/legacy-tool-message-compat.test.ts +++ b/src/lib/ai/__tests__/legacy-tool-message-compat.test.ts @@ -28,7 +28,7 @@ describe("normalizeLegacyToolMessages", () => { const part = normalized[0]?.parts[0]; expect(part).toMatchObject({ - type: "tool-processUrls", + type: "tool-web_fetch", input: { urls: ["https://example.com"], }, @@ -64,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" }], @@ -100,11 +100,57 @@ 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" }, + }); + }); }); diff --git a/src/lib/ai/chat-tool-names.ts b/src/lib/ai/chat-tool-names.ts new file mode 100644 index 00000000..395c03e2 --- /dev/null +++ b/src/lib/ai/chat-tool-names.ts @@ -0,0 +1,95 @@ +/** + * 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]; + +/** Older tool name → canonical */ +export const LEGACY_CHAT_TOOL_NAMES: Record = { + // Original camelCase + processUrls: 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; +} + +/** Autogen / SSE events that mirror the web search tool */ +export function matchesWebSearchStreamToolName(name: string | undefined): boolean { + return name === CHAT_TOOL.WEB_SEARCH || name === "webSearch"; +} + +/** Autogen link-scraping progress (aligns with web_fetch) */ +export function matchesWebFetchStreamToolName(name: string | undefined): boolean { + return ( + name === CHAT_TOOL.WEB_FETCH || + name === "url_fetch" || + name === "urlFetch" + ); +} 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 3803b438..b334e1d4 100644 --- a/src/lib/ai/legacy-tool-message-compat.ts +++ b/src/lib/ai/legacy-tool-message-compat.ts @@ -1,4 +1,5 @@ import type { UIMessage } from "ai"; +import { CHAT_TOOL, canonicalizeToolUIPartType } from "./chat-tool-names"; import { normalizeProcessUrlsArgs } from "./process-urls-shared"; import { normalizeWebSearchResult } from "./web-search-shared"; @@ -8,7 +9,7 @@ function normalizeWebSearchOutput(output: unknown): unknown { } export function normalizeLegacyToolMessages(messages: UIMessage[]): UIMessage[] { - return messages.map((message) => { + return messages.map((message): UIMessage => { if (!Array.isArray(message.parts)) { return message; } @@ -18,23 +19,26 @@ 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 type = canonicalizeToolUIPartType(part.type); + 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 2031debf..d3ceebe2 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, clearCardContent, 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(), @@ -69,7 +69,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(), @@ -101,7 +101,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(), @@ -133,7 +133,7 @@ export function parseFlashcardResult(input: unknown): FlashcardResult { return coerceToFlashcardResult(input); } -/** 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 57602bb8..b305d061 100644 --- a/src/lib/ai/tools/index.ts +++ b/src/lib/ai/tools/index.ts @@ -20,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; @@ -29,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; } /** @@ -54,33 +52,28 @@ export function createChatTools(config: ChatToolsConfig): Record { return { // URL processing - processUrls: createProcessUrlsTool(), + [CHAT_TOOL.WEB_FETCH]: createProcessUrlsTool(), // Search - webSearch: createWebSearchTool(), - searchWorkspace: createSearchWorkspaceTool(ctx), - readWorkspace: createReadWorkspaceTool(ctx), + [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-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..8cf4a2e0 100644 --- a/src/lib/ai/tools/web-search.ts +++ b/src/lib/ai/tools/web-search.ts @@ -95,7 +95,7 @@ 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({ 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..71688122 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, })); 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..285d6ffe 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 workspace_read 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 workspace_read 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[] = []; From 5dbb78638ecf9cec778091e337c6d476acf0e42f Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:13:33 -0400 Subject: [PATCH 05/19] fix: degrade removed legacy tools during chat replay Downgrade unsupported historical tool parts to text before AI SDK validation so removed tools no longer break old chat continuation, while keeping renamed and still-available tools intact. Made-with: Cursor --- src/app/api/chat/route.ts | 4 +- .../legacy-tool-message-compat.test.ts | 111 +++++++++++++++++- src/lib/ai/chat-tool-names.ts | 5 + src/lib/ai/legacy-tool-message-compat.ts | 31 ++++- 4 files changed, 147 insertions(+), 4 deletions(-) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index fd13583a..bb70f623 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -169,7 +169,9 @@ async function handlePOST(req: Request) { clientTools: body.tools, }); - const compatibleMessages = normalizeLegacyToolMessages(messages); + const compatibleMessages = normalizeLegacyToolMessages(messages, { + availableToolNames: Object.keys(tools), + }); const validation = await safeValidateUIMessages({ messages: compatibleMessages, 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 3cf3c536..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", () => { @@ -153,4 +153,113 @@ describe("normalizeLegacyToolMessages", () => { 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/chat-tool-names.ts b/src/lib/ai/chat-tool-names.ts index 395c03e2..7ba78553 100644 --- a/src/lib/ai/chat-tool-names.ts +++ b/src/lib/ai/chat-tool-names.ts @@ -19,6 +19,7 @@ export const CHAT_TOOL = { } 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 = { @@ -80,6 +81,10 @@ export function toolPartMatchesCanonical( 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/legacy-tool-message-compat.ts b/src/lib/ai/legacy-tool-message-compat.ts index b334e1d4..b11ff73c 100644 --- a/src/lib/ai/legacy-tool-message-compat.ts +++ b/src/lib/ai/legacy-tool-message-compat.ts @@ -1,5 +1,9 @@ import type { UIMessage } from "ai"; -import { CHAT_TOOL, canonicalizeToolUIPartType } from "./chat-tool-names"; +import { + CHAT_TOOL, + canonicalizeToolUIPartType, + isCanonicalChatToolName, +} from "./chat-tool-names"; import { normalizeProcessUrlsArgs } from "./process-urls-shared"; import { normalizeWebSearchResult } from "./web-search-shared"; @@ -8,7 +12,16 @@ function normalizeWebSearchOutput(output: unknown): unknown { return normalized ?? output; } -export function normalizeLegacyToolMessages(messages: UIMessage[]): UIMessage[] { +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; @@ -19,7 +32,21 @@ export function normalizeLegacyToolMessages(messages: UIMessage[]): UIMessage[] return part; } + const originalToolName = part.type.slice("tool-".length); const type = canonicalizeToolUIPartType(part.type); + const canonicalToolName = type.slice("tool-".length); + + if ( + type === part.type && + !availableToolNames.has(originalToolName) && + !isCanonicalChatToolName(originalToolName) + ) { + return { + type: "text", + text: summarizeRemovedToolPart(originalToolName), + }; + } + let next = type === part.type ? part : { ...part, type }; if (type === `tool-${CHAT_TOOL.WEB_FETCH}` && "input" in next) { From da0097d8b602b87b3f859d7ae91cf0b3da35d443 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:42:00 -0400 Subject: [PATCH 06/19] refactor(assistant): drop inline message markers and URL_CONTEXT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove thread UI parsing for reply, selected-cards, file, and URL inline markers; simplify UserMessageText and EditComposer to plain text. Strip [URL_CONTEXT:…] handling from attachment previews and the chat API (processMessages). URL attachments still use .url files or http(s) names. Made-with: Cursor --- src/app/api/chat/route.ts | 45 +- src/components/assistant-ui/attachment.tsx | 82 +-- src/components/assistant-ui/thread.tsx | 805 +-------------------- 3 files changed, 17 insertions(+), 915 deletions(-) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index bb70f623..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. @@ -207,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); @@ -229,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, ); @@ -254,8 +217,8 @@ 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, }); @@ -298,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/components/assistant-ui/attachment.tsx b/src/components/assistant-ui/attachment.tsx index 78da36e4..1b2b7f57 100644 --- a/src/components/assistant-ui/attachment.tsx +++ b/src/components/assistant-ui/attachment.tsx @@ -102,33 +102,7 @@ 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 }; - } - } - + // URL attachment: virtual .url file (composer) or attachment name is http(s) 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) @@ -306,32 +280,7 @@ 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"; - } - } - + // URL attachment: virtual .url file or attachment name is http(s) URL // Check file name (for pending attachments in composer) if (att.file?.name.endsWith(".url")) { return "URL"; @@ -372,32 +321,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; - } - } - + // URL attachment: virtual .url file or attachment name is http(s) URL // Check file name (for pending attachments in composer) if (att.file?.name.endsWith(".url")) { return true; diff --git a/src/components/assistant-ui/thread.tsx b/src/components/assistant-ui/thread.tsx index 9fc6e83e..9f747198 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -94,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"; @@ -1241,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 && @@ -1259,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}
; }; @@ -1697,400 +1377,22 @@ 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 hasParsedRef = useRef(false); - const hasAttachmentsRestoredRef = useRef(false); - const [parsedReplies, setParsedReplies] = useState([]); - const [parsedUrls, setParsedUrls] = useState([]); - const [parsedCardsContext, setParsedCardsContext] = useState(""); + const initRef = useRef(false); 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; - + if (initRef.current) return; 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]); + if (!composerState) return; + initRef.current = true; + const t = composerState.text ?? ""; + setOriginalText(t); + setCurrentText(t); + }, [aui]); return (
@@ -2104,68 +1406,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} - - ); - })} -
-
- )} -
From 7dd5a970c685297c58e58c1a5598fd5e3476e2ec Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:43:02 -0400 Subject: [PATCH 07/19] fix: simplify workspace card controls Replace the custom workspace card control wrapper with direct button triggers so the three-dot menu opens reliably and matches the select button styling. Made-with: Cursor --- .../workspace-canvas/WorkspaceCardActions.tsx | 140 +++++++++--------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceCardActions.tsx b/src/components/workspace-canvas/WorkspaceCardActions.tsx index 3f8b2c01..5d24c76c 100644 --- a/src/components/workspace-canvas/WorkspaceCardActions.tsx +++ b/src/components/workspace-canvas/WorkspaceCardActions.tsx @@ -101,56 +101,14 @@ 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 FloatingControlButton({ - ariaLabel, - title, - backgroundColor, - hoverBackgroundColor, - onClick, - className, - children, -}: FloatingControlButtonProps) { - return ( - - ); +function getFloatingControlStyle(backgroundColor: string): React.CSSProperties { + return { + backgroundColor, + backdropFilter: "blur(8px)", + }; } interface WorkspaceCardControlsProps { @@ -217,17 +175,33 @@ export function WorkspaceCardControls({ )} > {showScrollLockButton && ( - { + event.stopPropagation(); + }} + onMouseEnter={(event) => { + event.currentTarget.style.backgroundColor = + defaultHoverBackgroundColor; + }} + onMouseLeave={(event) => { + event.currentTarget.style.backgroundColor = defaultBackgroundColor; + }} + onClick={(event) => { + event.stopPropagation(); + onToggleScrollLock(); + }} > {isScrollLocked ? ( @@ -242,31 +216,57 @@ export function WorkspaceCardControls({ > {isScrollLocked ? "Scroll" : "Lock"} - + )} - { + event.stopPropagation(); + }} + onMouseEnter={(event) => { + event.currentTarget.style.backgroundColor = + selectionHoverBackgroundColor; + }} + onMouseLeave={(event) => { + event.currentTarget.style.backgroundColor = selectionBackgroundColor; + }} + onClick={(event) => { + event.stopPropagation(); + onToggleSelection(); + }} > {isSelected ? : } - + - - + Date: Sun, 5 Apr 2026 18:43:02 -0400 Subject: [PATCH 08/19] refactor: simplify shared workspace modal Move the shared workspace modal onto the shared dialog primitives and TanStack Query so loading and create flows follow the app's standard modal and data patterns. Made-with: Cursor --- .../workspace/SharedWorkspaceModal.tsx | 259 +++++++++--------- 1 file changed, 122 insertions(+), 137 deletions(-) diff --git a/src/components/workspace/SharedWorkspaceModal.tsx b/src/components/workspace/SharedWorkspaceModal.tsx index f106982f..eb2f8d85 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,111 @@ interface SharedWorkspaceModalProps { workspaceId: string; } -export default function SharedWorkspaceModal({ - open, - onOpenChange, +interface SharedWorkspaceModalContentProps { 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: (slug: string) => void; +} + +function SharedWorkspaceModalContent({ + workspaceId, + onImported, +}: 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: open && 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 +146,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 +156,7 @@ export default function SharedWorkspaceModal({

)} -
+
{/* Name */} @@ -223,7 +169,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 +186,13 @@ export default function SharedWorkspaceModal({ {isLoading ? ( ) : ( - + { + setFormError(null); + setSelectedIconOverride(icon); + }} + >
-
-
+ + ); +} + +export default function SharedWorkspaceModal({ + open, + onOpenChange, + workspaceId, +}: SharedWorkspaceModalProps) { + const router = useRouter(); + + const handleOpenChange = (newOpen: boolean) => { + onOpenChange(newOpen); + if (!newOpen) { + router.push("/home"); + } + }; + + return ( + + {open ? ( + { + onOpenChange(false); + window.location.href = `/workspace/${slug}`; + }} + /> + ) : null} + ); } From 1563408736c42c1cd669a7236140e1d7213f36e6 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:43:03 -0400 Subject: [PATCH 09/19] refactor: simplify workspace instruction modal Use the shared dialog shell for the onboarding modal so overlay, escape handling, and open-state animation come from Radix instead of custom visibility state. Made-with: Cursor --- .../onboarding/WorkspaceInstructionModal.tsx | 145 ++++++++---------- 1 file changed, 64 insertions(+), 81 deletions(-) diff --git a/src/components/onboarding/WorkspaceInstructionModal.tsx b/src/components/onboarding/WorkspaceInstructionModal.tsx index 2b5a40e2..2af2dd6b 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) => (
-
-
+
+ +
); } From 7ae2e49ca0721dfd30c864c952e73777a09b8138 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:48:44 -0400 Subject: [PATCH 10/19] chore: update deps --- package.json | 104 +++++++++--------- .../assistant-ui/WorkspaceRuntimeProvider.tsx | 2 +- .../assistant-ui/tool-ui-loading-shell.tsx | 25 ++++- .../ai/use-workspace-context-provider.ts | 27 ++--- src/lib/ai/tools/web-search.ts | 4 +- src/lib/chat/custom-thread-list-adapter.tsx | 2 +- 6 files changed, 88 insertions(+), 76 deletions(-) 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/components/assistant-ui/WorkspaceRuntimeProvider.tsx b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx index 6a267514..2d5afb07 100644 --- a/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx +++ b/src/components/assistant-ui/WorkspaceRuntimeProvider.tsx @@ -3,7 +3,7 @@ import { AssistantRuntimeProvider, Tools, - unstable_useRemoteThreadListRuntime as useRemoteThreadListRuntime, + useRemoteThreadListRuntime, useAui, } from "@assistant-ui/react"; import { diff --git a/src/components/assistant-ui/tool-ui-loading-shell.tsx b/src/components/assistant-ui/tool-ui-loading-shell.tsx index 53c804cf..a501186b 100644 --- a/src/components/assistant-ui/tool-ui-loading-shell.tsx +++ b/src/components/assistant-ui/tool-ui-loading-shell.tsx @@ -1,5 +1,7 @@ "use client"; +import { useMemo } from "react"; +import { useToolArgsStatus } from "@assistant-ui/react"; import { Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import ShinyText from "@/components/ShinyText"; @@ -21,6 +23,23 @@ export function ToolUILoadingShell({ subtitle, className, }: ToolUILoadingShellProps) { + const { propStatus } = useToolArgsStatus>(); + + const resolvedSubtitle = useMemo(() => { + if (subtitle) return subtitle; + + const fieldStatuses = Object.values(propStatus); + if (fieldStatuses.includes("streaming")) { + return "Preparing request..."; + } + + if (fieldStatuses.includes("complete")) { + return "Working..."; + } + + return undefined; + }, [propStatus, subtitle]); + return (
- {subtitle && ( - {subtitle} + {resolvedSubtitle && ( + + {resolvedSubtitle} + )}
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/tools/web-search.ts b/src/lib/ai/tools/web-search.ts index 8cf4a2e0..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, @@ -102,7 +102,7 @@ export async function executeWebSearch(query: string): Promise 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/chat/custom-thread-list-adapter.tsx b/src/lib/chat/custom-thread-list-adapter.tsx index be2e5eef..37afee92 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"; From 1d1f685ea02a2409328f8dcfa0adc923c5797eb6 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:55:27 -0400 Subject: [PATCH 11/19] fix: type error --- src/components/workspace/SharedWorkspaceModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/workspace/SharedWorkspaceModal.tsx b/src/components/workspace/SharedWorkspaceModal.tsx index eb2f8d85..04c6602a 100644 --- a/src/components/workspace/SharedWorkspaceModal.tsx +++ b/src/components/workspace/SharedWorkspaceModal.tsx @@ -35,7 +35,7 @@ interface SharedWorkspaceModalProps { } interface SharedWorkspaceModalContentProps { - workspaceId, + workspaceId: string; onImported: (slug: string) => void; } @@ -66,7 +66,7 @@ function SharedWorkspaceModalContent({ return response.json(); }, - enabled: open && Boolean(workspaceId), + enabled: Boolean(workspaceId), staleTime: 5 * 60 * 1000, retry: 1, }); From 30421ac63cc26445eb610979bd2d4942cb1d32d8 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:56:41 -0400 Subject: [PATCH 12/19] fix: remove tool arg subtitle --- .../assistant-ui/tool-ui-loading-shell.tsx | 47 ++++--------------- 1 file changed, 9 insertions(+), 38 deletions(-) diff --git a/src/components/assistant-ui/tool-ui-loading-shell.tsx b/src/components/assistant-ui/tool-ui-loading-shell.tsx index a501186b..7b728d49 100644 --- a/src/components/assistant-ui/tool-ui-loading-shell.tsx +++ b/src/components/assistant-ui/tool-ui-loading-shell.tsx @@ -1,7 +1,5 @@ "use client"; -import { useMemo } from "react"; -import { useToolArgsStatus } from "@assistant-ui/react"; import { Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import ShinyText from "@/components/ShinyText"; @@ -9,37 +7,17 @@ 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) { - const { propStatus } = useToolArgsStatus>(); - - const resolvedSubtitle = useMemo(() => { - if (subtitle) return subtitle; - - const fieldStatuses = Object.values(propStatus); - if (fieldStatuses.includes("streaming")) { - return "Preparing request..."; - } - - if (fieldStatuses.includes("complete")) { - return "Working..."; - } - - return undefined; - }, [propStatus, subtitle]); - return (
-
- - - - {resolvedSubtitle && ( - - {resolvedSubtitle} - - )} -
+ + +
); From de6df96d5bfe15d0e56d8dddc265cf5c05835f4a Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:17:15 -0400 Subject: [PATCH 13/19] refactor(ai): centralize model selection and provider config Unify chat and generation model IDs in a shared registry so providers, defaults, and UI labels stay in sync across API routes and client state. Made-with: Cursor --- src/app/api/cards/from-message/route.ts | 3 +- src/app/api/chat/route.ts | 57 ++---- src/app/api/threads/[id]/title/route.ts | 3 +- src/app/api/workspaces/autogen/route.ts | 7 +- .../api/workspaces/generate-title/route.ts | 3 +- src/components/assistant-ui/ModelPicker.tsx | 106 ++-------- src/lib/ai/model-registry.ts | 185 ++++++++++++++++++ src/lib/ai/tools/web-search.ts | 3 +- src/lib/ai/workers/text-selection-worker.ts | 3 +- src/lib/stores/ui-store.ts | 3 +- .../audio-transcribe/steps/transcribe.ts | 3 +- 11 files changed, 230 insertions(+), 146 deletions(-) create mode 100644 src/lib/ai/model-registry.ts diff --git a/src/app/api/cards/from-message/route.ts b/src/app/api/cards/from-message/route.ts index d0ff179f..07ff38d2 100644 --- a/src/app/api/cards/from-message/route.ts +++ b/src/app/api/cards/from-message/route.ts @@ -6,6 +6,7 @@ import { logger } from "@/lib/utils/logger"; import { processMessageContent } from "@/lib/ai/clean-message-content"; import { google } from "@ai-sdk/google"; import { generateText } from "ai"; +import { GOOGLE_MODEL_IDS } from "@/lib/ai/model-registry"; /** * POST /api/cards/from-message @@ -87,7 +88,7 @@ RULES: - Return ONLY the reformatted document (first line or # heading = title; rest = body). No preamble or footer.`; const aiResult = await generateText({ - model: google("gemini-3-flash-preview"), + model: google(GOOGLE_MODEL_IDS.GEMINI_3_FLASH_PREVIEW), system: systemPrompt, prompt: `Extract the pure document content from this assistant response. Preserve all information. Remove only conversational/meta fluff. Output title + body in markdown:\n\n${content}`, }); diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index b23fbcd8..53ee79ba 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -16,6 +16,12 @@ import { auth } from "@/lib/auth"; import { headers } from "next/headers"; import { createChatTools } from "@/lib/ai/tools"; import { getPostHogServerClient } from "@/lib/posthog-server"; +import { + DEFAULT_CHAT_MODEL_ID, + getChatGatewayOptions, + getGoogleProviderOptionsForChat, + resolveChatGatewayModelId, +} from "@/lib/ai/model-registry"; import { withServerObservability } from "@/lib/with-server-observability"; import { normalizeLegacyToolMessages } from "@/lib/ai/legacy-tool-message-compat"; @@ -176,19 +182,11 @@ async function handlePOST(req: Request) { // Get pre-formatted selected cards context from client (no DB fetch needed) const selectedCardsContext = getSelectedCardsContext(body); - // Get model ID and ensure it has the correct prefix for Gateway - let modelId = body.modelId || "gemini-3-flash-preview"; - - // Auto-prefix with google/ if it looks like a gemini model and lacks prefix - // This allows existing client code to work without changes - if (modelId.startsWith("gemini-") && !modelId.startsWith("google/")) { - modelId = `google/${modelId}`; - } - - // Auto-prefix with anthropic/ if it looks like a Claude model and lacks prefix - if (modelId.includes("claude") && !modelId.startsWith("anthropic/")) { - modelId = `anthropic/${modelId}`; - } + // Normalize model to a canonical gateway model ID. + // Supports persisted legacy IDs (e.g. unprefixed gemini-*). + const modelId = resolveChatGatewayModelId( + body.modelId || DEFAULT_CHAT_MODEL_ID, + ); // Inject selected cards + reply selections into the last user message injectSelectionContext( @@ -222,38 +220,9 @@ async function handlePOST(req: Request) { modelId, }); - // Configure Google Thinking capabilities - const googleConfig: any = { - grounding: { - // googleSearchRetrieval removed to force usage of explicit web_search tool - }, - thinkingConfig: { - includeThoughts: true, - }, - }; - - // Gemini 3 Flash: set thinkingLevel (Gemini 2.5 uses default dynamic budget) - if (modelId.includes("gemini-3-flash")) { - googleConfig.thinkingConfig.thinkingLevel = "minimal"; - } - - // Prepare provider options. - // Prefer Bedrock first for Claude models, then fall back to Anthropic. - // Non-Claude models stay on their native providers. - const gatewayOptions: any = { - caching: "auto", - models: [modelId], - ...(userId ? { user: userId } : {}), - }; - - if (modelId.startsWith("anthropic/")) { - gatewayOptions.order = ["bedrock", "anthropic"]; - gatewayOptions.only = ["bedrock", "anthropic"]; - } - const providerOptions: any = { - gateway: gatewayOptions, - google: googleConfig, + gateway: getChatGatewayOptions({ modelId, userId }), + google: getGoogleProviderOptionsForChat(modelId), }; const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://thinkex.app"; diff --git a/src/app/api/threads/[id]/title/route.ts b/src/app/api/threads/[id]/title/route.ts index 69af3621..20662b73 100644 --- a/src/app/api/threads/[id]/title/route.ts +++ b/src/app/api/threads/[id]/title/route.ts @@ -10,9 +10,10 @@ import { } from "@/lib/api/workspace-helpers"; import { eq } from "drizzle-orm"; import { withServerObservability } from "@/lib/with-server-observability"; +import { GOOGLE_MODEL_IDS } from "@/lib/ai/model-registry"; /** Model ID used for lightweight background text tasks */ -const GEMINI_FLASH_LITE_MODEL = "gemini-2.5-flash-lite"; +const GEMINI_FLASH_LITE_MODEL = GOOGLE_MODEL_IDS.GEMINI_2_5_FLASH_LITE; function extractTextFromMessage(msg: { content?: unknown[] }): string { if (!msg.content || !Array.isArray(msg.content)) return ""; diff --git a/src/app/api/workspaces/autogen/route.ts b/src/app/api/workspaces/autogen/route.ts index dd7b82f2..898d2bd8 100644 --- a/src/app/api/workspaces/autogen/route.ts +++ b/src/app/api/workspaces/autogen/route.ts @@ -29,6 +29,7 @@ import { type UploadedAsset, } from "@/lib/uploads/uploaded-asset"; import { startAssetProcessing } from "@/lib/uploads/start-asset-processing"; +import { GOOGLE_MODEL_IDS } from "@/lib/ai/model-registry"; const MAX_TITLE_LENGTH = 60; const LOG_TRUNCATE = 400; @@ -131,7 +132,7 @@ async function runSearchPhase( send: (ev: StreamEvent) => void ): Promise<{ searchContext: string; sources: Array<{ title: string; url: string }> }> { const { output } = await generateText({ - model: google("gemini-2.5-flash-lite"), + model: google(GOOGLE_MODEL_IDS.GEMINI_2_5_FLASH_LITE), output: Output.object({ schema: SEARCH_DECISION_SCHEMA }), prompt: `Given this user prompt for a study workspace, decide if web search would help. @@ -199,7 +200,7 @@ async function runDistillationAgent( : userMessage.content; const { partialOutputStream } = streamText({ - model: google("gemini-2.5-flash-lite"), + model: google(GOOGLE_MODEL_IDS.GEMINI_2_5_FLASH_LITE), output: Output.object({ schema: DISTILLED_SCHEMA }), system: ` You are a workspace content distiller. The user provides content (prompt, files, links). You extract metadata and distilled content for creating study materials. @@ -676,7 +677,7 @@ export async function POST(request: NextRequest) { type OutputType = z.infer; let output: OutputType | undefined; const { partialOutputStream } = streamText({ - model: google("gemini-2.5-flash"), + model: google(GOOGLE_MODEL_IDS.GEMINI_2_5_FLASH), system: DOCUMENT_QUIZ_SYSTEM, output: Output.object({ name: "DocumentQuiz", diff --git a/src/app/api/workspaces/generate-title/route.ts b/src/app/api/workspaces/generate-title/route.ts index 078c10fc..454e2de7 100644 --- a/src/app/api/workspaces/generate-title/route.ts +++ b/src/app/api/workspaces/generate-title/route.ts @@ -8,6 +8,7 @@ import { WORKSPACE_ICON_NAMES, formatIconForStorage, } from "@/lib/workspace-icons"; +import { GOOGLE_MODEL_IDS } from "@/lib/ai/model-registry"; const MAX_TITLE_LENGTH = 60; @@ -41,7 +42,7 @@ async function handlePOST(request: NextRequest) { } const { output } = await generateText({ - model: google("gemini-2.5-flash-lite"), + model: google(GOOGLE_MODEL_IDS.GEMINI_2_5_FLASH_LITE), output: Output.object({ schema: z.object({ title: z.string().describe("A short, concise workspace title (max 5-6 words)"), diff --git a/src/components/assistant-ui/ModelPicker.tsx b/src/components/assistant-ui/ModelPicker.tsx index a5d22eae..2d1bc9ca 100644 --- a/src/components/assistant-ui/ModelPicker.tsx +++ b/src/components/assistant-ui/ModelPicker.tsx @@ -12,6 +12,13 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { + CHAT_MODEL_PROVIDER_GROUPS, + getChatModelDisplayName, + resolveChatModelConfig, + type ChatModelConfig, + type ChatModelProvider, +} from "@/lib/ai/model-registry"; import { useUIStore } from "@/lib/stores/ui-store"; import { focusComposerInput } from "@/lib/utils/composer-utils"; import { cn } from "@/lib/utils"; @@ -28,22 +35,11 @@ const PROVIDER_COMPANY_NAMES = { ChatGPT: "OPENAI", } as const; -type ModelProvider = keyof typeof MODEL_LOGO_PATHS; - -type ModelConfig = { - id: string; - name: string; - description: string; - speed: string; - costLevel: number; - strengths: string; -}; - function ModelProviderIcon({ provider, className, }: { - provider: ModelProvider; + provider: ChatModelProvider; className?: string; }) { return ( @@ -72,8 +68,8 @@ function ModelPickerRow({ descriptionHoverOpen, onDescriptionHoverOpenChange, }: { - provider: ModelProvider; - model: ModelConfig; + provider: ChatModelProvider; + model: ChatModelConfig; isSelected: boolean; onSelect: () => void; descriptionHoverOpen: boolean; @@ -173,87 +169,13 @@ function ModelPickerRow({ ); } -const MODEL_PROVIDERS: Array<{ - provider: ModelProvider; - models: ModelConfig[]; -}> = [ - { - provider: "Gemini", - models: [ - { - id: "gemini-3.1-pro-preview", - name: "Gemini 3.1 Pro", - description: "Higher-quality reasoning for complex work", - speed: "Medium", - costLevel: 3, - strengths: - "Complex reasoning, long-context work, and higher-quality structured output.", - }, - { - id: "gemini-3-flash-preview", - name: "Gemini 3.0 Flash", - description: "Fast, lightweight model for everyday tasks", - speed: "Fast", - costLevel: 1, - strengths: - "Quick responses, lightweight drafting, and lower-cost chat workflows.", - }, - ], - }, - { - provider: "Claude", - models: [ - { - id: "anthropic/claude-sonnet-4.6", - name: "Claude Sonnet 4.6", - description: "Strong coding and general reasoning model", - speed: "Medium", - costLevel: 3, - strengths: - "Coding, complex workflows, and reliable multi-step reasoning.", - }, - { - id: "anthropic/claude-haiku-4.5", - name: "Claude Haiku 4.5", - description: "Fast, cheaper Claude model for simpler tasks", - speed: "Very fast", - costLevel: 1, - strengths: - "Fast drafting, simple transformations, and lower-cost day-to-day use.", - }, - ], - }, - { - provider: "ChatGPT", - models: [ - { - id: "openai/gpt-5-chat", - name: "GPT 5", - description: "Balanced general-purpose chat and reasoning model", - speed: "Medium-fast", - costLevel: 2, - strengths: - "General chat, writing, and reasoning across a wide range of tasks.", - }, - ], - }, -]; - -const ALL_MODELS = MODEL_PROVIDERS.flatMap((provider) => provider.models); - -function getModelDisplayName(modelId: string): string { - const model = ALL_MODELS.find((item) => item.id === modelId); - return model?.name ?? modelId; -} - export function ModelPicker() { const selectedModelId = useUIStore((state) => state.selectedModelId); const setSelectedModelId = useUIStore((state) => state.setSelectedModelId); const [isOpen, setIsOpen] = useState(false); const [hoverDescModelId, setHoverDescModelId] = useState(null); - const selectedModel = - ALL_MODELS.find((m) => m.id === selectedModelId) ?? ALL_MODELS[0]; + const selectedModel = resolveChatModelConfig(selectedModelId); return ( - {getModelDisplayName(selectedModel.id)} + {getChatModelDisplayName(selectedModel.id)} event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()} > - {MODEL_PROVIDERS.map((group, groupIndex) => ( + {CHAT_MODEL_PROVIDER_GROUPS.map((group, groupIndex) => (
{groupIndex > 0 ? (
) : null}
- {PROVIDER_COMPANY_NAMES[group.provider]} + {PROVIDER_COMPANY_NAMES[group.provider] ?? group.companyName}
{group.models.map((model) => ( group.models, +); + +const CHAT_MODEL_BY_ID = new Map( + ALL_CHAT_MODELS.map((model) => [model.id, model] as const), +); + +const CHAT_MODEL_BY_ALIAS = new Map( + ALL_CHAT_MODELS.flatMap((model) => + (model.aliases ?? []).map((alias) => [alias, model] as const), + ), +); + +export const DEFAULT_CHAT_MODEL_ID = `google/${GOOGLE_MODEL_IDS.GEMINI_3_FLASH_PREVIEW}`; + +export function resolveChatModelConfig(modelId: string | null | undefined) { + if (!modelId) return CHAT_MODEL_BY_ID.get(DEFAULT_CHAT_MODEL_ID)!; + return ( + CHAT_MODEL_BY_ID.get(modelId) ?? + CHAT_MODEL_BY_ALIAS.get(modelId) ?? + CHAT_MODEL_BY_ID.get(DEFAULT_CHAT_MODEL_ID)! + ); +} + +export function resolveChatGatewayModelId(modelId: string | null | undefined) { + return resolveChatModelConfig(modelId).id; +} + +export function getChatModelDisplayName(modelId: string | null | undefined) { + return resolveChatModelConfig(modelId).name; +} + +export function getChatGatewayOptions({ + modelId, + userId, +}: { + modelId: string; + userId?: string | null; +}) { + const options: { + caching: "auto"; + models: string[]; + user?: string; + order?: string[]; + only?: string[]; + } = { + caching: "auto", + models: [modelId], + }; + + if (userId) { + options.user = userId; + } + + // Prefer Bedrock first for Claude models, then fall back to Anthropic. + if (modelId.startsWith("anthropic/")) { + options.order = ["bedrock", "anthropic"]; + options.only = ["bedrock", "anthropic"]; + } + + return options; +} + +export function getGoogleProviderOptionsForChat(modelId: string) { + const options: { + grounding: Record; + thinkingConfig: { includeThoughts: boolean; thinkingLevel?: "minimal" }; + } = { + grounding: { + // googleSearchRetrieval removed to force usage of explicit web_search tool + }, + thinkingConfig: { + includeThoughts: true, + }, + }; + + if (modelId === `google/${GOOGLE_MODEL_IDS.GEMINI_3_FLASH_PREVIEW}`) { + options.thinkingConfig.thinkingLevel = "minimal"; + } + + return options; +} diff --git a/src/lib/ai/tools/web-search.ts b/src/lib/ai/tools/web-search.ts index d2c8a234..0e724478 100644 --- a/src/lib/ai/tools/web-search.ts +++ b/src/lib/ai/tools/web-search.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { tool, generateText, stepCountIs, zodSchema, type ToolSet } from "ai"; import { google } from "@ai-sdk/google"; +import { GOOGLE_MODEL_IDS } from "@/lib/ai/model-registry"; import { WebSearchResultSchema, type WebSearchResult, @@ -99,7 +100,7 @@ export async function resolveGroundingChunksToSources( */ export async function executeWebSearch(query: string): Promise { const { text, providerMetadata } = await generateText({ - model: google('gemini-2.5-flash-lite'), + model: google(GOOGLE_MODEL_IDS.GEMINI_2_5_FLASH_LITE), tools: { googleSearch: google.tools.googleSearch({}), } as ToolSet, diff --git a/src/lib/ai/workers/text-selection-worker.ts b/src/lib/ai/workers/text-selection-worker.ts index afe47d85..7ebbc79e 100644 --- a/src/lib/ai/workers/text-selection-worker.ts +++ b/src/lib/ai/workers/text-selection-worker.ts @@ -1,6 +1,7 @@ import { google } from "@ai-sdk/google"; import { generateText } from "ai"; import { logger } from "@/lib/utils/logger"; +import { GOOGLE_MODEL_IDS } from "@/lib/ai/model-registry"; /** * WORKER 4: Text Selection Agent @@ -25,7 +26,7 @@ export async function textSelectionWorker( const systemInstruction = `You transform selected text as requested. Return ONLY the transformed text—no preamble, labels, or meta-commentary. Be concise: summarize in 1-3 sentences; explain in 2-5 sentences; rewrite/improve to similar length; translate preserves length.`; const result = await generateText({ - model: google("gemini-2.5-flash"), + model: google(GOOGLE_MODEL_IDS.GEMINI_2_5_FLASH), system: systemInstruction, prompt: prompts[action] + (additionalContext ? `\n\nAdditional context: ${additionalContext}` : ""), }); diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts index c869c2c9..c9f610d7 100644 --- a/src/lib/stores/ui-store.ts +++ b/src/lib/stores/ui-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { devtools, persist, createJSONStorage } from 'zustand/middleware'; import { WORKSPACE_PANEL_SIZES } from '@/lib/layout-constants'; +import { DEFAULT_CHAT_MODEL_ID } from '@/lib/ai/model-registry'; /** * UI Store - Manages all UI state (chat, modals, search, layout, text selection) @@ -147,7 +148,7 @@ const initialState = { showJsonView: false, activeFolderId: null, - selectedModelId: 'gemini-3-flash-preview', + selectedModelId: DEFAULT_CHAT_MODEL_ID, // Text selection inMultiSelectMode: false, diff --git a/src/workflows/audio-transcribe/steps/transcribe.ts b/src/workflows/audio-transcribe/steps/transcribe.ts index f4afbb8d..ae3376ba 100644 --- a/src/workflows/audio-transcribe/steps/transcribe.ts +++ b/src/workflows/audio-transcribe/steps/transcribe.ts @@ -4,6 +4,7 @@ import { createPartFromUri, createUserContent, } from "@google/genai"; +import { GOOGLE_MODEL_IDS } from "@/lib/ai/model-registry"; export interface TranscribeResult { summary: string; @@ -36,7 +37,7 @@ Requirements: 4. Provide the total duration of the audio in seconds (a single number, e.g. 180.5 for 3 minutes).`; const response = await client.models.generateContent({ - model: "gemini-2.5-flash", + model: GOOGLE_MODEL_IDS.GEMINI_2_5_FLASH, contents: createUserContent([ createPartFromUri(fileUri, mimeType), prompt, From 2ceff5b94f1606120990467b2393141c69bf6655 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:17:25 -0400 Subject: [PATCH 14/19] fix(assistant-ui): harden tool result handling and legacy mapping Show actionable error states for incomplete or unparsable tool outputs, remove unsafe casts/dead aliases, and align legacy tool-name normalization so chat replay and tool UIs behave consistently. Made-with: Cursor --- .../assistant-ui/AddYoutubeVideoToolUI.tsx | 13 +++- .../assistant-ui/CreateDocumentToolUI.tsx | 7 ++ .../assistant-ui/CreateFlashcardToolUI.tsx | 32 ++++++--- .../assistant-ui/CreateQuizToolUI.tsx | 23 +++--- .../assistant-ui/EditItemToolUI.tsx | 8 ++- .../assistant-ui/ReadWorkspaceToolUI.tsx | 2 +- .../assistant-ui/SearchWorkspaceToolUI.tsx | 2 +- .../assistant-ui/URLContextToolUI.tsx | 33 +++++---- src/lib/ai/chat-tool-names.ts | 10 +-- src/lib/ai/legacy-tool-message-compat.ts | 4 ++ src/lib/ai/tool-result-schemas.ts | 71 +++++++++---------- .../__tests__/workspace-worker.edit.test.ts | 36 ++++++++++ src/lib/utils/format-workspace-context.ts | 4 +- 13 files changed, 162 insertions(+), 83 deletions(-) diff --git a/src/components/assistant-ui/AddYoutubeVideoToolUI.tsx b/src/components/assistant-ui/AddYoutubeVideoToolUI.tsx index 6947f7ed..02eccce0 100644 --- a/src/components/assistant-ui/AddYoutubeVideoToolUI.tsx +++ b/src/components/assistant-ui/AddYoutubeVideoToolUI.tsx @@ -107,6 +107,17 @@ export const renderAddYoutubeVideoToolUI: AssistantToolUIProps< } let content: ReactNode = null; + const statusErrorMessage = + status.type === "incomplete" && status.reason === "error" + ? typeof (status as { error?: unknown }).error === "string" + ? (status as { error: string }).error + : typeof (status as { message?: unknown }).message === "string" + ? (status as { message: string }).message + : typeof (status as { payload?: { message?: unknown } }).payload + ?.message === "string" + ? (status as { payload: { message: string } }).payload.message + : undefined + : undefined; if (parsed?.success) { content = ; @@ -123,7 +134,7 @@ export const renderAddYoutubeVideoToolUI: AssistantToolUIProps< content = ( ); } diff --git a/src/components/assistant-ui/CreateDocumentToolUI.tsx b/src/components/assistant-ui/CreateDocumentToolUI.tsx index f2d909bc..196bb319 100644 --- a/src/components/assistant-ui/CreateDocumentToolUI.tsx +++ b/src/components/assistant-ui/CreateDocumentToolUI.tsx @@ -236,6 +236,13 @@ function CreateDocumentToolRenderer({ message={parsed.message} /> ); + } else if (status.type === "complete" && !parsed) { + content = ( + + ); } else if (status.type === "running") { content = ; } else if (status.type === "incomplete" && status.reason === "error") { diff --git a/src/components/assistant-ui/CreateFlashcardToolUI.tsx b/src/components/assistant-ui/CreateFlashcardToolUI.tsx index 312ffc83..43a640d2 100644 --- a/src/components/assistant-ui/CreateFlashcardToolUI.tsx +++ b/src/components/assistant-ui/CreateFlashcardToolUI.tsx @@ -34,9 +34,15 @@ type CreateFlashcardArgs = cards?: Array<{ front: string; back: string }>; }; type CreateFlashcardToolRendererProps = { - args: CreateFlashcardArgs; - result?: FlashcardResult; - status: { type: string; reason?: string }; + args: Parameters< + AssistantToolUIProps["render"] + >[0]["args"]; + result: Parameters< + AssistantToolUIProps["render"] + >[0]["result"]; + status: Parameters< + AssistantToolUIProps["render"] + >[0]["status"]; }; function isCreateFlashcardArgsObject( @@ -243,15 +249,17 @@ function CreateFlashcardToolRenderer({ (w) => w.id === workspaceId, ); - let parsed: FlashcardResult | null = null; - if (status.type === "complete" && result != null) { + const parsed = useMemo(() => { + if (status.type !== "complete" || result == null) { + return null; + } try { - parsed = parseFlashcardResult(result); + return parseFlashcardResult(result); } catch (err) { logger.error("🎨 [CreateFlashcardTool] Failed to parse result:", err); - parsed = null; + return null; } - } + }, [result, status.type]); useEffect(() => { logger.group(`🎨 [CreateFlashcardTool] RENDER CALLED`, true); @@ -293,6 +301,14 @@ function CreateFlashcardToolRenderer({ "⏳ [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 = ( { + if (process.env.NODE_ENV === "production") { + return; + } logger.debug("🎯 [CreateQuizTool] Render:", { args, result, @@ -241,7 +244,9 @@ function CreateQuizToolRenderer({ let content: ReactNode = null; - if (parsed?.success) { + if (status.type === "running") { + content = ; + } else if (status.type === "complete" && parsed?.success) { content = ( ); - } else if (status.type === "running") { - content = ; - } else if ( - (status.type === "incomplete" && status.reason === "error") || - (status.type === "complete" && parsed && !parsed.success) - ) { + } else if (status.type === "complete" && parsed && !parsed.success) { + content = ( + + ); + } else if (status.type === "incomplete" && status.reason === "error") { content = ( ); } diff --git a/src/components/assistant-ui/EditItemToolUI.tsx b/src/components/assistant-ui/EditItemToolUI.tsx index 6c34c821..8506c98b 100644 --- a/src/components/assistant-ui/EditItemToolUI.tsx +++ b/src/components/assistant-ui/EditItemToolUI.tsx @@ -161,6 +161,12 @@ export const renderEditItemToolUI: AssistantToolUIProps< } let content: ReactNode = null; + const statusErrorMessage = + typeof (status as { error?: unknown }).error === "string" + ? (status as { error: string }).error + : typeof (status as { message?: unknown }).message === "string" + ? (status as { message: string }).message + : "An error occurred while editing"; if (parsed?.success) { content = ( @@ -183,7 +189,7 @@ export const renderEditItemToolUI: AssistantToolUIProps< ); } else if (status.type === "incomplete" && status.reason === "error") { content = ( - + ); } diff --git a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx index c26125ee..7a30f969 100644 --- a/src/components/assistant-ui/ReadWorkspaceToolUI.tsx +++ b/src/components/assistant-ui/ReadWorkspaceToolUI.tsx @@ -79,7 +79,7 @@ export const renderReadWorkspaceToolUI: AssistantToolUIProps< content = ( ); } diff --git a/src/components/assistant-ui/SearchWorkspaceToolUI.tsx b/src/components/assistant-ui/SearchWorkspaceToolUI.tsx index 5761cd1f..e7f67383 100644 --- a/src/components/assistant-ui/SearchWorkspaceToolUI.tsx +++ b/src/components/assistant-ui/SearchWorkspaceToolUI.tsx @@ -42,7 +42,7 @@ export const renderSearchWorkspaceToolUI: AssistantToolUIProps< content = ( ); } diff --git a/src/components/assistant-ui/URLContextToolUI.tsx b/src/components/assistant-ui/URLContextToolUI.tsx index 33a922cf..3a32a790 100644 --- a/src/components/assistant-ui/URLContextToolUI.tsx +++ b/src/components/assistant-ui/URLContextToolUI.tsx @@ -232,22 +232,25 @@ export const renderURLContextToolUI: AssistantToolUIProps<{ 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 ( diff --git a/src/lib/ai/chat-tool-names.ts b/src/lib/ai/chat-tool-names.ts index 7ba78553..30b78b1e 100644 --- a/src/lib/ai/chat-tool-names.ts +++ b/src/lib/ai/chat-tool-names.ts @@ -25,6 +25,7 @@ const CANONICAL_CHAT_TOOL_NAMES = new Set(Object.values(CHAT_TOOL)); 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, @@ -89,12 +90,3 @@ export function isCanonicalChatToolName(name: string): name is ChatToolName { export function matchesWebSearchStreamToolName(name: string | undefined): boolean { return name === CHAT_TOOL.WEB_SEARCH || name === "webSearch"; } - -/** Autogen link-scraping progress (aligns with web_fetch) */ -export function matchesWebFetchStreamToolName(name: string | undefined): boolean { - return ( - name === CHAT_TOOL.WEB_FETCH || - name === "url_fetch" || - name === "urlFetch" - ); -} diff --git a/src/lib/ai/legacy-tool-message-compat.ts b/src/lib/ai/legacy-tool-message-compat.ts index b11ff73c..68c0fa97 100644 --- a/src/lib/ai/legacy-tool-message-compat.ts +++ b/src/lib/ai/legacy-tool-message-compat.ts @@ -36,6 +36,10 @@ export function normalizeLegacyToolMessages( 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 ( type === part.type && !availableToolNames.has(originalToolName) && diff --git a/src/lib/ai/tool-result-schemas.ts b/src/lib/ai/tool-result-schemas.ts index d3ceebe2..23137e7b 100644 --- a/src/lib/ai/tool-result-schemas.ts +++ b/src/lib/ai/tool-result-schemas.ts @@ -20,7 +20,7 @@ const baseWorkspace = z }) .passthrough(); -/** document_create, clearCardContent, item_edit */ +/** document_create, item_edit */ export const WorkspaceResultSchema = baseWorkspace; export type WorkspaceResult = z.infer; @@ -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"); } @@ -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)) { @@ -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)) { 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 71688122..7ae92f23 100644 --- a/src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts +++ b/src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts @@ -141,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 () => { @@ -182,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 () => { @@ -300,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/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 285d6ffe..b19da3bb 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -646,7 +646,7 @@ function formatPdfDetailsFull( } } else if (data.ocrStatus === "processing") { lines.push( - ` - (Content is being extracted. Please wait a moment and try workspace_read 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 workspace_read again.)`, + ` - (Content is being extracted. Please wait a moment and try again.)`, ); } else { if (data.altText) { From 31d966d7dd706956ef4de55299fa160f68580271 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:17:30 -0400 Subject: [PATCH 15/19] fix(ui): stabilize modal controls and workspace card interactions Prevent accidental blur/propagation in floating card controls, add explicit shared-workspace dismiss behavior, and tighten attachment/thread interaction state plus safe thread list query encoding. Made-with: Cursor --- src/components/assistant-ui/attachment.tsx | 97 ++++++------------- src/components/assistant-ui/thread.tsx | 19 ++-- .../onboarding/WorkspaceInstructionModal.tsx | 7 +- .../workspace-canvas/WorkspaceCardActions.tsx | 91 +++++++++-------- .../workspace/SharedWorkspaceModal.tsx | 17 +++- src/lib/chat/custom-thread-list-adapter.tsx | 4 +- 6 files changed, 108 insertions(+), 127 deletions(-) diff --git a/src/components/assistant-ui/attachment.tsx b/src/components/assistant-ui/attachment.tsx index 1b2b7f57..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,44 +129,14 @@ const useAttachmentSrc = () => { isUrl: false, url: undefined, }; - // URL attachment: virtual .url file (composer) or attachment name is http(s) 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( @@ -280,24 +277,10 @@ const AttachmentUI: FC = () => { } | undefined; if (!att) return "File"; - // URL attachment: virtual .url file or attachment name is http(s) 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": @@ -321,25 +304,7 @@ const AttachmentUI: FC = () => { } | undefined; if (!att) return false; - // URL attachment: virtual .url file or attachment name is http(s) URL - // 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/thread.tsx b/src/components/assistant-ui/thread.tsx index 9f747198..528f012f 100644 --- a/src/components/assistant-ui/thread.tsx +++ b/src/components/assistant-ui/thread.tsx @@ -1380,19 +1380,12 @@ const UserActionBar: FC = () => { const EditComposer: FC = () => { const aui = useAui(); const hasUploading = useAttachmentUploadStore((s) => s.uploadingIds.size > 0); - const initRef = useRef(false); - const [originalText, setOriginalText] = useState(""); - const [currentText, setCurrentText] = useState(""); - - useEffect(() => { - if (initRef.current) return; - const composerState = aui?.composer()?.getState(); - if (!composerState) return; - initRef.current = true; - const t = composerState.text ?? ""; - setOriginalText(t); - setCurrentText(t); - }, [aui]); + const [originalText, setOriginalText] = useState( + () => aui?.composer()?.getState()?.text ?? "", + ); + const [currentText, setCurrentText] = useState( + () => aui?.composer()?.getState()?.text ?? "", + ); return (
diff --git a/src/components/onboarding/WorkspaceInstructionModal.tsx b/src/components/onboarding/WorkspaceInstructionModal.tsx index 2af2dd6b..01745639 100644 --- a/src/components/onboarding/WorkspaceInstructionModal.tsx +++ b/src/components/onboarding/WorkspaceInstructionModal.tsx @@ -509,10 +509,13 @@ export function WorkspaceInstructionModal({
{/* CTA — vertically centered in bottom bar */} - {canClose && ( + {allowClose && ( @@ -251,19 +268,7 @@ export function WorkspaceCardControls({ title="Card settings" className={cn(floatingControlButtonClassName, "w-8 hover:scale-110")} style={getFloatingControlStyle(defaultBackgroundColor)} - onMouseDown={(event) => { - event.stopPropagation(); - }} - onMouseEnter={(event) => { - event.currentTarget.style.backgroundColor = - defaultHoverBackgroundColor; - }} - onMouseLeave={(event) => { - event.currentTarget.style.backgroundColor = defaultBackgroundColor; - }} - onClick={(event) => { - event.stopPropagation(); - }} + {...settingsHandlers} > diff --git a/src/components/workspace/SharedWorkspaceModal.tsx b/src/components/workspace/SharedWorkspaceModal.tsx index 04c6602a..c6bd81e3 100644 --- a/src/components/workspace/SharedWorkspaceModal.tsx +++ b/src/components/workspace/SharedWorkspaceModal.tsx @@ -37,11 +37,13 @@ interface SharedWorkspaceModalProps { interface SharedWorkspaceModalContentProps { workspaceId: string; onImported: (slug: string) => void; + onDismiss: () => void; } function SharedWorkspaceModalContent({ workspaceId, onImported, + onDismiss, }: SharedWorkspaceModalContentProps) { const createWorkspace = useCreateWorkspace(); const [formError, setFormError] = useState(null); @@ -270,7 +272,17 @@ function SharedWorkspaceModalContent({
{/* Footer */} -
+
+ event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()} > - {CHAT_MODEL_PROVIDER_GROUPS.map((group, groupIndex) => ( + {MODEL_PROVIDERS.map((group, groupIndex) => (
{groupIndex > 0 ? (
) : null}
- {PROVIDER_COMPANY_NAMES[group.provider] ?? group.companyName} + {PROVIDER_COMPANY_NAMES[group.provider]}
{group.models.map((model) => ( group.models, -); - -const CHAT_MODEL_BY_ID = new Map( - ALL_CHAT_MODELS.map((model) => [model.id, model] as const), -); - -const CHAT_MODEL_BY_ALIAS = new Map( - ALL_CHAT_MODELS.flatMap((model) => - (model.aliases ?? []).map((alias) => [alias, model] as const), - ), -); - -export const DEFAULT_CHAT_MODEL_ID = `google/${GOOGLE_MODEL_IDS.GEMINI_3_FLASH_PREVIEW}`; - -export function resolveChatModelConfig(modelId: string | null | undefined) { - if (!modelId) return CHAT_MODEL_BY_ID.get(DEFAULT_CHAT_MODEL_ID)!; - return ( - CHAT_MODEL_BY_ID.get(modelId) ?? - CHAT_MODEL_BY_ALIAS.get(modelId) ?? - CHAT_MODEL_BY_ID.get(DEFAULT_CHAT_MODEL_ID)! - ); -} - -export function resolveChatGatewayModelId(modelId: string | null | undefined) { - return resolveChatModelConfig(modelId).id; -} - -export function getChatModelDisplayName(modelId: string | null | undefined) { - return resolveChatModelConfig(modelId).name; -} - -export function getChatGatewayOptions({ - modelId, - userId, -}: { - modelId: string; - userId?: string | null; -}) { - const options: { - caching: "auto"; - models: string[]; - user?: string; - order?: string[]; - only?: string[]; - } = { - caching: "auto", - models: [modelId], - }; - - if (userId) { - options.user = userId; - } - - // Prefer Bedrock first for Claude models, then fall back to Anthropic. - if (modelId.startsWith("anthropic/")) { - options.order = ["bedrock", "anthropic"]; - options.only = ["bedrock", "anthropic"]; - } - - return options; -} - -export function getGoogleProviderOptionsForChat(modelId: string) { - const options: { - grounding: Record; - thinkingConfig: { includeThoughts: boolean; thinkingLevel?: "minimal" }; - } = { - grounding: { - // googleSearchRetrieval removed to force usage of explicit web_search tool - }, - thinkingConfig: { - includeThoughts: true, - }, - }; - - if (modelId === `google/${GOOGLE_MODEL_IDS.GEMINI_3_FLASH_PREVIEW}`) { - options.thinkingConfig.thinkingLevel = "minimal"; - } - - return options; -} diff --git a/src/lib/ai/tools/web-search.ts b/src/lib/ai/tools/web-search.ts index 0e724478..d2c8a234 100644 --- a/src/lib/ai/tools/web-search.ts +++ b/src/lib/ai/tools/web-search.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { tool, generateText, stepCountIs, zodSchema, type ToolSet } from "ai"; import { google } from "@ai-sdk/google"; -import { GOOGLE_MODEL_IDS } from "@/lib/ai/model-registry"; import { WebSearchResultSchema, type WebSearchResult, @@ -100,7 +99,7 @@ export async function resolveGroundingChunksToSources( */ export async function executeWebSearch(query: string): Promise { const { text, providerMetadata } = await generateText({ - model: google(GOOGLE_MODEL_IDS.GEMINI_2_5_FLASH_LITE), + model: google('gemini-2.5-flash-lite'), tools: { googleSearch: google.tools.googleSearch({}), } as ToolSet, diff --git a/src/lib/ai/workers/text-selection-worker.ts b/src/lib/ai/workers/text-selection-worker.ts index 7ebbc79e..afe47d85 100644 --- a/src/lib/ai/workers/text-selection-worker.ts +++ b/src/lib/ai/workers/text-selection-worker.ts @@ -1,7 +1,6 @@ import { google } from "@ai-sdk/google"; import { generateText } from "ai"; import { logger } from "@/lib/utils/logger"; -import { GOOGLE_MODEL_IDS } from "@/lib/ai/model-registry"; /** * WORKER 4: Text Selection Agent @@ -26,7 +25,7 @@ export async function textSelectionWorker( const systemInstruction = `You transform selected text as requested. Return ONLY the transformed text—no preamble, labels, or meta-commentary. Be concise: summarize in 1-3 sentences; explain in 2-5 sentences; rewrite/improve to similar length; translate preserves length.`; const result = await generateText({ - model: google(GOOGLE_MODEL_IDS.GEMINI_2_5_FLASH), + model: google("gemini-2.5-flash"), system: systemInstruction, prompt: prompts[action] + (additionalContext ? `\n\nAdditional context: ${additionalContext}` : ""), }); diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts index c9f610d7..c869c2c9 100644 --- a/src/lib/stores/ui-store.ts +++ b/src/lib/stores/ui-store.ts @@ -1,7 +1,6 @@ import { create } from 'zustand'; import { devtools, persist, createJSONStorage } from 'zustand/middleware'; import { WORKSPACE_PANEL_SIZES } from '@/lib/layout-constants'; -import { DEFAULT_CHAT_MODEL_ID } from '@/lib/ai/model-registry'; /** * UI Store - Manages all UI state (chat, modals, search, layout, text selection) @@ -148,7 +147,7 @@ const initialState = { showJsonView: false, activeFolderId: null, - selectedModelId: DEFAULT_CHAT_MODEL_ID, + selectedModelId: 'gemini-3-flash-preview', // Text selection inMultiSelectMode: false, diff --git a/src/workflows/audio-transcribe/steps/transcribe.ts b/src/workflows/audio-transcribe/steps/transcribe.ts index ae3376ba..f4afbb8d 100644 --- a/src/workflows/audio-transcribe/steps/transcribe.ts +++ b/src/workflows/audio-transcribe/steps/transcribe.ts @@ -4,7 +4,6 @@ import { createPartFromUri, createUserContent, } from "@google/genai"; -import { GOOGLE_MODEL_IDS } from "@/lib/ai/model-registry"; export interface TranscribeResult { summary: string; @@ -37,7 +36,7 @@ Requirements: 4. Provide the total duration of the audio in seconds (a single number, e.g. 180.5 for 3 minutes).`; const response = await client.models.generateContent({ - model: GOOGLE_MODEL_IDS.GEMINI_2_5_FLASH, + model: "gemini-2.5-flash", contents: createUserContent([ createPartFromUri(fileUri, mimeType), prompt,