paperclip/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx

617 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type {
CapabilityEvidenceSectionId,
CapabilityThreadItem,
CapabilityThreadTurn,
} from "../../../src/issue-thread/types";
import { InteractionCard, type CapabilityInteractionResponse } from "./InteractionCard";
import { Chip, StatusBadge, Timestamp, formatBytes } from "./primitives";
export interface EvalAssertion {
id: string;
title: string;
description: string;
passed: boolean;
detail: string;
definition: Record<string, unknown>;
}
export function EvalAssertions({ assertions }: { assertions: EvalAssertion[] }) {
const [selected, setSelected] = useState<EvalAssertion | null>(null);
if (assertions.length === 0) return null;
return (
<>
<div className="pit-inline-assertions" aria-label="Eval assertions">
{assertions.map((assertion) => (
<button type="button" key={assertion.id} data-passed={assertion.passed} onClick={() => setSelected(assertion)}>
<strong>{assertion.passed ? "✓ PASS" : "✕ FAIL"} · {assertion.title}</strong>
<span>{assertion.detail}</span>
</button>
))}
</div>
{selected !== null ? (
<div className="pit-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setSelected(null); }}>
<div className="pit-dialog pit-eval-check-dialog" role="dialog" aria-modal="true" aria-labelledby="eval-check-title">
<header className="pit-tool-dialog-head"><div><span className="pit-card-meta">Eval assertion · {selected.id}</span><h2 id="eval-check-title">{selected.title}</h2></div><button type="button" className="pit-icon-button" aria-label="Close assertion" onClick={() => setSelected(null)}>×</button></header>
<p>{selected.description}</p>
<p className="pit-card-meta">Observed result</p><p>{selected.detail}</p>
<p className="pit-card-meta">Original case definition</p><pre className="pit-code">{JSON.stringify(selected.definition, null, 2)}</pre>
</div>
</div>
) : null}
</>
);
}
/** Progressive disclosure budget from contract §3 (T4). */
const VISIBLE_STRIPS = 3;
const STATUS_GLYPH = { ok: "✓", denied: "✕", running: "⏳" } as const;
function MarkdownBody({ children }: { children: string }) {
return (
<div className="pit-card-body pit-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
</div>
);
}
export interface ThreadCallbacks {
onOpenEvidence: (section: CapabilityEvidenceSectionId, recordId: string) => void;
onRespond: (response: CapabilityInteractionResponse) => void;
focusInteractionId: string | null;
}
function ToolStrip({
item,
callbacks,
}: {
item: Extract<CapabilityThreadItem, { kind: "tool_activity" }>;
callbacks: ThreadCallbacks;
}) {
return (
<details
id={item.id}
className="pit-activity-item"
data-thread-item="tool_activity"
data-status={item.status}
data-tool-strip={item.operationId}
>
<summary className="pit-activity-summary">
<span className="pit-activity-glyph" aria-hidden="true">
{STATUS_GLYPH[item.status]}
</span>
<span className="pit-visually-hidden">{item.status}</span>
<span className="pit-activity-operation">{item.operationId}</span>
<span className="pit-activity-description">{item.summary}</span>
<span className="pit-activity-caret" aria-hidden="true">›</span>
</summary>
<div className="pit-activity-detail">
<p className="pit-card-meta">Sanitized tool input and result</p>
<div className="pit-activity-payloads">
<pre className="pit-code">{JSON.stringify(item.input, null, 2)}</pre>
<pre className="pit-code">{JSON.stringify(item.result, null, 2)}</pre>
</div>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View in Evidence
</button>
</div>
</details>
);
}
function ProgressItem({
item,
}: {
item: Extract<CapabilityThreadItem, { kind: "progress_activity" }>;
}) {
return (
<details
id={item.id}
className="pit-activity-item pit-progress-activity"
data-thread-item="progress_activity"
data-activity={item.activity}
data-status={item.status}
open={item.status === "running"}
>
<summary className="pit-activity-summary">
<span className="pit-activity-glyph" aria-hidden="true">
{item.status === "running" ? "⏳" : "✓"}
</span>
<span className="pit-activity-operation">{item.label}</span>
<span className="pit-activity-description">{item.summary}</span>
<span className="pit-activity-caret" aria-hidden="true">›</span>
</summary>
<div className="pit-activity-detail">
{Object.entries(item.details).map(([name, value]) =>
name === "withheld" ? null : (
<div className="pit-activity-field" key={name}>
<span>{name}</span>
<pre className="pit-code">{typeof value === "string" ? value : JSON.stringify(value, null, 2)}</pre>
</div>
),
)}
{Array.isArray(item.details.withheld) ? (
<p className="pit-withheld-note">
Not available here: {item.details.withheld.join(", ")}.
</p>
) : null}
</div>
</details>
);
}
function valueRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function ProviderActivity({ item, callbacks }: {
item: Extract<CapabilityThreadItem, { kind: "provider_activity" }>;
callbacks: ThreadCallbacks;
}) {
const payload = valueRecord(item.payload);
const steps = Array.isArray(payload.steps) ? payload.steps.map(valueRecord) : [];
const children = Array.isArray(payload.children) ? payload.children.map(valueRecord) : [];
const sources = Array.isArray(payload.sources) ? payload.sources.map(valueRecord) : [];
const output = typeof payload.output === "string" ? payload.output : "";
const glyph = item.status === "running" ? "⏳" : item.status === "failed" ? "✕" : item.status === "interrupted" ? "■" : "✓";
return (
<details id={item.id} className="pit-activity-item pit-provider-activity" data-thread-item="provider_activity" data-family={item.family} data-status={item.status} open={item.status === "running"}>
<summary className="pit-activity-summary"><span className="pit-activity-glyph" aria-hidden="true">{glyph}</span><span className="pit-activity-operation">{item.title}</span><span className="pit-activity-description">{item.summary}</span><span className="pit-activity-caret" aria-hidden="true">›</span></summary>
<div className="pit-activity-detail">
{steps.length > 0 ? <ol className="pit-provider-plan">{steps.map((step, index) => <li key={String(step.stepId ?? index)} data-plan-status={String(step.status ?? "pending")}><span aria-hidden="true">{step.status === "completed" ? "✓" : step.status === "blocked" ? "!" : step.status === "in_progress" ? "◐" : "○"}</span>{String(step.body ?? "")}</li>)}</ol> : null}
{children.length > 0 ? <ul className="pit-provider-tree">{children.map((child, index) => <li key={String(child.childId ?? index)}><strong>{String(child.role ?? "Child agent")}</strong><span>{String(child.status ?? "unknown")}</span><small>{String(child.summary ?? "")}</small></li>)}</ul> : null}
{sources.length > 0 ? <ul className="pit-provider-sources">{sources.map((source, index) => { const url = typeof source.url === "string" && /^https?:\/\//.test(source.url) ? source.url : null; return <li key={String(source.sourceId ?? index)}>{url === null ? <span>{String(source.title ?? "Unavailable source")}</span> : <a href={url} target="_blank" rel="noreferrer">{String(source.title ?? url)}</a>}<small>Provider-reported</small></li>; })}</ul> : null}
{output.length > 0 ? <pre className="pit-provider-output" aria-label="Command output">{output}</pre> : null}
{item.family === "model_identity" ? <dl className="pit-provider-fields"><dt>Requested</dt><dd>{String(payload.requestedModel ?? "—")}</dd><dt>Effective</dt><dd>{String(payload.effectiveModel ?? "—")}</dd></dl> : null}
{item.family === "artifact" && typeof payload.reference === "string" ? <button type="button" className="pit-link-button">Open {payload.reference}</button> : null}
<button type="button" className="pit-link-button" onClick={() => callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)}>View in Evidence</button>
</div>
</details>
);
}
function FileDiff({ diff }: { diff: string | null }) {
if (diff === null) return <p className="pit-workspace-binary">Binary or oversized file; textual diff unavailable.</p>;
return (
<pre className="pit-workspace-diff" aria-label="Unified file diff">
{diff.split("\n").map((line, index) => (
<span
key={`${index}:${line}`}
data-diff-line={line.startsWith("+") && !line.startsWith("+++")
? "addition"
: line.startsWith("-") && !line.startsWith("---")
? "deletion"
: line.startsWith("@@")
? "hunk"
: "context"}
>{line || " "}{"\n"}</span>
))}
</pre>
);
}
function WorkspaceChanges({
item,
}: {
item: Extract<CapabilityThreadItem, { kind: "workspace_changes" }>;
}) {
const [expanded, setExpanded] = useState(false);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const files = item.changeSet.files;
const visible = expanded ? files : files.slice(0, 3);
const selected = files.find((file) => file.path === selectedPath) ?? null;
const stats = (file: (typeof files)[number]) => (
<span className="pit-workspace-stats">
{file.additions === null ? null : <span data-stat="add">+{file.additions}</span>}
{file.deletions === null ? null : <span data-stat="delete">-{file.deletions}</span>}
</span>
);
return (
<>
<article id={item.id} className="pit-workspace-card" data-thread-item="workspace_changes">
<header className="pit-workspace-head">
<span className="pit-workspace-icon" aria-hidden="true">▣</span>
<span>
<strong>{item.changeSet.complete ? "Edited" : "Editing"} {files.length} file{files.length === 1 ? "" : "s"}</strong>
<small>{item.changeSet.source === "runner_verified" ? "Verified from workspace" : "Reported by harness"}</small>
</span>
<span className="pit-workspace-total">
{item.changeSet.totals.additions === null ? null : <span data-stat="add">+{item.changeSet.totals.additions}</span>}
{item.changeSet.totals.deletions === null ? null : <span data-stat="delete">-{item.changeSet.totals.deletions}</span>}
</span>
<button type="button" className="pit-workspace-review" onClick={() => setSelectedPath(files[0]?.path ?? null)}>Review</button>
</header>
<div className="pit-workspace-files">
{visible.map((file) => (
<button type="button" key={`${file.operation}:${file.path}`} onClick={() => setSelectedPath(file.path)}>
<span className="pit-workspace-path">
{file.previousPath === null ? file.path : `${file.previousPath} → ${file.path}`}
<small>{file.operation.replace("_", " ")}</small>
</span>
{stats(file)}
</button>
))}
</div>
{files.length > 3 ? (
<button type="button" className="pit-workspace-more" onClick={() => setExpanded((value) => !value)}>
{expanded ? "Show fewer files" : `Show ${files.length - 3} more files`} <span aria-hidden="true">⌄</span>
</button>
) : null}
</article>
{selected !== null ? (
<div className="pit-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setSelectedPath(null); }}>
<div className="pit-dialog pit-workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="workspace-diff-title">
<header className="pit-tool-dialog-head">
<div>
<span className="pit-card-meta">{selected.operation.replace("_", " ")} · workspace diff</span>
<h2 id="workspace-diff-title">{selected.path}</h2>
</div>
{stats(selected)}
<button type="button" className="pit-icon-button" aria-label="Close diff" onClick={() => setSelectedPath(null)}>×</button>
</header>
{files.length > 1 ? (
<nav className="pit-workspace-file-nav" aria-label="Changed files">
{files.map((file) => <button type="button" key={file.path} data-selected={file.path === selected.path} onClick={() => setSelectedPath(file.path)}>{file.path}</button>)}
</nav>
) : null}
<FileDiff diff={selected.diff} />
</div>
</div>
) : null}
</>
);
}
function WorkspaceFileReference({
item,
}: {
item: Extract<CapabilityThreadItem, { kind: "workspace_file_reference" }>;
}) {
const [open, setOpen] = useState(false);
const extension = item.reference.path.includes(".")
? item.reference.path.split(".").at(-1)?.toUpperCase()
: "FILE";
const presentation = item.reference.presentation === "document"
? "Document"
: item.reference.presentation === "code"
? "Code"
: item.reference.presentation === "image"
? "Image"
: "File";
return (
<>
<article id={item.id} className="pit-file-reference-card" data-thread-item="workspace_file_reference">
<span className="pit-file-reference-icon" aria-hidden="true">▤</span>
<span className="pit-file-reference-copy">
<strong>{item.reference.displayName}</strong>
<small>{presentation} · {extension}{item.reference.line === null ? "" : ` · line ${item.reference.line}`}</small>
</span>
<button type="button" className="pit-workspace-review" onClick={() => setOpen(true)}>Open</button>
</article>
{open ? (
<div className="pit-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setOpen(false); }}>
<div className="pit-dialog pit-file-reference-dialog" role="dialog" aria-modal="true" aria-labelledby="workspace-file-title">
<header className="pit-tool-dialog-head">
<div><span className="pit-card-meta">{item.reference.path}</span><h2 id="workspace-file-title">{item.reference.displayName}</h2></div>
<button type="button" className="pit-icon-button" aria-label="Close file" onClick={() => setOpen(false)}>×</button>
</header>
<div className="pit-file-reference-preview">
{item.reference.preview === null
? <p className="pit-workspace-binary">Preview unavailable. The reference remains recorded with its normalized workspace path.</p>
: item.reference.presentation === "document"
? <MarkdownBody>{item.reference.preview}</MarkdownBody>
: <pre className="pit-code">{item.reference.preview}</pre>}
{item.reference.previewTruncated ? <p className="pit-withheld-note">Preview truncated by the runner.</p> : null}
</div>
</div>
</div>
) : null}
</>
);
}
function ThreadItemView({
item,
callbacks,
}: {
item: CapabilityThreadItem;
callbacks: ThreadCallbacks;
}) {
switch (item.kind) {
case "user_message":
return (
<article
id={item.id}
className="pit-message"
data-role="user"
data-thread-item="user_message"
>
<div className="pit-card-head">
<span className="pit-card-author">{item.author}</span>
<Timestamp value={item.at} />
</div>
<MarkdownBody>{item.body}</MarkdownBody>
</article>
);
case "agent_message":
return (
<article
id={item.id}
className="pit-message"
data-role="assistant"
data-thread-item="agent_message"
data-streaming={item.streaming}
>
<div className="pit-card-head">
<span className="pit-card-author">{item.author}</span>
<Timestamp value={item.at} />
{item.streaming ? (
<Chip tone="accent" testId="streaming-indicator">
<span aria-hidden="true">⏳</span>
Streaming
</Chip>
) : null}
</div>
<MarkdownBody>{item.body}</MarkdownBody>
</article>
);
case "durable_comment":
return (
<article id={item.id} className="pit-card" data-thread-item="durable_comment">
<div className="pit-card-head">
<span className="pit-card-author">{item.author}</span>
<Timestamp value={item.at} />
<span
className="pit-durable-tag"
title={`Recorded by the ${item.operationId} semantic operation.`}
>
<span aria-hidden="true">◆</span>
Recorded to mock thread
</span>
</div>
<div className="pit-card-body">{item.body}</div>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View in Evidence
</button>
</article>
);
case "tool_activity":
return <ToolStrip item={item} callbacks={callbacks} />;
case "progress_activity":
return <ProgressItem item={item} />;
case "workspace_changes":
return <WorkspaceChanges item={item} />;
case "workspace_file_reference":
return <WorkspaceFileReference item={item} />;
case "provider_activity":
return <ProviderActivity item={item} callbacks={callbacks} />;
case "denial":
return (
<div id={item.id} className="pit-strip pit-denial" data-status="denied" data-thread-item="denial">
<div className="pit-strip-button">
<span className="pit-strip-glyph" aria-hidden="true">
✕
</span>
<span className="pit-strip-operation">{item.operationId}</span>
<span className="pit-strip-summary" data-testid="denial-reason">
{item.reason}
</span>
</div>
<div className="pit-strip-detail">
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View in Evidence
</button>
</div>
</div>
);
case "interaction":
return (
<InteractionCard
card={item}
autoFocus={callbacks.focusInteractionId === item.interactionId}
onRespond={callbacks.onRespond}
onOpenEvidence={(section, recordId) =>
callbacks.onOpenEvidence(section as CapabilityEvidenceSectionId, recordId)
}
/>
);
case "document":
return (
<article id={item.id} className="pit-card" data-thread-item="document">
<div className="pit-card-head">
<span className="pit-card-author">{item.title}</span>
<span className="pit-card-meta">{item.documentKey}</span>
<Timestamp value={item.at} />
</div>
<div className="pit-card-body">
<span className="pit-card-meta" data-testid="revision-chain">
{item.revisionFrom === null ? `r${item.revisionTo}` : `r${item.revisionFrom} → r${item.revisionTo}`}
</span>
{" · "}
{item.author}
{item.staleBehind !== null ? (
<>
{" "}
<Chip>
<span aria-hidden="true">⌛</span>
Stale — {item.staleBehind} newer revision(s)
</Chip>
</>
) : null}
</div>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View diff
</button>
</article>
);
case "deliverable":
return (
<article id={item.id} className="pit-card" data-thread-item="deliverable">
<div className="pit-card-head">
<span className="pit-card-author">{item.filename}</span>
<Timestamp value={item.at} />
</div>
<div className="pit-card-body">
<span className="pit-card-meta">
{item.deliverableKind} · {formatBytes(item.byteSize)} · registered by {item.registeredBy}
</span>
</div>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
Download {item.filename}
</button>
</article>
);
case "dependency":
return (
<article id={item.id} className="pit-card" data-thread-item="dependency">
<div className="pit-card-head">
<span className="pit-card-author">Delegation</span>
<Timestamp value={item.at} />
</div>
<ul className="pit-summary-list">
{item.createdTasks.map((task) => (
<li key={task.identifier}>
<span className="pit-record-title">{task.identifier}</span> {task.title}
</li>
))}
{item.blockerEdges.map((edge) => (
<li key={edge}>{edge}</li>
))}
</ul>
</article>
);
case "disposition":
return (
<article id={item.id} className="pit-card pit-terminal-card" data-thread-item="disposition">
<div className="pit-card-head">
<StatusBadge status={item.status} />
<span className="pit-card-meta">{item.operationId}</span>
<Timestamp value={item.at} />
</div>
<div className="pit-card-body">{item.body}</div>
{item.blockerOwner !== null ? (
<p className="pit-card-meta">Blocker owner: {item.blockerOwner}</p>
) : null}
</article>
);
case "system_notice":
return (
<p id={item.id} className="pit-notice" data-thread-item="system_notice">
<span aria-hidden="true">{item.glyph}</span>
<span className="pit-notice-text">{item.text}</span>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
Details
</button>
</p>
);
}
}
export function TurnGroup({
turn,
callbacks,
assertions = {},
terminalAssertions = [],
}: {
turn: CapabilityThreadTurn;
callbacks: ThreadCallbacks;
assertions?: Record<string, EvalAssertion[]>;
terminalAssertions?: EvalAssertion[];
}) {
const [showAllStrips, setShowAllStrips] = useState(false);
const stripIndexes = turn.items
.map((item, index) => (item.kind === "tool_activity" ? index : -1))
.filter((index) => index >= 0);
const hiddenStripIndexes = new Set(
showAllStrips ? [] : stripIndexes.slice(VISIBLE_STRIPS),
);
return (
<section className="pit-turn" data-turn-id={turn.id} aria-label={`Turn ${turn.ordinal}`}>
<h2 className="pit-turn-header">
<span className="pit-turn-label">
Turn {turn.ordinal} · {turn.mode} · {turn.toolCallCount} tool call
{turn.toolCallCount === 1 ? "" : "s"} ·{" "}
{new Date(turn.at).toISOString().slice(11, 19)}
</span>
{turn.stoppedByUser ? (
<span className="pit-stopped-marker" data-testid="stopped-marker">
Stopped by user
</span>
) : null}
</h2>
{turn.items.map((item, index) => hiddenStripIndexes.has(index) ? null : (
<div className="pit-eval-item" key={item.id}>
<ThreadItemView item={item} callbacks={callbacks} />
<EvalAssertions assertions={assertions[item.id] ?? []} />
</div>
))}
{hiddenStripIndexes.size > 0 ? (
<button
type="button"
className="pit-more-button"
onClick={() => setShowAllStrips(true)}
>
{hiddenStripIndexes.size} more…
</button>
) : null}
<EvalAssertions assertions={terminalAssertions} />
</section>
);
}