feat(runner): stabilize native planning and review

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-22 10:57:02 -05:00
1 parent 7e9d93f1d2
commit edde864925
10 files changed
+460 -25

No files matched your search

@@ -253,7 +253,25 @@ pub fn run_durable_runner(
)?;
}
if provider.is_some() {
send_outbox(&mut client, &state, &mut sent_source_seq)?;
if let Err(error) = send_outbox(&mut client, &state, &mut sent_source_seq) {
// A run attachment deliberately rotates the immutable run binding and
// makes the control plane close the old authenticated connection. The
// command-result write can still succeed locally after the peer has
// closed, so the first observable failure may be this subsequent outbox
// flush. Treat every steady-state write failure like a receive-side
// disconnect: preserve the durable suffix and reconnect under the
// rotated lease identity instead of terminating runnerd.
state.record_diagnostic(error.to_string());
if state.lifecycle == "revoked" {
store.save(&state)?;
connection_lease_token.take();
bootstrap_ticket.take();
return Ok(());
}
state.reconnect_count += 1;
store.save(&state)?;
break true;
}
}
if (state.stop_after_flush || state.lifecycle == "revoked")
&& state.outbox.is_empty()
@@ -617,6 +635,7 @@ fn provider_descriptor(
Some(crate::codex_provider::ProviderRuntimeIdentity::LocalProcess { process_id, .. }) => Some(process_id),
_ => None,
},
"agentProcessId": runtime.and_then(|item| item.agent_process_id()),
}),
Some(crate::codex_provider::ProviderConfig::Local(value)) => json!({
"provider": provider_kind_name(value.kind),
@@ -95,6 +95,63 @@ describe("provider plan synchronization", () => {
})).toBe("# Health check\n\n1. Add endpoint\n2. Verify it");
});
it("decodes the native provider's compact plan reference into readable Markdown", () => {
expect(semanticProviderPlanMarkdown({
artifacts: [{
kind: "native_provider_plan",
ref: "native-provider-plan:health-check-endpoint-v1#1-register-GET-health-return-200-json-status-ok;2-add-API-tests",
}],
})).toBe([
"# Health check endpoint",
"",
"1. Register GET /health return 200 JSON status ok",
"2. Add API tests",
].join("\n"));
});
it("decodes a task-scoped native plan URI", () => {
expect(semanticProviderPlanMarkdown({
artifacts: [{
kind: "native_provider_plan",
ref: "native-plan://DOT-13/health-check#1-add-GET-health;2-add-tests",
}],
})).toBe("# Health check\n\n1. Add GET /health\n2. Add tests");
});
it("retains readable Markdown embedded after a native provider plan reference", () => {
expect(semanticProviderPlanMarkdown({
artifacts: [{
kind: "native_provider_plan",
ref: "native-provider-plan:DOT-14-health-check-v1\n1. Add `GET /health`.\n2. Add tests.",
}],
})).toBe("# Health check\n\n1. Add `GET /health`.\n2. Add tests.");
});
it("normalizes a plain numbered native provider plan", () => {
expect(semanticProviderPlanMarkdown({
artifacts: [{
kind: "native_provider_plan",
ref: "1. Add GET /health. | 2. Add tests. | 3. Document it.",
}],
})).toBe("# Plan\n\n1. Add GET /health.\n2. Add tests.\n3. Document it.");
});
it("normalizes a task-labelled inline numbered plan", () => {
expect(semanticProviderPlanMarkdown({
artifacts: [{
kind: "native_provider_plan",
ref: "DOT-16 plan: (1) add GET /health; (2) add tests; (3) document it.",
}],
})).toBe("# Plan\n\n1. add GET /health\n2. add tests\n3. document it.");
});
it("uses an explicitly numbered semantic summary when the artifact is opaque", () => {
expect(semanticProviderPlanMarkdown({
summary: "Native provider plan completed: 1) add GET /health; 2) add tests; 3) document it.",
artifacts: [{ kind: "native_provider_plan", ref: "native-provider-plan:DOT-18:health-check" }],
})).toBe("# Plan\n\n1. add GET /health\n2. add tests\n3. document it.");
});
it("renders a bounded Markdown checklist without embedding provenance", () => {
const markdown = providerPlanMarkdown({
explanation: "Release safely",
@@ -123,8 +123,76 @@ export function semanticProviderPlanMarkdown(result: Record<string, unknown>): s
const artifact = record(value);
if (artifact.kind !== "native_provider_plan" || typeof artifact.ref !== "string") continue;
const match = artifact.ref.match(/<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/i);
const markdown = (match?.[1] ?? artifact.ref).trim();
if (markdown) return markdown.slice(0, 256_000);
const completedMarkdown = match?.[1]?.trim();
if (completedMarkdown) return completedMarkdown.slice(0, 256_000);
const embedded = artifact.ref.match(/^native-provider-plan:([^\n]+)\n([\s\S]+)$/i);
if (embedded) {
const title = embedded[1]!
.replace(/^(?:DOT-\d+-)?/i, "")
.replace(/-v\d+$/i, "")
.replace(/-/g, " ")
.trim();
const body = embedded[2]!.trim();
if (title && body) {
return [`# ${title.charAt(0).toUpperCase()}${title.slice(1)}`, "", body]
.join("\n")
.slice(0, 256_000);
}
}
if (/^\s*1\.\s+/.test(artifact.ref)) {
const numberedPlan = artifact.ref
.split(/\s+\|\s+(?=\d+\.\s+)/)
.join("\n")
.trim();
if (numberedPlan) return `# Plan\n\n${numberedPlan}`.slice(0, 256_000);
}
// Some qualified Codex builds use the artifact reference itself as a
// compact, human-readable plan. Accept only an explicitly numbered form;
// arbitrary opaque artifact references must never become plan documents.
const inlineNumbered = artifact.ref.trim();
if (/\(1\)\s+.+\(2\)\s+/s.test(inlineNumbered)) {
const body = inlineNumbered
.replace(/^DOT-\d+\s+plan:\s*/i, "")
.replace(/^\(1\)\s*/, "1. ")
.replace(/;\s*\((\d+)\)\s*/g, "\n$1. ")
.trim();
if (body) return `# Plan\n\n${body}`.slice(0, 256_000);
}
const compact = artifact.ref.match(/^native-provider-plan:([^#]+)#(.+)$/i)
?? artifact.ref.match(/^native-plan:\/\/[^/]+\/([^#]+)#(.+)$/i);
if (!compact) continue;
const humanize = (slug: string) => slug
.replace(/\b(GET|POST|PUT|PATCH|DELETE)-([a-z0-9][a-z0-9-]*)/gi, (_whole, method: string, path: string) => `${method.toUpperCase()} /${path}`)
.replace(/-/g, " ")
.replace(/\bjson\b/gi, "JSON")
.replace(/\bapi\b/gi, "API")
.replace(/\s+/g, " ")
.trim();
const title = humanize(compact[1]!.replace(/-v\d+$/i, ""));
const steps = compact[2]!.split(";").flatMap((encoded) => {
const parsed = encoded.match(/^\d+-(.+)$/);
const sentence = humanize(parsed?.[1] ?? encoded);
return sentence ? [sentence.charAt(0).toUpperCase() + sentence.slice(1)] : [];
});
if (!title || steps.length === 0) continue;
return [`# ${title.charAt(0).toUpperCase()}${title.slice(1)}`, "", ...steps.map((step, index) => `${index + 1}. ${step}`)]
.join("\n")
.slice(0, 256_000);
}
const hasNativePlanArtifact = artifacts.some((value) => record(value).kind === "native_provider_plan");
const summary = typeof result.summary === "string" ? result.summary.trim() : "";
const summaryPlan = hasNativePlanArtifact ? summary.match(/(?:^|:\s*)(1\)\s+[\s\S]+;\s*2\)\s+[\s\S]+;\s*3\)\s+[\s\S]+)$/) : null;
if (summaryPlan) {
const body = summaryPlan[1]!
.replace(/^1\)\s*/, "1. ")
.replace(/;\s*(\d+)\)\s*/g, "\n$1. ")
.trim();
if (body) return `# Plan\n\n${body}`.slice(0, 256_000);
}
return null;
}
@@ -194,6 +262,7 @@ export async function synchronizeCompletedProviderPlan(input: {
if (!current || !revision?.id || current.latestRevisionId !== revision.id) {
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "conflict", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: current?.latestRevisionNumber ?? null, currentRevisionId: current?.latestRevisionId ?? null, confirmationId: null };
}
let confirmationId: string;
try {
const confirmation = await issueThreadInteractionService(input.db).create(
{ id: input.execution.binding.issueId, companyId: input.execution.binding.companyId },
@@ -203,7 +272,7 @@ export async function synchronizeCompletedProviderPlan(input: {
sourceRunId: input.execution.binding.runId,
title: `Review plan revision ${revision.revisionNumber}`,
summary: "Review the synchronized Paperclip plan.",
continuationPolicy: "wake_assignee_on_accept",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: `Approve plan revision ${revision.revisionNumber}?`,
@@ -225,19 +294,29 @@ export async function synchronizeCompletedProviderPlan(input: {
} as never,
{ agentId: input.execution.binding.agentId, runId: input.execution.binding.runId },
);
const currentIssue = await input.db.select({ status: issues.status }).from(issues)
.where(eq(issues.id, input.execution.binding.issueId))
.limit(1).then((rows) => rows[0] ?? null);
if (currentIssue && currentIssue.status !== "review") {
await issueService(input.db).update(input.execution.binding.issueId, {
status: "review",
actorAgentId: input.execution.binding.agentId,
});
}
return { eventId: input.event.sourceEventId, planId, providerRevision, status, baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: revision.revisionNumber, currentRevisionId: revision.id, confirmationId: confirmation.id };
confirmationId = confirmation.id;
} catch {
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "approval_failed", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: revision.revisionNumber, currentRevisionId: revision.id, confirmationId: null };
}
try {
const currentIssue = await input.db.select({ status: issues.status }).from(issues)
.where(eq(issues.id, input.execution.binding.issueId))
.limit(1).then((rows) => rows[0] ?? null);
if (currentIssue && currentIssue.status !== "in_review") {
await issueService(input.db).update(input.execution.binding.issueId, {
status: "in_review",
actorAgentId: input.execution.binding.agentId,
});
}
} catch {
const settledIssue = await input.db.select({ status: issues.status }).from(issues)
.where(eq(issues.id, input.execution.binding.issueId))
.limit(1).then((rows) => rows[0] ?? null);
if (settledIssue?.status !== "in_review") {
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "approval_failed", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: revision.revisionNumber, currentRevisionId: revision.id, confirmationId };
}
}
return { eventId: input.event.sourceEventId, planId, providerRevision, status, baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: revision.revisionNumber, currentRevisionId: revision.id, confirmationId };
}
class SessionToolAuthorityRouter {
@@ -1,4 +1,5 @@
import { createServer } from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
@@ -24,6 +25,7 @@ describe("paperclip-runner real server vertical slice", () => {
const agentId = "00000000-0000-4000-8000-000000000702";
const issueId = "00000000-0000-4000-8000-000000000703";
const runId = "00000000-0000-4000-8000-000000000704";
const resumedRunId = "00000000-0000-4000-8000-000000000705";
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("paperclip-runner-real-server-");
@@ -73,10 +75,13 @@ describe("paperclip-runner real server vertical slice", () => {
const address = server.address();
if (address === null || typeof address === "string") throw new Error("Expected a TCP listener.");
setupRunnerPrpWebSocketServer(server, { port: address.port });
const stateDirectory = await mkdtemp(resolve(tmpdir(), "paperclip-runner-real-resume-"));
const bundle = createRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: resolve(import.meta.dirname, "../../../../packages/paperclip-runner/runner/target/debug/fake-codex-app-server"),
codexArgs: [],
stateDirectory,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
prpIdentity: {
runnerInstanceId: "runner-real-server",
environmentLeaseId: "lease-real-server",
@@ -121,8 +126,77 @@ describe("paperclip-runner real server vertical slice", () => {
run: { id: runId },
});
expect(bundle.evidence().diagnostics).toContain("runnerd authenticated to the durable PRP control plane");
await bundle.transport.close();
await db.insert(heartbeatRuns).values({
id: resumedRunId,
companyId,
agentId,
status: "running",
runtimeMode: "native",
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: { issueId },
});
await db.update(issues).set({ executionRunId: resumedRunId }).where(eq(issues.id, issueId));
const resumedAuthority = new PaperclipRunnerToolAuthority(db, {
companyId,
agentId,
issueId,
runId: resumedRunId,
});
const restored = createRunnerdCodexTransport({
runnerBinary: defaultCapabilityRunnerdBinary(),
codexCommand: resolve(import.meta.dirname, "../../../../packages/paperclip-runner/runner/target/debug/fake-codex-app-server"),
codexArgs: [],
stateDirectory,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
resumeDynamicTools: resumedAuthority.definitions(),
prpIdentity: {
runnerInstanceId: "runner-real-server",
environmentLeaseId: "lease-real-server",
runId: resumedRunId,
normalizedSessionId: "session-real-server",
turnId: "turn-real-server-resumed",
itemId: "item-real-server-resumed",
},
controlPlaneRegistration: (prp) => registerRunnerPrpAuthority({ runId: resumedRunId, authority: prp }),
});
restored.transport.setServerRequestHandler(async (request) => {
const params = request.params as Record<string, unknown>;
const result = await resumedAuthority.execute({
tool: String(params.tool),
callId: String(params.callId),
arguments: params.arguments,
});
observedResults.push(result);
return {
success: true,
contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, result }) }],
};
});
try {
await restored.transport.request("thread/read", {});
await restored.transport.request("turn/start", {
input: [{ type: "text", text: "Read the same task in a resumed process." }],
});
for await (const notification of restored.transport.notifications()) {
if (notification.method === "turn/completed") break;
}
expect(observedResults).toHaveLength(2);
expect(observedResults[1]).toMatchObject({
activeTask: { id: issueId, identifier: "RRS-1" },
run: { id: resumedRunId },
});
expect(restored.evidence().diagnostics).toContain(
"runnerd restored its durable PRP session and provider thread",
);
} finally {
await restored.transport.close();
}
} finally {
await bundle.transport.close();
await rm(stateDirectory, { recursive: true, force: true });
server.closeAllConnections();
server.close();
}
+2
View File
@@ -874,6 +874,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
(item: TaskChatInteractionItem) => (
<TaskChatInteractionCard
item={item}
planDocument={planDocument}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
@@ -897,6 +898,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
onSubmitInteractionVerdicts,
imageUploadHandler,
externalReferences,
planDocument,
],
);
@@ -14,11 +14,14 @@ import {
Circle,
CircleHelp,
GitBranch,
Lightbulb,
ListChecks,
Loader2,
Maximize2,
MessageSquareQuote,
X,
} from "lucide-react";
import type { IssueDocument } from "@paperclipai/shared";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
import { MarkdownBody } from "@/components/MarkdownBody";
import { Button } from "@/components/ui/button";
@@ -53,6 +56,7 @@ type SharedInteractionProps = Omit<
export interface TaskChatCompactInteractionCardProps extends SharedInteractionProps {
interaction: IssueThreadInteraction;
planDocument?: IssueDocument | null;
}
const OTHER_ANSWER_ID = "__paperclip_other__";
@@ -182,6 +186,86 @@ function CompactTarget({ interaction }: { interaction: RequestConfirmationIntera
return <span className="inline-flex rounded-sm border border-border px-2 py-0.5 font-mono text-xs text-muted-foreground">{label}</span>;
}
function planPreviewContent(markdown: string) {
const lines = markdown
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const headingIndex = lines.findIndex((line) => /^#\s+/.test(line));
const title = headingIndex >= 0
? lines[headingIndex]!.replace(/^#\s+/, "").trim()
: "Plan";
const preview = lines
.filter((_, index) => index !== headingIndex)
.map((line) => line
.replace(/^#{1,6}\s+/, "")
.replace(/^[-*+]\s+/, "")
.replace(/^\d+[.)]\s+/, "")
.replace(/`([^`]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\[([^\]]+)]\([^)]*\)/g, "$1")
.trim())
.filter(Boolean)
.slice(0, 3);
return { title, preview };
}
function PlanReviewPreview({
interaction,
planDocument,
}: {
interaction: RequestConfirmationInteraction;
planDocument?: IssueDocument | null;
}) {
const target = interaction.payload.target;
if (target?.type !== "issue_document" || target.key !== "plan") return null;
const targetRevision = target.revisionNumber ?? null;
const documentMatchesTarget = Boolean(
planDocument
&& (targetRevision == null || planDocument.latestRevisionNumber === targetRevision),
);
const content = documentMatchesTarget && planDocument
? planPreviewContent(planDocument.body)
: { title: target.label ?? "Plan", preview: [] as string[] };
const revisionLabel = targetRevision == null ? null : `v${targetRevision}`;
return (
<a
href="#document-plan"
data-testid="plan-review-preview"
aria-label={`Open ${target.label ?? revisionLabel ?? "plan"}`}
className="group block overflow-hidden rounded-md border border-border bg-muted/25 text-left transition-colors hover:border-foreground/25 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex items-center gap-2 border-b border-border/70 px-3 py-2.5 text-sm text-muted-foreground">
<Lightbulb aria-hidden className="h-4 w-4 shrink-0" />
<span className="font-medium">Plan</span>
{revisionLabel ? <span className="text-xs">{revisionLabel}</span> : null}
<Maximize2
aria-hidden
className="ml-auto h-4 w-4 transition-transform group-hover:scale-105 group-hover:text-foreground"
/>
</div>
<div className="relative max-h-36 overflow-hidden px-4 py-3">
<h3 className="text-base font-semibold leading-6 text-foreground">{content.title}</h3>
{content.preview.length > 0 ? (
<ul className="mt-2 space-y-1.5 text-sm leading-5 text-muted-foreground">
{content.preview.map((line, index) => (
<li key={`${index}-${line}`} className="flex gap-2">
<span aria-hidden className="shrink-0 text-muted-foreground/60">•</span>
<span className="line-clamp-1">{line}</span>
</li>
))}
</ul>
) : (
<p className="mt-1 text-sm text-muted-foreground">Open the synchronized plan to review it.</p>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-linear-to-b from-transparent to-card/95" />
</div>
</a>
);
}
function ResolvedInteraction({ interaction }: { interaction: IssueThreadInteraction }) {
const summary = buildIssueThreadInteractionSummary(interaction);
let detail: ReactNode = null;
@@ -453,12 +537,14 @@ function AskUserQuestionsCard({
function ConfirmationCard({
interaction,
planDocument,
onAcceptInteraction,
onRejectInteraction,
externalReferences,
errorMessage,
}: {
interaction: RequestConfirmationInteraction;
planDocument?: IssueDocument | null;
onAcceptInteraction?: SharedInteractionProps["onAcceptInteraction"];
onRejectInteraction?: SharedInteractionProps["onRejectInteraction"];
externalReferences?: SharedInteractionProps["externalReferences"];
@@ -473,6 +559,8 @@ function ConfirmationCard({
|| interaction.payload.allowDeclineReason
|| interaction.payload.declineReasonPlaceholder,
);
const isPlanConfirmation = interaction.payload.target?.type === "issue_document"
&& interaction.payload.target.key === "plan";
async function resolve(action: "accept" | "reject") {
if (action === "reject" && interaction.payload.rejectRequiresReason && !reason.trim()) return;
@@ -490,11 +578,15 @@ function ConfirmationCard({
return (
<div>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm leading-5 text-foreground">{interaction.payload.prompt}</p>
<CompactTarget interaction={interaction} />
</div>
{interaction.payload.detailsMarkdown ? (
{isPlanConfirmation ? (
<PlanReviewPreview interaction={interaction} planDocument={planDocument} />
) : (
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm leading-5 text-foreground">{interaction.payload.prompt}</p>
<CompactTarget interaction={interaction} />
</div>
)}
{!isPlanConfirmation && interaction.payload.detailsMarkdown ? (
<Details><MarkdownBody externalReferences={externalReferences}>{interaction.payload.detailsMarkdown}</MarkdownBody></Details>
) : null}
{rejecting ? (
@@ -887,6 +979,7 @@ function ItemVerdictsCard({
export function TaskChatCompactInteractionCard({
interaction,
planDocument,
agentMap,
currentUserId,
userLabelMap,
@@ -914,7 +1007,7 @@ export function TaskChatCompactInteractionCard({
) : interaction.kind === "ask_user_questions" ? (
<AskUserQuestionsCard interaction={interaction} onSubmitInteractionAnswers={onSubmitInteractionAnswers} onCancelInteraction={onCancelInteraction} errorMessage={errorMessage} />
) : interaction.kind === "request_confirmation" ? (
<ConfirmationCard interaction={interaction} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} externalReferences={externalReferences} errorMessage={errorMessage} />
<ConfirmationCard interaction={interaction} planDocument={planDocument} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} externalReferences={externalReferences} errorMessage={errorMessage} />
) : interaction.kind === "request_checkbox_confirmation" ? (
<CheckboxConfirmationCard interaction={interaction} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} externalReferences={externalReferences} errorMessage={errorMessage} />
) : interaction.kind === "suggest_tasks" ? (
@@ -4,6 +4,7 @@ import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IssueDocument } from "@paperclipai/shared";
import type { IssueThreadInteraction, RequestConfirmationInteraction } from "@/lib/issue-thread-interactions";
import { ThemeProvider } from "@/context/ThemeContext";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -87,6 +88,56 @@ describe("TaskChatInteractionCard", () => {
expect(container.textContent).toContain("Approve the plan");
});
it("renders a plan confirmation as a linked document preview", () => {
const interaction = createRequestConfirmation({
title: "Review plan revision 3",
payload: {
version: 1,
prompt: "Approve plan revision 3?",
acceptLabel: "Approve plan",
target: {
type: "issue_document",
issueId: "issue-1",
documentId: "document-plan",
key: "plan",
revisionId: "revision-3",
revisionNumber: 3,
label: "Plan v3",
},
},
});
const planDocument = {
id: "document-plan",
issueId: "issue-1",
key: "plan",
title: "Plan",
body: "# Health-check endpoint\n1. Define the health response contract.\n2. Test readiness before and after startup.\n3. Document the verification request.",
latestRevisionId: "revision-3",
latestRevisionNumber: 3,
} as IssueDocument;
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(interaction)}
planDocument={planDocument}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
const preview = container.querySelector('[data-testid="plan-review-preview"]');
expect(preview?.getAttribute("href")).toBe("#document-plan");
expect(preview?.getAttribute("aria-label")).toBe("Open Plan v3");
expect(container.textContent).toContain("Health-check endpoint");
expect(container.textContent).toContain("Test readiness before and after startup.");
expect(container.textContent).not.toContain("Approve plan revision 3?");
expect(container.textContent).toContain("Approve plan");
});
it("puts the primary CTA at the right edge of the compact action row", () => {
flushSync(() => {
root.render(
@@ -1,4 +1,5 @@
import type { ComponentProps } from "react";
import type { IssueDocument } from "@paperclipai/shared";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
import { shouldHideInteractionCard } from "@/lib/issue-thread-interactions";
import { TaskChatCompactInteractionCard } from "./TaskChatCompactInteractionCard";
@@ -9,6 +10,7 @@ type InteractionCardProps = Omit<ComponentProps<typeof IssueThreadInteractionCar
export interface TaskChatInteractionCardProps extends InteractionCardProps {
item: TaskChatInteractionItem;
planDocument?: IssueDocument | null;
}
/**
@@ -18,7 +20,7 @@ export interface TaskChatInteractionCardProps extends InteractionCardProps {
* secret proposals deliberately retain the specialized shared renderer because
* its risk, expiry, and safe-binding receipts are security-relevant.
*/
export function TaskChatInteractionCard({ item, ...cardProps }: TaskChatInteractionCardProps) {
export function TaskChatInteractionCard({ item, planDocument, ...cardProps }: TaskChatInteractionCardProps) {
const interaction = item.interaction;
if (shouldHideInteractionCard(interaction)) return null;
if (
@@ -49,7 +51,11 @@ export function TaskChatInteractionCard({ item, ...cardProps }: TaskChatInteract
{useSpecializedRenderer ? (
<IssueThreadInteractionCard interaction={interaction} primaryActionOnRight {...cardProps} />
) : (
<TaskChatCompactInteractionCard interaction={interaction} {...cardProps} />
<TaskChatCompactInteractionCard
interaction={interaction}
planDocument={planDocument}
{...cardProps}
/>
)}
</div>
);
+45 -1
View File
@@ -476,7 +476,20 @@ vi.mock("@/components/ui/dialog", () => ({
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null),
SheetContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
SheetContent: ({
children,
className,
"data-testid": testId,
}: {
children?: ReactNode;
className?: string;
"data-testid"?: string;
}) => (
<div className={className} data-testid={testId}>
{children}
<button type="button" data-slot="sheet-close">Close</button>
</div>
),
SheetHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
SheetDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>,
SheetTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
@@ -1302,6 +1315,37 @@ describe("IssueDetail", () => {
expect(mockSetPanelVisible).not.toHaveBeenCalled();
});
it("opens a plan deep link in a dismissible full-screen mobile panel", async () => {
mockSidebarState.isMobile = true;
mockLocation.hash = "#document-plan";
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockIssuePropertiesRender).toHaveBeenCalledWith(expect.objectContaining({
inline: true,
documentDeepLink: expect.objectContaining({
tab: "plans",
documentKey: "plan",
}),
}));
});
const panel = document.querySelector('[data-testid="mobile-plan-panel"]');
expect(panel).not.toBeNull();
expect(panel?.className).toContain("h-dvh");
expect(panel?.className).toContain("w-screen");
expect(panel?.textContent).toContain("Plan");
expect(panel?.querySelector('[data-slot="sheet-close"]')).not.toBeNull();
});
it("renders the full sub-task tree below the title in the chat center pane", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockResolvedValue([
+12 -2
View File
@@ -5605,9 +5605,19 @@ export function IssueDetail() {
{/* Mobile properties drawer */}
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
<SheetContent side="bottom" className="max-h-(--sz-85dvh) pb-(--sz-safe-bottom)">
<SheetContent
side={documentDeepLink?.documentKey === "plan" ? "right" : "bottom"}
className={cn(
documentDeepLink?.documentKey === "plan"
? "inset-0 h-dvh w-screen max-w-none gap-0 border-0 p-0 sm:max-w-none"
: "max-h-(--sz-85dvh) pb-(--sz-safe-bottom)",
)}
data-testid={documentDeepLink?.documentKey === "plan" ? "mobile-plan-panel" : undefined}
>
<SheetHeader>
<SheetTitle className="text-sm">Properties</SheetTitle>
<SheetTitle className="text-sm">
{documentDeepLink?.documentKey === "plan" ? "Plan" : "Properties"}
</SheetTitle>
</SheetHeader>
<ScrollArea className="flex-1 overflow-y-auto">
<div className="px-4 pb-4">