mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Add shared rich ACP transport, durable questions and permissions, verified provider packaging, and bounded activity and plan presentation. Keep Cursor, Copilot, and Pi pending their separate provider qualification. Persist interaction settlement before publication, fence failed writes until fresh recovery, and preserve owned-process cleanup. Incorporate reviewed mainline integration with extended harness coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
619 lines
25 KiB
TypeScript
619 lines
25 KiB
TypeScript
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 === "provider_notice" && Array.isArray(payload.details) ? <dl className="pit-provider-fields">{payload.details.slice(0, 64).map((value, index) => { const detail = valueRecord(value); return <div key={index}><dt>{String(detail.name ?? "Detail")}</dt><dd>{String(detail.value ?? "")}</dd></div>; })}</dl> : 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.mode === "replay" && turn.toolCallCount === 0
|
||
? "no recorded tool calls"
|
||
: `${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>
|
||
);
|
||
}
|