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
43 changes: 43 additions & 0 deletions apps/desktop/src/components/settings/ModelSelectionPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
bindingFromModelInfo,
formatTokenCount,
modelMatchesFilter,
nativeWebSearchSupportedOn,
publishedThinkingLevels,
sortThinkingLevels,
type ModelBinding,
Expand Down Expand Up @@ -183,6 +184,12 @@ export type ModelSelectionPanesProps = {
busy?: boolean;
/** Probe the service's model list now, skipping the edit debounce. */
onReload?: () => void;
/**
* Effective API style of the provider being configured. Gates the native
* web search opt-in: only wires that can carry a provider-hosted search
* tool offer the checkbox at all.
*/
apiStyle?: string;
};

/**
Expand All @@ -196,6 +203,7 @@ export function ModelSelectionPanes({
listTitle,
busy = false,
onReload,
apiStyle,
}: ModelSelectionPanesProps) {
const { t } = useTranslation();
const { rows, models, publishedLevelsById, setModels } = selection;
Expand Down Expand Up @@ -246,6 +254,11 @@ export function ModelSelectionPanes({
if (models.length === 0) setChosenQuery("");
}, [models.length]);

// The hosted web search tool only exists on two wires; on any other
// style the opt-in cannot work, so the checkbox stays present but disabled
// with an explanatory hint instead of silently doing nothing.
const nativeWebSearchWireCapable = nativeWebSearchSupportedOn(apiStyle);

/**
* The chosen list narrows with the discovered list's rule plus the binding's
* alias: a case-insensitive substring match over the id, the alias, and the
Expand Down Expand Up @@ -826,6 +839,36 @@ export function ModelSelectionPanes({
<IconHelp size={13} />
</Tooltip>
</span>
<span className="provider-chosen-delegation">
<label className="provider-chosen-capability">
<input
type="checkbox"
checked={binding.nativeWebSearch === true}
disabled={!nativeWebSearchWireCapable}
onChange={(event) =>
updateBinding(binding.id, {
nativeWebSearch: event.target.checked || undefined,
})
}
/>
<span>{t("settings.nativeWebSearch")}</span>
</label>
<Tooltip
className="provider-chosen-delegation-help"
label={t(
nativeWebSearchWireCapable
? "settings.nativeWebSearchHint"
: "settings.nativeWebSearchUnsupported",
)}
ariaLabel={t(
nativeWebSearchWireCapable
? "settings.nativeWebSearchHint"
: "settings.nativeWebSearchUnsupported",
)}
>
<IconHelp size={13} />
</Tooltip>
</span>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ export function ProviderSetupDialog({
listTitle={t("settings.serviceModels")}
busy={saving}
onReload={discovery.reload}
apiStyle={resolvedApiStyle}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export function VendorAccountDialog({
listTitle={t("settings.accountModels")}
busy={saving}
onReload={discovery.reload}
apiStyle={provider.apiStyle ?? ""}
/>
</div>

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/components/settings/provider-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function copyProviderConfiguration(provider: ProviderPublic, name: string
...(model.supportsImages !== undefined ? { supportsImages: model.supportsImages } : {}),
...(model.supportsDocuments !== undefined ? { supportsDocuments: model.supportsDocuments } : {}),
...(model.availableForSubagents !== undefined ? { availableForSubagents: model.availableForSubagents } : {}),
...(model.nativeWebSearch !== undefined ? { nativeWebSearch: model.nativeWebSearch } : {}),
})),
};
}
12 changes: 12 additions & 0 deletions apps/desktop/src/features/chat/transcript/ActivityGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,14 @@ import { ToolRow } from "./ToolRow";
import { TranscriptSearchContext } from "../../../lib/transcript-search-context";
import { useAppStore } from "../../../stores/app-store";
import { resolveThinkingDisplayMode } from "../../../lib/turn-process";
import { HostedSearchRow } from "./HostedSearchRow";

type Translate = (key: string, options?: Record<string, unknown>) => string;

type ActivityItem = AssistantActivityItem;

export function activityItemDetail(item: ActivityItem): string {
if (item.kind === "hostedSearch") return item.round.query ?? "";
if (item.kind === "thinking") {
// Latest thought line, so a collapsed header reads like a live ticker.
const lines = thinkingText(item.message)
Expand Down Expand Up @@ -169,6 +171,9 @@ export function activityItemsEqual(
if (previous.kind === "tool" && next.kind === "tool") {
return subagentRunsEqual(previous.delegate, next.delegate);
}
if (previous.kind === "hostedSearch" && next.kind === "hostedSearch") {
return previous.round === next.round;
}
return true;
}

Expand Down Expand Up @@ -349,6 +354,13 @@ export const ActivityGroup = memo(function ActivityGroup({
/>
<ReviewChangeCard message={item.message} />
</Fragment>
) : item.kind === "hostedSearch" ? (
<HostedSearchRow
key={`hosted-search-${item.message.id}-${item.round.id}`}
round={item.round}
streaming={isActive && item.message.status === "streaming"}
onUserInteraction={claimDisclosure}
/>
) : (
<ThinkingRow
key={`thinking-${item.message.id}`}
Expand Down
146 changes: 146 additions & 0 deletions apps/desktop/src/features/chat/transcript/HostedSearchRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { memo, useCallback, useId } from "react";
import { useTranslation } from "react-i18next";
import type { HostedSearchRound } from "@pi-desktop/shared";
import {
IconChevronRight,
IconCircleAlert,
IconGlobe,
} from "../../../components/icons";
import { DisclosureCollapseRail, useAutomaticDisclosure } from "./shared";

/**
* One provider-hosted web search round, rendered on the same tool-row idiom
* as thinking and tool calls: icon + name + summary header, chevron
* disclosure, sources in the body. Sources link out as plain text — no
* favicon fetches — so reading a transcript never leaks source hostnames to
* a third party nor renders broken image placeholders (#579).
*/

