mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
feat(mcp) [split 6/8]: add Tools and Profiles UI foundation (#9561)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Governed MCP access spans contracts, runtime enforcement, adapters, UI surfaces, and operator verification > - The parity reference PR #9534 is too large for effective automated or human review > - The feature therefore needs a linear stack whose individual diffs stay below the 100-file review limit > - This pull request is split 6/8 and focuses on UI API, shared components, and Tools/Profile surfaces > - The benefit is a standalone, testable review boundary while preserving byte-for-byte parity at the top of the stack ## Linked Issues or Issue Description - Related parity reference: #9534 - Problem: Operators need typed clients and administration surfaces that compile independently before navigation exposes them. - Proposed solution: Adds UI APIs, hooks, libraries, shared components, Tools/Profiles pages, and the plugin settings consumer required by the new company-scoped API. - Alternatives considered: keeping #9534 as one 403-file review, or rewriting the feature to manufacture seams; both were rejected in favor of path extraction plus compile-driven boundary moves. - Roadmap alignment: this advances the existing governed MCP/tool-access work already represented by #9534; it does not introduce a separate roadmap initiative. - Stack position: base branch is `pap10341-split/05-runtime-integration`. - Merge policy: merge bottom-up, in order, only after the complete eight-PR stack has been reviewed and the top-of-stack parity gate remains empty. - Requested review: UXDesigner sanity pass on Tools/Profile surfaces; Greptile on every PR. ## What Changed - Adds UI APIs, hooks, libraries, shared components, Tools/Profiles pages, and the plugin settings consumer required by the new company-scoped API. - Keeps this PR below 100 changed files and independently typecheckable. - Preserves the final tree from #9534 when combined with the other seven stack levels. ## Verification - `pnpm typecheck` - `pnpm check:token-gates` — all gates clean - Focused UI Vitest run with `NODE_ENV=test` — 28 files, 183 tests passed ## Risks - Large dead-code UI additions can drift from activation routes; PR 7 supplies the registration layer and top-level parity catches omissions. - Stack risk: merging out of order can expose incomplete layers; mitigate by following the documented bottom-up merge policy. - Parity risk: later edits to an intermediate branch can drift from #9534; mitigate by re-running the empty top-of-stack diff before merge. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, exact model ID `gpt-5.4`; runtime-managed context window; medium reasoning with repository, shell, Git, GitHub CLI, and code-execution tools enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] Internal references are omitted except the execution-plan link explicitly required for this coordinated split stack - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge ## Stack Coordination - Internal execution plan: [PAP-13874](/PAP/issues/PAP-13874#document-plan) - Parity reference: #9534 - Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563 - Merge bottom-up only after full-stack review and an empty parity diff at #9563. ## UI Evidence QA captured these from the live Garden MCP split stack at 1440px and verified clean rendering:    --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9c8adee48b
commit
a116713b9a
86 files changed
+16969
-63
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TranscriptEntry } from "../../adapters";
|
||||
import type { ToolRunDecision } from "@paperclipai/shared";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "../MarkdownBody";
|
||||
import { cn, formatTokens } from "../../lib/utils";
|
||||
import { runningLabelText } from "../../lib/status-colors";
|
||||
@@ -24,6 +25,7 @@ const RAW_INITIAL_ROWS = 180;
|
||||
|
||||
interface RunTranscriptViewProps {
|
||||
entries: TranscriptEntry[];
|
||||
toolDecisions?: readonly ToolRunDecision[];
|
||||
mode?: TranscriptMode;
|
||||
density?: TranscriptDensity;
|
||||
limit?: number;
|
||||
@@ -55,6 +57,8 @@ type TranscriptBlock =
|
||||
endTs?: string;
|
||||
name: string;
|
||||
toolUseId?: string;
|
||||
invocationId?: string;
|
||||
actionRequestId?: string;
|
||||
input: unknown;
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
@@ -74,6 +78,9 @@ type TranscriptBlock =
|
||||
items: Array<{
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
toolUseId?: string;
|
||||
invocationId?: string;
|
||||
actionRequestId?: string;
|
||||
input: unknown;
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
@@ -88,6 +95,9 @@ type TranscriptBlock =
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
name: string;
|
||||
toolUseId?: string;
|
||||
invocationId?: string;
|
||||
actionRequestId?: string;
|
||||
input: unknown;
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
@@ -325,6 +335,89 @@ function summarizeToolResult(result: string | undefined, isError: boolean | unde
|
||||
return truncate(firstLine, density === "compact" ? 84 : 140);
|
||||
}
|
||||
|
||||
type ToolDecisionRefs = {
|
||||
toolUseId?: string;
|
||||
invocationId?: string;
|
||||
actionRequestId?: string;
|
||||
};
|
||||
|
||||
type ToolDecisionMaps = {
|
||||
byInvocationId: Map<string, ToolRunDecision>;
|
||||
byActionRequestId: Map<string, ToolRunDecision>;
|
||||
};
|
||||
|
||||
function buildToolDecisionMaps(decisions: readonly ToolRunDecision[] | undefined): ToolDecisionMaps {
|
||||
const byInvocationId = new Map<string, ToolRunDecision>();
|
||||
const byActionRequestId = new Map<string, ToolRunDecision>();
|
||||
for (const decision of decisions ?? []) {
|
||||
byInvocationId.set(decision.invocation.id, decision);
|
||||
if (decision.actionRequest?.id) {
|
||||
byActionRequestId.set(decision.actionRequest.id, decision);
|
||||
}
|
||||
if (decision.latestAuditEvent?.actionRequestId) {
|
||||
byActionRequestId.set(decision.latestAuditEvent.actionRequestId, decision);
|
||||
}
|
||||
}
|
||||
return { byInvocationId, byActionRequestId };
|
||||
}
|
||||
|
||||
function findToolDecision(maps: ToolDecisionMaps, refs: ToolDecisionRefs): ToolRunDecision | null {
|
||||
if (refs.invocationId) {
|
||||
const decision = maps.byInvocationId.get(refs.invocationId);
|
||||
if (decision) return decision;
|
||||
}
|
||||
if (refs.actionRequestId) {
|
||||
const decision = maps.byActionRequestId.get(refs.actionRequestId);
|
||||
if (decision) return decision;
|
||||
}
|
||||
if (refs.toolUseId) {
|
||||
return maps.byInvocationId.get(refs.toolUseId) ?? maps.byActionRequestId.get(refs.toolUseId) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function summarizeToolDecision(decision: ToolRunDecision | null): { label: string; className: string; detail?: string } | null {
|
||||
if (!decision) return null;
|
||||
if (decision.pendingAction) {
|
||||
return {
|
||||
label: "Needs approval",
|
||||
className: "text-amber-700 dark:text-amber-300",
|
||||
detail: `Action request ${decision.pendingAction.actionRequestId.slice(0, 8)}`,
|
||||
};
|
||||
}
|
||||
if (decision.denialReason || decision.invocation.status === "denied" || decision.outcome === "denied") {
|
||||
return {
|
||||
label: "Denied",
|
||||
className: "text-red-700 dark:text-red-300",
|
||||
detail: decision.denialReason ?? decision.reasonCode ?? undefined,
|
||||
};
|
||||
}
|
||||
if (decision.invocation.status === "failed" || decision.invocation.status === "timed_out" || decision.outcome === "failure" || decision.outcome === "timeout") {
|
||||
return {
|
||||
label: decision.invocation.status === "timed_out" || decision.outcome === "timeout" ? "Timed out" : "Failed",
|
||||
className: "text-red-700 dark:text-red-300",
|
||||
detail: decision.denialReason ?? decision.reasonCode ?? undefined,
|
||||
};
|
||||
}
|
||||
if (decision.actionRequest?.status === "approved") {
|
||||
return { label: "Approved", className: "text-emerald-700 dark:text-emerald-300" };
|
||||
}
|
||||
if (decision.actionRequest?.status === "executed") {
|
||||
return { label: "Executed", className: "text-emerald-700 dark:text-emerald-300" };
|
||||
}
|
||||
if (decision.decision === "allow" || decision.invocation.status === "authorized" || decision.invocation.status === "executing" || decision.invocation.status === "succeeded") {
|
||||
return { label: "Allowed", className: "text-emerald-700 dark:text-emerald-300" };
|
||||
}
|
||||
if (decision.decision === "require_approval" || decision.invocation.approvalState === "pending") {
|
||||
return { label: "Needs approval", className: "text-amber-700 dark:text-amber-300" };
|
||||
}
|
||||
return {
|
||||
label: humanizeLabel(decision.invocation.status),
|
||||
className: "text-foreground/70",
|
||||
detail: decision.reasonCode ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSystemActivity(text: string): { activityId?: string; name: string; status: "running" | "completed" } | null {
|
||||
const match = text.match(/^item (started|completed):\s*([a-z0-9_-]+)(?:\s+\(id=([^)]+)\))?$/i);
|
||||
if (!match) return null;
|
||||
@@ -368,6 +461,9 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
||||
pending.push({
|
||||
ts: block.ts,
|
||||
endTs: block.endTs,
|
||||
toolUseId: block.toolUseId,
|
||||
invocationId: block.invocationId,
|
||||
actionRequestId: block.actionRequestId,
|
||||
input: block.input,
|
||||
result: block.result,
|
||||
isError: block.isError,
|
||||
@@ -412,6 +508,9 @@ function groupToolBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
||||
ts: block.ts,
|
||||
endTs: block.endTs,
|
||||
name: block.name,
|
||||
toolUseId: block.toolUseId,
|
||||
invocationId: block.invocationId,
|
||||
actionRequestId: block.actionRequestId,
|
||||
input: block.input,
|
||||
result: block.result,
|
||||
isError: block.isError,
|
||||
@@ -484,6 +583,8 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
ts: entry.ts,
|
||||
name: displayToolName(entry.name, entry.input),
|
||||
toolUseId,
|
||||
invocationId: entry.invocationId,
|
||||
actionRequestId: entry.actionRequestId,
|
||||
input: entry.input,
|
||||
status: "running",
|
||||
};
|
||||
@@ -726,14 +827,69 @@ function TranscriptThinkingBlock({
|
||||
);
|
||||
}
|
||||
|
||||
function ToolDecisionBadge({ decision }: { decision: ToolRunDecision | null }) {
|
||||
const summary = summarizeToolDecision(decision);
|
||||
if (!summary) return null;
|
||||
return (
|
||||
<span className={cn("text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)", summary.className)}>
|
||||
{summary.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolDecisionInlineDetail({ decision }: { decision: ToolRunDecision | null }) {
|
||||
const summary = summarizeToolDecision(decision);
|
||||
if (!summary?.detail) return null;
|
||||
return (
|
||||
<div className="mt-1 break-words text-(length:--text-micro) text-muted-foreground">
|
||||
{summary.detail}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolDecisionDetails({ decision, compact }: { decision: ToolRunDecision | null; compact: boolean }) {
|
||||
if (!decision) return null;
|
||||
const actionRequest = decision.actionRequest;
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg border border-border/60 bg-background/60 p-2",
|
||||
compact ? "text-(length:--text-micro)" : "text-xs",
|
||||
)}>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground">Decision</span>
|
||||
<ToolDecisionBadge decision={decision} />
|
||||
{decision.reasonCode && <span className="font-mono text-muted-foreground">{decision.reasonCode}</span>}
|
||||
</div>
|
||||
{decision.denialReason && (
|
||||
<div className="mt-1 break-words text-red-700 dark:text-red-300">
|
||||
{decision.denialReason}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 grid gap-1 font-mono text-muted-foreground sm:grid-cols-2">
|
||||
<span>invocation {decision.invocation.id.slice(0, 8)}</span>
|
||||
<span>audit {decision.auditEvents.length}</span>
|
||||
{actionRequest && <span>action {actionRequest.status} {actionRequest.id.slice(0, 8)}</span>}
|
||||
{actionRequest?.interactionId && <span>card {actionRequest.interactionId.slice(0, 8)}</span>}
|
||||
</div>
|
||||
{decision.pendingAction?.previewMarkdown && (
|
||||
<MarkdownBody className="mt-2 text-(length:--text-micro) leading-5 text-foreground/75 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
{decision.pendingAction.previewMarkdown}
|
||||
</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptToolCard({
|
||||
block,
|
||||
density,
|
||||
decision,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "tool" }>;
|
||||
density: TranscriptDensity;
|
||||
decision: ToolRunDecision | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(block.status === "error");
|
||||
const [open, setOpen] = useState(block.status === "error" || Boolean(decision?.pendingAction || decision?.denialReason));
|
||||
const compact = density === "compact";
|
||||
const parsedResult = parseStructuredToolResult(block.result);
|
||||
const statusLabel =
|
||||
@@ -784,10 +940,12 @@ function TranscriptToolCard({
|
||||
<span className={cn("text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)", statusTone)}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<ToolDecisionBadge decision={decision} />
|
||||
</div>
|
||||
<div className={cn("mt-1 break-words text-foreground/80", compact ? "text-xs" : "text-sm")}>
|
||||
{summary}
|
||||
</div>
|
||||
<ToolDecisionInlineDetail decision={decision} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -822,6 +980,7 @@ function TranscriptToolCard({
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<ToolDecisionDetails decision={decision} compact={compact} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -837,14 +996,22 @@ function hasSelectedText() {
|
||||
function TranscriptCommandGroup({
|
||||
block,
|
||||
density,
|
||||
toolDecisionMaps,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "command_group" }>;
|
||||
density: TranscriptDensity;
|
||||
toolDecisionMaps: ToolDecisionMaps;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const compact = density === "compact";
|
||||
const runningItem = [...block.items].reverse().find((item) => item.status === "running");
|
||||
const latestItem = block.items[block.items.length - 1] ?? null;
|
||||
const highlightedDecision =
|
||||
block.items
|
||||
.map((item) => findToolDecision(toolDecisionMaps, item))
|
||||
.find((decision) => decision?.pendingAction || decision?.denialReason)
|
||||
?? block.items.map((item) => findToolDecision(toolDecisionMaps, item)).find(Boolean)
|
||||
?? null;
|
||||
const hasError = block.items.some((item) => item.status === "error");
|
||||
const isRunning = Boolean(runningItem);
|
||||
const showExpandedErrorState = open && hasError;
|
||||
@@ -898,6 +1065,12 @@ function TranscriptCommandGroup({
|
||||
<div className="text-(length:--text-micro) font-semibold uppercase leading-none tracking-(--tracking-label) text-muted-foreground/70">
|
||||
{title}
|
||||
</div>
|
||||
{highlightedDecision && (
|
||||
<div className="mt-1">
|
||||
<ToolDecisionBadge decision={highlightedDecision} />
|
||||
<ToolDecisionInlineDetail decision={highlightedDecision} />
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
|
||||
{subtitle}
|
||||
@@ -951,6 +1124,7 @@ function TranscriptCommandGroup({
|
||||
{formatToolPayload(item.result)}
|
||||
</pre>
|
||||
)}
|
||||
<ToolDecisionDetails decision={findToolDecision(toolDecisionMaps, item)} compact={compact} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -962,15 +1136,23 @@ function TranscriptCommandGroup({
|
||||
function TranscriptToolGroup({
|
||||
block,
|
||||
density,
|
||||
toolDecisionMaps,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "tool_group" }>;
|
||||
density: TranscriptDensity;
|
||||
toolDecisionMaps: ToolDecisionMaps;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const compact = density === "compact";
|
||||
const runningItem = [...block.items].reverse().find((item) => item.status === "running");
|
||||
const hasError = block.items.some((item) => item.status === "error");
|
||||
const isRunning = Boolean(runningItem);
|
||||
const highlightedDecision =
|
||||
block.items
|
||||
.map((item) => findToolDecision(toolDecisionMaps, item))
|
||||
.find((decision) => decision?.pendingAction || decision?.denialReason)
|
||||
?? block.items.map((item) => findToolDecision(toolDecisionMaps, item)).find(Boolean)
|
||||
?? null;
|
||||
const uniqueNames = [...new Set(block.items.map((item) => item.name))];
|
||||
const toolLabel =
|
||||
uniqueNames.length === 1
|
||||
@@ -1024,6 +1206,12 @@ function TranscriptToolGroup({
|
||||
<div className={cn("font-semibold uppercase leading-none tracking-(--tracking-label)", compact ? "text-(length:--text-nano)" : "text-(length:--text-micro)", "text-muted-foreground/70")}>
|
||||
{title}
|
||||
</div>
|
||||
{highlightedDecision && (
|
||||
<div className="mt-1">
|
||||
<ToolDecisionBadge decision={highlightedDecision} />
|
||||
<ToolDecisionInlineDetail decision={highlightedDecision} />
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
|
||||
{subtitle}
|
||||
@@ -1065,6 +1253,7 @@ function TranscriptToolGroup({
|
||||
)}>
|
||||
{item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"}
|
||||
</span>
|
||||
<ToolDecisionBadge decision={findToolDecision(toolDecisionMaps, item)} />
|
||||
</div>
|
||||
<div className={cn("grid gap-2 pl-7", compact ? "grid-cols-1" : "lg:grid-cols-2")}>
|
||||
<div>
|
||||
@@ -1085,6 +1274,9 @@ function TranscriptToolGroup({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pl-7">
|
||||
<ToolDecisionDetails decision={findToolDecision(toolDecisionMaps, item)} compact={compact} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1501,6 +1693,7 @@ function RawTranscriptView({
|
||||
|
||||
export function RunTranscriptView({
|
||||
entries,
|
||||
toolDecisions,
|
||||
mode = "nice",
|
||||
density = "comfortable",
|
||||
limit,
|
||||
@@ -1511,6 +1704,7 @@ export function RunTranscriptView({
|
||||
thinkingClassName,
|
||||
externalReferences,
|
||||
}: RunTranscriptViewProps) {
|
||||
const toolDecisionMaps = useMemo(() => buildToolDecisionMaps(toolDecisions), [toolDecisions]);
|
||||
const blocks = useMemo(
|
||||
() => (mode === "raw" ? [] : normalizeTranscript(entries, streaming)),
|
||||
[entries, mode, streaming],
|
||||
@@ -1556,9 +1750,19 @@ export function RunTranscriptView({
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
)}
|
||||
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
|
||||
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
|
||||
{block.type === "tool_group" && <TranscriptToolGroup block={block} density={density} />}
|
||||
{block.type === "tool" && (
|
||||
<TranscriptToolCard
|
||||
block={block}
|
||||
density={density}
|
||||
decision={findToolDecision(toolDecisionMaps, block)}
|
||||
/>
|
||||
)}
|
||||
{block.type === "command_group" && (
|
||||
<TranscriptCommandGroup block={block} density={density} toolDecisionMaps={toolDecisionMaps} />
|
||||
)}
|
||||
{block.type === "tool_group" && (
|
||||
<TranscriptToolGroup block={block} density={density} toolDecisionMaps={toolDecisionMaps} />
|
||||
)}
|
||||
{block.type === "diff_group" && <TranscriptDiffGroup block={block} density={density} />}
|
||||
{block.type === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
|
||||
{block.type === "system_group" && <TranscriptSystemGroup block={block} density={density} />}
|
||||
|
||||
Reference in new issue
Block a user