Skip to content
Draft
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
71 changes: 70 additions & 1 deletion components/AgentChat/ActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useMemo, useState, type ComponentType } from 'react';
import {
AppWindow,
Ban,
BookOpen,
Brain,
Expand All @@ -23,12 +24,15 @@ import { Loader } from '@/components/ui/Loader';
import { cn } from '@/utils/styles';
import { MarkdownMessage } from './MarkdownMessage';
import { CodeExecutionPanel } from './CodeExecutionPanel';
import { ArtifactCard } from './artifacts/ArtifactCard';
import { useArtifactViewer } from './artifacts/ArtifactViewer';
import { answerRevealKey, isRevealable, narrationRevealKey } from '@/hooks/useTextReveal';
import type {
ActivityCallStatus,
ChatFeedItem,
ChatActivitySource,
ChatToolCallActivity,
ChatToolDraftActivity,
} from '@/types/agentChat';

/**
Expand All @@ -47,6 +51,9 @@ const TOOL_ICONS: Record<string, ComponentType<{ className?: string }>> = {
get_work_fulltext: FileSearch,
code_execution: SquareTerminal,
bash_code_execution: SquareTerminal,
create_artifact: AppWindow,
update_artifact: AppWindow,
read_artifact: AppWindow,
};

/**
Expand Down Expand Up @@ -151,6 +158,8 @@ const TEXT_SHINE = cn(
*/
function ToolCallRow({ call }: { readonly call: ChatToolCallActivity }) {
const ToolIcon = TOOL_ICONS[call.tool] ?? Wrench;
const viewer = useArtifactViewer();
const artifact = call.artifact;

return (
<div className="flex items-start gap-2">
Expand All @@ -169,9 +178,18 @@ function ToolCallRow({ call }: { readonly call: ChatToolCallActivity }) {
{humanizeLabel(call.label)}
</span>
</div>
{call.detail && (
{/* An artifact's card names it, so its title isn't repeated as detail. */}
{call.detail && !artifact && (
<p className="mt-1.5 break-words leading-relaxed text-gray-500">{call.detail}</p>
)}
{artifact && (
<ArtifactCard
title={artifact.title}
kind={artifact.kind}
caption={`Version ${artifact.version}`}
onOpen={viewer ? () => viewer.openArtifact(artifact) : undefined}
/>
)}
{call.code_execution && (
<CodeExecutionPanel execution={call.code_execution} tool={call.tool} />
)}
Expand All @@ -181,6 +199,54 @@ function ToolCallRow({ call }: { readonly call: ChatToolCallActivity }) {
);
}

/**
* An artifact the agent is writing: its label with the sweep, and a card
* that opens the viewer on the content as it streams. The content is a page
* or a program, not prose, so it never unfolds into the feed.
*/
function ArtifactDraftRow({
draft,
streaming,
executionId,
}: {
readonly draft: ChatToolDraftActivity & { id?: string };
readonly streaming: boolean;
readonly executionId?: number;
}) {
const viewer = useArtifactViewer();
const lines = draft.text ? draft.text.split('\n').length : 0;
const itemId = draft.id;
return (
<div className="flex items-start gap-2">
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 font-medium text-gray-800">
<AppWindow className="h-3.5 w-3.5 shrink-0 text-gray-400" aria-hidden="true" />
<span
className={cn(
'min-w-0 truncate',
streaming && cn('[--shine:theme(colors.gray.800)]', TEXT_SHINE)
)}
>
{humanizeLabel(draft.label)}
</span>
</div>
<ArtifactCard
title={draft.artifact?.title || 'Untitled artifact'}
kind={draft.artifact?.kind}
caption={lines === 1 ? '1 line' : `${lines} lines`}
live={streaming}
onOpen={
viewer && executionId != null && itemId
? () => viewer.openDraft(executionId, itemId)
: undefined
}
/>
</div>
</div>
);
}

/**
* Markdown markers dropped for the collapsed one-line preview, where markup
* can't render and would read as clutter. Underscores stay: intraword `_`
Expand Down Expand Up @@ -399,6 +465,9 @@ function ActivityItemBody({
/>
);
}
if (item.type === 'tool_draft' && item.tool === 'create_artifact') {
return <ArtifactDraftRow draft={item} streaming={streaming} executionId={executionId} />;
}
if (item.type === 'tool_draft') {
// Coloured and iconed like the tool row it becomes, so the draft and the
// call it turns into read as one step rather than two.
Expand Down
111 changes: 57 additions & 54 deletions components/AgentChat/ChatTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { answerRevealKey, isRevealable } from '@/hooks/useTextReveal';
import { ExecutionProgress } from './ExecutionProgress';
import { PendingThinkingRow } from './ActivityFeed';
import { RoutedModelNote } from './RoutedModelNote';
import { ArtifactViewerProvider } from './artifacts/ArtifactViewer';

type TranscriptEntry =
| { key: string; kind: 'user'; message: ChatMessage }
Expand Down Expand Up @@ -207,61 +208,63 @@ export function ChatTranscript({
// Turns are separated by whitespace alone now that neither the assistant
// answer nor its tool activity carries a card, so the gap between them has
// to be larger than the gap within one turn.
<div className="space-y-5">
{entries.map((entry) => {
switch (entry.kind) {
case 'user':
return (
<UserBubble
key={entry.key}
text={entry.message.content}
attachments={entry.message.attachments}
/>
);
case 'pending-user':
// Two siblings, not one wrapper: the echo stands in for a user
// entry and the execution entry about to follow it, so it has to
// take the same between-entry gap that pair will. Boxed together
// they took the within-turn 12px where the real pair takes 20px,
// and the placeholder dropped 8px the moment the refetch landed.
return (
<Fragment key={entry.key}>
<UserBubble text={entry.text} attachments={entry.attachments} />
<PendingThinkingRow />
</Fragment>
);
case 'execution':
return (
<div key={entry.key} className="space-y-3">
<ExecutionProgress
execution={entry.execution}
failureActions={renderFailureActions?.(entry.execution)}
<ArtifactViewerProvider chat={chat}>
<div className="space-y-5">
{entries.map((entry) => {
switch (entry.kind) {
case 'user':
return (
<UserBubble
key={entry.key}
text={entry.message.content}
attachments={entry.message.attachments}
/>
{entry.execution.routed_model && (
<RoutedModelNote
model={entry.execution.model}
routedModel={entry.execution.routed_model}
);
case 'pending-user':
// Two siblings, not one wrapper: the echo stands in for a user
// entry and the execution entry about to follow it, so it has to
// take the same between-entry gap that pair will. Boxed together
// they took the within-turn 12px where the real pair takes 20px,
// and the placeholder dropped 8px the moment the refetch landed.
return (
<Fragment key={entry.key}>
<UserBubble text={entry.text} attachments={entry.attachments} />
<PendingThinkingRow />
</Fragment>
);
case 'execution':
return (
<div key={entry.key} className="space-y-3">
<ExecutionProgress
execution={entry.execution}
failureActions={renderFailureActions?.(entry.execution)}
/>
)}
{entry.answer && (
<AssistantBubble
content={entry.answer.content}
revealKey={
isRevealable(answerRevealKey(entry.execution.id))
? answerRevealKey(entry.execution.id)
: null
}
/>
)}
{renderExecutionExtra?.(entry.execution)}
</div>
);
case 'assistant':
return <AssistantBubble key={entry.key} content={entry.message.content} />;
default:
return null;
}
})}
</div>
{entry.execution.routed_model && (
<RoutedModelNote
model={entry.execution.model}
routedModel={entry.execution.routed_model}
/>
)}
{entry.answer && (
<AssistantBubble
content={entry.answer.content}
revealKey={
isRevealable(answerRevealKey(entry.execution.id))
? answerRevealKey(entry.execution.id)
: null
}
/>
)}
{renderExecutionExtra?.(entry.execution)}
</div>
);
case 'assistant':
return <AssistantBubble key={entry.key} content={entry.message.content} />;
default:
return null;
}
})}
</div>
</ArtifactViewerProvider>
);
}
51 changes: 51 additions & 0 deletions components/AgentChat/artifacts/ArtifactCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client';

import { ChevronRight } from 'lucide-react';
import { cn } from '@/utils/styles';
import { artifactKindInfo } from './artifactKinds';

interface ArtifactCardProps {
readonly title: string;
readonly kind: string | undefined;
/** The second line: the version, or how far a draft has got. */
readonly caption: string;
readonly live?: boolean;
/** Absent where no viewer is mounted; the card is then a plain label. */
readonly onOpen?: () => void;
}

/** An artifact in the activity feed, opening it in the viewer. */
export function ArtifactCard({ title, kind, caption, live = false, onOpen }: ArtifactCardProps) {
const { icon: Icon, label } = artifactKindInfo(kind);
const body = (
<>
<span
className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-gray-50',
live && 'motion-safe:animate-pulse'
)}
>
<Icon className="h-4 w-4 text-gray-500" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-gray-900">{title}</span>
<span className="block truncate text-xs text-gray-500">
{label} · {caption}
</span>
</span>
{onOpen && <ChevronRight className="h-4 w-4 shrink-0 text-gray-400" aria-hidden="true" />}
</>
);
const className =
'mt-2 flex w-full max-w-sm items-center gap-3 rounded-lg border border-gray-200 bg-white px-3 py-2 text-left text-sm';
if (!onOpen) return <div className={className}>{body}</div>;
return (
<button
type="button"
onClick={onOpen}
className={cn(className, 'transition-colors hover:border-gray-300 hover:bg-gray-50')}
>
{body}
</button>
);
}
61 changes: 61 additions & 0 deletions components/AgentChat/artifacts/ArtifactFrame.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use client';

import { useEffect, useMemo, useRef, useState } from 'react';
import { TriangleAlert } from 'lucide-react';
import { cn } from '@/utils/styles';
import { ARTIFACT_ERROR_MESSAGE, buildArtifactDocument } from './artifactDocument';

interface ArtifactFrameProps {
readonly kind: string;
readonly content: string;
readonly title: string;
readonly className?: string;
}

/**
* An artifact running in its sandbox (see `artifactDocument`). Errors the page
* reports are shown over its bottom edge; any other message from it is
* ignored, and only a message from this frame's own window counts.
*/
export function ArtifactFrame({ kind, content, title, className }: ArtifactFrameProps) {
const frameRef = useRef<HTMLIFrameElement>(null);
const srcDoc = useMemo(() => buildArtifactDocument(kind, content), [kind, content]);
const [error, setError] = useState<{ srcDoc: string | null; message: string } | null>(null);

useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.source == null || event.source !== frameRef.current?.contentWindow) return;
const data = event.data as { type?: unknown; message?: unknown } | null;
if (data?.type !== ARTIFACT_ERROR_MESSAGE || typeof data.message !== 'string') return;
setError({ srcDoc, message: data.message });
};
window.addEventListener('message', onMessage);

Check failure on line 32 in components/AgentChat/artifacts/ArtifactFrame.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Verify the origin of the received message.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaGjRlhrHx0kdB2OStAO&open=AaGjRlhrHx0kdB2OStAO&pullRequest=1270
return () => window.removeEventListener('message', onMessage);
}, [srcDoc]);

if (srcDoc == null) return null;
// An error belongs to the document that raised it; a new version starts clean.
const shownError = error?.srcDoc === srcDoc ? error.message : null;

return (
<div className={cn('relative h-full w-full bg-white', className)}>
<iframe
ref={frameRef}
// Scripts only: no same-origin access, top navigation, popups or forms.
sandbox="allow-scripts"
srcDoc={srcDoc}
title={title}
referrerPolicy="no-referrer"
className="h-full w-full border-0"
/>
{shownError && (
<div className="absolute inset-x-3 bottom-3 flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 shadow-sm">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<p className="min-w-0 break-words">
<span className="font-medium">This artifact hit an error:</span> {shownError}
</p>
</div>
)}
</div>
);
}
Loading
Loading