Skip to content

Commit d431f32

Browse files
committed
feat(ui): add WebFetch tool widget with comprehensive content display
- Add WebFetchWidget component with URL display, prompt handling, and content preview - Integrate WebFetch widget into StreamMessage tool rendering pipeline - Include loading states, error handling, and expandable content functionality - Support both basic URL fetching and prompted analysis workflows
1 parent a7e17f1 commit d431f32

2 files changed

Lines changed: 193 additions & 2 deletions

File tree

src/components/StreamMessage.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ import {
3535
TaskWidget,
3636
LSResultWidget,
3737
ThinkingWidget,
38-
WebSearchWidget
38+
WebSearchWidget,
39+
WebFetchWidget
3940
} from "./ToolWidgets";
4041

4142
interface StreamMessageProps {
@@ -246,6 +247,12 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
246247
return <WebSearchWidget query={input.query} result={toolResult} />;
247248
}
248249

250+
// WebFetch tool
251+
if (toolName === "webfetch" && input?.url) {
252+
renderedSomething = true;
253+
return <WebFetchWidget url={input.url} prompt={input.prompt} result={toolResult} />;
254+
}
255+
249256
// Default - return null
250257
return null;
251258
};
@@ -361,7 +368,7 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
361368
const toolUse = prevMsg.message.content.find((c: any) => c.type === 'tool_use' && c.id === content.tool_use_id);
362369
if (toolUse) {
363370
const toolName = toolUse.name?.toLowerCase();
364-
const toolsWithWidgets = ['task','edit','multiedit','todowrite','ls','read','glob','bash','write','grep','websearch'];
371+
const toolsWithWidgets = ['task','edit','multiedit','todowrite','ls','read','glob','bash','write','grep','websearch','webfetch'];
365372
if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) {
366373
hasCorrespondingWidget = true;
367374
}

