Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
Daniel Han
2c43a0fbfb Studio: cap generated-image overlay to 520px x min(70vh, 620px)
Previous overlay used `max-h-full max-w-full object-contain`, which fills
whatever flex area is available. On large viewports that scales a
generated image past its intrinsic resolution and turns it soft.

Explicit caps: `h-auto`, `w-auto`, `max-w-[520px]`, `max-h-[min(70vh, 620px)]`.
Aspect ratio preserved, only downscales. Wrapped the image in an
`inline-block` relative box so the download/close buttons anchor to the
image's actual top-right corner instead of the wider flex container.
2026-05-26 13:05:36 +00:00
pre-commit-ci[bot]
1b116d2eca [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-26 12:54:17 +00:00
Daniel Han
9807cdf039 Studio: surface per-call web_search sources, full shell command, exit_code, transparent image overlay
Backend (external_provider.py):
- Request `include=[web_search_call.action.sources, web_search_call.results]`
  so each web_search card has its consulted URLs and (for reasoning
  models) the search-result snippets attached. Format them as the
  Title/URL/Snippet blocks the frontend's source-pill parser already
  understands, so every search card now surfaces real sources instead
  of repeating the trigger label.
- _format_shell_output now always emits exit_code (not just non-zero),
  reads stdout from `stdout`/`text`/`content` for tolerance across
  shell_call_output revisions, and dumps the raw entry dict as a
  fallback when no recognised text fields are present so the user
  still sees what OpenAI sent. Drops the literal "(no output)" string
  in favour of an empty result; the frontend renders a friendlier
  placeholder.

Frontend:
- tool-ui-code-execution.tsx: expanded card now renders the full
  untruncated command in its own labelled block above the output, so
  long heredocs clipped in the trigger are reachable. Empty output is
  rendered as italic "Command completed with no output." instead of
  showing nothing.
- tool-ui-web-search.tsx: removed the <pre> fallback that just echoed
  the trigger label back; non-search cards now link to the page URL
  when no sources are present.
- thread.tsx: image overlay container is now transparent (no muted
  background, no border ring); the image itself carries the rounded
  corners and the buttons float over its top-right corner so no
  yellow frame appears around narrow images.

Tests: 2 new web_search source-formatting tests; 66/66 pass.
2026-05-26 12:53:58 +00:00
pre-commit-ci[bot]
0894a1fad8 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-26 12:36:57 +00:00
Daniel Han
42159249ea Studio: agentic web_search action variants + image overlay polish (OpenAI)
Backend: dispatch OpenAI Responses web_search_call on action.type
({search, open_page, find_in_page}) so agentic gpt-5.x calls render
meaningful per-card labels (`Read <url>`, `Find "<pattern>" in <url>`)
instead of empty quotes. Probe action.queries[0]/item.query as
fallbacks for older shapes. Backfill query from any prior event for the
same id.

Frontend: web-search card renders all three action variants in both
collapsed trigger and running states; falls back to `Searching...` when
nothing is known. Defensive scrubber in MarkdownText strips any
leftover U+E200/U+E201/U+E202 citation markers that survive the
backend rewriter (e.g. SSE dropped before the end-of-stream flush).

Image overlay: download + close buttons enlarged to size-10 with green
primary styling so they match the "Type edits below, then send" pill.
Image generation Show more/less now uses ChevronRight/Down icons to
match the other tool cards.

Tests: 3 new web_search action-variant tests; 55/55 pass.
2026-05-26 12:35:52 +00:00
7 changed files with 406 additions and 87 deletions

View file

@ -243,6 +243,89 @@ def _split_pending_citation_tail(text: str) -> tuple[str, str]:
return text[:last_open], text[last_open:]
def _extract_web_search_action(item: dict[str, Any]) -> dict[str, Any]:
"""Normalise web_search_call.action across variants. gpt-5.x agentic
search emits action.type in {search, open_page, find_in_page}; older
variants may put query at item.query or use action.queries[0].
Returns only non-empty keys among query/url/pattern/action_type.
"""
if not isinstance(item, dict):
return {}
action = item.get("action") if isinstance(item.get("action"), dict) else {}
atype = action.get("type") if isinstance(action.get("type"), str) else ""
query = action.get("query") if isinstance(action.get("query"), str) else ""
if not query:
# Fallbacks: action.queries[0], item.query, item.queries[0].
for src in (action.get("queries"), item.get("queries")):
if isinstance(src, list) and src and isinstance(src[0], str) and src[0]:
query = src[0]
break
if not query and isinstance(item.get("query"), str):
query = item["query"]
url = action.get("url") if isinstance(action.get("url"), str) else ""
pattern = action.get("pattern") if isinstance(action.get("pattern"), str) else ""
out: dict[str, Any] = {}
if query:
out["query"] = query
if url:
out["url"] = url
if pattern:
out["pattern"] = pattern
if atype:
out["action_type"] = atype
return out
def _format_web_search_per_call_sources(results: Any, sources: Any) -> str:
"""Format per-call sources as the Title/URL/Snippet block the
frontend's parseSearchResults expects. Prefers `results` (snippet
bearing, reasoning models) then `action.sources` (urls only).
Returns "" when neither is populated.
"""
blocks: list[str] = []
if isinstance(results, list):
for r in results:
if not isinstance(r, dict):
continue
url = r.get("url") if isinstance(r.get("url"), str) else ""
if not url:
continue
title = r.get("title") if isinstance(r.get("title"), str) else url
snippet = r.get("snippet") or r.get("text") or ""
entry = f"Title: {title}\nURL: {url}"
if isinstance(snippet, str) and snippet:
entry += f"\nSnippet: {snippet}"
blocks.append(entry)
if not blocks and isinstance(sources, list):
for s in sources:
url = ""
if isinstance(s, str):
url = s
elif isinstance(s, dict):
url = s.get("url") if isinstance(s.get("url"), str) else ""
if not url:
continue
blocks.append(f"Title: {url}\nURL: {url}")
return "\n---\n".join(blocks)
def _web_search_card_text(args: dict[str, Any]) -> str:
"""Single-line summary for the per-card tool_end result."""
if not args:
return ""
q, u, p = args.get("query") or "", args.get("url") or "", args.get("pattern") or ""
atype = args.get("action_type") or ""
if atype == "open_page" and u:
return f"Read: {u}"
if atype == "find_in_page" and u:
return f"Find {p!r} in {u}" if p else f"Find in {u}"
if q:
return f"Searching: {q}"
if u:
return f"Read: {u}"
return ""
class _AnthropicThinkingSpec(NamedTuple):
prefixes: tuple[str, ...]
kind: Literal["adaptive", "manual"]
@ -2185,7 +2268,7 @@ class ExternalProviderClient:
parts.append(f"--- stderr ---\n{stderr}")
if isinstance(return_code, int) and return_code != 0:
parts.append(f"return_code: {return_code}")
return "\n".join(parts) if parts else "(no output)"
return "\n".join(parts) if parts else ""
if inner_type == "text_editor_code_execution_result":
# view: file content; create: is_file_update flag;
# str_replace: diff `lines` list. The matching
@ -3233,6 +3316,14 @@ class ExternalProviderClient:
tools_array.append(_openai_image_generation_tool())
if tools_array:
body["tools"] = tools_array
# Opt into the per-call source list (action.sources) and the
# per-call snippet list (results, reasoning models only) so
# each web_search card can surface what was actually consulted.
if "web_search" in enabled_tools:
body["include"] = [
"web_search_call.action.sources",
"web_search_call.results",
]
url = f"{self.base_url}/responses"
completion_id = f"chatcmpl-openai-{model.replace('/', '-')}"
@ -3267,6 +3358,13 @@ class ExternalProviderClient:
attempt_body["tools"] = tools_array_attempt
else:
attempt_body.pop("tools", None)
if "web_search" in enabled_tools:
attempt_body["include"] = [
"web_search_call.action.sources",
"web_search_call.results",
]
else:
attempt_body.pop("include", None)
return attempt_body
def _is_openai_container_expired_error(error_text: str) -> bool:
@ -3468,43 +3566,52 @@ class ExternalProviderClient:
return f"data: {_json.dumps(chunk)}"
def _format_shell_output(output: Any) -> str:
"""Render an OpenAI `shell_call_output.output` list
as the preformatted text payload the frontend's
CodeExecutionToolUI displays inside a <pre>. Each
entry has stdout/stderr/outcome concatenate them
with a separator block per entry and append
`return_code` / `(timeout)` annotations only when
they convey information beyond "succeeded".
"""Render OpenAI `shell_call_output.output` for the
CodeExecutionToolUI <pre>. Each entry has
stdout/stderr/outcome (canonical) or `text`/`content`
(older shapes). When the entry contains no
recognised text fields at all, dump the raw JSON so
the user can see what OpenAI actually returned.
"""
if not isinstance(output, list):
return ""
parts: list[str] = []
for entry in output:
if not isinstance(entry, dict):
if isinstance(entry, str) and entry:
parts.append(entry)
continue
stdout = entry.get("stdout") or ""
stdout = (
entry.get("stdout")
or entry.get("text")
or entry.get("content")
or ""
)
stderr = entry.get("stderr") or ""
outcome = entry.get("outcome") or {}
chunk_parts: list[str] = []
if stdout:
if isinstance(stdout, str) and stdout:
chunk_parts.append(stdout)
if stderr:
if isinstance(stderr, str) and stderr:
chunk_parts.append(f"--- stderr ---\n{stderr}")
if isinstance(outcome, dict):
outcome_type = outcome.get("type")
if outcome_type == "exit":
exit_code = outcome.get("exit_code")
if isinstance(exit_code, int) and exit_code != 0:
chunk_parts.append(f"return_code: {exit_code}")
if isinstance(exit_code, int):
chunk_parts.append(f"exit_code: {exit_code}")
elif outcome_type == "timeout":
chunk_parts.append("(timeout)")
if not chunk_parts:
# Unknown shape: surface the raw dict so the
# user can see what OpenAI actually returned.
try:
chunk_parts.append(_json.dumps(entry, indent = 2))
except (TypeError, ValueError):
pass
if chunk_parts:
parts.append("\n".join(chunk_parts))
return (
"\n--- next command ---\n".join(parts)
if parts
else "(no output)"
)
return "\n--- next command ---\n".join(parts)
def _record_url_citation(payload: dict[str, Any]) -> None:
"""Append a url_citation onto the shared all_url_citations
@ -3844,45 +3951,56 @@ class ExternalProviderClient:
yield _chunk_with_text(summary_text)
reasoning_emitted = True
elif item.get("type") == "web_search_call":
# done is the canonical place to read the
# query, so emit both tool_start and tool_end
# here. Frontend then renders a card per call
# with the proper "Searching: <query>" label.
# Citations are aggregated separately and the
# *last* call's result is overwritten at
# response.completed with the citation list
# (so the source-pill extraction at message
# tail surfaces them once).
# Dispatch on action.type so open_page /
# find_in_page render url+pattern instead
# of an empty query card. Last call's
# result is overwritten with citations at
# response.completed.
item_id = item.get("id", "") or (
f"ws_{len(web_search_calls)}"
)
action = item.get("action")
query = (
action.get("query", "")
if isinstance(action, dict)
else ""
)
web_search_calls[item_id] = {"query": query}
args = _extract_web_search_action(item)
# Backfill query from any prior event for this id.
if not args.get("query"):
prior = web_search_calls.get(item_id) or {}
prior_q = (
prior.get("query")
if isinstance(prior, dict)
else ""
)
if isinstance(prior_q, str) and prior_q:
args["query"] = prior_q
web_search_calls[item_id] = dict(args)
yield _emit_tool_event(
{
"type": "tool_start",
"tool_name": "web_search",
"tool_call_id": item_id,
"arguments": (
{"query": query} if query else {}
),
"arguments": args,
}
)
# Per-card text; last call gets overwritten
# with citations at response.completed.
per_call_result = (
f"Searching: {query}" if query else ""
# Per-call sources: prefer the snippet-bearing
# `results` list (reasoning models only), then
# fall back to `action.sources` URLs. Formatted
# as the Title/URL/Snippet block the frontend
# parser already understands.
action_obj = (
item.get("action")
if isinstance(item.get("action"), dict)
else {}
) or {}
per_call_sources = (
_format_web_search_per_call_sources(
item.get("results"),
action_obj.get("sources"),
)
)
yield _emit_tool_event(
{
"type": "tool_end",
"tool_call_id": item_id,
"result": per_call_result,
"result": per_call_sources
or _web_search_card_text(args),
}
)
elif item.get("type") == "shell_call":

View file

@ -200,6 +200,139 @@ def test_web_search_empty_query_falls_back_to_empty_result(monkeypatch):
assert ends[0]["result"] == ""
def test_web_search_open_page_action_renders_url(monkeypatch):
"""gpt-5.x agentic search emits `open_page` with a url (no query)."""
sse_events = [
{
"type": "response.output_item.done",
"item": {
"type": "web_search_call",
"id": "ws_open",
"action": {
"type": "open_page",
"url": "https://en.wikipedia.org/wiki/Tiger",
},
},
},
{"type": "response.completed", "response": {}},
]
lines = _drive_stream(sse_events, ["web_search"], monkeypatch)
events = _tool_events(lines)
starts = [e for e in events if e["type"] == "tool_start"]
ends = [e for e in events if e["type"] == "tool_end"]
assert starts[0]["arguments"]["url"] == "https://en.wikipedia.org/wiki/Tiger"
assert starts[0]["arguments"]["action_type"] == "open_page"
assert "Read: https://en.wikipedia.org/wiki/Tiger" in ends[0]["result"]
def test_web_search_find_in_page_action_renders_url_and_pattern(monkeypatch):
"""`find_in_page` actions surface both url and pattern to the card."""
sse_events = [
{
"type": "response.output_item.done",
"item": {
"type": "web_search_call",
"id": "ws_find",
"action": {
"type": "find_in_page",
"url": "https://en.wikipedia.org/wiki/Tiger",
"pattern": "population",
},
},
},
{"type": "response.completed", "response": {}},
]
lines = _drive_stream(sse_events, ["web_search"], monkeypatch)
events = _tool_events(lines)
starts = [e for e in events if e["type"] == "tool_start"]
ends = [e for e in events if e["type"] == "tool_end"]
assert starts[0]["arguments"]["url"] == "https://en.wikipedia.org/wiki/Tiger"
assert starts[0]["arguments"]["pattern"] == "population"
assert starts[0]["arguments"]["action_type"] == "find_in_page"
assert "population" in ends[0]["result"]
assert "en.wikipedia.org" in ends[0]["result"]
def test_web_search_action_queries_plural_falls_back(monkeypatch):
"""`action.queries[0]` is used when `action.query` is absent (older shape)."""
sse_events = [
{
"type": "response.output_item.done",
"item": {
"type": "web_search_call",
"id": "ws_plural",
"action": {"queries": ["renewable energy 2026"]},
},
},
{"type": "response.completed", "response": {}},
]
lines = _drive_stream(sse_events, ["web_search"], monkeypatch)
events = _tool_events(lines)
starts = [e for e in events if e["type"] == "tool_start"]
ends = [e for e in events if e["type"] == "tool_end"]
assert starts[0]["arguments"]["query"] == "renewable energy 2026"
assert ends[0]["result"] == "Searching: renewable energy 2026"
def test_web_search_per_call_results_formatted_as_source_blocks(monkeypatch):
"""`results` array (reasoning models) is formatted into Title/URL/Snippet blocks."""
sse_events = [
{
"type": "response.output_item.done",
"item": {
"type": "web_search_call",
"id": "ws_r",
"action": {"type": "search", "query": "tiger ranking"},
"results": [
{
"url": "https://a.example/1",
"title": "Tigers",
"snippet": "Big cats",
},
{"url": "https://b.example/2", "title": "Lion stats"},
],
},
},
{"type": "response.completed", "response": {}},
]
lines = _drive_stream(sse_events, ["web_search"], monkeypatch)
events = _tool_events(lines)
ends = [e for e in events if e["type"] == "tool_end"]
body = ends[0]["result"]
assert "Title: Tigers" in body
assert "URL: https://a.example/1" in body
assert "Snippet: Big cats" in body
assert "Title: Lion stats" in body
def test_web_search_action_sources_url_only_falls_back(monkeypatch):
"""`action.sources` URLs are surfaced when `results` is absent."""
sse_events = [
{
"type": "response.output_item.done",
"item": {
"type": "web_search_call",
"id": "ws_s",
"action": {
"type": "search",
"query": "X",
"sources": [
{"type": "url", "url": "https://x.example/1"},
"https://x.example/2",
],
},
},
},
{"type": "response.completed", "response": {}},
]
lines = _drive_stream(sse_events, ["web_search"], monkeypatch)
events = _tool_events(lines)
ends = [e for e in events if e["type"] == "tool_end"]
body = ends[0]["result"]
assert "https://x.example/1" in body
assert "https://x.example/2" in body
# ── shell_call output fallbacks ────────────────────────────────────────

View file

@ -411,9 +411,22 @@ function StreamdownBlock(props: BlockProps) {
}
const AUDIO_PLAYER_RE = /<audio-player\s+src="([^"]+)"\s*\/>/;
// Defensive scrub: strip any OpenAI cite markers that escape the
// backend rewriter (e.g. SSE dropped before end-of-stream flush).
const OPENAI_CITE_MARKER_RE = /cite[^]*/g;
const OPENAI_PUA_ORPHAN_RE = /[]/g;
function scrubOpenAICitationMarkers(text: string): string {
if (!text) return text;
if (!text.includes("") && !text.includes("")) return text;
return text.replace(OPENAI_CITE_MARKER_RE, "").replace(OPENAI_PUA_ORPHAN_RE, "");
}
const MarkdownTextImpl = () => {
const { text, status } = useMessagePartText();
const processedText = useMemo(() => preprocessLaTeX(text), [text]);
const processedText = useMemo(
() => preprocessLaTeX(scrubOpenAICitationMarkers(text)),
[text],
);
const audioMatch = text.match(AUDIO_PLAYER_RE);
if (audioMatch) {

View file

@ -230,14 +230,23 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({
)}
aria-label="Generated image preview"
>
<div className="pointer-events-auto relative flex min-h-0 w-full max-w-[1100px] flex-1 flex-col items-center justify-center gap-3 rounded-3xl bg-muted/10 p-3 ring-1 ring-border/20">
<div className="absolute inset-x-3 top-3 z-10 flex justify-end">
<div className="flex shrink-0 items-center gap-1 rounded-full bg-background/70 p-1 ring-1 ring-border/20 backdrop-blur-sm">
<div className="pointer-events-auto relative flex min-h-0 w-full max-w-[1100px] flex-1 flex-col items-center justify-center gap-3">
<div className="flex min-h-0 flex-1 items-center justify-center">
<div className="relative inline-block">
<img
src={overlay.image}
alt={overlay.title}
// Keep aspect ratio + downscale only: explicit caps so a
// huge viewport doesn't scale a generated image past its
// intrinsic resolution and turn soft.
className="block h-auto max-h-[min(70vh,620px)] w-auto max-w-[520px] rounded-2xl object-contain"
/>
<div className="absolute right-2 top-2 z-10 flex shrink-0 items-center gap-2 rounded-full bg-background/80 p-1.5 ring-1 ring-border/30 backdrop-blur-sm">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 rounded-full"
size="icon"
className="size-10 rounded-full bg-primary/10 text-primary ring-1 ring-primary/30 hover:bg-primary/20 hover:text-primary"
onClick={() =>
downloadImagePart({
image: overlay.image,
@ -246,26 +255,20 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({
}
aria-label="Download generated image"
>
<DownloadIcon className="size-3.5" />
<DownloadIcon className="size-5" strokeWidth={2} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 rounded-full"
size="icon"
className="size-10 rounded-full bg-primary/10 text-primary ring-1 ring-primary/30 hover:bg-primary/20 hover:text-primary"
onClick={closeOverlay}
aria-label="Close generated image preview"
>
<XIcon className="size-3.5" />
<XIcon className="size-5" strokeWidth={2} />
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 items-center justify-center pt-1">
<img
src={overlay.image}
alt={overlay.title}
className="max-h-full max-w-full object-contain"
/>
</div>
</div>
<div
className="w-full max-w-[min(100%,46rem)] shrink-0 text-center"

View file

@ -175,6 +175,12 @@ const CodeExecutionToolUIImpl: ToolCallMessagePartComponent = ({
[resultText],
);
// Surface the full untruncated command in the expanded body so a
// user clicking the row can read the heredoc that was clipped in
// the trigger label.
const showFullCommand = !isRunning && kind === "bash" && !!command && command !== commandLabel;
const emptyOutput = !isRunning && !resultText;
return (
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
<ToolFallbackTrigger
@ -188,16 +194,36 @@ const CodeExecutionToolUIImpl: ToolCallMessagePartComponent = ({
<LoaderIcon className="size-3.5 animate-spin" />
<span>{runningLabel}</span>
</div>
) : resultText ? (
<div>
<div className="flex justify-end">
<CopyBtn text={resultText} />
</div>
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
{displayedResult}
</pre>
) : (
<div className="flex flex-col gap-2">
{showFullCommand ? (
<div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">Command</span>
<CopyBtn text={command} />
</div>
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
{command}
</pre>
</div>
) : null}
{resultText ? (
<div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">Output</span>
<CopyBtn text={resultText} />
</div>
<pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
{displayedResult}
</pre>
</div>
) : emptyOutput ? (
<p className="text-xs italic text-muted-foreground">
Command completed with no output.
</p>
) : null}
</div>
) : null}
)}
</ToolFallbackContent>
</ToolFallbackRoot>
);

View file

@ -6,7 +6,13 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { DownloadIcon, ImageIcon, PencilIcon } from "lucide-react";
import {
ChevronDownIcon,
ChevronRightIcon,
DownloadIcon,
ImageIcon,
PencilIcon,
} from "lucide-react";
import type { CSSProperties, MouseEvent } from "react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { useGeneratedImageOverlay } from "./generated-image-overlay-context";
@ -348,7 +354,7 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
{promptCanExpand ? (
<button
type="button"
className="mt-2 inline-flex text-xs font-medium text-foreground/80 underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-foreground/80 underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
onClick={() =>
setExpandedCaptionPrompt((value) =>
value === captionPrompt ? null : captionPrompt,
@ -356,6 +362,11 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
}
aria-expanded={promptExpanded}
>
{promptExpanded ? (
<ChevronDownIcon className="size-3.5" />
) : (
<ChevronRightIcon className="size-3.5" />
)}
{promptExpanded ? "Show less" : "Show more"}
</button>
) : null}

View file

@ -70,7 +70,11 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
}) => {
const query = (args as { query?: string })?.query ?? "";
const url = ((args as { url?: string })?.url ?? "").trim();
const isUrlFetch = !!url;
const pattern = (args as { pattern?: string })?.pattern ?? "";
const actionType = (args as { action_type?: string })?.action_type ?? "";
// gpt-5.x agentic action.type: search | open_page | find_in_page.
const isFindInPage = actionType === "find_in_page" || (!!url && !!pattern);
const isUrlFetch = !!url && !isFindInPage;
const displayDomain = (() => {
if (!url) return "";
try {
@ -105,11 +109,15 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
<ToolFallbackTrigger
toolName={
isUrlFetch
? displayDomain ? `Read ${displayDomain}` : "Read page"
: query
? `Searched "${query}"`
: "Web Search"
isFindInPage
? pattern
? `Found "${pattern}" in ${displayDomain || "page"}`
: `Find in ${displayDomain || "page"}`
: isUrlFetch
? displayDomain ? `Read ${displayDomain}` : "Read page"
: query
? `Searched "${query}"`
: "Web Search"
}
status={status}
icon={GlobeIcon}
@ -119,9 +127,15 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>
{isUrlFetch
? <>Reading {displayDomain || "page"}&hellip;</>
: <>Searching for &ldquo;{query}&rdquo;&hellip;</>
{isFindInPage
? pattern
? <>Finding &ldquo;{pattern}&rdquo; in {displayDomain || "page"}&hellip;</>
: <>Searching {displayDomain || "page"}&hellip;</>
: isUrlFetch
? <>Reading {displayDomain || "page"}&hellip;</>
: query
? <>Searching for &ldquo;{query}&rdquo;&hellip;</>
: <>Searching&hellip;</>
}
</span>
</div>
@ -140,14 +154,15 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
</Source>
))}
</div>
) : result ? (
<div>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 text-xs">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
</div>
) : url ? (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="break-all text-xs text-primary underline-offset-4 hover:underline"
>
{url}
</a>
) : null}
</ToolFallbackContent>
</ToolFallbackRoot>