Studio: fix Deep Research SSE framing, source counts, and favicon privacy

- Normalize the whole SSE buffer so a CRLF split across transport chunks
  still frames events.
- Count web and document sources together in the activity header so a
  RAG-only run is not shown as zero sources.
- Cap the plan editor at the run's configured maxSteps instead of a
  hard-coded 30.
- Add an allowRemoteIcons opt-out to the sources components and disable
  third-party favicon requests for research sources so visited domains are
  not leaked.
This commit is contained in:
danielhanchen 2026-07-19 11:47:15 +00:00
commit e75a7683a7
4 changed files with 30 additions and 12 deletions

View file

@ -40,14 +40,16 @@ function SourceIcon({
url,
className,
size = 3,
allowRemoteIcons = true,
...props
}: ComponentProps<"span"> & { url: string; size?: number }) {
}: ComponentProps<"span"> & { url: string; size?: number; allowRemoteIcons?: boolean }) {
const [hasError, setHasError] = useState(false);
const domain = extractDomain(url);
const SIZE_CLASSES: Record<number, string> = { 3: "size-3", 4: "size-4", 5: "size-5" };
const sizeClass = SIZE_CLASSES[size] ?? "size-3";
if (hasError) {
// When disabled, render the letter fallback instead of fetching a third-party favicon.
if (hasError || !allowRemoteIcons) {
return (
<span
data-slot="source-icon-fallback"
@ -137,7 +139,10 @@ export interface SourceData {
description?: string;
}
const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
const SourceBadge: FC<{ source: SourceData; allowRemoteIcons?: boolean }> = ({
source,
allowRemoteIcons = true,
}) => {
const domain = extractDomain(source.url);
const displayTitle = source.title || domain;
@ -146,7 +151,7 @@ const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
<HoverCardTrigger asChild>
<span className="inline-block">
<Source href={source.url}>
<SourceIcon url={source.url} />
<SourceIcon url={source.url} allowRemoteIcons={allowRemoteIcons} />
<SourceTitle>{displayTitle}</SourceTitle>
</Source>
</span>
@ -158,7 +163,12 @@ const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
style={{ animation: "none" }}
>
<div className="flex gap-2.5">
<SourceIcon url={source.url} size={4} className="mt-0.5 shrink-0" />
<SourceIcon
url={source.url}
size={4}
className="mt-0.5 shrink-0"
allowRemoteIcons={allowRemoteIcons}
/>
<div className="min-w-0 space-y-1">
<p className="text-sm font-semibold leading-tight truncate">
{source.title || domain}
@ -178,8 +188,9 @@ const SourceBadge: FC<{ source: SourceData }> = ({ source }) => {
// ── Grouped sources with 2-row collapse ─────────────────────
const SourcesGroup: FC<{ sources?: SourceData[] }> = ({
const SourcesGroup: FC<{ sources?: SourceData[]; allowRemoteIcons?: boolean }> = ({
sources: suppliedSources,
allowRemoteIcons = true,
}) => {
const message = useMessage();
const containerRef = useRef<HTMLDivElement>(null);
@ -280,7 +291,7 @@ const SourcesGroup: FC<{ sources?: SourceData[] }> = ({
{sources.map((source) => (
<span key={source.id} className="inline-block">
<Source href={source.url}>
<SourceIcon url={source.url} />
<SourceIcon url={source.url} allowRemoteIcons={allowRemoteIcons} />
<SourceTitle>{source.title || extractDomain(source.url)}</SourceTitle>
</Source>
</span>
@ -291,7 +302,7 @@ const SourcesGroup: FC<{ sources?: SourceData[] }> = ({
{/* Visible container */}
<div className="flex flex-wrap gap-1">
{displayedSources.map((source) => (
<SourceBadge key={source.id} source={source} />
<SourceBadge key={source.id} source={source} allowRemoteIcons={allowRemoteIcons} />
))}
{shouldCollapse && !expanded && (
<button

View file

@ -161,7 +161,9 @@ export async function* streamResearchEvents(
try {
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done }).replace(/\r\n/g, "\n");
buffer += decoder.decode(value, { stream: !done });
// Normalize on the whole buffer so a CRLF split across chunks still frames.
buffer = buffer.replace(/\r\n/g, "\n");
let boundary = buffer.indexOf("\n\n");
while (boundary >= 0) {
const block = buffer.slice(0, boundary);

View file

@ -633,7 +633,7 @@ function PlanReview({ runId }: { runId: string }): ReactElement | null {
<Button
variant="ghost"
size="sm"
disabled={draft.steps.length >= 30}
disabled={draft.steps.length >= (run?.config?.budgets?.maxSteps ?? 30)}
onClick={() => {
setStepKeys((keys) => [
...keys,
@ -787,6 +787,11 @@ export function ResearchActivityPanel({
}
const { run, activities } = session;
const elapsedEnd = run.completedAt ?? elapsedNow ?? run.updatedAt;
// Count web and document sources together so a RAG-only run is not shown as 0.
const documentCount = new Set(
(run.documentSources ?? []).map((source) => source.documentId ?? source.filename),
).size;
const sourceCount = run.sources.length + documentCount;
const allowedDomains = run.config?.websitePolicy?.allowedDomains ?? [];
const blockedDomains = run.config?.websitePolicy?.blockedDomains ?? [];
const websiteLimitLabel = allowedDomains.length
@ -857,7 +862,7 @@ export function ResearchActivityPanel({
</p>
) : null}
<p className="mt-1 text-[10.5px] tabular-nums text-muted-foreground">
{formatElapsed(run.createdAt, elapsedEnd)} · {run.sources.length}{" "}
{formatElapsed(run.createdAt, elapsedEnd)} · {sourceCount}{" "}
sources ·{" "}
{run.steps.filter((step) => step.status === "completed").length}{" "}
actions

View file

@ -106,7 +106,7 @@ export function ResearchMessage(): ReactElement {
markdown={run.report}
className="max-h-none overflow-visible border-0 bg-transparent p-0 text-[15.5px]"
/>
<SourcesGroup sources={sources} />
<SourcesGroup sources={sources} allowRemoteIcons={false} />
<DocumentSourcesGroup sources={documentSources} />
</div>
);