+
+ {open && expandable ? (
+
+
+ {round.query ? (
+
{round.query}
+ ) : null}
+ {shown.length > 0 ? (
+
+ ) : null}
+ {hidden > 0 ? (
+
+ {t("chat.webSearchMore", { count: hidden })}
+
+ ) : null}
+
+ ) : null}
+
+ );
+}, (previous, next) =>
+ previous.round === next.round &&
+ previous.streaming === next.streaming &&
+ previous.onUserInteraction === next.onUserInteraction,
+);
diff --git a/apps/desktop/src/lib/assistant-turns.ts b/apps/desktop/src/lib/assistant-turns.ts
index 86fbc4c439..60cd553740 100644
--- a/apps/desktop/src/lib/assistant-turns.ts
+++ b/apps/desktop/src/lib/assistant-turns.ts
@@ -1,8 +1,10 @@
import type {
ContextCompactionMark,
+ HostedSearchRound,
MessageUsage,
UiMessage,
} from "@pi-desktop/shared";
+import { hostedSearchRounds } from "@pi-desktop/shared";
import { isDelegationStartTool } from "./tool-display";
export type AssistantActivityItem =
@@ -12,6 +14,12 @@ export type AssistantActivityItem =
message: UiMessage;
/** Present on a `Task` call: what the delegate it spawned did. */
delegate?: SubagentRun;
+ }
+ | {
+ kind: "hostedSearch";
+ message: UiMessage;
+ /** One provider search round of the message; each round is a row. */
+ round: HostedSearchRound;
};
/** One row a delegate produced, in the order the delegate produced it. */
@@ -64,6 +72,7 @@ function isVisibleMessage(message: UiMessage): boolean {
message.role === "assistant" &&
!(message.content || "").trim() &&
!messageThinking(message) &&
+ !message.hostedSearch &&
!message.error
);
}
@@ -241,6 +250,9 @@ export function buildTranscriptEntries(
const current = ensureTurn(message);
const thinking = messageThinking(message);
if (thinking) pushActivity({ kind: "thinking", message });
+ for (const round of hostedSearchRounds(message.hostedSearch)) {
+ pushActivity({ kind: "hostedSearch", message, round });
+ }
if ((message.content || "").trim() || !thinking || message.error) {
current.parts.push({ kind: "message", message });
if (!current.anchorId && (message.content || "").trim()) {
diff --git a/apps/desktop/src/styles/messages.css b/apps/desktop/src/styles/messages.css
index 399baa3feb..7493b52b55 100644
--- a/apps/desktop/src/styles/messages.css
+++ b/apps/desktop/src/styles/messages.css
@@ -2982,3 +2982,55 @@
display: inline-flex;
color: var(--ds-error);
}
+
+/* Provider-hosted web search round. The row reuses the tool-row idiom; only
+ * the body payload needs its own rules. */
+.hosted-search-query {
+ margin: 2px 0 6px;
+ color: var(--ds-text-secondary);
+ font-size: var(--text-sm);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.hosted-search-sources {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.hosted-search-sources a {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 6px;
+ max-width: 100%;
+ min-width: 0;
+ color: var(--ds-text-primary);
+ font-size: var(--text-sm);
+ text-decoration: none;
+}
+
+.hosted-search-sources a:hover {
+ text-decoration: underline;
+}
+
+.hosted-search-source-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.hosted-search-source-host {
+ color: var(--ds-text-tertiary);
+ flex: none;
+}
+
+.hosted-search-more {
+ display: inline-block;
+ margin-top: 4px;
+ color: var(--ds-text-tertiary);
+ font-size: var(--text-sm);
+}
diff --git a/apps/desktop/test/transcript-disclosure-reading.test.mjs b/apps/desktop/test/transcript-disclosure-reading.test.mjs
index 15ac5f40a1..fdeeec38ac 100644
--- a/apps/desktop/test/transcript-disclosure-reading.test.mjs
+++ b/apps/desktop/test/transcript-disclosure-reading.test.mjs
@@ -54,8 +54,8 @@ test("every manual title hands over the element the reader clicked", () => {
);
assert.equal(
toolHeaders?.length,
- 2,
- "the thinking row and the tool row must both anchor their own header",
+ 3,
+ "the thinking row, the tool row and the hosted search row must each anchor their own header",
);
assert.match(transcript, /onCollapse=\{collapseDisclosure\}/);
const collapseWrappers = transcript.match(
@@ -63,8 +63,8 @@ test("every manual title hands over the element the reader clicked", () => {
);
assert.equal(
collapseWrappers?.length,
- 2,
- "the thinking row and the tool row must both anchor when their rail collapses them",
+ 3,
+ "the thinking row, the tool row and the hosted search row must each anchor when their rail collapses them",
);
});
diff --git a/crates/host-core/src/plugin_sessions.rs b/crates/host-core/src/plugin_sessions.rs
index 38862f0eec..fd09e435a4 100644
--- a/crates/host-core/src/plugin_sessions.rs
+++ b/crates/host-core/src/plugin_sessions.rs
@@ -255,6 +255,7 @@ fn parse_message(
is_error: None,
parent_tool_call_id: None,
agent_name: None,
+ hosted_search: None,
session_message: None,
})
}
diff --git a/crates/host-core/src/plugins/providers.rs b/crates/host-core/src/plugins/providers.rs
index 5c053fd766..d55a1954cc 100644
--- a/crates/host-core/src/plugins/providers.rs
+++ b/crates/host-core/src/plugins/providers.rs
@@ -145,6 +145,7 @@ pub(crate) fn declared_providers(manifest: &PluginManifest) -> Vec