feat(ui): add web search widget and enhance tool widgets

- Add WebSearchWidget for displaying search results with collapsible sections and clickable links
- Enhance GrepWidget with improved result parsing, visual design, and structured display
- Replace external links in GitHubAgentBrowser with Tauri shell integration for desktop security
- Update StreamMessage to support WebSearch tool rendering
- Add ReactMarkdown support for rich text display in search results
This commit is contained in:
Mufeed VH
2025-06-25 21:02:50 +05:30
parent 915914e735
commit ccd95330cb
3 changed files with 429 additions and 40 deletions

View File

@@ -18,6 +18,7 @@ import { Badge } from "@/components/ui/badge";
import { api, type GitHubAgentFile, type AgentExport, type Agent } from "@/lib/api";
import { type AgentIconName } from "./CCAgents";
import { ICON_MAP } from "./IconPicker";
import { open } from "@tauri-apps/plugin-shell";
interface GitHubAgentBrowserProps {
isOpen: boolean;
@@ -148,6 +149,15 @@ export const GitHubAgentBrowser: React.FC<GitHubAgentBrowserProps> = ({
return <Icon className="h-8 w-8" />;
};
const handleGitHubLinkClick = async (e: React.MouseEvent) => {
e.preventDefault();
try {
await open("https://github.com/getAsterisk/claudia/tree/main/cc_agents");
} catch (error) {
console.error('Failed to open GitHub link:', error);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
@@ -163,15 +173,13 @@ export const GitHubAgentBrowser: React.FC<GitHubAgentBrowserProps> = ({
<div className="px-4 py-3 bg-muted/50 rounded-lg mb-4">
<p className="text-sm text-muted-foreground">
Agents are fetched from{" "}
<a
href="https://github.com/getAsterisk/claudia/tree/main/cc_agents"
target="_blank"
rel="noopener noreferrer"
<button
onClick={handleGitHubLinkClick}
className="text-primary hover:underline inline-flex items-center gap-1"
>
github.com/getAsterisk/claudia/cc_agents
<Globe className="h-3 w-3" />
</a>
</button>
</p>
<p className="text-sm text-muted-foreground mt-1">
You can contribute your custom agents to the repository!

View File

@@ -34,7 +34,8 @@ import {
SystemInitializedWidget,
TaskWidget,
LSResultWidget,
ThinkingWidget
ThinkingWidget,
WebSearchWidget
} from "./ToolWidgets";
interface StreamMessageProps {
@@ -239,6 +240,12 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
return <GrepWidget pattern={input.pattern} include={input.include} path={input.path} exclude={input.exclude} result={toolResult} />;
}
// WebSearch tool
if (toolName === "websearch" && input?.query) {
renderedSomething = true;
return <WebSearchWidget query={input.query} result={toolResult} />;
}
// Default - return null
return null;
};
@@ -354,7 +361,7 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
const toolUse = prevMsg.message.content.find((c: any) => c.type === 'tool_use' && c.id === content.tool_use_id);
if (toolUse) {
const toolName = toolUse.name?.toLowerCase();
const toolsWithWidgets = ['task','edit','multiedit','todowrite','ls','read','glob','bash','write','grep'];
const toolsWithWidgets = ['task','edit','multiedit','todowrite','ls','read','glob','bash','write','grep','websearch'];
if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) {
hasCorrespondingWidget = true;
}

View File

@@ -51,6 +51,8 @@ import { createPortal } from "react-dom";
import * as Diff from 'diff';
import { Card, CardContent } from "@/components/ui/card";
import { detectLinks, makeLinksClickable } from "@/lib/linkDetector";
import ReactMarkdown from "react-markdown";
import { open } from "@tauri-apps/plugin-shell";
/**
* Widget for TodoWrite tool - displays a beautiful TODO list
@@ -857,45 +859,205 @@ export const GrepWidget: React.FC<{
path?: string;
exclude?: string;
result?: any;
}> = ({ pattern, include, path, exclude, result: _result }) => {
}> = ({ pattern, include, path, exclude, result }) => {
const [isExpanded, setIsExpanded] = useState(true);
// Extract result content if available
let resultContent = '';
let isError = false;
if (result) {
isError = result.is_error || false;
if (typeof result.content === 'string') {
resultContent = result.content;
} else if (result.content && typeof result.content === 'object') {
if (result.content.text) {
resultContent = result.content.text;
} else if (Array.isArray(result.content)) {
resultContent = result.content
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
.join('\n');
} else {
resultContent = JSON.stringify(result.content, null, 2);
}
}
}
// Parse grep results to extract file paths and matches
const parseGrepResults = (content: string) => {
const lines = content.split('\n').filter(line => line.trim());
const results: Array<{
file: string;
lineNumber: number;
content: string;
}> = [];
lines.forEach(line => {
// Common grep output format: filename:lineNumber:content
const match = line.match(/^(.+?):(\d+):(.*)$/);
if (match) {
results.push({
file: match[1],
lineNumber: parseInt(match[2], 10),
content: match[3]
});
}
});
return results;
};
const grepResults = result && !isError ? parseGrepResults(resultContent) : [];
return (
<div className="space-y-2">
<div className="flex items-center gap-2 mb-2">
<Code className="h-4 w-4 text-primary" />
<div className="flex items-center gap-2 p-3 rounded-lg bg-gradient-to-r from-emerald-500/10 to-teal-500/10 border border-emerald-500/20">
<Search className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Searching with grep</span>
{!result && (
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<div className="h-2 w-2 bg-emerald-500 rounded-full animate-pulse" />
<span>Searching...</span>
</div>
)}
</div>
<div className="space-y-1.5 text-xs">
<div className="flex gap-2 items-center">
<span className="text-muted-foreground w-16">Pattern:</span>
<code className="font-mono bg-muted px-2 py-0.5 rounded flex-1">
{pattern}
</code>
{/* Search Parameters */}
<div className="rounded-lg border bg-muted/20 p-3 space-y-2">
<div className="grid gap-2">
{/* Pattern with regex highlighting */}
<div className="flex items-start gap-3">
<div className="flex items-center gap-1.5 min-w-[80px]">
<Code className="h-3 w-3 text-emerald-500" />
<span className="text-xs font-medium text-muted-foreground">Pattern</span>
</div>
<code className="flex-1 font-mono text-sm bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 rounded-md text-emerald-600 dark:text-emerald-400">
{pattern}
</code>
</div>
{/* Path */}
{path && (
<div className="flex items-start gap-3">
<div className="flex items-center gap-1.5 min-w-[80px]">
<FolderOpen className="h-3 w-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">Path</span>
</div>
<code className="flex-1 font-mono text-xs bg-muted px-2 py-1 rounded truncate">
{path}
</code>
</div>
)}
{/* Include/Exclude patterns in a row */}
{(include || exclude) && (
<div className="flex gap-4">
{include && (
<div className="flex items-center gap-2 flex-1">
<div className="flex items-center gap-1.5">
<FilePlus className="h-3 w-3 text-green-500" />
<span className="text-xs font-medium text-muted-foreground">Include</span>
</div>
<code className="font-mono text-xs bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded text-green-600 dark:text-green-400">
{include}
</code>
</div>
)}
{exclude && (
<div className="flex items-center gap-2 flex-1">
<div className="flex items-center gap-1.5">
<X className="h-3 w-3 text-red-500" />
<span className="text-xs font-medium text-muted-foreground">Exclude</span>
</div>
<code className="font-mono text-xs bg-red-500/10 border border-red-500/20 px-2 py-0.5 rounded text-red-600 dark:text-red-400">
{exclude}
</code>
</div>
)}
</div>
)}
</div>
{path && (
<div className="flex gap-2 items-center">
<span className="text-muted-foreground w-16">Path:</span>
<code className="font-mono bg-muted px-2 py-0.5 rounded flex-1">
{path}
</code>
</div>
)}
{include && (
<div className="flex gap-2 items-center">
<span className="text-muted-foreground w-16">Include:</span>
<code className="font-mono bg-muted px-2 py-0.5 rounded">
{include}
</code>
</div>
)}
{exclude && (
<div className="flex gap-2 items-center">
<span className="text-muted-foreground w-16">Exclude:</span>
<code className="font-mono bg-muted px-2 py-0.5 rounded">
{exclude}
</code>
</div>
)}
</div>
{/* Results */}
{result && (
<div className="space-y-2">
{isError ? (
<div className="flex items-center gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0" />
<div className="text-sm text-red-600 dark:text-red-400">
{resultContent || "Search failed"}
</div>
</div>
) : grepResults.length > 0 ? (
<>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
<span>{grepResults.length} matches found</span>
</button>
{isExpanded && (
<div className="rounded-lg border bg-zinc-950 overflow-hidden">
<div className="max-h-[400px] overflow-y-auto">
{grepResults.map((match, idx) => {
const fileName = match.file.split('/').pop() || match.file;
const dirPath = match.file.substring(0, match.file.lastIndexOf('/'));
return (
<div
key={idx}
className={cn(
"flex items-start gap-3 p-3 border-b border-zinc-800 hover:bg-zinc-900/50 transition-colors",
idx === grepResults.length - 1 && "border-b-0"
)}
>
<div className="flex items-center gap-2 min-w-[60px]">
<FileText className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-xs font-mono text-emerald-400">
{match.lineNumber}
</span>
</div>
<div className="flex-1 space-y-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-blue-400 truncate">
{fileName}
</span>
{dirPath && (
<span className="text-xs text-muted-foreground truncate">
{dirPath}
</span>
)}
</div>
<code className="text-xs font-mono text-zinc-300 block whitespace-pre-wrap break-all">
{match.content.trim()}
</code>
</div>
</div>
);
})}
</div>
</div>
)}
</>
) : (
<div className="flex items-center gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<Info className="h-5 w-5 text-amber-500 flex-shrink-0" />
<div className="text-sm text-amber-600 dark:text-amber-400">
No matches found for the given pattern.
</div>
</div>
)}
</div>
)}
</div>
);
};
@@ -1870,6 +2032,218 @@ export const TaskWidget: React.FC<{
);
};
/**
* Widget for WebSearch tool - displays web search query and results
*/
export const WebSearchWidget: React.FC<{
query: string;
result?: any;
}> = ({ query, result }) => {
const [expandedSections, setExpandedSections] = useState<Set<number>>(new Set());
// Parse the result to extract all links sections and build a structured representation
const parseSearchResult = (resultContent: string) => {
const sections: Array<{
type: 'text' | 'links';
content: string | Array<{ title: string; url: string }>;
}> = [];
// Split by "Links: [" to find all link sections
const parts = resultContent.split(/Links:\s*\[/);
// First part is always text (or empty)
if (parts[0]) {
sections.push({ type: 'text', content: parts[0].trim() });
}
// Process each links section
parts.slice(1).forEach(part => {
try {
// Find the closing bracket
const closingIndex = part.indexOf(']');
if (closingIndex === -1) return;
const linksJson = '[' + part.substring(0, closingIndex + 1);
const remainingText = part.substring(closingIndex + 1).trim();
// Parse the JSON array
const links = JSON.parse(linksJson);
sections.push({ type: 'links', content: links });
// Add any remaining text
if (remainingText) {
sections.push({ type: 'text', content: remainingText });
}
} catch (e) {
// If parsing fails, treat it as text
sections.push({ type: 'text', content: 'Links: [' + part });
}
});
return sections;
};
const toggleSection = (index: number) => {
const newExpanded = new Set(expandedSections);
if (newExpanded.has(index)) {
newExpanded.delete(index);
} else {
newExpanded.add(index);
}
setExpandedSections(newExpanded);
};
// Extract result content if available
let searchResults: {
sections: Array<{
type: 'text' | 'links';
content: string | Array<{ title: string; url: string }>;
}>;
noResults: boolean;
} = { sections: [], noResults: false };
if (result) {
let resultContent = '';
if (typeof result.content === 'string') {
resultContent = result.content;
} else if (result.content && typeof result.content === 'object') {
if (result.content.text) {
resultContent = result.content.text;
} else if (Array.isArray(result.content)) {
resultContent = result.content
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
.join('\n');
} else {
resultContent = JSON.stringify(result.content, null, 2);
}
}
searchResults.noResults = resultContent.toLowerCase().includes('no links found') ||
resultContent.toLowerCase().includes('no results');
searchResults.sections = parseSearchResult(resultContent);
}
const handleLinkClick = async (url: string) => {
try {
await open(url);
} catch (error) {
console.error('Failed to open URL:', error);
}
};
return (
<div className="flex flex-col gap-2">
{/* Subtle Search Query Header */}
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-500/5 border border-blue-500/10">
<Globe className="h-4 w-4 text-blue-500/70" />
<span className="text-xs font-medium uppercase tracking-wider text-blue-600/70 dark:text-blue-400/70">Web Search</span>
<span className="text-sm text-muted-foreground/80 flex-1 truncate">{query}</span>
</div>
{/* Results */}
{result && (
<div className="rounded-lg border bg-background/50 backdrop-blur-sm overflow-hidden">
{!searchResults.sections.length ? (
<div className="px-3 py-2 flex items-center gap-2 text-muted-foreground">
<div className="animate-pulse flex items-center gap-1">
<div className="h-1 w-1 bg-blue-500 rounded-full animate-bounce [animation-delay:-0.3s]"></div>
<div className="h-1 w-1 bg-blue-500 rounded-full animate-bounce [animation-delay:-0.15s]"></div>
<div className="h-1 w-1 bg-blue-500 rounded-full animate-bounce"></div>
</div>
<span className="text-sm">Searching...</span>
</div>
) : searchResults.noResults ? (
<div className="px-3 py-2">
<div className="flex items-center gap-2 text-muted-foreground">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">No results found</span>
</div>
</div>
) : (
<div className="p-3 space-y-3">
{searchResults.sections.map((section, idx) => {
if (section.type === 'text') {
return (
<div key={idx} className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown>{section.content as string}</ReactMarkdown>
</div>
);
} else if (section.type === 'links' && Array.isArray(section.content)) {
const links = section.content;
const isExpanded = expandedSections.has(idx);
return (
<div key={idx} className="space-y-1.5">
{/* Toggle Button */}
<button
onClick={() => toggleSection(idx)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<span>{links.length} result{links.length !== 1 ? 's' : ''}</span>
</button>
{/* Links Display */}
{isExpanded ? (
/* Expanded Card View */
<div className="grid gap-1.5 ml-4">
{links.map((link, linkIdx) => (
<button
key={linkIdx}
onClick={() => handleLinkClick(link.url)}
className="group flex flex-col gap-0.5 p-2.5 rounded-md border bg-card/30 hover:bg-card/50 hover:border-blue-500/30 transition-all text-left"
>
<div className="flex items-start gap-2">
<Globe2 className="h-3.5 w-3.5 text-blue-500/70 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium group-hover:text-blue-500 transition-colors line-clamp-2">
{link.title}
</div>
<div className="text-xs text-muted-foreground mt-0.5 truncate">
{link.url}
</div>
</div>
</div>
</button>
))}
</div>
) : (
/* Collapsed Pills View */
<div className="flex flex-wrap gap-1.5 ml-4">
{links.map((link, linkIdx) => (
<button
key={linkIdx}
onClick={(e) => {
e.stopPropagation();
handleLinkClick(link.url);
}}
className="group inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-500/5 hover:bg-blue-500/10 border border-blue-500/10 hover:border-blue-500/20 transition-all"
>
<Globe2 className="h-3 w-3 text-blue-500/70" />
<span className="truncate max-w-[180px] text-foreground/70 group-hover:text-foreground/90">
{link.title}
</span>
</button>
))}
</div>
)}
</div>
);
}
return null;
})}
</div>
)}
</div>
)}
</div>
);
};
/**
* Widget for displaying AI thinking/reasoning content
* Collapsible and closed by default