src/components/ToolWidgets.tsx

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2288,3 +2288,187 @@ export const ThinkingWidget: React.FC<{
22882288
</div>
22892289
);
22902290
};
2291+
2292+
/**
2293+
* Widget for WebFetch tool - displays URL fetching with optional prompts
2294+
*/
2295+
export const WebFetchWidget: React.FC<{
2296+
url: string;
2297+
prompt?: string;
2298+
result?: any;
2299+
}> = ({ url, prompt, result }) => {
2300+
const [isExpanded, setIsExpanded] = useState(false);
2301+
const [showFullContent, setShowFullContent] = useState(false);
2302+
2303+
// Extract result content if available
2304+
let fetchedContent = '';
2305+
let isLoading = !result;
2306+
let hasError = false;
2307+
2308+
if (result) {
2309+
if (typeof result.content === 'string') {
2310+
fetchedContent = result.content;
2311+
} else if (result.content && typeof result.content === 'object') {
2312+
if (result.content.text) {
2313+
fetchedContent = result.content.text;
2314+
} else if (Array.isArray(result.content)) {
2315+
fetchedContent = result.content
2316+
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
2317+
.join('\n');
2318+
} else {
2319+
fetchedContent = JSON.stringify(result.content, null, 2);
2320+
}
2321+
}
2322+
2323+
// Check if there's an error
2324+
hasError = result.is_error ||
2325+
fetchedContent.toLowerCase().includes('error') ||
2326+
fetchedContent.toLowerCase().includes('failed');
2327+
}
2328+
2329+
// Truncate content for preview
2330+
const maxPreviewLength = 500;
2331+
const isTruncated = fetchedContent.length > maxPreviewLength;
2332+
const previewContent = isTruncated && !showFullContent
2333+
? fetchedContent.substring(0, maxPreviewLength) + '...'
2334+
: fetchedContent;
2335+
2336+
// Extract domain from URL for display
2337+
const getDomain = (urlString: string) => {
2338+
try {
2339+
const urlObj = new URL(urlString);
2340+
return urlObj.hostname;
2341+
} catch {
2342+
return urlString;
2343+
}
2344+
};
2345+
2346+
const handleUrlClick = async () => {
2347+
try {
2348+
await open(url);
2349+
} catch (error) {
2350+
console.error('Failed to open URL:', error);
2351+
}
2352+
};
2353+
2354+
return (
2355+
<div className="flex flex-col gap-2">
2356+
{/* Header with URL and optional prompt */}
2357+
<div className="space-y-2">
2358+
{/* URL Display */}
2359+
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-purple-500/5 border border-purple-500/10">
2360+
<Globe className="h-4 w-4 text-purple-500/70" />
2361+
<span className="text-xs font-medium uppercase tracking-wider text-purple-600/70 dark:text-purple-400/70">Fetching</span>
2362+
<button
2363+
onClick={handleUrlClick}
2364+
className="text-sm text-foreground/80 hover:text-foreground flex-1 truncate text-left hover:underline decoration-purple-500/50"
2365+
>
2366+
{url}
2367+
</button>
2368+
</div>
2369+
2370+
{/* Prompt Display */}
2371+
{prompt && (
2372+
<div className="ml-6 space-y-1">
2373+
<button
2374+
onClick={() => setIsExpanded(!isExpanded)}
2375+
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
2376+
>
2377+
<ChevronRight className={cn("h-3 w-3 transition-transform", isExpanded && "rotate-90")} />
2378+
<Info className="h-3 w-3" />
2379+
<span>Analysis Prompt</span>
2380+
</button>
2381+
2382+
{isExpanded && (
2383+
<div className="rounded-lg border bg-muted/30 p-3 ml-4">
2384+
<p className="text-sm text-foreground/90">
2385+
{prompt}
2386+
</p>
2387+
</div>
2388+
)}
2389+
</div>
2390+
)}
2391+
</div>
2392+
2393+
{/* Results */}
2394+
{isLoading ? (
2395+
<div className="rounded-lg border bg-background/50 backdrop-blur-sm overflow-hidden">
2396+
<div className="px-3 py-2 flex items-center gap-2 text-muted-foreground">
2397+
<div className="animate-pulse flex items-center gap-1">
2398+
<div className="h-1 w-1 bg-purple-500 rounded-full animate-bounce [animation-delay:-0.3s]"></div>
2399+
<div className="h-1 w-1 bg-purple-500 rounded-full animate-bounce [animation-delay:-0.15s]"></div>
2400+
<div className="h-1 w-1 bg-purple-500 rounded-full animate-bounce"></div>
2401+
</div>
2402+
<span className="text-sm">Fetching content from {getDomain(url)}...</span>
2403+
</div>
2404+
</div>
2405+
) : fetchedContent ? (
2406+
<div className="rounded-lg border bg-background/50 backdrop-blur-sm overflow-hidden">
2407+
{hasError ? (
2408+
<div className="px-3 py-2">
2409+
<div className="flex items-center gap-2 text-destructive">
2410+
<AlertCircle className="h-4 w-4" />
2411+
<span className="text-sm font-medium">Failed to fetch content</span>
2412+
</div>
2413+
<pre className="mt-2 text-xs font-mono text-muted-foreground whitespace-pre-wrap">
2414+
{fetchedContent}
2415+
</pre>
2416+
</div>
2417+
) : (
2418+
<div className="p-3 space-y-2">
2419+
{/* Content Header */}
2420+
<div className="flex items-center justify-between">
2421+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
2422+
<FileText className="h-3.5 w-3.5" />
2423+
<span>Content from {getDomain(url)}</span>
2424+
</div>
2425+
{isTruncated && (
2426+
<button
2427+
onClick={() => setShowFullContent(!showFullContent)}
2428+
className="text-xs text-purple-500 hover:text-purple-600 transition-colors flex items-center gap-1"
2429+
>
2430+
{showFullContent ? (
2431+
<>
2432+
<ChevronUp className="h-3 w-3" />
2433+
Show less
2434+
</>
2435+
) : (
2436+
<>
2437+
<ChevronDown className="h-3 w-3" />
2438+
Show full content
2439+
</>
2440+
)}
2441+
</button>
2442+
)}
2443+
</div>
2444+
2445+
{/* Fetched Content */}
2446+
<div className="relative">
2447+
<div className={cn(
2448+
"rounded-lg bg-muted/30 p-3 overflow-hidden",
2449+
!showFullContent && isTruncated && "max-h-[300px]"
2450+
)}>
2451+
<pre className="text-sm font-mono text-foreground/90 whitespace-pre-wrap">
2452+
{previewContent}
2453+
</pre>
2454+
{!showFullContent && isTruncated && (
2455+
<div className="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-muted/30 to-transparent pointer-events-none" />
2456+
)}
2457+
</div>
2458+
</div>
2459+
</div>
2460+
)}
2461+
</div>
2462+
) : (
2463+
<div className="rounded-lg border bg-background/50 backdrop-blur-sm overflow-hidden">
2464+
<div className="px-3 py-2">
2465+
<div className="flex items-center gap-2 text-muted-foreground">
2466+
<Info className="h-4 w-4" />
2467+
<span className="text-sm">No content returned</span>
2468+
</div>
2469+
</div>
2470+
</div>
2471+
)}
2472+
</div>
2473+
);
2474+
};

0 commit comments

Comments
 (0)