const HOSTED_SEARCH_PREVIEW_COUNT = 5;

function sourceHost(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return "";
}
}

export const HostedSearchRow = memo(function HostedSearchRow({
round,
streaming,
onUserInteraction,
}: {
round: HostedSearchRound;
streaming: boolean;
onUserInteraction?: () => void;
}) {
const { t } = useTranslation();
const detailsId = useId();
const disclosure = useAutomaticDisclosure(false);
const { open, toggle: toggleDisclosure, collapse: collapseDisclosure } = disclosure;
const titleRef = disclosure.titleRef;
const toggleRow = useCallback(() => {
onUserInteraction?.();
toggleDisclosure();
}, [onUserInteraction, toggleDisclosure]);
const collapseRow = useCallback(() => {
onUserInteraction?.();
collapseDisclosure();
}, [collapseDisclosure, onUserInteraction]);

const searching = streaming && round.status === "searching";
const failed = round.status === "failed";
const sources = round.sources ?? [];
// The opened page leads the body list; dedupe against extracted sources.
const links = [
...(round.url ? [{ url: round.url }] : []),
...sources.filter((source) => source.url !== round.url),
];
const shown = links.slice(0, HOSTED_SEARCH_PREVIEW_COUNT);
const hidden = links.length - shown.length;
const expandable = Boolean(round.query) || links.length > 0;
const summary =
round.query ??
(round.url
? sourceHost(round.url)
: sources.length > 0
? t("chat.webSearchSources", { count: sources.length })
: "");
const name = failed
? t("chat.webSearchFailed")
: searching
? t("chat.webSearching")
: round.kind === "openPage"
? t("chat.webOpenPage")
: round.kind === "findInPage"
? t("chat.webFindInPage")
: t("chat.webSearch");

return (
<div className={`tool-row hosted-search ${open ? "open" : ""}`}>
<button
ref={titleRef}
className="tool-row-header"
aria-expanded={open}
aria-controls={expandable ? detailsId : undefined}
disabled={!expandable}
onClick={toggleRow}
>
<span className="tool-row-icon" aria-hidden>
{failed ? <IconCircleAlert size={15} /> : <IconGlobe size={15} />}
</span>
<span
className={`tool-row-name ${searching ? "running" : ""} ${failed ? "turn-process-error" : ""}`}
>
{name}
</span>
{summary ? <span className="tool-row-summary">{summary}</span> : null}
{expandable ? (
<span className="tool-row-caret" aria-hidden>
<IconChevronRight size={12} />
</span>
) : null}
</button>
{open && expandable ? (
<div className="tool-row-body" id={detailsId}>
<DisclosureCollapseRail
label={t("chat.collapseDetails")}
onCollapse={collapseRow}
/>
{round.query ? (
<div className="hosted-search-query selectable">{round.query}</div>
) : null}
{shown.length > 0 ? (
<ul className="hosted-search-sources">
{shown.map((source) => {
const host = sourceHost(source.url);
return (
<li key={source.url}>
<a href={source.url} target="_blank" rel="noopener noreferrer">
<span className="hosted-search-source-title">
{source.title || host || source.url}
</span>
{host && source.title ? (
<span className="hosted-search-source-host">{host}</span>
) : null}
</a>
</li>
);
})}
</ul>
) : null}
{hidden > 0 ? (
<span className="hosted-search-more">
{t("chat.webSearchMore", { count: hidden })}
</span>
) : null}
</div>
) : null}
</div>
);
}, (previous, next) =>
previous.round === next.round &&
previous.streaming === next.streaming &&
previous.onUserInteraction === next.onUserInteraction,
);
12 changes: 12 additions & 0 deletions apps/desktop/src/lib/assistant-turns.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -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. */
Expand Down Expand Up @@ -64,6 +72,7 @@ function isVisibleMessage(message: UiMessage): boolean {
message.role === "assistant" &&
!(message.content || "").trim() &&
!messageThinking(message) &&
!message.hostedSearch &&
!message.error
);
}
Expand Down Expand Up @@ -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()) {
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/src/styles/messages.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading
Loading