diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs index 4093b02441a..329b205b6ec 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs @@ -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), diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 27f6e631200..e1a0b810f6c 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -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", diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index b58228a66b6..73d45962f82 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -123,8 +123,76 @@ export function semanticProviderPlanMarkdown(result: Record): s const artifact = record(value); if (artifact.kind !== "native_provider_plan" || typeof artifact.ref !== "string") continue; const match = artifact.ref.match(/\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 { diff --git a/server/src/services/native-runtime/paperclip-runner-real-server.integration.test.ts b/server/src/services/native-runtime/paperclip-runner-real-server.integration.test.ts index aca40ba9c9b..1c853aef923 100644 --- a/server/src/services/native-runtime/paperclip-runner-real-server.integration.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-real-server.integration.test.ts @@ -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; + 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(); } diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 5a3a2e136f1..a56d44bb7d7 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -874,6 +874,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { (item: TaskChatInteractionItem) => ( {label}; } +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 ( + +
+ + Plan + {revisionLabel ? {revisionLabel} : null} + +
+
+

{content.title}

+ {content.preview.length > 0 ? ( +
    + {content.preview.map((line, index) => ( +
  • + • + {line} +
  • + ))} +
+ ) : ( +

Open the synchronized plan to review it.

+ )} +
+
+
+ ); +} + 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 (
-
-

{interaction.payload.prompt}

- -
- {interaction.payload.detailsMarkdown ? ( + {isPlanConfirmation ? ( + + ) : ( +
+

{interaction.payload.prompt}

+ +
+ )} + {!isPlanConfirmation && interaction.payload.detailsMarkdown ? (
{interaction.payload.detailsMarkdown}
) : 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" ? ( ) : interaction.kind === "request_confirmation" ? ( - + ) : interaction.kind === "request_checkbox_confirmation" ? ( ) : interaction.kind === "suggest_tasks" ? ( diff --git a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx index 309220bac83..e65a8c00d4a 100644 --- a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx @@ -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( + + + + + , + ); + }); + + 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( diff --git a/ui/src/components/task-chat/TaskChatInteractionCard.tsx b/ui/src/components/task-chat/TaskChatInteractionCard.tsx index 180e1989701..dff17168c8b 100644 --- a/ui/src/components/task-chat/TaskChatInteractionCard.tsx +++ b/ui/src/components/task-chat/TaskChatInteractionCard.tsx @@ -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 ) : ( - + )}
); diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index e63429e2e76..c15b61b62d4 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -476,7 +476,20 @@ vi.mock("@/components/ui/dialog", () => ({ vi.mock("@/components/ui/sheet", () => ({ Sheet: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ?
{children}
: null), - SheetContent: ({ children }: { children?: ReactNode }) =>
{children}
, + SheetContent: ({ + children, + className, + "data-testid": testId, + }: { + children?: ReactNode; + className?: string; + "data-testid"?: string; + }) => ( +
+ {children} + +
+ ), SheetHeader: ({ children }: { children?: ReactNode }) =>
{children}
, SheetDescription: ({ children }: { children?: ReactNode }) =>

{children}

, SheetTitle: ({ children }: { children?: ReactNode }) =>

{children}

, @@ -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( + + + , + ); + }); + + 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([ diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index fbeb92657c5..7180bab5941 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -5605,9 +5605,19 @@ export function IssueDetail() { {/* Mobile properties drawer */} - + - Properties + + {documentDeepLink?.documentKey === "plan" ? "Plan" : "Properties"} +