Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/components/chat/tools/AddYoutubeVideoToolUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,14 @@ export const renderAddYoutubeVideoToolUI: ChatToolUIProps<
if (parsed?.success) {
content = <AddYoutubeVideoReceipt args={args} result={parsed} status={status} />;
} else if (status.type === "running") {
content = <ToolUILoadingShell label="Adding YouTube video..." />;
const titleRunning = args?.title?.trim();
content = (
<ToolUILoadingShell
label={
titleRunning ? `Adding "${titleRunning}"…` : "Adding YouTube video…"
}
/>
);
} else if (status.type === "complete" && parsed && !parsed.success) {
content = (
<ToolUIErrorShell
Expand Down
11 changes: 10 additions & 1 deletion src/components/chat/tools/CreateDocumentToolUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,16 @@ function CreateDocumentToolRenderer({
/>
);
} else if (status.type === "running") {
content = <ToolUILoadingShell label="Creating document..." />;
const titleRunning = args?.title?.trim();
content = (
<ToolUILoadingShell
label={
titleRunning
? `Creating "${titleRunning}"…`
: "Creating document…"
}
/>
);
} else if (status.type === "incomplete" && status.reason === "error") {
content = (
<ToolUIErrorShell
Expand Down
12 changes: 11 additions & 1 deletion src/components/chat/tools/CreateFlashcardToolUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,17 @@ function CreateFlashcardToolRenderer({
logger.debug(
"⏳ [CreateFlashcardTool] Rendering loading state - status is running",
);
content = <ToolUILoadingShell label="Generating flashcards..." />;
const argsObjRunning = isCreateFlashcardArgsObject(args) ? args : null;
const titleRunning = argsObjRunning?.title?.trim();
content = (
<ToolUILoadingShell
label={
titleRunning
? `Creating "${titleRunning}" flashcards…`
: "Generating flashcards…"
}
/>
);
} else if (status.type === "complete" && !parsed) {
logger.error("🎨 [CreateFlashcardTool] Complete status had no parseable result");
content = (
Expand Down
9 changes: 8 additions & 1 deletion src/components/chat/tools/CreateQuizToolUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,14 @@ function CreateQuizToolRenderer({
let content: ReactNode = null;

if (status.type === "running") {
content = <ToolUILoadingShell label="Generating quiz..." />;
const titleRunning = args?.title?.trim();
content = (
<ToolUILoadingShell
label={
titleRunning ? `Creating "${titleRunning}" quiz…` : "Generating quiz…"
}
/>
);
} else if (status.type === "complete" && parsed?.success) {
content = (
<CreateQuizReceipt
Expand Down
10 changes: 7 additions & 3 deletions src/components/chat/tools/ExecuteCodeToolUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,17 @@ function chartLabel(chartType: string, index: number): string {
}

export const renderExecuteCodeToolUI: ChatToolUIProps<
{ code: string },
{ title?: string; code: string },
CodeExecuteResult
>["render"] = ({ status, args, result }) => {
const runningLabel =
typeof args?.title === "string" && args.title.trim().length > 0
? `${args.title.trim()}…`
: "Calculating…";
return (
<ToolUIErrorBoundary componentName="ExecuteCode">
{status.type === "running" ? (
<ToolUILoadingShell label="Calculating…" />
<ToolUILoadingShell label={runningLabel} />
) : status.type === "incomplete" ? (
<div className="my-1 rounded-md border border-border/50 bg-card/50 px-3 py-2 text-xs text-muted-foreground">
Couldn&apos;t complete this step. Try again or rephrase your question.
Expand All @@ -54,7 +58,7 @@ function ExecuteCodeResult({
args,
result,
}: {
args: { code: string };
args: { title?: string; code: string };
result: CodeExecuteResult | undefined;
}) {
const [showCode, setShowCode] = useState(false);
Expand Down
11 changes: 8 additions & 3 deletions src/components/chat/tools/SearchWorkspaceToolUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,22 @@ import { ToolUIErrorBoundary } from "@/components/tool-ui/shared";
import { ToolUILoadingShell } from "./tool-ui-loading-shell";
import { ToolUIErrorShell } from "./tool-ui-error-shell";

type GrepArgs = { pattern: string; include?: string; path?: string };
type GrepArgs = { title?: string; pattern: string; include?: string; path?: string };
type GrepResult = { success: boolean; matches?: number; output?: string; message?: string };

export const renderSearchWorkspaceToolUI: ChatToolUIProps<
GrepArgs,
GrepResult
>["render"] = ({ status, result }) => {
>["render"] = ({ args, status, result }) => {
let content: React.ReactNode = null;

if (status.type === "running") {
content = <ToolUILoadingShell label="Searching workspace..." />;
const title = args?.title?.trim();
content = (
<ToolUILoadingShell
label={title ? `${title}…` : "Searching workspace…"}
/>
);
} else if (status.type === "complete" && result) {
if (!result.success && result.message) {
content = (
Expand Down
2 changes: 1 addition & 1 deletion src/components/chat/tools/URLContextToolUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ export const renderURLContextToolUI: ChatToolUIProps<{
rel="noopener noreferrer"
className="text-xs text-primary hover:underline break-all"
>
{url}
{url.replace(/^https?:\/\//, "")}
</a>
</div>
{urlMeta && isComplete && urlMeta.urlRetrievalStatus && (
Expand Down
4 changes: 2 additions & 2 deletions src/components/chat/tools/WebMapToolUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export const renderWebMapToolUI: ChatToolUIProps<
rel="noopener noreferrer"
className="text-xs text-primary hover:underline break-all"
>
{sourceUrl}
{sourceUrl.replace(/^https?:\/\//, "")}
</a>
</div>
)}
Expand Down Expand Up @@ -316,7 +316,7 @@ export const renderWebMapToolUI: ChatToolUIProps<
rel="noopener noreferrer"
className="text-xs text-primary hover:underline break-all"
>
{link.url}
{link.url.replace(/^https?:\/\//, "")}
</a>
</div>
{link.title && (
Expand Down
17 changes: 14 additions & 3 deletions src/components/chat/tools/WebSearchToolUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,20 +189,30 @@ const getDomain = (url: string) => {
};

const WebSearchContent: FC<{
args: { query?: string };
status: { type: string };
result: WebSearchResult | null;
}> = ({ status, result }) => {
}> = ({ args, status, result }) => {
const isRunning = status.type === "running";
const parsed = result ? parseWebSearchResult(result) : null;
const metadata = parsed?.groundingMetadata;
const queries = metadata?.webSearchQueries;
const sources = parsed?.sources ?? [];

const argsQuery = args?.query?.trim();
const triggerLabel = isRunning
? argsQuery
? `Searching the web for "${argsQuery}"`
: "Searching the web"
: argsQuery
? `Searched the web for "${argsQuery}"`
: "Searched the web";

return (
<ToolRoot>
<ToolTrigger
active={isRunning}
label="Searching Web"
label={triggerLabel}
icon={<GlobeIcon className="size-4 shrink-0" />}
/>

Expand Down Expand Up @@ -277,12 +287,13 @@ export const renderWebSearchToolUI: ChatToolUIProps<
{ query: string },
WebSearchResult
>["render"] = ({
args,
status,
result,
}) => {
return (
<ToolUIErrorBoundary componentName="WebSearch">
<WebSearchContent status={status} result={result ?? null} />
<WebSearchContent args={args} status={status} result={result ?? null} />
</ToolUIErrorBoundary>
);
};
8 changes: 7 additions & 1 deletion src/components/chat/tools/YouTubeSearchToolUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,13 @@ const YouTubeSearchContent: FC<{
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-xs font-medium truncate">
{isRunning ? "Searching YouTube..." : "YouTube Search"}
{isRunning
? args?.query?.trim()
? `Searching YouTube for "${args.query.trim()}"…`
: "Searching YouTube…"
: args?.query?.trim()
? `YouTube: "${args.query.trim()}"`
: "YouTube Search"}
</span>
{status.type === "complete" && result && (
<span className="text-[10px] text-muted-foreground">
Expand Down
2 changes: 2 additions & 0 deletions src/lib/ai/tools/execute-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type CodeExecuteResult,
type CodeExecuteStep,
} from "@/lib/ai/code-execute-shared";
import { toolTitleField } from "./tool-utils";

const SANDBOX_TIMEOUT_MS = 300_000;
const EXECUTION_TIMEOUT_MS = 60_000;
Expand Down Expand Up @@ -84,6 +85,7 @@ export function createExecuteCodeTool() {
description: buildCodeExecuteToolDescription(),
inputSchema: zodSchema(
z.object({
title: toolTitleField,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Make title optional here so older code_execute tool messages still validate on replay.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/ai/tools/execute-code.ts, line 88:

<comment>Make `title` optional here so older code_execute tool messages still validate on replay.</comment>

<file context>
@@ -84,6 +85,7 @@ export function createExecuteCodeTool() {
     description: buildCodeExecuteToolDescription(),
     inputSchema: zodSchema(
       z.object({
+        title: toolTitleField,
         code: z
           .string()
</file context>
Suggested change
title: toolTitleField,
title: toolTitleField.optional(),

code: z
.string()
.min(1)
Expand Down
3 changes: 2 additions & 1 deletion src/lib/ai/tools/search-workspace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { tool, zodSchema } from "ai";
import { z } from "zod";
import { loadStateForTool } from "./tool-utils";
import { loadStateForTool, toolTitleField } from "./tool-utils";
import { extractSearchableText } from "./workspace-search-utils";
import { getVirtualPath } from "@/lib/utils/workspace-fs";
import type { WorkspaceToolContext } from "./workspace-tools";
Expand Down Expand Up @@ -44,6 +44,7 @@ export function createSearchWorkspaceTool(ctx: WorkspaceToolContext) {
"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({
title: toolTitleField,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Make title optional or backfill it for legacy search-workspace parts. Older chats don't have this field, and replay validation will fail without a compatibility path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/ai/tools/search-workspace.ts, line 47:

<comment>Make `title` optional or backfill it for legacy `search-workspace` parts. Older chats don't have this field, and replay validation will fail without a compatibility path.</comment>

<file context>
@@ -44,6 +44,7 @@ export function createSearchWorkspaceTool(ctx: WorkspaceToolContext) {
             "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({
+                title: toolTitleField,
                 pattern: z.string().describe("Search pattern (plain text or regex)"),
                 include: z.string().optional().describe('Optional item type filter, e.g. "document", "flashcard", "pdf", "quiz", "audio", "image", "website", or "youtube"'),
</file context>
Suggested change
title: toolTitleField,
title: toolTitleField.optional(),

pattern: z.string().describe("Search pattern (plain text or regex)"),
include: z.string().optional().describe('Optional item type filter, e.g. "document", "flashcard", "pdf", "quiz", "audio", "image", "website", or "youtube"'),
path: z.string().optional().describe("Folder prefix (Physics/) or exact item path (Physics/documents/File.md)"),
Expand Down
20 changes: 20 additions & 0 deletions src/lib/ai/tools/tool-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,31 @@
* Shared utilities for AI tools
*/

import { z } from "zod";
import { loadWorkspaceState } from "@/lib/workspace/workspace-state-read";
import type { Item } from "@/lib/workspace-state/types";
import type { WorkspaceToolContext } from "./workspace-tools";
import { resolveItemByPath } from "./workspace-search-utils";

/**
* Shared `title` field for tool inputs whose other arguments are technical
* (regex patterns, raw URLs, generated code). The model writes a short
* present-tense gerund phrase that the UI shows while the tool runs, in
* place of a generic "Loading…" label.
*
* Place this as the FIRST field in a tool's input schema so it streams in
* before the heavier arguments and the loading shell can render it
* immediately during `input-streaming`.
*/
export const toolTitleField = z

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: toolTitleField is required (no .optional()), but the AI SDK validates tool inputs against this schema before execution. If the LLM omits the field or older saved chats are replayed without it, Zod will throw InvalidToolInputError and the tool call will fail entirely instead of falling back to the generic label. Mark it .optional() so the graceful fallback logic in the UI components actually works.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/ai/tools/tool-utils.ts, line 21:

<comment>`toolTitleField` is required (no `.optional()`), but the AI SDK validates tool inputs against this schema before execution. If the LLM omits the field or older saved chats are replayed without it, Zod will throw `InvalidToolInputError` and the tool call will fail entirely instead of falling back to the generic label. Mark it `.optional()` so the graceful fallback logic in the UI components actually works.</comment>

<file context>
@@ -2,11 +2,30 @@
+ * before the heavier arguments and the loading shell can render it
+ * immediately during `input-streaming`.
+ */
+export const toolTitleField = z
+  .string()
+  .min(1)
</file context>

.string()
.min(1)
.max(60)
.optional()
.describe(
Comment on lines +21 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 toolTitleField is required (no .optional()), but both consumer UI types declare it as title?: string and the fallback logic (args?.title?.trim()) assumes it can be absent. When replaying older saved chats that predate this field, or when the LLM omits it (which LLMs sometimes do despite instructions), Zod will throw a validation error and the tool call will fail entirely instead of falling back gracefully.

Suggested change
export const toolTitleField = z
.string()
.min(1)
.max(60)
.describe(
export const toolTitleField = z
.string()
.min(1)
.max(60)
.optional()
.describe(

Fix in Cursor

'A short present-tense gerund phrase (3–6 words) describing what this tool call is doing in plain language for a non-technical user. Shown in the UI while the tool runs. Examples: "Searching your notes for photosynthesis", "Reading the Wikipedia article", "Computing average grades". No trailing punctuation, no tool names, no jargon (regex, URL, etc.). Strongly encouraged on every call so the user sees meaningful progress instead of a generic loading label.',
);

/**
* Sanitize tool results for the model's context window.
*
Expand Down
Loading